From 91ac6e46c0c0e577a1747888d9326ce200e1caa6 Mon Sep 17 00:00:00 2001 From: bussyjd Date: Fri, 5 Jun 2026 13:31:27 +0400 Subject: [PATCH 1/2] fix(buy-x402): unify + extend x402 auth pool expiry (default 1 month, optional never) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 - 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. --- internal/embed/skills/buy-x402/SKILL.md | 2 + internal/embed/skills/buy-x402/scripts/buy.py | 48 ++++++++++++++++--- 2 files changed, 44 insertions(+), 6 deletions(-) diff --git a/internal/embed/skills/buy-x402/SKILL.md b/internal/embed/skills/buy-x402/SKILL.md index b8ebdf87..f6555410 100644 --- a/internal/embed/skills/buy-x402/SKILL.md +++ b/internal/embed/skills/buy-x402/SKILL.md @@ -13,6 +13,8 @@ Purchase access to remote x402-gated services. There are two flows, picked by us Both flows auto-detect the token + transfer method from the seller's 402 response. Currently supported: **USDC via EIP-3009** (Base Sepolia, Base Mainnet, Ethereum Mainnet) and **OBOL via Permit2** (Ethereum Mainnet). +**Auth expiry (`OBOL_X402_AUTH_TTL` / `--auth-ttl`).** A pre-signed pool is spent over time, so each auth carries a *spendability* deadline — distinct from the per-request settle window (`maxTimeoutSeconds`). One knob controls **both** payment methods (Permit2 `deadline` and ERC-3009 `validBefore`): default **30 days (1 month)**; pass a number of seconds (floored at 300s = one settle window); or pass **`never`** (also `0`/`none`) for a non-expiring pool (mapped to the uint sentinel `4294967295`, ~year 2106, which both contracts accept). Set per-buy with `--auth-ttl ` or globally via the `OBOL_X402_AUTH_TTL` env. A too-short value silently expires the pool minutes after buy. + Chain names follow the eRPC project aliases: `mainnet`, `base`, `base-sepolia`. CAIP-2 strings (`eip155:1`, `eip155:8453`, `eip155:84532`) and the alias `ethereum` are accepted on input and normalized internally. Unknown chains fail loudly with the supported list — buy.py will not silently sign against base-sepolia when the seller is on mainnet. ## Gasless Payments diff --git a/internal/embed/skills/buy-x402/scripts/buy.py b/internal/embed/skills/buy-x402/scripts/buy.py index 7c482983..bc2add89 100644 --- a/internal/embed/skills/buy-x402/scripts/buy.py +++ b/internal/embed/skills/buy-x402/scripts/buy.py @@ -18,7 +18,7 @@ buy --endpoint --model Pre-sign + author PurchaseRequest [--budget ] [--count ] [--auto-refill[=true|false]] [--refill-threshold ] - [--refill-count ] + [--refill-count ] [--auth-ttl ] list List purchased providers + remaining auths status Check sidecar health + remaining auths process |--all Reconcile auto-refill policies @@ -834,6 +834,36 @@ def _resolve_eip3009_domain(extra, chain, asset): return USDC_DOMAIN_NAME, USDC_DOMAIN_VERSION +# Auth expiry — ONE knob (OBOL_X402_AUTH_TTL) for BOTH payment methods, so a +# pre-signed pool always expires at the same wall-clock whether the seller takes +# USDC (ERC-3009 validBefore) or OBOL (Permit2 deadline). +DEFAULT_AUTH_TTL_SECONDS = 30 * 24 * 3600 # 1 month +MAX_SAFE_DEADLINE = 4294967295 # 0xFFFFFFFF (~year 2106) == "never"; the uint +# both USDC transferWithAuthorization (validBefore <) and the Permit2/x402 +# contracts (deadline <=) accept without overflow. + + +def _auth_expiry(): + """Absolute unix expiry shared by Permit2 `deadline` and ERC-3009 `validBefore`. + + Controlled by OBOL_X402_AUTH_TTL: + - unset -> now + 30 days (1 month; the default) + - -> now + max(seconds, 300) (floor = one settle window) + - 0 / never / none -> MAX_SAFE_DEADLINE (no expiry, ~year 2106) + + This is the pool's spendability lifetime — a separate concept from the + per-request settle window (payment.maxTimeoutSeconds). + """ + raw = os.environ.get("OBOL_X402_AUTH_TTL", "").strip().lower() + if raw in ("0", "never", "none", "-1"): + return MAX_SAFE_DEADLINE + try: + ttl = max(int(raw), 300) + except (TypeError, ValueError): + ttl = DEFAULT_AUTH_TTL_SECONDS + return int(time.time()) + ttl + + def _presign_auths(signer_address, pay_to, price, chain, usdc_addr, count, payment=None, extensions=None): """Pre-sign N x402 payment payloads, defaulting to legacy ERC-3009 USDC.""" chain = _resolve_chain(chain) @@ -859,8 +889,8 @@ def _presign_auths(signer_address, pay_to, price, chain, usdc_addr, count, payme # mined, so wall-clock based "now - slack" can still be in the # future and the facilitator rejects with PaymentTooEarly(). valid_after = "0" - expiry_window = max(int(payment.get("maxTimeoutSeconds", 60)), 300) - deadline = str(int(time.time()) + expiry_window) + # Permit2 deadline = the pool's spendability lifetime (see _auth_expiry). + deadline = str(_auth_expiry()) permit2_nonce = str(int.from_bytes(secrets.token_bytes(32), "big")) typed_data = { "types": { @@ -995,6 +1025,7 @@ def _presign_auths(signer_address, pay_to, price, chain, usdc_addr, count, payme extra, chain, payment.get("asset", usdc_addr), ) nonce = "0x" + secrets.token_hex(32) + valid_before = str(_auth_expiry()) typed_data = { "types": { @@ -1025,7 +1056,7 @@ def _presign_auths(signer_address, pay_to, price, chain, usdc_addr, count, payme "to": pay_to, "value": str(price), "validAfter": "0", - "validBefore": "4294967295", + "validBefore": valid_before, "nonce": nonce, }, } @@ -1058,7 +1089,7 @@ def _presign_auths(signer_address, pay_to, price, chain, usdc_addr, count, payme "to": pay_to, "value": str(price), "validAfter": "0", - "validBefore": "4294967295", + "validBefore": valid_before, "nonce": nonce, }, }, @@ -1955,7 +1986,8 @@ def usage(): print(" buy --endpoint --model Pre-sign + configure paid/") print(" [--budget ] [--count ]") print(" [--auto-refill[=true|false]] [--refill-threshold ]") - print(" [--refill-count ]") + print(" [--refill-count ] [--auth-ttl ]") + print(" --auth-ttl pool expiry: seconds, or 'never' (default 30d/1mo); env OBOL_X402_AUTH_TTL") print(" list List purchased providers") print(" status Check sidecar + auths") print(" process | --all Reconcile auto-refill policies") @@ -1994,6 +2026,8 @@ def usage(): if kind not in ("http", "inference"): print(f"Error: --type must be 'http' or 'inference', got '{kind}'", file=sys.stderr) sys.exit(1) + if opts.get("auth_ttl") is not None: + os.environ["OBOL_X402_AUTH_TTL"] = str(opts["auth_ttl"]) cmd_pay( positional[0], method=opts.get("method", "GET"), @@ -2015,6 +2049,8 @@ def usage(): if not endpoint or not model: print("Error: --endpoint and --model are required.", file=sys.stderr) sys.exit(1) + if opts.get("auth_ttl") is not None: + os.environ["OBOL_X402_AUTH_TTL"] = str(opts["auth_ttl"]) cmd_buy(name, endpoint, model, budget, count, opts) elif cmd == "refill": From 8558b55d8a1e2b2af29d0088d7a852f1c94912eb Mon Sep 17 00:00:00 2001 From: bussyjd Date: Fri, 5 Jun 2026 13:42:53 +0400 Subject: [PATCH 2/2] test(buy-x402): cover _auth_expiry (default 1mo, never sentinel, floor, fallback) Locks the unified expiry contract and the facilitator-safe MAX_SAFE_DEADLINE (0xFFFFFFFF). Run: python3 tests/test_buy_auth_expiry.py --- tests/test_buy_auth_expiry.py | 79 +++++++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 tests/test_buy_auth_expiry.py diff --git a/tests/test_buy_auth_expiry.py b/tests/test_buy_auth_expiry.py new file mode 100644 index 00000000..759da0a6 --- /dev/null +++ b/tests/test_buy_auth_expiry.py @@ -0,0 +1,79 @@ +#!/usr/bin/env python3 +"""Unit tests for buy.py auth-pool expiry (_auth_expiry). + +Locks the unified expiry contract shared by the Permit2 `deadline` and the +ERC-3009 `validBefore`: default 1 month, configurable seconds (floored at the +settle window), and a `never` sentinel that the x402 facilitator accepts +(validBefore/deadline only checked as >= now + small buffer, with no +maxTimeoutSeconds upper bound). +""" +import importlib.util +import os +import sys +import time +import unittest +from pathlib import Path + +MODULE_PATH = ( + Path(__file__).resolve().parents[1] + / "internal" / "embed" / "skills" / "buy-x402" / "scripts" / "buy.py" +) + + +def load_buy_module(): + spec = importlib.util.spec_from_file_location("buy_x402", MODULE_PATH) + module = importlib.util.module_from_spec(spec) + assert spec.loader is not None + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +class AuthExpiryTest(unittest.TestCase): + def setUp(self): + self.buy = load_buy_module() + os.environ.pop("OBOL_X402_AUTH_TTL", None) + + def tearDown(self): + os.environ.pop("OBOL_X402_AUTH_TTL", None) + + def test_default_is_one_month(self): + now = int(time.time()) + self.assertAlmostEqual( + self.buy._auth_expiry() - now, 30 * 24 * 3600, delta=5 + ) + + def test_never_aliases_map_to_sentinel(self): + for value in ("never", "0", "none", "-1", "NEVER", " never "): + os.environ["OBOL_X402_AUTH_TTL"] = value + self.assertEqual( + self.buy._auth_expiry(), self.buy.MAX_SAFE_DEADLINE, msg=value + ) + + def test_custom_seconds(self): + os.environ["OBOL_X402_AUTH_TTL"] = "3600" + now = int(time.time()) + self.assertAlmostEqual(self.buy._auth_expiry() - now, 3600, delta=5) + + def test_floored_at_settle_window(self): + # Values below the 300s settle-window floor are clamped up. + os.environ["OBOL_X402_AUTH_TTL"] = "10" + now = int(time.time()) + self.assertAlmostEqual(self.buy._auth_expiry() - now, 300, delta=5) + + def test_garbage_falls_back_to_default(self): + os.environ["OBOL_X402_AUTH_TTL"] = "not-a-number" + now = int(time.time()) + self.assertAlmostEqual( + self.buy._auth_expiry() - now, 30 * 24 * 3600, delta=5 + ) + + def test_sentinel_is_facilitator_safe(self): + # 0xFFFFFFFF (~year 2106): a real uint the facilitator accepts + # (validBefore/deadline must be >= now + buffer; no upper bound). + self.assertEqual(self.buy.MAX_SAFE_DEADLINE, 0xFFFFFFFF) + self.assertGreater(self.buy.MAX_SAFE_DEADLINE, int(time.time()) + 6) + + +if __name__ == "__main__": + unittest.main()