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)) 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 83ca0171..67195f6a 100644 --- a/packages/smart-accounts-kit/src/experimental/index.ts +++ b/packages/smart-accounts-kit/src/experimental/index.ts @@ -5,3 +5,12 @@ export { type Environment, type DelegationStorageConfig, } from './delegationStorage'; +export { + createx402DelegationProvider, + parseEip155ChainId, + type x402DelegationProvider, + type x402DelegationProviderConfig, + type x402DelegationProviderPaymentPayload, + 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 new file mode 100644 index 00000000..329dcff6 --- /dev/null +++ b/packages/smart-accounts-kit/src/experimental/x402DelegationProvider.ts @@ -0,0 +1,82 @@ +import { + createOpenDelegation, + encodeDelegations, + prepareSignDelegationTypedData, +} from '../delegation'; +import type { + PaymentRequirements, + x402DelegationProvider, + x402DelegationProviderConfig, + x402DelegationProviderPaymentPayload, +} from './x402DelegationProviderTypes'; +import { + parseEip155ChainId, + resolveDelegationCreationContext, +} from './x402DelegationProviderUtils'; + +export type { + MaybeDeferred, + PaymentRequirements, + x402DelegationProvider, + x402DelegationProviderConfig, + x402DelegationProviderPaymentPayload, +} from './x402DelegationProviderTypes'; + +export { parseEip155ChainId } from './x402DelegationProviderUtils'; + +/** + * 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 createx402DelegationProvider( + config: x402DelegationProviderConfig, +): x402DelegationProvider { + return async ( + requirements: PaymentRequirements, + ): Promise => { + const { + account, + delegationManager, + existingDelegations, + createDelegationConfig, + } = await resolveDelegationCreationContext(config, requirements); + + const delegation = createOpenDelegation(createDelegationConfig); + + const chainId = parseEip155ChainId(requirements.network); + + 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/x402DelegationProviderTypes.ts b/packages/smart-accounts-kit/src/experimental/x402DelegationProviderTypes.ts new file mode 100644 index 00000000..959e5741 --- /dev/null +++ b/packages/smart-accounts-kit/src/experimental/x402DelegationProviderTypes.ts @@ -0,0 +1,72 @@ +import type { Account, Address, 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 | TResult); + +/** + * Function that turns payment requirements into a signed delegation payload. + */ +export type x402DelegationProvider = ( + paymentRequirements: PaymentRequirements, +) => Promise; + +/** + * Configuration for redeemer constraint enforcement. + */ +export type RedeemersConfig = { + requireRedeemers: boolean; + addresses?: MaybeDeferred; +}; + +/** + * Configuration used to create a x402DelegationProvider. + * + * `account` is required and is used for signing the delegation. + */ +export type x402DelegationProviderConfig = { + account: MaybeDeferred; + environment?: MaybeDeferred; + from?: MaybeDeferred; + salt?: MaybeDeferred; + 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 new file mode 100644 index 00000000..c7a7b7a2 --- /dev/null +++ b/packages/smart-accounts-kit/src/experimental/x402DelegationProviderUtils.ts @@ -0,0 +1,561 @@ +import { + createAllowedCalldataTerms, + createRedeemerTerms, + createTimestampTerms, + decodeRedeemerTerms, + decodeTimestampTerms, +} from '@metamask/delegation-core'; +import { parseCaipChainId } from '@metamask/utils'; +import { pad, type Account, type Address, type 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 { getSmartAccountsEnvironment } from '../smartAccountsEnvironment'; +import type { + Caveat, + Delegation, + 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[]; + existingDelegations: Delegation[]; + redeemerAddresses: Hex[]; + requireRedeemers: boolean; +}; + +/** + * Inputs for payee constraint enforcement. + */ +type EnsurePayeeSufficientlyConstrainedParams = { + allowedCalldataEnforcer: Hex; + caveats: Caveat[]; + existingDelegations: Delegation[]; + 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. + */ +export type DelegationCreationContext = { + account: 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; + existingDelegations: Delegation[]; + facilitatorAddresses: Hex[] | undefined; + payee: Hex; + expirySeconds?: number; + requireRedeemers: boolean; + redeemerAddresses: Address[] | undefined; +}; + +/** + * 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. + */ +async function resolveMaybeDeferred( + maybeDeferred: MaybeDeferred, + requirements: PaymentRequirements, +): Promise { + if (typeof maybeDeferred === 'function') { + const deferred = maybeDeferred as ( + deferredRequirements: PaymentRequirements, + ) => Promise | TResult; + + return await deferred(requirements); + } + + 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'); + } + + const parsedChainId = Number(reference); + + if (isNaN(parsedChainId)) { + throw new Error('Invalid chain id'); + } + + return parsedChainId; +} + +/** + * 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: Address): Address => + address.toLowerCase() as Address; + +/** + * 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[], + match: (caveat: Caveat) => boolean, +): 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 `afterThreshold = 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[] => { + 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); + + 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. + * + * 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.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. + */ +export const ensureRedeemerSufficientlyConstrained = ({ + redeemerEnforcer, + caveats, + existingDelegations, + redeemerAddresses, + requireRedeemers, +}: EnsureRedeemerSufficientlyConstrainedParams): Caveat[] => { + const redeemerAddressNormalized = normalizeAddress(redeemerEnforcer); + + if (redeemerAddresses.length === 0) { + if (!requireRedeemers) { + return caveats; + } + + 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 redeemerAddressesNormalized = redeemerAddresses.map(normalizeAddress); + const redeemerAddressesSet = new Set(redeemerAddressesNormalized); + + const hasSupersedingRedeemerCaveat = 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 facilitator addresses, it is sufficiently constrained. + return allowedRedeemerAddresses.every((item) => + redeemerAddressesSet.has(item), + ); + }, + ); + + if (hasSupersedingRedeemerCaveat) { + return caveats; + } + + const redeemerCaveat: Caveat = { + enforcer: redeemerEnforcer, + terms: createRedeemerTerms({ redeemers: redeemerAddresses }), + 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: pad(payee, { size: 32 }), + }); + + const allowedCalldataEnforcerNormalized = normalizeAddress( + allowedCalldataEnforcer, + ); + + const lowercaseCalldataTerms = allowedCalldataTerms.toLowerCase(); + + const hasSupersedingAllowedCalldataConstraint = hasMatchingCaveats( + caveats, + existingDelegations, + ({ enforcer, terms }) => + normalizeAddress(enforcer) === allowedCalldataEnforcerNormalized && + terms.toLowerCase() === lowercaseCalldataTerms, + ); + + if (hasSupersedingAllowedCalldataConstraint) { + 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. + * @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 = ({ + environment, + caveatsConfig, + existingDelegations, + facilitatorAddresses, + payee, + expirySeconds, + requireRedeemers, + redeemerAddresses, +}: Resolvex402DelegationCaveatsParams): Caveat[] => { + const { + caveatEnforcers: { + RedeemerEnforcer: redeemerEnforcer, + AllowedCalldataEnforcer: allowedCalldataEnforcer, + TimestampEnforcer: timestampEnforcer, + }, + } = environment; + + if (!redeemerEnforcer) { + throw new Error('RedeemerEnforcer not found in environment'); + } + + if (!allowedCalldataEnforcer) { + throw new Error('AllowedCalldataEnforcer not found in environment'); + } + + if (!timestampEnforcer) { + throw new Error('TimestampEnforcer 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 allRedeemerAddresses = new Set( + [...(facilitatorAddresses ?? []), ...(redeemerAddresses ?? [])].map( + normalizeAddress, + ), + ); + + const caveatsWithRedeemer = ensureRedeemerSufficientlyConstrained({ + redeemerEnforcer, + caveats: initialCaveats, + existingDelegations, + redeemerAddresses: Array.from(allRedeemerAddresses), + requireRedeemers, + }); + + const caveatsWithPayee = ensurePayeeSufficientlyConstrained({ + allowedCalldataEnforcer, + caveats: caveatsWithRedeemer, + existingDelegations, + payee, + }); + + if (expirySeconds === undefined) { + return caveatsWithPayee; + } + + return ensureExpirySufficientlyConstrained({ + timestampEnforcer, + caveats: caveatsWithPayee, + existingDelegations, + expirySeconds, + }); +}; + +/** + * 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: x402DelegationProviderConfig, + 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, + requirements, + ); + const environment = + specifiedEnvironment ?? + getSmartAccountsEnvironment(parseEip155ChainId(requirements.network)); + + 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; + const salt = + (await resolveMaybeDeferred(config.salt, requirements)) ?? 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 } = environment; + const caveats = resolvex402DelegationCaveats({ + environment, + caveatsConfig, + existingDelegations, + facilitatorAddresses, + payee: requirements.payTo as Hex, + expirySeconds, + requireRedeemers, + redeemerAddresses, + }); + + 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, + from, + caveats, + salt, + scope, + parentDelegation, + }; + } else { + createDelegationConfig = { + environment, + from, + caveats, + salt, + scope, + }; + } + + return { + account, + delegationManager, + existingDelegations, + createDelegationConfig, + }; +}; 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/experimental/x402DelegationProvider.test.ts b/packages/smart-accounts-kit/test/experimental/x402DelegationProvider.test.ts new file mode 100644 index 00000000..09f99d51 --- /dev/null +++ b/packages/smart-accounts-kit/test/experimental/x402DelegationProvider.test.ts @@ -0,0 +1,261 @@ +import type { Account, Hex } from 'viem'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { + x402DelegationProviderConfig, + PaymentRequirements, +} from '../../src/experimental/x402DelegationProvider'; +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(() => ({ + createAllowedCalldataTerms: vi.fn(), + createRedeemerTerms: vi.fn(), + decodeRedeemerTerms: vi.fn(), +})); + +const utilsMocks = vi.hoisted(() => ({ + generateSalt: vi.fn(), +})); + +vi.mock('../../src/caveatBuilder', () => ({ + resolveCaveats: caveatBuilderMocks.resolveCaveats, +})); + +vi.mock('../../src/delegation', () => delegationMocks); + +vi.mock('@metamask/delegation-core', () => delegationCoreMocks); +vi.mock('../../src/utils/', () => utilsMocks); + +const { createx402DelegationProvider, parseEip155ChainId } = + await import('../../src/experimental/x402DelegationProvider'); + +const mockDelegationManager = + '0x1000000000000000000000000000000000000001' as Hex; +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: {} }; +const mockPermissionContext = '0xfeed' as Hex; +const mockAllowedCalldataTerms = '0x3333' as Hex; +const mockGeneratedSalt = + '0x1111111111111111111111111111111111111111111111111111111111111111' as Hex; +const mockAuthority = + '0x0000000000000000000000000000000000000000000000000000000000000000' as Hex; + +const mockRequirements: PaymentRequirements = { + scheme: 'exact', + network: 'eip155: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 => { + const overrideCaveatEnforcers = overrides.caveatEnforcers ?? {}; + + return { + DelegationManager: mockDelegationManager, + caveatEnforcers: { + RedeemerEnforcer: mockRedeemerEnforcer, + AllowedCalldataEnforcer: mockPayeeEnforcer, + TimestampEnforcer: mockTimestampEnforcer, + ...overrideCaveatEnforcers, + }, + ...overrides, + } as SmartAccountsEnvironment; +}; + +describe('createx402DelegationProvider', () => { + beforeEach(() => { + vi.clearAllMocks(); + + caveatBuilderMocks.resolveCaveats.mockReturnValue([ + { + enforcer: '0x9000000000000000000000000000000000000009', + terms: '0x11', + args: '0x', + }, + ]); + delegationCoreMocks.createAllowedCalldataTerms.mockReturnValue( + mockAllowedCalldataTerms, + ); + 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, + 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 = createx402DelegationProvider({ + 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).toBe(mockGeneratedSalt); + expect(createCallArg.scope).toStrictEqual({ + type: 'erc20TransferAmount', + tokenAddress: mockRequirements.asset, + maxAmount: BigInt(mockRequirements.amount), + }); + + expect(delegationMocks.prepareSignDelegationTypedData).toHaveBeenCalledWith( + { + delegationManager: mockDelegationManager, + chainId: 1, + 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('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('throws when account does not support typed data signing', async () => { + const account = createMockAccount({ + signTypedData: undefined, + }); + const provider = createx402DelegationProvider({ + account, + environment: createMockEnvironment(), + } as x402DelegationProviderConfig); + + await expect(provider(mockRequirements)).rejects.toThrow( + 'Account does not support signTypedData', + ); + }); +}); + +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 new file mode 100644 index 00000000..3807d610 --- /dev/null +++ b/packages/smart-accounts-kit/test/experimental/x402DelegationProviderUtils.test.ts @@ -0,0 +1,883 @@ +import { + createAllowedCalldataTerms, + createRedeemerTerms, + createTimestampTerms, + decodeRedeemerTerms, + decodeTimestampTerms, +} from '@metamask/delegation-core'; +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'; +import * as smartAccountsEnvironmentModule from '../../src/smartAccountsEnvironment'; +import type { + Caveat, + Delegation, + SmartAccountsEnvironment, +} from '../../src/types'; + +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; +const payee = '0x6000000000000000000000000000000000000006' as const; +const otherPayee = '0x7000000000000000000000000000000000000007' as const; +const rootAuthority = `0x${'00'.repeat(32)}` as const; +const baseEnvironment = { + DelegationManager: '0xa00000000000000000000000000000000000000a', + EntryPoint: '0xa10000000000000000000000000000000000000a', + SimpleFactory: '0xa20000000000000000000000000000000000000a', + implementations: {}, + caveatEnforcers: { + RedeemerEnforcer: redeemerEnforcer, + AllowedCalldataEnforcer: allowedCalldataEnforcer, + TimestampEnforcer: timestampEnforcer, + }, +} 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('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[] = []; + + 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: [], + redeemerAddresses: [], + requireRedeemers: true, + }), + ).toThrow('Redeemer must be constrained'); + }); + + it('returns caveats unchanged when redeemer addresses 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', + }, + ]), + ], + redeemerAddresses: [], + requireRedeemers: true, + }); + + 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: [], + redeemerAddresses: [facilitatorA, facilitatorB], + requireRedeemers: true, + }); + + 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', + }, + ]), + ], + redeemerAddresses: [facilitatorA, facilitatorB], + requireRedeemers: true, + }); + + 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: pad(payee, { size: 32 }), + }); + 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: pad(payee, { size: 32 }), + }), + 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: pad(otherPayee, { size: 32 }), + }), + args: '0x', + }, + ]), + ], + payee, + }); + + expect(result).toContainEqual({ + enforcer: allowedCalldataEnforcer, + terms: createAllowedCalldataTerms({ + startIndex: 4, + value: pad(payee, { size: 32 }), + }), + 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, + requireRedeemers: false, + redeemerAddresses: undefined, + }), + ).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, + requireRedeemers: false, + redeemerAddresses: undefined, + }), + ).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, + requireRedeemers: false, + redeemerAddresses: undefined, + }); + + 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, + requireRedeemers: false, + redeemerAddresses: undefined, + }); + + 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, + requireRedeemers: false, + redeemerAddresses: undefined, + }); + + 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, + requireRedeemers: false, + redeemerAddresses: undefined, + }), + ).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, + 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', () => { + 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); + const deferredFrom = vi.fn( + async () => '0xba000000000000000000000000000000000000ba' as const, + ); + const deferredSalt = vi.fn( + async (): Promise => `0x${'55'.repeat(32)}`, + ); + + 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 () => []); + 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('does not throw when facilitators are missing and redeemers are optional', 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, + }, + ), + ).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')); + + 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(); + }); + + 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(); + }); + + 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(); + }); + }); +}); diff --git a/packages/smart-accounts-kit/test/utils.ts b/packages/smart-accounts-kit/test/utils.ts index 4280cf53..a9100014 100644 --- a/packages/smart-accounts-kit/test/utils.ts +++ b/packages/smart-accounts-kit/test/utils.ts @@ -10,6 +10,8 @@ import { Implementation } from '../src/constants'; import { deploySmartAccountsEnvironment } from '../src/smartAccountsEnvironment'; import type { ToMetaMaskSmartAccountParameters } from '../src/types'; +export { generateSalt } from '../src/utils/'; + export const OWNER_ACCOUNT: Account = privateKeyToAccount(generatePrivateKey()); export const DEPLOYED_ADDRESS = privateKeyToAddress(generatePrivateKey()); export const SALT = '0x12345678901234567890123456789'; 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"