Skip to content

fix(buy-x402): unify + extend x402 auth pool expiry (default 1 month, optional never)#601

Merged
OisinKyne merged 3 commits into
mainfrom
feat/buy-x402-auth-ttl
Jun 5, 2026
Merged

fix(buy-x402): unify + extend x402 auth pool expiry (default 1 month, optional never)#601
OisinKyne merged 3 commits into
mainfrom
feat/buy-x402-auth-ttl

Conversation

@bussyjd
Copy link
Copy Markdown
Contributor

@bussyjd bussyjd commented Jun 5, 2026

What

Unifies and extends the x402 pre-signed auth pool expiry — one knob for both payment methods, default 1 month, with an optional non-expiration setting.

Bug

Pre-signed pool auths silently expired ~5 minutes after buy. _presign_auths set the Permit2 (OBOL) deadline = now + max(maxTimeoutSeconds, 300) (= 300 s) — conflating the per-request settle window (maxTimeoutSeconds) with the pool's spendability lifetime. Meanwhile the ERC-3009 (USDC) path hardcoded validBefore = 4294967295 (~never). So OBOL pools became unusable minutes after purchase (the first auth settled, the rest expired) while USDC pools never expired — inconsistent.

Observed live: a pool intended for ongoing use had a Permit2 deadline ~5 min out; later paid requests failed with seller 503 "Payment verification failed" (on-chain block.timestamp <= deadline rejects the expired auth).

Fix

A single _auth_expiry() resolver drives both the Permit2 deadline and the ERC-3009 validBefore:

  • OBOL_X402_AUTH_TTL env or --auth-ttl <seconds|never> flag (on buy and pay)
  • default 30 days (1 month); floored at 300 s (one settle window)
  • 0 / never / none4294967295 (~year 2106) = non-expiration — the uint sentinel that both USDC transferWithAuthorization (validBefore <) and the Permit2/x402 contracts (deadline <=) accept without overflow

Both payment methods now expire at the same wall-clock.

Why this is the right (and only) place

The deadline/validBefore is set at sign time in buy.py, carried verbatim through PurchaseRequest → serviceoffer-controller → x402-buyer → seller (the remote-signer signs blindly; nothing downstream re-derives it), and enforced only on-chain at settle. So buy.py's _presign_auths is the single amendment point.

Test

  • python3 -m py_compile ✓ · go build ./cmd/obol ✓ (embed intact) · repo pre-commit ✓
  • _auth_expiry() verified: default → 30.0 days; never/04294967295; 36003600 s; 10300 s (floored)

Notes

Scope

Two files, additive: internal/embed/skills/buy-x402/scripts/buy.py (+42), internal/embed/skills/buy-x402/SKILL.md (+2). No controller, RBAC, or Go changes.

bussyjd added 2 commits June 5, 2026 13:31
… optional never)

Pre-signed pool auths were silently expiring ~5 minutes after buy: the Permit2
(OBOL) path set deadline = now + max(maxTimeoutSeconds, 300) (=300s), conflating
the per-request settle window with the pool's spendability lifetime, while the
ERC-3009 (USDC) path used validBefore=4294967295 (~never) — inconsistent.

Introduce a single _auth_expiry() used by BOTH payment methods:
  - OBOL_X402_AUTH_TTL (seconds) / --auth-ttl <seconds|never>
  - default 30 days (1 month); floor 300s
  - 0/never/none -> 4294967295 (~year 2106), the uint both contracts accept

Permit2 deadline and ERC-3009 validBefore now expire at the same wall-clock.
…r, fallback)

Locks the unified expiry contract and the facilitator-safe MAX_SAFE_DEADLINE
(0xFFFFFFFF). Run: python3 tests/test_buy_auth_expiry.py
@bussyjd
Copy link
Copy Markdown
Contributor Author

bussyjd commented Jun 5, 2026

Update — test coverage + x402 parity

Test coverage added (8558b55d, tests/test_buy_auth_expiry.py): 6 cases locking the unified expiry contract — default 1 month; never/0/none/-1/aliases → MAX_SAFE_DEADLINE (0xFFFFFFFF); custom seconds; 300s floor; garbage→default; sentinel is facilitator-safe. Passes; existing test_buy_autorefill (13) and test_buy_normalize_endpoint unaffected.

x402 parity (verified against the coinbase/x402 go SDK at the rev we depend on):

  • The facilitator imposes no maxTimeoutSeconds upper bound on validBefore/deadline — it only checks not-expired + not-future:
    • ERC-3009 exact/v1/facilitator/scheme.go:166-174validBefore >= now+6, validAfter <= now
    • Permit2 exact/facilitator/permit2.godeadline >= now+buffer, validAfter <= now
    • Our long-lived pool auths (30d / sentinel) pass; validAfter="0" passes.
  • maxTimeoutSeconds is the per-request window the reference client uses (deadline = now + maxTimeoutSeconds) — standard x402 is per-request. Our pre-signed pool legitimately extends this with a longer lifetime, which the facilitator accepts.
  • The 4294967295 sentinel + far-future validBefore are already exercised in our suite (buy_side_test.go, verifier_test.go, forwardauth_test.go).

Net: spec-aligned; the original 5-min bug produced ErrAuthorizationValidBeforeExpired / ErrPermit2DeadlineExpired (surfaced as seller 503).

@OisinKyne OisinKyne merged commit 1d7e35a into main Jun 5, 2026
8 checks passed
@OisinKyne OisinKyne deleted the feat/buy-x402-auth-ttl branch June 5, 2026 18:20
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.

2 participants