Skip to content

feat(auth-capture/facilitator): make settle feeBps + delegated feeReceiver a facilitator policy#65

Open
A1igator wants to merge 5 commits into
mainfrom
A1igator/auth-capture-explicit-feebps
Open

feat(auth-capture/facilitator): make settle feeBps + delegated feeReceiver a facilitator policy#65
A1igator wants to merge 5 commits into
mainfrom
A1igator/auth-capture-explicit-feebps

Conversation

@A1igator

@A1igator A1igator commented Jun 3, 2026

Copy link
Copy Markdown
Contributor

Closes #64.

Problem

The auth-capture facilitator hard-coded two settle-time values that the merchant's signature actually leaves open as a choice within signed bounds:

  1. feeBps — hard-coded to paymentInfo.minFeeBps. The signature commits only to the band [minFeeBps, maxFeeBps]; the escrow's _validateFee accepts any in-band value. So for any captureAuthorizer that honors the forwarded fee (EOA path, thin pass-throughs), the fee was always the floor and maxFeeBps was dead.

  2. feeReceiver — hard-coded to paymentInfo.feeReceiver. Per the scheme spec, feeRecipient may be set to address(0) "to let the captureAuthorizer specify any non-zero recipient at capture/charge time." The facilitator never supplied one, so that delegation path was dead: feeRecipient = address(0) with maxFeeBps > 0 pairs feeBps > 0 with receiver 0 → on-chain ZeroFeeReceiver revert. The delegated receiver could never be paid.

Both are the same defect: a knob the spec exposes for settle-time choice, silently collapsed to one value because the facilitator had no input to pick from. Picking within those bounds is the settle-time operator's discretion, and in x402's facilitator-submits flow the facilitator is that operator — so the choice belongs to facilitator config, not the merchant-authored wire extra.

Changes

  • Add optional selectFeeBps and selectFeeReceiver policies to the AuthCaptureEvmScheme constructor (AuthCaptureFacilitatorOptions).
    • selectFeeBps defaults to the band floor (minFeeBps).
    • selectFeeReceiver is consulted only when the merchant delegated (feeRecipient == address(0)); a configured non-zero recipient is passed through unchanged, because the escrow forces a match.
  • One resolveFee returning { feeBps, feeReceiver }, shared by settle and simulateSettle, so neither value can drift between simulate and submit (issue Q3 — lockstep is now structural).
  • Validate before submitting: an out-of-band feeBpsfee_bps_out_of_range; a non-zero fee with a zero receiver → zero_fee_receiver. Both mirror the escrow's own custom errors, so a misconfigured facilitator fails fast with a clean typed reason instead of an opaque on-chain revert.

Backward compatibility

Defaults reproduce the old behavior exactly — floor feeBps + the configured recipient — so a facilitator that passes no options is unchanged. The new policies are an optional 2nd constructor arg.

Not changed

  • No contract change. Contracts that recompute the fee (e.g. PaymentOperator) are unaffected — the forwarded values are inert for them.
  • No wire-format / extra change. The merchant's knobs stay the band and feeRecipient (incl. the address(0) delegation the spec already documents).

Tests

settle — facilitator feeBps policy and settle — feeReceiver delegation suites cover: defaults (floor + configured recipient); in-band/delegated values forwarded to both the charge args and the simulation; out-of-band feeBps and fee-with-zero-receiver rejected before any writeContract; configured recipient passed through with the policy not consulted; and zero receiver allowed when no fee is taken. 132/132 unit tests pass.

Note: tests are unit-level against a mocked signer (same as the existing suite); the on-chain _validateFee / ZeroFeeReceiver behavior this relies on is taken from the scheme spec + escrow error surface, not re-verified against a live escrow here.

🤖 Generated with Claude Code

A1igator and others added 2 commits June 3, 2026 15:18
The facilitator hard-coded paymentInfo.minFeeBps as the actual feeBps on
charge/capture calls. The payer's signature commits only to the band
[minFeeBps, maxFeeBps] on the PaymentInfo hash, never to a concrete feeBps;
the escrow's _validateFee accepts any in-band value. So the floor was not a
protocol constraint, just the absence of an input. For any captureAuthorizer
that honors the forwarded fee (EOA path, thin pass-through contracts), the
fee was always the floor and maxFeeBps was effectively dead.

Picking the in-band value is the settle-time operator's discretion, and in
x402's facilitator-submits flow the facilitator is that operator. So the
value belongs to facilitator config, not the merchant-authored wire `extra`
(a merchant wanting an exact fee just collapses the band to min == max).

- Add optional `selectFeeBps` policy to the scheme constructor; defaults to
  the band floor (minFeeBps) when unset, so existing behavior is unchanged.
- Validate the chosen value against [min, max] and surface an out-of-band
  result as the stable `fee_bps_out_of_range` reason before submitting,
  rather than letting the escrow revert opaquely on-chain.
- Extract one `buildSettleArgs` helper + shared `resolveFeeBps`, used by both
  settle and simulateSettle, so the two cannot drift on the forwarded fee.

No contract or wire-format change. Contracts that recompute the fee (e.g.
PaymentOperator) are unaffected — the forwarded value is inert for them.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…eReceiver

The escrow gives feeReceiver the same configured-or-free shape as the fee
band, and the scheme spec exposes it: feeRecipient is committed as
PaymentInfo.feeReceiver, and "Set to address(0) to let the captureAuthorizer
specify any non-zero recipient at capture/charge time." The facilitator hard-
coded paymentInfo.feeReceiver into the charge args, so the delegation path was
dead — a merchant who set feeRecipient=address(0) with maxFeeBps>0 would pair
feeBps>0 with receiver 0 and hit the escrow's ZeroFeeReceiver revert. The
delegated receiver could never actually be paid.

Symmetric to the feeBps fix:

- Add optional `selectFeeReceiver` policy, consulted only when the merchant
  delegated (feeRecipient == address(0)); a configured non-zero recipient is
  passed through unchanged because the escrow forces a match.
- Fold both into one `resolveFee` returning { feeBps, feeReceiver }, shared by
  settle and simulateSettle so neither the fee nor the receiver can drift.
- Reject a non-zero fee with a zero receiver up front as the typed
  `zero_fee_receiver` reason (mirroring the escrow's ZeroFeeReceiver) instead
  of letting it revert on-chain.

Defaults unchanged: floor feeBps + the configured recipient, so existing
behavior is identical when no policy is supplied.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@A1igator A1igator changed the title feat(auth-capture/facilitator): make settle feeBps a facilitator policy feat(auth-capture/facilitator): make settle feeBps + delegated feeReceiver a facilitator policy Jun 3, 2026
A1igator and others added 3 commits June 3, 2026 15:31
Move the private `resolveFee` below the public methods (member-ordering) and
give `FeeSelectionError`'s constructor a JSDoc block description + params. The
pre-commit hook runs build but not eslint, so these slipped through; no
behavior change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…doc + test gaps

SDK review (round 1) findings:

- A selectFeeReceiver callback can return a malformed string despite the
  0x${string} type (TS template literals don't constrain hex/length). The old
  code only checked != zeroAddress, so a bad value reached viem's ABI encoder
  and threw a non-FeeSelectionError deep in settle — escaping the typed-reason
  catch. Validate with viem isAddress (strict:false, since on-chain addresses
  are case-insensitive) and surface the existing `invalid_fee_receiver` reason
  before submitting. Also fixes a non-string return crashing `.toLowerCase()`.
- Document the selector contract: bad return values map to typed reasons, but a
  callback that throws propagates (operator misconfig should fail loudly).
- Add tests for the previously-uncovered non-integer feeBps guard and the
  malformed-receiver path (both assert no writeContract).
- Constructor JSDoc now covers selectFeeReceiver; README documents the optional
  fee policy.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…licy README sample

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@A1igator A1igator requested a review from vraspar June 4, 2026 22:04

@vraspar vraspar left a comment

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 the overall direction is sound: choosing the settle-time fee in facilitator config does not expand payer authorization because the payer still signs the [minFeeBps, maxFeeBps] band and the escrow enforces it. I also do not see this requiring a client or wire-format change; keeping it out of extra is the right boundary.

That said, I think this needs a docs/precision pass before merge. The current wording makes stronger guarantees than the code can provide for arbitrary selector callbacks, and the scheme spec still contradicts the implementation around settlement arg shape. Those are exactly the kinds of details future facilitator authors will cargo-cult, so I would rather fix them now while this PR is touching the fee policy surface.

One additional top-level note because the spec file is not in this diff: specs/schemes/auth-capture/scheme_auth_capture_evm.md still says settlement calls authorize/charge with only (paymentInfo, amount, tokenCollector, collectorData), and still describes Permit2 collector data as ABI-encoded. The implementation now uses the six-arg charge tail for fee policy and passes the raw Permit2 signature, so the spec should be brought back into sync or future implementers can rebuild the old broken path.


/**
* Resolve the actual `feeBps` + `feeReceiver` to forward on a `charge`/
* `capture` call. Single source of truth shared by `settle` and

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.

The helper is shared, but settle() still calls the selector once during verify()/simulateSettle and then again before writeContract. A stateful or time-dependent selector can therefore simulate one in-band fee/receiver and submit another, so the “can never drift” guarantee is stronger than the code actually enforces. Either resolve the fee once for the settle path and reuse the same values for simulation + write, or explicitly require deterministic/pure selectors and soften the lockstep language/tests around this.


### Fee policy (optional)

On `charge`/`capture` the escrow accepts any `feeBps` within the merchant's signed `[minFeeBps, maxFeeBps]` band, and any non-zero `feeReceiver` when the merchant delegated by setting `feeRecipient == address(0)`. The signature commits to the band, not a concrete value, so the facilitator chooses within it. Pass an optional second argument to select those values; both default to the conservative choice (band floor + the configured recipient), so omitting it is unchanged behavior:

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.

This wording can read as if the constructor policy controls both charge and later two-phase capture, but the implementation only consults the selectors when facilitator settle() chooses charge (autoCapture: true). For authorize/two-phase flow, capture-time fee choice is still the captureAuthorizer’s job. I’d narrow this section to “facilitator-submitted charge” and trim the repeated rationale so facilitator authors do not infer an upstream client/wire-format change is needed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

auth-capture facilitator hard-codes minFeeBps as the settle feeBps — EOA-path fee is always the floor; maxFeeBps never exercised

2 participants