Skip to content

Commit ab71f5e

Browse files
vvillait88claude
andcommitted
feat(commerce): dual-emit accepts (amount + maxAmountRequired) + x402 sample in discovery probe
Mirror of node-commerce 6fcf928. Two related changes for compat with v1-only x402 parsers (notably Coinbase awal at payments-mcp.coinbase.com, hardcoded to read maxAmountRequired): 1. payment_required_header + build_402_body run accepts[] through alias_amount_fields. Each entry gets BOTH `amount` (v2 spec) AND `maxAmountRequired` (v1 spec). Strict v2 parsers ignore the alias; v1-only parsers find their field. Idempotent + symmetric. 2. build_discovery_probe_response accepts an optional `x402_sample` (X402SampleProbe). When set, the probe response also carries: - `payment-required` header (base64 PaymentRequired with sample accepts) - `accepts` array in the body Lets x402 discovery clients find merchant's x402 support from an empty-body POST. Awal x402 details now extracts requirements end-to-end. 489 tests pass, ruff + ty clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent d8a8243 commit ab71f5e

7 files changed

Lines changed: 124 additions & 6 deletions

File tree

agentscore_commerce/challenge/body.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from typing import Any, Literal
55

66
from agentscore_commerce.challenge.pricing import PricingBlock
7+
from agentscore_commerce.payment.wwwauthenticate import alias_amount_fields
78

89

910
@dataclass
@@ -34,7 +35,7 @@ def build_402_body(input: Build402BodyInput) -> dict[str, Any]:
3435
body: dict[str, Any] = {"payment_required": True, "accepted_methods": input.accepted_methods}
3536
if input.x402:
3637
body["x402Version"] = input.x402.version
37-
body["accepts"] = input.x402.accepts
38+
body["accepts"] = alias_amount_fields(input.x402.accepts)
3839
if input.amount_usd is not None:
3940
body["amount_usd"] = input.amount_usd
4041
if input.currency:

agentscore_commerce/discovery/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
from agentscore_commerce.discovery.probe import (
2121
DiscoveryProbeOptions,
2222
DiscoveryProbeResponse,
23+
X402SampleProbe,
2324
build_discovery_probe_response,
2425
is_discovery_probe_request,
2526
)
@@ -40,6 +41,7 @@
4041
"LlmsTxtSection",
4142
"PaymentMethodConfig",
4243
"WellKnownMppInput",
44+
"X402SampleProbe",
4345
"agentscore_denial_schemas",
4446
"agentscore_openapi_snippets",
4547
"agentscore_payment_required_schema",

agentscore_commerce/discovery/probe.py

Lines changed: 44 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,36 @@
11
"""Discovery probe — answers empty-body POSTs from MPP crawlers (mppscan, link-cli) with a sample 402."""
22

3+
import base64
34
import json
4-
from dataclasses import dataclass
5+
from dataclasses import dataclass, field
56
from datetime import UTC, datetime, timedelta
6-
from typing import Any, Protocol
7+
from typing import Any, Literal, Protocol
78

89
from agentscore_commerce.payment.directive import (
910
PaymentDirectiveInput,
1011
PaymentRequestInput,
1112
build_payment_request_blob,
1213
payment_directive,
1314
)
15+
from agentscore_commerce.payment.wwwauthenticate import (
16+
PaymentRequiredHeaderInput,
17+
payment_required_header,
18+
)
19+
20+
21+
@dataclass
22+
class X402SampleProbe:
23+
"""Sample x402 accepts to embed in the discovery probe's PAYMENT-REQUIRED header.
24+
25+
Crawlers (e.g. ``awal x402 details``) can find this endpoint's x402 support
26+
without a real business-shaped request. Each entry is run through
27+
``alias_amount_fields`` so v1-only parsers find ``maxAmountRequired`` and
28+
v2-strict parsers find ``amount``.
29+
"""
30+
31+
accepts: list[Any]
32+
version: Literal[1, 2] = 2
33+
resource_url: str | None = None
1434

1535

1636
@dataclass
@@ -23,6 +43,7 @@ class DiscoveryProbeOptions:
2343
ttl_seconds: int = 300
2444
docs_url: str | None = None
2545
message: str | None = None
46+
x402_sample: X402SampleProbe | None = field(default=None)
2647

2748

2849
@dataclass
@@ -54,9 +75,29 @@ def build_discovery_probe_response(opts: DiscoveryProbeOptions) -> DiscoveryProb
5475
}
5576
if opts.docs_url:
5677
body_obj["docs"] = opts.docs_url
78+
headers: dict[str, str] = {"content-type": "application/json", "www-authenticate": directive}
79+
80+
if opts.x402_sample is not None:
81+
x402v = opts.x402_sample.version
82+
# payment_required_header internally runs alias_amount_fields, so v1+v2
83+
# parsers both find their expected field name on the header decode.
84+
header_kwargs: dict[str, Any] = {"x402_version": x402v, "accepts": opts.x402_sample.accepts}
85+
if opts.x402_sample.resource_url:
86+
header_kwargs["resource"] = {
87+
"url": opts.x402_sample.resource_url,
88+
"mimeType": "application/json",
89+
}
90+
encoded = payment_required_header(PaymentRequiredHeaderInput(**header_kwargs))
91+
headers["payment-required"] = encoded
92+
# Mirror the aliased accepts in the body so clients that fall back from
93+
# header → body (e.g. awal's discover) can still extract requirements.
94+
decoded = json.loads(base64.b64decode(encoded).decode())
95+
body_obj["x402Version"] = x402v
96+
body_obj["accepts"] = decoded["accepts"]
97+
5798
return DiscoveryProbeResponse(
5899
status=402,
59-
headers={"content-type": "application/json", "www-authenticate": directive},
100+
headers=headers,
60101
body=json.dumps(body_obj, separators=(",", ":")),
61102
)
62103

agentscore_commerce/payment/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
from agentscore_commerce.payment.usdc import USDC
4343
from agentscore_commerce.payment.wwwauthenticate import (
4444
PaymentRequiredHeaderInput,
45+
alias_amount_fields,
4546
payment_required_header,
4647
www_authenticate_header,
4748
)
@@ -107,6 +108,7 @@
107108
"X402AcceptsBlock",
108109
"X402FacilitatorChoice",
109110
"X402SymbolicRail",
111+
"alias_amount_fields",
110112
"build_idempotency_key",
111113
"build_payment_directive",
112114
"build_payment_headers",

agentscore_commerce/payment/wwwauthenticate.py

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,31 @@ def www_authenticate_header(directives: list[str]) -> str:
1414
return ", ".join(directives)
1515

1616

17+
def alias_amount_fields(accepts: list[Any]) -> list[Any]:
18+
"""Add the v1↔v2 amount-field alias to each accepts entry. Idempotent.
19+
20+
Used by both ``payment_required_header`` (header emit) and ``build_402_body``
21+
(body emit) so every x402 entry on the wire carries BOTH ``amount`` (v2 spec)
22+
AND ``maxAmountRequired`` (v1 spec). Strict v1-only parsers (e.g. Coinbase
23+
awal at ``payments-mcp.coinbase.com``, hardcoded to read ``maxAmountRequired``)
24+
work alongside strict v2 parsers, which ignore the alias.
25+
"""
26+
out: list[Any] = []
27+
for entry in accepts:
28+
if not isinstance(entry, dict):
29+
out.append(entry)
30+
continue
31+
has_amount = "amount" in entry
32+
has_max_amount = "maxAmountRequired" in entry
33+
if has_amount and not has_max_amount:
34+
out.append({**entry, "maxAmountRequired": entry["amount"]})
35+
elif has_max_amount and not has_amount:
36+
out.append({**entry, "amount": entry["maxAmountRequired"]})
37+
else:
38+
out.append(entry)
39+
return out
40+
41+
1742
@dataclass
1843
class PaymentRequiredHeaderInput:
1944
x402_version: Literal[1, 2]
@@ -22,8 +47,12 @@ class PaymentRequiredHeaderInput:
2247

2348

2449
def payment_required_header(input: PaymentRequiredHeaderInput) -> str:
25-
"""Encode the standard x402 PAYMENT-REQUIRED header (base64-encoded JSON)."""
26-
body: dict[str, Any] = {"x402Version": input.x402_version, "accepts": input.accepts}
50+
"""Encode the standard x402 PAYMENT-REQUIRED header (base64-encoded JSON).
51+
52+
Each accepts entry is post-processed via :func:`alias_amount_fields` so v1-only
53+
clients (e.g. awal) and v2-strict clients can both read it.
54+
"""
55+
body: dict[str, Any] = {"x402Version": input.x402_version, "accepts": alias_amount_fields(input.accepts)}
2756
if input.resource is not None:
2857
body["resource"] = input.resource
2958
raw = json.dumps(body, separators=(",", ":")).encode()

tests/test_challenge.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,3 +148,19 @@ def test_build_402_body_assembles_full_response():
148148
assert body["pricing"]["total"] == "108"
149149
assert body["identity_mode"] == "wallet"
150150
assert body["agent_instructions"]["how_to_pay"] == {}
151+
152+
153+
def test_build_402_body_emits_v1_alias_on_accepts_entries():
154+
"""Each accepts entry carries both `amount` (v2) and `maxAmountRequired` (v1)."""
155+
body = build_402_body(
156+
Build402BodyInput(
157+
accepted_methods=[],
158+
x402=X402PaymentRequired(
159+
accepts=[{"scheme": "exact", "network": "eip155:84532", "amount": "110000"}],
160+
version=2,
161+
),
162+
)
163+
)
164+
entry = body["accepts"][0]
165+
assert entry["amount"] == "110000"
166+
assert entry["maxAmountRequired"] == "110000"

tests/test_payment_misc.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,33 @@ def test_payment_required_header_base64_encodes_json():
6868
assert decoded["resource"]["url"] == "https://x"
6969

7070

71+
def test_payment_required_header_emits_v1_alias_for_v2_clients():
72+
"""v1-only parsers (Coinbase awal) read maxAmountRequired; v2-strict parsers read amount.
73+
74+
Header carries both so either side works.
75+
"""
76+
from agentscore_commerce.payment import alias_amount_fields
77+
78+
h = payment_required_header(
79+
PaymentRequiredHeaderInput(
80+
x402_version=2,
81+
accepts=[{"scheme": "exact", "network": "eip155:84532", "amount": "110000"}],
82+
)
83+
)
84+
decoded = json.loads(base64.b64decode(h))
85+
assert decoded["accepts"][0]["amount"] == "110000"
86+
assert decoded["accepts"][0]["maxAmountRequired"] == "110000"
87+
88+
# Reverse: vendor emitting v1 shape gets amount alias added.
89+
aliased = alias_amount_fields([{"scheme": "exact", "maxAmountRequired": "110000"}])
90+
assert aliased[0]["amount"] == "110000"
91+
assert aliased[0]["maxAmountRequired"] == "110000"
92+
93+
# Idempotent: both already set → unchanged.
94+
both = alias_amount_fields([{"amount": "1", "maxAmountRequired": "1"}])
95+
assert both[0] == {"amount": "1", "maxAmountRequired": "1"}
96+
97+
7198
def test_settlement_override_header_returns_name_value_pair():
7299
name, value = settlement_override_header(SettlementOverrides(amount="1500"))
73100
assert name == SETTLEMENT_OVERRIDES_HEADER

0 commit comments

Comments
 (0)