From 96aee52a164e62341d4d6c22a5eee249144865f9 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 29 Apr 2026 01:45:09 +0000 Subject: [PATCH 01/14] feat: add support for redelegating from permission contexts This implements support for creating redelegations from ERC-7715 permission contexts, improving the developer experience when working with delegation chains. Key changes: 1. **Enhanced createDelegation/createOpenDelegation**: - Added parameter to accept PermissionContext directly - Made optional when a parent delegation exists (scope is inherited) - Supports both Delegation[] and encoded Hex formats 2. **New redelegatePermissionContext action**: - High-level convenience function for the complete redelegation workflow - Automatically extracts leaf delegation from permission context - Signs the new delegation and returns updated permission context - Supports both specific delegate and open delegation (ANY_BENEFICIARY) - Can be used standalone or as a client extension via redelegatePermissionContextActions 3. **Improved type safety**: - Uses discriminated unions to ensure only one parent source is provided - Prevents conflicting parentDelegation and parentPermissionContext parameters 4. **Comprehensive test coverage**: - Tests for parentPermissionContext with Delegation arrays and encoded Hex - Tests for scope inheritance when no scope is provided - Tests for scope override with explicit scope parameter - Tests for both standalone and client extension usage patterns Breaking changes: None. All existing APIs remain unchanged and backward compatible. Addresses: https://github.com/MetaMask/smart-accounts-kit/issues/196 Co-authored-by: jeffsmale90 --- .../smart-accounts-kit/src/actions/index.ts | 8 + .../actions/redelegatePermissionContext.ts | 265 ++++++++++++ .../src/caveatBuilder/resolveCaveats.ts | 18 +- packages/smart-accounts-kit/src/delegation.ts | 104 ++++- .../redelegatePermissionContext.test.ts | 382 ++++++++++++++++++ .../test/delegation.test.ts | 165 ++++++++ 6 files changed, 926 insertions(+), 16 deletions(-) create mode 100644 packages/smart-accounts-kit/src/actions/redelegatePermissionContext.ts create mode 100644 packages/smart-accounts-kit/test/actions/redelegatePermissionContext.test.ts diff --git a/packages/smart-accounts-kit/src/actions/index.ts b/packages/smart-accounts-kit/src/actions/index.ts index 8fb39ebf..c9e315aa 100644 --- a/packages/smart-accounts-kit/src/actions/index.ts +++ b/packages/smart-accounts-kit/src/actions/index.ts @@ -50,6 +50,14 @@ export { type SignUserOperationReturnType, } from './signUserOperation'; +// Redelegation actions +export { + redelegatePermissionContext, + redelegatePermissionContextActions, + type RedelegatePermissionContextParameters, + type RedelegatePermissionContextReturnType, +} from './redelegatePermissionContext'; + export { erc7715RequestExecutionPermissionsAction as requestExecutionPermissions, type MetaMaskExtensionClient, diff --git a/packages/smart-accounts-kit/src/actions/redelegatePermissionContext.ts b/packages/smart-accounts-kit/src/actions/redelegatePermissionContext.ts new file mode 100644 index 00000000..74e6848c --- /dev/null +++ b/packages/smart-accounts-kit/src/actions/redelegatePermissionContext.ts @@ -0,0 +1,265 @@ +import type { + Account, + Address, + Chain, + Client, + Hex, + Transport, + WalletClient, +} from 'viem'; +import { BaseError } from 'viem'; +import { parseAccount } from 'viem/accounts'; + +import { trackSmartAccountsKitFunctionCall } from '../analytics'; +import { + createDelegation, + createOpenDelegation, + decodeDelegations, + encodeDelegations, + type CreateDelegationOptions, + type CreateOpenDelegationOptions, +} from '../delegation'; +import type { + Delegation, + PermissionContext, + SmartAccountsEnvironment, +} from '../types'; +import type { Caveats } from '../caveatBuilder'; +import type { ScopeConfig } from '../caveatBuilder/scope'; +import { signDelegation } from './signDelegation'; + +export type RedelegatePermissionContextParameters = { + /** Account to sign the delegation with */ + account?: Account | Address; + /** Environment configuration */ + environment: SmartAccountsEnvironment; + /** The permission context to redelegate from (from ERC-7715 response) */ + permissionContext: PermissionContext; + /** The address of the delegation manager contract */ + delegationManager: Address; + /** The chain ID for the signature */ + chainId: number; + /** Optional scope - if not provided, inherits from parent */ + scope?: ScopeConfig; + /** Additional caveats to apply to the redelegation */ + caveats?: Caveats; + /** Optional salt for uniqueness */ + salt?: Hex; + /** Name of the DelegationManager contract */ + name?: string; + /** Version of the DelegationManager contract */ + version?: string; + /** Whether to allow insecure unrestricted delegation */ + allowInsecureUnrestrictedDelegation?: boolean; +} & ( + | { delegate: Address } // Specific delegate + | { delegate?: never } // Open delegation +); + +export type RedelegatePermissionContextReturnType = { + /** The signed delegation that was created */ + delegation: Delegation; + /** The new permission context with the redelegation prepended (encoded) */ + permissionContext: Hex; +}; + +/** + * Creates a redelegation from an existing permission context and returns + * both the signed delegation and the updated permission context. + * + * This action handles the complete redelegation workflow: + * 1. Extracts the leaf delegation from the permission context + * 2. Creates a new delegation inheriting from it + * 3. Signs the new delegation + * 4. Prepends it to the delegation chain + * 5. Returns the encoded permission context + * + * @param client - Wallet client with signing capability + * @param parameters - Redelegation parameters + * @returns Object containing the signed delegation and new permission context + * + * @example + * ```ts + * // Redelegate to a specific address + * const result = await redelegatePermissionContext(walletClient, { + * permissionContext: erc7715Response.context, + * delegationManager: environment.DelegationManager, + * chainId: 11155111, + * environment, + * delegate: charlie.address, + * caveats: [timestampCaveat], + * }); + * + * // Use the new permission context in a transaction + * await client.sendUserOperationWithDelegation({ + * calls: [{ + * to: contractAddress, + * data: callData, + * permissionContext: result.permissionContext, + * delegationManager: environment.DelegationManager, + * }], + * }); + * ``` + * + * @example + * ```ts + * // Create an open redelegation (anyone can use it) + * const result = await redelegatePermissionContext(walletClient, { + * permissionContext: erc7715Response.context, + * delegationManager: environment.DelegationManager, + * chainId: 11155111, + * environment, + * // No delegate = open delegation + * caveats: [limitedCallsCaveat], + * }); + * ``` + */ +export async function redelegatePermissionContext< + TChain extends Chain | undefined, + TAccount extends Account | undefined, +>( + client: Client & { + signTypedData: WalletClient['signTypedData']; + }, + parameters: RedelegatePermissionContextParameters, +): Promise { + const { + account: accountParam = client.account, + environment, + permissionContext, + delegationManager, + chainId, + scope, + caveats, + salt, + name = 'DelegationManager', + version = '1', + allowInsecureUnrestrictedDelegation = false, + } = parameters; + + if (!accountParam) { + throw new BaseError('Account not found. Please provide an account.'); + } + + const account = parseAccount(accountParam); + + trackSmartAccountsKitFunctionCall('redelegatePermissionContext', { + chainId, + hasDelegate: 'delegate' in parameters && parameters.delegate !== undefined, + hasScope: scope !== undefined, + hasCaveats: caveats !== undefined, + }); + + // Decode the permission context to get the delegation chain + const delegations = decodeDelegations(permissionContext); + + if (delegations.length === 0) { + throw new BaseError( + 'Permission context must contain at least one delegation', + ); + } + + // The leaf delegation is the first element (chain ordered leaf to root) + const leafDelegation = delegations[0]; + + // Create the unsigned delegation + // We always pass parentDelegation as the leaf delegation + // TypeScript struggles with the discriminated union, so we build the object explicitly + let unsignedDelegation: Omit; + + const commonOptions = { + environment, + from: account.address, + parentDelegation: leafDelegation as Delegation | Hex, + ...(scope !== undefined && { scope }), + ...(caveats !== undefined && { caveats }), + ...(salt !== undefined && { salt }), + }; + + if ('delegate' in parameters && parameters.delegate) { + unsignedDelegation = createDelegation({ + ...commonOptions, + to: parameters.delegate, + } as CreateDelegationOptions); + } else { + unsignedDelegation = createOpenDelegation( + commonOptions as CreateOpenDelegationOptions, + ); + } + + // Sign the delegation + const signature = await signDelegation(client, { + account: accountParam, + delegation: unsignedDelegation, + delegationManager, + chainId, + name, + version, + allowInsecureUnrestrictedDelegation, + }); + + // Create the signed delegation + const signedDelegation: Delegation = { + ...unsignedDelegation, + signature, + }; + + // Prepend the new delegation to create the new chain + const newDelegationChain = [signedDelegation, ...delegations]; + + // Return both the delegation and the encoded permission context + return { + delegation: signedDelegation, + permissionContext: encodeDelegations(newDelegationChain), + }; +} + +/** + * Creates redelegation actions that can be used to extend a wallet client. + * + * @returns A function that can be used with wallet client extend method. + * @example + * ```ts + * const walletClient = createWalletClient({ + * chain: sepolia, + * transport: http() + * }).extend(redelegatePermissionContextActions()); + * + * // Now you can call it directly on the client + * const result = await walletClient.redelegatePermissionContext({ + * permissionContext: erc7715Response.context, + * delegate: charlie.address, + * environment, + * delegationManager: environment.DelegationManager, + * }); + * ``` + */ +export function redelegatePermissionContextActions() { + return < + TChain extends Chain | undefined, + TAccount extends Account | undefined, + >( + client: Client & { + signTypedData: WalletClient['signTypedData']; + }, + ) => ({ + redelegatePermissionContext: async ( + parameters: Omit & { + chainId?: number; + }, + ) => + redelegatePermissionContext(client, { + chainId: + parameters.chainId ?? + (() => { + if (!client.chain?.id) { + throw new BaseError( + 'Chain ID is required. Either provide it in parameters or configure the client with a chain.', + ); + } + return client.chain.id; + })(), + ...parameters, + }), + }); +} diff --git a/packages/smart-accounts-kit/src/caveatBuilder/resolveCaveats.ts b/packages/smart-accounts-kit/src/caveatBuilder/resolveCaveats.ts index 0bbeadec..f6e98bb9 100644 --- a/packages/smart-accounts-kit/src/caveatBuilder/resolveCaveats.ts +++ b/packages/smart-accounts-kit/src/caveatBuilder/resolveCaveats.ts @@ -1,5 +1,8 @@ import type { CaveatBuilder } from './caveatBuilder'; -import type { CoreCaveatConfiguration } from './coreCaveatBuilder'; +import { + createCaveatBuilder, + type CoreCaveatConfiguration, +} from './coreCaveatBuilder'; import { createCaveatBuilderFromScope, type ScopeConfig } from './scope'; import type { Caveat, SmartAccountsEnvironment } from '../types'; @@ -10,7 +13,7 @@ export type Caveats = CaveatBuilder | (Caveat | CoreCaveatConfiguration)[]; * * @param config - The configuration for the caveat builder. * @param config.environment - The environment to be used for the caveat builder. - * @param config.scope - The scope to be used for the caveat builder. + * @param config.scope - The scope to be used for the caveat builder. Optional - when not provided and redelegating, scope is inherited from the parent delegation. * @param config.caveats - The caveats to be resolved, which can be either a CaveatBuilder or an array of Caveat or CaveatConfiguration. Optional - if not provided, only scope caveats will be used. * @returns The resolved array of caveats. */ @@ -20,10 +23,15 @@ export const resolveCaveats = ({ caveats, }: { environment: SmartAccountsEnvironment; - scope: ScopeConfig; + scope?: ScopeConfig; caveats?: Caveats; }) => { - const scopeCaveatBuilder = createCaveatBuilderFromScope(environment, scope); + // Create base caveat builder from scope if provided, otherwise use core caveat builder + const scopeCaveatBuilder = scope + ? createCaveatBuilderFromScope(environment, scope) + : createCaveatBuilder(environment, { + allowInsecureUnrestrictedDelegation: true, + }); if (caveats) { if ('build' in caveats && typeof caveats.build === 'function') { @@ -35,7 +43,7 @@ export const resolveCaveats = ({ try { if ('type' in caveat) { const { type, ...config } = caveat; - scopeCaveatBuilder.addCaveat(type, config); + (scopeCaveatBuilder as any).addCaveat(type, config); } else { scopeCaveatBuilder.addCaveat(caveat); } diff --git a/packages/smart-accounts-kit/src/delegation.ts b/packages/smart-accounts-kit/src/delegation.ts index e64b8963..01445963 100644 --- a/packages/smart-accounts-kit/src/delegation.ts +++ b/packages/smart-accounts-kit/src/delegation.ts @@ -211,12 +211,26 @@ export const hashDelegation = (input: Delegation): Hex => { type BaseCreateDelegationOptions = { environment: SmartAccountsEnvironment; - scope: ScopeConfig; from: Hex; caveats?: Caveats; - parentDelegation?: Delegation | Hex; salt?: Hex; -}; +} & ( + | { + scope: ScopeConfig; + parentDelegation?: never; + parentPermissionContext?: never; + } + | { + scope?: ScopeConfig; + parentDelegation: Delegation | Hex; + parentPermissionContext?: never; + } + | { + scope?: ScopeConfig; + parentDelegation?: never; + parentPermissionContext: PermissionContext; + } +); /** * Options for creating a specific delegation @@ -230,6 +244,46 @@ export type CreateDelegationOptions = BaseCreateDelegationOptions & { */ export type CreateOpenDelegationOptions = BaseCreateDelegationOptions; +/** + * Extracts the leaf delegation from a permission context. + * The leaf delegation is the first element in the array (chain is ordered leaf to root). + * + * @param permissionContext - The permission context containing the delegation chain. + * @returns The leaf delegation. + * @internal + */ +const extractLeafDelegation = ( + permissionContext: PermissionContext, +): Delegation => { + const delegations = decodeDelegations(permissionContext); + + if (delegations.length === 0) { + throw new Error('Permission context must contain at least one delegation'); + } + + // We've verified the array is not empty, so delegations[0] is guaranteed to exist + return delegations[0]!; +}; + +/** + * Resolves the parent delegation from either a direct delegation or permission context. + * + * @param parentDelegation - The parent delegation or its hash. + * @param parentPermissionContext - The permission context containing the parent delegation. + * @returns The resolved parent delegation, or undefined if neither is provided. + * @internal + */ +const resolveParentDelegation = ( + parentDelegation?: Delegation | Hex, + parentPermissionContext?: PermissionContext, +): Delegation | Hex | undefined => { + if (parentPermissionContext) { + return extractLeafDelegation(parentPermissionContext); + } + + return parentDelegation; +}; + /** * Resolves the authority for a delegation based on the parent delegation. * @@ -281,11 +335,25 @@ const getCaveatNames = ({ export const createDelegation = ( options: CreateDelegationOptions, ): Delegation => { - const caveats = resolveCaveats(options); + const parentDelegation = resolveParentDelegation( + 'parentDelegation' in options ? options.parentDelegation : undefined, + 'parentPermissionContext' in options + ? options.parentPermissionContext + : undefined, + ); + + const caveats = resolveCaveats({ + environment: options.environment, + scope: options.scope, + caveats: options.caveats, + }); trackSmartAccountsKitFunctionCall('createDelegation', { - hasParentDelegation: options.parentDelegation !== undefined, - scope: options.scope.type, + hasParentDelegation: + ('parentDelegation' in options && options.parentDelegation !== undefined) || + ('parentPermissionContext' in options && + options.parentPermissionContext !== undefined), + scope: options.scope?.type ?? null, caveatNames: getCaveatNames({ caveats, environment: options.environment, @@ -295,7 +363,7 @@ export const createDelegation = ( return { delegate: options.to, delegator: options.from, - authority: resolveAuthority(options.parentDelegation), + authority: resolveAuthority(parentDelegation), caveats, salt: options.salt ?? '0x00', signature: '0x', @@ -311,11 +379,25 @@ export const createDelegation = ( export const createOpenDelegation = ( options: CreateOpenDelegationOptions, ): Delegation => { - const caveats = resolveCaveats(options); + const parentDelegation = resolveParentDelegation( + 'parentDelegation' in options ? options.parentDelegation : undefined, + 'parentPermissionContext' in options + ? options.parentPermissionContext + : undefined, + ); + + const caveats = resolveCaveats({ + environment: options.environment, + scope: options.scope, + caveats: options.caveats, + }); trackSmartAccountsKitFunctionCall('createOpenDelegation', { - hasParentDelegation: options.parentDelegation !== undefined, - scope: options.scope.type, + hasParentDelegation: + ('parentDelegation' in options && options.parentDelegation !== undefined) || + ('parentPermissionContext' in options && + options.parentPermissionContext !== undefined), + scope: options.scope?.type ?? null, caveatNames: getCaveatNames({ caveats, environment: options.environment, @@ -325,7 +407,7 @@ export const createOpenDelegation = ( return { delegate: ANY_BENEFICIARY, delegator: options.from, - authority: resolveAuthority(options.parentDelegation), + authority: resolveAuthority(parentDelegation), caveats, salt: options.salt ?? '0x00', signature: '0x', diff --git a/packages/smart-accounts-kit/test/actions/redelegatePermissionContext.test.ts b/packages/smart-accounts-kit/test/actions/redelegatePermissionContext.test.ts new file mode 100644 index 00000000..13715a2e --- /dev/null +++ b/packages/smart-accounts-kit/test/actions/redelegatePermissionContext.test.ts @@ -0,0 +1,382 @@ +import { createWalletClient, http, type Address, type Hex } from 'viem'; +import { privateKeyToAccount } from 'viem/accounts'; +import { sepolia } from 'viem/chains'; +import { describe, it, expect, beforeEach } from 'vitest'; + +import { + redelegatePermissionContext, + redelegatePermissionContextActions, +} from '../../src/actions/redelegatePermissionContext'; +import { createDelegation, encodeDelegations } from '../../src/delegation'; +import { ScopeType } from '../../src/constants'; +import type { SmartAccountsEnvironment } from '../../src/types'; + +const mockPrivateKey = + '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80' as Hex; +const account = privateKeyToAccount(mockPrivateKey); + +const mockEnvironment: SmartAccountsEnvironment = { + DelegationManager: '0x1234567890123456789012345678901234567890', + EntryPoint: '0x0000000071727De22E5E9d8BAf0edAc6f37da032', + SimpleFactory: '0x9876543210987654321098765432109876543210', + implementations: {}, + caveatEnforcers: { + ValueLteEnforcer: '0x1111111111111111111111111111111111111111', + ERC20TransferAmountEnforcer: '0x2222222222222222222222222222222222222222', + AllowedTargets: '0x3333333333333333333333333333333333333333', + AllowedMethods: '0x4444444444444444444444444444444444444444', + TimestampEnforcer: '0x5555555555555555555555555555555555555555', + ExactCalldataEnforcer: '0x6666666666666666666666666666666666666666', + NativeTokenTransferAmountEnforcer: + '0x7777777777777777777777777777777777777777', + }, +}; + +const mockDelegationManager: Address = + '0x1234567890123456789012345678901234567890'; +const mockChainId = sepolia.id; + +describe('redelegatePermissionContext', () => { + let client: ReturnType; + + beforeEach(() => { + client = createWalletClient({ + account, + chain: sepolia, + transport: http('https://rpc.sepolia.org'), + }); + }); + + it('should create a redelegation with a specific delegate', async () => { + const rootDelegation = createDelegation({ + environment: mockEnvironment, + scope: { + type: ScopeType.Erc20TransferAmount, + tokenAddress: '0xabc0000000000000000000000000000000000000', + maxAmount: 1000n, + }, + to: account.address, + from: '0x1000000000000000000000000000000000000001', + }); + + const permissionContext = encodeDelegations([rootDelegation]); + const newDelegate: Address = '0x2000000000000000000000000000000000000002'; + + const timestampCaveat = { + enforcer: mockEnvironment.caveatEnforcers.TimestampEnforcer as Address, + terms: '0x0000000000000000000000000000000000000000000000000000000000000001' as Hex, + args: '0x00' as Hex, + }; + + const result = await redelegatePermissionContext(client, { + environment: mockEnvironment, + permissionContext, + delegationManager: mockDelegationManager, + chainId: mockChainId, + delegate: newDelegate, + caveats: [timestampCaveat], + }); + + expect(result.delegation.delegate).to.equal(newDelegate); + expect(result.delegation.delegator).to.equal(account.address); + expect(result.delegation.signature).to.match(/^0x[a-fA-F0-9]+$/u); + expect(result.permissionContext).to.match(/^0x[a-fA-F0-9]+$/u); + + // Verify the permission context is valid and longer than the original + expect(result.permissionContext.length).to.be.greaterThan( + permissionContext.length, + ); + }); + + it('should create an open redelegation when no delegate is specified', async () => { + const rootDelegation = createDelegation({ + environment: mockEnvironment, + scope: { + type: ScopeType.Erc20TransferAmount, + tokenAddress: '0xabc0000000000000000000000000000000000000', + maxAmount: 500n, + }, + to: account.address, + from: '0x1000000000000000000000000000000000000001', + }); + + const permissionContext = encodeDelegations([rootDelegation]); + + const timestampCaveat = { + enforcer: mockEnvironment.caveatEnforcers.TimestampEnforcer as Address, + terms: '0x0000000000000000000000000000000000000000000000000000000000000001' as Hex, + args: '0x00' as Hex, + }; + + const result = await redelegatePermissionContext(client, { + environment: mockEnvironment, + permissionContext, + delegationManager: mockDelegationManager, + chainId: mockChainId, + // No delegate specified + caveats: [timestampCaveat], + }); + + expect(result.delegation.delegate).to.equal( + '0x0000000000000000000000000000000000000a11', // ANY_BENEFICIARY + ); + expect(result.delegation.delegator).to.equal(account.address); + expect(result.delegation.signature).to.match(/^0x[a-fA-F0-9]+$/u); + }); + + it('should add additional caveats to the redelegation', async () => { + const rootDelegation = createDelegation({ + environment: mockEnvironment, + scope: { + type: ScopeType.Erc20TransferAmount, + tokenAddress: '0xabc0000000000000000000000000000000000000', + maxAmount: 1000n, + }, + to: account.address, + from: '0x1000000000000000000000000000000000000001', + }); + + const permissionContext = encodeDelegations([rootDelegation]); + const newDelegate: Address = '0x2000000000000000000000000000000000000002'; + + const timestampCaveat = { + enforcer: mockEnvironment.caveatEnforcers.TimestampEnforcer as Address, + terms: '0x0000000000000000000000000000000000000000000000000000000000000001' as Hex, + args: '0x00' as Hex, + }; + + const result = await redelegatePermissionContext(client, { + environment: mockEnvironment, + permissionContext, + delegationManager: mockDelegationManager, + chainId: mockChainId, + delegate: newDelegate, + caveats: [timestampCaveat], + }); + + expect(result.delegation.caveats).to.deep.include(timestampCaveat); + }); + + it('should inherit scope from parent when no scope is provided', async () => { + const rootDelegation = createDelegation({ + environment: mockEnvironment, + scope: { + type: ScopeType.Erc20TransferAmount, + tokenAddress: '0xabc0000000000000000000000000000000000000', + maxAmount: 1000n, + }, + to: account.address, + from: '0x1000000000000000000000000000000000000001', + }); + + const permissionContext = encodeDelegations([rootDelegation]); + const newDelegate: Address = '0x2000000000000000000000000000000000000002'; + + const timestampCaveat = { + enforcer: mockEnvironment.caveatEnforcers.TimestampEnforcer as Address, + terms: '0x0000000000000000000000000000000000000000000000000000000000000001' as Hex, + args: '0x00' as Hex, + }; + + const result = await redelegatePermissionContext(client, { + environment: mockEnvironment, + permissionContext, + delegationManager: mockDelegationManager, + chainId: mockChainId, + delegate: newDelegate, + // No scope provided - should inherit from parent + caveats: [timestampCaveat], // Add a caveat so signature doesn't fail + }); + + expect(result.delegation.delegate).to.equal(newDelegate); + // Should have the additional caveat we added + expect(result.delegation.caveats).to.deep.include(timestampCaveat); + }); + + it('should allow scope override even with parent', async () => { + const rootDelegation = createDelegation({ + environment: mockEnvironment, + scope: { + type: ScopeType.Erc20TransferAmount, + tokenAddress: '0xabc0000000000000000000000000000000000000', + maxAmount: 1000n, + }, + to: account.address, + from: '0x1000000000000000000000000000000000000001', + }); + + const permissionContext = encodeDelegations([rootDelegation]); + const newDelegate: Address = '0x2000000000000000000000000000000000000002'; + + const result = await redelegatePermissionContext(client, { + environment: mockEnvironment, + permissionContext, + delegationManager: mockDelegationManager, + chainId: mockChainId, + delegate: newDelegate, + scope: { + type: ScopeType.NativeTokenTransferAmount, + maxAmount: 500n, + }, + }); + + expect(result.delegation.delegate).to.equal(newDelegate); + // Should have caveats from the new scope + expect(result.delegation.caveats.length).to.be.greaterThan(0); + }); + + it('should throw error if permission context is empty', async () => { + const emptyContext = encodeDelegations([]); + + await expect( + redelegatePermissionContext(client, { + environment: mockEnvironment, + permissionContext: emptyContext, + delegationManager: mockDelegationManager, + chainId: mockChainId, + delegate: '0x2000000000000000000000000000000000000002', + }), + ).rejects.toThrow('Permission context must contain at least one delegation'); + }); + + it('should throw error if no account is provided', async () => { + const clientWithoutAccount = createWalletClient({ + chain: sepolia, + transport: http(), + }); + + const rootDelegation = createDelegation({ + environment: mockEnvironment, + scope: { + type: ScopeType.Erc20TransferAmount, + tokenAddress: '0xabc0000000000000000000000000000000000000', + maxAmount: 1000n, + }, + to: account.address, + from: '0x1000000000000000000000000000000000000001', + }); + + const permissionContext = encodeDelegations([rootDelegation]); + + await expect( + redelegatePermissionContext(clientWithoutAccount, { + environment: mockEnvironment, + permissionContext, + delegationManager: mockDelegationManager, + chainId: mockChainId, + delegate: '0x2000000000000000000000000000000000000002', + }), + ).rejects.toThrow('Account not found'); + }); + + it('should work with explicit account parameter', async () => { + const clientWithoutAccount = createWalletClient({ + chain: sepolia, + transport: http('https://rpc.sepolia.org'), + }); + + const rootDelegation = createDelegation({ + environment: mockEnvironment, + scope: { + type: ScopeType.Erc20TransferAmount, + tokenAddress: '0xabc0000000000000000000000000000000000000', + maxAmount: 1000n, + }, + to: account.address, + from: '0x1000000000000000000000000000000000000001', + }); + + const permissionContext = encodeDelegations([rootDelegation]); + const newDelegate: Address = '0x2000000000000000000000000000000000000002'; + + const timestampCaveat = { + enforcer: mockEnvironment.caveatEnforcers.TimestampEnforcer as Address, + terms: '0x0000000000000000000000000000000000000000000000000000000000000001' as Hex, + args: '0x00' as Hex, + }; + + const result = await redelegatePermissionContext(clientWithoutAccount, { + account, + environment: mockEnvironment, + permissionContext, + delegationManager: mockDelegationManager, + chainId: mockChainId, + delegate: newDelegate, + caveats: [timestampCaveat], + }); + + expect(result.delegation.delegate).to.equal(newDelegate); + expect(result.delegation.delegator).to.equal(account.address); + }); +}); + +describe('redelegatePermissionContextActions', () => { + it('should extend a wallet client with redelegatePermissionContext', async () => { + const client = createWalletClient({ + account, + chain: sepolia, + transport: http('https://rpc.sepolia.org'), + }).extend(redelegatePermissionContextActions()); + + const rootDelegation = createDelegation({ + environment: mockEnvironment, + scope: { + type: ScopeType.Erc20TransferAmount, + tokenAddress: '0xabc0000000000000000000000000000000000000', + maxAmount: 1000n, + }, + to: account.address, + from: '0x1000000000000000000000000000000000000001', + }); + + const permissionContext = encodeDelegations([rootDelegation]); + const newDelegate: Address = '0x2000000000000000000000000000000000000002'; + + const timestampCaveat = { + enforcer: mockEnvironment.caveatEnforcers.TimestampEnforcer as Address, + terms: '0x0000000000000000000000000000000000000000000000000000000000000001' as Hex, + args: '0x00' as Hex, + }; + + const result = await client.redelegatePermissionContext({ + environment: mockEnvironment, + permissionContext, + delegationManager: mockDelegationManager, + delegate: newDelegate, + caveats: [timestampCaveat], + // chainId should be inferred from client + }); + + expect(result.delegation.delegate).to.equal(newDelegate); + expect(result.delegation.delegator).to.equal(account.address); + }); + + it('should throw error if chain is not configured and chainId is not provided', async () => { + const clientWithoutChain = createWalletClient({ + account, + transport: http('https://rpc.sepolia.org'), + }).extend(redelegatePermissionContextActions()); + + const rootDelegation = createDelegation({ + environment: mockEnvironment, + scope: { + type: ScopeType.Erc20TransferAmount, + tokenAddress: '0xabc0000000000000000000000000000000000000', + maxAmount: 1000n, + }, + to: account.address, + from: '0x1000000000000000000000000000000000000001', + }); + + const permissionContext = encodeDelegations([rootDelegation]); + + await expect( + clientWithoutChain.redelegatePermissionContext({ + environment: mockEnvironment, + permissionContext, + delegationManager: mockDelegationManager, + delegate: '0x2000000000000000000000000000000000000002', + }), + ).rejects.toThrow('Chain ID is required'); + }); +}); diff --git a/packages/smart-accounts-kit/test/delegation.test.ts b/packages/smart-accounts-kit/test/delegation.test.ts index cf482830..9c976d97 100644 --- a/packages/smart-accounts-kit/test/delegation.test.ts +++ b/packages/smart-accounts-kit/test/delegation.test.ts @@ -813,3 +813,168 @@ describe('signDelegation', () => { expect(signature).to.have.length(132); }); }); + +describe('parentPermissionContext support', () => { + it('should create a delegation using parentPermissionContext with Delegation array', () => { + const parentDelegation = createDelegation({ + environment: smartAccountEnvironment, + scope: erc20Scope, + to: mockDelegate, + from: mockDelegator, + }); + + const permissionContext = [parentDelegation]; + + const result = createDelegation({ + environment: smartAccountEnvironment, + to: randomAddress(), + from: mockDelegate, + parentPermissionContext: permissionContext, + caveats: [mockCaveat], + }); + + expect(result.authority).to.equal(resolveAuthority(parentDelegation)); + expect(result.caveats).to.deep.equal([mockCaveat]); + }); + + it('should create a delegation using parentPermissionContext with encoded Hex', () => { + const parentDelegation = createDelegation({ + environment: smartAccountEnvironment, + scope: erc20Scope, + to: mockDelegate, + from: mockDelegator, + }); + + const encodedContext = encodeDelegations([parentDelegation]); + + const result = createDelegation({ + environment: smartAccountEnvironment, + to: randomAddress(), + from: mockDelegate, + parentPermissionContext: encodedContext, + caveats: [mockCaveat], + }); + + expect(result.authority).to.equal(resolveAuthority(parentDelegation)); + expect(result.caveats).to.deep.equal([mockCaveat]); + }); + + it('should create an open delegation using parentPermissionContext', () => { + const parentDelegation = createDelegation({ + environment: smartAccountEnvironment, + scope: erc20Scope, + to: mockDelegate, + from: mockDelegator, + }); + + const permissionContext = [parentDelegation]; + + const result = createOpenDelegation({ + environment: smartAccountEnvironment, + from: mockDelegate, + parentPermissionContext: permissionContext, + caveats: [mockCaveat], + }); + + expect(result.authority).to.equal(resolveAuthority(parentDelegation)); + expect(result.delegate).to.equal( + '0x0000000000000000000000000000000000000a11', // ANY_BENEFICIARY + ); + expect(result.caveats).to.deep.equal([mockCaveat]); + }); + + it('should inherit scope from parent when no scope is provided', () => { + const parentDelegation = createDelegation({ + environment: smartAccountEnvironment, + scope: erc20Scope, + to: mockDelegate, + from: mockDelegator, + }); + + const permissionContext = [parentDelegation]; + + const result = createDelegation({ + environment: smartAccountEnvironment, + to: randomAddress(), + from: mockDelegate, + parentPermissionContext: permissionContext, + // No scope provided - inherits from parent + }); + + expect(result.authority).to.equal(resolveAuthority(parentDelegation)); + // When no scope is provided, caveats should be empty (scope is inherited through the chain) + expect(result.caveats).to.deep.equal([]); + }); + + it('should allow scope override even with parent', () => { + const parentDelegation = createDelegation({ + environment: smartAccountEnvironment, + scope: erc20Scope, + to: mockDelegate, + from: mockDelegator, + }); + + const permissionContext = [parentDelegation]; + + const differentErc20Scope = { + type: ScopeType.Erc20TransferAmount as const, + tokenAddress: '0xdffe000000000000000000000000000000000000', + maxAmount: 5000n, + }; + + const result = createDelegation({ + environment: smartAccountEnvironment, + to: randomAddress(), + from: mockDelegate, + parentPermissionContext: permissionContext, + scope: differentErc20Scope, + }); + + expect(result.authority).to.equal(resolveAuthority(parentDelegation)); + // Should have new scope's caveats + expect(result.caveats.length).to.be.greaterThan(0); + }); + + it('should throw error if permission context is empty', () => { + const emptyContext: Delegation[] = []; + + expect(() => + createDelegation({ + environment: smartAccountEnvironment, + to: randomAddress(), + from: mockDelegate, + parentPermissionContext: emptyContext, + }), + ).to.throw('Permission context must contain at least one delegation'); + }); + + it('should extract leaf delegation from multi-delegation chain', () => { + const rootDelegation = createDelegation({ + environment: smartAccountEnvironment, + scope: erc20Scope, + to: mockDelegate, + from: mockDelegator, + }); + + const childDelegation = createDelegation({ + environment: smartAccountEnvironment, + to: randomAddress(), + from: mockDelegate, + parentDelegation: rootDelegation, + scope: erc20Scope, + }); + + // Chain ordered leaf to root + const permissionContext = [childDelegation, rootDelegation]; + + const result = createDelegation({ + environment: smartAccountEnvironment, + to: randomAddress(), + from: randomAddress(), + parentPermissionContext: permissionContext, + }); + + // Should use childDelegation (leaf) as parent + expect(result.authority).to.equal(resolveAuthority(childDelegation)); + }); +}); From 1dea0cd0b1809087306a2d908a44ebeaadeef873 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 29 Apr 2026 01:47:07 +0000 Subject: [PATCH 02/14] fix: correct type annotation for tokenAddress in test Co-authored-by: jeffsmale90 --- packages/smart-accounts-kit/test/delegation.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/smart-accounts-kit/test/delegation.test.ts b/packages/smart-accounts-kit/test/delegation.test.ts index 9c976d97..68dac0ad 100644 --- a/packages/smart-accounts-kit/test/delegation.test.ts +++ b/packages/smart-accounts-kit/test/delegation.test.ts @@ -918,7 +918,7 @@ describe('parentPermissionContext support', () => { const differentErc20Scope = { type: ScopeType.Erc20TransferAmount as const, - tokenAddress: '0xdffe000000000000000000000000000000000000', + tokenAddress: '0xdffe000000000000000000000000000000000000' as const, maxAmount: 5000n, }; From 5cad4dfa7969387668442d7d9130bbbe97b10eea Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 29 Apr 2026 01:51:48 +0000 Subject: [PATCH 03/14] fix: resolve linting issues - Fix import order in redelegatePermissionContext.ts and test files - Remove forbidden non-null assertion, use explicit check instead - Apply auto-formatting fixes Co-authored-by: jeffsmale90 --- .../actions/redelegatePermissionContext.ts | 4 ++-- packages/smart-accounts-kit/src/delegation.ts | 14 +++++++---- .../redelegatePermissionContext.test.ts | 24 ++++++++++++------- 3 files changed, 28 insertions(+), 14 deletions(-) diff --git a/packages/smart-accounts-kit/src/actions/redelegatePermissionContext.ts b/packages/smart-accounts-kit/src/actions/redelegatePermissionContext.ts index 74e6848c..9f98b1a0 100644 --- a/packages/smart-accounts-kit/src/actions/redelegatePermissionContext.ts +++ b/packages/smart-accounts-kit/src/actions/redelegatePermissionContext.ts @@ -11,6 +11,8 @@ import { BaseError } from 'viem'; import { parseAccount } from 'viem/accounts'; import { trackSmartAccountsKitFunctionCall } from '../analytics'; +import type { Caveats } from '../caveatBuilder'; +import type { ScopeConfig } from '../caveatBuilder/scope'; import { createDelegation, createOpenDelegation, @@ -24,8 +26,6 @@ import type { PermissionContext, SmartAccountsEnvironment, } from '../types'; -import type { Caveats } from '../caveatBuilder'; -import type { ScopeConfig } from '../caveatBuilder/scope'; import { signDelegation } from './signDelegation'; export type RedelegatePermissionContextParameters = { diff --git a/packages/smart-accounts-kit/src/delegation.ts b/packages/smart-accounts-kit/src/delegation.ts index 01445963..10d2ab5a 100644 --- a/packages/smart-accounts-kit/src/delegation.ts +++ b/packages/smart-accounts-kit/src/delegation.ts @@ -261,8 +261,12 @@ const extractLeafDelegation = ( throw new Error('Permission context must contain at least one delegation'); } - // We've verified the array is not empty, so delegations[0] is guaranteed to exist - return delegations[0]!; + const leafDelegation = delegations[0]; + if (!leafDelegation) { + throw new Error('Failed to extract leaf delegation'); + } + + return leafDelegation; }; /** @@ -350,7 +354,8 @@ export const createDelegation = ( trackSmartAccountsKitFunctionCall('createDelegation', { hasParentDelegation: - ('parentDelegation' in options && options.parentDelegation !== undefined) || + ('parentDelegation' in options && + options.parentDelegation !== undefined) || ('parentPermissionContext' in options && options.parentPermissionContext !== undefined), scope: options.scope?.type ?? null, @@ -394,7 +399,8 @@ export const createOpenDelegation = ( trackSmartAccountsKitFunctionCall('createOpenDelegation', { hasParentDelegation: - ('parentDelegation' in options && options.parentDelegation !== undefined) || + ('parentDelegation' in options && + options.parentDelegation !== undefined) || ('parentPermissionContext' in options && options.parentPermissionContext !== undefined), scope: options.scope?.type ?? null, diff --git a/packages/smart-accounts-kit/test/actions/redelegatePermissionContext.test.ts b/packages/smart-accounts-kit/test/actions/redelegatePermissionContext.test.ts index 13715a2e..6180397f 100644 --- a/packages/smart-accounts-kit/test/actions/redelegatePermissionContext.test.ts +++ b/packages/smart-accounts-kit/test/actions/redelegatePermissionContext.test.ts @@ -7,8 +7,8 @@ import { redelegatePermissionContext, redelegatePermissionContextActions, } from '../../src/actions/redelegatePermissionContext'; -import { createDelegation, encodeDelegations } from '../../src/delegation'; import { ScopeType } from '../../src/constants'; +import { createDelegation, encodeDelegations } from '../../src/delegation'; import type { SmartAccountsEnvironment } from '../../src/types'; const mockPrivateKey = @@ -64,7 +64,8 @@ describe('redelegatePermissionContext', () => { const timestampCaveat = { enforcer: mockEnvironment.caveatEnforcers.TimestampEnforcer as Address, - terms: '0x0000000000000000000000000000000000000000000000000000000000000001' as Hex, + terms: + '0x0000000000000000000000000000000000000000000000000000000000000001' as Hex, args: '0x00' as Hex, }; @@ -104,7 +105,8 @@ describe('redelegatePermissionContext', () => { const timestampCaveat = { enforcer: mockEnvironment.caveatEnforcers.TimestampEnforcer as Address, - terms: '0x0000000000000000000000000000000000000000000000000000000000000001' as Hex, + terms: + '0x0000000000000000000000000000000000000000000000000000000000000001' as Hex, args: '0x00' as Hex, }; @@ -141,7 +143,8 @@ describe('redelegatePermissionContext', () => { const timestampCaveat = { enforcer: mockEnvironment.caveatEnforcers.TimestampEnforcer as Address, - terms: '0x0000000000000000000000000000000000000000000000000000000000000001' as Hex, + terms: + '0x0000000000000000000000000000000000000000000000000000000000000001' as Hex, args: '0x00' as Hex, }; @@ -174,7 +177,8 @@ describe('redelegatePermissionContext', () => { const timestampCaveat = { enforcer: mockEnvironment.caveatEnforcers.TimestampEnforcer as Address, - terms: '0x0000000000000000000000000000000000000000000000000000000000000001' as Hex, + terms: + '0x0000000000000000000000000000000000000000000000000000000000000001' as Hex, args: '0x00' as Hex, }; @@ -236,7 +240,9 @@ describe('redelegatePermissionContext', () => { chainId: mockChainId, delegate: '0x2000000000000000000000000000000000000002', }), - ).rejects.toThrow('Permission context must contain at least one delegation'); + ).rejects.toThrow( + 'Permission context must contain at least one delegation', + ); }); it('should throw error if no account is provided', async () => { @@ -291,7 +297,8 @@ describe('redelegatePermissionContext', () => { const timestampCaveat = { enforcer: mockEnvironment.caveatEnforcers.TimestampEnforcer as Address, - terms: '0x0000000000000000000000000000000000000000000000000000000000000001' as Hex, + terms: + '0x0000000000000000000000000000000000000000000000000000000000000001' as Hex, args: '0x00' as Hex, }; @@ -334,7 +341,8 @@ describe('redelegatePermissionContextActions', () => { const timestampCaveat = { enforcer: mockEnvironment.caveatEnforcers.TimestampEnforcer as Address, - terms: '0x0000000000000000000000000000000000000000000000000000000000000001' as Hex, + terms: + '0x0000000000000000000000000000000000000000000000000000000000000001' as Hex, args: '0x00' as Hex, }; From d640fe0fc86498cb0e4249e3c81ce1c2e1ee8e52 Mon Sep 17 00:00:00 2001 From: Jeff Smale <6363749+jeffsmale90@users.noreply.github.com> Date: Thu, 7 May 2026 10:29:07 +1200 Subject: [PATCH 04/14] Refine changes to createDelegation and createOpenDelegation - resolveParentDelegation accepts config rather than position arguments - simplifies hasParentDelegation in trackSmartAccountsKitFunctionCall - refactors tests to enforce strict hierarchy - adds a new test to ensure an empty permission context is rejected --- packages/smart-accounts-kit/src/delegation.ts | 42 +-- .../test/delegation.test.ts | 345 +++++++++--------- 2 files changed, 196 insertions(+), 191 deletions(-) diff --git a/packages/smart-accounts-kit/src/delegation.ts b/packages/smart-accounts-kit/src/delegation.ts index 10d2ab5a..ca3b523c 100644 --- a/packages/smart-accounts-kit/src/delegation.ts +++ b/packages/smart-accounts-kit/src/delegation.ts @@ -272,15 +272,19 @@ const extractLeafDelegation = ( /** * Resolves the parent delegation from either a direct delegation or permission context. * - * @param parentDelegation - The parent delegation or its hash. - * @param parentPermissionContext - The permission context containing the parent delegation. + * @param options - The options for resolving the parent delegation. + * @param options.parentDelegation - The parent delegation or its hash. + * @param options.parentPermissionContext - The permission context containing the parent delegation. * @returns The resolved parent delegation, or undefined if neither is provided. * @internal */ -const resolveParentDelegation = ( - parentDelegation?: Delegation | Hex, - parentPermissionContext?: PermissionContext, -): Delegation | Hex | undefined => { +const resolveParentDelegation = ({ + parentDelegation, + parentPermissionContext, +}: Pick< + BaseCreateDelegationOptions, + 'parentDelegation' | 'parentPermissionContext' +>): Delegation | Hex | undefined => { if (parentPermissionContext) { return extractLeafDelegation(parentPermissionContext); } @@ -339,12 +343,7 @@ const getCaveatNames = ({ export const createDelegation = ( options: CreateDelegationOptions, ): Delegation => { - const parentDelegation = resolveParentDelegation( - 'parentDelegation' in options ? options.parentDelegation : undefined, - 'parentPermissionContext' in options - ? options.parentPermissionContext - : undefined, - ); + const parentDelegation = resolveParentDelegation(options); const caveats = resolveCaveats({ environment: options.environment, @@ -354,10 +353,8 @@ export const createDelegation = ( trackSmartAccountsKitFunctionCall('createDelegation', { hasParentDelegation: - ('parentDelegation' in options && - options.parentDelegation !== undefined) || - ('parentPermissionContext' in options && - options.parentPermissionContext !== undefined), + options.parentDelegation !== undefined || + options.parentPermissionContext !== undefined, scope: options.scope?.type ?? null, caveatNames: getCaveatNames({ caveats, @@ -384,12 +381,7 @@ export const createDelegation = ( export const createOpenDelegation = ( options: CreateOpenDelegationOptions, ): Delegation => { - const parentDelegation = resolveParentDelegation( - 'parentDelegation' in options ? options.parentDelegation : undefined, - 'parentPermissionContext' in options - ? options.parentPermissionContext - : undefined, - ); + const parentDelegation = resolveParentDelegation(options); const caveats = resolveCaveats({ environment: options.environment, @@ -399,10 +391,8 @@ export const createOpenDelegation = ( trackSmartAccountsKitFunctionCall('createOpenDelegation', { hasParentDelegation: - ('parentDelegation' in options && - options.parentDelegation !== undefined) || - ('parentPermissionContext' in options && - options.parentPermissionContext !== undefined), + options.parentDelegation !== undefined || + options.parentPermissionContext !== undefined, scope: options.scope?.type ?? null, caveatNames: getCaveatNames({ caveats, diff --git a/packages/smart-accounts-kit/test/delegation.test.ts b/packages/smart-accounts-kit/test/delegation.test.ts index 68dac0ad..ea65ec07 100644 --- a/packages/smart-accounts-kit/test/delegation.test.ts +++ b/packages/smart-accounts-kit/test/delegation.test.ts @@ -352,6 +352,160 @@ describe('createDelegation', () => { signature: '0x', }); }); + + describe('parentPermissionContext support', () => { + it('should create a delegation using parentPermissionContext with Delegation array', () => { + const parentDelegation = createDelegation({ + environment: smartAccountEnvironment, + scope: erc20Scope, + to: mockDelegate, + from: mockDelegator, + }); + + const permissionContext = [parentDelegation]; + + const result = createDelegation({ + environment: smartAccountEnvironment, + to: randomAddress(), + from: mockDelegate, + parentPermissionContext: permissionContext, + caveats: [mockCaveat], + }); + + expect(result.authority).to.equal(resolveAuthority(parentDelegation)); + expect(result.caveats).to.deep.equal([mockCaveat]); + }); + + it('should create a delegation using parentPermissionContext with encoded Hex', () => { + const parentDelegation = createDelegation({ + environment: smartAccountEnvironment, + scope: erc20Scope, + to: mockDelegate, + from: mockDelegator, + }); + + const encodedContext = encodeDelegations([parentDelegation]); + + const result = createDelegation({ + environment: smartAccountEnvironment, + to: randomAddress(), + from: mockDelegate, + parentPermissionContext: encodedContext, + caveats: [mockCaveat], + }); + + expect(result.authority).to.equal(resolveAuthority(parentDelegation)); + expect(result.caveats).to.deep.equal([mockCaveat]); + }); + + it('should inherit scope from parent when no scope is provided', () => { + const parentDelegation = createDelegation({ + environment: smartAccountEnvironment, + scope: erc20Scope, + to: mockDelegate, + from: mockDelegator, + }); + + const permissionContext = [parentDelegation]; + + const result = createDelegation({ + environment: smartAccountEnvironment, + to: randomAddress(), + from: mockDelegate, + parentPermissionContext: permissionContext, + // No scope provided - inherits from parent + }); + + expect(result.authority).to.equal(resolveAuthority(parentDelegation)); + // When no scope is provided, caveats should be empty (scope is inherited through the chain) + expect(result.caveats).to.deep.equal([]); + }); + + it('should allow scope override even with parent', () => { + const parentDelegation = createDelegation({ + environment: smartAccountEnvironment, + scope: erc20Scope, + to: mockDelegate, + from: mockDelegator, + }); + + const permissionContext = [parentDelegation]; + + const differentErc20Scope = { + type: ScopeType.Erc20TransferAmount as const, + tokenAddress: '0xdffe000000000000000000000000000000000000' as const, + maxAmount: 5000n, + }; + + const result = createDelegation({ + environment: smartAccountEnvironment, + to: randomAddress(), + from: mockDelegate, + parentPermissionContext: permissionContext, + scope: differentErc20Scope, + }); + + expect(result.authority).to.equal(resolveAuthority(parentDelegation)); + // Should have new scope's caveats + expect(result.caveats.length).to.be.greaterThan(0); + }); + + it('should throw error if permission context is empty', () => { + const emptyContext: Delegation[] = []; + + expect(() => + createDelegation({ + environment: smartAccountEnvironment, + to: randomAddress(), + from: mockDelegate, + parentPermissionContext: emptyContext, + }), + ).to.throw('Permission context must contain at least one delegation'); + }); + + it('should throw error if encoded permission context is empty', () => { + const emptyContext = encodeDelegations([]); + + expect(() => + createDelegation({ + environment: smartAccountEnvironment, + to: randomAddress(), + from: mockDelegate, + parentPermissionContext: emptyContext, + }), + ).to.throw('Permission context must contain at least one delegation'); + }); + + it('should extract leaf delegation from multi-delegation chain', () => { + const rootDelegation = createDelegation({ + environment: smartAccountEnvironment, + scope: erc20Scope, + to: mockDelegate, + from: mockDelegator, + }); + + const childDelegation = createDelegation({ + environment: smartAccountEnvironment, + to: randomAddress(), + from: mockDelegate, + parentDelegation: rootDelegation, + scope: erc20Scope, + }); + + // Chain ordered leaf to root + const permissionContext = [childDelegation, rootDelegation]; + + const result = createDelegation({ + environment: smartAccountEnvironment, + to: randomAddress(), + from: randomAddress(), + parentPermissionContext: permissionContext, + }); + + // Should use childDelegation (leaf) as parent + expect(result.authority).to.equal(resolveAuthority(childDelegation)); + }); + }); }); describe('createOpenDelegation', () => { @@ -498,6 +652,32 @@ describe('createOpenDelegation', () => { signature: '0x', }); }); + + describe('parentPermissionContext support', () => { + it('should create an open delegation using parentPermissionContext', () => { + const parentDelegation = createDelegation({ + environment: smartAccountEnvironment, + scope: erc20Scope, + to: mockDelegate, + from: mockDelegator, + }); + + const permissionContext = [parentDelegation]; + + const result = createOpenDelegation({ + environment: smartAccountEnvironment, + from: mockDelegate, + parentPermissionContext: permissionContext, + caveats: [mockCaveat], + }); + + expect(result.authority).to.equal(resolveAuthority(parentDelegation)); + expect(result.delegate).to.equal( + '0x0000000000000000000000000000000000000a11', // ANY_BENEFICIARY + ); + expect(result.caveats).to.deep.equal([mockCaveat]); + }); + }); }); describe('encodeDelegations', () => { @@ -813,168 +993,3 @@ describe('signDelegation', () => { expect(signature).to.have.length(132); }); }); - -describe('parentPermissionContext support', () => { - it('should create a delegation using parentPermissionContext with Delegation array', () => { - const parentDelegation = createDelegation({ - environment: smartAccountEnvironment, - scope: erc20Scope, - to: mockDelegate, - from: mockDelegator, - }); - - const permissionContext = [parentDelegation]; - - const result = createDelegation({ - environment: smartAccountEnvironment, - to: randomAddress(), - from: mockDelegate, - parentPermissionContext: permissionContext, - caveats: [mockCaveat], - }); - - expect(result.authority).to.equal(resolveAuthority(parentDelegation)); - expect(result.caveats).to.deep.equal([mockCaveat]); - }); - - it('should create a delegation using parentPermissionContext with encoded Hex', () => { - const parentDelegation = createDelegation({ - environment: smartAccountEnvironment, - scope: erc20Scope, - to: mockDelegate, - from: mockDelegator, - }); - - const encodedContext = encodeDelegations([parentDelegation]); - - const result = createDelegation({ - environment: smartAccountEnvironment, - to: randomAddress(), - from: mockDelegate, - parentPermissionContext: encodedContext, - caveats: [mockCaveat], - }); - - expect(result.authority).to.equal(resolveAuthority(parentDelegation)); - expect(result.caveats).to.deep.equal([mockCaveat]); - }); - - it('should create an open delegation using parentPermissionContext', () => { - const parentDelegation = createDelegation({ - environment: smartAccountEnvironment, - scope: erc20Scope, - to: mockDelegate, - from: mockDelegator, - }); - - const permissionContext = [parentDelegation]; - - const result = createOpenDelegation({ - environment: smartAccountEnvironment, - from: mockDelegate, - parentPermissionContext: permissionContext, - caveats: [mockCaveat], - }); - - expect(result.authority).to.equal(resolveAuthority(parentDelegation)); - expect(result.delegate).to.equal( - '0x0000000000000000000000000000000000000a11', // ANY_BENEFICIARY - ); - expect(result.caveats).to.deep.equal([mockCaveat]); - }); - - it('should inherit scope from parent when no scope is provided', () => { - const parentDelegation = createDelegation({ - environment: smartAccountEnvironment, - scope: erc20Scope, - to: mockDelegate, - from: mockDelegator, - }); - - const permissionContext = [parentDelegation]; - - const result = createDelegation({ - environment: smartAccountEnvironment, - to: randomAddress(), - from: mockDelegate, - parentPermissionContext: permissionContext, - // No scope provided - inherits from parent - }); - - expect(result.authority).to.equal(resolveAuthority(parentDelegation)); - // When no scope is provided, caveats should be empty (scope is inherited through the chain) - expect(result.caveats).to.deep.equal([]); - }); - - it('should allow scope override even with parent', () => { - const parentDelegation = createDelegation({ - environment: smartAccountEnvironment, - scope: erc20Scope, - to: mockDelegate, - from: mockDelegator, - }); - - const permissionContext = [parentDelegation]; - - const differentErc20Scope = { - type: ScopeType.Erc20TransferAmount as const, - tokenAddress: '0xdffe000000000000000000000000000000000000' as const, - maxAmount: 5000n, - }; - - const result = createDelegation({ - environment: smartAccountEnvironment, - to: randomAddress(), - from: mockDelegate, - parentPermissionContext: permissionContext, - scope: differentErc20Scope, - }); - - expect(result.authority).to.equal(resolveAuthority(parentDelegation)); - // Should have new scope's caveats - expect(result.caveats.length).to.be.greaterThan(0); - }); - - it('should throw error if permission context is empty', () => { - const emptyContext: Delegation[] = []; - - expect(() => - createDelegation({ - environment: smartAccountEnvironment, - to: randomAddress(), - from: mockDelegate, - parentPermissionContext: emptyContext, - }), - ).to.throw('Permission context must contain at least one delegation'); - }); - - it('should extract leaf delegation from multi-delegation chain', () => { - const rootDelegation = createDelegation({ - environment: smartAccountEnvironment, - scope: erc20Scope, - to: mockDelegate, - from: mockDelegator, - }); - - const childDelegation = createDelegation({ - environment: smartAccountEnvironment, - to: randomAddress(), - from: mockDelegate, - parentDelegation: rootDelegation, - scope: erc20Scope, - }); - - // Chain ordered leaf to root - const permissionContext = [childDelegation, rootDelegation]; - - const result = createDelegation({ - environment: smartAccountEnvironment, - to: randomAddress(), - from: randomAddress(), - parentPermissionContext: permissionContext, - }); - - // Should use childDelegation (leaf) as parent - expect(result.authority).to.equal(resolveAuthority(childDelegation)); - }); -}); From 603d7b7943b53ea2f8382a7d0a95bac51ddcccb4 Mon Sep 17 00:00:00 2001 From: Jeff Smale <6363749+jeffsmale90@users.noreply.github.com> Date: Thu, 7 May 2026 10:46:10 +1200 Subject: [PATCH 05/14] Ensure that only delegations with inheritance may be created without a scope --- .../src/caveatBuilder/resolveCaveats.ts | 7 ++ packages/smart-accounts-kit/src/delegation.ts | 2 + .../test/caveatBuilder/resolveCaveats.test.ts | 68 +++++++++++++++++++ .../test/delegation.test.ts | 19 ++++++ 4 files changed, 96 insertions(+) diff --git a/packages/smart-accounts-kit/src/caveatBuilder/resolveCaveats.ts b/packages/smart-accounts-kit/src/caveatBuilder/resolveCaveats.ts index f6e98bb9..a9d57131 100644 --- a/packages/smart-accounts-kit/src/caveatBuilder/resolveCaveats.ts +++ b/packages/smart-accounts-kit/src/caveatBuilder/resolveCaveats.ts @@ -15,17 +15,24 @@ export type Caveats = CaveatBuilder | (Caveat | CoreCaveatConfiguration)[]; * @param config.environment - The environment to be used for the caveat builder. * @param config.scope - The scope to be used for the caveat builder. Optional - when not provided and redelegating, scope is inherited from the parent delegation. * @param config.caveats - The caveats to be resolved, which can be either a CaveatBuilder or an array of Caveat or CaveatConfiguration. Optional - if not provided, only scope caveats will be used. + * @param config.hasInheritance - Whether the delegation has inheritance. * @returns The resolved array of caveats. */ export const resolveCaveats = ({ environment, scope, caveats, + hasInheritance, }: { environment: SmartAccountsEnvironment; scope?: ScopeConfig; caveats?: Caveats; + hasInheritance: boolean; }) => { + if (!scope && !hasInheritance) { + throw new Error('Scope is required when the delegation has no inheritance'); + } + // Create base caveat builder from scope if provided, otherwise use core caveat builder const scopeCaveatBuilder = scope ? createCaveatBuilderFromScope(environment, scope) diff --git a/packages/smart-accounts-kit/src/delegation.ts b/packages/smart-accounts-kit/src/delegation.ts index ca3b523c..87b503dd 100644 --- a/packages/smart-accounts-kit/src/delegation.ts +++ b/packages/smart-accounts-kit/src/delegation.ts @@ -349,6 +349,7 @@ export const createDelegation = ( environment: options.environment, scope: options.scope, caveats: options.caveats, + hasInheritance: Boolean(parentDelegation), }); trackSmartAccountsKitFunctionCall('createDelegation', { @@ -387,6 +388,7 @@ export const createOpenDelegation = ( environment: options.environment, scope: options.scope, caveats: options.caveats, + hasInheritance: Boolean(parentDelegation), }); trackSmartAccountsKitFunctionCall('createOpenDelegation', { diff --git a/packages/smart-accounts-kit/test/caveatBuilder/resolveCaveats.test.ts b/packages/smart-accounts-kit/test/caveatBuilder/resolveCaveats.test.ts index 871952bc..b6f79ead 100644 --- a/packages/smart-accounts-kit/test/caveatBuilder/resolveCaveats.test.ts +++ b/packages/smart-accounts-kit/test/caveatBuilder/resolveCaveats.test.ts @@ -48,6 +48,7 @@ describe('resolveCaveats', () => { environment, scope: erc20Scope, caveats: caveatBuilder, + hasInheritance: false, }); // 4 caveats: 2 from the scope, 2 from the builder @@ -65,6 +66,7 @@ describe('resolveCaveats', () => { environment, scope: erc20Scope, caveats: caveatBuilder, + hasInheritance: false, }); expect(result).to.be.an('array'); @@ -83,6 +85,7 @@ describe('resolveCaveats', () => { environment, scope: erc20Scope, caveats: caveatBuilder as any, + hasInheritance: false, }); expect(result).to.be.an('array'); @@ -98,6 +101,7 @@ describe('resolveCaveats', () => { environment, scope: erc20Scope, caveats, + hasInheritance: false, }); expect(result).to.be.an('array'); @@ -125,6 +129,7 @@ describe('resolveCaveats', () => { environment, scope: erc20Scope, caveats: caveatConfigs, + hasInheritance: false, }); expect(result).to.be.an('array'); @@ -135,6 +140,7 @@ describe('resolveCaveats', () => { environment, scope: erc20Scope, caveats: [], + hasInheritance: false, }); expect(result.length).to.be.greaterThan(scopeOnlyResult.length); }); @@ -158,6 +164,7 @@ describe('resolveCaveats', () => { environment, scope: erc20Scope, caveats: mixedCaveats, + hasInheritance: false, }); expect(result).to.be.an('array'); @@ -173,6 +180,7 @@ describe('resolveCaveats', () => { environment, scope: erc20Scope, caveats: [], + hasInheritance: false, }); expect(result).to.be.an('array'); @@ -197,6 +205,7 @@ describe('resolveCaveats', () => { environment, scope: erc721Scope, caveats: [caveatConfig], + hasInheritance: false, }); expect(result).to.be.an('array'); @@ -208,12 +217,14 @@ describe('resolveCaveats', () => { environment, scope: erc20Scope, caveats: [mockCaveat1], + hasInheritance: false, }); const resultWithoutCaveats = resolveCaveats({ environment, scope: erc20Scope, caveats: [], + hasInheritance: false, }); expect(resultWithCaveats.length).to.be.greaterThan( @@ -236,8 +247,65 @@ describe('resolveCaveats', () => { environment, scope: erc20Scope, caveats: [invalidType as any], + hasInheritance: false, }); }).to.throw('Invalid caveat'); }); }); + + describe('hasInheritance', () => { + it('should throw if scope is not provided and the delegation has no inheritance', () => { + expect(() => + resolveCaveats({ + environment, + caveats: [mockCaveat1], + hasInheritance: false, + }), + ).to.throw('Scope is required when the delegation has no inheritance'); + }); + + it('should throw if neither scope nor caveats are provided and the delegation has no inheritance', () => { + expect(() => + resolveCaveats({ + environment, + hasInheritance: false, + }), + ).to.throw('Scope is required when the delegation has no inheritance'); + }); + + it('should resolve caveats without a scope when the delegation has inheritance', () => { + const result = resolveCaveats({ + environment, + caveats: [mockCaveat1, mockCaveat2], + hasInheritance: true, + }); + + // No scope caveats are added when the scope is inherited from the parent + expect(result).to.have.lengthOf(2); + expect(result).to.deep.include(mockCaveat1); + expect(result).to.deep.include(mockCaveat2); + }); + + it('should return an empty array when no scope, no caveats and the delegation has inheritance', () => { + const result = resolveCaveats({ + environment, + hasInheritance: true, + }); + + expect(result).to.deep.equal([]); + }); + + it('should still apply scope caveats when both scope and inheritance are provided', () => { + const result = resolveCaveats({ + environment, + scope: erc20Scope, + caveats: [mockCaveat1], + hasInheritance: true, + }); + + // Scope caveats are still produced when an explicit scope is provided + expect(result.length).to.be.greaterThan(1); + expect(result).to.deep.include(mockCaveat1); + }); + }); }); diff --git a/packages/smart-accounts-kit/test/delegation.test.ts b/packages/smart-accounts-kit/test/delegation.test.ts index ea65ec07..fc467a6f 100644 --- a/packages/smart-accounts-kit/test/delegation.test.ts +++ b/packages/smart-accounts-kit/test/delegation.test.ts @@ -353,6 +353,16 @@ describe('createDelegation', () => { }); }); + it('throws if no scope no inheritance is provided', () => { + expect(() => { + createDelegation({ + environment: smartAccountEnvironment, + to: mockDelegate, + from: mockDelegator, + } as any); + }).toThrow('Scope is required when the delegation has no inheritance'); + }); + describe('parentPermissionContext support', () => { it('should create a delegation using parentPermissionContext with Delegation array', () => { const parentDelegation = createDelegation({ @@ -653,6 +663,15 @@ describe('createOpenDelegation', () => { }); }); + it('throws if no scope no inheritance is provided', () => { + expect(() => { + createOpenDelegation({ + environment: smartAccountEnvironment, + from: mockDelegator, + } as any); + }).toThrow('Scope is required when the delegation has no inheritance'); + }); + describe('parentPermissionContext support', () => { it('should create an open delegation using parentPermissionContext', () => { const parentDelegation = createDelegation({ From 485fc43562ac1e2a98b51f2af08d26d2d51067b1 Mon Sep 17 00:00:00 2001 From: Jeff Smale <6363749+jeffsmale90@users.noreply.github.com> Date: Thu, 7 May 2026 11:03:08 +1200 Subject: [PATCH 06/14] Refine redelegatePermissionContext function --- .../actions/redelegatePermissionContext.ts | 88 ++++++------------- .../redelegatePermissionContext.test.ts | 32 +++---- 2 files changed, 39 insertions(+), 81 deletions(-) diff --git a/packages/smart-accounts-kit/src/actions/redelegatePermissionContext.ts b/packages/smart-accounts-kit/src/actions/redelegatePermissionContext.ts index 9f98b1a0..4c812057 100644 --- a/packages/smart-accounts-kit/src/actions/redelegatePermissionContext.ts +++ b/packages/smart-accounts-kit/src/actions/redelegatePermissionContext.ts @@ -18,8 +18,6 @@ import { createOpenDelegation, decodeDelegations, encodeDelegations, - type CreateDelegationOptions, - type CreateOpenDelegationOptions, } from '../delegation'; import type { Delegation, @@ -33,10 +31,8 @@ export type RedelegatePermissionContextParameters = { account?: Account | Address; /** Environment configuration */ environment: SmartAccountsEnvironment; - /** The permission context to redelegate from (from ERC-7715 response) */ + /** The permission context to redelegate from (i.e., from ERC-7715 response) */ permissionContext: PermissionContext; - /** The address of the delegation manager contract */ - delegationManager: Address; /** The chain ID for the signature */ chainId: number; /** Optional scope - if not provided, inherits from parent */ @@ -45,16 +41,9 @@ export type RedelegatePermissionContextParameters = { caveats?: Caveats; /** Optional salt for uniqueness */ salt?: Hex; - /** Name of the DelegationManager contract */ - name?: string; - /** Version of the DelegationManager contract */ - version?: string; - /** Whether to allow insecure unrestricted delegation */ - allowInsecureUnrestrictedDelegation?: boolean; -} & ( - | { delegate: Address } // Specific delegate - | { delegate?: never } // Open delegation -); + /** The address of the delegate to redelegate to */ + to?: Address; +}; export type RedelegatePermissionContextReturnType = { /** The signed delegation that was created */ @@ -127,14 +116,11 @@ export async function redelegatePermissionContext< account: accountParam = client.account, environment, permissionContext, - delegationManager, chainId, scope, caveats, salt, - name = 'DelegationManager', - version = '1', - allowInsecureUnrestrictedDelegation = false, + to, } = parameters; if (!accountParam) { @@ -145,72 +131,57 @@ export async function redelegatePermissionContext< trackSmartAccountsKitFunctionCall('redelegatePermissionContext', { chainId, - hasDelegate: 'delegate' in parameters && parameters.delegate !== undefined, + hasDelegate: Boolean(parameters.to), hasScope: scope !== undefined, hasCaveats: caveats !== undefined, }); - // Decode the permission context to get the delegation chain const delegations = decodeDelegations(permissionContext); - if (delegations.length === 0) { + const parentDelegation = delegations[0]; + + if (!parentDelegation) { throw new BaseError( 'Permission context must contain at least one delegation', ); } - // The leaf delegation is the first element (chain ordered leaf to root) - const leafDelegation = delegations[0]; - - // Create the unsigned delegation - // We always pass parentDelegation as the leaf delegation - // TypeScript struggles with the discriminated union, so we build the object explicitly - let unsignedDelegation: Omit; - - const commonOptions = { + const createDelegationOptions = { environment, from: account.address, - parentDelegation: leafDelegation as Delegation | Hex, - ...(scope !== undefined && { scope }), - ...(caveats !== undefined && { caveats }), - ...(salt !== undefined && { salt }), + scope, + caveats, + parentDelegation, + salt, }; - if ('delegate' in parameters && parameters.delegate) { - unsignedDelegation = createDelegation({ - ...commonOptions, - to: parameters.delegate, - } as CreateDelegationOptions); - } else { - unsignedDelegation = createOpenDelegation( - commonOptions as CreateOpenDelegationOptions, - ); - } + const unsignedDelegation = to + ? createDelegation({ + ...createDelegationOptions, + to, + }) + : createOpenDelegation(createDelegationOptions); - // Sign the delegation const signature = await signDelegation(client, { - account: accountParam, + account, delegation: unsignedDelegation, - delegationManager, + delegationManager: environment.DelegationManager, chainId, - name, - version, - allowInsecureUnrestrictedDelegation, }); - // Create the signed delegation const signedDelegation: Delegation = { ...unsignedDelegation, signature, }; - // Prepend the new delegation to create the new chain - const newDelegationChain = [signedDelegation, ...delegations]; + const newPermissionContext = encodeDelegations([ + signedDelegation, + ...delegations, + ]); - // Return both the delegation and the encoded permission context return { delegation: signedDelegation, - permissionContext: encodeDelegations(newDelegationChain), + permissionContext: newPermissionContext, }; } @@ -227,10 +198,9 @@ export async function redelegatePermissionContext< * * // Now you can call it directly on the client * const result = await walletClient.redelegatePermissionContext({ - * permissionContext: erc7715Response.context, - * delegate: charlie.address, * environment, - * delegationManager: environment.DelegationManager, + * permissionContext: erc7715Response.context, + * to: charlie.address, * }); * ``` */ diff --git a/packages/smart-accounts-kit/test/actions/redelegatePermissionContext.test.ts b/packages/smart-accounts-kit/test/actions/redelegatePermissionContext.test.ts index 6180397f..0ffc2f69 100644 --- a/packages/smart-accounts-kit/test/actions/redelegatePermissionContext.test.ts +++ b/packages/smart-accounts-kit/test/actions/redelegatePermissionContext.test.ts @@ -32,8 +32,6 @@ const mockEnvironment: SmartAccountsEnvironment = { }, }; -const mockDelegationManager: Address = - '0x1234567890123456789012345678901234567890'; const mockChainId = sepolia.id; describe('redelegatePermissionContext', () => { @@ -72,9 +70,8 @@ describe('redelegatePermissionContext', () => { const result = await redelegatePermissionContext(client, { environment: mockEnvironment, permissionContext, - delegationManager: mockDelegationManager, chainId: mockChainId, - delegate: newDelegate, + to: newDelegate, caveats: [timestampCaveat], }); @@ -113,9 +110,8 @@ describe('redelegatePermissionContext', () => { const result = await redelegatePermissionContext(client, { environment: mockEnvironment, permissionContext, - delegationManager: mockDelegationManager, chainId: mockChainId, - // No delegate specified + // No `to` specified - creates an open delegation caveats: [timestampCaveat], }); @@ -151,9 +147,8 @@ describe('redelegatePermissionContext', () => { const result = await redelegatePermissionContext(client, { environment: mockEnvironment, permissionContext, - delegationManager: mockDelegationManager, chainId: mockChainId, - delegate: newDelegate, + to: newDelegate, caveats: [timestampCaveat], }); @@ -185,9 +180,8 @@ describe('redelegatePermissionContext', () => { const result = await redelegatePermissionContext(client, { environment: mockEnvironment, permissionContext, - delegationManager: mockDelegationManager, chainId: mockChainId, - delegate: newDelegate, + to: newDelegate, // No scope provided - should inherit from parent caveats: [timestampCaveat], // Add a caveat so signature doesn't fail }); @@ -215,9 +209,8 @@ describe('redelegatePermissionContext', () => { const result = await redelegatePermissionContext(client, { environment: mockEnvironment, permissionContext, - delegationManager: mockDelegationManager, chainId: mockChainId, - delegate: newDelegate, + to: newDelegate, scope: { type: ScopeType.NativeTokenTransferAmount, maxAmount: 500n, @@ -236,9 +229,8 @@ describe('redelegatePermissionContext', () => { redelegatePermissionContext(client, { environment: mockEnvironment, permissionContext: emptyContext, - delegationManager: mockDelegationManager, chainId: mockChainId, - delegate: '0x2000000000000000000000000000000000000002', + to: '0x2000000000000000000000000000000000000002', }), ).rejects.toThrow( 'Permission context must contain at least one delegation', @@ -268,9 +260,8 @@ describe('redelegatePermissionContext', () => { redelegatePermissionContext(clientWithoutAccount, { environment: mockEnvironment, permissionContext, - delegationManager: mockDelegationManager, chainId: mockChainId, - delegate: '0x2000000000000000000000000000000000000002', + to: '0x2000000000000000000000000000000000000002', }), ).rejects.toThrow('Account not found'); }); @@ -306,9 +297,8 @@ describe('redelegatePermissionContext', () => { account, environment: mockEnvironment, permissionContext, - delegationManager: mockDelegationManager, chainId: mockChainId, - delegate: newDelegate, + to: newDelegate, caveats: [timestampCaveat], }); @@ -349,8 +339,7 @@ describe('redelegatePermissionContextActions', () => { const result = await client.redelegatePermissionContext({ environment: mockEnvironment, permissionContext, - delegationManager: mockDelegationManager, - delegate: newDelegate, + to: newDelegate, caveats: [timestampCaveat], // chainId should be inferred from client }); @@ -382,8 +371,7 @@ describe('redelegatePermissionContextActions', () => { clientWithoutChain.redelegatePermissionContext({ environment: mockEnvironment, permissionContext, - delegationManager: mockDelegationManager, - delegate: '0x2000000000000000000000000000000000000002', + to: '0x2000000000000000000000000000000000000002', }), ).rejects.toThrow('Chain ID is required'); }); From fba187ebcf1c4329eb9aca502797cc32e65f2e2e Mon Sep 17 00:00:00 2001 From: Jeff Smale <6363749+jeffsmale90@users.noreply.github.com> Date: Tue, 5 May 2026 11:04:23 +1200 Subject: [PATCH 07/14] Add changelog entry --- packages/smart-accounts-kit/CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/smart-accounts-kit/CHANGELOG.md b/packages/smart-accounts-kit/CHANGELOG.md index 24b22dbf..043b802b 100644 --- a/packages/smart-accounts-kit/CHANGELOG.md +++ b/packages/smart-accounts-kit/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add utils and wallet actions for redelegating a `permissionContext`: ([#217](https://github.com/metamask/smart-accounts-kit/pull/217)) + ## [1.4.0] ### Added From 157251b760e08cbda5f2fbe1ff1b8c9c14f68541 Mon Sep 17 00:00:00 2001 From: Jeff Smale <6363749+jeffsmale90@users.noreply.github.com> Date: Thu, 7 May 2026 11:39:54 +1200 Subject: [PATCH 08/14] Split redelegatePermissionContext into redelegatePermissionContext and redelegateOpenPermissionContext --- .../smart-accounts-kit/src/actions/index.ts | 2 + .../actions/redelegatePermissionContext.ts | 325 ++++++++++++----- .../redelegatePermissionContext.test.ts | 340 +++++++++--------- 3 files changed, 407 insertions(+), 260 deletions(-) diff --git a/packages/smart-accounts-kit/src/actions/index.ts b/packages/smart-accounts-kit/src/actions/index.ts index c9e315aa..34613a70 100644 --- a/packages/smart-accounts-kit/src/actions/index.ts +++ b/packages/smart-accounts-kit/src/actions/index.ts @@ -53,8 +53,10 @@ export { // Redelegation actions export { redelegatePermissionContext, + redelegatePermissionContextOpen, redelegatePermissionContextActions, type RedelegatePermissionContextParameters, + type RedelegatePermissionContextOpenParameters, type RedelegatePermissionContextReturnType, } from './redelegatePermissionContext'; diff --git a/packages/smart-accounts-kit/src/actions/redelegatePermissionContext.ts b/packages/smart-accounts-kit/src/actions/redelegatePermissionContext.ts index 4c812057..1bf1c01f 100644 --- a/packages/smart-accounts-kit/src/actions/redelegatePermissionContext.ts +++ b/packages/smart-accounts-kit/src/actions/redelegatePermissionContext.ts @@ -26,7 +26,7 @@ import type { } from '../types'; import { signDelegation } from './signDelegation'; -export type RedelegatePermissionContextParameters = { +type BaseRedelegatePermissionContextParameters = { /** Account to sign the delegation with */ account?: Account | Address; /** Environment configuration */ @@ -41,10 +41,17 @@ export type RedelegatePermissionContextParameters = { caveats?: Caveats; /** Optional salt for uniqueness */ salt?: Hex; - /** The address of the delegate to redelegate to */ - to?: Address; }; +export type RedelegatePermissionContextParameters = + BaseRedelegatePermissionContextParameters & { + /** The address of the delegate to redelegate to */ + to: Address; + }; + +export type RedelegatePermissionContextOpenParameters = + BaseRedelegatePermissionContextParameters; + export type RedelegatePermissionContextReturnType = { /** The signed delegation that was created */ delegation: Delegation; @@ -52,9 +59,105 @@ export type RedelegatePermissionContextReturnType = { permissionContext: Hex; }; +type SigningClient< + TChain extends Chain | undefined, + TAccount extends Account | undefined, +> = Client & { + signTypedData: WalletClient['signTypedData']; +}; + +/** + * Shared workflow for creating, signing and prepending a redelegation to a + * permission context. The caller decides whether to produce a specific or open + * redelegation by supplying the appropriate `unsignedDelegation`. + * + * @param client - Wallet client with signing capability. + * @param options - Workflow options. + * @param options.account - Account to sign the delegation with. + * @param options.environment - Environment configuration. + * @param options.delegations - The decoded delegation chain (leaf first). + * @param options.unsignedDelegation - The new (unsigned) redelegation to prepend. + * @param options.chainId - The chain ID for the signature. + * @returns The signed delegation and updated, encoded permission context. + */ +async function signAndPrependRedelegation( + client: SigningClient, + options: { + account: Account; + environment: SmartAccountsEnvironment; + delegations: Delegation[]; + unsignedDelegation: Delegation; + chainId: number; + }, +): Promise { + const { account, environment, delegations, unsignedDelegation, chainId } = + options; + + const signature = await signDelegation(client, { + account, + delegation: unsignedDelegation, + delegationManager: environment.DelegationManager, + chainId, + }); + + const signedDelegation: Delegation = { + ...unsignedDelegation, + signature, + }; + + const newPermissionContext = encodeDelegations([ + signedDelegation, + ...delegations, + ]); + + return { + delegation: signedDelegation, + permissionContext: newPermissionContext, + }; +} + +/** + * Resolves and validates shared inputs used by both redelegation actions. + * + * @param client - Wallet client with signing capability. + * @param parameters - The base redelegation parameters. + * @returns The resolved account, decoded chain and parent (leaf) delegation. + */ +function resolveRedelegationInputs< + TChain extends Chain | undefined, + TAccount extends Account | undefined, +>( + client: SigningClient, + parameters: BaseRedelegatePermissionContextParameters, +): { + account: Account; + delegations: Delegation[]; + parentDelegation: Delegation; +} { + const { account: accountParam = client.account, permissionContext } = + parameters; + + if (!accountParam) { + throw new BaseError('Account not found. Please provide an account.'); + } + + const account = parseAccount(accountParam); + const delegations = decodeDelegations(permissionContext); + const parentDelegation = delegations[0]; + + if (!parentDelegation) { + throw new BaseError( + 'Permission context must contain at least one delegation', + ); + } + + return { account, delegations, parentDelegation }; +} + /** - * Creates a redelegation from an existing permission context and returns - * both the signed delegation and the updated permission context. + * Creates a redelegation to a specific delegate from an existing permission + * context and returns both the signed delegation and the updated permission + * context. * * This action handles the complete redelegation workflow: * 1. Extracts the leaf delegation from the permission context @@ -63,23 +166,23 @@ export type RedelegatePermissionContextReturnType = { * 4. Prepends it to the delegation chain * 5. Returns the encoded permission context * - * @param client - Wallet client with signing capability - * @param parameters - Redelegation parameters - * @returns Object containing the signed delegation and new permission context + * Use {@link redelegatePermissionContextOpen} to create an open redelegation + * (delegate set to `ANY_BENEFICIARY`). + * + * @param client - Wallet client with signing capability. + * @param parameters - Redelegation parameters. + * @returns Object containing the signed delegation and new permission context. * * @example * ```ts - * // Redelegate to a specific address * const result = await redelegatePermissionContext(walletClient, { + * environment, * permissionContext: erc7715Response.context, - * delegationManager: environment.DelegationManager, * chainId: 11155111, - * environment, - * delegate: charlie.address, + * to: charlie.address, * caveats: [timestampCaveat], * }); * - * // Use the new permission context in a transaction * await client.sendUserOperationWithDelegation({ * calls: [{ * to: contractAddress, @@ -89,119 +192,157 @@ export type RedelegatePermissionContextReturnType = { * }], * }); * ``` + */ +export async function redelegatePermissionContext< + TChain extends Chain | undefined, + TAccount extends Account | undefined, +>( + client: SigningClient, + parameters: RedelegatePermissionContextParameters, +): Promise { + const { environment, chainId, scope, caveats, salt, to } = parameters; + + const { account, delegations, parentDelegation } = resolveRedelegationInputs( + client, + parameters, + ); + + trackSmartAccountsKitFunctionCall('redelegatePermissionContext', { + chainId, + hasScope: scope !== undefined, + hasCaveats: caveats !== undefined, + }); + + const unsignedDelegation = createDelegation({ + environment, + from: account.address, + to, + scope, + caveats, + parentDelegation, + salt, + }); + + return signAndPrependRedelegation(client, { + account, + environment, + delegations, + unsignedDelegation, + chainId, + }); +} + +/** + * Creates an open redelegation (delegate set to `ANY_BENEFICIARY`) from an + * existing permission context and returns both the signed delegation and the + * updated permission context. + * + * Use {@link redelegatePermissionContext} when you want to delegate to a + * specific address. + * + * @param client - Wallet client with signing capability. + * @param parameters - Open redelegation parameters. + * @returns Object containing the signed delegation and new permission context. * * @example * ```ts - * // Create an open redelegation (anyone can use it) - * const result = await redelegatePermissionContext(walletClient, { + * const result = await redelegatePermissionContextOpen(walletClient, { + * environment, * permissionContext: erc7715Response.context, - * delegationManager: environment.DelegationManager, * chainId: 11155111, - * environment, - * // No delegate = open delegation * caveats: [limitedCallsCaveat], * }); * ``` */ -export async function redelegatePermissionContext< +export async function redelegatePermissionContextOpen< TChain extends Chain | undefined, TAccount extends Account | undefined, >( - client: Client & { - signTypedData: WalletClient['signTypedData']; - }, - parameters: RedelegatePermissionContextParameters, + client: SigningClient, + parameters: RedelegatePermissionContextOpenParameters, ): Promise { - const { - account: accountParam = client.account, - environment, - permissionContext, - chainId, - scope, - caveats, - salt, - to, - } = parameters; + const { environment, chainId, scope, caveats, salt } = parameters; - if (!accountParam) { - throw new BaseError('Account not found. Please provide an account.'); - } - - const account = parseAccount(accountParam); + const { account, delegations, parentDelegation } = resolveRedelegationInputs( + client, + parameters, + ); - trackSmartAccountsKitFunctionCall('redelegatePermissionContext', { + trackSmartAccountsKitFunctionCall('redelegatePermissionContextOpen', { chainId, - hasDelegate: Boolean(parameters.to), hasScope: scope !== undefined, hasCaveats: caveats !== undefined, }); - const delegations = decodeDelegations(permissionContext); - - const parentDelegation = delegations[0]; - - if (!parentDelegation) { - throw new BaseError( - 'Permission context must contain at least one delegation', - ); - } - - const createDelegationOptions = { + const unsignedDelegation = createOpenDelegation({ environment, from: account.address, scope, caveats, parentDelegation, salt, - }; - - const unsignedDelegation = to - ? createDelegation({ - ...createDelegationOptions, - to, - }) - : createOpenDelegation(createDelegationOptions); + }); - const signature = await signDelegation(client, { + return signAndPrependRedelegation(client, { account, - delegation: unsignedDelegation, - delegationManager: environment.DelegationManager, + environment, + delegations, + unsignedDelegation, chainId, }); +} - const signedDelegation: Delegation = { - ...unsignedDelegation, - signature, - }; - - const newPermissionContext = encodeDelegations([ - signedDelegation, - ...delegations, - ]); - - return { - delegation: signedDelegation, - permissionContext: newPermissionContext, - }; +/** + * Resolves the chain id, falling back to the client's configured chain. + * + * @param client - The client to read the chain id from. + * @param client.chain - The client's configured chain. + * @param chainId - The explicit chain id, if provided. + * @returns The resolved chain id. + */ +function resolveChainId( + client: { chain?: { id: number } | undefined }, + chainId: number | undefined, +): number { + if (chainId !== undefined) { + return chainId; + } + if (!client.chain?.id) { + throw new BaseError( + 'Chain ID is required. Either provide it in parameters or configure the client with a chain.', + ); + } + return client.chain.id; } /** * Creates redelegation actions that can be used to extend a wallet client. * - * @returns A function that can be used with wallet client extend method. + * Adds two actions: + * - `redelegatePermissionContext` for redelegating to a specific delegate. + * - `redelegatePermissionContextOpen` for creating an open redelegation. + * + * @returns A function that can be used with the wallet client `extend` method. + * * @example * ```ts * const walletClient = createWalletClient({ * chain: sepolia, - * transport: http() + * transport: http(), * }).extend(redelegatePermissionContextActions()); * - * // Now you can call it directly on the client - * const result = await walletClient.redelegatePermissionContext({ + * // Specific redelegation + * const specific = await walletClient.redelegatePermissionContext({ * environment, * permissionContext: erc7715Response.context, * to: charlie.address, * }); + * + * // Open redelegation + * const open = await walletClient.redelegatePermissionContextOpen({ + * environment, + * permissionContext: erc7715Response.context, + * }); * ``` */ export function redelegatePermissionContextActions() { @@ -209,9 +350,7 @@ export function redelegatePermissionContextActions() { TChain extends Chain | undefined, TAccount extends Account | undefined, >( - client: Client & { - signTypedData: WalletClient['signTypedData']; - }, + client: SigningClient, ) => ({ redelegatePermissionContext: async ( parameters: Omit & { @@ -219,17 +358,17 @@ export function redelegatePermissionContextActions() { }, ) => redelegatePermissionContext(client, { - chainId: - parameters.chainId ?? - (() => { - if (!client.chain?.id) { - throw new BaseError( - 'Chain ID is required. Either provide it in parameters or configure the client with a chain.', - ); - } - return client.chain.id; - })(), ...parameters, + chainId: resolveChainId(client, parameters.chainId), + }), + redelegatePermissionContextOpen: async ( + parameters: Omit & { + chainId?: number; + }, + ) => + redelegatePermissionContextOpen(client, { + ...parameters, + chainId: resolveChainId(client, parameters.chainId), }), }); } diff --git a/packages/smart-accounts-kit/test/actions/redelegatePermissionContext.test.ts b/packages/smart-accounts-kit/test/actions/redelegatePermissionContext.test.ts index 0ffc2f69..1d07f761 100644 --- a/packages/smart-accounts-kit/test/actions/redelegatePermissionContext.test.ts +++ b/packages/smart-accounts-kit/test/actions/redelegatePermissionContext.test.ts @@ -5,6 +5,7 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { redelegatePermissionContext, + redelegatePermissionContextOpen, redelegatePermissionContextActions, } from '../../src/actions/redelegatePermissionContext'; import { ScopeType } from '../../src/constants'; @@ -34,6 +35,30 @@ const mockEnvironment: SmartAccountsEnvironment = { const mockChainId = sepolia.id; +const timestampCaveat = { + enforcer: mockEnvironment.caveatEnforcers.TimestampEnforcer as Address, + terms: + '0x0000000000000000000000000000000000000000000000000000000000000001' as Hex, + args: '0x00' as Hex, +}; + +const buildRootPermissionContext = ({ + maxAmount = 1000n, +}: { maxAmount?: bigint } = {}) => { + const rootDelegation = createDelegation({ + environment: mockEnvironment, + scope: { + type: ScopeType.Erc20TransferAmount, + tokenAddress: '0xabc0000000000000000000000000000000000000', + maxAmount, + }, + to: account.address, + from: '0x1000000000000000000000000000000000000001', + }); + + return encodeDelegations([rootDelegation]); +}; + describe('redelegatePermissionContext', () => { let client: ReturnType; @@ -46,27 +71,9 @@ describe('redelegatePermissionContext', () => { }); it('should create a redelegation with a specific delegate', async () => { - const rootDelegation = createDelegation({ - environment: mockEnvironment, - scope: { - type: ScopeType.Erc20TransferAmount, - tokenAddress: '0xabc0000000000000000000000000000000000000', - maxAmount: 1000n, - }, - to: account.address, - from: '0x1000000000000000000000000000000000000001', - }); - - const permissionContext = encodeDelegations([rootDelegation]); + const permissionContext = buildRootPermissionContext(); const newDelegate: Address = '0x2000000000000000000000000000000000000002'; - const timestampCaveat = { - enforcer: mockEnvironment.caveatEnforcers.TimestampEnforcer as Address, - terms: - '0x0000000000000000000000000000000000000000000000000000000000000001' as Hex, - args: '0x00' as Hex, - }; - const result = await redelegatePermissionContext(client, { environment: mockEnvironment, permissionContext, @@ -86,64 +93,10 @@ describe('redelegatePermissionContext', () => { ); }); - it('should create an open redelegation when no delegate is specified', async () => { - const rootDelegation = createDelegation({ - environment: mockEnvironment, - scope: { - type: ScopeType.Erc20TransferAmount, - tokenAddress: '0xabc0000000000000000000000000000000000000', - maxAmount: 500n, - }, - to: account.address, - from: '0x1000000000000000000000000000000000000001', - }); - - const permissionContext = encodeDelegations([rootDelegation]); - - const timestampCaveat = { - enforcer: mockEnvironment.caveatEnforcers.TimestampEnforcer as Address, - terms: - '0x0000000000000000000000000000000000000000000000000000000000000001' as Hex, - args: '0x00' as Hex, - }; - - const result = await redelegatePermissionContext(client, { - environment: mockEnvironment, - permissionContext, - chainId: mockChainId, - // No `to` specified - creates an open delegation - caveats: [timestampCaveat], - }); - - expect(result.delegation.delegate).to.equal( - '0x0000000000000000000000000000000000000a11', // ANY_BENEFICIARY - ); - expect(result.delegation.delegator).to.equal(account.address); - expect(result.delegation.signature).to.match(/^0x[a-fA-F0-9]+$/u); - }); - it('should add additional caveats to the redelegation', async () => { - const rootDelegation = createDelegation({ - environment: mockEnvironment, - scope: { - type: ScopeType.Erc20TransferAmount, - tokenAddress: '0xabc0000000000000000000000000000000000000', - maxAmount: 1000n, - }, - to: account.address, - from: '0x1000000000000000000000000000000000000001', - }); - - const permissionContext = encodeDelegations([rootDelegation]); + const permissionContext = buildRootPermissionContext(); const newDelegate: Address = '0x2000000000000000000000000000000000000002'; - const timestampCaveat = { - enforcer: mockEnvironment.caveatEnforcers.TimestampEnforcer as Address, - terms: - '0x0000000000000000000000000000000000000000000000000000000000000001' as Hex, - args: '0x00' as Hex, - }; - const result = await redelegatePermissionContext(client, { environment: mockEnvironment, permissionContext, @@ -156,54 +109,24 @@ describe('redelegatePermissionContext', () => { }); it('should inherit scope from parent when no scope is provided', async () => { - const rootDelegation = createDelegation({ - environment: mockEnvironment, - scope: { - type: ScopeType.Erc20TransferAmount, - tokenAddress: '0xabc0000000000000000000000000000000000000', - maxAmount: 1000n, - }, - to: account.address, - from: '0x1000000000000000000000000000000000000001', - }); - - const permissionContext = encodeDelegations([rootDelegation]); + const permissionContext = buildRootPermissionContext(); const newDelegate: Address = '0x2000000000000000000000000000000000000002'; - const timestampCaveat = { - enforcer: mockEnvironment.caveatEnforcers.TimestampEnforcer as Address, - terms: - '0x0000000000000000000000000000000000000000000000000000000000000001' as Hex, - args: '0x00' as Hex, - }; - const result = await redelegatePermissionContext(client, { environment: mockEnvironment, permissionContext, chainId: mockChainId, to: newDelegate, // No scope provided - should inherit from parent - caveats: [timestampCaveat], // Add a caveat so signature doesn't fail + caveats: [timestampCaveat], }); expect(result.delegation.delegate).to.equal(newDelegate); - // Should have the additional caveat we added expect(result.delegation.caveats).to.deep.include(timestampCaveat); }); it('should allow scope override even with parent', async () => { - const rootDelegation = createDelegation({ - environment: mockEnvironment, - scope: { - type: ScopeType.Erc20TransferAmount, - tokenAddress: '0xabc0000000000000000000000000000000000000', - maxAmount: 1000n, - }, - to: account.address, - from: '0x1000000000000000000000000000000000000001', - }); - - const permissionContext = encodeDelegations([rootDelegation]); + const permissionContext = buildRootPermissionContext(); const newDelegate: Address = '0x2000000000000000000000000000000000000002'; const result = await redelegatePermissionContext(client, { @@ -243,18 +166,7 @@ describe('redelegatePermissionContext', () => { transport: http(), }); - const rootDelegation = createDelegation({ - environment: mockEnvironment, - scope: { - type: ScopeType.Erc20TransferAmount, - tokenAddress: '0xabc0000000000000000000000000000000000000', - maxAmount: 1000n, - }, - to: account.address, - from: '0x1000000000000000000000000000000000000001', - }); - - const permissionContext = encodeDelegations([rootDelegation]); + const permissionContext = buildRootPermissionContext(); await expect( redelegatePermissionContext(clientWithoutAccount, { @@ -272,27 +184,9 @@ describe('redelegatePermissionContext', () => { transport: http('https://rpc.sepolia.org'), }); - const rootDelegation = createDelegation({ - environment: mockEnvironment, - scope: { - type: ScopeType.Erc20TransferAmount, - tokenAddress: '0xabc0000000000000000000000000000000000000', - maxAmount: 1000n, - }, - to: account.address, - from: '0x1000000000000000000000000000000000000001', - }); - - const permissionContext = encodeDelegations([rootDelegation]); + const permissionContext = buildRootPermissionContext(); const newDelegate: Address = '0x2000000000000000000000000000000000000002'; - const timestampCaveat = { - enforcer: mockEnvironment.caveatEnforcers.TimestampEnforcer as Address, - terms: - '0x0000000000000000000000000000000000000000000000000000000000000001' as Hex, - args: '0x00' as Hex, - }; - const result = await redelegatePermissionContext(clientWithoutAccount, { account, environment: mockEnvironment, @@ -307,34 +201,119 @@ describe('redelegatePermissionContext', () => { }); }); -describe('redelegatePermissionContextActions', () => { - it('should extend a wallet client with redelegatePermissionContext', async () => { - const client = createWalletClient({ +describe('redelegatePermissionContextOpen', () => { + let client: ReturnType; + + beforeEach(() => { + client = createWalletClient({ account, chain: sepolia, transport: http('https://rpc.sepolia.org'), - }).extend(redelegatePermissionContextActions()); + }); + }); + + it('should create an open redelegation (delegate = ANY_BENEFICIARY)', async () => { + const permissionContext = buildRootPermissionContext({ maxAmount: 500n }); - const rootDelegation = createDelegation({ + const result = await redelegatePermissionContextOpen(client, { environment: mockEnvironment, + permissionContext, + chainId: mockChainId, + caveats: [timestampCaveat], + }); + + expect(result.delegation.delegate).to.equal( + '0x0000000000000000000000000000000000000a11', // ANY_BENEFICIARY + ); + expect(result.delegation.delegator).to.equal(account.address); + expect(result.delegation.signature).to.match(/^0x[a-fA-F0-9]+$/u); + }); + + it('should add additional caveats to the open redelegation', async () => { + const permissionContext = buildRootPermissionContext(); + + const result = await redelegatePermissionContextOpen(client, { + environment: mockEnvironment, + permissionContext, + chainId: mockChainId, + caveats: [timestampCaveat], + }); + + expect(result.delegation.caveats).to.deep.include(timestampCaveat); + }); + + it('should inherit scope from parent when no scope is provided', async () => { + const permissionContext = buildRootPermissionContext(); + + const result = await redelegatePermissionContextOpen(client, { + environment: mockEnvironment, + permissionContext, + chainId: mockChainId, + // No scope - inherits from parent. Add a caveat so the delegation isn't empty. + caveats: [timestampCaveat], + }); + + expect(result.delegation.caveats).to.deep.include(timestampCaveat); + }); + + it('should allow scope override on an open redelegation', async () => { + const permissionContext = buildRootPermissionContext(); + + const result = await redelegatePermissionContextOpen(client, { + environment: mockEnvironment, + permissionContext, + chainId: mockChainId, scope: { - type: ScopeType.Erc20TransferAmount, - tokenAddress: '0xabc0000000000000000000000000000000000000', - maxAmount: 1000n, + type: ScopeType.NativeTokenTransferAmount, + maxAmount: 500n, }, - to: account.address, - from: '0x1000000000000000000000000000000000000001', }); - const permissionContext = encodeDelegations([rootDelegation]); - const newDelegate: Address = '0x2000000000000000000000000000000000000002'; + expect(result.delegation.caveats.length).to.be.greaterThan(0); + }); - const timestampCaveat = { - enforcer: mockEnvironment.caveatEnforcers.TimestampEnforcer as Address, - terms: - '0x0000000000000000000000000000000000000000000000000000000000000001' as Hex, - args: '0x00' as Hex, - }; + it('should throw error if permission context is empty', async () => { + const emptyContext = encodeDelegations([]); + + await expect( + redelegatePermissionContextOpen(client, { + environment: mockEnvironment, + permissionContext: emptyContext, + chainId: mockChainId, + }), + ).rejects.toThrow( + 'Permission context must contain at least one delegation', + ); + }); + + it('should throw error if no account is provided', async () => { + const clientWithoutAccount = createWalletClient({ + chain: sepolia, + transport: http(), + }); + + const permissionContext = buildRootPermissionContext(); + + await expect( + redelegatePermissionContextOpen(clientWithoutAccount, { + environment: mockEnvironment, + permissionContext, + chainId: mockChainId, + }), + ).rejects.toThrow('Account not found'); + }); +}); + +describe('redelegatePermissionContextActions', () => { + it('should extend a wallet client with redelegatePermissionContext', async () => { + const client = createWalletClient({ + account, + chain: sepolia, + transport: http('https://rpc.sepolia.org'), + }).extend(redelegatePermissionContextActions()); + + const permissionContext = buildRootPermissionContext(); + const newDelegate: Address = '0x2000000000000000000000000000000000000002'; const result = await client.redelegatePermissionContext({ environment: mockEnvironment, @@ -348,24 +327,35 @@ describe('redelegatePermissionContextActions', () => { expect(result.delegation.delegator).to.equal(account.address); }); - it('should throw error if chain is not configured and chainId is not provided', async () => { - const clientWithoutChain = createWalletClient({ + it('should extend a wallet client with redelegatePermissionContextOpen', async () => { + const client = createWalletClient({ account, + chain: sepolia, transport: http('https://rpc.sepolia.org'), }).extend(redelegatePermissionContextActions()); - const rootDelegation = createDelegation({ + const permissionContext = buildRootPermissionContext(); + + const result = await client.redelegatePermissionContextOpen({ environment: mockEnvironment, - scope: { - type: ScopeType.Erc20TransferAmount, - tokenAddress: '0xabc0000000000000000000000000000000000000', - maxAmount: 1000n, - }, - to: account.address, - from: '0x1000000000000000000000000000000000000001', + permissionContext, + caveats: [timestampCaveat], + // chainId should be inferred from client }); - const permissionContext = encodeDelegations([rootDelegation]); + expect(result.delegation.delegate).to.equal( + '0x0000000000000000000000000000000000000a11', // ANY_BENEFICIARY + ); + expect(result.delegation.delegator).to.equal(account.address); + }); + + it('should throw error if chain is not configured and chainId is not provided (specific)', async () => { + const clientWithoutChain = createWalletClient({ + account, + transport: http('https://rpc.sepolia.org'), + }).extend(redelegatePermissionContextActions()); + + const permissionContext = buildRootPermissionContext(); await expect( clientWithoutChain.redelegatePermissionContext({ @@ -375,4 +365,20 @@ describe('redelegatePermissionContextActions', () => { }), ).rejects.toThrow('Chain ID is required'); }); + + it('should throw error if chain is not configured and chainId is not provided (open)', async () => { + const clientWithoutChain = createWalletClient({ + account, + transport: http('https://rpc.sepolia.org'), + }).extend(redelegatePermissionContextActions()); + + const permissionContext = buildRootPermissionContext(); + + await expect( + clientWithoutChain.redelegatePermissionContextOpen({ + environment: mockEnvironment, + permissionContext, + }), + ).rejects.toThrow('Chain ID is required'); + }); }); From ba5345df9ea67f961fa76430791b2689e18cacce Mon Sep 17 00:00:00 2001 From: Jeff Smale <6363749+jeffsmale90@users.noreply.github.com> Date: Thu, 7 May 2026 14:39:39 +1200 Subject: [PATCH 09/14] Allow insecure unrestricted delegation when signing a redelegation --- .../actions/redelegatePermissionContext.ts | 6 +++ .../redelegatePermissionContext.test.ts | 42 +++++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/packages/smart-accounts-kit/src/actions/redelegatePermissionContext.ts b/packages/smart-accounts-kit/src/actions/redelegatePermissionContext.ts index 1bf1c01f..b263b502 100644 --- a/packages/smart-accounts-kit/src/actions/redelegatePermissionContext.ts +++ b/packages/smart-accounts-kit/src/actions/redelegatePermissionContext.ts @@ -98,6 +98,12 @@ async function signAndPrependRedelegation( delegation: unsignedDelegation, delegationManager: environment.DelegationManager, chainId, + // Redelegations always inherit from a parent delegation (enforced by + // `resolveRedelegationInputs`), so the parent's caveats provide the + // restriction even when the redelegation itself adds no extra caveats. + // This mirrors `resolveCaveats`, which also allows empty caveats in this + // case. + allowInsecureUnrestrictedDelegation: true, }); const signedDelegation: Delegation = { diff --git a/packages/smart-accounts-kit/test/actions/redelegatePermissionContext.test.ts b/packages/smart-accounts-kit/test/actions/redelegatePermissionContext.test.ts index 1d07f761..a21bafb9 100644 --- a/packages/smart-accounts-kit/test/actions/redelegatePermissionContext.test.ts +++ b/packages/smart-accounts-kit/test/actions/redelegatePermissionContext.test.ts @@ -125,6 +125,29 @@ describe('redelegatePermissionContext', () => { expect(result.delegation.caveats).to.deep.include(timestampCaveat); }); + it('should sign successfully when inheriting from parent without scope or caveats', async () => { + const permissionContext = buildRootPermissionContext(); + const newDelegate: Address = '0x2000000000000000000000000000000000000002'; + + const result = await redelegatePermissionContext(client, { + environment: mockEnvironment, + permissionContext, + chainId: mockChainId, + to: newDelegate, + // No scope and no caveats - should inherit entirely from parent + // and still produce a valid signature. + }); + + expect(result.delegation.delegate).to.equal(newDelegate); + expect(result.delegation.delegator).to.equal(account.address); + expect(result.delegation.caveats).to.deep.equal([]); + expect(result.delegation.signature).to.match(/^0x[a-fA-F0-9]+$/u); + expect(result.permissionContext).to.match(/^0x[a-fA-F0-9]+$/u); + expect(result.permissionContext.length).to.be.greaterThan( + permissionContext.length, + ); + }); + it('should allow scope override even with parent', async () => { const permissionContext = buildRootPermissionContext(); const newDelegate: Address = '0x2000000000000000000000000000000000000002'; @@ -256,6 +279,25 @@ describe('redelegatePermissionContextOpen', () => { expect(result.delegation.caveats).to.deep.include(timestampCaveat); }); + it('should sign successfully when inheriting from parent without scope or caveats', async () => { + const permissionContext = buildRootPermissionContext(); + + const result = await redelegatePermissionContextOpen(client, { + environment: mockEnvironment, + permissionContext, + chainId: mockChainId, + // No scope and no caveats - should inherit entirely from parent + // and still produce a valid signature. + }); + + expect(result.delegation.delegate).to.equal( + '0x0000000000000000000000000000000000000a11', // ANY_BENEFICIARY + ); + expect(result.delegation.delegator).to.equal(account.address); + expect(result.delegation.caveats).to.deep.equal([]); + expect(result.delegation.signature).to.match(/^0x[a-fA-F0-9]+$/u); + }); + it('should allow scope override on an open redelegation', async () => { const permissionContext = buildRootPermissionContext(); From 2b5c60f2de8b93a1ea4ae58d6a19c96fefb2c1d6 Mon Sep 17 00:00:00 2001 From: Jeff Smale <6363749+jeffsmale90@users.noreply.github.com> Date: Mon, 11 May 2026 12:12:18 +1200 Subject: [PATCH 10/14] Pass parentDelegation.delegate as the 'from' address instead of the signer's address --- .../actions/redelegatePermissionContext.ts | 4 +- .../redelegatePermissionContext.test.ts | 80 +++++++++++++++++-- 2 files changed, 75 insertions(+), 9 deletions(-) diff --git a/packages/smart-accounts-kit/src/actions/redelegatePermissionContext.ts b/packages/smart-accounts-kit/src/actions/redelegatePermissionContext.ts index b263b502..80e48149 100644 --- a/packages/smart-accounts-kit/src/actions/redelegatePermissionContext.ts +++ b/packages/smart-accounts-kit/src/actions/redelegatePermissionContext.ts @@ -221,7 +221,7 @@ export async function redelegatePermissionContext< const unsignedDelegation = createDelegation({ environment, - from: account.address, + from: parentDelegation.delegate, to, scope, caveats, @@ -282,7 +282,7 @@ export async function redelegatePermissionContextOpen< const unsignedDelegation = createOpenDelegation({ environment, - from: account.address, + from: parentDelegation.delegate, scope, caveats, parentDelegation, diff --git a/packages/smart-accounts-kit/test/actions/redelegatePermissionContext.test.ts b/packages/smart-accounts-kit/test/actions/redelegatePermissionContext.test.ts index a21bafb9..56b65741 100644 --- a/packages/smart-accounts-kit/test/actions/redelegatePermissionContext.test.ts +++ b/packages/smart-accounts-kit/test/actions/redelegatePermissionContext.test.ts @@ -59,6 +59,24 @@ const buildRootPermissionContext = ({ return encodeDelegations([rootDelegation]); }; +const buildPermissionContextWithParentDelegate = ( + parentDelegate: Address, + { maxAmount = 1000n }: { maxAmount?: bigint } = {}, +) => { + const rootDelegation = createDelegation({ + environment: mockEnvironment, + scope: { + type: ScopeType.Erc20TransferAmount, + tokenAddress: '0xabc0000000000000000000000000000000000000', + maxAmount, + }, + to: parentDelegate, + from: '0x1000000000000000000000000000000000000001', + }); + + return encodeDelegations([rootDelegation]); +}; + describe('redelegatePermissionContext', () => { let client: ReturnType; @@ -83,7 +101,9 @@ describe('redelegatePermissionContext', () => { }); expect(result.delegation.delegate).to.equal(newDelegate); - expect(result.delegation.delegator).to.equal(account.address); + expect(result.delegation.delegator.toLowerCase()).to.equal( + account.address.toLowerCase(), + ); expect(result.delegation.signature).to.match(/^0x[a-fA-F0-9]+$/u); expect(result.permissionContext).to.match(/^0x[a-fA-F0-9]+$/u); @@ -139,7 +159,9 @@ describe('redelegatePermissionContext', () => { }); expect(result.delegation.delegate).to.equal(newDelegate); - expect(result.delegation.delegator).to.equal(account.address); + expect(result.delegation.delegator.toLowerCase()).to.equal( + account.address.toLowerCase(), + ); expect(result.delegation.caveats).to.deep.equal([]); expect(result.delegation.signature).to.match(/^0x[a-fA-F0-9]+$/u); expect(result.permissionContext).to.match(/^0x[a-fA-F0-9]+$/u); @@ -148,6 +170,24 @@ describe('redelegatePermissionContext', () => { ); }); + it("should use the parent delegation's delegate as from address", async () => { + const parentDelegate: Address = + '0x3000000000000000000000000000000000000003'; + const permissionContext = + buildPermissionContextWithParentDelegate(parentDelegate); + const newDelegate: Address = '0x2000000000000000000000000000000000000002'; + + const result = await redelegatePermissionContext(client, { + environment: mockEnvironment, + permissionContext, + chainId: mockChainId, + to: newDelegate, + }); + + expect(result.delegation.delegator).to.equal(parentDelegate); + expect(result.delegation.delegator).to.not.equal(account.address); + }); + it('should allow scope override even with parent', async () => { const permissionContext = buildRootPermissionContext(); const newDelegate: Address = '0x2000000000000000000000000000000000000002'; @@ -220,7 +260,9 @@ describe('redelegatePermissionContext', () => { }); expect(result.delegation.delegate).to.equal(newDelegate); - expect(result.delegation.delegator).to.equal(account.address); + expect(result.delegation.delegator.toLowerCase()).to.equal( + account.address.toLowerCase(), + ); }); }); @@ -248,7 +290,9 @@ describe('redelegatePermissionContextOpen', () => { expect(result.delegation.delegate).to.equal( '0x0000000000000000000000000000000000000a11', // ANY_BENEFICIARY ); - expect(result.delegation.delegator).to.equal(account.address); + expect(result.delegation.delegator.toLowerCase()).to.equal( + account.address.toLowerCase(), + ); expect(result.delegation.signature).to.match(/^0x[a-fA-F0-9]+$/u); }); @@ -293,11 +337,29 @@ describe('redelegatePermissionContextOpen', () => { expect(result.delegation.delegate).to.equal( '0x0000000000000000000000000000000000000a11', // ANY_BENEFICIARY ); - expect(result.delegation.delegator).to.equal(account.address); + expect(result.delegation.delegator.toLowerCase()).to.equal( + account.address.toLowerCase(), + ); expect(result.delegation.caveats).to.deep.equal([]); expect(result.delegation.signature).to.match(/^0x[a-fA-F0-9]+$/u); }); + it("should use the parent delegation's delegate as from address", async () => { + const parentDelegate: Address = + '0x3000000000000000000000000000000000000003'; + const permissionContext = + buildPermissionContextWithParentDelegate(parentDelegate); + + const result = await redelegatePermissionContextOpen(client, { + environment: mockEnvironment, + permissionContext, + chainId: mockChainId, + }); + + expect(result.delegation.delegator).to.equal(parentDelegate); + expect(result.delegation.delegator).to.not.equal(account.address); + }); + it('should allow scope override on an open redelegation', async () => { const permissionContext = buildRootPermissionContext(); @@ -366,7 +428,9 @@ describe('redelegatePermissionContextActions', () => { }); expect(result.delegation.delegate).to.equal(newDelegate); - expect(result.delegation.delegator).to.equal(account.address); + expect(result.delegation.delegator.toLowerCase()).to.equal( + account.address.toLowerCase(), + ); }); it('should extend a wallet client with redelegatePermissionContextOpen', async () => { @@ -388,7 +452,9 @@ describe('redelegatePermissionContextActions', () => { expect(result.delegation.delegate).to.equal( '0x0000000000000000000000000000000000000a11', // ANY_BENEFICIARY ); - expect(result.delegation.delegator).to.equal(account.address); + expect(result.delegation.delegator.toLowerCase()).to.equal( + account.address.toLowerCase(), + ); }); it('should throw error if chain is not configured and chainId is not provided (specific)', async () => { From 3fa9795e5d8261c3a739ba89b096e1748c0a79cd Mon Sep 17 00:00:00 2001 From: Jeff Smale <6363749+jeffsmale90@users.noreply.github.com> Date: Mon, 11 May 2026 13:56:07 +1200 Subject: [PATCH 11/14] Tighten up requirements for createDelegation and createOpenDelegation params: - parentDelegation may not be ROOT_AUTHORITY - resolveCaveats now accepts isScopeOptional rather than hasInheritance --- .../src/caveatBuilder/resolveCaveats.ts | 10 ++-- packages/smart-accounts-kit/src/delegation.ts | 33 +++++++--- .../test/caveatBuilder/resolveCaveats.test.ts | 48 +++++++-------- .../test/delegation.test.ts | 60 ++++++++++++++++++- 4 files changed, 111 insertions(+), 40 deletions(-) diff --git a/packages/smart-accounts-kit/src/caveatBuilder/resolveCaveats.ts b/packages/smart-accounts-kit/src/caveatBuilder/resolveCaveats.ts index a9d57131..73a4ac13 100644 --- a/packages/smart-accounts-kit/src/caveatBuilder/resolveCaveats.ts +++ b/packages/smart-accounts-kit/src/caveatBuilder/resolveCaveats.ts @@ -15,22 +15,22 @@ export type Caveats = CaveatBuilder | (Caveat | CoreCaveatConfiguration)[]; * @param config.environment - The environment to be used for the caveat builder. * @param config.scope - The scope to be used for the caveat builder. Optional - when not provided and redelegating, scope is inherited from the parent delegation. * @param config.caveats - The caveats to be resolved, which can be either a CaveatBuilder or an array of Caveat or CaveatConfiguration. Optional - if not provided, only scope caveats will be used. - * @param config.hasInheritance - Whether the delegation has inheritance. + * @param config.isScopeOptional - Whether the scope is optional. * @returns The resolved array of caveats. */ export const resolveCaveats = ({ environment, scope, caveats, - hasInheritance, + isScopeOptional, }: { environment: SmartAccountsEnvironment; scope?: ScopeConfig; caveats?: Caveats; - hasInheritance: boolean; + isScopeOptional: boolean; }) => { - if (!scope && !hasInheritance) { - throw new Error('Scope is required when the delegation has no inheritance'); + if (!scope && !isScopeOptional) { + throw new Error('Scope is required'); } // Create base caveat builder from scope if provided, otherwise use core caveat builder diff --git a/packages/smart-accounts-kit/src/delegation.ts b/packages/smart-accounts-kit/src/delegation.ts index 87b503dd..909b7554 100644 --- a/packages/smart-accounts-kit/src/delegation.ts +++ b/packages/smart-accounts-kit/src/delegation.ts @@ -275,20 +275,35 @@ const extractLeafDelegation = ( * @param options - The options for resolving the parent delegation. * @param options.parentDelegation - The parent delegation or its hash. * @param options.parentPermissionContext - The permission context containing the parent delegation. + * @param options.scope - The scope to be used for the delegation. * @returns The resolved parent delegation, or undefined if neither is provided. * @internal */ const resolveParentDelegation = ({ parentDelegation, parentPermissionContext, + scope, }: Pick< BaseCreateDelegationOptions, - 'parentDelegation' | 'parentPermissionContext' + 'parentDelegation' | 'parentPermissionContext' | 'scope' >): Delegation | Hex | undefined => { if (parentPermissionContext) { return extractLeafDelegation(parentPermissionContext); } + if ( + typeof parentDelegation === 'string' && + parentDelegation.toLowerCase() === ROOT_AUTHORITY.toLowerCase() + ) { + throw new Error(`Invalid parent delegation - cannot be ${ROOT_AUTHORITY}`); + } + + if (!parentDelegation && !scope) { + throw new Error( + 'Invalid arguments - must specify either parentDelegation, parentPermissionContext, or scope', + ); + } + return parentDelegation; }; @@ -345,17 +360,17 @@ export const createDelegation = ( ): Delegation => { const parentDelegation = resolveParentDelegation(options); + const hasInheritance = parentDelegation !== undefined; + const caveats = resolveCaveats({ environment: options.environment, scope: options.scope, caveats: options.caveats, - hasInheritance: Boolean(parentDelegation), + isScopeOptional: hasInheritance, }); trackSmartAccountsKitFunctionCall('createDelegation', { - hasParentDelegation: - options.parentDelegation !== undefined || - options.parentPermissionContext !== undefined, + hasInheritance, scope: options.scope?.type ?? null, caveatNames: getCaveatNames({ caveats, @@ -384,17 +399,17 @@ export const createOpenDelegation = ( ): Delegation => { const parentDelegation = resolveParentDelegation(options); + const hasInheritance = parentDelegation !== undefined; + const caveats = resolveCaveats({ environment: options.environment, scope: options.scope, caveats: options.caveats, - hasInheritance: Boolean(parentDelegation), + isScopeOptional: hasInheritance, }); trackSmartAccountsKitFunctionCall('createOpenDelegation', { - hasParentDelegation: - options.parentDelegation !== undefined || - options.parentPermissionContext !== undefined, + hasInheritance, scope: options.scope?.type ?? null, caveatNames: getCaveatNames({ caveats, diff --git a/packages/smart-accounts-kit/test/caveatBuilder/resolveCaveats.test.ts b/packages/smart-accounts-kit/test/caveatBuilder/resolveCaveats.test.ts index b6f79ead..fadee105 100644 --- a/packages/smart-accounts-kit/test/caveatBuilder/resolveCaveats.test.ts +++ b/packages/smart-accounts-kit/test/caveatBuilder/resolveCaveats.test.ts @@ -48,7 +48,7 @@ describe('resolveCaveats', () => { environment, scope: erc20Scope, caveats: caveatBuilder, - hasInheritance: false, + isScopeOptional: false, }); // 4 caveats: 2 from the scope, 2 from the builder @@ -66,7 +66,7 @@ describe('resolveCaveats', () => { environment, scope: erc20Scope, caveats: caveatBuilder, - hasInheritance: false, + isScopeOptional: false, }); expect(result).to.be.an('array'); @@ -85,7 +85,7 @@ describe('resolveCaveats', () => { environment, scope: erc20Scope, caveats: caveatBuilder as any, - hasInheritance: false, + isScopeOptional: false, }); expect(result).to.be.an('array'); @@ -101,7 +101,7 @@ describe('resolveCaveats', () => { environment, scope: erc20Scope, caveats, - hasInheritance: false, + isScopeOptional: false, }); expect(result).to.be.an('array'); @@ -129,7 +129,7 @@ describe('resolveCaveats', () => { environment, scope: erc20Scope, caveats: caveatConfigs, - hasInheritance: false, + isScopeOptional: false, }); expect(result).to.be.an('array'); @@ -140,7 +140,7 @@ describe('resolveCaveats', () => { environment, scope: erc20Scope, caveats: [], - hasInheritance: false, + isScopeOptional: false, }); expect(result.length).to.be.greaterThan(scopeOnlyResult.length); }); @@ -164,7 +164,7 @@ describe('resolveCaveats', () => { environment, scope: erc20Scope, caveats: mixedCaveats, - hasInheritance: false, + isScopeOptional: false, }); expect(result).to.be.an('array'); @@ -180,7 +180,7 @@ describe('resolveCaveats', () => { environment, scope: erc20Scope, caveats: [], - hasInheritance: false, + isScopeOptional: false, }); expect(result).to.be.an('array'); @@ -205,7 +205,7 @@ describe('resolveCaveats', () => { environment, scope: erc721Scope, caveats: [caveatConfig], - hasInheritance: false, + isScopeOptional: false, }); expect(result).to.be.an('array'); @@ -217,14 +217,14 @@ describe('resolveCaveats', () => { environment, scope: erc20Scope, caveats: [mockCaveat1], - hasInheritance: false, + isScopeOptional: false, }); const resultWithoutCaveats = resolveCaveats({ environment, scope: erc20Scope, caveats: [], - hasInheritance: false, + isScopeOptional: false, }); expect(resultWithCaveats.length).to.be.greaterThan( @@ -247,37 +247,37 @@ describe('resolveCaveats', () => { environment, scope: erc20Scope, caveats: [invalidType as any], - hasInheritance: false, + isScopeOptional: false, }); }).to.throw('Invalid caveat'); }); }); - describe('hasInheritance', () => { + describe('isScopeOptional', () => { it('should throw if scope is not provided and the delegation has no inheritance', () => { expect(() => resolveCaveats({ environment, caveats: [mockCaveat1], - hasInheritance: false, + isScopeOptional: false, }), - ).to.throw('Scope is required when the delegation has no inheritance'); + ).to.throw('Scope is required'); }); - it('should throw if neither scope nor caveats are provided and the delegation has no inheritance', () => { + it('should throw if neither scope nor caveats are provided and scope is optional', () => { expect(() => resolveCaveats({ environment, - hasInheritance: false, + isScopeOptional: false, }), - ).to.throw('Scope is required when the delegation has no inheritance'); + ).to.throw('Scope is required'); }); - it('should resolve caveats without a scope when the delegation has inheritance', () => { + it('should resolve caveats without a scope when scope is optional', () => { const result = resolveCaveats({ environment, caveats: [mockCaveat1, mockCaveat2], - hasInheritance: true, + isScopeOptional: true, }); // No scope caveats are added when the scope is inherited from the parent @@ -286,21 +286,21 @@ describe('resolveCaveats', () => { expect(result).to.deep.include(mockCaveat2); }); - it('should return an empty array when no scope, no caveats and the delegation has inheritance', () => { + it('should return an empty array when no scope, no caveats and scope is optional', () => { const result = resolveCaveats({ environment, - hasInheritance: true, + isScopeOptional: true, }); expect(result).to.deep.equal([]); }); - it('should still apply scope caveats when both scope and inheritance are provided', () => { + it('should still apply scope caveats when both scope and scope is optional', () => { const result = resolveCaveats({ environment, scope: erc20Scope, caveats: [mockCaveat1], - hasInheritance: true, + isScopeOptional: true, }); // Scope caveats are still produced when an explicit scope is provided diff --git a/packages/smart-accounts-kit/test/delegation.test.ts b/packages/smart-accounts-kit/test/delegation.test.ts index fc467a6f..5edffb82 100644 --- a/packages/smart-accounts-kit/test/delegation.test.ts +++ b/packages/smart-accounts-kit/test/delegation.test.ts @@ -225,6 +225,20 @@ describe('createDelegation', () => { }); }); + it('throws if parent delegation is root authority with differing case', () => { + const mixedCaseRootAuthority: Hex = `0x${ROOT_AUTHORITY.slice(2).toUpperCase()}`; + + expect(() => + createDelegation({ + environment: smartAccountEnvironment, + scope: erc20Scope, + to: mockDelegate, + from: mockDelegator, + parentDelegation: mixedCaseRootAuthority, + }), + ).toThrow(`Invalid parent delegation - cannot be ${ROOT_AUTHORITY}`); + }); + it('should create a delegation with caveats', () => { const caveats: Caveat[] = [ { @@ -360,7 +374,22 @@ describe('createDelegation', () => { to: mockDelegate, from: mockDelegator, } as any); - }).toThrow('Scope is required when the delegation has no inheritance'); + }).toThrow( + 'Invalid arguments - must specify either parentDelegation, parentPermissionContext, or scope', + ); + }); + + it('throws if scope is explicitly undefined and no inheritance is provided', () => { + expect(() => { + createDelegation({ + environment: smartAccountEnvironment, + to: mockDelegate, + from: mockDelegator, + scope: undefined, + } as any); + }).toThrow( + 'Invalid arguments - must specify either parentDelegation, parentPermissionContext, or scope', + ); }); describe('parentPermissionContext support', () => { @@ -558,6 +587,19 @@ describe('createOpenDelegation', () => { }); }); + it('throws if parent delegation is root authority with differing case', () => { + const mixedCaseRootAuthority: Hex = `0x${ROOT_AUTHORITY.slice(2).toUpperCase()}`; + + expect(() => + createOpenDelegation({ + environment: smartAccountEnvironment, + scope: erc20Scope, + from: mockDelegator, + parentDelegation: mixedCaseRootAuthority, + }), + ).toThrow(`Invalid parent delegation - cannot be ${ROOT_AUTHORITY}`); + }); + it('should create an open delegation with caveats', () => { const caveats: Caveat[] = [ { @@ -669,7 +711,21 @@ describe('createOpenDelegation', () => { environment: smartAccountEnvironment, from: mockDelegator, } as any); - }).toThrow('Scope is required when the delegation has no inheritance'); + }).toThrow( + 'Invalid arguments - must specify either parentDelegation, parentPermissionContext, or scope', + ); + }); + + it('throws if scope is explicitly undefined and no inheritance is provided', () => { + expect(() => { + createOpenDelegation({ + environment: smartAccountEnvironment, + from: mockDelegator, + scope: undefined, + } as any); + }).toThrow( + 'Invalid arguments - must specify either parentDelegation, parentPermissionContext, or scope', + ); }); describe('parentPermissionContext support', () => { From cb28b092bf9577e0510b76341f3281f84f4e0447 Mon Sep 17 00:00:00 2001 From: Jeff Smale <6363749+jeffsmale90@users.noreply.github.com> Date: Mon, 11 May 2026 16:42:25 +1200 Subject: [PATCH 12/14] Refactor createDelegation and createOpenDelegation to use shared resolveDelegationArgs --- packages/smart-accounts-kit/src/delegation.ts | 121 +++++++++--------- 1 file changed, 62 insertions(+), 59 deletions(-) diff --git a/packages/smart-accounts-kit/src/delegation.ts b/packages/smart-accounts-kit/src/delegation.ts index 909b7554..5c7b611d 100644 --- a/packages/smart-accounts-kit/src/delegation.ts +++ b/packages/smart-accounts-kit/src/delegation.ts @@ -18,6 +18,7 @@ import { type Caveats, resolveCaveats } from './caveatBuilder'; import type { ScopeConfig } from './caveatBuilder/scope'; import { CAVEAT_ABI_TYPE_COMPONENTS } from './caveats'; import type { + Caveat, Delegation, PermissionContext, SmartAccountsEnvironment, @@ -269,44 +270,6 @@ const extractLeafDelegation = ( return leafDelegation; }; -/** - * Resolves the parent delegation from either a direct delegation or permission context. - * - * @param options - The options for resolving the parent delegation. - * @param options.parentDelegation - The parent delegation or its hash. - * @param options.parentPermissionContext - The permission context containing the parent delegation. - * @param options.scope - The scope to be used for the delegation. - * @returns The resolved parent delegation, or undefined if neither is provided. - * @internal - */ -const resolveParentDelegation = ({ - parentDelegation, - parentPermissionContext, - scope, -}: Pick< - BaseCreateDelegationOptions, - 'parentDelegation' | 'parentPermissionContext' | 'scope' ->): Delegation | Hex | undefined => { - if (parentPermissionContext) { - return extractLeafDelegation(parentPermissionContext); - } - - if ( - typeof parentDelegation === 'string' && - parentDelegation.toLowerCase() === ROOT_AUTHORITY.toLowerCase() - ) { - throw new Error(`Invalid parent delegation - cannot be ${ROOT_AUTHORITY}`); - } - - if (!parentDelegation && !scope) { - throw new Error( - 'Invalid arguments - must specify either parentDelegation, parentPermissionContext, or scope', - ); - } - - return parentDelegation; -}; - /** * Resolves the authority for a delegation based on the parent delegation. * @@ -350,17 +313,45 @@ const getCaveatNames = ({ }; /** - * Creates a delegation with specific delegate. + * Resolves the delegation arguments from the options. * * @param options - The options for creating the delegation. - * @returns The created delegation data structure. + * @returns The resolved delegation arguments. */ -export const createDelegation = ( - options: CreateDelegationOptions, -): Delegation => { - const parentDelegation = resolveParentDelegation(options); +const resolveDelegationArgs = ( + options: BaseCreateDelegationOptions, +): { + authority: Hex; + caveats: Caveat[]; + hasInheritance: boolean; + salt: Hex; +} => { + const optionsHasParentPermissionContext = + 'parentPermissionContext' in options && options.parentPermissionContext; + + const resolvedParentDelegation = optionsHasParentPermissionContext + ? extractLeafDelegation(options.parentPermissionContext) + : options.parentDelegation; + + const authority = (() => { + if (!resolvedParentDelegation) { + if (!options.scope) { + throw new Error('Invalid arguments - must specify either parentDelegation, parentPermissionContext, or scope'); + } + return ROOT_AUTHORITY; + } + + if (typeof resolvedParentDelegation === 'string') { + if (resolvedParentDelegation.toLowerCase() === ROOT_AUTHORITY.toLowerCase()) { + throw new Error(`Invalid parent delegation - cannot be ${ROOT_AUTHORITY}`); + } + return resolvedParentDelegation; + } - const hasInheritance = parentDelegation !== undefined; + return hashDelegation(resolvedParentDelegation); + })(); + + const hasInheritance = resolvedParentDelegation !== undefined; const caveats = resolveCaveats({ environment: options.environment, @@ -369,6 +360,26 @@ export const createDelegation = ( isScopeOptional: hasInheritance, }); + return { + authority, + caveats, + hasInheritance, + salt: options.salt ?? '0x00', + }; +}; + +/** + * Creates a delegation with specific delegate. + * + * @param options - The options for creating the delegation. + * @returns The created delegation data structure. + */ +export const createDelegation = ( + options: CreateDelegationOptions, +): Delegation => { + const { authority, caveats, hasInheritance, salt } = + resolveDelegationArgs(options); + trackSmartAccountsKitFunctionCall('createDelegation', { hasInheritance, scope: options.scope?.type ?? null, @@ -381,9 +392,9 @@ export const createDelegation = ( return { delegate: options.to, delegator: options.from, - authority: resolveAuthority(parentDelegation), + authority, caveats, - salt: options.salt ?? '0x00', + salt, signature: '0x', }; }; @@ -397,16 +408,8 @@ export const createDelegation = ( export const createOpenDelegation = ( options: CreateOpenDelegationOptions, ): Delegation => { - const parentDelegation = resolveParentDelegation(options); - - const hasInheritance = parentDelegation !== undefined; - - const caveats = resolveCaveats({ - environment: options.environment, - scope: options.scope, - caveats: options.caveats, - isScopeOptional: hasInheritance, - }); + const { authority, caveats, hasInheritance, salt } = + resolveDelegationArgs(options); trackSmartAccountsKitFunctionCall('createOpenDelegation', { hasInheritance, @@ -420,9 +423,9 @@ export const createOpenDelegation = ( return { delegate: ANY_BENEFICIARY, delegator: options.from, - authority: resolveAuthority(parentDelegation), + authority, caveats, - salt: options.salt ?? '0x00', + salt, signature: '0x', }; }; From 6273f370c434716753fc725039d9d40748c5e6ed Mon Sep 17 00:00:00 2001 From: Jeff Smale <6363749+jeffsmale90@users.noreply.github.com> Date: Mon, 11 May 2026 17:02:07 +1200 Subject: [PATCH 13/14] If parent delegation is an opendelegation, from should be the signer --- .../actions/redelegatePermissionContext.ts | 31 +++++---- packages/smart-accounts-kit/src/delegation.ts | 12 +++- .../redelegatePermissionContext.test.ts | 66 ++++++++++++++++++- 3 files changed, 91 insertions(+), 18 deletions(-) diff --git a/packages/smart-accounts-kit/src/actions/redelegatePermissionContext.ts b/packages/smart-accounts-kit/src/actions/redelegatePermissionContext.ts index 80e48149..f7925029 100644 --- a/packages/smart-accounts-kit/src/actions/redelegatePermissionContext.ts +++ b/packages/smart-accounts-kit/src/actions/redelegatePermissionContext.ts @@ -18,6 +18,7 @@ import { createOpenDelegation, decodeDelegations, encodeDelegations, + ROOT_AUTHORITY, } from '../delegation'; import type { Delegation, @@ -99,7 +100,7 @@ async function signAndPrependRedelegation( delegationManager: environment.DelegationManager, chainId, // Redelegations always inherit from a parent delegation (enforced by - // `resolveRedelegationInputs`), so the parent's caveats provide the + // `resolveRedelegationArgs`), so the parent's caveats provide the // restriction even when the redelegation itself adds no extra caveats. // This mirrors `resolveCaveats`, which also allows empty caveats in this // case. @@ -129,7 +130,7 @@ async function signAndPrependRedelegation( * @param parameters - The base redelegation parameters. * @returns The resolved account, decoded chain and parent (leaf) delegation. */ -function resolveRedelegationInputs< +function resolveRedelegationArgs< TChain extends Chain | undefined, TAccount extends Account | undefined, >( @@ -139,6 +140,7 @@ function resolveRedelegationInputs< account: Account; delegations: Delegation[]; parentDelegation: Delegation; + from: Hex; } { const { account: accountParam = client.account, permissionContext } = parameters; @@ -157,7 +159,14 @@ function resolveRedelegationInputs< ); } - return { account, delegations, parentDelegation }; + const isParentOpenDelegation = + parentDelegation.authority.toLowerCase() === ROOT_AUTHORITY.toLowerCase(); + + const from = isParentOpenDelegation + ? account.address + : parentDelegation.delegate; + + return { account, delegations, parentDelegation, from }; } /** @@ -208,10 +217,8 @@ export async function redelegatePermissionContext< ): Promise { const { environment, chainId, scope, caveats, salt, to } = parameters; - const { account, delegations, parentDelegation } = resolveRedelegationInputs( - client, - parameters, - ); + const { account, delegations, parentDelegation, from } = + resolveRedelegationArgs(client, parameters); trackSmartAccountsKitFunctionCall('redelegatePermissionContext', { chainId, @@ -221,7 +228,7 @@ export async function redelegatePermissionContext< const unsignedDelegation = createDelegation({ environment, - from: parentDelegation.delegate, + from, to, scope, caveats, @@ -269,10 +276,8 @@ export async function redelegatePermissionContextOpen< ): Promise { const { environment, chainId, scope, caveats, salt } = parameters; - const { account, delegations, parentDelegation } = resolveRedelegationInputs( - client, - parameters, - ); + const { account, delegations, parentDelegation, from } = + resolveRedelegationArgs(client, parameters); trackSmartAccountsKitFunctionCall('redelegatePermissionContextOpen', { chainId, @@ -282,7 +287,7 @@ export async function redelegatePermissionContextOpen< const unsignedDelegation = createOpenDelegation({ environment, - from: parentDelegation.delegate, + from, scope, caveats, parentDelegation, diff --git a/packages/smart-accounts-kit/src/delegation.ts b/packages/smart-accounts-kit/src/delegation.ts index 5c7b611d..385c0fb3 100644 --- a/packages/smart-accounts-kit/src/delegation.ts +++ b/packages/smart-accounts-kit/src/delegation.ts @@ -336,14 +336,20 @@ const resolveDelegationArgs = ( const authority = (() => { if (!resolvedParentDelegation) { if (!options.scope) { - throw new Error('Invalid arguments - must specify either parentDelegation, parentPermissionContext, or scope'); + throw new Error( + 'Invalid arguments - must specify either parentDelegation, parentPermissionContext, or scope', + ); } return ROOT_AUTHORITY; } if (typeof resolvedParentDelegation === 'string') { - if (resolvedParentDelegation.toLowerCase() === ROOT_AUTHORITY.toLowerCase()) { - throw new Error(`Invalid parent delegation - cannot be ${ROOT_AUTHORITY}`); + if ( + resolvedParentDelegation.toLowerCase() === ROOT_AUTHORITY.toLowerCase() + ) { + throw new Error( + `Invalid parent delegation - cannot be ${ROOT_AUTHORITY}`, + ); } return resolvedParentDelegation; } diff --git a/packages/smart-accounts-kit/test/actions/redelegatePermissionContext.test.ts b/packages/smart-accounts-kit/test/actions/redelegatePermissionContext.test.ts index 56b65741..9c8f11bb 100644 --- a/packages/smart-accounts-kit/test/actions/redelegatePermissionContext.test.ts +++ b/packages/smart-accounts-kit/test/actions/redelegatePermissionContext.test.ts @@ -9,7 +9,11 @@ import { redelegatePermissionContextActions, } from '../../src/actions/redelegatePermissionContext'; import { ScopeType } from '../../src/constants'; -import { createDelegation, encodeDelegations } from '../../src/delegation'; +import { + createDelegation, + createOpenDelegation, + encodeDelegations, +} from '../../src/delegation'; import type { SmartAccountsEnvironment } from '../../src/types'; const mockPrivateKey = @@ -74,7 +78,35 @@ const buildPermissionContextWithParentDelegate = ( from: '0x1000000000000000000000000000000000000001', }); - return encodeDelegations([rootDelegation]); + const leafDelegation = createDelegation({ + environment: mockEnvironment, + scope: { + type: ScopeType.Erc20TransferAmount, + tokenAddress: '0xabc0000000000000000000000000000000000000', + maxAmount, + }, + to: parentDelegate, + from: '0x2000000000000000000000000000000000000002', + parentDelegation: rootDelegation, + }); + + return encodeDelegations([leafDelegation, rootDelegation]); +}; + +const buildPermissionContextWithOpenLeafDelegation = ({ + maxAmount = 1000n, +}: { maxAmount?: bigint } = {}) => { + const openDelegation = createOpenDelegation({ + environment: mockEnvironment, + scope: { + type: ScopeType.Erc20TransferAmount, + tokenAddress: '0xabc0000000000000000000000000000000000000', + maxAmount, + }, + from: '0x1000000000000000000000000000000000000001', + }); + + return encodeDelegations([openDelegation]); }; describe('redelegatePermissionContext', () => { @@ -188,6 +220,22 @@ describe('redelegatePermissionContext', () => { expect(result.delegation.delegator).to.not.equal(account.address); }); + it('should use the account address as from when the leaf delegation is open', async () => { + const permissionContext = buildPermissionContextWithOpenLeafDelegation(); + const newDelegate: Address = '0x2000000000000000000000000000000000000002'; + + const result = await redelegatePermissionContext(client, { + environment: mockEnvironment, + permissionContext, + chainId: mockChainId, + to: newDelegate, + }); + + expect(result.delegation.delegator.toLowerCase()).to.equal( + account.address.toLowerCase(), + ); + }); + it('should allow scope override even with parent', async () => { const permissionContext = buildRootPermissionContext(); const newDelegate: Address = '0x2000000000000000000000000000000000000002'; @@ -360,6 +408,20 @@ describe('redelegatePermissionContextOpen', () => { expect(result.delegation.delegator).to.not.equal(account.address); }); + it('should use the account address as from when the leaf delegation is open', async () => { + const permissionContext = buildPermissionContextWithOpenLeafDelegation(); + + const result = await redelegatePermissionContextOpen(client, { + environment: mockEnvironment, + permissionContext, + chainId: mockChainId, + }); + + expect(result.delegation.delegator.toLowerCase()).to.equal( + account.address.toLowerCase(), + ); + }); + it('should allow scope override on an open redelegation', async () => { const permissionContext = buildRootPermissionContext(); From 469ee5af590568dcb8362ba5ac0eca992f29f6c1 Mon Sep 17 00:00:00 2001 From: Jeff Smale <6363749+jeffsmale90@users.noreply.github.com> Date: Mon, 11 May 2026 17:24:53 +1200 Subject: [PATCH 14/14] Fix incorrect checking of root_authority rather than any_delegate --- .../actions/redelegatePermissionContext.ts | 4 +- .../redelegatePermissionContext.test.ts | 50 +++++++++++++++++++ 2 files changed, 52 insertions(+), 2 deletions(-) diff --git a/packages/smart-accounts-kit/src/actions/redelegatePermissionContext.ts b/packages/smart-accounts-kit/src/actions/redelegatePermissionContext.ts index f7925029..61d2dc23 100644 --- a/packages/smart-accounts-kit/src/actions/redelegatePermissionContext.ts +++ b/packages/smart-accounts-kit/src/actions/redelegatePermissionContext.ts @@ -14,11 +14,11 @@ import { trackSmartAccountsKitFunctionCall } from '../analytics'; import type { Caveats } from '../caveatBuilder'; import type { ScopeConfig } from '../caveatBuilder/scope'; import { + ANY_BENEFICIARY, createDelegation, createOpenDelegation, decodeDelegations, encodeDelegations, - ROOT_AUTHORITY, } from '../delegation'; import type { Delegation, @@ -160,7 +160,7 @@ function resolveRedelegationArgs< } const isParentOpenDelegation = - parentDelegation.authority.toLowerCase() === ROOT_AUTHORITY.toLowerCase(); + parentDelegation.delegate.toLowerCase() === ANY_BENEFICIARY.toLowerCase(); const from = isParentOpenDelegation ? account.address diff --git a/packages/smart-accounts-kit/test/actions/redelegatePermissionContext.test.ts b/packages/smart-accounts-kit/test/actions/redelegatePermissionContext.test.ts index 9c8f11bb..4f8dd7a7 100644 --- a/packages/smart-accounts-kit/test/actions/redelegatePermissionContext.test.ts +++ b/packages/smart-accounts-kit/test/actions/redelegatePermissionContext.test.ts @@ -109,6 +109,24 @@ const buildPermissionContextWithOpenLeafDelegation = ({ return encodeDelegations([openDelegation]); }; +const buildPermissionContextWithRootAuthorityLeafDelegate = ( + leafDelegate: Address, + { maxAmount = 1000n }: { maxAmount?: bigint } = {}, +) => { + const rootAuthorityLeafDelegation = createDelegation({ + environment: mockEnvironment, + scope: { + type: ScopeType.Erc20TransferAmount, + tokenAddress: '0xabc0000000000000000000000000000000000000', + maxAmount, + }, + to: leafDelegate, + from: '0x1000000000000000000000000000000000000001', + }); + + return encodeDelegations([rootAuthorityLeafDelegation]); +}; + describe('redelegatePermissionContext', () => { let client: ReturnType; @@ -236,6 +254,23 @@ describe('redelegatePermissionContext', () => { ); }); + it('should not treat ROOT_AUTHORITY leaf as open when delegate is not ANY_BENEFICIARY', async () => { + const leafDelegate: Address = '0x3000000000000000000000000000000000000003'; + const permissionContext = + buildPermissionContextWithRootAuthorityLeafDelegate(leafDelegate); + const newDelegate: Address = '0x2000000000000000000000000000000000000002'; + + const result = await redelegatePermissionContext(client, { + environment: mockEnvironment, + permissionContext, + chainId: mockChainId, + to: newDelegate, + }); + + expect(result.delegation.delegator).to.equal(leafDelegate); + expect(result.delegation.delegator).to.not.equal(account.address); + }); + it('should allow scope override even with parent', async () => { const permissionContext = buildRootPermissionContext(); const newDelegate: Address = '0x2000000000000000000000000000000000000002'; @@ -422,6 +457,21 @@ describe('redelegatePermissionContextOpen', () => { ); }); + it('should not treat ROOT_AUTHORITY leaf as open when delegate is not ANY_BENEFICIARY', async () => { + const leafDelegate: Address = '0x3000000000000000000000000000000000000003'; + const permissionContext = + buildPermissionContextWithRootAuthorityLeafDelegate(leafDelegate); + + const result = await redelegatePermissionContextOpen(client, { + environment: mockEnvironment, + permissionContext, + chainId: mockChainId, + }); + + expect(result.delegation.delegator).to.equal(leafDelegate); + expect(result.delegation.delegator).to.not.equal(account.address); + }); + it('should allow scope override on an open redelegation', async () => { const permissionContext = buildRootPermissionContext();