From 620a9e210a0afa02ffcc920e9c15774d4381eeab Mon Sep 17 00:00:00 2001 From: Jeff Smale <6363749+jeffsmale90@users.noreply.github.com> Date: Mon, 25 May 2026 13:55:01 +1200 Subject: [PATCH 01/17] Adds createDelegationProvider utility function --- .../src/experimental/delegationProvider.ts | 254 ++++++++++++++ .../src/experimental/index.ts | 7 + .../experimental/delegationProvider.test.ts | 311 ++++++++++++++++++ 3 files changed, 572 insertions(+) create mode 100644 packages/smart-accounts-kit/src/experimental/delegationProvider.ts create mode 100644 packages/smart-accounts-kit/test/experimental/delegationProvider.test.ts diff --git a/packages/smart-accounts-kit/src/experimental/delegationProvider.ts b/packages/smart-accounts-kit/src/experimental/delegationProvider.ts new file mode 100644 index 00000000..5e9016e9 --- /dev/null +++ b/packages/smart-accounts-kit/src/experimental/delegationProvider.ts @@ -0,0 +1,254 @@ +import { createRedeemerTerms } from '@metamask/delegation-core'; +import { bytesToHex, type Account, type Address, type Hex } from 'viem'; + +import type { Caveats } from '../caveatBuilder'; +import { resolveCaveats } from '../caveatBuilder'; +import type { ScopeConfig } from '../caveatBuilder/scope'; +import { ScopeType } from '../constants'; +import { + createOpenDelegation, + decodeDelegations, + encodeDelegations, + prepareSignDelegationTypedData, +} from '../delegation'; +import type { + Caveat, + Delegation, + PermissionContext, + SmartAccountsEnvironment, +} from '../types'; + +/** + * Payment requirement details supplied by an x402 server challenge. + * + * These values are used to scope and construct the delegation that will be + * returned by a {@link DelegationProvider}. + */ +export type PaymentRequirements = { + scheme: string; + network: string; + asset: string; + amount: string; + payTo: string; + maxTimeoutSeconds: number; + extra?: Record; +}; + +/** + * Encoded delegation response consumed by x402 payment flows. + * + * The payload includes the delegation manager address, the encoded permission + * context to use for execution, and the delegator account that signed it. + */ +export type DelegationProviderPaymentPayload = { + delegationManager: `0x${string}`; + permissionContext: `0x${string}`; + delegator: `0x${string}`; +}; + +/** + * Function that turns payment requirements into a signed delegation payload. + */ +export type DelegationProvider = ( + paymentRequirements: PaymentRequirements, +) => Promise; + +type Deferred = (requirements: PaymentRequirements) => TResult; + +type MaybeDeferred = TResult | Deferred; + +const resolveMaybeDeferred = async ( + maybeDeferred: MaybeDeferred, + requirements: PaymentRequirements, +): Promise => { + if (typeof maybeDeferred === 'function') { + return (maybeDeferred as Deferred)(requirements); + } + + return maybeDeferred; +}; + +/** + * Configuration used to create a DelegationProvider. + * + * `account` is required and is used for signing the delegation. + */ +export type DelegationProviderConfig = { + account: Account; + environment: SmartAccountsEnvironment; + from?: Hex; + salt?: Hex; + caveats?: MaybeDeferred; + parentPermissionContext?: MaybeDeferred; +}; + +const createSalt = (): Hex => { + if (typeof globalThis.crypto?.getRandomValues !== 'function') { + throw new Error('Secure randomness is unavailable in this runtime'); + } + + const randomValues = globalThis.crypto.getRandomValues(new Uint8Array(32)); + return bytesToHex(randomValues); +}; + +type DelegationCreationContext = { + account: Account; + delegationManager: Address; + existingDelegations: Delegation[]; + createDelegationConfig: Parameters[0]; +}; + +const resolveDelegationCreationContext = async ( + config: DelegationProviderConfig, + requirements: PaymentRequirements, +): Promise => { + const caveatsConfig = await resolveMaybeDeferred( + config.caveats, + requirements, + ); + const parentPermissionContext = await resolveMaybeDeferred( + config.parentPermissionContext, + requirements, + ); + + const { account } = config; + const from = config.from ?? account.address; + const salt = config.salt ?? createSalt(); + + const scope = { + type: ScopeType.Erc20TransferAmount, + tokenAddress: requirements.asset as Hex, + maxAmount: BigInt(requirements.amount), + } as ScopeConfig; + + const facilitatorAddresses = requirements.extra?.facilitatorAddresses as + | Hex[] + | undefined; + + if (!facilitatorAddresses || facilitatorAddresses.length === 0) { + throw new Error('Facilitator addresses are required'); + } + + const { + DelegationManager: delegationManager, + caveatEnforcers: { RedeemerEnforcer }, + } = config.environment; + + if (!RedeemerEnforcer) { + throw new Error('RedeemerEnforcer not found in environment'); + } + + const redeemerCaveat: Caveat = { + enforcer: RedeemerEnforcer, + terms: createRedeemerTerms({ redeemers: facilitatorAddresses }), + args: '0x', + }; + + const caveats = [ + ...resolveCaveats({ + environment: config.environment, + caveats: caveatsConfig, + // we need to resolve the caveats so that we can add more, the scope is added in the createDelegation call + isScopeOptional: true, + }), + redeemerCaveat, + ]; + + let createDelegationConfig: Parameters[0]; + let existingDelegations: Delegation[]; + + if (parentPermissionContext) { + const decodedPermissionContext = decodeDelegations(parentPermissionContext); + const parentDelegation = decodedPermissionContext[0]; + + if (!parentDelegation) { + throw new Error('Parent permission context is not a valid delegation'); + } + + createDelegationConfig = { + environment: config.environment, + from, + caveats, + salt, + scope, + parentDelegation, + }; + + existingDelegations = decodedPermissionContext; + } else { + createDelegationConfig = { + environment: config.environment, + from, + caveats, + salt, + scope, + }; + + existingDelegations = []; + } + + return { + account, + delegationManager, + existingDelegations, + createDelegationConfig, + }; +}; + +/** + * Creates a delegation provider function for x402 payment requirements. + * + * The returned provider resolves deferred config values using incoming payment + * requirements, creates an open delegation, signs it, and returns an encoded + * permission context payload. + * + * @param config - Delegation creation and signing configuration. + * @returns A provider that maps payment requirements to a signed delegation payload. + */ +export function createDelegationProvider( + config: DelegationProviderConfig, +): DelegationProvider { + return async ( + requirements: PaymentRequirements, + ): Promise => { + const { + account, + delegationManager, + existingDelegations, + createDelegationConfig, + } = await resolveDelegationCreationContext(config, requirements); + + const delegation = createOpenDelegation(createDelegationConfig); + + // todo: extract chainId from the network parameter + const chainId = requirements.network as unknown as number; + + const typedData = prepareSignDelegationTypedData({ + delegationManager, + chainId, + delegation, + }); + + if (!account.signTypedData) { + throw new Error('Account does not support signTypedData'); + } + + const signature = await account.signTypedData(typedData); + + const signedDelegation = { + ...delegation, + signature, + }; + + const permissionContext = encodeDelegations([ + signedDelegation, + ...existingDelegations, + ]); + + return { + delegationManager, + permissionContext, + delegator: delegation.delegator, + }; + }; +} diff --git a/packages/smart-accounts-kit/src/experimental/index.ts b/packages/smart-accounts-kit/src/experimental/index.ts index 83ca0171..87e4129b 100644 --- a/packages/smart-accounts-kit/src/experimental/index.ts +++ b/packages/smart-accounts-kit/src/experimental/index.ts @@ -5,3 +5,10 @@ export { type Environment, type DelegationStorageConfig, } from './delegationStorage'; +export { + createDelegationProvider, + type DelegationProvider, + type DelegationProviderConfig, + type DelegationProviderPaymentPayload, + type PaymentRequirements as DelegationProviderPaymentRequirements, +} from './delegationProvider'; diff --git a/packages/smart-accounts-kit/test/experimental/delegationProvider.test.ts b/packages/smart-accounts-kit/test/experimental/delegationProvider.test.ts new file mode 100644 index 00000000..99cde6f9 --- /dev/null +++ b/packages/smart-accounts-kit/test/experimental/delegationProvider.test.ts @@ -0,0 +1,311 @@ +import type { Account, Hex } from 'viem'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { + DelegationProviderConfig, + PaymentRequirements, +} from '../../src/experimental/delegationProvider'; +import type { SmartAccountsEnvironment } from '../../src/types'; + +const caveatBuilderMocks = vi.hoisted(() => ({ + resolveCaveats: vi.fn(), +})); + +const delegationMocks = vi.hoisted(() => ({ + createOpenDelegation: vi.fn(), + decodeDelegations: vi.fn(), + encodeDelegations: vi.fn(), + prepareSignDelegationTypedData: vi.fn(), +})); + +const delegationCoreMocks = vi.hoisted(() => ({ + createRedeemerTerms: vi.fn(), +})); + +vi.mock('../../src/caveatBuilder', () => ({ + resolveCaveats: caveatBuilderMocks.resolveCaveats, +})); + +vi.mock('../../src/delegation', () => delegationMocks); + +vi.mock('@metamask/delegation-core', () => delegationCoreMocks); + +const { createDelegationProvider } = + await import('../../src/experimental/delegationProvider'); + +const mockDelegationManager = + '0x1000000000000000000000000000000000000001' as Hex; +const mockRedeemerEnforcer = + '0x2000000000000000000000000000000000000002' as Hex; +const mockDelegator = '0x3000000000000000000000000000000000000003' as Hex; +const mockSignature = '0xabc123' as Hex; +const mockTypedData = { domain: {}, message: {} }; +const mockPermissionContext = '0xfeed' as Hex; +const mockAuthority = + '0x0000000000000000000000000000000000000000000000000000000000000000' as Hex; + +const mockRequirements: PaymentRequirements = { + scheme: 'exact', + network: '1', + asset: '0x4000000000000000000000000000000000000004', + amount: '500', + payTo: '0x5000000000000000000000000000000000000005', + maxTimeoutSeconds: 120, + extra: { + facilitatorAddresses: [ + '0x6000000000000000000000000000000000000006', + '0x7000000000000000000000000000000000000007', + ], + }, +}; + +const createMockAccount = (overrides: Partial = {}): Account => + ({ + address: '0x8000000000000000000000000000000000000008', + signTypedData: vi.fn().mockResolvedValue(mockSignature), + ...overrides, + }) as unknown as Account; + +const createMockEnvironment = ( + overrides: Partial = {}, +): SmartAccountsEnvironment => + ({ + DelegationManager: mockDelegationManager, + caveatEnforcers: { + RedeemerEnforcer: mockRedeemerEnforcer, + }, + ...overrides, + }) as SmartAccountsEnvironment; + +describe('createDelegationProvider', () => { + beforeEach(() => { + vi.clearAllMocks(); + + caveatBuilderMocks.resolveCaveats.mockReturnValue([ + { + enforcer: '0x9000000000000000000000000000000000000009', + terms: '0x11', + args: '0x', + }, + ]); + delegationCoreMocks.createRedeemerTerms.mockReturnValue('0x22'); + delegationMocks.createOpenDelegation.mockReturnValue({ + delegate: '0xa00000000000000000000000000000000000000a', + delegator: mockDelegator, + authority: mockAuthority, + caveats: [], + salt: '0x33', + }); + delegationMocks.prepareSignDelegationTypedData.mockReturnValue( + mockTypedData, + ); + delegationMocks.encodeDelegations.mockReturnValue(mockPermissionContext); + delegationMocks.decodeDelegations.mockReturnValue([]); + }); + + it('creates and signs a delegation using default from/salt values', async () => { + const account = createMockAccount(); + const environment = createMockEnvironment(); + const provider = createDelegationProvider({ + account, + environment, + caveats: [], + }); + + const result = await provider(mockRequirements); + + expect(caveatBuilderMocks.resolveCaveats).toHaveBeenCalledWith({ + environment, + caveats: [], + isScopeOptional: true, + }); + expect(delegationCoreMocks.createRedeemerTerms).toHaveBeenCalledWith({ + redeemers: mockRequirements.extra?.facilitatorAddresses, + }); + + const createCallArg = + delegationMocks.createOpenDelegation.mock.calls[0]?.[0]; + expect(createCallArg.from).toBe(account.address); + expect(createCallArg.salt).toMatch(/^0x[0-9a-f]{64}$/u); + expect(createCallArg.scope).toStrictEqual({ + type: 'erc20TransferAmount', + tokenAddress: mockRequirements.asset, + maxAmount: BigInt(mockRequirements.amount), + }); + + expect(delegationMocks.prepareSignDelegationTypedData).toHaveBeenCalledWith( + { + delegationManager: mockDelegationManager, + chainId: mockRequirements.network, + delegation: delegationMocks.createOpenDelegation.mock.results[0]?.value, + }, + ); + expect(account.signTypedData).toHaveBeenCalledWith(mockTypedData); + expect(delegationMocks.encodeDelegations).toHaveBeenCalledWith([ + { + ...delegationMocks.createOpenDelegation.mock.results[0]?.value, + signature: mockSignature, + }, + ]); + expect(result).toStrictEqual({ + delegationManager: mockDelegationManager, + permissionContext: mockPermissionContext, + delegator: mockDelegator, + }); + }); + + it('uses deferred caveats and deferred parent permission context', async () => { + const account = createMockAccount(); + const environment = createMockEnvironment(); + const deferredCaveats = vi.fn(() => []); + const deferredParentPermissionContext = vi.fn(() => '0xdeferred' as Hex); + const parentDelegation = { + delegate: '0xde100000000000000000000000000000000000e1', + delegator: '0xde200000000000000000000000000000000000e2', + authority: mockAuthority, + caveats: [], + salt: '0x99', + signature: '0xaa', + }; + delegationMocks.decodeDelegations.mockReturnValue([parentDelegation]); + const provider = createDelegationProvider({ + account, + environment, + caveats: deferredCaveats, + parentPermissionContext: deferredParentPermissionContext, + }); + + await provider(mockRequirements); + + expect(deferredCaveats).toHaveBeenCalledWith(mockRequirements); + expect(deferredParentPermissionContext).toHaveBeenCalledWith( + mockRequirements, + ); + expect(caveatBuilderMocks.resolveCaveats).toHaveBeenCalledWith({ + environment, + caveats: [], + isScopeOptional: true, + }); + expect(delegationMocks.decodeDelegations).toHaveBeenCalledWith( + '0xdeferred', + ); + }); + + it('uses explicit from/salt and includes parent delegation when provided', async () => { + const account = createMockAccount(); + const environment = createMockEnvironment(); + const from = '0xb00000000000000000000000000000000000000b' as Hex; + const salt = + '0xc00000000000000000000000000000000000000000000000000000000000000c' as Hex; + const parentPermissionContext = '0xd0' as Hex; + const parentDelegation = { + delegate: '0xd1000000000000000000000000000000000000d1', + delegator: '0xd2000000000000000000000000000000000000d2', + authority: mockAuthority, + caveats: [], + salt: '0x44', + signature: '0x55', + }; + const existingDelegation = { + delegate: '0xd3000000000000000000000000000000000000d3', + delegator: '0xd4000000000000000000000000000000000000d4', + authority: mockAuthority, + caveats: [], + salt: '0x66', + signature: '0x77', + }; + delegationMocks.decodeDelegations.mockReturnValue([ + parentDelegation, + existingDelegation, + ]); + + const provider = createDelegationProvider({ + account, + environment, + from, + salt, + caveats: [], + parentPermissionContext, + }); + + await provider(mockRequirements); + + expect(delegationMocks.decodeDelegations).toHaveBeenCalledWith( + parentPermissionContext, + ); + expect(delegationMocks.createOpenDelegation).toHaveBeenCalledWith( + expect.objectContaining({ + from, + salt, + parentDelegation, + }), + ); + expect(delegationMocks.encodeDelegations).toHaveBeenCalledWith([ + { + ...delegationMocks.createOpenDelegation.mock.results[0]?.value, + signature: mockSignature, + }, + parentDelegation, + existingDelegation, + ]); + }); + + it('throws when facilitator addresses are missing', async () => { + const account = createMockAccount(); + const provider = createDelegationProvider({ + account, + environment: createMockEnvironment(), + }); + + await expect( + provider({ + ...mockRequirements, + extra: undefined, + }), + ).rejects.toThrow('Facilitator addresses are required'); + }); + + it('throws when redeemer enforcer is missing from environment', async () => { + const account = createMockAccount(); + const provider = createDelegationProvider({ + account, + environment: createMockEnvironment({ + caveatEnforcers: { + RedeemerEnforcer: undefined as unknown as Hex, + }, + }), + }); + + await expect(provider(mockRequirements)).rejects.toThrow( + 'RedeemerEnforcer not found in environment', + ); + }); + + it('throws when parent permission context does not decode into a delegation', async () => { + const account = createMockAccount(); + delegationMocks.decodeDelegations.mockReturnValue([]); + const provider = createDelegationProvider({ + account, + environment: createMockEnvironment(), + parentPermissionContext: '0xee' as Hex, + }); + + await expect(provider(mockRequirements)).rejects.toThrow( + 'Parent permission context is not a valid delegation', + ); + }); + + it('throws when account does not support typed data signing', async () => { + const account = createMockAccount({ + signTypedData: undefined, + }); + const provider = createDelegationProvider({ + account, + environment: createMockEnvironment(), + } as DelegationProviderConfig); + + await expect(provider(mockRequirements)).rejects.toThrow( + 'Account does not support signTypedData', + ); + }); +}); From 1b0177caf54c852184df800df83c22bee85bbe0c Mon Sep 17 00:00:00 2001 From: Jeff Smale <6363749+jeffsmale90@users.noreply.github.com> Date: Mon, 25 May 2026 14:05:58 +1200 Subject: [PATCH 02/17] Add generateSalt to @metamask/smart-accounts-kit/utils --- .../src/experimental/delegationProvider.ts | 14 +++----------- packages/smart-accounts-kit/src/utils.ts | 1 + packages/smart-accounts-kit/src/utils/index.ts | 16 ++++++++++++++++ packages/smart-accounts-kit/test/utils.ts | 1 + 4 files changed, 21 insertions(+), 11 deletions(-) diff --git a/packages/smart-accounts-kit/src/experimental/delegationProvider.ts b/packages/smart-accounts-kit/src/experimental/delegationProvider.ts index 5e9016e9..f57e8498 100644 --- a/packages/smart-accounts-kit/src/experimental/delegationProvider.ts +++ b/packages/smart-accounts-kit/src/experimental/delegationProvider.ts @@ -1,5 +1,5 @@ import { createRedeemerTerms } from '@metamask/delegation-core'; -import { bytesToHex, type Account, type Address, type Hex } from 'viem'; +import type { Account, Address, Hex } from 'viem'; import type { Caveats } from '../caveatBuilder'; import { resolveCaveats } from '../caveatBuilder'; @@ -17,6 +17,7 @@ import type { PermissionContext, SmartAccountsEnvironment, } from '../types'; +import { generateSalt } from '../utils/index'; /** * Payment requirement details supplied by an x402 server challenge. @@ -82,15 +83,6 @@ export type DelegationProviderConfig = { parentPermissionContext?: MaybeDeferred; }; -const createSalt = (): Hex => { - if (typeof globalThis.crypto?.getRandomValues !== 'function') { - throw new Error('Secure randomness is unavailable in this runtime'); - } - - const randomValues = globalThis.crypto.getRandomValues(new Uint8Array(32)); - return bytesToHex(randomValues); -}; - type DelegationCreationContext = { account: Account; delegationManager: Address; @@ -113,7 +105,7 @@ const resolveDelegationCreationContext = async ( const { account } = config; const from = config.from ?? account.address; - const salt = config.salt ?? createSalt(); + const salt = config.salt ?? generateSalt(); const scope = { type: ScopeType.Erc20TransferAmount, diff --git a/packages/smart-accounts-kit/src/utils.ts b/packages/smart-accounts-kit/src/utils.ts index cf84c477..6683348a 100644 --- a/packages/smart-accounts-kit/src/utils.ts +++ b/packages/smart-accounts-kit/src/utils.ts @@ -75,3 +75,4 @@ export function toHexOrThrow( return toHex(value); } + diff --git a/packages/smart-accounts-kit/src/utils/index.ts b/packages/smart-accounts-kit/src/utils/index.ts index 18074e30..c9f14338 100644 --- a/packages/smart-accounts-kit/src/utils/index.ts +++ b/packages/smart-accounts-kit/src/utils/index.ts @@ -1,3 +1,5 @@ +import { toHex } from 'viem'; + export { decodeCaveat } from '../caveats'; export { @@ -44,3 +46,17 @@ export { export type { CoreCaveatBuilder, CaveatBuilderConfig } from '../caveatBuilder'; export { createCaveatBuilder, CaveatBuilder } from '../caveatBuilder'; + +/** + * Generates a cryptographically secure random salt. + * + * @returns A 32-byte hex salt. + */ +export function generateSalt() { + if (typeof globalThis.crypto?.getRandomValues !== 'function') { + throw new Error('Secure randomness is unavailable in this runtime'); + } + + const randomValues = globalThis.crypto.getRandomValues(new Uint8Array(32)); + return toHex(randomValues); +} diff --git a/packages/smart-accounts-kit/test/utils.ts b/packages/smart-accounts-kit/test/utils.ts index 4280cf53..486555a7 100644 --- a/packages/smart-accounts-kit/test/utils.ts +++ b/packages/smart-accounts-kit/test/utils.ts @@ -9,6 +9,7 @@ import { import { Implementation } from '../src/constants'; import { deploySmartAccountsEnvironment } from '../src/smartAccountsEnvironment'; import type { ToMetaMaskSmartAccountParameters } from '../src/types'; +export { generateSalt } from '../src/utils/index'; export const OWNER_ACCOUNT: Account = privateKeyToAccount(generatePrivateKey()); export const DEPLOYED_ADDRESS = privateKeyToAddress(generatePrivateKey()); From 051f5030e3edd9f57b13248f57033932753ce50d Mon Sep 17 00:00:00 2001 From: Jeff Smale <6363749+jeffsmale90@users.noreply.github.com> Date: Mon, 25 May 2026 15:43:42 +1200 Subject: [PATCH 03/17] Rename DelegationProvider to x402DelegationProvider --- .../src/experimental/index.ts | 12 ++++----- ...nProvider.ts => x402DelegationProvider.ts} | 22 ++++++++-------- ...test.ts => x402DelegationProvider.test.ts} | 26 +++++++++---------- 3 files changed, 30 insertions(+), 30 deletions(-) rename packages/smart-accounts-kit/src/experimental/{delegationProvider.ts => x402DelegationProvider.ts} (92%) rename packages/smart-accounts-kit/test/experimental/{delegationProvider.test.ts => x402DelegationProvider.test.ts} (93%) diff --git a/packages/smart-accounts-kit/src/experimental/index.ts b/packages/smart-accounts-kit/src/experimental/index.ts index 87e4129b..6f99b3e8 100644 --- a/packages/smart-accounts-kit/src/experimental/index.ts +++ b/packages/smart-accounts-kit/src/experimental/index.ts @@ -6,9 +6,9 @@ export { type DelegationStorageConfig, } from './delegationStorage'; export { - createDelegationProvider, - type DelegationProvider, - type DelegationProviderConfig, - type DelegationProviderPaymentPayload, - type PaymentRequirements as DelegationProviderPaymentRequirements, -} from './delegationProvider'; + createx402DelegationProvider, + type x402DelegationProvider, + type x402DelegationProviderConfig, + type x402DelegationProviderPaymentPayload, + type PaymentRequirements as x402DelegationProviderPaymentRequirements, +} from './x402DelegationProvider'; diff --git a/packages/smart-accounts-kit/src/experimental/delegationProvider.ts b/packages/smart-accounts-kit/src/experimental/x402DelegationProvider.ts similarity index 92% rename from packages/smart-accounts-kit/src/experimental/delegationProvider.ts rename to packages/smart-accounts-kit/src/experimental/x402DelegationProvider.ts index f57e8498..656ff9f8 100644 --- a/packages/smart-accounts-kit/src/experimental/delegationProvider.ts +++ b/packages/smart-accounts-kit/src/experimental/x402DelegationProvider.ts @@ -23,7 +23,7 @@ import { generateSalt } from '../utils/index'; * Payment requirement details supplied by an x402 server challenge. * * These values are used to scope and construct the delegation that will be - * returned by a {@link DelegationProvider}. + * returned by a {@link x402DelegationProvider}. */ export type PaymentRequirements = { scheme: string; @@ -41,7 +41,7 @@ export type PaymentRequirements = { * The payload includes the delegation manager address, the encoded permission * context to use for execution, and the delegator account that signed it. */ -export type DelegationProviderPaymentPayload = { +export type x402DelegationProviderPaymentPayload = { delegationManager: `0x${string}`; permissionContext: `0x${string}`; delegator: `0x${string}`; @@ -50,9 +50,9 @@ export type DelegationProviderPaymentPayload = { /** * Function that turns payment requirements into a signed delegation payload. */ -export type DelegationProvider = ( +export type x402DelegationProvider = ( paymentRequirements: PaymentRequirements, -) => Promise; +) => Promise; type Deferred = (requirements: PaymentRequirements) => TResult; @@ -70,11 +70,11 @@ const resolveMaybeDeferred = async ( }; /** - * Configuration used to create a DelegationProvider. + * Configuration used to create a x402DelegationProvider. * * `account` is required and is used for signing the delegation. */ -export type DelegationProviderConfig = { +export type x402DelegationProviderConfig = { account: Account; environment: SmartAccountsEnvironment; from?: Hex; @@ -91,7 +91,7 @@ type DelegationCreationContext = { }; const resolveDelegationCreationContext = async ( - config: DelegationProviderConfig, + config: x402DelegationProviderConfig, requirements: PaymentRequirements, ): Promise => { const caveatsConfig = await resolveMaybeDeferred( @@ -197,12 +197,12 @@ const resolveDelegationCreationContext = async ( * @param config - Delegation creation and signing configuration. * @returns A provider that maps payment requirements to a signed delegation payload. */ -export function createDelegationProvider( - config: DelegationProviderConfig, -): DelegationProvider { +export function createx402DelegationProvider( + config: x402DelegationProviderConfig, +): x402DelegationProvider { return async ( requirements: PaymentRequirements, - ): Promise => { + ): Promise => { const { account, delegationManager, diff --git a/packages/smart-accounts-kit/test/experimental/delegationProvider.test.ts b/packages/smart-accounts-kit/test/experimental/x402DelegationProvider.test.ts similarity index 93% rename from packages/smart-accounts-kit/test/experimental/delegationProvider.test.ts rename to packages/smart-accounts-kit/test/experimental/x402DelegationProvider.test.ts index 99cde6f9..c2c68ec1 100644 --- a/packages/smart-accounts-kit/test/experimental/delegationProvider.test.ts +++ b/packages/smart-accounts-kit/test/experimental/x402DelegationProvider.test.ts @@ -2,9 +2,9 @@ import type { Account, Hex } from 'viem'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { - DelegationProviderConfig, + x402DelegationProviderConfig, PaymentRequirements, -} from '../../src/experimental/delegationProvider'; +} from '../../src/experimental/x402DelegationProvider'; import type { SmartAccountsEnvironment } from '../../src/types'; const caveatBuilderMocks = vi.hoisted(() => ({ @@ -30,8 +30,8 @@ vi.mock('../../src/delegation', () => delegationMocks); vi.mock('@metamask/delegation-core', () => delegationCoreMocks); -const { createDelegationProvider } = - await import('../../src/experimental/delegationProvider'); +const { createx402DelegationProvider } = + await import('../../src/experimental/x402DelegationProvider'); const mockDelegationManager = '0x1000000000000000000000000000000000000001' as Hex; @@ -77,7 +77,7 @@ const createMockEnvironment = ( ...overrides, }) as SmartAccountsEnvironment; -describe('createDelegationProvider', () => { +describe('createx402DelegationProvider', () => { beforeEach(() => { vi.clearAllMocks(); @@ -106,7 +106,7 @@ describe('createDelegationProvider', () => { it('creates and signs a delegation using default from/salt values', async () => { const account = createMockAccount(); const environment = createMockEnvironment(); - const provider = createDelegationProvider({ + const provider = createx402DelegationProvider({ account, environment, caveats: [], @@ -168,7 +168,7 @@ describe('createDelegationProvider', () => { signature: '0xaa', }; delegationMocks.decodeDelegations.mockReturnValue([parentDelegation]); - const provider = createDelegationProvider({ + const provider = createx402DelegationProvider({ account, environment, caveats: deferredCaveats, @@ -219,7 +219,7 @@ describe('createDelegationProvider', () => { existingDelegation, ]); - const provider = createDelegationProvider({ + const provider = createx402DelegationProvider({ account, environment, from, @@ -252,7 +252,7 @@ describe('createDelegationProvider', () => { it('throws when facilitator addresses are missing', async () => { const account = createMockAccount(); - const provider = createDelegationProvider({ + const provider = createx402DelegationProvider({ account, environment: createMockEnvironment(), }); @@ -267,7 +267,7 @@ describe('createDelegationProvider', () => { it('throws when redeemer enforcer is missing from environment', async () => { const account = createMockAccount(); - const provider = createDelegationProvider({ + const provider = createx402DelegationProvider({ account, environment: createMockEnvironment({ caveatEnforcers: { @@ -284,7 +284,7 @@ describe('createDelegationProvider', () => { it('throws when parent permission context does not decode into a delegation', async () => { const account = createMockAccount(); delegationMocks.decodeDelegations.mockReturnValue([]); - const provider = createDelegationProvider({ + const provider = createx402DelegationProvider({ account, environment: createMockEnvironment(), parentPermissionContext: '0xee' as Hex, @@ -299,10 +299,10 @@ describe('createDelegationProvider', () => { const account = createMockAccount({ signTypedData: undefined, }); - const provider = createDelegationProvider({ + const provider = createx402DelegationProvider({ account, environment: createMockEnvironment(), - } as DelegationProviderConfig); + } as x402DelegationProviderConfig); await expect(provider(mockRequirements)).rejects.toThrow( 'Account does not support signTypedData', From 2a3eb625902c3d9edd17107a75d72a2b6e12dae4 Mon Sep 17 00:00:00 2001 From: Jeff Smale <6363749+jeffsmale90@users.noreply.github.com> Date: Mon, 25 May 2026 15:44:47 +1200 Subject: [PATCH 04/17] Lint fixing --- .../src/experimental/x402DelegationProvider.ts | 2 +- packages/smart-accounts-kit/src/utils.ts | 1 - packages/smart-accounts-kit/test/utils.ts | 3 ++- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/smart-accounts-kit/src/experimental/x402DelegationProvider.ts b/packages/smart-accounts-kit/src/experimental/x402DelegationProvider.ts index 656ff9f8..361d2671 100644 --- a/packages/smart-accounts-kit/src/experimental/x402DelegationProvider.ts +++ b/packages/smart-accounts-kit/src/experimental/x402DelegationProvider.ts @@ -17,7 +17,7 @@ import type { PermissionContext, SmartAccountsEnvironment, } from '../types'; -import { generateSalt } from '../utils/index'; +import { generateSalt } from '../utils/'; /** * Payment requirement details supplied by an x402 server challenge. diff --git a/packages/smart-accounts-kit/src/utils.ts b/packages/smart-accounts-kit/src/utils.ts index 6683348a..cf84c477 100644 --- a/packages/smart-accounts-kit/src/utils.ts +++ b/packages/smart-accounts-kit/src/utils.ts @@ -75,4 +75,3 @@ export function toHexOrThrow( return toHex(value); } - diff --git a/packages/smart-accounts-kit/test/utils.ts b/packages/smart-accounts-kit/test/utils.ts index 486555a7..a9100014 100644 --- a/packages/smart-accounts-kit/test/utils.ts +++ b/packages/smart-accounts-kit/test/utils.ts @@ -9,7 +9,8 @@ import { import { Implementation } from '../src/constants'; import { deploySmartAccountsEnvironment } from '../src/smartAccountsEnvironment'; import type { ToMetaMaskSmartAccountParameters } from '../src/types'; -export { generateSalt } from '../src/utils/index'; + +export { generateSalt } from '../src/utils/'; export const OWNER_ACCOUNT: Account = privateKeyToAccount(generatePrivateKey()); export const DEPLOYED_ADDRESS = privateKeyToAddress(generatePrivateKey()); From 8fd72fcfa5343c1a7418733af5c97a6504a75064 Mon Sep 17 00:00:00 2001 From: Jeff Smale <6363749+jeffsmale90@users.noreply.github.com> Date: Mon, 25 May 2026 17:18:35 +1200 Subject: [PATCH 05/17] Improves facilitatorAddress / redeemer caveat handling: - if no facilitatorAddresses are specified, at least one redeemer caveat must be either in the parentPermissionContext or specified caveats - if facilitatorAddresses are specified, only add a redeemer caveat if the redeemers are not already constrained to the facilitator addresses --- .../experimental/x402DelegationProvider.ts | 131 ++++++++++---- .../x402DelegationProvider.test.ts | 161 +++++++++++++++++- 2 files changed, 255 insertions(+), 37 deletions(-) diff --git a/packages/smart-accounts-kit/src/experimental/x402DelegationProvider.ts b/packages/smart-accounts-kit/src/experimental/x402DelegationProvider.ts index 361d2671..ae53dab2 100644 --- a/packages/smart-accounts-kit/src/experimental/x402DelegationProvider.ts +++ b/packages/smart-accounts-kit/src/experimental/x402DelegationProvider.ts @@ -1,4 +1,7 @@ -import { createRedeemerTerms } from '@metamask/delegation-core'; +import { + createRedeemerTerms, + decodeRedeemerTerms, +} from '@metamask/delegation-core'; import type { Account, Address, Hex } from 'viem'; import type { Caveats } from '../caveatBuilder'; @@ -90,6 +93,87 @@ type DelegationCreationContext = { createDelegationConfig: Parameters[0]; }; +type ResolveX402DelegationCaveatsParams = { + environment: SmartAccountsEnvironment; + caveatsConfig: Caveats | undefined; + existingDelegations: Delegation[]; + facilitatorAddresses: Hex[] | undefined; +}; + +const resolveX402DelegationCaveats = ({ + environment, + caveatsConfig, + existingDelegations, + facilitatorAddresses, +}: ResolveX402DelegationCaveatsParams): Caveat[] => { + const { + caveatEnforcers: { RedeemerEnforcer: redeemerEnforcer }, + } = environment; + + if (!redeemerEnforcer) { + throw new Error('RedeemerEnforcer not found in environment'); + } + + const redeemerAddressLowerCase = redeemerEnforcer.toLowerCase(); + + const caveats = resolveCaveats({ + environment, + caveats: caveatsConfig, + // we need to resolve the caveats so that we can add more, the scope is added in the createDelegation call + isScopeOptional: true, + }); + + const existingRedeemerCaveats = [ + ...caveats, + ...existingDelegations.flatMap((enforcer) => enforcer.caveats), + ].filter( + ({ enforcer }) => enforcer.toLowerCase() === redeemerAddressLowerCase, + ); + + const hasRedeemerCaveatInChainOrSpecifiedCaveats = + existingRedeemerCaveats.length > 0; + + if (!facilitatorAddresses || facilitatorAddresses.length === 0) { + // if no facilitators are specified, we must at least have some constraints on allowed redeemer + if (!hasRedeemerCaveatInChainOrSpecifiedCaveats) { + throw new Error( + 'Redeemer must be constrained, either in the specified `caveats`, `parentPermissionContext`, or the `PaymentRequirements` as `extra.facilitatorAddresses`.', + ); + } + + return caveats; + } + + const facilitatorAddressesLowerCase = facilitatorAddresses.map((address) => + address.toLowerCase(), + ); + + // if any of the existing redeemer caveats only specify facilitator addresses, we don't need to add a new caveat + const hasSufficientlyConstrainedRedeemerCaveat = existingRedeemerCaveats.some( + (caveat) => { + const allowedRedeemerAddresses = decodeRedeemerTerms( + caveat.terms, + ).redeemers.map((redeemer) => redeemer.toLowerCase()); + + return allowedRedeemerAddresses.every((allowedRedeemerAddress) => + facilitatorAddressesLowerCase.includes(allowedRedeemerAddress), + ); + }, + ); + + if (hasSufficientlyConstrainedRedeemerCaveat) { + return caveats; + } + + const redeemerCaveat: Caveat = { + enforcer: redeemerEnforcer, + terms: createRedeemerTerms({ redeemers: facilitatorAddresses }), + args: '0x', + }; + + return [...caveats, redeemerCaveat]; +}; + const resolveDelegationCreationContext = async ( config: x402DelegationProviderConfig, requirements: PaymentRequirements, @@ -117,41 +201,22 @@ const resolveDelegationCreationContext = async ( | Hex[] | undefined; - if (!facilitatorAddresses || facilitatorAddresses.length === 0) { - throw new Error('Facilitator addresses are required'); - } - - const { - DelegationManager: delegationManager, - caveatEnforcers: { RedeemerEnforcer }, - } = config.environment; - - if (!RedeemerEnforcer) { - throw new Error('RedeemerEnforcer not found in environment'); - } - - const redeemerCaveat: Caveat = { - enforcer: RedeemerEnforcer, - terms: createRedeemerTerms({ redeemers: facilitatorAddresses }), - args: '0x', - }; + const existingDelegations = parentPermissionContext + ? decodeDelegations(parentPermissionContext) + : []; - const caveats = [ - ...resolveCaveats({ - environment: config.environment, - caveats: caveatsConfig, - // we need to resolve the caveats so that we can add more, the scope is added in the createDelegation call - isScopeOptional: true, - }), - redeemerCaveat, - ]; + const { DelegationManager: delegationManager } = config.environment; + const caveats = resolveX402DelegationCaveats({ + environment: config.environment, + caveatsConfig, + existingDelegations, + facilitatorAddresses, + }); let createDelegationConfig: Parameters[0]; - let existingDelegations: Delegation[]; if (parentPermissionContext) { - const decodedPermissionContext = decodeDelegations(parentPermissionContext); - const parentDelegation = decodedPermissionContext[0]; + const parentDelegation = existingDelegations[0]; if (!parentDelegation) { throw new Error('Parent permission context is not a valid delegation'); @@ -165,8 +230,6 @@ const resolveDelegationCreationContext = async ( scope, parentDelegation, }; - - existingDelegations = decodedPermissionContext; } else { createDelegationConfig = { environment: config.environment, @@ -175,8 +238,6 @@ const resolveDelegationCreationContext = async ( salt, scope, }; - - existingDelegations = []; } return { diff --git a/packages/smart-accounts-kit/test/experimental/x402DelegationProvider.test.ts b/packages/smart-accounts-kit/test/experimental/x402DelegationProvider.test.ts index c2c68ec1..1ac48569 100644 --- a/packages/smart-accounts-kit/test/experimental/x402DelegationProvider.test.ts +++ b/packages/smart-accounts-kit/test/experimental/x402DelegationProvider.test.ts @@ -20,6 +20,11 @@ const delegationMocks = vi.hoisted(() => ({ const delegationCoreMocks = vi.hoisted(() => ({ createRedeemerTerms: vi.fn(), + decodeRedeemerTerms: vi.fn(), +})); + +const utilsMocks = vi.hoisted(() => ({ + generateSalt: vi.fn(), })); vi.mock('../../src/caveatBuilder', () => ({ @@ -29,6 +34,7 @@ vi.mock('../../src/caveatBuilder', () => ({ vi.mock('../../src/delegation', () => delegationMocks); vi.mock('@metamask/delegation-core', () => delegationCoreMocks); +vi.mock('../../src/utils/', () => utilsMocks); const { createx402DelegationProvider } = await import('../../src/experimental/x402DelegationProvider'); @@ -41,6 +47,8 @@ const mockDelegator = '0x3000000000000000000000000000000000000003' as Hex; const mockSignature = '0xabc123' as Hex; const mockTypedData = { domain: {}, message: {} }; const mockPermissionContext = '0xfeed' as Hex; +const mockGeneratedSalt = + '0x1111111111111111111111111111111111111111111111111111111111111111' as Hex; const mockAuthority = '0x0000000000000000000000000000000000000000000000000000000000000000' as Hex; @@ -89,6 +97,25 @@ describe('createx402DelegationProvider', () => { }, ]); delegationCoreMocks.createRedeemerTerms.mockReturnValue('0x22'); + delegationCoreMocks.decodeRedeemerTerms.mockImplementation((terms: Hex) => { + if (terms === '0x01') { + return { + redeemers: ['0x6000000000000000000000000000000000000006'], + }; + } + + if (terms === '0x02') { + return { + redeemers: [ + '0x6000000000000000000000000000000000000006', + '0x9000000000000000000000000000000000000009', + ], + }; + } + + return { redeemers: [] }; + }); + utilsMocks.generateSalt.mockReturnValue(mockGeneratedSalt); delegationMocks.createOpenDelegation.mockReturnValue({ delegate: '0xa00000000000000000000000000000000000000a', delegator: mockDelegator, @@ -126,7 +153,7 @@ describe('createx402DelegationProvider', () => { const createCallArg = delegationMocks.createOpenDelegation.mock.calls[0]?.[0]; expect(createCallArg.from).toBe(account.address); - expect(createCallArg.salt).toMatch(/^0x[0-9a-f]{64}$/u); + expect(createCallArg.salt).toBe(mockGeneratedSalt); expect(createCallArg.scope).toStrictEqual({ type: 'erc20TransferAmount', tokenAddress: mockRequirements.asset, @@ -250,6 +277,136 @@ describe('createx402DelegationProvider', () => { ]); }); + describe('redeemer caveat resolution', () => { + it('throws when facilitators are missing and no redeemer caveat exists', async () => { + const account = createMockAccount(); + const provider = createx402DelegationProvider({ + account, + environment: createMockEnvironment(), + }); + + await expect( + provider({ + ...mockRequirements, + extra: undefined, + }), + ).rejects.toThrow('Redeemer must be constrained'); + }); + + it('allows missing facilitators when parent chain has a redeemer caveat', async () => { + const account = createMockAccount(); + delegationMocks.decodeDelegations.mockReturnValue([ + { + delegate: '0xde100000000000000000000000000000000000e1', + delegator: '0xde200000000000000000000000000000000000e2', + authority: mockAuthority, + caveats: [ + { + enforcer: mockRedeemerEnforcer, + terms: '0x01', + args: '0x', + }, + ], + salt: '0x99', + signature: '0xaa', + }, + ]); + const provider = createx402DelegationProvider({ + account, + environment: createMockEnvironment(), + parentPermissionContext: '0xee' as Hex, + }); + + await expect( + provider({ + ...mockRequirements, + extra: undefined, + }), + ).resolves.toStrictEqual({ + delegationManager: mockDelegationManager, + permissionContext: mockPermissionContext, + delegator: mockDelegator, + }); + expect(delegationCoreMocks.createRedeemerTerms).not.toHaveBeenCalled(); + }); + + it('does not add a redeemer caveat when existing redeemers are subset of facilitators', async () => { + const account = createMockAccount(); + delegationMocks.decodeDelegations.mockReturnValue([ + { + delegate: '0xde100000000000000000000000000000000000e1', + delegator: '0xde200000000000000000000000000000000000e2', + authority: mockAuthority, + caveats: [ + { + enforcer: mockRedeemerEnforcer, + terms: '0x01', + args: '0x', + }, + ], + salt: '0x99', + signature: '0xaa', + }, + ]); + const provider = createx402DelegationProvider({ + account, + environment: createMockEnvironment(), + parentPermissionContext: '0xee' as Hex, + }); + + await provider(mockRequirements); + + const createCallArg = + delegationMocks.createOpenDelegation.mock.calls[0]?.[0]; + expect(createCallArg.caveats).toStrictEqual([ + { + enforcer: '0x9000000000000000000000000000000000000009', + terms: '0x11', + args: '0x', + }, + ]); + expect(delegationCoreMocks.createRedeemerTerms).not.toHaveBeenCalled(); + }); + + it('adds a redeemer caveat when existing redeemers are not subset of facilitators', async () => { + const account = createMockAccount(); + delegationMocks.decodeDelegations.mockReturnValue([ + { + delegate: '0xde100000000000000000000000000000000000e1', + delegator: '0xde200000000000000000000000000000000000e2', + authority: mockAuthority, + caveats: [ + { + enforcer: mockRedeemerEnforcer, + terms: '0x02', + args: '0x', + }, + ], + salt: '0x99', + signature: '0xaa', + }, + ]); + const provider = createx402DelegationProvider({ + account, + environment: createMockEnvironment(), + parentPermissionContext: '0xee' as Hex, + }); + + await provider(mockRequirements); + + expect(delegationCoreMocks.createRedeemerTerms).toHaveBeenCalledWith({ + redeemers: mockRequirements.extra?.facilitatorAddresses, + }); + const createCallArg = + delegationMocks.createOpenDelegation.mock.calls[0]?.[0]; + expect(createCallArg.caveats).toContainEqual({ + enforcer: mockRedeemerEnforcer, + terms: '0x22', + args: '0x', + }); + }); + }); + it('throws when facilitator addresses are missing', async () => { const account = createMockAccount(); const provider = createx402DelegationProvider({ @@ -262,7 +419,7 @@ describe('createx402DelegationProvider', () => { ...mockRequirements, extra: undefined, }), - ).rejects.toThrow('Facilitator addresses are required'); + ).rejects.toThrow('Redeemer must be constrained'); }); it('throws when redeemer enforcer is missing from environment', async () => { From 92fcf254c67a1e07d16b867127baca7b9eab93f3 Mon Sep 17 00:00:00 2001 From: Jeff Smale <6363749+jeffsmale90@users.noreply.github.com> Date: Mon, 25 May 2026 17:28:57 +1200 Subject: [PATCH 06/17] Minor refactor and tidy up --- .../experimental/x402DelegationProvider.ts | 46 ++++++++++--------- 1 file changed, 24 insertions(+), 22 deletions(-) diff --git a/packages/smart-accounts-kit/src/experimental/x402DelegationProvider.ts b/packages/smart-accounts-kit/src/experimental/x402DelegationProvider.ts index ae53dab2..906909e0 100644 --- a/packages/smart-accounts-kit/src/experimental/x402DelegationProvider.ts +++ b/packages/smart-accounts-kit/src/experimental/x402DelegationProvider.ts @@ -93,19 +93,24 @@ type DelegationCreationContext = { createDelegationConfig: Parameters[0]; }; -type ResolveX402DelegationCaveatsParams = { +type Resolvex402DelegationCaveatsParams = { environment: SmartAccountsEnvironment; caveatsConfig: Caveats | undefined; existingDelegations: Delegation[]; facilitatorAddresses: Hex[] | undefined; }; -const resolveX402DelegationCaveats = ({ +const normalizeAddress = (address: Hex): string => address.toLowerCase(); + +const isSubset = (subset: string[], superset: string[]): boolean => + subset.every((item) => superset.includes(item)); + +const resolvex402DelegationCaveats = ({ environment, caveatsConfig, existingDelegations, facilitatorAddresses, -}: ResolveX402DelegationCaveatsParams): Caveat[] => { +}: Resolvex402DelegationCaveatsParams): Caveat[] => { const { caveatEnforcers: { RedeemerEnforcer: redeemerEnforcer }, } = environment; @@ -114,28 +119,28 @@ const resolveX402DelegationCaveats = ({ throw new Error('RedeemerEnforcer not found in environment'); } - const redeemerAddressLowerCase = redeemerEnforcer.toLowerCase(); + const redeemerAddressNormalized = normalizeAddress(redeemerEnforcer); const caveats = resolveCaveats({ environment, caveats: caveatsConfig, - // we need to resolve the caveats so that we can add more, the scope is added in the createDelegation call + // Resolve caveats first so we can append a redeemer caveat when needed. + // Scope is still attached later during delegation creation. isScopeOptional: true, }); - const existingRedeemerCaveats = [ + const redeemerCaveats = [ ...caveats, - ...existingDelegations.flatMap((enforcer) => enforcer.caveats), + ...existingDelegations.flatMap((delegation) => delegation.caveats), ].filter( - ({ enforcer }) => enforcer.toLowerCase() === redeemerAddressLowerCase, + ({ enforcer }) => normalizeAddress(enforcer) === redeemerAddressNormalized, ); - const hasRedeemerCaveatInChainOrSpecifiedCaveats = - existingRedeemerCaveats.length > 0; + const hasExistingRedeemerConstraint = redeemerCaveats.length > 0; if (!facilitatorAddresses || facilitatorAddresses.length === 0) { - // if no facilitators are specified, we must at least have some constraints on allowed redeemer - if (!hasRedeemerCaveatInChainOrSpecifiedCaveats) { + // Without facilitators, a redeemer constraint must already exist. + if (!hasExistingRedeemerConstraint) { throw new Error( 'Redeemer must be constrained, either in the specified `caveats`, `parentPermissionContext`, or the `PaymentRequirements` as `extra.facilitatorAddresses`.', ); @@ -144,20 +149,17 @@ const resolveX402DelegationCaveats = ({ return caveats; } - const facilitatorAddressesLowerCase = facilitatorAddresses.map((address) => - address.toLowerCase(), - ); + const facilitatorAddressesLowerCase = + facilitatorAddresses.map(normalizeAddress); - // if any of the existing redeemer caveats only specify facilitator addresses, we don't need to add a new caveat - const hasSufficientlyConstrainedRedeemerCaveat = existingRedeemerCaveats.some( + // If an existing redeemer caveat is already within facilitator bounds, no new caveat is needed. + const hasSufficientlyConstrainedRedeemerCaveat = redeemerCaveats.some( (caveat) => { const allowedRedeemerAddresses = decodeRedeemerTerms( caveat.terms, - ).redeemers.map((redeemer) => redeemer.toLowerCase()); + ).redeemers.map(normalizeAddress); - return allowedRedeemerAddresses.every((allowedRedeemerAddress) => - facilitatorAddressesLowerCase.includes(allowedRedeemerAddress), - ); + return isSubset(allowedRedeemerAddresses, facilitatorAddressesLowerCase); }, ); @@ -206,7 +208,7 @@ const resolveDelegationCreationContext = async ( : []; const { DelegationManager: delegationManager } = config.environment; - const caveats = resolveX402DelegationCaveats({ + const caveats = resolvex402DelegationCaveats({ environment: config.environment, caveatsConfig, existingDelegations, From 27efa737bd56e334556e2fc913bbf4ce7070bd69 Mon Sep 17 00:00:00 2001 From: Jeff Smale <6363749+jeffsmale90@users.noreply.github.com> Date: Tue, 26 May 2026 10:32:30 +1200 Subject: [PATCH 07/17] Add payee caveat - only if there isn't an existing caveat constraining the payee - also minor refactor to put various x402 utilities into x402DelegationProviderUtils.ts --- packages/smart-accounts-kit/package.json | 1 + .../src/experimental/index.ts | 3 +- .../experimental/x402DelegationProvider.ts | 223 ++--------- .../x402DelegationProviderUtils.ts | 373 ++++++++++++++++++ .../x402DelegationProvider.test.ts | 210 +++------- .../x402DelegationProviderUtils.test.ts | 285 +++++++++++++ packages/x402/src/x402Client.ts | 1 + packages/x402/src/x402Server.ts | 1 - yarn.lock | 1 + 9 files changed, 734 insertions(+), 364 deletions(-) create mode 100644 packages/smart-accounts-kit/src/experimental/x402DelegationProviderUtils.ts create mode 100644 packages/smart-accounts-kit/test/experimental/x402DelegationProviderUtils.test.ts diff --git a/packages/smart-accounts-kit/package.json b/packages/smart-accounts-kit/package.json index d92cab69..27e54514 100644 --- a/packages/smart-accounts-kit/package.json +++ b/packages/smart-accounts-kit/package.json @@ -128,6 +128,7 @@ "@metamask/delegation-abis": "^1.1.0", "@metamask/delegation-core": "^2.2.1", "@metamask/delegation-deployments": "^1.4.0", + "@metamask/utils": "^11.4.0", "openapi-fetch": "^0.13.5", "ox": "0.8.1" }, diff --git a/packages/smart-accounts-kit/src/experimental/index.ts b/packages/smart-accounts-kit/src/experimental/index.ts index 6f99b3e8..c07d33c4 100644 --- a/packages/smart-accounts-kit/src/experimental/index.ts +++ b/packages/smart-accounts-kit/src/experimental/index.ts @@ -10,5 +10,6 @@ export { type x402DelegationProvider, type x402DelegationProviderConfig, type x402DelegationProviderPaymentPayload, - type PaymentRequirements as x402DelegationProviderPaymentRequirements, + type PaymentRequirements, + type MaybeDeferred, } from './x402DelegationProvider'; diff --git a/packages/smart-accounts-kit/src/experimental/x402DelegationProvider.ts b/packages/smart-accounts-kit/src/experimental/x402DelegationProvider.ts index 906909e0..59510d31 100644 --- a/packages/smart-accounts-kit/src/experimental/x402DelegationProvider.ts +++ b/packages/smart-accounts-kit/src/experimental/x402DelegationProvider.ts @@ -1,26 +1,14 @@ -import { - createRedeemerTerms, - decodeRedeemerTerms, -} from '@metamask/delegation-core'; -import type { Account, Address, Hex } from 'viem'; +import { parseCaipChainId } from '@metamask/utils'; +import type { Account, Hex } from 'viem'; import type { Caveats } from '../caveatBuilder'; -import { resolveCaveats } from '../caveatBuilder'; -import type { ScopeConfig } from '../caveatBuilder/scope'; -import { ScopeType } from '../constants'; import { createOpenDelegation, - decodeDelegations, encodeDelegations, prepareSignDelegationTypedData, } from '../delegation'; -import type { - Caveat, - Delegation, - PermissionContext, - SmartAccountsEnvironment, -} from '../types'; -import { generateSalt } from '../utils/'; +import type { PermissionContext, SmartAccountsEnvironment } from '../types'; +import { resolveDelegationCreationContext } from './x402DelegationProviderUtils'; /** * Payment requirement details supplied by an x402 server challenge. @@ -45,9 +33,9 @@ export type PaymentRequirements = { * context to use for execution, and the delegator account that signed it. */ export type x402DelegationProviderPaymentPayload = { - delegationManager: `0x${string}`; - permissionContext: `0x${string}`; - delegator: `0x${string}`; + delegationManager: Hex; + permissionContext: Hex; + delegator: Hex; }; /** @@ -57,20 +45,14 @@ export type x402DelegationProvider = ( paymentRequirements: PaymentRequirements, ) => Promise; -type Deferred = (requirements: PaymentRequirements) => TResult; - -type MaybeDeferred = TResult | Deferred; - -const resolveMaybeDeferred = async ( - maybeDeferred: MaybeDeferred, - requirements: PaymentRequirements, -): Promise => { - if (typeof maybeDeferred === 'function') { - return (maybeDeferred as Deferred)(requirements); - } - - return maybeDeferred; -}; +/** + * Value that can be provided eagerly or derived lazily from payment requirements. + * + * @template TResult - Resolved value type. + */ +export type MaybeDeferred = + | TResult + | ((requirements: PaymentRequirements) => TResult); /** * Configuration used to create a x402DelegationProvider. @@ -86,170 +68,6 @@ export type x402DelegationProviderConfig = { parentPermissionContext?: MaybeDeferred; }; -type DelegationCreationContext = { - account: Account; - delegationManager: Address; - existingDelegations: Delegation[]; - createDelegationConfig: Parameters[0]; -}; - -type Resolvex402DelegationCaveatsParams = { - environment: SmartAccountsEnvironment; - caveatsConfig: Caveats | undefined; - existingDelegations: Delegation[]; - facilitatorAddresses: Hex[] | undefined; -}; - -const normalizeAddress = (address: Hex): string => address.toLowerCase(); - -const isSubset = (subset: string[], superset: string[]): boolean => - subset.every((item) => superset.includes(item)); - -const resolvex402DelegationCaveats = ({ - environment, - caveatsConfig, - existingDelegations, - facilitatorAddresses, -}: Resolvex402DelegationCaveatsParams): Caveat[] => { - const { - caveatEnforcers: { RedeemerEnforcer: redeemerEnforcer }, - } = environment; - - if (!redeemerEnforcer) { - throw new Error('RedeemerEnforcer not found in environment'); - } - - const redeemerAddressNormalized = normalizeAddress(redeemerEnforcer); - - const caveats = resolveCaveats({ - environment, - caveats: caveatsConfig, - // Resolve caveats first so we can append a redeemer caveat when needed. - // Scope is still attached later during delegation creation. - isScopeOptional: true, - }); - - const redeemerCaveats = [ - ...caveats, - ...existingDelegations.flatMap((delegation) => delegation.caveats), - ].filter( - ({ enforcer }) => normalizeAddress(enforcer) === redeemerAddressNormalized, - ); - - const hasExistingRedeemerConstraint = redeemerCaveats.length > 0; - - if (!facilitatorAddresses || facilitatorAddresses.length === 0) { - // Without facilitators, a redeemer constraint must already exist. - if (!hasExistingRedeemerConstraint) { - throw new Error( - 'Redeemer must be constrained, either in the specified `caveats`, `parentPermissionContext`, or the `PaymentRequirements` as `extra.facilitatorAddresses`.', - ); - } - - return caveats; - } - - const facilitatorAddressesLowerCase = - facilitatorAddresses.map(normalizeAddress); - - // If an existing redeemer caveat is already within facilitator bounds, no new caveat is needed. - const hasSufficientlyConstrainedRedeemerCaveat = redeemerCaveats.some( - (caveat) => { - const allowedRedeemerAddresses = decodeRedeemerTerms( - caveat.terms, - ).redeemers.map(normalizeAddress); - - return isSubset(allowedRedeemerAddresses, facilitatorAddressesLowerCase); - }, - ); - - if (hasSufficientlyConstrainedRedeemerCaveat) { - return caveats; - } - - const redeemerCaveat: Caveat = { - enforcer: redeemerEnforcer, - terms: createRedeemerTerms({ redeemers: facilitatorAddresses }), - args: '0x', - }; - - return [...caveats, redeemerCaveat]; -}; - -const resolveDelegationCreationContext = async ( - config: x402DelegationProviderConfig, - requirements: PaymentRequirements, -): Promise => { - const caveatsConfig = await resolveMaybeDeferred( - config.caveats, - requirements, - ); - const parentPermissionContext = await resolveMaybeDeferred( - config.parentPermissionContext, - requirements, - ); - - const { account } = config; - const from = config.from ?? account.address; - const salt = config.salt ?? generateSalt(); - - const scope = { - type: ScopeType.Erc20TransferAmount, - tokenAddress: requirements.asset as Hex, - maxAmount: BigInt(requirements.amount), - } as ScopeConfig; - - const facilitatorAddresses = requirements.extra?.facilitatorAddresses as - | Hex[] - | undefined; - - const existingDelegations = parentPermissionContext - ? decodeDelegations(parentPermissionContext) - : []; - - const { DelegationManager: delegationManager } = config.environment; - const caveats = resolvex402DelegationCaveats({ - environment: config.environment, - caveatsConfig, - existingDelegations, - facilitatorAddresses, - }); - - let createDelegationConfig: Parameters[0]; - - if (parentPermissionContext) { - const parentDelegation = existingDelegations[0]; - - if (!parentDelegation) { - throw new Error('Parent permission context is not a valid delegation'); - } - - createDelegationConfig = { - environment: config.environment, - from, - caveats, - salt, - scope, - parentDelegation, - }; - } else { - createDelegationConfig = { - environment: config.environment, - from, - caveats, - salt, - scope, - }; - } - - return { - account, - delegationManager, - existingDelegations, - createDelegationConfig, - }; -}; - /** * Creates a delegation provider function for x402 payment requirements. * @@ -275,8 +93,15 @@ export function createx402DelegationProvider( const delegation = createOpenDelegation(createDelegationConfig); - // todo: extract chainId from the network parameter - const chainId = requirements.network as unknown as number; + const { namespace, reference } = parseCaipChainId( + requirements.network as `${string}:${string}`, + ); + + if (namespace !== 'eip155') { + throw new Error('Unsupported chain namespace'); + } + + const chainId = parseInt(reference, 10); const typedData = prepareSignDelegationTypedData({ delegationManager, diff --git a/packages/smart-accounts-kit/src/experimental/x402DelegationProviderUtils.ts b/packages/smart-accounts-kit/src/experimental/x402DelegationProviderUtils.ts new file mode 100644 index 00000000..d323112f --- /dev/null +++ b/packages/smart-accounts-kit/src/experimental/x402DelegationProviderUtils.ts @@ -0,0 +1,373 @@ +import { + createAllowedCalldataTerms, + createRedeemerTerms, + decodeRedeemerTerms, +} from '@metamask/delegation-core'; +import type { Account, Address, Hex } from 'viem'; + +import type { Caveats } from '../caveatBuilder'; +import { resolveCaveats } from '../caveatBuilder'; +import { ScopeType } from '../constants'; +import type { createOpenDelegation } from '../delegation'; +import { decodeDelegations } from '../delegation'; +import type { + Caveat, + Delegation, + PermissionContext, + SmartAccountsEnvironment, +} from '../types'; +import { generateSalt } from '../utils/'; + +type EnsureRedeemerSufficientlyConstrainedParams = { + redeemerEnforcer: Hex; + caveats: Caveat[]; + existingDelegations: Delegation[]; + facilitatorAddresses: Hex[] | undefined; +}; + +type EnsurePayeeSufficientlyConstrainedParams = { + allowedCalldataEnforcer: Hex; + caveats: Caveat[]; + existingDelegations: Delegation[]; + payee: Hex; +}; + +type Deferred = ( + requirements: TRequirements, +) => TResult; + +type MaybeDeferred = + | TResult + | Deferred; + +type ResolveDelegationCreationContextRequirements = { + scheme: string; + network: string; + asset: string; + amount: string; + payTo: string; + maxTimeoutSeconds: number; + extra?: Record; +}; + +type ResolveDelegationCreationContextConfig = { + account: Account; + environment: SmartAccountsEnvironment; + from?: Hex; + salt?: Hex; + caveats?: MaybeDeferred< + Caveats | undefined, + ResolveDelegationCreationContextRequirements + >; + parentPermissionContext?: MaybeDeferred< + PermissionContext | undefined, + ResolveDelegationCreationContextRequirements + >; +}; + +export type DelegationCreationContext = { + account: Account; + delegationManager: Address; + existingDelegations: Delegation[]; + createDelegationConfig: Parameters[0]; +}; + +export type Resolvex402DelegationCaveatsParams = { + environment: SmartAccountsEnvironment; + caveatsConfig: Caveats | undefined; + existingDelegations: Delegation[]; + facilitatorAddresses: Hex[] | undefined; + payee: Hex; +}; + +const resolveMaybeDeferred = async ( + maybeDeferred: MaybeDeferred | undefined, + requirements: TRequirements, +): Promise => { + if (typeof maybeDeferred === 'function') { + return (maybeDeferred as Deferred)(requirements); + } + + return maybeDeferred; +}; + +// ERC-20 transfer(to, value), `to` parameter starts at index 4 +const TRANSFER_PAYEE_INDEX = 4; + +const normalizeAddress = (address: Hex): string => address.toLowerCase(); + +const isSubset = (subset: string[], superset: string[]): boolean => + subset.every((item) => superset.includes(item)); + +const hasMatchingCaveats = ( + caveats: Caveat[], + delegations: Delegation[], + match: (caveat: Caveat) => boolean, +): boolean => + [...caveats, ...delegations.flatMap((delegation) => delegation.caveats)].some( + match, + ); + +/** + * Ensures caveats include a sufficiently strict redeemer constraint. + * + * Returns the caveat list unchanged when an existing redeemer caveat is already + * strict enough, or appends a new redeemer caveat scoped to facilitator addresses. + * + * @param options0 - Redeemer constraint evaluation inputs. + * @param options0.redeemerEnforcer - Address of the redeemer enforcer caveat contract. + * @param options0.caveats - Currently resolved caveats for the delegation being created. + * @param options0.existingDelegations - Existing parent-chain delegations to inspect for inherited constraints. + * @param options0.facilitatorAddresses - Optional facilitator addresses from payment requirements used to bound redeemers. + * @returns The original caveats when sufficiently constrained, otherwise caveats with a redeemer caveat appended. + * @throws If no facilitator addresses are provided and no redeemer constraint exists. + */ +export const ensureRedeemerSufficientlyConstrained = ({ + redeemerEnforcer, + caveats, + existingDelegations, + facilitatorAddresses, +}: EnsureRedeemerSufficientlyConstrainedParams): Caveat[] => { + const redeemerAddressNormalized = normalizeAddress(redeemerEnforcer); + + if (!facilitatorAddresses || facilitatorAddresses.length === 0) { + const hasExistingRedeemerCaveat = hasMatchingCaveats( + caveats, + existingDelegations, + ({ enforcer }) => + normalizeAddress(enforcer) === redeemerAddressNormalized, + ); + + if (!hasExistingRedeemerCaveat) { + throw new Error( + 'Redeemer must be constrained, either in the specified `caveats`, `parentPermissionContext`, or the `PaymentRequirements` as `extra.facilitatorAddresses`.', + ); + } + + return caveats; + } + + const facilitatorAddressesNormalized = + facilitatorAddresses.map(normalizeAddress); + + const hasSufficientlyConstrainedRedeemerCaveat = hasMatchingCaveats( + caveats, + existingDelegations, + (caveat) => { + if (normalizeAddress(caveat.enforcer) !== redeemerAddressNormalized) { + return false; + } + + const allowedRedeemerAddresses = decodeRedeemerTerms( + caveat.terms, + ).redeemers.map(normalizeAddress); + + // if this redeemer caveat only allows (some of) thefacilitator addresses, it is sufficiently constrained + return isSubset(allowedRedeemerAddresses, facilitatorAddressesNormalized); + }, + ); + + if (hasSufficientlyConstrainedRedeemerCaveat) { + return caveats; + } + + const redeemerCaveat: Caveat = { + enforcer: redeemerEnforcer, + terms: createRedeemerTerms({ redeemers: facilitatorAddresses }), + args: '0x', + }; + + return [...caveats, redeemerCaveat]; +}; + +/** + * Ensures caveats include an allowed-calldata constraint for the payment payee. + * + * Scans both the in-progress caveat list and parent delegation caveats for an + * `AllowedCalldataEnforcer` caveat whose terms match the encoded payee calldata + * constraint. If found, returns caveats unchanged; otherwise appends a payee caveat. + * + * @param options0 - Payee constraint evaluation inputs. + * @param options0.allowedCalldataEnforcer - Address of the AllowedCalldataEnforcer caveat contract. + * @param options0.caveats - Currently resolved caveats for the delegation being created. + * @param options0.existingDelegations - Existing parent-chain delegations to inspect for inherited constraints. + * @param options0.payee - Expected ERC-20 transfer recipient to enforce in calldata. + * @returns The original caveats when an equivalent payee constraint exists, otherwise caveats with a payee caveat appended. + */ +export const ensurePayeeSufficientlyConstrained = ({ + allowedCalldataEnforcer, + caveats, + existingDelegations, + payee, +}: EnsurePayeeSufficientlyConstrainedParams): Caveat[] => { + const allowedCalldataTerms = createAllowedCalldataTerms({ + startIndex: TRANSFER_PAYEE_INDEX, + value: payee, + }); + + const allowedCalldataEnforcerNormalized = normalizeAddress( + allowedCalldataEnforcer, + ); + + const normalizedAllowedCalldataTerms = normalizeAddress(allowedCalldataTerms); + + const hasMatchingAllowedCalldataConstraint = hasMatchingCaveats( + caveats, + existingDelegations, + ({ enforcer, terms }) => + normalizeAddress(enforcer) === allowedCalldataEnforcerNormalized && + normalizeAddress(terms) === normalizedAllowedCalldataTerms, + ); + + if (hasMatchingAllowedCalldataConstraint) { + return caveats; + } + + const payeeCaveat: Caveat = { + enforcer: allowedCalldataEnforcer, + terms: allowedCalldataTerms, + args: '0x', + }; + + return [...caveats, payeeCaveat]; +}; + +/** + * Resolves caveats and applies x402-specific redeemer and payee constraints. + * + * @param options0 - Caveat resolution inputs. + * @param options0.environment - Environment containing caveat enforcer addresses. + * @param options0.caveatsConfig - Optional caveat builder config. + * @param options0.existingDelegations - Existing parent-chain delegations. + * @param options0.facilitatorAddresses - Optional facilitator addresses used for redeemer constraints. + * @param options0.payee - Payee address used for allowed calldata constraints. + * @returns Caveats after redeemer and payee constraints are enforced. + */ +export const resolvex402DelegationCaveats = ({ + environment, + caveatsConfig, + existingDelegations, + facilitatorAddresses, + payee, +}: Resolvex402DelegationCaveatsParams): Caveat[] => { + const { + caveatEnforcers: { + RedeemerEnforcer: redeemerEnforcer, + AllowedCalldataEnforcer: allowedCalldataEnforcer, + }, + } = environment; + + if (!redeemerEnforcer) { + throw new Error('RedeemerEnforcer not found in environment'); + } + + if (!allowedCalldataEnforcer) { + throw new Error('AllowedCalldataEnforcer not found in environment'); + } + + const initialCaveats = resolveCaveats({ + environment, + caveats: caveatsConfig, + // Resolve caveats first so downstream constraint checks can append as needed. + // Scope is still attached later during delegation creation. + isScopeOptional: true, + }); + + const caveatsWithRedeemer = ensureRedeemerSufficientlyConstrained({ + redeemerEnforcer, + caveats: initialCaveats, + existingDelegations, + facilitatorAddresses, + }); + + const caveatsWithPayee = ensurePayeeSufficientlyConstrained({ + allowedCalldataEnforcer, + caveats: caveatsWithRedeemer, + existingDelegations, + payee, + }); + + return caveatsWithPayee; +}; + +/** + * Builds the delegation creation context from provider config and requirements. + * + * @param config - Delegation provider config for context construction. + * @param requirements - Payment requirements used to scope caveats. + * @returns The resolved context used to create and sign a delegation. + */ +export const resolveDelegationCreationContext = async ( + config: ResolveDelegationCreationContextConfig, + requirements: ResolveDelegationCreationContextRequirements, +): Promise => { + const caveatsConfig = await resolveMaybeDeferred( + config.caveats, + requirements, + ); + const parentPermissionContext = await resolveMaybeDeferred( + config.parentPermissionContext, + requirements, + ); + + const { account } = config; + const from = config.from ?? account.address; + const salt = config.salt ?? generateSalt(); + + const scope = { + type: ScopeType.Erc20TransferAmount, + tokenAddress: requirements.asset as Hex, + maxAmount: BigInt(requirements.amount), + } as const; + + const facilitatorAddresses = requirements.extra?.facilitatorAddresses as + | Hex[] + | undefined; + + const existingDelegations = parentPermissionContext + ? decodeDelegations(parentPermissionContext) + : []; + + const { DelegationManager: delegationManager } = config.environment; + const caveats = resolvex402DelegationCaveats({ + environment: config.environment, + caveatsConfig, + existingDelegations, + facilitatorAddresses, + payee: requirements.payTo as Hex, + }); + + let createDelegationConfig: Parameters[0]; + + if (parentPermissionContext) { + const parentDelegation = existingDelegations[0]; + + if (!parentDelegation) { + throw new Error('Parent permission context is not a valid delegation'); + } + + createDelegationConfig = { + environment: config.environment, + from, + caveats, + salt, + scope, + parentDelegation, + }; + } else { + createDelegationConfig = { + environment: config.environment, + from, + caveats, + salt, + scope, + }; + } + + return { + account, + delegationManager, + existingDelegations, + createDelegationConfig, + }; +}; diff --git a/packages/smart-accounts-kit/test/experimental/x402DelegationProvider.test.ts b/packages/smart-accounts-kit/test/experimental/x402DelegationProvider.test.ts index 1ac48569..5f16af4f 100644 --- a/packages/smart-accounts-kit/test/experimental/x402DelegationProvider.test.ts +++ b/packages/smart-accounts-kit/test/experimental/x402DelegationProvider.test.ts @@ -19,6 +19,7 @@ const delegationMocks = vi.hoisted(() => ({ })); const delegationCoreMocks = vi.hoisted(() => ({ + createAllowedCalldataTerms: vi.fn(), createRedeemerTerms: vi.fn(), decodeRedeemerTerms: vi.fn(), })); @@ -43,10 +44,12 @@ const mockDelegationManager = '0x1000000000000000000000000000000000000001' as Hex; const mockRedeemerEnforcer = '0x2000000000000000000000000000000000000002' as Hex; +const mockPayeeEnforcer = '0x2000000000000000000000000000000000000004' as Hex; const mockDelegator = '0x3000000000000000000000000000000000000003' as Hex; const mockSignature = '0xabc123' as Hex; const mockTypedData = { domain: {}, message: {} }; const mockPermissionContext = '0xfeed' as Hex; +const mockAllowedCalldataTerms = '0x3333' as Hex; const mockGeneratedSalt = '0x1111111111111111111111111111111111111111111111111111111111111111' as Hex; const mockAuthority = @@ -54,7 +57,7 @@ const mockAuthority = const mockRequirements: PaymentRequirements = { scheme: 'exact', - network: '1', + network: 'eip155:1', asset: '0x4000000000000000000000000000000000000004', amount: '500', payTo: '0x5000000000000000000000000000000000000005', @@ -81,6 +84,7 @@ const createMockEnvironment = ( DelegationManager: mockDelegationManager, caveatEnforcers: { RedeemerEnforcer: mockRedeemerEnforcer, + AllowedCalldataEnforcer: mockPayeeEnforcer, }, ...overrides, }) as SmartAccountsEnvironment; @@ -96,6 +100,9 @@ describe('createx402DelegationProvider', () => { args: '0x', }, ]); + delegationCoreMocks.createAllowedCalldataTerms.mockReturnValue( + mockAllowedCalldataTerms, + ); delegationCoreMocks.createRedeemerTerms.mockReturnValue('0x22'); delegationCoreMocks.decodeRedeemerTerms.mockImplementation((terms: Hex) => { if (terms === '0x01') { @@ -163,7 +170,7 @@ describe('createx402DelegationProvider', () => { expect(delegationMocks.prepareSignDelegationTypedData).toHaveBeenCalledWith( { delegationManager: mockDelegationManager, - chainId: mockRequirements.network, + chainId: 1, delegation: delegationMocks.createOpenDelegation.mock.results[0]?.value, }, ); @@ -181,6 +188,44 @@ describe('createx402DelegationProvider', () => { }); }); + it('parses chainId from an eip155 CAIP network identifier', async () => { + const account = createMockAccount(); + const environment = createMockEnvironment(); + const provider = createx402DelegationProvider({ + account, + environment, + caveats: [], + }); + + await provider({ + ...mockRequirements, + network: 'eip155:8453', + }); + + expect(delegationMocks.prepareSignDelegationTypedData).toHaveBeenCalledWith( + expect.objectContaining({ + chainId: 8453, + }), + ); + }); + + it('throws when network namespace is not eip155', async () => { + const account = createMockAccount(); + const environment = createMockEnvironment(); + const provider = createx402DelegationProvider({ + account, + environment, + caveats: [], + }); + + await expect( + provider({ + ...mockRequirements, + network: 'cosmos:cosmoshub-4', + }), + ).rejects.toThrow('Unsupported chain namespace'); + }); + it('uses deferred caveats and deferred parent permission context', async () => { const account = createMockAccount(); const environment = createMockEnvironment(); @@ -277,167 +322,6 @@ describe('createx402DelegationProvider', () => { ]); }); - describe('redeemer caveat resolution', () => { - it('throws when facilitators are missing and no redeemer caveat exists', async () => { - const account = createMockAccount(); - const provider = createx402DelegationProvider({ - account, - environment: createMockEnvironment(), - }); - - await expect( - provider({ - ...mockRequirements, - extra: undefined, - }), - ).rejects.toThrow('Redeemer must be constrained'); - }); - - it('allows missing facilitators when parent chain has a redeemer caveat', async () => { - const account = createMockAccount(); - delegationMocks.decodeDelegations.mockReturnValue([ - { - delegate: '0xde100000000000000000000000000000000000e1', - delegator: '0xde200000000000000000000000000000000000e2', - authority: mockAuthority, - caveats: [ - { - enforcer: mockRedeemerEnforcer, - terms: '0x01', - args: '0x', - }, - ], - salt: '0x99', - signature: '0xaa', - }, - ]); - const provider = createx402DelegationProvider({ - account, - environment: createMockEnvironment(), - parentPermissionContext: '0xee' as Hex, - }); - - await expect( - provider({ - ...mockRequirements, - extra: undefined, - }), - ).resolves.toStrictEqual({ - delegationManager: mockDelegationManager, - permissionContext: mockPermissionContext, - delegator: mockDelegator, - }); - expect(delegationCoreMocks.createRedeemerTerms).not.toHaveBeenCalled(); - }); - - it('does not add a redeemer caveat when existing redeemers are subset of facilitators', async () => { - const account = createMockAccount(); - delegationMocks.decodeDelegations.mockReturnValue([ - { - delegate: '0xde100000000000000000000000000000000000e1', - delegator: '0xde200000000000000000000000000000000000e2', - authority: mockAuthority, - caveats: [ - { - enforcer: mockRedeemerEnforcer, - terms: '0x01', - args: '0x', - }, - ], - salt: '0x99', - signature: '0xaa', - }, - ]); - const provider = createx402DelegationProvider({ - account, - environment: createMockEnvironment(), - parentPermissionContext: '0xee' as Hex, - }); - - await provider(mockRequirements); - - const createCallArg = - delegationMocks.createOpenDelegation.mock.calls[0]?.[0]; - expect(createCallArg.caveats).toStrictEqual([ - { - enforcer: '0x9000000000000000000000000000000000000009', - terms: '0x11', - args: '0x', - }, - ]); - expect(delegationCoreMocks.createRedeemerTerms).not.toHaveBeenCalled(); - }); - - it('adds a redeemer caveat when existing redeemers are not subset of facilitators', async () => { - const account = createMockAccount(); - delegationMocks.decodeDelegations.mockReturnValue([ - { - delegate: '0xde100000000000000000000000000000000000e1', - delegator: '0xde200000000000000000000000000000000000e2', - authority: mockAuthority, - caveats: [ - { - enforcer: mockRedeemerEnforcer, - terms: '0x02', - args: '0x', - }, - ], - salt: '0x99', - signature: '0xaa', - }, - ]); - const provider = createx402DelegationProvider({ - account, - environment: createMockEnvironment(), - parentPermissionContext: '0xee' as Hex, - }); - - await provider(mockRequirements); - - expect(delegationCoreMocks.createRedeemerTerms).toHaveBeenCalledWith({ - redeemers: mockRequirements.extra?.facilitatorAddresses, - }); - const createCallArg = - delegationMocks.createOpenDelegation.mock.calls[0]?.[0]; - expect(createCallArg.caveats).toContainEqual({ - enforcer: mockRedeemerEnforcer, - terms: '0x22', - args: '0x', - }); - }); - }); - - it('throws when facilitator addresses are missing', async () => { - const account = createMockAccount(); - const provider = createx402DelegationProvider({ - account, - environment: createMockEnvironment(), - }); - - await expect( - provider({ - ...mockRequirements, - extra: undefined, - }), - ).rejects.toThrow('Redeemer must be constrained'); - }); - - it('throws when redeemer enforcer is missing from environment', async () => { - const account = createMockAccount(); - const provider = createx402DelegationProvider({ - account, - environment: createMockEnvironment({ - caveatEnforcers: { - RedeemerEnforcer: undefined as unknown as Hex, - }, - }), - }); - - await expect(provider(mockRequirements)).rejects.toThrow( - 'RedeemerEnforcer not found in environment', - ); - }); - it('throws when parent permission context does not decode into a delegation', async () => { const account = createMockAccount(); delegationMocks.decodeDelegations.mockReturnValue([]); diff --git a/packages/smart-accounts-kit/test/experimental/x402DelegationProviderUtils.test.ts b/packages/smart-accounts-kit/test/experimental/x402DelegationProviderUtils.test.ts new file mode 100644 index 00000000..3e8c7dd0 --- /dev/null +++ b/packages/smart-accounts-kit/test/experimental/x402DelegationProviderUtils.test.ts @@ -0,0 +1,285 @@ +import { + createAllowedCalldataTerms, + createRedeemerTerms, +} from '@metamask/delegation-core'; +import type { Hex, Account } from 'viem'; +import { describe, expect, it } from 'vitest'; + +import { + ensurePayeeSufficientlyConstrained, + ensureRedeemerSufficientlyConstrained, + resolvex402DelegationCaveats, + resolveDelegationCreationContext, +} from '../../src/experimental/x402DelegationProviderUtils'; +import type { + Caveat, + Delegation, + SmartAccountsEnvironment, +} from '../../src/types'; + +const redeemerEnforcer = '0x1000000000000000000000000000000000000001' as Hex; +const allowedCalldataEnforcer = + '0x2000000000000000000000000000000000000002' as Hex; +const facilitatorA = '0x3000000000000000000000000000000000000003' as Hex; +const facilitatorB = '0x4000000000000000000000000000000000000004' as Hex; +const facilitatorC = '0x5000000000000000000000000000000000000005' as Hex; +const payee = '0x6000000000000000000000000000000000000006' as Hex; +const otherPayee = '0x7000000000000000000000000000000000000007' as Hex; +const rootAuthority = `0x${'00'.repeat(32)}`; +const baseEnvironment = { + DelegationManager: '0xa00000000000000000000000000000000000000a', + EntryPoint: '0xa10000000000000000000000000000000000000a', + SimpleFactory: '0xa20000000000000000000000000000000000000a', + implementations: {}, + caveatEnforcers: { + RedeemerEnforcer: redeemerEnforcer, + AllowedCalldataEnforcer: allowedCalldataEnforcer, + }, +} as unknown as SmartAccountsEnvironment; +const mockAccount = { + address: '0x8000000000000000000000000000000000000008', +} as unknown as Account; + +const makeDelegation = (caveats: Caveat[]): Delegation => ({ + delegate: '0x8000000000000000000000000000000000000008', + delegator: '0x9000000000000000000000000000000000000009', + authority: rootAuthority, + caveats, + salt: `0x${'11'.repeat(32)}`, + signature: `0x${'22'.repeat(65)}`, +}); + +describe('x402DelegationProviderUtils', () => { + describe('ensureRedeemerSufficientlyConstrained', () => { + it('throws when facilitators are missing and no redeemer caveat exists', () => { + expect(() => + ensureRedeemerSufficientlyConstrained({ + redeemerEnforcer, + caveats: [], + existingDelegations: [], + facilitatorAddresses: undefined, + }), + ).toThrow('Redeemer must be constrained'); + }); + + it('returns caveats unchanged when facilitators are missing but parent has redeemer caveat', () => { + const initialCaveats: Caveat[] = [ + { + enforcer: '0xa00000000000000000000000000000000000000a', + terms: '0x1234', + args: '0x', + }, + ]; + + const result = ensureRedeemerSufficientlyConstrained({ + redeemerEnforcer, + caveats: initialCaveats, + existingDelegations: [ + makeDelegation([ + { + enforcer: redeemerEnforcer, + terms: createRedeemerTerms({ redeemers: [facilitatorA] }), + args: '0x', + }, + ]), + ], + facilitatorAddresses: undefined, + }); + + expect(result).toStrictEqual(initialCaveats); + }); + + it('does not append when an existing redeemer caveat is already sufficiently constrained', () => { + const initialCaveats: Caveat[] = [ + { + enforcer: redeemerEnforcer, + terms: createRedeemerTerms({ redeemers: [facilitatorA] }), + args: '0x', + }, + ]; + + const result = ensureRedeemerSufficientlyConstrained({ + redeemerEnforcer, + caveats: initialCaveats, + existingDelegations: [], + facilitatorAddresses: [facilitatorA, facilitatorB], + }); + + expect(result).toStrictEqual(initialCaveats); + }); + + it('appends a redeemer caveat when existing constraints are too broad', () => { + const initialCaveats: Caveat[] = []; + + const result = ensureRedeemerSufficientlyConstrained({ + redeemerEnforcer, + caveats: initialCaveats, + existingDelegations: [ + makeDelegation([ + { + enforcer: redeemerEnforcer, + terms: createRedeemerTerms({ + redeemers: [facilitatorA, facilitatorC], + }), + args: '0x', + }, + ]), + ], + facilitatorAddresses: [facilitatorA, facilitatorB], + }); + + expect(result).toContainEqual({ + enforcer: redeemerEnforcer, + terms: createRedeemerTerms({ redeemers: [facilitatorA, facilitatorB] }), + args: '0x', + }); + }); + }); + + describe('ensurePayeeSufficientlyConstrained', () => { + it('returns caveats unchanged when current caveats already constrain payee', () => { + const matchingTerms = createAllowedCalldataTerms({ + startIndex: 4, + value: payee, + }); + const initialCaveats: Caveat[] = [ + { + enforcer: allowedCalldataEnforcer, + terms: matchingTerms, + args: '0x', + }, + ]; + + const result = ensurePayeeSufficientlyConstrained({ + allowedCalldataEnforcer, + caveats: initialCaveats, + existingDelegations: [], + payee, + }); + + expect(result).toStrictEqual(initialCaveats); + }); + + it('returns caveats unchanged when parent caveats already constrain payee', () => { + const initialCaveats: Caveat[] = [ + { + enforcer: '0xb00000000000000000000000000000000000000b', + terms: '0x5678', + args: '0x', + }, + ]; + + const result = ensurePayeeSufficientlyConstrained({ + allowedCalldataEnforcer, + caveats: initialCaveats, + existingDelegations: [ + makeDelegation([ + { + enforcer: allowedCalldataEnforcer, + terms: createAllowedCalldataTerms({ + startIndex: 4, + value: payee, + }), + args: '0x', + }, + ]), + ], + payee, + }); + + expect(result).toStrictEqual(initialCaveats); + }); + + it('appends a payee caveat when no matching allowed calldata constraint exists', () => { + const initialCaveats: Caveat[] = []; + + const result = ensurePayeeSufficientlyConstrained({ + allowedCalldataEnforcer, + caveats: initialCaveats, + existingDelegations: [ + makeDelegation([ + { + enforcer: allowedCalldataEnforcer, + terms: createAllowedCalldataTerms({ + startIndex: 4, + value: otherPayee, + }), + args: '0x', + }, + ]), + ], + payee, + }); + + expect(result).toContainEqual({ + enforcer: allowedCalldataEnforcer, + terms: createAllowedCalldataTerms({ + startIndex: 4, + value: payee, + }), + args: '0x', + }); + }); + }); + + describe('resolvex402DelegationCaveats', () => { + it('throws when RedeemerEnforcer is missing from environment', () => { + expect(() => + resolvex402DelegationCaveats({ + environment: { + ...baseEnvironment, + caveatEnforcers: { + ...baseEnvironment.caveatEnforcers, + RedeemerEnforcer: undefined as unknown as Hex, + }, + }, + caveatsConfig: undefined, + existingDelegations: [], + facilitatorAddresses: [facilitatorA], + payee, + }), + ).toThrow('RedeemerEnforcer not found in environment'); + }); + + it('throws when AllowedCalldataEnforcer is missing from environment', () => { + expect(() => + resolvex402DelegationCaveats({ + environment: { + ...baseEnvironment, + caveatEnforcers: { + ...baseEnvironment.caveatEnforcers, + AllowedCalldataEnforcer: undefined as unknown as Hex, + }, + }, + caveatsConfig: undefined, + existingDelegations: [], + facilitatorAddresses: [facilitatorA], + payee, + }), + ).toThrow('AllowedCalldataEnforcer not found in environment'); + }); + }); + + describe('resolveDelegationCreationContext', () => { + it('throws when facilitators are missing and no redeemer caveat exists', async () => { + await expect( + resolveDelegationCreationContext( + { + account: mockAccount, + environment: baseEnvironment, + salt: `0x${'33'.repeat(32)}`, + }, + { + scheme: 'exact', + network: 'eip155:1', + asset: facilitatorA, + amount: '1', + payTo: payee, + maxTimeoutSeconds: 120, + extra: undefined, + }, + ), + ).rejects.toThrow('Redeemer must be constrained'); + }); + }); +}); diff --git a/packages/x402/src/x402Client.ts b/packages/x402/src/x402Client.ts index c2b0b02d..01f0c6e7 100644 --- a/packages/x402/src/x402Client.ts +++ b/packages/x402/src/x402Client.ts @@ -1,3 +1,4 @@ +import { parseCaipChainId } from "@metamask/utils"; import { type Hex, getAddress, isHex } from 'viem'; export type x402PaymentRequirements = { diff --git a/packages/x402/src/x402Server.ts b/packages/x402/src/x402Server.ts index 784958a7..fc7c4476 100644 --- a/packages/x402/src/x402Server.ts +++ b/packages/x402/src/x402Server.ts @@ -1,5 +1,4 @@ import { type Address, getAddress } from 'viem'; - import type { x402PaymentRequirements } from './x402Client'; export type x402Erc7710ServerConfig = { diff --git a/yarn.lock b/yarn.lock index 1d60c782..9ff10e55 100644 --- a/yarn.lock +++ b/yarn.lock @@ -784,6 +784,7 @@ __metadata: "@metamask/eslint-config": "npm:14.1.0" "@metamask/eslint-config-nodejs": "npm:14.0.0" "@metamask/eslint-config-typescript": "npm:14.0.0" + "@metamask/utils": "npm:^11.4.0" "@types/node": "npm:^20.19.0" "@types/sinon": "npm:^17.0.3" "@vitest/coverage-v8": "npm:3.2.4" From 22f61b9439a5b789b4d097dcd04a638fa1293c24 Mon Sep 17 00:00:00 2001 From: Jeff Smale <6363749+jeffsmale90@users.noreply.github.com> Date: Tue, 26 May 2026 11:04:53 +1200 Subject: [PATCH 08/17] Make MaybeDeferred async - also refactor types into x402DelegationProviderTypes.ts to simplify internal type dependencies --- .../experimental/x402DelegationProvider.ts | 73 ++--------- .../x402DelegationProviderTypes.ts | 62 ++++++++++ .../x402DelegationProviderUtils.ts | 109 +++++++++-------- .../x402DelegationProvider.test.ts | 110 ----------------- .../x402DelegationProviderUtils.test.ts | 113 ++++++++++++++++-- 5 files changed, 240 insertions(+), 227 deletions(-) create mode 100644 packages/smart-accounts-kit/src/experimental/x402DelegationProviderTypes.ts diff --git a/packages/smart-accounts-kit/src/experimental/x402DelegationProvider.ts b/packages/smart-accounts-kit/src/experimental/x402DelegationProvider.ts index 59510d31..c4a1b91b 100644 --- a/packages/smart-accounts-kit/src/experimental/x402DelegationProvider.ts +++ b/packages/smart-accounts-kit/src/experimental/x402DelegationProvider.ts @@ -1,72 +1,25 @@ import { parseCaipChainId } from '@metamask/utils'; -import type { Account, Hex } from 'viem'; -import type { Caveats } from '../caveatBuilder'; import { createOpenDelegation, encodeDelegations, prepareSignDelegationTypedData, } from '../delegation'; -import type { PermissionContext, SmartAccountsEnvironment } from '../types'; +import type { + PaymentRequirements, + x402DelegationProvider, + x402DelegationProviderConfig, + x402DelegationProviderPaymentPayload, +} from './x402DelegationProviderTypes'; import { resolveDelegationCreationContext } from './x402DelegationProviderUtils'; -/** - * Payment requirement details supplied by an x402 server challenge. - * - * These values are used to scope and construct the delegation that will be - * returned by a {@link x402DelegationProvider}. - */ -export type PaymentRequirements = { - scheme: string; - network: string; - asset: string; - amount: string; - payTo: string; - maxTimeoutSeconds: number; - extra?: Record; -}; - -/** - * Encoded delegation response consumed by x402 payment flows. - * - * The payload includes the delegation manager address, the encoded permission - * context to use for execution, and the delegator account that signed it. - */ -export type x402DelegationProviderPaymentPayload = { - delegationManager: Hex; - permissionContext: Hex; - delegator: Hex; -}; - -/** - * Function that turns payment requirements into a signed delegation payload. - */ -export type x402DelegationProvider = ( - paymentRequirements: PaymentRequirements, -) => Promise; - -/** - * Value that can be provided eagerly or derived lazily from payment requirements. - * - * @template TResult - Resolved value type. - */ -export type MaybeDeferred = - | TResult - | ((requirements: PaymentRequirements) => TResult); - -/** - * Configuration used to create a x402DelegationProvider. - * - * `account` is required and is used for signing the delegation. - */ -export type x402DelegationProviderConfig = { - account: Account; - environment: SmartAccountsEnvironment; - from?: Hex; - salt?: Hex; - caveats?: MaybeDeferred; - parentPermissionContext?: MaybeDeferred; -}; +export type { + MaybeDeferred, + PaymentRequirements, + x402DelegationProvider, + x402DelegationProviderConfig, + x402DelegationProviderPaymentPayload, +} from './x402DelegationProviderTypes'; /** * Creates a delegation provider function for x402 payment requirements. diff --git a/packages/smart-accounts-kit/src/experimental/x402DelegationProviderTypes.ts b/packages/smart-accounts-kit/src/experimental/x402DelegationProviderTypes.ts new file mode 100644 index 00000000..7fd205fe --- /dev/null +++ b/packages/smart-accounts-kit/src/experimental/x402DelegationProviderTypes.ts @@ -0,0 +1,62 @@ +import type { Account, Hex } from 'viem'; + +import type { Caveats } from '../caveatBuilder'; +import type { PermissionContext, SmartAccountsEnvironment } from '../types'; + +/** + * Payment requirement details supplied by an x402 server challenge. + * + * These values are used to scope and construct the delegation that will be + * returned by a {@link x402DelegationProvider}. + */ +export type PaymentRequirements = { + scheme: string; + network: string; + asset: string; + amount: string; + payTo: string; + maxTimeoutSeconds: number; + extra?: Record; +}; + +/** + * Encoded delegation response consumed by x402 payment flows. + * + * The payload includes the delegation manager address, the encoded permission + * context to use for execution, and the delegator account that signed it. + */ +export type x402DelegationProviderPaymentPayload = { + delegationManager: Hex; + permissionContext: Hex; + delegator: Hex; +}; + +/** + * Value that can be provided eagerly or derived lazily from runtime requirements. + * + * @template TResult - Resolved value type. + */ +export type MaybeDeferred = + | TResult + | ((requirements: PaymentRequirements) => Promise); + +/** + * Function that turns payment requirements into a signed delegation payload. + */ +export type x402DelegationProvider = ( + paymentRequirements: PaymentRequirements, +) => Promise; + +/** + * Configuration used to create a x402DelegationProvider. + * + * `account` is required and is used for signing the delegation. + */ +export type x402DelegationProviderConfig = { + account: Account; + environment: SmartAccountsEnvironment; + from?: Hex; + salt?: Hex; + caveats?: MaybeDeferred; + parentPermissionContext?: MaybeDeferred; +}; diff --git a/packages/smart-accounts-kit/src/experimental/x402DelegationProviderUtils.ts b/packages/smart-accounts-kit/src/experimental/x402DelegationProviderUtils.ts index d323112f..7c840a64 100644 --- a/packages/smart-accounts-kit/src/experimental/x402DelegationProviderUtils.ts +++ b/packages/smart-accounts-kit/src/experimental/x402DelegationProviderUtils.ts @@ -3,7 +3,7 @@ import { createRedeemerTerms, decodeRedeemerTerms, } from '@metamask/delegation-core'; -import type { Account, Address, Hex } from 'viem'; +import type { Address, Hex } from 'viem'; import type { Caveats } from '../caveatBuilder'; import { resolveCaveats } from '../caveatBuilder'; @@ -16,8 +16,16 @@ import type { PermissionContext, SmartAccountsEnvironment, } from '../types'; +import type { + MaybeDeferred, + PaymentRequirements, + x402DelegationProviderConfig, +} from './x402DelegationProviderTypes'; import { generateSalt } from '../utils/'; +/** + * Inputs for redeemer constraint enforcement. + */ type EnsureRedeemerSufficientlyConstrainedParams = { redeemerEnforcer: Hex; caveats: Caveat[]; @@ -25,6 +33,9 @@ type EnsureRedeemerSufficientlyConstrainedParams = { facilitatorAddresses: Hex[] | undefined; }; +/** + * Inputs for payee constraint enforcement. + */ type EnsurePayeeSufficientlyConstrainedParams = { allowedCalldataEnforcer: Hex; caveats: Caveat[]; @@ -32,46 +43,19 @@ type EnsurePayeeSufficientlyConstrainedParams = { payee: Hex; }; -type Deferred = ( - requirements: TRequirements, -) => TResult; - -type MaybeDeferred = - | TResult - | Deferred; - -type ResolveDelegationCreationContextRequirements = { - scheme: string; - network: string; - asset: string; - amount: string; - payTo: string; - maxTimeoutSeconds: number; - extra?: Record; -}; - -type ResolveDelegationCreationContextConfig = { - account: Account; - environment: SmartAccountsEnvironment; - from?: Hex; - salt?: Hex; - caveats?: MaybeDeferred< - Caveats | undefined, - ResolveDelegationCreationContextRequirements - >; - parentPermissionContext?: MaybeDeferred< - PermissionContext | undefined, - ResolveDelegationCreationContextRequirements - >; -}; - +/** + * Resolved context required to build and sign an x402 delegation. + */ export type DelegationCreationContext = { - account: Account; + account: x402DelegationProviderConfig['account']; delegationManager: Address; existingDelegations: Delegation[]; createDelegationConfig: Parameters[0]; }; +/** + * Inputs for resolving delegation caveats with x402-specific constraints. + */ export type Resolvex402DelegationCaveatsParams = { environment: SmartAccountsEnvironment; caveatsConfig: Caveats | undefined; @@ -80,25 +64,58 @@ export type Resolvex402DelegationCaveatsParams = { payee: Hex; }; -const resolveMaybeDeferred = async ( - maybeDeferred: MaybeDeferred | undefined, - requirements: TRequirements, +/** + * Resolves eager or deferred values against payment requirements. + * + * @param maybeDeferred - Value or async resolver function. + * @param requirements - Payment requirements passed to deferred resolvers. + * @returns The resolved value, or undefined when no value is provided. + */ +const resolveMaybeDeferred = async ( + maybeDeferred: MaybeDeferred | undefined, + requirements: PaymentRequirements, ): Promise => { if (typeof maybeDeferred === 'function') { - return (maybeDeferred as Deferred)(requirements); + const deferred = maybeDeferred as ( + deferredRequirements: PaymentRequirements, + ) => Promise; + return await deferred(requirements); } return maybeDeferred; }; -// ERC-20 transfer(to, value), `to` parameter starts at index 4 +/** + * ERC-20 `transfer(address to, uint256 value)` calldata index for `to`. + */ const TRANSFER_PAYEE_INDEX = 4; +/** + * Normalizes an address-like hex string for case-insensitive comparisons. + * + * @param address - Address value to normalize. + * @returns Lowercased hex string. + */ const normalizeAddress = (address: Hex): string => address.toLowerCase(); +/** + * Checks whether every item in `subset` appears in `superset`. + * + * @param subset - Candidate subset values. + * @param superset - Candidate superset values. + * @returns True when `subset` is fully contained in `superset`. + */ const isSubset = (subset: string[], superset: string[]): boolean => subset.every((item) => superset.includes(item)); +/** + * Returns whether any caveat in local or inherited delegations matches a predicate. + * + * @param caveats - Current caveat list. + * @param delegations - Existing delegations whose caveats should also be searched. + * @param match - Predicate used to match caveats. + * @returns True when at least one caveat satisfies `match`. + */ const hasMatchingCaveats = ( caveats: Caveat[], delegations: Delegation[], @@ -298,17 +315,15 @@ export const resolvex402DelegationCaveats = ({ * @returns The resolved context used to create and sign a delegation. */ export const resolveDelegationCreationContext = async ( - config: ResolveDelegationCreationContextConfig, - requirements: ResolveDelegationCreationContextRequirements, + config: x402DelegationProviderConfig, + requirements: PaymentRequirements, ): Promise => { - const caveatsConfig = await resolveMaybeDeferred( + const caveatsConfig: Caveats | undefined = await resolveMaybeDeferred( config.caveats, requirements, ); - const parentPermissionContext = await resolveMaybeDeferred( - config.parentPermissionContext, - requirements, - ); + const parentPermissionContext: PermissionContext | undefined = + await resolveMaybeDeferred(config.parentPermissionContext, requirements); const { account } = config; const from = config.from ?? account.address; diff --git a/packages/smart-accounts-kit/test/experimental/x402DelegationProvider.test.ts b/packages/smart-accounts-kit/test/experimental/x402DelegationProvider.test.ts index 5f16af4f..765d93a8 100644 --- a/packages/smart-accounts-kit/test/experimental/x402DelegationProvider.test.ts +++ b/packages/smart-accounts-kit/test/experimental/x402DelegationProvider.test.ts @@ -226,116 +226,6 @@ describe('createx402DelegationProvider', () => { ).rejects.toThrow('Unsupported chain namespace'); }); - it('uses deferred caveats and deferred parent permission context', async () => { - const account = createMockAccount(); - const environment = createMockEnvironment(); - const deferredCaveats = vi.fn(() => []); - const deferredParentPermissionContext = vi.fn(() => '0xdeferred' as Hex); - const parentDelegation = { - delegate: '0xde100000000000000000000000000000000000e1', - delegator: '0xde200000000000000000000000000000000000e2', - authority: mockAuthority, - caveats: [], - salt: '0x99', - signature: '0xaa', - }; - delegationMocks.decodeDelegations.mockReturnValue([parentDelegation]); - const provider = createx402DelegationProvider({ - account, - environment, - caveats: deferredCaveats, - parentPermissionContext: deferredParentPermissionContext, - }); - - await provider(mockRequirements); - - expect(deferredCaveats).toHaveBeenCalledWith(mockRequirements); - expect(deferredParentPermissionContext).toHaveBeenCalledWith( - mockRequirements, - ); - expect(caveatBuilderMocks.resolveCaveats).toHaveBeenCalledWith({ - environment, - caveats: [], - isScopeOptional: true, - }); - expect(delegationMocks.decodeDelegations).toHaveBeenCalledWith( - '0xdeferred', - ); - }); - - it('uses explicit from/salt and includes parent delegation when provided', async () => { - const account = createMockAccount(); - const environment = createMockEnvironment(); - const from = '0xb00000000000000000000000000000000000000b' as Hex; - const salt = - '0xc00000000000000000000000000000000000000000000000000000000000000c' as Hex; - const parentPermissionContext = '0xd0' as Hex; - const parentDelegation = { - delegate: '0xd1000000000000000000000000000000000000d1', - delegator: '0xd2000000000000000000000000000000000000d2', - authority: mockAuthority, - caveats: [], - salt: '0x44', - signature: '0x55', - }; - const existingDelegation = { - delegate: '0xd3000000000000000000000000000000000000d3', - delegator: '0xd4000000000000000000000000000000000000d4', - authority: mockAuthority, - caveats: [], - salt: '0x66', - signature: '0x77', - }; - delegationMocks.decodeDelegations.mockReturnValue([ - parentDelegation, - existingDelegation, - ]); - - const provider = createx402DelegationProvider({ - account, - environment, - from, - salt, - caveats: [], - parentPermissionContext, - }); - - await provider(mockRequirements); - - expect(delegationMocks.decodeDelegations).toHaveBeenCalledWith( - parentPermissionContext, - ); - expect(delegationMocks.createOpenDelegation).toHaveBeenCalledWith( - expect.objectContaining({ - from, - salt, - parentDelegation, - }), - ); - expect(delegationMocks.encodeDelegations).toHaveBeenCalledWith([ - { - ...delegationMocks.createOpenDelegation.mock.results[0]?.value, - signature: mockSignature, - }, - parentDelegation, - existingDelegation, - ]); - }); - - it('throws when parent permission context does not decode into a delegation', async () => { - const account = createMockAccount(); - delegationMocks.decodeDelegations.mockReturnValue([]); - const provider = createx402DelegationProvider({ - account, - environment: createMockEnvironment(), - parentPermissionContext: '0xee' as Hex, - }); - - await expect(provider(mockRequirements)).rejects.toThrow( - 'Parent permission context is not a valid delegation', - ); - }); - it('throws when account does not support typed data signing', async () => { const account = createMockAccount({ signTypedData: undefined, diff --git a/packages/smart-accounts-kit/test/experimental/x402DelegationProviderUtils.test.ts b/packages/smart-accounts-kit/test/experimental/x402DelegationProviderUtils.test.ts index 3e8c7dd0..642bbe63 100644 --- a/packages/smart-accounts-kit/test/experimental/x402DelegationProviderUtils.test.ts +++ b/packages/smart-accounts-kit/test/experimental/x402DelegationProviderUtils.test.ts @@ -3,7 +3,7 @@ import { createRedeemerTerms, } from '@metamask/delegation-core'; import type { Hex, Account } from 'viem'; -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { ensurePayeeSufficientlyConstrained, @@ -17,15 +17,15 @@ import type { SmartAccountsEnvironment, } from '../../src/types'; -const redeemerEnforcer = '0x1000000000000000000000000000000000000001' as Hex; +const redeemerEnforcer = '0x1000000000000000000000000000000000000001' as const; const allowedCalldataEnforcer = - '0x2000000000000000000000000000000000000002' as Hex; -const facilitatorA = '0x3000000000000000000000000000000000000003' as Hex; -const facilitatorB = '0x4000000000000000000000000000000000000004' as Hex; -const facilitatorC = '0x5000000000000000000000000000000000000005' as Hex; -const payee = '0x6000000000000000000000000000000000000006' as Hex; -const otherPayee = '0x7000000000000000000000000000000000000007' as Hex; -const rootAuthority = `0x${'00'.repeat(32)}`; + '0x2000000000000000000000000000000000000002' as const; +const facilitatorA = '0x3000000000000000000000000000000000000003' as const; +const facilitatorB = '0x4000000000000000000000000000000000000004' as const; +const facilitatorC = '0x5000000000000000000000000000000000000005' as const; +const payee = '0x6000000000000000000000000000000000000006' as const; +const otherPayee = '0x7000000000000000000000000000000000000007' as const; +const rootAuthority = `0x${'00'.repeat(32)}` as const; const baseEnvironment = { DelegationManager: '0xa00000000000000000000000000000000000000a', EntryPoint: '0xa10000000000000000000000000000000000000a', @@ -35,7 +35,7 @@ const baseEnvironment = { RedeemerEnforcer: redeemerEnforcer, AllowedCalldataEnforcer: allowedCalldataEnforcer, }, -} as unknown as SmartAccountsEnvironment; +} as SmartAccountsEnvironment; const mockAccount = { address: '0x8000000000000000000000000000000000000008', } as unknown as Account; @@ -261,6 +261,99 @@ describe('x402DelegationProviderUtils', () => { }); describe('resolveDelegationCreationContext', () => { + it('uses deferred caveats and deferred parent permission context', async () => { + const parentDelegation = makeDelegation([]); + const deferredCaveats = vi.fn(async () => []); + const deferredParentPermissionContext = vi.fn( + async () => [parentDelegation] as Delegation[], + ); + + const result = await resolveDelegationCreationContext( + { + account: mockAccount, + environment: baseEnvironment, + caveats: deferredCaveats, + parentPermissionContext: deferredParentPermissionContext, + salt: `0x${'33'.repeat(32)}`, + }, + { + scheme: 'exact', + network: 'eip155:1', + asset: facilitatorA, + amount: '1', + payTo: payee, + maxTimeoutSeconds: 120, + extra: { facilitatorAddresses: [facilitatorA] }, + }, + ); + + expect(deferredCaveats).toHaveBeenCalledOnce(); + expect(deferredParentPermissionContext).toHaveBeenCalledOnce(); + expect(result.createDelegationConfig).toEqual( + expect.objectContaining({ + parentDelegation, + }), + ); + }); + + it('uses explicit from/salt and parent delegation when provided', async () => { + const parentDelegation = makeDelegation([]); + const from = '0xb00000000000000000000000000000000000000b' as const; + const salt = `0x${'44'.repeat(32)}` as const; + + const result = await resolveDelegationCreationContext( + { + account: mockAccount, + environment: baseEnvironment, + from, + salt, + caveats: [], + parentPermissionContext: [parentDelegation], + }, + { + scheme: 'exact', + network: 'eip155:1', + asset: facilitatorA, + amount: '1', + payTo: payee, + maxTimeoutSeconds: 120, + extra: { facilitatorAddresses: [facilitatorA] }, + }, + ); + + expect(result.createDelegationConfig).toEqual( + expect.objectContaining({ + from, + salt, + parentDelegation, + }), + ); + expect(result.existingDelegations).toStrictEqual([parentDelegation]); + }); + + it('throws when parent permission context does not decode into a delegation', async () => { + await expect( + resolveDelegationCreationContext( + { + account: mockAccount, + environment: baseEnvironment, + caveats: [], + parentPermissionContext: [], + salt: `0x${'33'.repeat(32)}`, + }, + { + scheme: 'exact', + network: 'eip155:1', + asset: facilitatorA, + amount: '1', + payTo: payee, + maxTimeoutSeconds: 120, + extra: { facilitatorAddresses: [facilitatorA] }, + }, + ), + ).rejects.toThrow('Parent permission context is not a valid delegation'); + }); + it('throws when facilitators are missing and no redeemer caveat exists', async () => { await expect( resolveDelegationCreationContext( From 5b186f83e278bc57141b3c1f82464a3d62892c9c Mon Sep 17 00:00:00 2001 From: Jeff Smale <6363749+jeffsmale90@users.noreply.github.com> Date: Tue, 26 May 2026 12:01:29 +1200 Subject: [PATCH 09/17] Make more configuration parameters MaybeDeferred - also add parseEip155ChainId function --- .../src/experimental/index.ts | 1 + .../experimental/x402DelegationProvider.ts | 29 ++++++++---- .../x402DelegationProviderTypes.ts | 8 ++-- .../x402DelegationProviderUtils.ts | 35 ++++++++++----- .../x402DelegationProvider.test.ts | 14 +++++- .../x402DelegationProviderUtils.test.ts | 45 +++++++++++++++++++ 6 files changed, 106 insertions(+), 26 deletions(-) diff --git a/packages/smart-accounts-kit/src/experimental/index.ts b/packages/smart-accounts-kit/src/experimental/index.ts index c07d33c4..67195f6a 100644 --- a/packages/smart-accounts-kit/src/experimental/index.ts +++ b/packages/smart-accounts-kit/src/experimental/index.ts @@ -7,6 +7,7 @@ export { } from './delegationStorage'; export { createx402DelegationProvider, + parseEip155ChainId, type x402DelegationProvider, type x402DelegationProviderConfig, type x402DelegationProviderPaymentPayload, diff --git a/packages/smart-accounts-kit/src/experimental/x402DelegationProvider.ts b/packages/smart-accounts-kit/src/experimental/x402DelegationProvider.ts index c4a1b91b..02d9b5a8 100644 --- a/packages/smart-accounts-kit/src/experimental/x402DelegationProvider.ts +++ b/packages/smart-accounts-kit/src/experimental/x402DelegationProvider.ts @@ -21,6 +21,25 @@ export type { x402DelegationProviderPaymentPayload, } from './x402DelegationProviderTypes'; +/** + * Parses an EIP-155 CAIP network identifier into a numeric chain ID. + * + * @param network - CAIP network identifier (for example, `eip155:1`). + * @returns Parsed numeric chain ID. + * @throws If the CAIP namespace is not `eip155`. + */ +export function parseEip155ChainId(network: PaymentRequirements['network']): number { + const { namespace, reference } = parseCaipChainId( + network as `${string}:${string}`, + ); + + if (namespace !== 'eip155') { + throw new Error('Unsupported chain namespace'); + } + + return parseInt(reference, 10); +} + /** * Creates a delegation provider function for x402 payment requirements. * @@ -46,15 +65,7 @@ export function createx402DelegationProvider( const delegation = createOpenDelegation(createDelegationConfig); - const { namespace, reference } = parseCaipChainId( - requirements.network as `${string}:${string}`, - ); - - if (namespace !== 'eip155') { - throw new Error('Unsupported chain namespace'); - } - - const chainId = parseInt(reference, 10); + const chainId = parseEip155ChainId(requirements.network); const typedData = prepareSignDelegationTypedData({ delegationManager, diff --git a/packages/smart-accounts-kit/src/experimental/x402DelegationProviderTypes.ts b/packages/smart-accounts-kit/src/experimental/x402DelegationProviderTypes.ts index 7fd205fe..20817aa4 100644 --- a/packages/smart-accounts-kit/src/experimental/x402DelegationProviderTypes.ts +++ b/packages/smart-accounts-kit/src/experimental/x402DelegationProviderTypes.ts @@ -53,10 +53,10 @@ export type x402DelegationProvider = ( * `account` is required and is used for signing the delegation. */ export type x402DelegationProviderConfig = { - account: Account; - environment: SmartAccountsEnvironment; - from?: Hex; - salt?: Hex; + account: MaybeDeferred; + environment: MaybeDeferred; + from?: MaybeDeferred; + salt?: MaybeDeferred; caveats?: MaybeDeferred; parentPermissionContext?: MaybeDeferred; }; diff --git a/packages/smart-accounts-kit/src/experimental/x402DelegationProviderUtils.ts b/packages/smart-accounts-kit/src/experimental/x402DelegationProviderUtils.ts index 7c840a64..01e507c5 100644 --- a/packages/smart-accounts-kit/src/experimental/x402DelegationProviderUtils.ts +++ b/packages/smart-accounts-kit/src/experimental/x402DelegationProviderUtils.ts @@ -3,7 +3,7 @@ import { createRedeemerTerms, decodeRedeemerTerms, } from '@metamask/delegation-core'; -import type { Address, Hex } from 'viem'; +import type { Account, Address, Hex } from 'viem'; import type { Caveats } from '../caveatBuilder'; import { resolveCaveats } from '../caveatBuilder'; @@ -47,7 +47,7 @@ type EnsurePayeeSufficientlyConstrainedParams = { * Resolved context required to build and sign an x402 delegation. */ export type DelegationCreationContext = { - account: x402DelegationProviderConfig['account']; + account: Account; delegationManager: Address; existingDelegations: Delegation[]; createDelegationConfig: Parameters[0]; @@ -71,10 +71,18 @@ export type Resolvex402DelegationCaveatsParams = { * @param requirements - Payment requirements passed to deferred resolvers. * @returns The resolved value, or undefined when no value is provided. */ -const resolveMaybeDeferred = async ( +async function resolveMaybeDeferred( + maybeDeferred: MaybeDeferred, + requirements: PaymentRequirements, +): Promise; +async function resolveMaybeDeferred( + maybeDeferred: MaybeDeferred | undefined, + requirements: PaymentRequirements, +): Promise; +async function resolveMaybeDeferred( maybeDeferred: MaybeDeferred | undefined, requirements: PaymentRequirements, -): Promise => { +): Promise { if (typeof maybeDeferred === 'function') { const deferred = maybeDeferred as ( deferredRequirements: PaymentRequirements, @@ -83,7 +91,7 @@ const resolveMaybeDeferred = async ( } return maybeDeferred; -}; +} /** * ERC-20 `transfer(address to, uint256 value)` calldata index for `to`. @@ -318,6 +326,8 @@ export const resolveDelegationCreationContext = async ( config: x402DelegationProviderConfig, requirements: PaymentRequirements, ): Promise => { + const account = await resolveMaybeDeferred(config.account, requirements); + const environment = await resolveMaybeDeferred(config.environment, requirements); const caveatsConfig: Caveats | undefined = await resolveMaybeDeferred( config.caveats, requirements, @@ -325,9 +335,10 @@ export const resolveDelegationCreationContext = async ( const parentPermissionContext: PermissionContext | undefined = await resolveMaybeDeferred(config.parentPermissionContext, requirements); - const { account } = config; - const from = config.from ?? account.address; - const salt = config.salt ?? generateSalt(); + const from = + (await resolveMaybeDeferred(config.from, requirements)) ?? account.address; + const salt = + (await resolveMaybeDeferred(config.salt, requirements)) ?? generateSalt(); const scope = { type: ScopeType.Erc20TransferAmount, @@ -343,9 +354,9 @@ export const resolveDelegationCreationContext = async ( ? decodeDelegations(parentPermissionContext) : []; - const { DelegationManager: delegationManager } = config.environment; + const { DelegationManager: delegationManager } = environment; const caveats = resolvex402DelegationCaveats({ - environment: config.environment, + environment, caveatsConfig, existingDelegations, facilitatorAddresses, @@ -362,7 +373,7 @@ export const resolveDelegationCreationContext = async ( } createDelegationConfig = { - environment: config.environment, + environment, from, caveats, salt, @@ -371,7 +382,7 @@ export const resolveDelegationCreationContext = async ( }; } else { createDelegationConfig = { - environment: config.environment, + environment, from, caveats, salt, diff --git a/packages/smart-accounts-kit/test/experimental/x402DelegationProvider.test.ts b/packages/smart-accounts-kit/test/experimental/x402DelegationProvider.test.ts index 765d93a8..9f160b1b 100644 --- a/packages/smart-accounts-kit/test/experimental/x402DelegationProvider.test.ts +++ b/packages/smart-accounts-kit/test/experimental/x402DelegationProvider.test.ts @@ -37,7 +37,7 @@ vi.mock('../../src/delegation', () => delegationMocks); vi.mock('@metamask/delegation-core', () => delegationCoreMocks); vi.mock('../../src/utils/', () => utilsMocks); -const { createx402DelegationProvider } = +const { createx402DelegationProvider, parseEip155ChainId } = await import('../../src/experimental/x402DelegationProvider'); const mockDelegationManager = @@ -240,3 +240,15 @@ describe('createx402DelegationProvider', () => { ); }); }); + +describe('parseEip155ChainId', () => { + it('parses a valid eip155 CAIP network', () => { + expect(parseEip155ChainId('eip155:8453')).toBe(8453); + }); + + it('throws for non-eip155 namespaces', () => { + expect(() => parseEip155ChainId('cosmos:cosmoshub-4')).toThrow( + 'Unsupported chain namespace', + ); + }); +}); diff --git a/packages/smart-accounts-kit/test/experimental/x402DelegationProviderUtils.test.ts b/packages/smart-accounts-kit/test/experimental/x402DelegationProviderUtils.test.ts index 642bbe63..9a6826dc 100644 --- a/packages/smart-accounts-kit/test/experimental/x402DelegationProviderUtils.test.ts +++ b/packages/smart-accounts-kit/test/experimental/x402DelegationProviderUtils.test.ts @@ -261,6 +261,51 @@ describe('x402DelegationProviderUtils', () => { }); describe('resolveDelegationCreationContext', () => { + it('resolves deferred account, environment, from, and salt', async () => { + const deferredAccount = vi.fn(async () => mockAccount); + const deferredEnvironment = vi.fn(async () => baseEnvironment); + const deferredFrom = vi.fn( + async () => '0xba000000000000000000000000000000000000ba' as const, + ); + const deferredSalt = vi.fn( + async () => + `0x${'55'.repeat(32)}` as `0x${string}`, + ); + + const result = await resolveDelegationCreationContext( + { + account: deferredAccount, + environment: deferredEnvironment, + from: deferredFrom, + salt: deferredSalt, + caveats: [], + }, + { + scheme: 'exact', + network: 'eip155:1', + asset: facilitatorA, + amount: '1', + payTo: payee, + maxTimeoutSeconds: 120, + extra: { facilitatorAddresses: [facilitatorA] }, + }, + ); + + expect(deferredAccount).toHaveBeenCalledOnce(); + expect(deferredEnvironment).toHaveBeenCalledOnce(); + expect(deferredFrom).toHaveBeenCalledOnce(); + expect(deferredSalt).toHaveBeenCalledOnce(); + expect(result.account).toBe(mockAccount); + expect(result.delegationManager).toBe(baseEnvironment.DelegationManager); + expect(result.createDelegationConfig).toEqual( + expect.objectContaining({ + from: '0xba000000000000000000000000000000000000ba', + salt: `0x${'55'.repeat(32)}`, + environment: baseEnvironment, + }), + ); + }); + it('uses deferred caveats and deferred parent permission context', async () => { const parentDelegation = makeDelegation([]); const deferredCaveats = vi.fn(async () => []); From 87296c48592f6eca0516f2d63c38a3c0fdcf2e68 Mon Sep 17 00:00:00 2001 From: Jeff Smale <6363749+jeffsmale90@users.noreply.github.com> Date: Tue, 26 May 2026 14:19:53 +1200 Subject: [PATCH 10/17] Add expirySeconds parameter, build expiry caveat if required --- .../experimental/x402DelegationProvider.ts | 4 +- .../x402DelegationProviderTypes.ts | 1 + .../x402DelegationProviderUtils.ts | 116 ++++++++++-- .../x402DelegationProviderUtils.test.ts | 166 +++++++++++++++++- 4 files changed, 269 insertions(+), 18 deletions(-) diff --git a/packages/smart-accounts-kit/src/experimental/x402DelegationProvider.ts b/packages/smart-accounts-kit/src/experimental/x402DelegationProvider.ts index 02d9b5a8..0d2f9771 100644 --- a/packages/smart-accounts-kit/src/experimental/x402DelegationProvider.ts +++ b/packages/smart-accounts-kit/src/experimental/x402DelegationProvider.ts @@ -28,7 +28,9 @@ export type { * @returns Parsed numeric chain ID. * @throws If the CAIP namespace is not `eip155`. */ -export function parseEip155ChainId(network: PaymentRequirements['network']): number { +export function parseEip155ChainId( + network: PaymentRequirements['network'], +): number { const { namespace, reference } = parseCaipChainId( network as `${string}:${string}`, ); diff --git a/packages/smart-accounts-kit/src/experimental/x402DelegationProviderTypes.ts b/packages/smart-accounts-kit/src/experimental/x402DelegationProviderTypes.ts index 20817aa4..dbf00c25 100644 --- a/packages/smart-accounts-kit/src/experimental/x402DelegationProviderTypes.ts +++ b/packages/smart-accounts-kit/src/experimental/x402DelegationProviderTypes.ts @@ -59,4 +59,5 @@ export type x402DelegationProviderConfig = { salt?: MaybeDeferred; caveats?: MaybeDeferred; parentPermissionContext?: MaybeDeferred; + expirySeconds?: MaybeDeferred; }; diff --git a/packages/smart-accounts-kit/src/experimental/x402DelegationProviderUtils.ts b/packages/smart-accounts-kit/src/experimental/x402DelegationProviderUtils.ts index 01e507c5..a498c8eb 100644 --- a/packages/smart-accounts-kit/src/experimental/x402DelegationProviderUtils.ts +++ b/packages/smart-accounts-kit/src/experimental/x402DelegationProviderUtils.ts @@ -1,7 +1,9 @@ import { createAllowedCalldataTerms, createRedeemerTerms, + createTimestampTerms, decodeRedeemerTerms, + decodeTimestampTerms, } from '@metamask/delegation-core'; import type { Account, Address, Hex } from 'viem'; @@ -43,6 +45,16 @@ type EnsurePayeeSufficientlyConstrainedParams = { payee: Hex; }; +/** + * Inputs for expiry constraint enforcement. + */ +type EnsureExpirySufficientlyConstrainedParams = { + timestampEnforcer: Hex; + caveats: Caveat[]; + existingDelegations: Delegation[]; + expirySeconds: number; +}; + /** * Resolved context required to build and sign an x402 delegation. */ @@ -62,6 +74,7 @@ export type Resolvex402DelegationCaveatsParams = { existingDelegations: Delegation[]; facilitatorAddresses: Hex[] | undefined; payee: Hex; + expirySeconds?: number; }; /** @@ -74,15 +87,7 @@ export type Resolvex402DelegationCaveatsParams = { async function resolveMaybeDeferred( maybeDeferred: MaybeDeferred, requirements: PaymentRequirements, -): Promise; -async function resolveMaybeDeferred( - maybeDeferred: MaybeDeferred | undefined, - requirements: PaymentRequirements, -): Promise; -async function resolveMaybeDeferred( - maybeDeferred: MaybeDeferred | undefined, - requirements: PaymentRequirements, -): Promise { +): Promise { if (typeof maybeDeferred === 'function') { const deferred = maybeDeferred as ( deferredRequirements: PaymentRequirements, @@ -133,6 +138,63 @@ const hasMatchingCaveats = ( match, ); +/** + * Ensures caveats include an expiry timestamp constraint when requested. + * + * If an existing timestamp caveat already enforces a tighter (earlier) or equal + * `validBefore` threshold than requested, caveats are returned unchanged. + * Otherwise a new timestamp caveat is appended with `validAfter = 0`. + * + * @param options0 - Expiry constraint evaluation inputs. + * @param options0.timestampEnforcer - Address of the TimestampEnforcer caveat contract. + * @param options0.caveats - Currently resolved caveats for the delegation being created. + * @param options0.existingDelegations - Existing parent-chain delegations to inspect for inherited constraints. + * @param options0.expirySeconds - Relative expiry from "now" in seconds. + * @returns The original caveats when sufficiently constrained, otherwise caveats with a timestamp caveat appended. + */ +export const ensureExpirySufficientlyConstrained = ({ + timestampEnforcer, + caveats, + existingDelegations, + expirySeconds, +}: EnsureExpirySufficientlyConstrainedParams): Caveat[] => { + const beforeThreshold = Math.floor(Date.now() / 1000) + expirySeconds; + const timestampEnforcerNormalized = normalizeAddress(timestampEnforcer); + + const hasSupersedingTimestampConstraint = hasMatchingCaveats( + caveats, + existingDelegations, + (caveat) => { + if (normalizeAddress(caveat.enforcer) !== timestampEnforcerNormalized) { + return false; + } + + const { beforeThreshold: existingBeforeThreshold } = decodeTimestampTerms( + caveat.terms, + ); + return ( + existingBeforeThreshold !== 0 && + existingBeforeThreshold <= beforeThreshold + ); + }, + ); + + if (hasSupersedingTimestampConstraint) { + return caveats; + } + + const timestampCaveat: Caveat = { + enforcer: timestampEnforcer, + terms: createTimestampTerms({ + afterThreshold: 0, + beforeThreshold, + }), + args: '0x', + }; + + return [...caveats, timestampCaveat]; +}; + /** * Ensures caveats include a sufficiently strict redeemer constraint. * @@ -175,7 +237,7 @@ export const ensureRedeemerSufficientlyConstrained = ({ const facilitatorAddressesNormalized = facilitatorAddresses.map(normalizeAddress); - const hasSufficientlyConstrainedRedeemerCaveat = hasMatchingCaveats( + const hasSupersedingRedeemerCaveat = hasMatchingCaveats( caveats, existingDelegations, (caveat) => { @@ -192,7 +254,7 @@ export const ensureRedeemerSufficientlyConstrained = ({ }, ); - if (hasSufficientlyConstrainedRedeemerCaveat) { + if (hasSupersedingRedeemerCaveat) { return caveats; } @@ -236,7 +298,7 @@ export const ensurePayeeSufficientlyConstrained = ({ const normalizedAllowedCalldataTerms = normalizeAddress(allowedCalldataTerms); - const hasMatchingAllowedCalldataConstraint = hasMatchingCaveats( + const hasSupersedingAllowedCalldataConstraint = hasMatchingCaveats( caveats, existingDelegations, ({ enforcer, terms }) => @@ -244,7 +306,7 @@ export const ensurePayeeSufficientlyConstrained = ({ normalizeAddress(terms) === normalizedAllowedCalldataTerms, ); - if (hasMatchingAllowedCalldataConstraint) { + if (hasSupersedingAllowedCalldataConstraint) { return caveats; } @@ -266,6 +328,7 @@ export const ensurePayeeSufficientlyConstrained = ({ * @param options0.existingDelegations - Existing parent-chain delegations. * @param options0.facilitatorAddresses - Optional facilitator addresses used for redeemer constraints. * @param options0.payee - Payee address used for allowed calldata constraints. + * @param options0.expirySeconds - Optional relative expiry in seconds for adding timestamp constraints. * @returns Caveats after redeemer and payee constraints are enforced. */ export const resolvex402DelegationCaveats = ({ @@ -274,11 +337,13 @@ export const resolvex402DelegationCaveats = ({ existingDelegations, facilitatorAddresses, payee, + expirySeconds, }: Resolvex402DelegationCaveatsParams): Caveat[] => { const { caveatEnforcers: { RedeemerEnforcer: redeemerEnforcer, AllowedCalldataEnforcer: allowedCalldataEnforcer, + TimestampEnforcer: timestampEnforcer, }, } = environment; @@ -312,7 +377,20 @@ export const resolvex402DelegationCaveats = ({ payee, }); - return caveatsWithPayee; + if (expirySeconds === undefined) { + return caveatsWithPayee; + } + + if (!timestampEnforcer) { + throw new Error('TimestampEnforcer not found in environment'); + } + + return ensureExpirySufficientlyConstrained({ + timestampEnforcer, + caveats: caveatsWithPayee, + existingDelegations, + expirySeconds, + }); }; /** @@ -327,13 +405,20 @@ export const resolveDelegationCreationContext = async ( requirements: PaymentRequirements, ): Promise => { const account = await resolveMaybeDeferred(config.account, requirements); - const environment = await resolveMaybeDeferred(config.environment, requirements); + const environment = await resolveMaybeDeferred( + config.environment, + requirements, + ); const caveatsConfig: Caveats | undefined = await resolveMaybeDeferred( config.caveats, requirements, ); const parentPermissionContext: PermissionContext | undefined = await resolveMaybeDeferred(config.parentPermissionContext, requirements); + const expirySeconds = await resolveMaybeDeferred( + config.expirySeconds, + requirements, + ); const from = (await resolveMaybeDeferred(config.from, requirements)) ?? account.address; @@ -361,6 +446,7 @@ export const resolveDelegationCreationContext = async ( existingDelegations, facilitatorAddresses, payee: requirements.payTo as Hex, + expirySeconds, }); let createDelegationConfig: Parameters[0]; diff --git a/packages/smart-accounts-kit/test/experimental/x402DelegationProviderUtils.test.ts b/packages/smart-accounts-kit/test/experimental/x402DelegationProviderUtils.test.ts index 9a6826dc..bc8157c1 100644 --- a/packages/smart-accounts-kit/test/experimental/x402DelegationProviderUtils.test.ts +++ b/packages/smart-accounts-kit/test/experimental/x402DelegationProviderUtils.test.ts @@ -1,6 +1,8 @@ import { createAllowedCalldataTerms, createRedeemerTerms, + createTimestampTerms, + decodeTimestampTerms, } from '@metamask/delegation-core'; import type { Hex, Account } from 'viem'; import { describe, expect, it, vi } from 'vitest'; @@ -20,6 +22,7 @@ import type { const redeemerEnforcer = '0x1000000000000000000000000000000000000001' as const; const allowedCalldataEnforcer = '0x2000000000000000000000000000000000000002' as const; +const timestampEnforcer = '0x2000000000000000000000000000000000000003' as const; const facilitatorA = '0x3000000000000000000000000000000000000003' as const; const facilitatorB = '0x4000000000000000000000000000000000000004' as const; const facilitatorC = '0x5000000000000000000000000000000000000005' as const; @@ -34,6 +37,7 @@ const baseEnvironment = { caveatEnforcers: { RedeemerEnforcer: redeemerEnforcer, AllowedCalldataEnforcer: allowedCalldataEnforcer, + TimestampEnforcer: timestampEnforcer, }, } as SmartAccountsEnvironment; const mockAccount = { @@ -258,6 +262,124 @@ describe('x402DelegationProviderUtils', () => { }), ).toThrow('AllowedCalldataEnforcer not found in environment'); }); + + it('adds a timestamp caveat when expirySeconds is specified', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2024-01-01T00:00:00Z')); + + const result = resolvex402DelegationCaveats({ + environment: baseEnvironment, + caveatsConfig: [], + existingDelegations: [], + facilitatorAddresses: [facilitatorA], + payee, + expirySeconds: 3600, + }); + + const timestampCaveat = result.find( + ({ enforcer }) => enforcer === timestampEnforcer, + ); + expect(timestampCaveat).toBeDefined(); + if (!timestampCaveat) { + throw new Error('Expected timestamp caveat to be present'); + } + expect(decodeTimestampTerms(timestampCaveat.terms)).toStrictEqual({ + afterThreshold: 0, + beforeThreshold: 1704070800, + }); + + vi.useRealTimers(); + }); + + it('does not add a timestamp caveat when an existing caveat is more restrictive', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2024-01-01T00:00:00Z')); + + const result = resolvex402DelegationCaveats({ + environment: baseEnvironment, + caveatsConfig: [], + existingDelegations: [ + makeDelegation([ + { + enforcer: timestampEnforcer, + terms: createTimestampTerms({ + afterThreshold: 0, + beforeThreshold: 1704070000, + }), + args: '0x', + }, + ]), + ], + facilitatorAddresses: [facilitatorA], + payee, + expirySeconds: 3600, + }); + + const timestampCaveats = result.filter( + ({ enforcer }) => enforcer === timestampEnforcer, + ); + expect(timestampCaveats).toHaveLength(0); + + vi.useRealTimers(); + }); + + it('adds a timestamp caveat when existing timestamp has no upper bound', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2024-01-01T00:00:00Z')); + + const result = resolvex402DelegationCaveats({ + environment: baseEnvironment, + caveatsConfig: [], + existingDelegations: [ + makeDelegation([ + { + enforcer: timestampEnforcer, + terms: createTimestampTerms({ + afterThreshold: 0, + beforeThreshold: 0, + }), + args: '0x', + }, + ]), + ], + facilitatorAddresses: [facilitatorA], + payee, + expirySeconds: 3600, + }); + + const timestampCaveat = result.find( + ({ enforcer }) => enforcer === timestampEnforcer, + ); + expect(timestampCaveat).toBeDefined(); + if (!timestampCaveat) { + throw new Error('Expected timestamp caveat to be present'); + } + expect(decodeTimestampTerms(timestampCaveat.terms)).toStrictEqual({ + afterThreshold: 0, + beforeThreshold: 1704070800, + }); + + vi.useRealTimers(); + }); + + it('throws when expirySeconds is specified but TimestampEnforcer is missing', () => { + expect(() => + resolvex402DelegationCaveats({ + environment: { + ...baseEnvironment, + caveatEnforcers: { + ...baseEnvironment.caveatEnforcers, + TimestampEnforcer: undefined as unknown as Hex, + }, + }, + caveatsConfig: [], + existingDelegations: [], + facilitatorAddresses: [facilitatorA], + payee, + expirySeconds: 60, + }), + ).toThrow('TimestampEnforcer not found in environment'); + }); }); describe('resolveDelegationCreationContext', () => { @@ -268,8 +390,7 @@ describe('x402DelegationProviderUtils', () => { async () => '0xba000000000000000000000000000000000000ba' as const, ); const deferredSalt = vi.fn( - async () => - `0x${'55'.repeat(32)}` as `0x${string}`, + async (): Promise => `0x${'55'.repeat(32)}`, ); const result = await resolveDelegationCreationContext( @@ -419,5 +540,46 @@ describe('x402DelegationProviderUtils', () => { ), ).rejects.toThrow('Redeemer must be constrained'); }); + + it('resolves deferred expirySeconds and applies timestamp caveat', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2024-01-01T00:00:00Z')); + + const deferredExpirySeconds = vi.fn(async () => 120); + const result = await resolveDelegationCreationContext( + { + account: mockAccount, + environment: baseEnvironment, + caveats: [], + salt: `0x${'66'.repeat(32)}`, + expirySeconds: deferredExpirySeconds, + }, + { + scheme: 'exact', + network: 'eip155:1', + asset: facilitatorA, + amount: '1', + payTo: payee, + maxTimeoutSeconds: 120, + extra: { facilitatorAddresses: [facilitatorA] }, + }, + ); + + expect(deferredExpirySeconds).toHaveBeenCalledOnce(); + const caveats = result.createDelegationConfig.caveats as Caveat[]; + const timestampCaveat = caveats.find( + ({ enforcer }) => enforcer === timestampEnforcer, + ); + expect(timestampCaveat).toBeDefined(); + if (!timestampCaveat) { + throw new Error('Expected timestamp caveat to be present'); + } + expect(decodeTimestampTerms(timestampCaveat.terms)).toStrictEqual({ + afterThreshold: 0, + beforeThreshold: 1704067320, + }); + + vi.useRealTimers(); + }); }); }); From 707a08cc121b586cfd2a62b82d5dd6f566d83ee4 Mon Sep 17 00:00:00 2001 From: Jeff Smale <6363749+jeffsmale90@users.noreply.github.com> Date: Tue, 26 May 2026 14:28:55 +1200 Subject: [PATCH 11/17] Make environment parameter optional resolve using getMetaMaskSmartAccountEnvironment if not specified --- .../experimental/x402DelegationProvider.ts | 28 +++----------- .../x402DelegationProviderTypes.ts | 2 +- .../x402DelegationProviderUtils.ts | 30 +++++++++++++-- .../x402DelegationProviderUtils.test.ts | 38 +++++++++++++++++++ 4 files changed, 70 insertions(+), 28 deletions(-) diff --git a/packages/smart-accounts-kit/src/experimental/x402DelegationProvider.ts b/packages/smart-accounts-kit/src/experimental/x402DelegationProvider.ts index 0d2f9771..329dcff6 100644 --- a/packages/smart-accounts-kit/src/experimental/x402DelegationProvider.ts +++ b/packages/smart-accounts-kit/src/experimental/x402DelegationProvider.ts @@ -1,5 +1,3 @@ -import { parseCaipChainId } from '@metamask/utils'; - import { createOpenDelegation, encodeDelegations, @@ -11,7 +9,10 @@ import type { x402DelegationProviderConfig, x402DelegationProviderPaymentPayload, } from './x402DelegationProviderTypes'; -import { resolveDelegationCreationContext } from './x402DelegationProviderUtils'; +import { + parseEip155ChainId, + resolveDelegationCreationContext, +} from './x402DelegationProviderUtils'; export type { MaybeDeferred, @@ -21,26 +22,7 @@ export type { x402DelegationProviderPaymentPayload, } from './x402DelegationProviderTypes'; -/** - * Parses an EIP-155 CAIP network identifier into a numeric chain ID. - * - * @param network - CAIP network identifier (for example, `eip155:1`). - * @returns Parsed numeric chain ID. - * @throws If the CAIP namespace is not `eip155`. - */ -export function parseEip155ChainId( - network: PaymentRequirements['network'], -): number { - const { namespace, reference } = parseCaipChainId( - network as `${string}:${string}`, - ); - - if (namespace !== 'eip155') { - throw new Error('Unsupported chain namespace'); - } - - return parseInt(reference, 10); -} +export { parseEip155ChainId } from './x402DelegationProviderUtils'; /** * Creates a delegation provider function for x402 payment requirements. diff --git a/packages/smart-accounts-kit/src/experimental/x402DelegationProviderTypes.ts b/packages/smart-accounts-kit/src/experimental/x402DelegationProviderTypes.ts index dbf00c25..a14374e4 100644 --- a/packages/smart-accounts-kit/src/experimental/x402DelegationProviderTypes.ts +++ b/packages/smart-accounts-kit/src/experimental/x402DelegationProviderTypes.ts @@ -54,7 +54,7 @@ export type x402DelegationProvider = ( */ export type x402DelegationProviderConfig = { account: MaybeDeferred; - environment: MaybeDeferred; + environment?: MaybeDeferred; from?: MaybeDeferred; salt?: MaybeDeferred; caveats?: MaybeDeferred; diff --git a/packages/smart-accounts-kit/src/experimental/x402DelegationProviderUtils.ts b/packages/smart-accounts-kit/src/experimental/x402DelegationProviderUtils.ts index a498c8eb..247de7b5 100644 --- a/packages/smart-accounts-kit/src/experimental/x402DelegationProviderUtils.ts +++ b/packages/smart-accounts-kit/src/experimental/x402DelegationProviderUtils.ts @@ -5,6 +5,7 @@ import { decodeRedeemerTerms, decodeTimestampTerms, } from '@metamask/delegation-core'; +import { parseCaipChainId } from '@metamask/utils'; import type { Account, Address, Hex } from 'viem'; import type { Caveats } from '../caveatBuilder'; @@ -12,6 +13,7 @@ import { resolveCaveats } from '../caveatBuilder'; import { ScopeType } from '../constants'; import type { createOpenDelegation } from '../delegation'; import { decodeDelegations } from '../delegation'; +import { getSmartAccountsEnvironment } from '../smartAccountsEnvironment'; import type { Caveat, Delegation, @@ -98,6 +100,27 @@ async function resolveMaybeDeferred( return maybeDeferred; } +/** + * Parses an EIP-155 CAIP network identifier into a numeric chain ID. + * + * @param network - CAIP network identifier (for example, `eip155:1`). + * @returns Parsed numeric chain ID. + * @throws If the CAIP namespace is not `eip155`. + */ +export function parseEip155ChainId( + network: PaymentRequirements['network'], +): number { + const { namespace, reference } = parseCaipChainId( + network as `${string}:${string}`, + ); + + if (namespace !== 'eip155') { + throw new Error('Unsupported chain namespace'); + } + + return parseInt(reference, 10); +} + /** * ERC-20 `transfer(address to, uint256 value)` calldata index for `to`. */ @@ -405,10 +428,9 @@ export const resolveDelegationCreationContext = async ( requirements: PaymentRequirements, ): Promise => { const account = await resolveMaybeDeferred(config.account, requirements); - const environment = await resolveMaybeDeferred( - config.environment, - requirements, - ); + const environment = + (await resolveMaybeDeferred(config.environment, requirements)) ?? + getSmartAccountsEnvironment(parseEip155ChainId(requirements.network)); const caveatsConfig: Caveats | undefined = await resolveMaybeDeferred( config.caveats, requirements, diff --git a/packages/smart-accounts-kit/test/experimental/x402DelegationProviderUtils.test.ts b/packages/smart-accounts-kit/test/experimental/x402DelegationProviderUtils.test.ts index bc8157c1..71f604c5 100644 --- a/packages/smart-accounts-kit/test/experimental/x402DelegationProviderUtils.test.ts +++ b/packages/smart-accounts-kit/test/experimental/x402DelegationProviderUtils.test.ts @@ -13,6 +13,7 @@ import { resolvex402DelegationCaveats, resolveDelegationCreationContext, } from '../../src/experimental/x402DelegationProviderUtils'; +import * as smartAccountsEnvironmentModule from '../../src/smartAccountsEnvironment'; import type { Caveat, Delegation, @@ -383,6 +384,43 @@ describe('x402DelegationProviderUtils', () => { }); describe('resolveDelegationCreationContext', () => { + it('resolves environment from network when environment is not provided', async () => { + const getEnvironmentSpy = vi + .spyOn(smartAccountsEnvironmentModule, 'getSmartAccountsEnvironment') + .mockReturnValue(baseEnvironment); + + try { + const result = await resolveDelegationCreationContext( + { + account: mockAccount, + caveats: [], + salt: `0x${'77'.repeat(32)}`, + }, + { + scheme: 'exact', + network: 'eip155:1', + asset: facilitatorA, + amount: '1', + payTo: payee, + maxTimeoutSeconds: 120, + extra: { facilitatorAddresses: [facilitatorA] }, + }, + ); + + expect(getEnvironmentSpy).toHaveBeenCalledWith(1); + expect(result.delegationManager).toBe( + baseEnvironment.DelegationManager, + ); + expect(result.createDelegationConfig).toEqual( + expect.objectContaining({ + environment: baseEnvironment, + }), + ); + } finally { + getEnvironmentSpy.mockRestore(); + } + }); + it('resolves deferred account, environment, from, and salt', async () => { const deferredAccount = vi.fn(async () => mockAccount); const deferredEnvironment = vi.fn(async () => baseEnvironment); From b9fe1e3cdc0944336db59ebd6f47c40dd7eb2b76 Mon Sep 17 00:00:00 2001 From: Jeff Smale <6363749+jeffsmale90@users.noreply.github.com> Date: Tue, 26 May 2026 14:34:40 +1200 Subject: [PATCH 12/17] MaybeDeferred configuration parameters may be syncronous or asyncronous --- .../x402DelegationProviderTypes.ts | 2 +- .../x402DelegationProviderUtils.ts | 3 +- .../x402DelegationProviderUtils.test.ts | 41 +++++++++++++++++++ 3 files changed, 44 insertions(+), 2 deletions(-) diff --git a/packages/smart-accounts-kit/src/experimental/x402DelegationProviderTypes.ts b/packages/smart-accounts-kit/src/experimental/x402DelegationProviderTypes.ts index a14374e4..771206ff 100644 --- a/packages/smart-accounts-kit/src/experimental/x402DelegationProviderTypes.ts +++ b/packages/smart-accounts-kit/src/experimental/x402DelegationProviderTypes.ts @@ -38,7 +38,7 @@ export type x402DelegationProviderPaymentPayload = { */ export type MaybeDeferred = | TResult - | ((requirements: PaymentRequirements) => Promise); + | ((requirements: PaymentRequirements) => Promise | TResult); /** * Function that turns payment requirements into a signed delegation payload. diff --git a/packages/smart-accounts-kit/src/experimental/x402DelegationProviderUtils.ts b/packages/smart-accounts-kit/src/experimental/x402DelegationProviderUtils.ts index 247de7b5..6b6d03f0 100644 --- a/packages/smart-accounts-kit/src/experimental/x402DelegationProviderUtils.ts +++ b/packages/smart-accounts-kit/src/experimental/x402DelegationProviderUtils.ts @@ -93,7 +93,8 @@ async function resolveMaybeDeferred( if (typeof maybeDeferred === 'function') { const deferred = maybeDeferred as ( deferredRequirements: PaymentRequirements, - ) => Promise; + ) => Promise | TResult; + return await deferred(requirements); } diff --git a/packages/smart-accounts-kit/test/experimental/x402DelegationProviderUtils.test.ts b/packages/smart-accounts-kit/test/experimental/x402DelegationProviderUtils.test.ts index 71f604c5..48e3dca3 100644 --- a/packages/smart-accounts-kit/test/experimental/x402DelegationProviderUtils.test.ts +++ b/packages/smart-accounts-kit/test/experimental/x402DelegationProviderUtils.test.ts @@ -619,5 +619,46 @@ describe('x402DelegationProviderUtils', () => { vi.useRealTimers(); }); + + it('resolves synchronous deferred expirySeconds and applies timestamp caveat', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2024-01-01T00:00:00Z')); + + const deferredExpirySeconds = vi.fn(() => 90); + const result = await resolveDelegationCreationContext( + { + account: mockAccount, + environment: baseEnvironment, + caveats: [], + salt: `0x${'66'.repeat(32)}`, + expirySeconds: deferredExpirySeconds, + }, + { + scheme: 'exact', + network: 'eip155:1', + asset: facilitatorA, + amount: '1', + payTo: payee, + maxTimeoutSeconds: 120, + extra: { facilitatorAddresses: [facilitatorA] }, + }, + ); + + expect(deferredExpirySeconds).toHaveBeenCalledOnce(); + const caveats = result.createDelegationConfig.caveats as Caveat[]; + const timestampCaveat = caveats.find( + ({ enforcer }) => enforcer === timestampEnforcer, + ); + expect(timestampCaveat).toBeDefined(); + if (!timestampCaveat) { + throw new Error('Expected timestamp caveat to be present'); + } + expect(decodeTimestampTerms(timestampCaveat.terms)).toStrictEqual({ + afterThreshold: 0, + beforeThreshold: 1704067290, + }); + + vi.useRealTimers(); + }); }); }); From 7823e1654c3f46762405dabeef76316ffda118cb Mon Sep 17 00:00:00 2001 From: Jeff Smale <6363749+jeffsmale90@users.noreply.github.com> Date: Tue, 26 May 2026 14:51:15 +1200 Subject: [PATCH 13/17] Remove unused import from @metamask/x402 --- packages/x402/src/x402Client.ts | 1 - packages/x402/src/x402Server.ts | 1 + 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/x402/src/x402Client.ts b/packages/x402/src/x402Client.ts index 01f0c6e7..c2b0b02d 100644 --- a/packages/x402/src/x402Client.ts +++ b/packages/x402/src/x402Client.ts @@ -1,4 +1,3 @@ -import { parseCaipChainId } from "@metamask/utils"; import { type Hex, getAddress, isHex } from 'viem'; export type x402PaymentRequirements = { diff --git a/packages/x402/src/x402Server.ts b/packages/x402/src/x402Server.ts index fc7c4476..784958a7 100644 --- a/packages/x402/src/x402Server.ts +++ b/packages/x402/src/x402Server.ts @@ -1,4 +1,5 @@ import { type Address, getAddress } from 'viem'; + import type { x402PaymentRequirements } from './x402Client'; export type x402Erc7710ServerConfig = { From 0993e551bd785058e51dc82199ce30c43ffc9a13 Mon Sep 17 00:00:00 2001 From: Jeff Smale <6363749+jeffsmale90@users.noreply.github.com> Date: Tue, 26 May 2026 14:55:24 +1200 Subject: [PATCH 14/17] Add changelog --- packages/smart-accounts-kit/CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/smart-accounts-kit/CHANGELOG.md b/packages/smart-accounts-kit/CHANGELOG.md index 61860817..51e480ce 100644 --- a/packages/smart-accounts-kit/CHANGELOG.md +++ b/packages/smart-accounts-kit/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Experimental `createx402DelegationProvider` added to @metamask/smart-accounts-kit/experimental ([#248](https://github.com/MetaMask/smart-accounts-kit/pull/248)) +- New `generateSalt` function added to @metamask/smart-accounts-kit/utils ([#248](https://github.com/MetaMask/smart-accounts-kit/pull/248)) - ERC-7715 `token-approval-revocation` permission type ([#226](https://github.com/MetaMask/smart-accounts-kit/pull/226), [#237](https://github.com/MetaMask/smart-accounts-kit/pull/237)) - `CaveatBuilder` for `ApprovalRevocationEnforcer`, deployment address added to `SmartAccountsEnvironment` ([#226](https://github.com/metamask/smart-accounts-kit/pull/226), [#237](https://github.com/MetaMask/smart-accounts-kit/pull/237)) From 952491900479a08c2f746864c94334d84263bd17 Mon Sep 17 00:00:00 2001 From: Jeff Smale <6363749+jeffsmale90@users.noreply.github.com> Date: Tue, 26 May 2026 15:26:07 +1200 Subject: [PATCH 15/17] Readability pass --- .../x402DelegationProviderUtils.ts | 56 +++++++++++-------- .../x402DelegationProvider.test.ts | 13 ++++- 2 files changed, 44 insertions(+), 25 deletions(-) diff --git a/packages/smart-accounts-kit/src/experimental/x402DelegationProviderUtils.ts b/packages/smart-accounts-kit/src/experimental/x402DelegationProviderUtils.ts index 6b6d03f0..f5697d62 100644 --- a/packages/smart-accounts-kit/src/experimental/x402DelegationProviderUtils.ts +++ b/packages/smart-accounts-kit/src/experimental/x402DelegationProviderUtils.ts @@ -135,16 +135,6 @@ const TRANSFER_PAYEE_INDEX = 4; */ const normalizeAddress = (address: Hex): string => address.toLowerCase(); -/** - * Checks whether every item in `subset` appears in `superset`. - * - * @param subset - Candidate subset values. - * @param superset - Candidate superset values. - * @returns True when `subset` is fully contained in `superset`. - */ -const isSubset = (subset: string[], superset: string[]): boolean => - subset.every((item) => superset.includes(item)); - /** * Returns whether any caveat in local or inherited delegations matches a predicate. * @@ -157,17 +147,30 @@ const hasMatchingCaveats = ( caveats: Caveat[], delegations: Delegation[], match: (caveat: Caveat) => boolean, -): boolean => - [...caveats, ...delegations.flatMap((delegation) => delegation.caveats)].some( - match, - ); +): boolean => { + for (const caveat of caveats) { + if (match(caveat)) { + return true; + } + } + + for (const delegation of delegations) { + for (const caveat of delegation.caveats) { + if (match(caveat)) { + return true; + } + } + } + + return false; +}; /** * Ensures caveats include an expiry timestamp constraint when requested. * * If an existing timestamp caveat already enforces a tighter (earlier) or equal * `validBefore` threshold than requested, caveats are returned unchanged. - * Otherwise a new timestamp caveat is appended with `validAfter = 0`. + * Otherwise a new timestamp caveat is appended with `afterThreshold = 0`. * * @param options0 - Expiry constraint evaluation inputs. * @param options0.timestampEnforcer - Address of the TimestampEnforcer caveat contract. @@ -260,6 +263,7 @@ export const ensureRedeemerSufficientlyConstrained = ({ const facilitatorAddressesNormalized = facilitatorAddresses.map(normalizeAddress); + const facilitatorAddressesSet = new Set(facilitatorAddressesNormalized); const hasSupersedingRedeemerCaveat = hasMatchingCaveats( caveats, @@ -273,8 +277,10 @@ export const ensureRedeemerSufficientlyConstrained = ({ caveat.terms, ).redeemers.map(normalizeAddress); - // if this redeemer caveat only allows (some of) thefacilitator addresses, it is sufficiently constrained - return isSubset(allowedRedeemerAddresses, facilitatorAddressesNormalized); + // If this redeemer caveat only allows facilitator addresses, it is sufficiently constrained. + return allowedRedeemerAddresses.every((item) => + facilitatorAddressesSet.has(item), + ); }, ); @@ -379,6 +385,10 @@ export const resolvex402DelegationCaveats = ({ throw new Error('AllowedCalldataEnforcer not found in environment'); } + if (!timestampEnforcer) { + throw new Error('TimestampEnforcer not found in environment'); + } + const initialCaveats = resolveCaveats({ environment, caveats: caveatsConfig, @@ -405,10 +415,6 @@ export const resolvex402DelegationCaveats = ({ return caveatsWithPayee; } - if (!timestampEnforcer) { - throw new Error('TimestampEnforcer not found in environment'); - } - return ensureExpirySufficientlyConstrained({ timestampEnforcer, caveats: caveatsWithPayee, @@ -429,9 +435,15 @@ export const resolveDelegationCreationContext = async ( requirements: PaymentRequirements, ): Promise => { const account = await resolveMaybeDeferred(config.account, requirements); + + const specifiedEnvironment = await resolveMaybeDeferred( + config.environment, + requirements, + ); const environment = - (await resolveMaybeDeferred(config.environment, requirements)) ?? + specifiedEnvironment ?? getSmartAccountsEnvironment(parseEip155ChainId(requirements.network)); + const caveatsConfig: Caveats | undefined = await resolveMaybeDeferred( config.caveats, requirements, diff --git a/packages/smart-accounts-kit/test/experimental/x402DelegationProvider.test.ts b/packages/smart-accounts-kit/test/experimental/x402DelegationProvider.test.ts index 9f160b1b..09f99d51 100644 --- a/packages/smart-accounts-kit/test/experimental/x402DelegationProvider.test.ts +++ b/packages/smart-accounts-kit/test/experimental/x402DelegationProvider.test.ts @@ -45,6 +45,8 @@ const mockDelegationManager = const mockRedeemerEnforcer = '0x2000000000000000000000000000000000000002' as Hex; const mockPayeeEnforcer = '0x2000000000000000000000000000000000000004' as Hex; +const mockTimestampEnforcer = + '0x2000000000000000000000000000000000000005' as Hex; const mockDelegator = '0x3000000000000000000000000000000000000003' as Hex; const mockSignature = '0xabc123' as Hex; const mockTypedData = { domain: {}, message: {} }; @@ -79,15 +81,20 @@ const createMockAccount = (overrides: Partial = {}): Account => const createMockEnvironment = ( overrides: Partial = {}, -): SmartAccountsEnvironment => - ({ +): SmartAccountsEnvironment => { + const overrideCaveatEnforcers = overrides.caveatEnforcers ?? {}; + + return { DelegationManager: mockDelegationManager, caveatEnforcers: { RedeemerEnforcer: mockRedeemerEnforcer, AllowedCalldataEnforcer: mockPayeeEnforcer, + TimestampEnforcer: mockTimestampEnforcer, + ...overrideCaveatEnforcers, }, ...overrides, - }) as SmartAccountsEnvironment; + } as SmartAccountsEnvironment; +}; describe('createx402DelegationProvider', () => { beforeEach(() => { From d0123250403a54b70851c805c040511ba931f304 Mon Sep 17 00:00:00 2001 From: Jeff Smale <6363749+jeffsmale90@users.noreply.github.com> Date: Wed, 27 May 2026 09:08:56 +1200 Subject: [PATCH 16/17] Add redeemer config, allowing caller to specify whether redeemer constraint is required, and which addresses should be constrained --- .../x402DelegationProviderTypes.ts | 11 +- .../x402DelegationProviderUtils.ts | 54 ++++-- .../x402DelegationProviderUtils.test.ts | 172 +++++++++++++++++- 3 files changed, 218 insertions(+), 19 deletions(-) diff --git a/packages/smart-accounts-kit/src/experimental/x402DelegationProviderTypes.ts b/packages/smart-accounts-kit/src/experimental/x402DelegationProviderTypes.ts index 771206ff..959e5741 100644 --- a/packages/smart-accounts-kit/src/experimental/x402DelegationProviderTypes.ts +++ b/packages/smart-accounts-kit/src/experimental/x402DelegationProviderTypes.ts @@ -1,4 +1,4 @@ -import type { Account, Hex } from 'viem'; +import type { Account, Address, Hex } from 'viem'; import type { Caveats } from '../caveatBuilder'; import type { PermissionContext, SmartAccountsEnvironment } from '../types'; @@ -47,6 +47,14 @@ export type x402DelegationProvider = ( paymentRequirements: PaymentRequirements, ) => Promise; +/** + * Configuration for redeemer constraint enforcement. + */ +export type RedeemersConfig = { + requireRedeemers: boolean; + addresses?: MaybeDeferred; +}; + /** * Configuration used to create a x402DelegationProvider. * @@ -60,4 +68,5 @@ export type x402DelegationProviderConfig = { caveats?: MaybeDeferred; parentPermissionContext?: MaybeDeferred; expirySeconds?: MaybeDeferred; + redeemers?: MaybeDeferred; }; diff --git a/packages/smart-accounts-kit/src/experimental/x402DelegationProviderUtils.ts b/packages/smart-accounts-kit/src/experimental/x402DelegationProviderUtils.ts index f5697d62..882697c2 100644 --- a/packages/smart-accounts-kit/src/experimental/x402DelegationProviderUtils.ts +++ b/packages/smart-accounts-kit/src/experimental/x402DelegationProviderUtils.ts @@ -34,7 +34,8 @@ type EnsureRedeemerSufficientlyConstrainedParams = { redeemerEnforcer: Hex; caveats: Caveat[]; existingDelegations: Delegation[]; - facilitatorAddresses: Hex[] | undefined; + redeemerAddresses: Hex[]; + requireRedeemers: boolean; }; /** @@ -77,6 +78,8 @@ export type Resolvex402DelegationCaveatsParams = { facilitatorAddresses: Hex[] | undefined; payee: Hex; expirySeconds?: number; + requireRedeemers: boolean; + redeemerAddresses: Address[] | undefined; }; /** @@ -133,7 +136,8 @@ const TRANSFER_PAYEE_INDEX = 4; * @param address - Address value to normalize. * @returns Lowercased hex string. */ -const normalizeAddress = (address: Hex): string => address.toLowerCase(); +const normalizeAddress = (address: Address): Address => + address.toLowerCase() as Address; /** * Returns whether any caveat in local or inherited delegations matches a predicate. @@ -232,7 +236,8 @@ export const ensureExpirySufficientlyConstrained = ({ * @param options0.redeemerEnforcer - Address of the redeemer enforcer caveat contract. * @param options0.caveats - Currently resolved caveats for the delegation being created. * @param options0.existingDelegations - Existing parent-chain delegations to inspect for inherited constraints. - * @param options0.facilitatorAddresses - Optional facilitator addresses from payment requirements used to bound redeemers. + * @param options0.redeemerAddresses - Optional addresses to which redemption should be constrained. + * @param options0.requireRedeemers - Whether at least one redeemer constraint must exist when no redeemer addresses are supplied. * @returns The original caveats when sufficiently constrained, otherwise caveats with a redeemer caveat appended. * @throws If no facilitator addresses are provided and no redeemer constraint exists. */ @@ -240,11 +245,16 @@ export const ensureRedeemerSufficientlyConstrained = ({ redeemerEnforcer, caveats, existingDelegations, - facilitatorAddresses, + redeemerAddresses, + requireRedeemers, }: EnsureRedeemerSufficientlyConstrainedParams): Caveat[] => { const redeemerAddressNormalized = normalizeAddress(redeemerEnforcer); - if (!facilitatorAddresses || facilitatorAddresses.length === 0) { + if (redeemerAddresses.length === 0) { + if (!requireRedeemers) { + return caveats; + } + const hasExistingRedeemerCaveat = hasMatchingCaveats( caveats, existingDelegations, @@ -261,9 +271,8 @@ export const ensureRedeemerSufficientlyConstrained = ({ return caveats; } - const facilitatorAddressesNormalized = - facilitatorAddresses.map(normalizeAddress); - const facilitatorAddressesSet = new Set(facilitatorAddressesNormalized); + const redeemerAddressesNormalized = redeemerAddresses.map(normalizeAddress); + const redeemerAddressesSet = new Set(redeemerAddressesNormalized); const hasSupersedingRedeemerCaveat = hasMatchingCaveats( caveats, @@ -279,7 +288,7 @@ export const ensureRedeemerSufficientlyConstrained = ({ // If this redeemer caveat only allows facilitator addresses, it is sufficiently constrained. return allowedRedeemerAddresses.every((item) => - facilitatorAddressesSet.has(item), + redeemerAddressesSet.has(item), ); }, ); @@ -290,7 +299,7 @@ export const ensureRedeemerSufficientlyConstrained = ({ const redeemerCaveat: Caveat = { enforcer: redeemerEnforcer, - terms: createRedeemerTerms({ redeemers: facilitatorAddresses }), + terms: createRedeemerTerms({ redeemers: redeemerAddresses }), args: '0x', }; @@ -359,6 +368,8 @@ export const ensurePayeeSufficientlyConstrained = ({ * @param options0.facilitatorAddresses - Optional facilitator addresses used for redeemer constraints. * @param options0.payee - Payee address used for allowed calldata constraints. * @param options0.expirySeconds - Optional relative expiry in seconds for adding timestamp constraints. + * @param options0.requireRedeemers - Whether redeemer constraints are mandatory when no facilitator or configured redeemer addresses are present. + * @param options0.redeemerAddresses - Optional redeemer addresses from provider config to merge with facilitator addresses. * @returns Caveats after redeemer and payee constraints are enforced. */ export const resolvex402DelegationCaveats = ({ @@ -368,6 +379,8 @@ export const resolvex402DelegationCaveats = ({ facilitatorAddresses, payee, expirySeconds, + requireRedeemers, + redeemerAddresses, }: Resolvex402DelegationCaveatsParams): Caveat[] => { const { caveatEnforcers: { @@ -397,11 +410,18 @@ export const resolvex402DelegationCaveats = ({ isScopeOptional: true, }); + const allRedeemerAddresses = new Set( + [...(facilitatorAddresses ?? []), ...(redeemerAddresses ?? [])].map( + normalizeAddress, + ), + ); + const caveatsWithRedeemer = ensureRedeemerSufficientlyConstrained({ redeemerEnforcer, caveats: initialCaveats, existingDelegations, - facilitatorAddresses, + redeemerAddresses: Array.from(allRedeemerAddresses), + requireRedeemers, }); const caveatsWithPayee = ensurePayeeSufficientlyConstrained({ @@ -435,6 +455,16 @@ export const resolveDelegationCreationContext = async ( requirements: PaymentRequirements, ): Promise => { const account = await resolveMaybeDeferred(config.account, requirements); + const redeemersConfig = await resolveMaybeDeferred( + config.redeemers, + requirements, + ); + + const requireRedeemers = redeemersConfig?.requireRedeemers ?? false; + const redeemerAddresses = await resolveMaybeDeferred( + redeemersConfig?.addresses, + requirements, + ); const specifiedEnvironment = await resolveMaybeDeferred( config.environment, @@ -482,6 +512,8 @@ export const resolveDelegationCreationContext = async ( facilitatorAddresses, payee: requirements.payTo as Hex, expirySeconds, + requireRedeemers, + redeemerAddresses, }); let createDelegationConfig: Parameters[0]; diff --git a/packages/smart-accounts-kit/test/experimental/x402DelegationProviderUtils.test.ts b/packages/smart-accounts-kit/test/experimental/x402DelegationProviderUtils.test.ts index 48e3dca3..068cce90 100644 --- a/packages/smart-accounts-kit/test/experimental/x402DelegationProviderUtils.test.ts +++ b/packages/smart-accounts-kit/test/experimental/x402DelegationProviderUtils.test.ts @@ -2,6 +2,7 @@ import { createAllowedCalldataTerms, createRedeemerTerms, createTimestampTerms, + decodeRedeemerTerms, decodeTimestampTerms, } from '@metamask/delegation-core'; import type { Hex, Account } from 'viem'; @@ -56,18 +57,33 @@ const makeDelegation = (caveats: Caveat[]): Delegation => ({ describe('x402DelegationProviderUtils', () => { describe('ensureRedeemerSufficientlyConstrained', () => { - it('throws when facilitators are missing and no redeemer caveat exists', () => { + it('returns caveats unchanged when redeemer addresses are missing and redeemers are optional', () => { + const initialCaveats: Caveat[] = []; + + const result = ensureRedeemerSufficientlyConstrained({ + redeemerEnforcer, + caveats: initialCaveats, + existingDelegations: [], + redeemerAddresses: [], + requireRedeemers: false, + }); + + expect(result).toStrictEqual(initialCaveats); + }); + + it('throws when redeemer addresses are missing and redeemers are required', () => { expect(() => ensureRedeemerSufficientlyConstrained({ redeemerEnforcer, caveats: [], existingDelegations: [], - facilitatorAddresses: undefined, + redeemerAddresses: [], + requireRedeemers: true, }), ).toThrow('Redeemer must be constrained'); }); - it('returns caveats unchanged when facilitators are missing but parent has redeemer caveat', () => { + it('returns caveats unchanged when redeemer addresses are missing but parent has redeemer caveat', () => { const initialCaveats: Caveat[] = [ { enforcer: '0xa00000000000000000000000000000000000000a', @@ -88,7 +104,8 @@ describe('x402DelegationProviderUtils', () => { }, ]), ], - facilitatorAddresses: undefined, + redeemerAddresses: [], + requireRedeemers: true, }); expect(result).toStrictEqual(initialCaveats); @@ -107,7 +124,8 @@ describe('x402DelegationProviderUtils', () => { redeemerEnforcer, caveats: initialCaveats, existingDelegations: [], - facilitatorAddresses: [facilitatorA, facilitatorB], + redeemerAddresses: [facilitatorA, facilitatorB], + requireRedeemers: true, }); expect(result).toStrictEqual(initialCaveats); @@ -130,7 +148,8 @@ describe('x402DelegationProviderUtils', () => { }, ]), ], - facilitatorAddresses: [facilitatorA, facilitatorB], + redeemerAddresses: [facilitatorA, facilitatorB], + requireRedeemers: true, }); expect(result).toContainEqual({ @@ -242,6 +261,8 @@ describe('x402DelegationProviderUtils', () => { existingDelegations: [], facilitatorAddresses: [facilitatorA], payee, + requireRedeemers: false, + redeemerAddresses: undefined, }), ).toThrow('RedeemerEnforcer not found in environment'); }); @@ -260,6 +281,8 @@ describe('x402DelegationProviderUtils', () => { existingDelegations: [], facilitatorAddresses: [facilitatorA], payee, + requireRedeemers: false, + redeemerAddresses: undefined, }), ).toThrow('AllowedCalldataEnforcer not found in environment'); }); @@ -275,6 +298,8 @@ describe('x402DelegationProviderUtils', () => { facilitatorAddresses: [facilitatorA], payee, expirySeconds: 3600, + requireRedeemers: false, + redeemerAddresses: undefined, }); const timestampCaveat = result.find( @@ -314,6 +339,8 @@ describe('x402DelegationProviderUtils', () => { facilitatorAddresses: [facilitatorA], payee, expirySeconds: 3600, + requireRedeemers: false, + redeemerAddresses: undefined, }); const timestampCaveats = result.filter( @@ -346,6 +373,8 @@ describe('x402DelegationProviderUtils', () => { facilitatorAddresses: [facilitatorA], payee, expirySeconds: 3600, + requireRedeemers: false, + redeemerAddresses: undefined, }); const timestampCaveat = result.find( @@ -378,9 +407,38 @@ describe('x402DelegationProviderUtils', () => { facilitatorAddresses: [facilitatorA], payee, expirySeconds: 60, + requireRedeemers: false, + redeemerAddresses: undefined, }), ).toThrow('TimestampEnforcer not found in environment'); }); + + it('enforces redeemer caveats from the union of facilitator and configured redeemer addresses', () => { + const result = resolvex402DelegationCaveats({ + environment: baseEnvironment, + caveatsConfig: [], + existingDelegations: [], + facilitatorAddresses: [facilitatorA], + payee, + requireRedeemers: true, + redeemerAddresses: [facilitatorB], + }); + + const redeemerCaveat = result.find( + ({ enforcer }) => enforcer === redeemerEnforcer, + ); + expect(redeemerCaveat).toBeDefined(); + if (!redeemerCaveat) { + throw new Error('Expected redeemer caveat to be present'); + } + + expect(decodeRedeemerTerms(redeemerCaveat.terms).redeemers).toEqual( + expect.arrayContaining([facilitatorA, facilitatorB]), + ); + expect(decodeRedeemerTerms(redeemerCaveat.terms).redeemers).toHaveLength( + 2, + ); + }); }); describe('resolveDelegationCreationContext', () => { @@ -558,7 +616,7 @@ describe('x402DelegationProviderUtils', () => { ).rejects.toThrow('Parent permission context is not a valid delegation'); }); - it('throws when facilitators are missing and no redeemer caveat exists', async () => { + it('does not throw when facilitators are missing and redeemers are optional', async () => { await expect( resolveDelegationCreationContext( { @@ -576,9 +634,109 @@ describe('x402DelegationProviderUtils', () => { extra: undefined, }, ), + ).resolves.toBeDefined(); + }); + + it('throws when redeemers are required but no redeemer sources are provided', async () => { + await expect( + resolveDelegationCreationContext( + { + account: mockAccount, + environment: baseEnvironment, + salt: `0x${'33'.repeat(32)}`, + redeemers: { + requireRedeemers: true, + }, + }, + { + scheme: 'exact', + network: 'eip155:1', + asset: facilitatorA, + amount: '1', + payTo: payee, + maxTimeoutSeconds: 120, + extra: undefined, + }, + ), ).rejects.toThrow('Redeemer must be constrained'); }); + it('adds redeemer caveat from redeemers config addresses when facilitators are missing', async () => { + const result = await resolveDelegationCreationContext( + { + account: mockAccount, + environment: baseEnvironment, + salt: `0x${'33'.repeat(32)}`, + redeemers: { + requireRedeemers: true, + addresses: [facilitatorB], + }, + }, + { + scheme: 'exact', + network: 'eip155:1', + asset: facilitatorA, + amount: '1', + payTo: payee, + maxTimeoutSeconds: 120, + extra: undefined, + }, + ); + + const caveats = result.createDelegationConfig.caveats as Caveat[]; + const redeemerCaveat = caveats.find( + ({ enforcer }) => enforcer === redeemerEnforcer, + ); + expect(redeemerCaveat).toBeDefined(); + if (!redeemerCaveat) { + throw new Error('Expected redeemer caveat to be present'); + } + expect(decodeRedeemerTerms(redeemerCaveat.terms).redeemers).toEqual([ + facilitatorB, + ]); + }); + + it('resolves deferred redeemers configuration and addresses', async () => { + const deferredRedeemerAddresses = vi.fn(async () => [facilitatorB]); + const deferredRedeemersConfig = vi.fn(async () => ({ + requireRedeemers: true, + addresses: deferredRedeemerAddresses, + })); + + const result = await resolveDelegationCreationContext( + { + account: mockAccount, + environment: baseEnvironment, + salt: `0x${'33'.repeat(32)}`, + redeemers: deferredRedeemersConfig, + }, + { + scheme: 'exact', + network: 'eip155:1', + asset: facilitatorA, + amount: '1', + payTo: payee, + maxTimeoutSeconds: 120, + extra: undefined, + }, + ); + + expect(deferredRedeemersConfig).toHaveBeenCalledOnce(); + expect(deferredRedeemerAddresses).toHaveBeenCalledOnce(); + + const caveats = result.createDelegationConfig.caveats as Caveat[]; + const redeemerCaveat = caveats.find( + ({ enforcer }) => enforcer === redeemerEnforcer, + ); + expect(redeemerCaveat).toBeDefined(); + if (!redeemerCaveat) { + throw new Error('Expected redeemer caveat to be present'); + } + expect(decodeRedeemerTerms(redeemerCaveat.terms).redeemers).toEqual([ + facilitatorB, + ]); + }); + it('resolves deferred expirySeconds and applies timestamp caveat', async () => { vi.useFakeTimers(); vi.setSystemTime(new Date('2024-01-01T00:00:00Z')); From ce66c3e582b26dbe0fd9f1b77bfb1c90a98eba5e Mon Sep 17 00:00:00 2001 From: Jeff Smale <6363749+jeffsmale90@users.noreply.github.com> Date: Wed, 27 May 2026 09:22:14 +1200 Subject: [PATCH 17/17] Minor fixes from review: - pad payee address to 32 bytes for allowedCalldataTerms - require expiry to be positive number - reject invalid chainId in network string --- .../x402DelegationProviderUtils.ts | 19 +++-- .../x402DelegationProviderUtils.test.ts | 71 +++++++++++++++++-- 2 files changed, 80 insertions(+), 10 deletions(-) diff --git a/packages/smart-accounts-kit/src/experimental/x402DelegationProviderUtils.ts b/packages/smart-accounts-kit/src/experimental/x402DelegationProviderUtils.ts index 882697c2..c7a7b7a2 100644 --- a/packages/smart-accounts-kit/src/experimental/x402DelegationProviderUtils.ts +++ b/packages/smart-accounts-kit/src/experimental/x402DelegationProviderUtils.ts @@ -6,7 +6,7 @@ import { decodeTimestampTerms, } from '@metamask/delegation-core'; import { parseCaipChainId } from '@metamask/utils'; -import type { Account, Address, Hex } from 'viem'; +import { pad, type Account, type Address, type Hex } from 'viem'; import type { Caveats } from '../caveatBuilder'; import { resolveCaveats } from '../caveatBuilder'; @@ -122,7 +122,13 @@ export function parseEip155ChainId( throw new Error('Unsupported chain namespace'); } - return parseInt(reference, 10); + const parsedChainId = Number(reference); + + if (isNaN(parsedChainId)) { + throw new Error('Invalid chain id'); + } + + return parsedChainId; } /** @@ -189,6 +195,9 @@ export const ensureExpirySufficientlyConstrained = ({ existingDelegations, expirySeconds, }: EnsureExpirySufficientlyConstrainedParams): Caveat[] => { + if (expirySeconds < 0) { + throw new Error('Expiry seconds must be a positive number'); + } const beforeThreshold = Math.floor(Date.now() / 1000) + expirySeconds; const timestampEnforcerNormalized = normalizeAddress(timestampEnforcer); @@ -328,21 +337,21 @@ export const ensurePayeeSufficientlyConstrained = ({ }: EnsurePayeeSufficientlyConstrainedParams): Caveat[] => { const allowedCalldataTerms = createAllowedCalldataTerms({ startIndex: TRANSFER_PAYEE_INDEX, - value: payee, + value: pad(payee, { size: 32 }), }); const allowedCalldataEnforcerNormalized = normalizeAddress( allowedCalldataEnforcer, ); - const normalizedAllowedCalldataTerms = normalizeAddress(allowedCalldataTerms); + const lowercaseCalldataTerms = allowedCalldataTerms.toLowerCase(); const hasSupersedingAllowedCalldataConstraint = hasMatchingCaveats( caveats, existingDelegations, ({ enforcer, terms }) => normalizeAddress(enforcer) === allowedCalldataEnforcerNormalized && - normalizeAddress(terms) === normalizedAllowedCalldataTerms, + terms.toLowerCase() === lowercaseCalldataTerms, ); if (hasSupersedingAllowedCalldataConstraint) { diff --git a/packages/smart-accounts-kit/test/experimental/x402DelegationProviderUtils.test.ts b/packages/smart-accounts-kit/test/experimental/x402DelegationProviderUtils.test.ts index 068cce90..3807d610 100644 --- a/packages/smart-accounts-kit/test/experimental/x402DelegationProviderUtils.test.ts +++ b/packages/smart-accounts-kit/test/experimental/x402DelegationProviderUtils.test.ts @@ -5,12 +5,13 @@ import { decodeRedeemerTerms, decodeTimestampTerms, } from '@metamask/delegation-core'; -import type { Hex, Account } from 'viem'; +import { pad, type Account, type Hex } from 'viem'; import { describe, expect, it, vi } from 'vitest'; import { ensurePayeeSufficientlyConstrained, ensureRedeemerSufficientlyConstrained, + parseEip155ChainId, resolvex402DelegationCaveats, resolveDelegationCreationContext, } from '../../src/experimental/x402DelegationProviderUtils'; @@ -56,6 +57,25 @@ const makeDelegation = (caveats: Caveat[]): Delegation => ({ }); describe('x402DelegationProviderUtils', () => { + describe('parseEip155ChainId', () => { + it('parses an eip155 CAIP network into a numeric chain id', () => { + expect(parseEip155ChainId('eip155:1')).toBe(1); + expect(parseEip155ChainId('eip155:8453')).toBe(8453); + }); + + it('throws when network namespace is not eip155', () => { + expect(() => parseEip155ChainId('solana:mainnet')).toThrow( + 'Unsupported chain namespace', + ); + }); + + it('throws when eip155 reference is not a valid number', () => { + expect(() => parseEip155ChainId('eip155:not-a-number')).toThrow( + 'Invalid chain id', + ); + }); + }); + describe('ensureRedeemerSufficientlyConstrained', () => { it('returns caveats unchanged when redeemer addresses are missing and redeemers are optional', () => { const initialCaveats: Caveat[] = []; @@ -164,7 +184,7 @@ describe('x402DelegationProviderUtils', () => { it('returns caveats unchanged when current caveats already constrain payee', () => { const matchingTerms = createAllowedCalldataTerms({ startIndex: 4, - value: payee, + value: pad(payee, { size: 32 }), }); const initialCaveats: Caveat[] = [ { @@ -202,7 +222,7 @@ describe('x402DelegationProviderUtils', () => { enforcer: allowedCalldataEnforcer, terms: createAllowedCalldataTerms({ startIndex: 4, - value: payee, + value: pad(payee, { size: 32 }), }), args: '0x', }, @@ -226,7 +246,7 @@ describe('x402DelegationProviderUtils', () => { enforcer: allowedCalldataEnforcer, terms: createAllowedCalldataTerms({ startIndex: 4, - value: otherPayee, + value: pad(otherPayee, { size: 32 }), }), args: '0x', }, @@ -239,7 +259,7 @@ describe('x402DelegationProviderUtils', () => { enforcer: allowedCalldataEnforcer, terms: createAllowedCalldataTerms({ startIndex: 4, - value: payee, + value: pad(payee, { size: 32 }), }), args: '0x', }); @@ -413,6 +433,21 @@ describe('x402DelegationProviderUtils', () => { ).toThrow('TimestampEnforcer not found in environment'); }); + it('throws when expirySeconds is negative', () => { + expect(() => + resolvex402DelegationCaveats({ + environment: baseEnvironment, + caveatsConfig: [], + existingDelegations: [], + facilitatorAddresses: [facilitatorA], + payee, + expirySeconds: -1, + requireRedeemers: false, + redeemerAddresses: undefined, + }), + ).toThrow('Expiry seconds must be a positive number'); + }); + it('enforces redeemer caveats from the union of facilitator and configured redeemer addresses', () => { const result = resolvex402DelegationCaveats({ environment: baseEnvironment, @@ -818,5 +853,31 @@ describe('x402DelegationProviderUtils', () => { vi.useRealTimers(); }); + + it('throws when deferred expirySeconds resolves to a negative value', async () => { + const deferredExpirySeconds = vi.fn(async () => -1); + + await expect( + resolveDelegationCreationContext( + { + account: mockAccount, + environment: baseEnvironment, + caveats: [], + salt: `0x${'66'.repeat(32)}`, + expirySeconds: deferredExpirySeconds, + }, + { + scheme: 'exact', + network: 'eip155:1', + asset: facilitatorA, + amount: '1', + payTo: payee, + maxTimeoutSeconds: 120, + extra: { facilitatorAddresses: [facilitatorA] }, + }, + ), + ).rejects.toThrow('Expiry seconds must be a positive number'); + expect(deferredExpirySeconds).toHaveBeenCalledOnce(); + }); }); });