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 diff --git a/packages/smart-accounts-kit/src/actions/index.ts b/packages/smart-accounts-kit/src/actions/index.ts index 8fb39ebf..34613a70 100644 --- a/packages/smart-accounts-kit/src/actions/index.ts +++ b/packages/smart-accounts-kit/src/actions/index.ts @@ -50,6 +50,16 @@ export { type SignUserOperationReturnType, } from './signUserOperation'; +// Redelegation actions +export { + redelegatePermissionContext, + redelegatePermissionContextOpen, + redelegatePermissionContextActions, + type RedelegatePermissionContextParameters, + type RedelegatePermissionContextOpenParameters, + 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..61d2dc23 --- /dev/null +++ b/packages/smart-accounts-kit/src/actions/redelegatePermissionContext.ts @@ -0,0 +1,385 @@ +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 type { Caveats } from '../caveatBuilder'; +import type { ScopeConfig } from '../caveatBuilder/scope'; +import { + ANY_BENEFICIARY, + createDelegation, + createOpenDelegation, + decodeDelegations, + encodeDelegations, +} from '../delegation'; +import type { + Delegation, + PermissionContext, + SmartAccountsEnvironment, +} from '../types'; +import { signDelegation } from './signDelegation'; + +type BaseRedelegatePermissionContextParameters = { + /** Account to sign the delegation with */ + account?: Account | Address; + /** Environment configuration */ + environment: SmartAccountsEnvironment; + /** The permission context to redelegate from (i.e., from ERC-7715 response) */ + permissionContext: PermissionContext; + /** 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; +}; + +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; + /** The new permission context with the redelegation prepended (encoded) */ + 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, + // Redelegations always inherit from a parent delegation (enforced by + // `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. + allowInsecureUnrestrictedDelegation: true, + }); + + 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 resolveRedelegationArgs< + TChain extends Chain | undefined, + TAccount extends Account | undefined, +>( + client: SigningClient, + parameters: BaseRedelegatePermissionContextParameters, +): { + account: Account; + delegations: Delegation[]; + parentDelegation: Delegation; + from: Hex; +} { + 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', + ); + } + + const isParentOpenDelegation = + parentDelegation.delegate.toLowerCase() === ANY_BENEFICIARY.toLowerCase(); + + const from = isParentOpenDelegation + ? account.address + : parentDelegation.delegate; + + return { account, delegations, parentDelegation, from }; +} + +/** + * 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 + * 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 + * + * 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 + * const result = await redelegatePermissionContext(walletClient, { + * environment, + * permissionContext: erc7715Response.context, + * chainId: 11155111, + * to: charlie.address, + * caveats: [timestampCaveat], + * }); + * + * await client.sendUserOperationWithDelegation({ + * calls: [{ + * to: contractAddress, + * data: callData, + * permissionContext: result.permissionContext, + * delegationManager: environment.DelegationManager, + * }], + * }); + * ``` + */ +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, from } = + resolveRedelegationArgs(client, parameters); + + trackSmartAccountsKitFunctionCall('redelegatePermissionContext', { + chainId, + hasScope: scope !== undefined, + hasCaveats: caveats !== undefined, + }); + + const unsignedDelegation = createDelegation({ + environment, + from, + 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 + * const result = await redelegatePermissionContextOpen(walletClient, { + * environment, + * permissionContext: erc7715Response.context, + * chainId: 11155111, + * caveats: [limitedCallsCaveat], + * }); + * ``` + */ +export async function redelegatePermissionContextOpen< + TChain extends Chain | undefined, + TAccount extends Account | undefined, +>( + client: SigningClient, + parameters: RedelegatePermissionContextOpenParameters, +): Promise { + const { environment, chainId, scope, caveats, salt } = parameters; + + const { account, delegations, parentDelegation, from } = + resolveRedelegationArgs(client, parameters); + + trackSmartAccountsKitFunctionCall('redelegatePermissionContextOpen', { + chainId, + hasScope: scope !== undefined, + hasCaveats: caveats !== undefined, + }); + + const unsignedDelegation = createOpenDelegation({ + environment, + from, + scope, + caveats, + parentDelegation, + salt, + }); + + return signAndPrependRedelegation(client, { + account, + environment, + delegations, + unsignedDelegation, + chainId, + }); +} + +/** + * 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. + * + * 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(), + * }).extend(redelegatePermissionContextActions()); + * + * // 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() { + return < + TChain extends Chain | undefined, + TAccount extends Account | undefined, + >( + client: SigningClient, + ) => ({ + redelegatePermissionContext: async ( + parameters: Omit & { + chainId?: number; + }, + ) => + redelegatePermissionContext(client, { + ...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/src/caveatBuilder/resolveCaveats.ts b/packages/smart-accounts-kit/src/caveatBuilder/resolveCaveats.ts index 0bbeadec..73a4ac13 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,20 +13,32 @@ 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. + * @param config.isScopeOptional - Whether the scope is optional. * @returns The resolved array of caveats. */ export const resolveCaveats = ({ environment, scope, caveats, + isScopeOptional, }: { environment: SmartAccountsEnvironment; - scope: ScopeConfig; + scope?: ScopeConfig; caveats?: Caveats; + isScopeOptional: boolean; }) => { - const scopeCaveatBuilder = createCaveatBuilderFromScope(environment, scope); + if (!scope && !isScopeOptional) { + throw new Error('Scope is required'); + } + + // 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 +50,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..385c0fb3 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, @@ -211,12 +212,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 +245,31 @@ 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'); + } + + const leafDelegation = delegations[0]; + if (!leafDelegation) { + throw new Error('Failed to extract leaf delegation'); + } + + return leafDelegation; +}; + /** * Resolves the authority for a delegation based on the parent delegation. * @@ -272,6 +312,68 @@ const getCaveatNames = ({ return []; }; +/** + * Resolves the delegation arguments from the options. + * + * @param options - The options for creating the delegation. + * @returns The resolved delegation arguments. + */ +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; + } + + return hashDelegation(resolvedParentDelegation); + })(); + + const hasInheritance = resolvedParentDelegation !== undefined; + + const caveats = resolveCaveats({ + environment: options.environment, + scope: options.scope, + caveats: options.caveats, + isScopeOptional: hasInheritance, + }); + + return { + authority, + caveats, + hasInheritance, + salt: options.salt ?? '0x00', + }; +}; + /** * Creates a delegation with specific delegate. * @@ -281,11 +383,12 @@ const getCaveatNames = ({ export const createDelegation = ( options: CreateDelegationOptions, ): Delegation => { - const caveats = resolveCaveats(options); + const { authority, caveats, hasInheritance, salt } = + resolveDelegationArgs(options); trackSmartAccountsKitFunctionCall('createDelegation', { - hasParentDelegation: options.parentDelegation !== undefined, - scope: options.scope.type, + hasInheritance, + scope: options.scope?.type ?? null, caveatNames: getCaveatNames({ caveats, environment: options.environment, @@ -295,9 +398,9 @@ export const createDelegation = ( return { delegate: options.to, delegator: options.from, - authority: resolveAuthority(options.parentDelegation), + authority, caveats, - salt: options.salt ?? '0x00', + salt, signature: '0x', }; }; @@ -311,11 +414,12 @@ export const createDelegation = ( export const createOpenDelegation = ( options: CreateOpenDelegationOptions, ): Delegation => { - const caveats = resolveCaveats(options); + const { authority, caveats, hasInheritance, salt } = + resolveDelegationArgs(options); trackSmartAccountsKitFunctionCall('createOpenDelegation', { - hasParentDelegation: options.parentDelegation !== undefined, - scope: options.scope.type, + hasInheritance, + scope: options.scope?.type ?? null, caveatNames: getCaveatNames({ caveats, environment: options.environment, @@ -325,9 +429,9 @@ export const createOpenDelegation = ( return { delegate: ANY_BENEFICIARY, delegator: options.from, - authority: resolveAuthority(options.parentDelegation), + authority, caveats, - salt: options.salt ?? '0x00', + salt, 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..4f8dd7a7 --- /dev/null +++ b/packages/smart-accounts-kit/test/actions/redelegatePermissionContext.test.ts @@ -0,0 +1,604 @@ +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, + redelegatePermissionContextOpen, + redelegatePermissionContextActions, +} from '../../src/actions/redelegatePermissionContext'; +import { ScopeType } from '../../src/constants'; +import { + createDelegation, + createOpenDelegation, + encodeDelegations, +} from '../../src/delegation'; +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 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]); +}; + +const buildPermissionContextWithParentDelegate = ( + parentDelegate: Address, + { maxAmount = 1000n }: { maxAmount?: bigint } = {}, +) => { + const rootDelegation = createDelegation({ + environment: mockEnvironment, + scope: { + type: ScopeType.Erc20TransferAmount, + tokenAddress: '0xabc0000000000000000000000000000000000000', + maxAmount, + }, + to: parentDelegate, + from: '0x1000000000000000000000000000000000000001', + }); + + 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]); +}; + +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; + + beforeEach(() => { + client = createWalletClient({ + account, + chain: sepolia, + transport: http('https://rpc.sepolia.org'), + }); + }); + + it('should create a redelegation with a specific delegate', async () => { + const permissionContext = buildRootPermissionContext(); + const newDelegate: Address = '0x2000000000000000000000000000000000000002'; + + const result = await redelegatePermissionContext(client, { + environment: mockEnvironment, + permissionContext, + chainId: mockChainId, + to: newDelegate, + caveats: [timestampCaveat], + }); + + expect(result.delegation.delegate).to.equal(newDelegate); + 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); + + // Verify the permission context is valid and longer than the original + expect(result.permissionContext.length).to.be.greaterThan( + permissionContext.length, + ); + }); + + it('should add additional caveats to the redelegation', async () => { + const permissionContext = buildRootPermissionContext(); + const newDelegate: Address = '0x2000000000000000000000000000000000000002'; + + const result = await redelegatePermissionContext(client, { + environment: mockEnvironment, + permissionContext, + chainId: mockChainId, + to: newDelegate, + 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 newDelegate: Address = '0x2000000000000000000000000000000000000002'; + + const result = await redelegatePermissionContext(client, { + environment: mockEnvironment, + permissionContext, + chainId: mockChainId, + to: newDelegate, + // No scope provided - should inherit from parent + caveats: [timestampCaveat], + }); + + expect(result.delegation.delegate).to.equal(newDelegate); + 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.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); + expect(result.permissionContext.length).to.be.greaterThan( + permissionContext.length, + ); + }); + + 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 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 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'; + + const result = await redelegatePermissionContext(client, { + environment: mockEnvironment, + permissionContext, + chainId: mockChainId, + to: 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, + chainId: mockChainId, + to: '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 permissionContext = buildRootPermissionContext(); + + await expect( + redelegatePermissionContext(clientWithoutAccount, { + environment: mockEnvironment, + permissionContext, + chainId: mockChainId, + to: '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 permissionContext = buildRootPermissionContext(); + const newDelegate: Address = '0x2000000000000000000000000000000000000002'; + + const result = await redelegatePermissionContext(clientWithoutAccount, { + account, + environment: mockEnvironment, + permissionContext, + chainId: mockChainId, + to: newDelegate, + caveats: [timestampCaveat], + }); + + expect(result.delegation.delegate).to.equal(newDelegate); + expect(result.delegation.delegator.toLowerCase()).to.equal( + account.address.toLowerCase(), + ); + }); +}); + +describe('redelegatePermissionContextOpen', () => { + let client: ReturnType; + + beforeEach(() => { + client = createWalletClient({ + account, + chain: sepolia, + transport: http('https://rpc.sepolia.org'), + }); + }); + + it('should create an open redelegation (delegate = ANY_BENEFICIARY)', async () => { + const permissionContext = buildRootPermissionContext({ maxAmount: 500n }); + + 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.toLowerCase()).to.equal( + account.address.toLowerCase(), + ); + 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 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.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 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 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(); + + const result = await redelegatePermissionContextOpen(client, { + environment: mockEnvironment, + permissionContext, + chainId: mockChainId, + scope: { + type: ScopeType.NativeTokenTransferAmount, + maxAmount: 500n, + }, + }); + + expect(result.delegation.caveats.length).to.be.greaterThan(0); + }); + + 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, + permissionContext, + to: newDelegate, + caveats: [timestampCaveat], + // chainId should be inferred from client + }); + + expect(result.delegation.delegate).to.equal(newDelegate); + expect(result.delegation.delegator.toLowerCase()).to.equal( + account.address.toLowerCase(), + ); + }); + + it('should extend a wallet client with redelegatePermissionContextOpen', async () => { + const client = createWalletClient({ + account, + chain: sepolia, + transport: http('https://rpc.sepolia.org'), + }).extend(redelegatePermissionContextActions()); + + const permissionContext = buildRootPermissionContext(); + + const result = await client.redelegatePermissionContextOpen({ + environment: mockEnvironment, + permissionContext, + caveats: [timestampCaveat], + // chainId should be inferred from client + }); + + expect(result.delegation.delegate).to.equal( + '0x0000000000000000000000000000000000000a11', // ANY_BENEFICIARY + ); + 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 () => { + const clientWithoutChain = createWalletClient({ + account, + transport: http('https://rpc.sepolia.org'), + }).extend(redelegatePermissionContextActions()); + + const permissionContext = buildRootPermissionContext(); + + await expect( + clientWithoutChain.redelegatePermissionContext({ + environment: mockEnvironment, + permissionContext, + to: '0x2000000000000000000000000000000000000002', + }), + ).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'); + }); +}); diff --git a/packages/smart-accounts-kit/test/caveatBuilder/resolveCaveats.test.ts b/packages/smart-accounts-kit/test/caveatBuilder/resolveCaveats.test.ts index 871952bc..fadee105 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, + isScopeOptional: false, }); // 4 caveats: 2 from the scope, 2 from the builder @@ -65,6 +66,7 @@ describe('resolveCaveats', () => { environment, scope: erc20Scope, caveats: caveatBuilder, + isScopeOptional: false, }); expect(result).to.be.an('array'); @@ -83,6 +85,7 @@ describe('resolveCaveats', () => { environment, scope: erc20Scope, caveats: caveatBuilder as any, + isScopeOptional: false, }); expect(result).to.be.an('array'); @@ -98,6 +101,7 @@ describe('resolveCaveats', () => { environment, scope: erc20Scope, caveats, + isScopeOptional: false, }); expect(result).to.be.an('array'); @@ -125,6 +129,7 @@ describe('resolveCaveats', () => { environment, scope: erc20Scope, caveats: caveatConfigs, + isScopeOptional: false, }); expect(result).to.be.an('array'); @@ -135,6 +140,7 @@ describe('resolveCaveats', () => { environment, scope: erc20Scope, caveats: [], + isScopeOptional: false, }); expect(result.length).to.be.greaterThan(scopeOnlyResult.length); }); @@ -158,6 +164,7 @@ describe('resolveCaveats', () => { environment, scope: erc20Scope, caveats: mixedCaveats, + isScopeOptional: false, }); expect(result).to.be.an('array'); @@ -173,6 +180,7 @@ describe('resolveCaveats', () => { environment, scope: erc20Scope, caveats: [], + isScopeOptional: false, }); expect(result).to.be.an('array'); @@ -197,6 +205,7 @@ describe('resolveCaveats', () => { environment, scope: erc721Scope, caveats: [caveatConfig], + isScopeOptional: false, }); expect(result).to.be.an('array'); @@ -208,12 +217,14 @@ describe('resolveCaveats', () => { environment, scope: erc20Scope, caveats: [mockCaveat1], + isScopeOptional: false, }); const resultWithoutCaveats = resolveCaveats({ environment, scope: erc20Scope, caveats: [], + isScopeOptional: false, }); expect(resultWithCaveats.length).to.be.greaterThan( @@ -236,8 +247,65 @@ describe('resolveCaveats', () => { environment, scope: erc20Scope, caveats: [invalidType as any], + isScopeOptional: false, }); }).to.throw('Invalid caveat'); }); }); + + describe('isScopeOptional', () => { + it('should throw if scope is not provided and the delegation has no inheritance', () => { + expect(() => + resolveCaveats({ + environment, + caveats: [mockCaveat1], + isScopeOptional: false, + }), + ).to.throw('Scope is required'); + }); + + it('should throw if neither scope nor caveats are provided and scope is optional', () => { + expect(() => + resolveCaveats({ + environment, + isScopeOptional: false, + }), + ).to.throw('Scope is required'); + }); + + it('should resolve caveats without a scope when scope is optional', () => { + const result = resolveCaveats({ + environment, + caveats: [mockCaveat1, mockCaveat2], + isScopeOptional: 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 scope is optional', () => { + const result = resolveCaveats({ + environment, + isScopeOptional: true, + }); + + expect(result).to.deep.equal([]); + }); + + it('should still apply scope caveats when both scope and scope is optional', () => { + const result = resolveCaveats({ + environment, + scope: erc20Scope, + caveats: [mockCaveat1], + isScopeOptional: 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 cf482830..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[] = [ { @@ -352,6 +366,185 @@ describe('createDelegation', () => { signature: '0x', }); }); + + it('throws if no scope no inheritance is provided', () => { + expect(() => { + createDelegation({ + environment: smartAccountEnvironment, + to: mockDelegate, + from: mockDelegator, + } as any); + }).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', () => { + 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', () => { @@ -394,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[] = [ { @@ -498,6 +704,55 @@ describe('createOpenDelegation', () => { signature: '0x', }); }); + + it('throws if no scope no inheritance is provided', () => { + expect(() => { + createOpenDelegation({ + environment: smartAccountEnvironment, + from: mockDelegator, + } as any); + }).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', () => { + 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', () => {