diff --git a/packages/smart-accounts-kit/CHANGELOG.md b/packages/smart-accounts-kit/CHANGELOG.md index 8f8bfa6f..43c9822c 100644 --- a/packages/smart-accounts-kit/CHANGELOG.md +++ b/packages/smart-accounts-kit/CHANGELOG.md @@ -11,6 +11,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Constants `METAMASK_FACILITATOR_ADDRESSES`, `METAMASK_FACILITATOR_ADDRESSES_DEV` added to @metamask/smart-accounts-kit/experimental ([#255](https://github.com/metamask/smart-accounts-kit/pull/255)) +### Changed + +- Experimental `createx402DelegationProvider` now resolves redeemers as the intersection of `facilitatorAddresses` and `redeemer.addresses` ([#256](https://github.com/MetaMask/smart-accounts-kit/pull/256)) + ## [1.6.0] ### Added diff --git a/packages/smart-accounts-kit/src/experimental/x402DelegationProviderUtils.ts b/packages/smart-accounts-kit/src/experimental/x402DelegationProviderUtils.ts index 9f2de1a8..c2d8c05e 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[]; - redeemerAddresses: Hex[]; + facilitatorAddresses: Address[] | undefined; + redeemerAddresses: Address[] | undefined; requireRedeemers: boolean; }; @@ -58,6 +59,14 @@ type EnsureExpirySufficientlyConstrainedParams = { expirySeconds: number; }; +/** + * Inputs for resolving allowed redeemer addresses. + */ +type ResolveRedeemerAddressesParams = Pick< + EnsureRedeemerSufficientlyConstrainedParams, + 'facilitatorAddresses' | 'redeemerAddresses' +>; + /** * Resolved context required to build and sign an x402 delegation. */ @@ -236,32 +245,84 @@ export const ensureExpirySufficientlyConstrained = ({ return [...caveats, timestampCaveat]; }; +/** + * Resolves allowed redeemer addresses from facilitator and configured redeemer inputs. + * + * If both inputs are provided, returns their intersection. + * If only one input is provided, returns that input. + * If neither input is provided, returns undefined. + * + * @param options - Redeemer address inputs. + * @param options.facilitatorAddresses - Optional facilitator addresses from the PaymentRequirements. + * @param options.redeemerAddresses - Optional redeemer addresses from the RedeemersConfig. + * @returns Resolved allowed redeemer addresses, or undefined when no input is provided. + */ +const resolveRedeemerAddresses = ({ + facilitatorAddresses, + redeemerAddresses, +}: ResolveRedeemerAddressesParams): Address[] | undefined => { + if (!facilitatorAddresses) { + if (!redeemerAddresses) { + return undefined; + } + return redeemerAddresses; + } + + if (!redeemerAddresses) { + return facilitatorAddresses; + } + + const normalizedFacilitatorAddresses = + facilitatorAddresses.map(normalizeAddress); + + const normalizedRedeemerAddressSet = new Set( + redeemerAddresses.map(normalizeAddress), + ); + + const redeemerAddressesIntersection = normalizedFacilitatorAddresses.filter( + (address) => normalizedRedeemerAddressSet.has(address), + ); + + return redeemerAddressesIntersection; +}; + /** * 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. + * @param config - Redeemer constraint evaluation inputs. + * @param config.redeemerEnforcer - Address of the redeemer enforcer caveat contract. + * @param config.caveats - Currently resolved caveats for the delegation being created. + * @param config.existingDelegations - Existing parent-chain delegations to inspect for inherited constraints. + * @param config.redeemerAddresses - Optional addresses to which redemption should be constrained. + * @param config.requireRedeemers - Whether at least one redeemer constraint must exist. * @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. + * @throws If either facilitatorAddresses and/or redeemerAddresses are provided, but the intersection is empty. + * @throws If requireRedeemers is true, but no valid redeemer addresses are provided, and no existing redeemer caveat is found in the existingDelegations. */ -export const ensureRedeemerSufficientlyConstrained = ({ - redeemerEnforcer, - caveats, - existingDelegations, - redeemerAddresses, - requireRedeemers, -}: EnsureRedeemerSufficientlyConstrainedParams): Caveat[] => { - const redeemerAddressNormalized = normalizeAddress(redeemerEnforcer); +export const ensureRedeemerSufficientlyConstrained = ( + config: EnsureRedeemerSufficientlyConstrainedParams, +): Caveat[] => { + // `redeemerAddresses` is the intersection of `facilitatorAddresses` and `redeemerAddresses`. + // If either is undefined, it implies no constraint is specified by that value, and the other is returned. + // If both are undefined, it implies that no constraint is specified overall. + const redeemerAddresses = resolveRedeemerAddresses(config); + + // If the result is defined, but zero-length, it implies no redeemers are allowed, which is a non-satisfiable constraint, so an error is thrown. + if (redeemerAddresses?.length === 0) { + throw new Error( + 'No valid redeemer addresses were resolved. If both `redeemers.addresses` and `extra.facilitatorAddresses` are provided, they must overlap. If only one is provided, it must include at least one address.', + ); + } + + const { caveats, existingDelegations, redeemerEnforcer } = config; + + const redeemerEnforcerNormalized = normalizeAddress(redeemerEnforcer); - if (redeemerAddresses.length === 0) { - if (!requireRedeemers) { + if (!redeemerAddresses) { + if (!config.requireRedeemers) { return caveats; } @@ -269,7 +330,7 @@ export const ensureRedeemerSufficientlyConstrained = ({ caveats, existingDelegations, ({ enforcer }) => - normalizeAddress(enforcer) === redeemerAddressNormalized, + normalizeAddress(enforcer) === redeemerEnforcerNormalized, ); if (!hasExistingRedeemerCaveat) { @@ -281,14 +342,13 @@ export const ensureRedeemerSufficientlyConstrained = ({ return caveats; } - const redeemerAddressesNormalized = redeemerAddresses.map(normalizeAddress); - const redeemerAddressesSet = new Set(redeemerAddressesNormalized); + const redeemerAddressesSet = new Set(redeemerAddresses.map(normalizeAddress)); const hasSupersedingRedeemerCaveat = hasMatchingCaveats( caveats, existingDelegations, (caveat) => { - if (normalizeAddress(caveat.enforcer) !== redeemerAddressNormalized) { + if (normalizeAddress(caveat.enforcer) !== redeemerEnforcerNormalized) { return false; } @@ -296,7 +356,7 @@ export const ensureRedeemerSufficientlyConstrained = ({ caveat.terms, ).redeemers.map(normalizeAddress); - // If this redeemer caveat only allows facilitator addresses, it is sufficiently constrained. + // If this redeemer caveat only allows redeemer addresses, it is sufficiently constrained. return allowedRedeemerAddresses.every((item) => redeemerAddressesSet.has(item), ); @@ -420,17 +480,12 @@ export const resolvex402DelegationCaveats = ({ isScopeOptional: true, }); - const allRedeemerAddresses = new Set( - [...(facilitatorAddresses ?? []), ...(redeemerAddresses ?? [])].map( - normalizeAddress, - ), - ); - const caveatsWithRedeemer = ensureRedeemerSufficientlyConstrained({ redeemerEnforcer, + facilitatorAddresses, + redeemerAddresses, caveats: initialCaveats, existingDelegations, - redeemerAddresses: Array.from(allRedeemerAddresses), requireRedeemers, }); diff --git a/packages/smart-accounts-kit/test/experimental/x402DelegationProviderUtils.test.ts b/packages/smart-accounts-kit/test/experimental/x402DelegationProviderUtils.test.ts index 0c3dfa7d..64cc8ba0 100644 --- a/packages/smart-accounts-kit/test/experimental/x402DelegationProviderUtils.test.ts +++ b/packages/smart-accounts-kit/test/experimental/x402DelegationProviderUtils.test.ts @@ -77,6 +77,32 @@ describe('x402DelegationProviderUtils', () => { }); describe('ensureRedeemerSufficientlyConstrained', () => { + it('throws when redeemer addresses are empty and redeemers are not required', () => { + expect(() => + ensureRedeemerSufficientlyConstrained({ + redeemerEnforcer, + caveats: [], + existingDelegations: [], + facilitatorAddresses: undefined, + redeemerAddresses: [], + requireRedeemers: false, + }), + ).toThrow('No valid redeemer addresses were resolved.'); + }); + + it('throws when redeemer addresses are empty and redeemers are required', () => { + expect(() => + ensureRedeemerSufficientlyConstrained({ + redeemerEnforcer, + caveats: [], + existingDelegations: [], + facilitatorAddresses: undefined, + redeemerAddresses: [], + requireRedeemers: true, + }), + ).toThrow('No valid redeemer addresses were resolved.'); + }); + it('returns caveats unchanged when redeemer addresses are missing and redeemers are optional', () => { const initialCaveats: Caveat[] = []; @@ -84,7 +110,8 @@ describe('x402DelegationProviderUtils', () => { redeemerEnforcer, caveats: initialCaveats, existingDelegations: [], - redeemerAddresses: [], + facilitatorAddresses: undefined, + redeemerAddresses: undefined, requireRedeemers: false, }); @@ -97,7 +124,8 @@ describe('x402DelegationProviderUtils', () => { redeemerEnforcer, caveats: [], existingDelegations: [], - redeemerAddresses: [], + facilitatorAddresses: undefined, + redeemerAddresses: undefined, requireRedeemers: true, }), ).toThrow('Redeemer must be constrained'); @@ -124,7 +152,8 @@ describe('x402DelegationProviderUtils', () => { }, ]), ], - redeemerAddresses: [], + facilitatorAddresses: undefined, + redeemerAddresses: undefined, requireRedeemers: true, }); @@ -144,6 +173,7 @@ describe('x402DelegationProviderUtils', () => { redeemerEnforcer, caveats: initialCaveats, existingDelegations: [], + facilitatorAddresses: undefined, redeemerAddresses: [facilitatorA, facilitatorB], requireRedeemers: true, }); @@ -168,6 +198,7 @@ describe('x402DelegationProviderUtils', () => { }, ]), ], + facilitatorAddresses: undefined, redeemerAddresses: [facilitatorA, facilitatorB], requireRedeemers: true, }); @@ -448,15 +479,15 @@ describe('x402DelegationProviderUtils', () => { ).toThrow('Expiry seconds must be a positive number'); }); - it('enforces redeemer caveats from the union of facilitator and configured redeemer addresses', () => { + it('enforces redeemer caveats from the intersection of facilitator and configured redeemer addresses', () => { const result = resolvex402DelegationCaveats({ environment: baseEnvironment, caveatsConfig: [], existingDelegations: [], - facilitatorAddresses: [facilitatorA], + facilitatorAddresses: [facilitatorA, facilitatorB], payee, requireRedeemers: true, - redeemerAddresses: [facilitatorB], + redeemerAddresses: [facilitatorB, facilitatorC], }); const redeemerCaveat = result.find( @@ -467,12 +498,9 @@ describe('x402DelegationProviderUtils', () => { 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, - ); + expect(decodeRedeemerTerms(redeemerCaveat.terms).redeemers).toEqual([ + facilitatorB, + ]); }); });