Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions internal/embed/skills/buy-x402/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,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 <seconds|never>` 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
Expand Down
48 changes: 42 additions & 6 deletions internal/embed/skills/buy-x402/scripts/buy.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
buy <name> --endpoint <url> --model <id> Pre-sign + author PurchaseRequest
[--budget <micro-units>] [--count <N>]
[--auto-refill[=true|false]] [--refill-threshold <N>]
[--refill-count <N>] [--set-default]
[--refill-count <N>] [--auth-ttl <seconds|never>] [--set-default]
list List purchased providers + remaining auths
status <name> Check sidecar health + remaining auths
process <name>|--all Reconcile auto-refill policies
Expand Down Expand Up @@ -836,6 +836,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)
- <seconds> -> 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)
Expand All @@ -861,8 +891,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": {
Expand Down Expand Up @@ -997,6 +1027,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": {
Expand Down Expand Up @@ -1027,7 +1058,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,
},
}
Expand Down Expand Up @@ -1060,7 +1091,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,
},
},
Expand Down Expand Up @@ -2123,7 +2154,8 @@ def usage():
print(" buy <name> --endpoint <url> --model <id> Pre-sign + configure paid/<model>")
print(" [--budget <micro-units>] [--count <N>]")
print(" [--auto-refill[=true|false]] [--refill-threshold <N>]")
print(" [--refill-count <N>] [--set-default]")
print(" [--refill-count <N>] [--auth-ttl <seconds|never>] [--set-default]")
print(" --auth-ttl pool expiry: seconds, or 'never' (default 30d/1mo); env OBOL_X402_AUTH_TTL")
print(" --set-default inference only: adopt paid/<model> as the agent's primary model")
print(" list List purchased providers")
print(" status <name> Check sidecar + auths")
Expand Down Expand Up @@ -2163,6 +2195,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"])
timeout = opts.get("timeout")
if timeout is not None:
try:
Expand Down Expand Up @@ -2192,6 +2226,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":
Expand Down
79 changes: 79 additions & 0 deletions tests/test_buy_auth_expiry.py
Original file line number Diff line number Diff line change
@@ -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()
Loading