feat(x402): generic beforePayment pre-sign hook (vendor-neutral)#205
feat(x402): generic beforePayment pre-sign hook (vendor-neutral)#205twzrd-sol wants to merge 1 commit into
Conversation
Adds an optional `beforePayment` callback to ProxyOptions. Fires after
requirement selection, before transaction build + signing — the last
deterministic checkpoint at which a payment can be refused.
- No vendor dependency. Wire any policy you own: allowlist, spend cap,
trust preflight, compliance check.
- Return `{ abort: true, reason }` to refuse — wallet never signs.
- Return `void` or `{ abort: false }` to proceed.
- Async-safe (can call external policy services).
- Fail-closed: unhandled throw aborts the payment.
Uses the same official x402 lifecycle hook (`onBeforePaymentCreation`)
that BlockRun's own builder-code stamp and chain logging already use.
📝 WalkthroughWalkthroughAdds an optional ChangesPre-payment policy
Estimated code review effort: 2 (Simple) | ~10 minutes Sequence Diagram(s)sequenceDiagram
participant startProxy
participant x402
participant beforePayment
participant paymentSigning
startProxy->>x402: register beforePayment hook
x402->>beforePayment: provide selected requirements and resource URL
beforePayment-->>x402: approval or abort decision
x402->>paymentSigning: build and sign payment when approved
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
🧹 Nitpick comments (2)
src/proxy.ts (2)
2048-2073: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueCapture
options.beforePaymentin a local variable to avoid non-null assertion.The
options.beforePayment!assertion inside the closure is safe at runtime (guaranteed by the enclosingif), but TypeScript can't narrow through async closures. Capturing the value in a local const eliminates the assertion and is more resilient to future refactoring.♻️ Proposed refactor
if (options.beforePayment) { + const hook = options.beforePayment; x402.onBeforePaymentCreation(async (ctx) => { - const decision = await options.beforePayment!({ + const decision = await hook({ selectedRequirements: { scheme: ctx.selectedRequirements.scheme, network: ctx.selectedRequirements.network, amount: ctx.selectedRequirements.amount, asset: ctx.selectedRequirements.asset, payTo: ctx.selectedRequirements.payTo, maxTimeoutSeconds: ctx.selectedRequirements.maxTimeoutSeconds, extra: ctx.selectedRequirements.extra, }, resourceUrl: ctx.resourceUrl, }); if (decision && decision.abort === true) { throw new Error( `[ClawRouter] payment aborted by beforePayment hook: ${decision.reason ?? "refused"}`, ); } }); }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/proxy.ts` around lines 2048 - 2073, Capture options.beforePayment in a local constant within the enclosing conditional, then invoke that local callback inside the async onBeforePaymentCreation closure instead of using the non-null assertion. Preserve the existing callback arguments and abort behavior.
2052-2071: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick winConsider adding a timeout guard around the hook execution.
The PR summary explicitly states the hook may call an external policy service asynchronously. If that service hangs,
await options.beforePayment!(...)blocks indefinitely, stalling payment creation and the entire request. While the overallrequestTimeoutMs(default 180s) may eventually cover this, a dedicated timeout on the hook call would fail fast and provide a clearer error. Consider wrapping the hook invocation withAbortSignal.timeout()or aPromise.raceagainst a configurable deadline.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/proxy.ts` around lines 2052 - 2071, Guard the asynchronous options.beforePayment hook invocation in the x402 onBeforePaymentCreation handler with a dedicated, preferably configurable timeout. Ensure a hanging external policy service rejects promptly with a clear timeout error, while preserving the existing decision.abort handling for hooks that complete normally.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In `@src/proxy.ts`:
- Around line 2048-2073: Capture options.beforePayment in a local constant
within the enclosing conditional, then invoke that local callback inside the
async onBeforePaymentCreation closure instead of using the non-null assertion.
Preserve the existing callback arguments and abort behavior.
- Around line 2052-2071: Guard the asynchronous options.beforePayment hook
invocation in the x402 onBeforePaymentCreation handler with a dedicated,
preferably configurable timeout. Ensure a hanging external policy service
rejects promptly with a clear timeout error, while preserving the existing
decision.abort handling for hooks that complete normally.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: 792c5101-a13c-4383-b55a-0ff425c18e4c
📒 Files selected for processing (1)
src/proxy.ts
What
Adds a generic
beforePaymentlifecycle hook tostartProxy.Fires after ClawRouter selects a payment requirement, before the transaction is built and signed — the last deterministic checkpoint where a payment can be refused without a wallet signature.
Design
x402.onBeforePaymentCreation, the same hook BlockRun's own builder-code stamp and chain logging already use.{ abort: true, reason }to refuse — wallet never signs, payment never reaches the merchant.void/{ abort: false }to proceed unchanged.Relationship to the withdrawn #204
#204 asked ClawRouter to add a branded peerDep (
twzrd-x402-gate) and TWZRD-named env vars. This PR replaces that with a generic, no-dependency seam. The same pre-sign outcome, but the policy is the caller's responsibility — not a specific package.Changes (+65 lines, 1 file)
src/proxy.ts:ProxyOptions.beforePaymenttype +onBeforePaymentCreationregistration instartProxyNo test file yet — if the shape is right, I'll add one.
🤖 Generated with Hermes Agent
Summary by CodeRabbit