Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/smart-accounts-kit/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ type EnsureRedeemerSufficientlyConstrainedParams = {
redeemerEnforcer: Hex;
caveats: Caveat[];
existingDelegations: Delegation[];
redeemerAddresses: Hex[];
redeemerAddresses: Address[] | undefined;
requireRedeemers: boolean;
};

Expand All @@ -58,6 +58,14 @@ type EnsureExpirySufficientlyConstrainedParams = {
expirySeconds: number;
};

/**
* Inputs for resolving allowed redeemer addresses.
*/
type ResolveRedeemerAddressesParams = {
facilitatorAddresses?: Address[];
redeemerAddresses?: Address[];
};

/**
* Resolved context required to build and sign an x402 delegation.
*/
Expand Down Expand Up @@ -236,6 +244,42 @@ export const ensureExpirySufficientlyConstrained = ({
return [...caveats, timestampCaveat];
};

/**
* Resolves the allowed redeemer addresses from the union of facilitator specified and caller specified redeemer addresses.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it's intersection, not union

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it sure is!

* If either are undefined, the other is returned.
*
* @param options - Redeemer address inputs.
* @param options.facilitatorAddresses - Optional facilitator addresses from the PaymentRequirements.
* @param options.redeemerAddresses - Optional redeemer addresses from the RedeemersConfig.
* @returns The allowed redeemer addresses.
*/
const resolveRedeemerAddresses = ({
facilitatorAddresses,
redeemerAddresses,
}: ResolveRedeemerAddressesParams): Address[] | undefined => {
if (!facilitatorAddresses) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If a payment server sends extra: { facilitatorAddresses: [] }, the code enters intersection logic, produces [], and throws a confusing error — even though the user's redeemerAddresses alone would have been sufficient.

@jeffsmale90 jeffsmale90 Jun 1, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the behaviour is correct. I've refactored slightly, added some comments, and clarified the error message to "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.".

If either extra.facilitatorAddresses or redeemers.addresses is specified it implies a constraint (if undefined, it implies no constraint). If the value is either [] or the intersection of the two values is [] it implies a constraint that cannot be satisfied, hence the error.

If we treat [] as no constraint, it's error prone - ["0xaddress1","0xaddress2"] means either address can redeem, ["oxaddress1"] means only that address can redeem, [] means anyone can

if (!redeemerAddresses) {
return undefined;
}
return redeemerAddresses;
}

if (!redeemerAddresses) {
return facilitatorAddresses;
Comment thread
jeffsmale90 marked this conversation as resolved.
}

const normalizedFacilitatorAddresses =
facilitatorAddresses.map(normalizeAddress);

const normalizedRedeemerAddressSet = new Set(
redeemerAddresses.map(normalizeAddress),
);

return normalizedFacilitatorAddresses.filter((address) =>
normalizedRedeemerAddressSet.has(address),
);
};

/**
* Ensures caveats include a sufficiently strict redeemer constraint.
*
Expand All @@ -258,9 +302,9 @@ export const ensureRedeemerSufficientlyConstrained = ({
redeemerAddresses,
requireRedeemers,
}: EnsureRedeemerSufficientlyConstrainedParams): Caveat[] => {
const redeemerAddressNormalized = normalizeAddress(redeemerEnforcer);
const redeemerEnforcerNormalized = normalizeAddress(redeemerEnforcer);

if (redeemerAddresses.length === 0) {
if (!redeemerAddresses || redeemerAddresses.length === 0) {
if (!requireRedeemers) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you clarify what this means exactly?

From my understanding, setting requireRedeemers: false just lets us omit the redeemer caveat when no redeemer source is given. If both facilitatorAddresses and redeemerAddresses are specified but have no overlapping addresses, should the process fail, or create a delegation with no redeemer restrictions on purpose?

I’m checking this because the current code handles an empty intersection the same as when no redeemer sources exist.

return caveats;
}
Expand All @@ -269,7 +313,7 @@ export const ensureRedeemerSufficientlyConstrained = ({
caveats,
existingDelegations,
({ enforcer }) =>
normalizeAddress(enforcer) === redeemerAddressNormalized,
normalizeAddress(enforcer) === redeemerEnforcerNormalized,
);

if (!hasExistingRedeemerCaveat) {
Expand All @@ -288,7 +332,7 @@ export const ensureRedeemerSufficientlyConstrained = ({
caveats,
existingDelegations,
(caveat) => {
if (normalizeAddress(caveat.enforcer) !== redeemerAddressNormalized) {
if (normalizeAddress(caveat.enforcer) !== redeemerEnforcerNormalized) {
return false;
}

Expand Down Expand Up @@ -420,17 +464,16 @@ export const resolvex402DelegationCaveats = ({
isScopeOptional: true,
});

const allRedeemerAddresses = new Set(
[...(facilitatorAddresses ?? []), ...(redeemerAddresses ?? [])].map(
normalizeAddress,
),
);
const resolvedRedeemerAddresses = resolveRedeemerAddresses({
facilitatorAddresses,
redeemerAddresses,
});

const caveatsWithRedeemer = ensureRedeemerSufficientlyConstrained({
redeemerEnforcer,
caveats: initialCaveats,
existingDelegations,
redeemerAddresses: Array.from(allRedeemerAddresses),
redeemerAddresses: resolvedRedeemerAddresses,
requireRedeemers,
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -448,15 +448,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(
Expand All @@ -467,12 +467,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,
]);
});
});

Expand Down
Loading