Skip to content

Commit 6cc9f20

Browse files
v2.5.0
1 parent 4bf4e1c commit 6cc9f20

8 files changed

Lines changed: 598 additions & 205 deletions

File tree

CHANGELOG.md

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
# Changelog
22

3+
## 2.5.0 — 2026-07-03
4+
5+
### Changed
6+
- x402 v2 payment requirements are read correctly: amount from the v2 `amount` field and resource from the top-level `resource` object, in addition to the v1 fields. The version is taken from `x402Version`, or inferred when absent; payloads whose version conflicts with their fields are rejected.
7+
- The signed payment returned for a v2 request now uses the v2 `PAYMENT-SIGNATURE` shape — an `accepted` block (including `extra`) and a top-level `resource` — so resource servers can verify it. v1 requests keep the flat v1 shape.
8+
- Payment skill examples updated to x402 v2 (v1 still supported).
9+
310
## 2.4.1 — 2026-06-29
411

512
### Fixed
@@ -21,3 +28,52 @@
2128

2229
### Added
2330
- `platformdirs` dependency for cross-platform data directory resolution.
31+
32+
## 2.3.0 — 2026-05-20
33+
34+
### Fixed
35+
- Amount display now uses 6 decimals consistently (sub-cent payments no longer show as 0.00).
36+
- Removed the redundant "$" prefix from amount displays.
37+
38+
## 2.2.2 — 2026-05-20
39+
40+
### Fixed
41+
- Windows taskbar now shows the MultiClaw icon instead of the Python icon (pip install).
42+
43+
### Docs
44+
- SKILL.md updated to v2-first format (CAIP-2 networks, `PAYMENT-SIGNATURE` header).
45+
46+
## 2.2.1 — 2026-05-19
47+
48+
### Fixed
49+
- SKILL.md is now bundled in the pip package, so the `/agent` endpoint works for pip installs.
50+
51+
## 2.2.0 — 2026-04-21
52+
53+
### Added
54+
- Market tab — browse and search x402 services from agentic.market in the GUI, with category filtering and copy-to-clipboard agent snippets.
55+
- Service discovery in SKILL.md — agents can discover x402 services via the Agentic.Market API, with budget-aware filtering against their spend policy.
56+
- MultiClaw Skills package — `npx skills add primer-systems/multiclaw-skills` installs setup, payment, and discovery skills.
57+
58+
### Docs
59+
- Updated the system interactions diagram.
60+
61+
## 2.1.0 — 2026-04-02
62+
63+
### Added
64+
- Published to PyPI — `pip install multiclaw` (or `multiclaw[gui]`).
65+
- Restructured as a proper Python package (`src/multiclaw/`).
66+
- Assets bundled with the pip install.
67+
68+
## 2.0.0 — 2026-03-31
69+
70+
### Added
71+
- CLI mode with an interactive REPL and scriptable single commands.
72+
- Headless daemon mode for server deployments.
73+
- In-GUI console window (File → Console).
74+
- Single-instance architecture: CLI connects to a running GUI via HTTP.
75+
76+
## 1.0.0 — 2026-02-19
77+
78+
### Added
79+
- Initial release.

README.md

Lines changed: 1 addition & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -251,34 +251,5 @@ pip install multiclaw # CLI only
251251

252252
## Changelog
253253

254-
### v2.3.0
255-
- **Fix:** Amount display now uses 6 decimals consistently (sub-cent payments no longer show as 0.00)
256-
- **Fix:** Removed redundant "$" prefix from amount displays (shows "0.002000 USDC" instead of "$0.00 USDC")
257-
258-
### v2.2.2
259-
- **Fix:** Windows taskbar now shows MultiClaw icon instead of Python icon (pip install)
260-
- **Docs:** SKILL.md updated to v2-first format (CAIP-2 networks, `PAYMENT-SIGNATURE` header)
261-
262-
### v2.2.1
263-
- **Fix:** SKILL.md now correctly bundled in pip package — `/agent` endpoint works for pip installs (was only working for PyInstaller builds)
264-
265-
### v2.2.0
266-
- **Market tab** — browse and search x402 services from [agentic.market](https://agentic.market) directly in the GUI, with category filtering and copy-to-clipboard agent snippets
267-
- **Service discovery in SKILL.md** — agents can now discover x402 services via the Agentic.Market API, with budget-aware filtering against their MultiClaw spend policy
268-
- **MultiClaw Skills package**`npx skills add primer-systems/multiclaw-skills` installs setup, payment, and discovery skills for any AI coding agent
269-
- Updated system interactions diagram in documentation
270-
271-
### v2.1.0
272-
- Published to PyPI — install with `pip install multiclaw` or `pip install multiclaw[gui]`
273-
- Restructured as a proper Python package (`src/multiclaw/`)
274-
- Assets bundled with pip install (logo, icons)
275-
276-
### v2.0.0
277-
- Added CLI mode with interactive REPL and scriptable single commands
278-
- Added headless daemon mode for server deployments
279-
- Added in-GUI console window (File → Console)
280-
- Single-instance architecture: CLI connects to running GUI via HTTP
281-
282-
### v1.0.0
283-
- Initial release
254+
See [CHANGELOG.md](CHANGELOG.md) for the full release history.
284255

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "multiclaw"
7-
version = "2.4.1"
7+
version = "2.5.0"
88
description = "Desktop x402 payment manager for AI agents"
99
readme = "README.md"
1010
requires-python = ">=3.11"

src/multiclaw/services/eip3009.py

Lines changed: 178 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -18,16 +18,23 @@
1818

1919
@dataclass
2020
class PaymentRequirements:
21-
"""Parsed payment requirements from a 402 response."""
21+
"""Parsed payment requirements from a 402 response.
22+
23+
This is MultiClaw's single, version-neutral representation. Whichever x402
24+
dialect came in on the wire (v1 or v2), the amount lives in
25+
`max_amount_required` and the resource in `resource` - downstream code
26+
(policy checks, signing, receipts) never touches the raw dialect fields.
27+
"""
2228
scheme: str
2329
network: str # CAIP-2 format (e.g., 'eip155:8453')
2430
chain_id: int
25-
max_amount_required: str
31+
max_amount_required: str # atomic units, as a string
2632
asset: str
2733
pay_to: str
2834
resource: Optional[str] = None
2935
token_name: Optional[str] = None
3036
token_version: Optional[str] = None
37+
x402_version: int = 2 # the wire version this was parsed from (1 or 2)
3138

3239

3340
def parse_caip_network(network: str) -> int:
@@ -79,50 +86,138 @@ def to_caip_network(network: str) -> str:
7986
return network
8087

8188

82-
def parse_payment_requirements(x402_data: Dict[str, Any]) -> PaymentRequirements:
89+
def _resolve_x402_version(x402_data: Dict[str, Any], req: Dict[str, Any]) -> int:
8390
"""
84-
Parse x402 v2 payment requirements.
91+
Decide which x402 dialect a payment is, decisively.
8592
86-
Args:
87-
x402_data: The x402 response data with 'accepts' array
93+
Rule (agreed):
94+
- If `x402Version` is present, it is authoritative. Reject if the body's
95+
amount field contradicts it - we do not guess a payment past a
96+
mismatched version tag.
97+
- If `x402Version` is absent (legacy v1 emitters often omit it), infer from
98+
the one field that differs between dialects: the amount field name.
99+
`maxAmountRequired` -> v1, `amount` -> v2. Both or neither -> reject.
88100
89-
Returns:
90-
PaymentRequirements object
101+
Returns the resolved version (1 or 2); raises ValueError otherwise.
102+
"""
103+
has_v1_amount = req.get("maxAmountRequired") is not None
104+
has_v2_amount = req.get("amount") is not None
105+
106+
declared = x402_data.get("x402Version")
107+
if declared is not None:
108+
if declared not in (1, 2):
109+
raise ValueError(f"Unsupported x402Version: {declared!r} (expected 1 or 2)")
110+
if declared == 1 and not has_v1_amount and has_v2_amount:
111+
raise ValueError(
112+
"x402Version is 1 but the amount is given as the v2 field 'amount' "
113+
"(expected 'maxAmountRequired'); refusing to guess"
114+
)
115+
if declared == 2 and not has_v2_amount and has_v1_amount:
116+
raise ValueError(
117+
"x402Version is 2 but the amount is given as the v1 field 'maxAmountRequired' "
118+
"(expected 'amount'); refusing to guess"
119+
)
120+
return declared
121+
122+
# No declared version - infer from the amount field name.
123+
if has_v1_amount and has_v2_amount:
124+
raise ValueError(
125+
"Ambiguous payment: both 'maxAmountRequired' (v1) and 'amount' (v2) present "
126+
"and no x402Version to disambiguate"
127+
)
128+
if has_v1_amount:
129+
return 1
130+
if has_v2_amount:
131+
return 2
132+
raise ValueError(
133+
"Missing amount: expected 'maxAmountRequired' (v1) or 'amount' (v2) in accepts"
134+
)
135+
136+
137+
def _extract_resource(x402_data: Dict[str, Any], req: Dict[str, Any], version: int) -> Optional[str]:
138+
"""
139+
Pull the resource URL out, tolerant of both dialects.
140+
141+
- v2: resource is a top-level object {url, description, mimeType}.
142+
- v1: resource is a per-accept string.
143+
We prefer the location the resolved version specifies but fall back to the
144+
other so a slightly-off emitter still yields a usable URL.
145+
"""
146+
top = x402_data.get("resource")
147+
top_url = top.get("url") if isinstance(top, dict) else top
148+
per_accept = req.get("resource")
149+
if version == 2:
150+
return top_url or per_accept
151+
return per_accept or top_url
152+
153+
154+
def parse_x402(x402_data: Dict[str, Any]) -> PaymentRequirements:
155+
"""
156+
Parse an x402 payment requirement (v1 or v2) into the neutral
157+
PaymentRequirements. This is the single intake parser - the only place that
158+
knows the difference between the dialects. Everything downstream reads the
159+
result, never the raw wire fields.
91160
92161
Raises:
93-
ValueError: If data is invalid or unsupported
162+
ValueError: on any malformed or contradictory payload (never guesses).
94163
"""
95-
version = x402_data.get("x402Version")
96-
if version != 2:
97-
raise ValueError(f"Unsupported x402 version: {version}. Expected 2.")
164+
if not isinstance(x402_data, dict):
165+
raise ValueError("x402 data must be an object")
98166

99-
accepts = x402_data.get("accepts", [])
100-
if not accepts:
101-
raise ValueError("No payment options in accepts array")
167+
accepts = x402_data.get("accepts")
168+
if accepts is None:
169+
raise ValueError("Missing 'accepts' array")
170+
if not isinstance(accepts, list):
171+
raise ValueError("'accepts' must be an array")
172+
if len(accepts) == 0:
173+
raise ValueError("Empty 'accepts' array")
102174

103-
# Use first accepted payment option
104175
req = accepts[0]
105-
network = req.get("network", "")
176+
if not isinstance(req, dict):
177+
raise ValueError("Invalid accepts[0] format")
178+
179+
version = _resolve_x402_version(x402_data, req)
180+
181+
# Amount - read the field the resolved version dictates.
182+
if version == 1:
183+
amount = req.get("maxAmountRequired")
184+
if amount is None:
185+
raise ValueError("Missing 'maxAmountRequired' in accepts")
186+
else:
187+
amount = req.get("amount")
188+
if amount is None:
189+
raise ValueError("Missing 'amount' in accepts")
190+
191+
network = req.get("network")
192+
if not network:
193+
raise ValueError("Missing 'network' in accepts")
194+
195+
pay_to = req.get("payTo")
196+
if not pay_to:
197+
raise ValueError("Missing 'payTo' in accepts")
198+
199+
asset = req.get("asset")
200+
if not asset:
201+
raise ValueError("Missing 'asset' in accepts")
106202

107203
# Normalize to CAIP-2
108204
caip_network = to_caip_network(network)
109205
chain_id = parse_caip_network(caip_network)
110206

111-
# Extract token metadata from 'extra' field if available
112-
extra = req.get("extra", {})
113-
token_name = extra.get("name")
114-
token_version = extra.get("version")
207+
# Extract token metadata from 'extra' field if available (same in both dialects)
208+
extra = req.get("extra") or {}
115209

116210
return PaymentRequirements(
117211
scheme=req.get("scheme", "exact"),
118212
network=caip_network,
119213
chain_id=chain_id,
120-
max_amount_required=str(req.get("maxAmountRequired", "0")),
121-
asset=req.get("asset", ""),
122-
pay_to=req.get("payTo", ""),
123-
resource=req.get("resource"),
124-
token_name=token_name,
125-
token_version=token_version
214+
max_amount_required=str(amount),
215+
asset=asset,
216+
pay_to=pay_to,
217+
resource=_extract_resource(x402_data, req, version),
218+
token_name=extra.get("name"),
219+
token_version=extra.get("version"),
220+
x402_version=version,
126221
)
127222

128223

@@ -213,23 +308,66 @@ def sign_transfer_authorization(
213308
return sig_hex
214309

215310

311+
def _build_accepted(requirements: PaymentRequirements, accepted_raw: Optional[Dict[str, Any]]) -> Dict[str, Any]:
312+
"""
313+
Build the `accepted` block for a v2 PaymentPayload: the chosen payment
314+
requirement echoed back to the server. Uses the normalized values from
315+
`requirements` (correct v2 field names, CAIP-2 network) and carries through
316+
`maxTimeoutSeconds` / `extra` from the merchant's original offer. `extra`
317+
(the USDC EIP-712 domain name/version) matters — the server reads it to
318+
reconstruct the verification domain.
319+
"""
320+
raw = accepted_raw if isinstance(accepted_raw, dict) else {}
321+
accepted = {
322+
"scheme": requirements.scheme,
323+
"network": requirements.network, # CAIP-2
324+
"amount": requirements.max_amount_required,
325+
"asset": requirements.asset,
326+
"payTo": requirements.pay_to,
327+
}
328+
if "maxTimeoutSeconds" in raw:
329+
accepted["maxTimeoutSeconds"] = raw["maxTimeoutSeconds"]
330+
extra = raw.get("extra")
331+
if extra is not None:
332+
accepted["extra"] = extra
333+
elif requirements.token_name is not None or requirements.token_version is not None:
334+
accepted["extra"] = {"name": requirements.token_name, "version": requirements.token_version}
335+
return accepted
336+
337+
338+
def _normalize_resource(resource: Any) -> Optional[Dict[str, Any]]:
339+
"""Return the top-level v2 resource object {url, description, mimeType}."""
340+
if isinstance(resource, dict):
341+
return resource
342+
if isinstance(resource, str):
343+
return {"url": resource, "description": "", "mimeType": ""}
344+
return None
345+
346+
216347
def create_payment(
217348
private_key: str,
218349
requirements: PaymentRequirements,
219350
token_name: Optional[str] = None,
220-
token_version: Optional[str] = None
351+
token_version: Optional[str] = None,
352+
accepted: Optional[Dict[str, Any]] = None,
353+
resource: Any = None,
221354
) -> Dict[str, Any]:
222355
"""
223-
Create a signed x402 v2 payment payload.
356+
Create a signed x402 v2 PaymentPayload.
224357
225358
Args:
226359
private_key: Hex-encoded private key
227360
requirements: Parsed payment requirements
228361
token_name: Token name (defaults to requirements or 'USD Coin')
229362
token_version: Token version (defaults to requirements or '2')
363+
accepted: The merchant's selected `accepts[]` entry (echoed back in the
364+
v2 `accepted` block). Falls back to `requirements` if not given.
365+
resource: The request's top-level resource (object or url string),
366+
emitted as the v2 top-level `resource`.
230367
231368
Returns:
232-
x402 v2 payment payload ready for PAYMENT-SIGNATURE header
369+
x402 v2 PaymentPayload ready for the PAYMENT-SIGNATURE header, shaped as
370+
{x402Version, accepted, resource?, payload{signature, authorization}}.
233371
"""
234372
# Ensure private key format
235373
if not private_key.startswith("0x"):
@@ -267,11 +405,13 @@ def create_payment(
267405
nonce=nonce_bytes
268406
)
269407

270-
# Build x402 v2 payload
271-
return {
408+
# Build x402 v2 PaymentPayload: the chosen requirement is echoed back in
409+
# `accepted` (with `extra`), the resource sits at the top level, and only
410+
# `authorization` is signed. scheme/network live inside `accepted`, not at
411+
# the top level (that was the v1 shape).
412+
payment = {
272413
"x402Version": X402_VERSION,
273-
"scheme": "exact",
274-
"network": requirements.network, # CAIP-2 format
414+
"accepted": _build_accepted(requirements, accepted),
275415
"payload": {
276416
"signature": signature,
277417
"authorization": {
@@ -284,3 +424,7 @@ def create_payment(
284424
}
285425
}
286426
}
427+
resource_obj = _normalize_resource(resource if resource is not None else requirements.resource)
428+
if resource_obj is not None:
429+
payment["resource"] = resource_obj
430+
return payment

0 commit comments

Comments
 (0)