a2a-protocol-core 0.1.0__tar.gz → 0.2.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (25) hide show
  1. a2a_protocol_core-0.2.1/CHANGELOG.md +59 -0
  2. {a2a_protocol_core-0.1.0 → a2a_protocol_core-0.2.1}/PKG-INFO +44 -1
  3. {a2a_protocol_core-0.1.0 → a2a_protocol_core-0.2.1}/README.md +41 -0
  4. a2a_protocol_core-0.2.1/examples/agent_pay_alias.py +87 -0
  5. {a2a_protocol_core-0.1.0 → a2a_protocol_core-0.2.1}/pyproject.toml +7 -1
  6. {a2a_protocol_core-0.1.0 → a2a_protocol_core-0.2.1}/src/a2a_protocol_core/__init__.py +22 -1
  7. a2a_protocol_core-0.2.1/src/a2a_protocol_core/x402_pay.py +297 -0
  8. a2a_protocol_core-0.2.1/tests/test_cross_language_vectors.py +59 -0
  9. a2a_protocol_core-0.2.1/tests/test_x402_pay.py +178 -0
  10. a2a_protocol_core-0.2.1/tests/vectors/canonical_vectors.json +281 -0
  11. a2a_protocol_core-0.2.1/tests/vectors/generate_vectors.py +156 -0
  12. a2a_protocol_core-0.1.0/CHANGELOG.md +0 -24
  13. {a2a_protocol_core-0.1.0 → a2a_protocol_core-0.2.1}/.gitignore +0 -0
  14. {a2a_protocol_core-0.1.0 → a2a_protocol_core-0.2.1}/LICENSE +0 -0
  15. {a2a_protocol_core-0.1.0 → a2a_protocol_core-0.2.1}/examples/trigger_payment_hook.py +0 -0
  16. {a2a_protocol_core-0.1.0 → a2a_protocol_core-0.2.1}/src/a2a_protocol_core/addressing.py +0 -0
  17. {a2a_protocol_core-0.1.0 → a2a_protocol_core-0.2.1}/src/a2a_protocol_core/canonical_hash.py +0 -0
  18. {a2a_protocol_core-0.1.0 → a2a_protocol_core-0.2.1}/src/a2a_protocol_core/client.py +0 -0
  19. {a2a_protocol_core-0.1.0 → a2a_protocol_core-0.2.1}/src/a2a_protocol_core/py.typed +0 -0
  20. {a2a_protocol_core-0.1.0 → a2a_protocol_core-0.2.1}/src/a2a_protocol_core/schemas.py +0 -0
  21. {a2a_protocol_core-0.1.0 → a2a_protocol_core-0.2.1}/src/a2a_protocol_core/semantic_normalizer.py +0 -0
  22. {a2a_protocol_core-0.1.0 → a2a_protocol_core-0.2.1}/tests/test_addressing.py +0 -0
  23. {a2a_protocol_core-0.1.0 → a2a_protocol_core-0.2.1}/tests/test_canonical_hash.py +0 -0
  24. {a2a_protocol_core-0.1.0 → a2a_protocol_core-0.2.1}/tests/test_schemas.py +0 -0
  25. {a2a_protocol_core-0.1.0 → a2a_protocol_core-0.2.1}/tests/test_semantic_normalizer.py +0 -0
@@ -0,0 +1,59 @@
1
+ # Changelog
2
+
3
+ All notable changes to `a2a-protocol-core` are documented here. This project
4
+ adheres to [Semantic Versioning](https://semver.org/).
5
+
6
+ ## [0.2.1] — 2026-07-20
7
+
8
+ Conformance fix for the XRPL exact scheme's field placement. Required before
9
+ the server drops its deprecated top-level mirrors (scheduled after 2026-10-01)
10
+ — a 0.2.0 client will stop attaching `InvoiceID` at that point and be rejected
11
+ by the server's mandatory invoice binding.
12
+
13
+ ### Fixed
14
+ - `pay_alias_xrp` now reads `invoiceId` and `sourceTag` from the x402
15
+ requirement's `extra` block, where the XRPL exact scheme
16
+ (`scheme_exact_xrpl.md`) places them, falling back to the deprecated
17
+ top-level mirrors for not-yet-updated servers. Previously only the top-level
18
+ copies were read, so a spec-conformant server that stopped mirroring them
19
+ would silently produce payments without `InvoiceID` — which the invoice
20
+ binding (the anti-replay mechanism) mandatorily rejects.
21
+
22
+ ## [0.2.0] — 2026-07-11
23
+
24
+ Adds the one-call x402 pay-path so an agent can pay a `pay:` alias from its own
25
+ wallet — the "make it actually easy" client, still fully non-custodial.
26
+
27
+ ### Added
28
+ - `pay_alias_xrp(...)` — resolve the x402 requirement for a `pay:` alias, sign +
29
+ submit the XRP payment **locally with the caller's own seed** (the seed never
30
+ leaves the process), then verify + fetch the signed resolve/verify/OFAC-screen
31
+ attestation. One call, end to end.
32
+ - `attest_settled_payment(...)` — bring-your-own already-settled tx hash (e.g. an
33
+ agent paying through its own wallet stack / a Coinbase Agentic Wallet) and get
34
+ the read-only verify + attestation. No `[xrpl]` extra needed.
35
+ - Pure, network-free helpers: `fetch_requirement`, `decode_payment_required`,
36
+ `build_x_payment_header`, `invoice_id_hash`, `summarize_attestation`, plus
37
+ `AttestationSummary` / `X402PaymentResult` / `X402PayError`.
38
+ - New optional extra `[xrpl]` (pulls `xrpl-py`) — only needed for the local
39
+ signing in `pay_alias_xrp`; the base install stays `pydantic` + `requests`.
40
+
41
+ ## [0.1.0] — 2026-06-28
42
+
43
+ Initial public protocol-core extraction from the DNS of Money Financial
44
+ Autonomy Stack. Published to PyPI.
45
+
46
+ ### Added
47
+ - `pay:` URI addressing (FAS-1 grammar): `is_valid_pay_uri`, `assert_valid_pay_uri`,
48
+ `PAY_URI_PATTERN`.
49
+ - A2A-009 semantic normalizer: `normalize_action`, `normalize_message`,
50
+ `compute_semantic_hash`, `SYNONYM_MAP`, `CANONICAL_ACTIONS`.
51
+ - A2A-008 canonical hashing: `compute_canonical_hash` — metadata-stable,
52
+ vocabulary-stable payment-intent hashing.
53
+ - A2A-041 wire schemas: `A2APaymentHookRequest`, `A2APaymentHookResponse`,
54
+ `ResolutionDetail`, `SettlementDetail`, `A2ACapabilities`.
55
+ - `A2APaymentHookClient` — synchronous payment-hook + capabilities client with
56
+ client-side validation.
57
+ - Test suite covering addressing, normalization, canonical hashing, and schema
58
+ validation.
59
+ - Apache-2.0 license, `py.typed` marker, runnable example.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: a2a-protocol-core
3
- Version: 0.1.0
3
+ Version: 0.2.1
4
4
  Summary: Open, deterministic protocol core for the DNS of Money A2A payment surface (FAS-1 pay: addressing, canonical hashing, payment-hook client).
5
5
  Project-URL: Homepage, https://dnsofmoney.com
6
6
  Project-URL: Specification, https://github.com/dnsofmoney/dns-of-money
@@ -20,6 +20,8 @@ Requires-Dist: requests>=2.31.0
20
20
  Provides-Extra: dev
21
21
  Requires-Dist: pytest>=8.3.0; extra == 'dev'
22
22
  Requires-Dist: ruff>=0.8.0; extra == 'dev'
23
+ Provides-Extra: xrpl
24
+ Requires-Dist: xrpl-py>=2.5.0; extra == 'xrpl'
23
25
  Description-Content-Type: text/markdown
24
26
 
25
27
  # a2a-protocol-core
@@ -81,6 +83,47 @@ print(result.settlement_result.status, result.iso_message_ref)
81
83
 
82
84
  See [`examples/trigger_payment_hook.py`](examples/trigger_payment_hook.py).
83
85
 
86
+ ## Pay a `pay:` alias in one call (non-custodial)
87
+
88
+ Resolve the price, pay from **your own wallet**, and get back a signed
89
+ resolve/verify/OFAC-screen attestation — one function, and your seed never leaves
90
+ your process:
91
+
92
+ ```bash
93
+ pip install "a2a-protocol-core[xrpl]"
94
+ ```
95
+
96
+ ```python
97
+ from a2a_protocol_core import pay_alias_xrp
98
+
99
+ result = pay_alias_xrp(
100
+ base_url="https://api.dnsofmoney.com",
101
+ alias="pay:vendor.alpha",
102
+ amount_xrp="0.10",
103
+ seed="s...", # YOUR XRPL wallet seed — signs locally, never transmitted
104
+ api_key="fas_live_...", # attributes the settle leg
105
+ )
106
+ print(result.tx_hash, result.summary.verdict) # e.g. "A1B2…", "CLEAR"
107
+ ```
108
+
109
+ Already settled the payment through your own wallet stack (or a Coinbase Agentic
110
+ Wallet)? Skip the signing and just fetch the attestation — no `[xrpl]` extra:
111
+
112
+ ```python
113
+ from a2a_protocol_core import attest_settled_payment
114
+
115
+ result = attest_settled_payment(
116
+ base_url="https://api.dnsofmoney.com",
117
+ alias="pay:vendor.alpha",
118
+ amount_xrp="0.10",
119
+ tx_hash="A1B2C3...", # your already-validated XRPL tx
120
+ api_key="fas_live_...",
121
+ )
122
+ ```
123
+
124
+ DNS of Money **verifies an already-settled transaction and returns metadata** — it
125
+ never holds your keys or your funds.
126
+
84
127
  ## Why canonical hashing?
85
128
 
86
129
  Two agents describing the same payment with different words ("send" vs
@@ -57,6 +57,47 @@ print(result.settlement_result.status, result.iso_message_ref)
57
57
 
58
58
  See [`examples/trigger_payment_hook.py`](examples/trigger_payment_hook.py).
59
59
 
60
+ ## Pay a `pay:` alias in one call (non-custodial)
61
+
62
+ Resolve the price, pay from **your own wallet**, and get back a signed
63
+ resolve/verify/OFAC-screen attestation — one function, and your seed never leaves
64
+ your process:
65
+
66
+ ```bash
67
+ pip install "a2a-protocol-core[xrpl]"
68
+ ```
69
+
70
+ ```python
71
+ from a2a_protocol_core import pay_alias_xrp
72
+
73
+ result = pay_alias_xrp(
74
+ base_url="https://api.dnsofmoney.com",
75
+ alias="pay:vendor.alpha",
76
+ amount_xrp="0.10",
77
+ seed="s...", # YOUR XRPL wallet seed — signs locally, never transmitted
78
+ api_key="fas_live_...", # attributes the settle leg
79
+ )
80
+ print(result.tx_hash, result.summary.verdict) # e.g. "A1B2…", "CLEAR"
81
+ ```
82
+
83
+ Already settled the payment through your own wallet stack (or a Coinbase Agentic
84
+ Wallet)? Skip the signing and just fetch the attestation — no `[xrpl]` extra:
85
+
86
+ ```python
87
+ from a2a_protocol_core import attest_settled_payment
88
+
89
+ result = attest_settled_payment(
90
+ base_url="https://api.dnsofmoney.com",
91
+ alias="pay:vendor.alpha",
92
+ amount_xrp="0.10",
93
+ tx_hash="A1B2C3...", # your already-validated XRPL tx
94
+ api_key="fas_live_...",
95
+ )
96
+ ```
97
+
98
+ DNS of Money **verifies an already-settled transaction and returns metadata** — it
99
+ never holds your keys or your funds.
100
+
60
101
  ## Why canonical hashing?
61
102
 
62
103
  Two agents describing the same payment with different words ("send" vs
@@ -0,0 +1,87 @@
1
+ """
2
+ Example: an AI agent pays a DNS of Money ``pay:`` alias and screens the counterparty.
3
+
4
+ The scenario the agent-payment world actually needs solved: your agent knows *who*
5
+ it wants to pay (a human-readable ``pay:`` name, not a raw r-address), needs to pay
6
+ in one step, and wants proof it screened the counterparty before releasing funds.
7
+
8
+ This is the whole loop in ~10 lines using ``a2a-protocol-core``:
9
+
10
+ quote → resolve the x402 price for the alias (no wallet needed)
11
+ pay → sign + submit XRP from the agent's OWN wallet (self-custody; seed never sent)
12
+ verify → get back a signed resolve/verify/OFAC-screen attestation
13
+ act → the agent decides based on the screen verdict
14
+
15
+ Works today for any XRPL-holding agent — e.g. one built on Ripple's XRPL AI Starter
16
+ Kit, Crossmint agent wallets, or a Xaman/xApp wallet. DNS of Money is non-custodial:
17
+ it verifies an already-settled tx and returns metadata; it never holds your keys or
18
+ funds.
19
+
20
+ Run (quote only — safe, no spend):
21
+ X402_BASE=https://api.dnsofmoney.com X402_ALIAS=pay:dnsofmoney \
22
+ python examples/agent_pay_alias.py
23
+
24
+ Run (real payment — needs the [xrpl] extra + your funded XRPL wallet + an API key):
25
+ pip install "a2a-protocol-core[xrpl]"
26
+ X402_BASE=https://api.dnsofmoney.com X402_ALIAS=pay:vendor.alpha X402_AMOUNT=0.10 \
27
+ FAS_API_KEY=fas_live_... XRPL_AGENT_SEED=s... \
28
+ python examples/agent_pay_alias.py
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import os
34
+ import sys
35
+
36
+ from a2a_protocol_core import (
37
+ X402PayError,
38
+ fetch_requirement,
39
+ pay_alias_xrp,
40
+ )
41
+
42
+
43
+ def main() -> int:
44
+ base_url = os.getenv("X402_BASE", "https://api.dnsofmoney.com")
45
+ alias = os.getenv("X402_ALIAS", "pay:dnsofmoney")
46
+ amount = os.getenv("X402_AMOUNT", "0.01")
47
+ seed = os.getenv("XRPL_AGENT_SEED", "").strip()
48
+ api_key = os.getenv("FAS_API_KEY", "").strip()
49
+ rpc = os.getenv("XRPL_RPC_URL", "https://xrplcluster.com")
50
+
51
+ # 1) QUOTE — resolve what this alias costs, on which rail. No wallet, no spend.
52
+ try:
53
+ req = fetch_requirement(base_url=base_url, alias=alias, amount_xrp=amount)
54
+ except X402PayError as exc:
55
+ print(f"[quote] could not get a price for {alias}: {exc}")
56
+ return 1
57
+ print(f"[quote] {alias} -> pay {req['maxAmountRequired']} drops to {req['payTo']} on {req['network']}")
58
+
59
+ # Quote-only mode: stop here unless the agent is configured to actually pay.
60
+ if not (seed and api_key):
61
+ print("[dry-run] set XRPL_AGENT_SEED (your wallet) + FAS_API_KEY to pay for real.")
62
+ return 0
63
+
64
+ # 2-3) PAY + VERIFY — one call: the agent signs from its own wallet, DNS of Money
65
+ # verifies read-only and returns the signed attestation.
66
+ try:
67
+ result = pay_alias_xrp(
68
+ base_url=base_url, alias=alias, amount_xrp=amount, seed=seed, api_key=api_key, xrpl_rpc_url=rpc
69
+ )
70
+ except X402PayError as exc:
71
+ print(f"[pay] failed: {exc}")
72
+ return 1
73
+
74
+ s = result.summary
75
+ print(f"[paid] tx={result.tx_hash} settled={result.settled} ({'signed' if s.signed else 'unsigned'} attestation)")
76
+ print(f"[screen] overall={s.verdict} payee={s.payee_verdict} payer={s.payer_verdict}")
77
+
78
+ # 4) ACT — the agent does its own thing based on the compliance verdict.
79
+ if s.verdict == "BLOCKED":
80
+ print("[decide] counterparty screened BLOCKED — the agent would halt / escalate here.")
81
+ return 2
82
+ print("[decide] counterparty clear — the agent proceeds with the delivered service.")
83
+ return 0
84
+
85
+
86
+ if __name__ == "__main__":
87
+ sys.exit(main())
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "a2a-protocol-core"
7
- version = "0.1.0"
7
+ version = "0.2.1"
8
8
  description = "Open, deterministic protocol core for the DNS of Money A2A payment surface (FAS-1 pay: addressing, canonical hashing, payment-hook client)."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -30,6 +30,12 @@ Specification = "https://github.com/dnsofmoney/dns-of-money"
30
30
  Source = "https://github.com/dnsofmoney/a2a-protocol-core"
31
31
 
32
32
  [project.optional-dependencies]
33
+ # The one-call pay-path (pay_alias_xrp) signs an XRP payment with the caller's own
34
+ # wallet — that needs xrpl-py. Kept optional so the base package stays dep-light
35
+ # (resolve/hash/attest work without it).
36
+ xrpl = [
37
+ "xrpl-py>=2.5.0",
38
+ ]
33
39
  dev = [
34
40
  "pytest>=8.3.0",
35
41
  "ruff>=0.8.0",
@@ -34,8 +34,19 @@ from a2a_protocol_core.semantic_normalizer import (
34
34
  normalize_action,
35
35
  normalize_message,
36
36
  )
37
+ from a2a_protocol_core.x402_pay import (
38
+ AttestationSummary,
39
+ X402PaymentResult,
40
+ X402PayError,
41
+ attest_settled_payment,
42
+ build_x_payment_header,
43
+ decode_payment_required,
44
+ fetch_requirement,
45
+ pay_alias_xrp,
46
+ summarize_attestation,
47
+ )
37
48
 
38
- __version__ = "0.1.0"
49
+ __version__ = "0.2.1"
39
50
 
40
51
  __all__ = [
41
52
  "__version__",
@@ -59,4 +70,14 @@ __all__ = [
59
70
  "A2ACapabilities",
60
71
  # client
61
72
  "A2APaymentHookClient",
73
+ # x402 pay-path (one-call; signing needs the [xrpl] extra)
74
+ "pay_alias_xrp",
75
+ "attest_settled_payment",
76
+ "fetch_requirement",
77
+ "decode_payment_required",
78
+ "build_x_payment_header",
79
+ "summarize_attestation",
80
+ "AttestationSummary",
81
+ "X402PaymentResult",
82
+ "X402PayError",
62
83
  ]
@@ -0,0 +1,297 @@
1
+ """
2
+ One-call x402 pay-path — pay a ``pay:`` alias from YOUR own wallet.
3
+
4
+ This is the "make it actually easy" helper: an agent developer calls one function
5
+ to resolve the x402 requirement for a ``pay:`` alias, sign + submit an XRP payment
6
+ **with their own wallet seed**, and hand the proof back to DNS of Money for a
7
+ read-only verify + the signed resolve/verify/OFAC-screen attestation.
8
+
9
+ Non-custodial by construction: the seed is used to sign locally, in *your* process,
10
+ and is never sent over the wire. DNS of Money verifies an already-settled tx and
11
+ returns metadata — it never holds your keys or your funds.
12
+
13
+ pip install "a2a-protocol-core[xrpl]"
14
+
15
+ from a2a_protocol_core import pay_alias_xrp
16
+
17
+ result = pay_alias_xrp(
18
+ base_url="https://api.dnsofmoney.com",
19
+ alias="pay:vendor.alpha",
20
+ amount_xrp="0.10",
21
+ seed="s...", # YOUR XRPL wallet seed — signs locally, never sent
22
+ api_key="fas_live_...", # attributes the settle leg
23
+ )
24
+ print(result.tx_hash, result.summary.verdict)
25
+
26
+ The pure helpers (``decode_payment_required``, ``build_x_payment_header``,
27
+ ``summarize_attestation``) have no network/xrpl dependency and are unit-tested; the
28
+ XRPL signing lazy-imports ``xrpl-py`` so the base package stays dependency-light.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import base64
34
+ import hashlib
35
+ import json
36
+ from dataclasses import dataclass
37
+ from decimal import Decimal
38
+ from typing import Any, Optional, Union
39
+
40
+ import requests
41
+
42
+ # Public mainnet XRPL JSON-RPC (multi-host cluster). Override for testnet/devnet.
43
+ DEFAULT_XRPL_RPC = "https://xrplcluster.com"
44
+ DEFAULT_TIMEOUT = 60
45
+
46
+
47
+ class X402PayError(RuntimeError):
48
+ """Raised when the x402 pay-path fails (bad challenge, failed settle, or signing error)."""
49
+
50
+
51
+ # ── Pure helpers (no network, no xrpl — unit-tested) ─────────────────────────────
52
+
53
+
54
+ def decode_payment_required(header_value: str) -> dict:
55
+ """Decode the base64(JSON) PAYMENT-REQUIRED header into the XRP requirement dict."""
56
+ return json.loads(base64.b64decode(header_value))
57
+
58
+
59
+ def build_x_payment_header(tx_hash: str, payer: Optional[str] = None) -> str:
60
+ """Build the X-PAYMENT header: base64(JSON) proof of the settled tx hash."""
61
+ payload: dict = {"txHash": tx_hash}
62
+ if payer:
63
+ payload["payer"] = payer
64
+ envelope = {"x402Version": 2, "payload": payload}
65
+ return base64.b64encode(json.dumps(envelope).encode()).decode()
66
+
67
+
68
+ def invoice_id_hash(invoice_id: str) -> str:
69
+ """The on-chain InvoiceID that binds a payment to a 402 challenge.
70
+
71
+ Matches the server verifier: SHA-256 of the requirement's ``invoiceId``, hex,
72
+ uppercased. Setting it on the Payment replay-binds the tx to this challenge.
73
+ """
74
+ return hashlib.sha256(invoice_id.encode("utf-8")).hexdigest().upper()
75
+
76
+
77
+ @dataclass
78
+ class AttestationSummary:
79
+ """The deliverable at a glance — the resolve/verify/OFAC-screen verdicts."""
80
+
81
+ verdict: Optional[str] # overall: CLEAR / REVIEW / BLOCKED / UNKNOWN
82
+ payee_verdict: Optional[str]
83
+ payer_verdict: Optional[str]
84
+ tx_id: Optional[str]
85
+ signed: bool
86
+
87
+
88
+ def summarize_attestation(attestation: dict) -> AttestationSummary:
89
+ """One-glance summary of the attestation (signed VC or unsigned flat form)."""
90
+ subject = attestation.get("credentialSubject", attestation)
91
+ screen = subject.get("screen") or {}
92
+ settlement = subject.get("settlement") or {}
93
+ return AttestationSummary(
94
+ verdict=screen.get("verdict"),
95
+ payee_verdict=(screen.get("payee") or {}).get("verdict"),
96
+ payer_verdict=(screen.get("payer") or {}).get("verdict"),
97
+ tx_id=settlement.get("txid"),
98
+ signed=bool(attestation.get("proof")),
99
+ )
100
+
101
+
102
+ @dataclass
103
+ class X402PaymentResult:
104
+ """The outcome of a completed x402 pay-path."""
105
+
106
+ tx_hash: str
107
+ payer: Optional[str]
108
+ settled: bool
109
+ idempotent: bool
110
+ attestation: dict
111
+ proof: dict
112
+ summary: AttestationSummary
113
+ raw: dict
114
+
115
+
116
+ # ── HTTP legs (challenge + settle) ───────────────────────────────────────────────
117
+
118
+
119
+ def _pay_url(base_url: str, alias: str) -> str:
120
+ return f"{base_url.rstrip('/')}/api/v1/x402/pay/{alias}"
121
+
122
+
123
+ def fetch_requirement(
124
+ *,
125
+ base_url: str,
126
+ alias: str,
127
+ amount_xrp: Union[str, Decimal],
128
+ session: Optional[requests.Session] = None,
129
+ timeout: int = DEFAULT_TIMEOUT,
130
+ ) -> dict:
131
+ """GET the 402 challenge and return the decoded XRP payment requirement.
132
+
133
+ Raises ``X402PayError`` if the server does not answer 402 (e.g. x402 disabled,
134
+ alias not payable, or the alias has no XRPL address).
135
+ """
136
+ http = session or requests.Session()
137
+ resp = http.get(_pay_url(base_url, alias), params={"amount": str(amount_xrp), "currency": "XRP"}, timeout=timeout)
138
+ if resp.status_code != 402:
139
+ raise X402PayError(f"expected a 402 challenge, got {resp.status_code}: {resp.text[:200]}")
140
+ header = resp.headers.get("PAYMENT-REQUIRED")
141
+ if not header:
142
+ raise X402PayError("402 response missing the PAYMENT-REQUIRED header")
143
+ return decode_payment_required(header)
144
+
145
+
146
+ def _settle(
147
+ *,
148
+ base_url: str,
149
+ alias: str,
150
+ amount_xrp: Union[str, Decimal],
151
+ tx_hash: str,
152
+ payer: Optional[str],
153
+ api_key: str,
154
+ session: requests.Session,
155
+ timeout: int,
156
+ ) -> dict:
157
+ headers = {"X-PAYMENT": build_x_payment_header(tx_hash, payer), "X-API-Key": api_key}
158
+ resp = session.get(
159
+ _pay_url(base_url, alias),
160
+ params={"amount": str(amount_xrp), "currency": "XRP"},
161
+ headers=headers,
162
+ timeout=timeout,
163
+ )
164
+ if resp.status_code != 200:
165
+ raise X402PayError(f"settle leg failed {resp.status_code}: {resp.text[:300]}")
166
+ return resp.json()
167
+
168
+
169
+ def _result_from_body(body: dict) -> X402PaymentResult:
170
+ attestation = body.get("attestation") or {}
171
+ proof = body.get("proof") or {}
172
+ return X402PaymentResult(
173
+ tx_hash=proof.get("transaction") or "",
174
+ payer=proof.get("payer"),
175
+ settled=bool(body.get("settled")),
176
+ idempotent=bool(body.get("idempotent")),
177
+ attestation=attestation,
178
+ proof=proof,
179
+ summary=summarize_attestation(attestation),
180
+ raw=body,
181
+ )
182
+
183
+
184
+ # ── XRPL signing (lazy xrpl-py import — the [xrpl] extra) ─────────────────────────
185
+
186
+
187
+ def _sign_and_submit_xrp(
188
+ *, pay_to: str, drops: str, seed: str, rpc_url: str, invoice_id: Optional[str], source_tag: Optional[int]
189
+ ) -> tuple[str, str]:
190
+ """Sign + submit an XRP Payment to ``pay_to`` for ``drops`` with the caller's seed.
191
+
192
+ Returns ``(tx_hash, payer_address)``. The seed never leaves this process.
193
+ """
194
+ try:
195
+ from xrpl.clients import JsonRpcClient
196
+ from xrpl.models.transactions import Payment
197
+ from xrpl.transaction import submit_and_wait
198
+ from xrpl.wallet import Wallet
199
+ except ImportError as exc: # pragma: no cover - import guard
200
+ raise X402PayError(
201
+ "signing requires the 'xrpl' extra — install with: pip install 'a2a-protocol-core[xrpl]'"
202
+ ) from exc
203
+
204
+ wallet = Wallet.from_seed(seed)
205
+ client = JsonRpcClient(rpc_url)
206
+ fields: dict[str, Any] = {"account": wallet.address, "destination": pay_to, "amount": str(drops)}
207
+ if invoice_id:
208
+ fields["invoice_id"] = invoice_id
209
+ if source_tag is not None:
210
+ fields["source_tag"] = source_tag
211
+ resp = submit_and_wait(Payment(**fields), client, wallet)
212
+ result = resp.result
213
+ tx_result = (result.get("meta") or {}).get("TransactionResult")
214
+ if result.get("validated") and tx_result == "tesSUCCESS":
215
+ return result["hash"], wallet.address
216
+ raise X402PayError(f"XRPL payment did not succeed (result={tx_result})")
217
+
218
+
219
+ # ── One-call entry points ────────────────────────────────────────────────────────
220
+
221
+
222
+ def pay_alias_xrp(
223
+ *,
224
+ base_url: str,
225
+ alias: str,
226
+ amount_xrp: Union[str, Decimal],
227
+ seed: str,
228
+ api_key: str,
229
+ xrpl_rpc_url: str = DEFAULT_XRPL_RPC,
230
+ session: Optional[requests.Session] = None,
231
+ timeout: int = DEFAULT_TIMEOUT,
232
+ ) -> X402PaymentResult:
233
+ """Pay a ``pay:`` alias in XRP from your own wallet, end to end.
234
+
235
+ Resolve the x402 requirement (402 challenge) → sign + submit the XRP payment
236
+ locally with ``seed`` → hand the proof to DNS of Money for read-only verify +
237
+ the signed attestation. Non-custodial: the seed signs in-process and is never
238
+ transmitted. Requires the ``[xrpl]`` extra. Raises ``X402PayError`` on failure.
239
+ """
240
+ http = session or requests.Session()
241
+ req = fetch_requirement(base_url=base_url, alias=alias, amount_xrp=amount_xrp, session=http, timeout=timeout)
242
+ # The XRPL exact scheme carries scheme fields in `extra`; the top-level copies
243
+ # are our own deprecated mirrors (removed after 2026-10-01). Prefer `extra`, fall
244
+ # back to top-level so this client still works against not-yet-updated servers.
245
+ extra = req.get("extra") or {}
246
+ invoice = extra.get("invoiceId") or req.get("invoiceId")
247
+ source_tag = extra.get("sourceTag") if extra.get("sourceTag") is not None else req.get("sourceTag")
248
+ tx_hash, payer = _sign_and_submit_xrp(
249
+ pay_to=req["payTo"],
250
+ drops=req["maxAmountRequired"],
251
+ seed=seed,
252
+ rpc_url=xrpl_rpc_url,
253
+ invoice_id=invoice_id_hash(invoice) if invoice else None,
254
+ source_tag=source_tag,
255
+ )
256
+ body = _settle(
257
+ base_url=base_url,
258
+ alias=alias,
259
+ amount_xrp=amount_xrp,
260
+ tx_hash=tx_hash,
261
+ payer=payer,
262
+ api_key=api_key,
263
+ session=http,
264
+ timeout=timeout,
265
+ )
266
+ return _result_from_body(body)
267
+
268
+
269
+ def attest_settled_payment(
270
+ *,
271
+ base_url: str,
272
+ alias: str,
273
+ amount_xrp: Union[str, Decimal],
274
+ tx_hash: str,
275
+ api_key: str,
276
+ payer: Optional[str] = None,
277
+ session: Optional[requests.Session] = None,
278
+ timeout: int = DEFAULT_TIMEOUT,
279
+ ) -> X402PaymentResult:
280
+ """Get the attestation for a payment you ALREADY settled on XRPL (bring-your-own tx).
281
+
282
+ Same settle leg as :func:`pay_alias_xrp`, minus the signing — for agents that pay
283
+ through their own wallet stack (or a Coinbase Agentic Wallet) and just want the
284
+ read-only verify + attestation. No ``[xrpl]`` extra needed.
285
+ """
286
+ http = session or requests.Session()
287
+ body = _settle(
288
+ base_url=base_url,
289
+ alias=alias,
290
+ amount_xrp=amount_xrp,
291
+ tx_hash=tx_hash,
292
+ payer=payer,
293
+ api_key=api_key,
294
+ session=http,
295
+ timeout=timeout,
296
+ )
297
+ return _result_from_body(body)
@@ -0,0 +1,59 @@
1
+ """
2
+ Cross-language conformance — the Python side of the shared contract.
3
+
4
+ Both this suite and the TS `@dnsofmoney/a2a-protocol-core` suite assert against
5
+ the SAME `vectors/canonical_vectors.json` (generated from this Python reference
6
+ by `generate_vectors.py`). The canonical/semantic hashes are byte-for-byte
7
+ identical across languages; this test pins Python to the vectors so neither side
8
+ can silently drift.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ from pathlib import Path
15
+
16
+ import pytest
17
+
18
+ from a2a_protocol_core import (
19
+ compute_canonical_hash,
20
+ compute_semantic_hash,
21
+ is_valid_pay_uri,
22
+ normalize_action,
23
+ )
24
+
25
+ VECTORS = json.loads((Path(__file__).parent / "vectors" / "canonical_vectors.json").read_text(encoding="utf-8"))
26
+
27
+
28
+ def test_pay_uri_vectors():
29
+ for v in VECTORS["pay_uri"]:
30
+ assert is_valid_pay_uri(v["input"]) is v["valid"], v["input"]
31
+
32
+
33
+ def test_normalize_action_ok_vectors():
34
+ for v in VECTORS["normalize_action"]["ok"]:
35
+ assert normalize_action(v["input"]) == v["output"], v["input"]
36
+
37
+
38
+ def test_normalize_action_error_vectors():
39
+ for bad in VECTORS["normalize_action"]["error"]:
40
+ with pytest.raises(ValueError):
41
+ normalize_action(bad)
42
+
43
+
44
+ def test_canonical_hash_matches_vectors():
45
+ for c in VECTORS["canonical_hash"]:
46
+ assert compute_canonical_hash(c["request"]) == c["expected_hash"], c["name"]
47
+
48
+
49
+ def test_canonical_hash_equivalence_groups():
50
+ by_name = {c["name"]: c["expected_hash"] for c in VECTORS["canonical_hash"]}
51
+ for group in VECTORS["canonical_hash_equivalence_groups"]:
52
+ first = by_name[group[0]]
53
+ for name in group:
54
+ assert by_name[name] == first, name
55
+
56
+
57
+ def test_semantic_hash_matches_vectors():
58
+ for s in VECTORS["semantic_hash"]:
59
+ assert compute_semantic_hash(s["normalized"]) == s["expected_hash"], s["name"]