Skip to content

feat(x402): adopt settle-first ordering (stacks on #563) - #564

Closed
raahulrahl wants to merge 5 commits into
fix/562-settle-before-deliverfrom
feat/x402-settle-first
Closed

feat(x402): adopt settle-first ordering (stacks on #563)#564
raahulrahl wants to merge 5 commits into
fix/562-settle-before-deliverfrom
feat/x402-settle-first

Conversation

@raahulrahl

Copy link
Copy Markdown
Member

Summary

Why this ordering

Researched x402 reference implementations before picking the order. Two camps:

Reference impl Order Designed for
Coinbase x402-express / x402-next verify → run → settle sub-second API endpoints
Google A2A x402 verify → settle → run long-running agent tasks

Bindu's workload is agent tasks. Following A2A's lead. Coinbase's buffer-then-settle pattern works for 200ms endpoints but leaves a verify-vs-settle window of seconds-to-minutes wide open for agent workloads — exactly the gap exploited in #562.

Latency on the happy path is unchanged: settle (2-5s on Base) just moves from after the LLM call to before it. Total wall-clock is identical.

Behavior change

Case Before this PR (#563) After this PR
Settle fails LLM ran, no artifact delivered, $0.30 LLM cost lost No LLM call, no cost. Task fails fast with recovery metadata.
Settle succeeds, work fails (couldn't happen — settle ran after) New "orphan payment" state. Payer was debited, no artifact delivered. Metadata flags payment-orphaned with full EIP-3009 fields for manual refund.
Happy path Task completed, artifact delivered Unchanged.

Orphan payment — the new failure mode

Settle-first introduces a symmetric risk: settle succeeds, then manifest.run raises (LLM provider outage, agent code bug). The payer paid, got nothing. x402 has no refund primitive, so this PR does NOT auto-refund — it persists the EIP-3009 fields and tags the payment payment-orphaned. Operator responsibility.

Documented in docs/PAYMENT.md. The expectation is orphan payments are rare; each one is a real bug to investigate.

Test plan

  • uv run pytest tests/unit/server/workers/test_manifest_worker.py — 32 passed (3 new + updated existing).
  • uv run pytest tests/unit/server/ tests/unit/extensions/x402/ — 427 passed.
  • uv run ruff check — clean.
  • Pre-commit hooks (ruff, ruff-format, ty, bandit, detect-secrets, pydocstyle) — all pass.
  • Manual smoke test against a real facilitator before merge (recommended; the call-order matters).

Three new tests are the contract:

  • test_settle_first_failure_does_not_invoke_manifest — failed settle → manifest.run was not called.
  • test_settle_first_runs_manifest_only_after_settle — strict call-order assertion: settle first, then manifest.
  • test_work_failure_after_settle_persists_orphan_payment — work raises after successful settle → task carries payment-orphaned metadata.

Removed two #563 tests (test_settle_failure_does_not_deliver_artifact, test_settle_success_unchanged) — they exercised the post-execute gate that no longer exists. Replaced with stronger end-to-end coverage.

What this still does NOT fix

  • Facilitator-timeout vs Base-confirmation race. Settle returns failure while the on-chain tx still confirms. Persisted metadata is sufficient for a reconciliation worker to reason about, but the worker itself is deferred. Same shape as before.
  • Refund automation. x402 has no refund primitive. Bindu doesn't try to send USDC back.

🤖 Generated with Claude Code

PR #563 closed the artifact-leak half of issue #562 (failed settle no
longer delivered the artifact) but agents still ate the LLM cost on
every failed settle — a payer who drained their wallet after verify,
or who lost a parallel-nonce settle race, still triggered an LLM call.

This PR moves _settle_payment to BEFORE manifest.run, so a failed
settlement costs the agent zero LLM tokens:

- run_task now calls _settle_payment immediately after extracting
  payment_context and before transitioning the task to "working".
  Settle-fail → _handle_settlement_failure with full recovery metadata
  → return. Manifest never runs.
- _handle_terminal_state takes settlement_metadata (pre-settled
  receipts) instead of payment_context; the redundant post-execute
  gate from #563 is removed.
- _handle_task_failure accepts the settlement_metadata and tags the
  payment as "payment-orphaned" when work raises after a successful
  settle. x402 has no refund primitive, so the EIP-3009 fields are
  persisted for the operator to issue a manual transfer back.

Wall-clock latency on the happy path is unchanged — settle (2-5s on
Base) just moves from after the LLM call to before it. This is the
same choice Google's A2A x402 extension makes for the same reason;
Coinbase's x402-express middleware uses the opposite ordering, but
that's tuned for sub-second API endpoints, not for agent workloads
where the verify-vs-settle gap can span minutes.

Three new tests cover the new contract:
- settle-fail must not invoke manifest.run
- settle must complete before manifest.run starts (call-order assertion)
- work failure after successful settle persists payment-orphaned metadata

Two tests from #563 (test_settle_failure_does_not_deliver_artifact,
test_settle_success_unchanged) are removed — they exercised the
post-execute gate that no longer exists. The new end-to-end settle-first
tests subsume them.

Stacks on #563.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented May 28, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 430cb0ec-1dd3-45c5-b006-6a98dbb4cb35

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/x402-settle-first

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

raahulrahl and others added 4 commits May 28, 2026 16:00
A single integration test file that drives the real worker pipeline
(ManifestWorker + InMemoryStorage + InMemoryNonceStore) against a mocked
facilitator for each scenario from the #562 discussion:

  1. Front-run drain — settle returns failure → manifest.run not called
  2. Settle timeout — settle raises → recovery metadata persisted
  3. Parallel-nonce double-spend — first task settles & runs, second
     fails settle and burns zero LLM tokens
  4. Replay — middleware rejects identical nonce on the second request

Scenarios 1-3 hit the worker directly with a realistic payment_context
dict (the same shape the middleware produces). Scenario 4 goes through
Starlette TestClient and the real X402Middleware to verify the
middleware-level defense is intact.

Each test prints a narrative trace so `pytest -s` reads like a
walkthrough — useful when explaining the fix to reviewers or auditing
post-merge behavior.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…2E demo

Two changes:

1. Fix latent loguru misuse in _settle_payment.

   ``logger.error(f"Error settling payment: {e}", exc_info=True)`` eagerly
   interpolates the exception into the message string, then passes that
   string to loguru — which re-parses it for ``{placeholder}`` templates.
   When the exception text contains JSON (every x402 SDK error does),
   loguru raises ``KeyError`` trying to resolve ``{success}`` / ``{error}``,
   masking the original error and skipping the recovery-metadata return.

   Switched both error logs to loguru's native templating:
     ``logger.error("Payment settlement failed: {}", error_reason)``
     ``logger.opt(exception=True).error("Error settling payment: {}", e)``

   Positional values are inserted literally — braces inside them are
   never re-parsed.

   This was already in the codebase before settle-first; the E2E demo
   below is what surfaced it.

2. Add a runnable subprocess E2E demo for all four #562 scenarios.

   ``tests/e2e/x402_scenarios/`` contains:
   - ``mock_facilitator.py`` — programmable fake that keys failure modes
     off the EIP-3009 nonce prefix (0xfa11 = settle-reverted,
     0xcdcd = facilitator timeout)
   - ``agent.py`` — minimal Bindu echo agent behind an x402 paywall,
     pointed at the mock facilitator via ``X402__FACILITATOR_URL``
   - ``run_e2e.py`` — driver that spawns both subprocesses, fires real
     HTTP requests for each scenario, and prints the observed task
     state, metadata, and recovery fields

   Run with: ``uv run python tests/e2e/x402_scenarios/run_e2e.py``

   Unlike the existing unit/integration tests (which mock at the worker
   boundary), this demo drives the whole stack — HTTP → X402Middleware
   → scheduler → ManifestWorker → real facilitator client — exactly as
   a real deployment would.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
PAYMENT.md already had a "Watching it work" section with an inline
fake facilitator, but it only covered the happy path. The new
tests/e2e/x402_scenarios/ harness covers all four #562 failure modes
end-to-end with a single command — surface it from the docs so
operators don't have to discover it from the PR history.

Also adds manifest_worker.py and the E2E directory to the
"Where to look in the code" pointer list.

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

Per CodeRabbit review on #563: the failure message included the raw
``error_text`` from the facilitator (e.g. internal HTTP 500 body, JSON
payloads). That can disclose facilitator topology or internal state to
the caller — and the caller already gets a clear "task failed" signal
plus the structured metadata fields they need.

The full reason still lives in ``task.metadata["x402.payment.error"]``
for operator audit; it's just no longer echoed in the agent message
that goes back over the wire.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@raahulrahl
raahulrahl deleted the branch fix/562-settle-before-deliver May 28, 2026 14:20
@raahulrahl raahulrahl closed this May 28, 2026
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.

1 participant