feat(auth-capture/facilitator): make settle feeBps + delegated feeReceiver a facilitator policy#65
feat(auth-capture/facilitator): make settle feeBps + delegated feeReceiver a facilitator policy#65A1igator wants to merge 5 commits into
Conversation
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>
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>
vraspar
left a comment
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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: |
There was a problem hiding this comment.
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.
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:
feeBps— hard-coded topaymentInfo.minFeeBps. The signature commits only to the band[minFeeBps, maxFeeBps]; the escrow's_validateFeeaccepts any in-band value. So for any captureAuthorizer that honors the forwarded fee (EOA path, thin pass-throughs), the fee was always the floor andmaxFeeBpswas dead.feeReceiver— hard-coded topaymentInfo.feeReceiver. Per the scheme spec,feeRecipientmay be set toaddress(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)withmaxFeeBps > 0pairsfeeBps > 0with receiver0→ on-chainZeroFeeReceiverrevert. 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
selectFeeBpsandselectFeeReceiverpolicies to theAuthCaptureEvmSchemeconstructor (AuthCaptureFacilitatorOptions).selectFeeBpsdefaults to the band floor (minFeeBps).selectFeeReceiveris consulted only when the merchant delegated (feeRecipient == address(0)); a configured non-zero recipient is passed through unchanged, because the escrow forces a match.resolveFeereturning{ feeBps, feeReceiver }, shared bysettleandsimulateSettle, so neither value can drift between simulate and submit (issue Q3 — lockstep is now structural).feeBps→fee_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
PaymentOperator) are unaffected — the forwarded values are inert for them.extrachange. The merchant's knobs stay the band andfeeRecipient(incl. theaddress(0)delegation the spec already documents).Tests
settle — facilitator feeBps policyandsettle — feeReceiver delegationsuites cover: defaults (floor + configured recipient); in-band/delegated values forwarded to both the charge args and the simulation; out-of-bandfeeBpsand fee-with-zero-receiver rejected before anywriteContract; configured recipient passed through with the policy not consulted; and zero receiver allowed when no fee is taken. 132/132 unit tests pass.🤖 Generated with Claude Code