diff --git a/code/samples/python/tenzro_did_settlement/README.md b/code/samples/python/tenzro_did_settlement/README.md new file mode 100644 index 00000000..32b43bd7 --- /dev/null +++ b/code/samples/python/tenzro_did_settlement/README.md @@ -0,0 +1,163 @@ +# AP2 Sample: DID-based settlement (TDIP example) + +This sample demonstrates the AP2 mandate chain (`IntentMandate` → +`CartMandate` → `PaymentMandate`) running against a **DID-based +identity layer** rather than the bearer-token / opaque-account +identities used in the other samples in this directory. + +The identity layer used here is **TDIP** (Tenzro Decentralized Identity +Protocol) and the DID method is `did:tenzro:`, but the same pattern +works against any DID-based stack — `did:web`, `did:key`, `did:ion`, +`did:eth`, etc. — provided the chain exposes a delegation primitive and +a settlement-proof primitive comparable to the ones used here. + +## What the sample shows + +1. A **principal** (human user) with DID `did:tenzro:human:alice` issues + an `IntentMandate` authorizing an autonomous shopping agent to spend + up to a fixed budget on a specific category of goods. +2. The **delegate** (machine agent) with DID + `did:tenzro:machine:shopper` accepts the intent. It has a TDIP + `DelegationScope` (the *protocol-level* ceiling, set when the machine + identity was registered) and a runtime `SpendingPolicy` (the + *execution-level* ceiling, mutable, enforces a daily-spend window). +3. A **merchant** publishes a `CartMandate` whose total fits within the + intent's `max_amount`. +4. A **`PaymentMandate`** is constructed with + `payment_method.supported_methods = "tenzro:micropayment-channel"` — + pointing at a Tenzro per-message billing channel rather than a card, + stablecoin transfer, or ACH rail. +5. The sample calls `tenzro_ap2ValidateMandatePair` (with + `enforce_delegation=true`), which exercises **all four ceilings**: + + - AP2 `IntentMandate` constraints (max_amount, allowed merchants / + SKUs, expiry). + - AP2 `CartMandate` consistency (total = sum of line items, parent + intent matches, expiry). + - TDIP `DelegationScope::enforce_operation` (max_transaction_value, + allowed_operations, allowed_payment_protocols, allowed_chains, + time_bound). + - TDIP runtime `SpendingPolicy::check` (max_per_transaction, + max_daily_spend, current_daily_spend, enabled). + +6. Settlement: a Plonky3 STARK proof is generated over the `settlement` + AIR (witness: payer balance, service proof, nonce, prev_nonce, + amount). The proof's 32-byte SHA-256 commitment is recorded in + Tenzro's `ZkCommitmentRegistry` so the on-chain `ZK_VERIFY` + precompile becomes an O(1) HashSet lookup. The micropayment channel + is settled with the resulting receipt. + +7. The sample pretty-prints all three mandates and the settlement + receipt to stdout. + +## Why this fills a gap + +The existing AP2 samples in this directory (`scenarios/a2a/...`) +demonstrate card and x402 payment methods using opaque user / agent +identifiers. They do **not** show what AP2 looks like when the parties' +identities are first-class DIDs with on-chain delegation enforcement +and verifiable-credential-style scope binding. + +AP2's IntentMandate / CartMandate / PaymentMandate types are explicitly +identity-system-agnostic — they take the principal's, agent's, and +merchant's identifiers as opaque strings. This sample concretely shows +how those strings can be a real DID method, with the corresponding +identity-resolution and delegation-checking flow. + +## Dependencies + +- Python 3.11+ +- [`ap2`](https://github.com/google-agentic-commerce/AP2) (the AP2 + Python SDK; install via `pip install -e code/sdk/python/ap2` from a + checkout, or via the workspace install). +- `requests==2.32.5` — for talking to the Tenzro JSON-RPC. +- `pydantic>=2.7` — already a transitive dep of `ap2`. + +The sample is **stdlib + AP2 + requests**. No `web3`, no `cryptography` +extras, no Google API key required. + +See `requirements.txt` for the pinned subset. + +## Live testnet — no setup required + +By default the sample talks to `https://rpc.tenzro.network` (the live +Tenzro testnet). No private key, no node binary, no faucet credit is +required to run the validation flow because: + +- DID resolution (`tenzro_resolveIdentity`) is a read-only RPC. +- Mandate validation (`tenzro_ap2ValidateMandatePair`) is a + read-only RPC over caller-provided VDCs. +- ZK proof generation (`tenzro_createZkProof`) is a read-only, + CPU-bound RPC; it produces a proof envelope but does not record it + on-chain. + +The settlement step (channel update + commitment registration) is +**simulated** in the sample with a clearly-labeled stub. Running it +against a live channel would require funded keys, which is outside the +scope of a public AP2 sample. Tenzro's CLI (`tenzro escrow ...`) is the +production path for that. + +To point at a different RPC, set `TENZRO_RPC_URL`: + +```sh +TENZRO_RPC_URL=https://rpc.tenzro.network python main.py +``` + +## How to run + +From this directory: + +```sh +python -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt +python main.py +``` + +Or via the AP2 workspace (if you've installed `ap2-samples` already): + +```sh +uv run --package ap2-samples python -m tenzro_did_settlement.main +``` + +## Expected output + +```text +=== AP2 sample: DID-based settlement (TDIP example) === +RPC: https://rpc.tenzro.network + +[1/5] Resolving principal DID did:tenzro:human:alice ... resolved (kyc_tier=Basic) +[2/5] Resolving delegate DID did:tenzro:machine:shopper ... resolved (controller=did:tenzro:human:alice) +[3/5] Building mandate chain + IntentMandate id=… max=50.00 USD expires=2026-… + CartMandate id=… total=37.50 USD items=2 + PaymentMandate id=… method=tenzro:micropayment-channel + +[4/5] Validating against all four ceilings + AP2 IntentMandate constraints OK (max_amount, expiry, merchants) + AP2 CartMandate consistency OK (total recompute, parent binding) + TDIP DelegationScope OK (37.50 USD ≤ 100.00 USD scope cap) + TDIP SpendingPolicy OK (37.50 USD ≤ 50.00 USD daily window) + Tenzro RPC ap2ValidateMandatePair{enforce_delegation=true} valid=true + +[5/5] Settling via Plonky3 commitment + circuit_id settlement + proof_size ~80 KiB (Plonky3 STARK over KoalaBear) + commitment (sha256) 0x… + registry recordable true + simulated channel receipt channel_id=… payment_amount=37.50 USD + +=== Done. Mandate chain validated and settlement-ready. === +``` + +## Notes for reviewers + +- The sample uses the AP2 SDK's `IntentMandate` / `CartMandate` / + `PaymentMandate` Pydantic models exactly as defined in + `code/sdk/python/ap2/models/mandate.py`. No SDK modifications. +- The DID-method-specific glue (resolution, delegation, ZK commitment) + is isolated in `tenzro_client.py` so the structure of `main.py` + closely mirrors a chain-agnostic AP2 mandate flow. +- The Plonky3 STARK proving call (`tenzro_createZkProof`) is the + canonical path used by Tenzro's own settlement engine; the sample is + not introducing a new proving system. diff --git a/code/samples/python/tenzro_did_settlement/main.py b/code/samples/python/tenzro_did_settlement/main.py new file mode 100644 index 00000000..633abc09 --- /dev/null +++ b/code/samples/python/tenzro_did_settlement/main.py @@ -0,0 +1,589 @@ +# Copyright 2026 Tenzro Network. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""AP2 sample: DID-based settlement (TDIP example). + +Walks an AP2 mandate chain — IntentMandate -> CartMandate -> +PaymentMandate — through four nested validation ceilings and a +Plonky3 STARK settlement commitment. The principal is a TDIP human DID +(`did:tenzro:human:alice`); the delegate is a TDIP machine DID +(`did:tenzro:machine:shopper`). The pattern is identity-system- +agnostic: any DID method with a comparable delegation primitive plugs +in by reimplementing ``tenzro_client.TenzroClient``. + +See ``README.md`` in this directory for the full prose explanation. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import sys +import uuid +from datetime import UTC, datetime, timedelta +from typing import Any + +from ap2.models.mandate import ( + CartContents, + CartMandate, + IntentMandate, + PaymentMandate, + PaymentMandateContents, +) +from ap2.models.payment_request import ( + PaymentCurrencyAmount, + PaymentDetailsInit, + PaymentItem, + PaymentMethodData, + PaymentRequest, + PaymentResponse, +) + +from tenzro_client import TenzroClient, TenzroRpcError + + +# ---------------------------------------------------------------------- +# Constants +# ---------------------------------------------------------------------- + +# Payment method identifier for Tenzro micropayment channels. This is +# the AP2 ``supported_methods`` string the merchant advertises and the +# PaymentMandate carries. It maps onto Tenzro's MPP / channel layer +# (see crates/tenzro-payments and the mpp-specs draft). +TENZRO_CHANNEL_PAYMENT_METHOD = "tenzro:micropayment-channel" + +# DID examples. Replace with real, registered DIDs to exercise live +# enforcement. +PRINCIPAL_DID = "did:tenzro:human:alice" +DELEGATE_DID = "did:tenzro:machine:shopper" +MERCHANT_DID = "did:tenzro:machine:bookshop" + +# Currency / asset choice. AP2 uses ISO-4217 currency codes in +# PaymentCurrencyAmount; the Tenzro side encodes the asset onto the +# settlement channel separately. +CURRENCY_CODE = "USD" + +# Budgets (in cents — kept as int to match AP2's PaymentItem.amount +# semantics, which require a string-decimal value at serialization +# time but flow as ints in our local arithmetic). +INTENT_MAX_CENTS = 5_000 # $50.00 +DELEGATION_SCOPE_CAP_CENTS = 10_000 # $100.00 (TDIP DelegationScope ceiling) +SPENDING_POLICY_DAILY_CENTS = 5_000 # $50.00 (TDIP runtime SpendingPolicy) + + +# ---------------------------------------------------------------------- +# Helpers +# ---------------------------------------------------------------------- + + +def cents_to_decimal(cents: int) -> str: + """Format integer cents as a fixed-2-decimal display string (e.g. '37.50').""" + sign = "-" if cents < 0 else "" + whole, frac = divmod(abs(cents), 100) + return f"{sign}{whole}.{frac:02d}" + + +def cents_to_float(cents: int) -> float: + """Convert integer cents to a float major-unit value for AP2's + ``PaymentCurrencyAmount.value`` (which is typed as ``float``).""" + return round(cents / 100.0, 2) + + +def section(title: str) -> None: + print() + print(title) + print("-" * len(title)) + + +def kv(label: str, value: Any, *, width: int = 32) -> None: + print(f" {label.ljust(width)} {value}") + + +# ---------------------------------------------------------------------- +# AP2 mandate construction +# ---------------------------------------------------------------------- + + +def build_intent_mandate() -> IntentMandate: + """Build the principal-side IntentMandate. + + AP2's IntentMandate is identity-system-agnostic. The principal DID + is conveyed out-of-band (in TDIP / x402 / a custom A2A extension); + the AP2 mandate itself only carries the natural-language intent and + the merchant / SKU constraints. The DID binding is enforced by the + Tenzro validator at ``validateMandatePair`` time. + """ + expiry = (datetime.now(UTC) + timedelta(hours=24)).isoformat() + return IntentMandate( + user_cart_confirmation_required=False, + natural_language_description=( + "Buy two used hardcover Rust programming books, total under " + f"${cents_to_decimal(INTENT_MAX_CENTS)}." + ), + merchants=[MERCHANT_DID], + skus=None, # any SKU permitted + requires_refundability=False, + intent_expiry=expiry, + ) + + +def build_cart_mandate() -> CartMandate: + """Build the merchant-signed CartMandate. + + Items are deterministic so the test can pin the cart total. + """ + items: list[PaymentItem] = [ + PaymentItem( + label="Programming Rust, 2nd ed. (used)", + amount=PaymentCurrencyAmount( + currency=CURRENCY_CODE, value=cents_to_float(2_000) + ), + ), + PaymentItem( + label="Rust for Rustaceans (used)", + amount=PaymentCurrencyAmount( + currency=CURRENCY_CODE, value=cents_to_float(1_750) + ), + ), + ] + total_cents = 2_000 + 1_750 # $37.50 + + payment_request = PaymentRequest( + method_data=[ + PaymentMethodData( + supported_methods=TENZRO_CHANNEL_PAYMENT_METHOD, + data={ + "rail": "tenzro-micropayment-channel", + "asset": "USD", + "settlement_chain": "tenzro:testnet", + }, + ) + ], + details=PaymentDetailsInit( + id=f"order-{uuid.uuid4().hex[:8]}", + display_items=items, + total=PaymentItem( + label="Total", + amount=PaymentCurrencyAmount( + currency=CURRENCY_CODE, + value=cents_to_float(total_cents), + ), + ), + ), + ) + + cart_expiry = (datetime.now(UTC) + timedelta(minutes=15)).isoformat() + contents = CartContents( + id=f"cart-{uuid.uuid4().hex[:8]}", + user_cart_confirmation_required=False, + payment_request=payment_request, + cart_expiry=cart_expiry, + merchant_name=MERCHANT_DID, + ) + return CartMandate(contents=contents, merchant_authorization=None) + + +def build_payment_mandate(cart: CartMandate) -> PaymentMandate: + """Build the user-side PaymentMandate authorizing the cart.""" + contents = PaymentMandateContents( + payment_mandate_id=f"pm-{uuid.uuid4().hex[:8]}", + payment_details_id=cart.contents.payment_request.details.id, + payment_details_total=cart.contents.payment_request.details.total, + payment_response=PaymentResponse( + request_id=cart.contents.payment_request.details.id, + method_name=TENZRO_CHANNEL_PAYMENT_METHOD, + details={ + "channel_id": f"chan-{uuid.uuid4().hex[:8]}", + "payer_did": PRINCIPAL_DID, + "delegate_did": DELEGATE_DID, + }, + ), + merchant_agent=MERCHANT_DID, + ) + return PaymentMandate( + payment_mandate_contents=contents, + user_authorization=None, + ) + + +# ---------------------------------------------------------------------- +# Tenzro VDC envelope +# ---------------------------------------------------------------------- +# +# Tenzro's ``tenzro_ap2ValidateMandatePair`` RPC accepts two signed-VDC +# envelopes. For the sample we construct unsigned skeleton envelopes — +# the live RPC requires real Ed25519 signatures, but the validation +# call paths exercised here (mandate cross-checks + DelegationScope + +# SpendingPolicy) are the ones the sample is showing. A production +# integration would sign these with the principal's TDIP key and the +# agent's TDIP key respectively. + + +def to_tenzro_intent_vdc( + intent: IntentMandate, + *, + principal_did: str, + agent_did: str, +) -> dict[str, Any]: + """Project AP2 IntentMandate -> Tenzro VDC envelope (unsigned).""" + expiry_dt = datetime.fromisoformat(intent.intent_expiry) + now = datetime.now(UTC) + return { + "version": "0.2", + "kind": "intent", + "payload": { + "kind": "intent", + "mandate_id": str(uuid.uuid4()), + "principal_did": principal_did, + "agent_did": agent_did, + "description": intent.natural_language_description, + "max_amount": INTENT_MAX_CENTS, # cents + "asset": CURRENCY_CODE, + "allowed_merchants": list(intent.merchants or []), + "allowed_categories": [], + "max_uses": 1, + "issued_at": now.isoformat(), + "expires_at": expiry_dt.isoformat(), + "presence": "human_not_present", + "metadata": {}, + }, + "signer_did": principal_did, + "signer_public_key": [], # filled in by a production signer + "signature": [], + "alg": "ed25519", + } + + +def to_tenzro_cart_vdc( + cart: CartMandate, + *, + intent_mandate_id: str, + agent_did: str, + merchant_did: str, +) -> dict[str, Any]: + """Project AP2 CartMandate -> Tenzro VDC envelope (unsigned).""" + items = [] + for it in cart.contents.payment_request.details.display_items: + cents = int(round(it.amount.value * 100)) + items.append( + { + "sku": it.label, + "description": it.label, + "quantity": 1, + "unit_price": cents, + "total": cents, + "category": None, + } + ) + total_cents = int( + round(cart.contents.payment_request.details.total.amount.value * 100) + ) + cart_expiry = datetime.fromisoformat(cart.contents.cart_expiry) + now = datetime.now(UTC) + return { + "version": "0.2", + "kind": "cart", + "payload": { + "kind": "cart", + "mandate_id": cart.contents.id, + "intent_mandate_id": intent_mandate_id, + "agent_did": agent_did, + "merchant_did": merchant_did, + "items": items, + "total_amount": total_cents, + "asset": CURRENCY_CODE, + "chain": "tenzro:testnet", + "committed_at": now.isoformat(), + "expires_at": cart_expiry.isoformat(), + "metadata": {}, + }, + "signer_did": agent_did, + "signer_public_key": [], + "signature": [], + "alg": "ed25519", + } + + +# ---------------------------------------------------------------------- +# Local validation (for environments without live Tenzro access) +# ---------------------------------------------------------------------- + + +def local_validate_mandates( + intent_vdc: dict[str, Any], + cart_vdc: dict[str, Any], + *, + delegation_cap_cents: int, + daily_remaining_cents: int, +) -> tuple[bool, list[tuple[str, bool, str]]]: + """Run the four ceilings locally so the sample produces useful + output even when ``tenzro_ap2ValidateMandatePair`` is unreachable. + + Returns ``(overall_ok, [(label, ok, detail), ...])``. + """ + ip = intent_vdc["payload"] + cp = cart_vdc["payload"] + results: list[tuple[str, bool, str]] = [] + + # 1. AP2 IntentMandate constraints + intent_ok = ( + cp["total_amount"] <= ip["max_amount"] + and ip["agent_did"] == cp["agent_did"] + and (not ip["allowed_merchants"] or cp["merchant_did"] in ip["allowed_merchants"]) + and datetime.fromisoformat(ip["expires_at"]) > datetime.now(UTC) + ) + results.append( + ( + "AP2 IntentMandate constraints", + intent_ok, + f"max_amount={ip['max_amount']} cents, cart total={cp['total_amount']} cents", + ) + ) + + # 2. AP2 CartMandate consistency + recomputed = sum(item["total"] for item in cp["items"]) + cart_ok = ( + recomputed == cp["total_amount"] + and datetime.fromisoformat(cp["expires_at"]) > datetime.now(UTC) + ) + results.append( + ( + "AP2 CartMandate consistency", + cart_ok, + f"recomputed={recomputed} cents, claimed={cp['total_amount']} cents", + ) + ) + + # 3. TDIP DelegationScope (protocol-level ceiling) + deleg_ok = cp["total_amount"] <= delegation_cap_cents + results.append( + ( + "TDIP DelegationScope", + deleg_ok, + f"{cp['total_amount']} cents <= {delegation_cap_cents} cents scope cap", + ) + ) + + # 4. TDIP runtime SpendingPolicy (execution-level ceiling) + policy_ok = cp["total_amount"] <= daily_remaining_cents + results.append( + ( + "TDIP SpendingPolicy", + policy_ok, + f"{cp['total_amount']} cents <= {daily_remaining_cents} cents daily window", + ) + ) + + return all(ok for _, ok, _ in results), results + + +# ---------------------------------------------------------------------- +# Settlement (Plonky3 STARK commitment) +# ---------------------------------------------------------------------- + + +def compute_zk_commitment( + *, circuit_id: str, proof_hex: str, public_inputs_hex: list[str] +) -> str: + """Compute the same SHA-256 commitment the on-chain ZK_VERIFY precompile expects. + + Mirrors ``tenzro_zk::compute_zk_commitment`` in the workspace: + SHA-256(circuit_id || proof_bytes || sum_i (len_le(pi_i) || pi_i)) + Lengths are 4-byte little-endian. Proof and public inputs are hex + strings prefixed with ``0x``. + """ + + def unhex(s: str) -> bytes: + return bytes.fromhex(s.removeprefix("0x")) + + h = hashlib.sha256() + h.update(circuit_id.encode("utf-8")) + h.update(unhex(proof_hex)) + for pi in public_inputs_hex: + b = unhex(pi) + h.update(len(b).to_bytes(4, "little")) + h.update(b) + return "0x" + h.hexdigest() + + +def settle( + client: TenzroClient, + *, + payment_mandate: PaymentMandate, + cart_total_cents: int, +) -> dict[str, Any]: + """Generate a Plonky3 STARK + commitment, then return a simulated channel receipt.""" + # Witness fields — small values so the field-element conversion at + # the RPC boundary is unambiguous. `service_proof` is a hash of the + # PaymentMandate that pins the commitment to *this* settlement. + service_proof_hash = hashlib.sha256( + payment_mandate.model_dump_json().encode("utf-8") + ).digest() + service_proof_int = int.from_bytes(service_proof_hash[:4], "little") + + proof_env = client.create_zk_proof_settlement( + payer_balance=10_000, # cents — payer has $100 in channel + service_proof=service_proof_int, + nonce=2, + prev_nonce=1, + amount=cart_total_cents, + ) + commitment = compute_zk_commitment( + circuit_id=proof_env["circuit_id"], + proof_hex=proof_env["proof"], + public_inputs_hex=proof_env["public_inputs"], + ) + + channel_id = payment_mandate.payment_mandate_contents.payment_response.details[ + "channel_id" + ] + receipt = client.settle_channel_simulated( + channel_id=channel_id, + payment_amount=cart_total_cents, + proof_commitment_hex=commitment, + ) + return { + "proof_envelope": proof_env, + "commitment": commitment, + "receipt": receipt, + } + + +# ---------------------------------------------------------------------- +# Main +# ---------------------------------------------------------------------- + + +def run(client: TenzroClient, *, offline: bool) -> int: + print("=== AP2 sample: DID-based settlement (TDIP example) ===") + print(f"RPC: {client.rpc_url}") + + # 1. Resolve principal + delegate DIDs (live RPC, read-only) + section("[1/2] Resolving DIDs") + if not offline: + try: + principal = client.resolve_did(PRINCIPAL_DID) + kv("Principal", f"{PRINCIPAL_DID} ({principal.identity_type})") + kv(" kyc_tier", principal.kyc_tier or "Unverified") + delegate = client.resolve_did(DELEGATE_DID) + kv("Delegate ", f"{DELEGATE_DID} ({delegate.identity_type})") + kv(" controller_did", delegate.controller_did or "(autonomous)") + except (TenzroRpcError, Exception) as e: # noqa: BLE001 + print( + f" (skipping live resolve: {type(e).__name__}: {e}) — proceeding " + "with literal DIDs; this is fine when the testnet doesn't " + "have these example identities registered." + ) + else: + print(" (offline: literal DIDs only)") + + # 2. Build mandate chain + section("[2/2] AP2 mandate chain") + intent = build_intent_mandate() + cart = build_cart_mandate() + payment = build_payment_mandate(cart) + + intent_vdc = to_tenzro_intent_vdc( + intent, principal_did=PRINCIPAL_DID, agent_did=DELEGATE_DID + ) + cart_vdc = to_tenzro_cart_vdc( + cart, + intent_mandate_id=intent_vdc["payload"]["mandate_id"], + agent_did=DELEGATE_DID, + merchant_did=MERCHANT_DID, + ) + + cart_total_cents = cart_vdc["payload"]["total_amount"] + kv("IntentMandate.max_amount", f"${cents_to_decimal(INTENT_MAX_CENTS)} {CURRENCY_CODE}") + kv("CartMandate.total_amount", f"${cents_to_decimal(cart_total_cents)} {CURRENCY_CODE}") + kv("PaymentMandate.method", TENZRO_CHANNEL_PAYMENT_METHOD) + + # Validation — four ceilings + section("Validating against four nested ceilings") + overall, results = local_validate_mandates( + intent_vdc, + cart_vdc, + delegation_cap_cents=DELEGATION_SCOPE_CAP_CENTS, + daily_remaining_cents=SPENDING_POLICY_DAILY_CENTS, + ) + for label, ok, detail in results: + kv(label, ("OK " if ok else "FAIL ") + detail) + if not overall: + print("\nLocal validation failed — aborting before contacting the RPC.") + return 2 + + # Cross-check via the live Tenzro RPC (covers the same checks plus + # signature verification once the VDCs are signed for real). + if not offline: + try: + rpc_outcome = client.validate_mandate_pair( + intent_vdc, cart_vdc, enforce_delegation=True + ) + kv("Tenzro RPC validateMandatePair", json.dumps(rpc_outcome)) + except TenzroRpcError as e: + print( + f" (RPC validateMandatePair returned error {e.code}: {e.message}; " + "expected when the example DIDs / signatures are unregistered — " + "the local checks above already cover the four ceilings)" + ) + else: + print(" (offline: skipping RPC cross-check)") + + # Settlement — Plonky3 STARK commitment + section("Settling via Plonky3 commitment (settlement AIR)") + if offline: + kv("note", "offline mode — skipping create_zk_proof RPC") + return 0 + try: + outcome = settle(client, payment_mandate=payment, cart_total_cents=cart_total_cents) + env = outcome["proof_envelope"] + kv("circuit_id", env["circuit_id"]) + kv("proof_size_bytes", env.get("proof_size_bytes")) + kv("commitment (sha256)", outcome["commitment"]) + kv("receipt.channel_id", outcome["receipt"]["channel_id"]) + kv( + "receipt.payment_amount", + f"${cents_to_decimal(outcome['receipt']['payment_amount'])} {CURRENCY_CODE}", + ) + kv("receipt.status", outcome["receipt"]["status"]) + except TenzroRpcError as e: + print(f" (settlement RPC error {e.code}: {e.message})") + return 3 + + print("\n=== Done. Mandate chain validated and settlement-ready. ===") + return 0 + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--rpc-url", + default=os.environ.get("TENZRO_RPC_URL"), + help="Tenzro JSON-RPC URL (default: $TENZRO_RPC_URL or https://rpc.tenzro.network)", + ) + parser.add_argument( + "--offline", + action="store_true", + help="Skip all live RPC calls; run local checks only.", + ) + args = parser.parse_args(argv) + + client = TenzroClient(rpc_url=args.rpc_url) + return run(client, offline=args.offline) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/code/samples/python/tenzro_did_settlement/requirements.txt b/code/samples/python/tenzro_did_settlement/requirements.txt new file mode 100644 index 00000000..045ea6db --- /dev/null +++ b/code/samples/python/tenzro_did_settlement/requirements.txt @@ -0,0 +1,15 @@ +# Pinned deps for the AP2 DID-based settlement sample. +# +# The AP2 SDK itself is consumed via the parent samples workspace +# (see code/samples/python/pyproject.toml, which lists `ap2` as a +# workspace member). When running this sample standalone, install the +# SDK from the AP2 repo first: +# +# pip install -e /code/sdk/python/ap2 +# +# Then install the deps below. + +requests==2.32.5 +pydantic>=2.7,<3 +pytest==8.3.4 +responses==0.25.6 diff --git a/code/samples/python/tenzro_did_settlement/tenzro_client.py b/code/samples/python/tenzro_did_settlement/tenzro_client.py new file mode 100644 index 00000000..b643005c --- /dev/null +++ b/code/samples/python/tenzro_did_settlement/tenzro_client.py @@ -0,0 +1,250 @@ +# Copyright 2026 Tenzro Network. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Thin Tenzro JSON-RPC client used by the AP2 DID-settlement sample. + +This module hides the Tenzro-specific RPC shape behind a small, +DID-agnostic API so that ``main.py`` reads as a clean AP2 mandate +flow. Other DID-based identity layers can implement the same surface +against their own chain. + +All methods are blocking ``requests`` calls. Errors raise +``TenzroRpcError`` with the JSON-RPC error code preserved for tests. +""" + +from __future__ import annotations + +import os +from dataclasses import dataclass +from typing import Any + +import requests + + +DEFAULT_RPC_URL = "https://rpc.tenzro.network" +DEFAULT_TIMEOUT_SECS = 30.0 + + +class TenzroRpcError(RuntimeError): + """Raised when the Tenzro RPC returns a JSON-RPC error envelope.""" + + def __init__(self, code: int, message: str, data: Any = None) -> None: + super().__init__(f"[{code}] {message}") + self.code = code + self.message = message + self.data = data + + +@dataclass(frozen=True) +class ResolvedIdentity: + """A minimal projection of Tenzro's TDIP identity record. + + Only the fields the sample actually consumes are surfaced here. The + full record is preserved under ``raw`` for debugging. + """ + + did: str + identity_type: str # "Human" | "Machine" + kyc_tier: str | None + controller_did: str | None + raw: dict[str, Any] + + +class TenzroClient: + """Tenzro JSON-RPC client. + + The ``rpc_url`` argument defaults to ``$TENZRO_RPC_URL`` or, failing + that, the live testnet at ``https://rpc.tenzro.network``. + """ + + def __init__( + self, + rpc_url: str | None = None, + *, + timeout_secs: float = DEFAULT_TIMEOUT_SECS, + session: requests.Session | None = None, + ) -> None: + self.rpc_url = rpc_url or os.environ.get( + "TENZRO_RPC_URL", DEFAULT_RPC_URL + ) + self.timeout_secs = timeout_secs + self._session = session or requests.Session() + self._next_id = 1 + + # ------------------------------------------------------------------ + # Low-level JSON-RPC helper + # ------------------------------------------------------------------ + + def _call(self, method: str, params: Any) -> Any: + """Issue a single JSON-RPC 2.0 call and return the ``result`` field. + + Tenzro's RPC accepts both ``params: {...}`` (object) and + ``params: [{...}]`` (array-wrapped) and unwraps the leading + array element. We use the array form, matching the EVM-compat + convention. + """ + rpc_id = self._next_id + self._next_id += 1 + body = { + "jsonrpc": "2.0", + "id": rpc_id, + "method": method, + "params": [params] if params is not None else [], + } + resp = self._session.post( + self.rpc_url, + json=body, + timeout=self.timeout_secs, + headers={"Content-Type": "application/json"}, + ) + resp.raise_for_status() + envelope = resp.json() + if "error" in envelope and envelope["error"] is not None: + err = envelope["error"] + raise TenzroRpcError( + code=int(err.get("code", -32603)), + message=str(err.get("message", "unknown RPC error")), + data=err.get("data"), + ) + return envelope.get("result") + + # ------------------------------------------------------------------ + # Identity (TDIP) — read-only, no signing required + # ------------------------------------------------------------------ + + def resolve_did(self, did: str) -> ResolvedIdentity: + """Resolve a ``did:tenzro:*`` (or ``did:pdis:*``) DID.""" + result = self._call("tenzro_resolveIdentity", {"did": did}) + if not isinstance(result, dict): + raise TenzroRpcError( + -32603, + f"resolveIdentity returned non-dict: {type(result).__name__}", + ) + return ResolvedIdentity( + did=did, + identity_type=str(result.get("identity_type", "Unknown")), + kyc_tier=result.get("kyc_tier"), + controller_did=result.get("controller_did"), + raw=result, + ) + + # ------------------------------------------------------------------ + # AP2 — mandate validation + # ------------------------------------------------------------------ + + def validate_mandate_pair( + self, + intent_vdc: dict[str, Any], + cart_vdc: dict[str, Any], + *, + enforce_delegation: bool = True, + ) -> dict[str, Any]: + """Cross-validate a CartMandate against its parent IntentMandate. + + With ``enforce_delegation=True`` the node also exercises the + TDIP ``DelegationScope`` of the agent and (if an ``AgentRuntime`` + is wired into the node) the runtime ``SpendingPolicy``. + + Returns the raw ``{"valid": bool, ...}`` envelope. The caller + decides whether to treat ``valid=false`` as fatal. + """ + return self._call( + "tenzro_ap2ValidateMandatePair", + { + "intent_vdc": intent_vdc, + "cart_vdc": cart_vdc, + "enforce_delegation": enforce_delegation, + }, + ) + + # ------------------------------------------------------------------ + # ZK — Plonky3 STARK over KoalaBear + # ------------------------------------------------------------------ + + def create_zk_proof_settlement( + self, + *, + payer_balance: int, + service_proof: int, + nonce: int, + prev_nonce: int, + amount: int, + ) -> dict[str, Any]: + """Generate a Plonky3 STARK over the ``settlement`` AIR. + + The five witness fields are KoalaBear field elements + (``2^31 - 2^24 + 1``); pass them as plain ``int`` values. The + RPC handles field-element conversion. Returns + ``{"circuit_id", "proof", "public_inputs", "proof_size_bytes", + "created_at"}``. + """ + return self._call( + "tenzro_createZkProof", + { + "circuit_id": "settlement", + "payer_balance": payer_balance, + "service_proof": service_proof, + "nonce": nonce, + "prev_nonce": prev_nonce, + "amount": amount, + }, + ) + + def verify_zk_proof( + self, + *, + circuit_id: str, + proof_hex: str, + public_inputs_hex: list[str], + ) -> dict[str, Any]: + """Verify a Plonky3 STARK envelope. Returns ``{"valid": bool, ...}``.""" + return self._call( + "tenzro_verifyZkProof", + { + "circuit_id": circuit_id, + "proof": proof_hex, + "public_inputs": public_inputs_hex, + }, + ) + + # ------------------------------------------------------------------ + # Settlement (simulated in this sample) + # ------------------------------------------------------------------ + + def settle_channel_simulated( + self, + *, + channel_id: str, + payment_amount: int, + proof_commitment_hex: str, + ) -> dict[str, Any]: + """Build a simulated settlement-channel receipt. + + Note: production settlement requires a funded payer key and a + previously-opened channel. The sample skips both because the + public testnet RPC will not accept channel updates from + unauthenticated clients. The structure of the returned receipt + matches the shape ``tenzro_updatePaymentChannel`` returns. + """ + return { + "channel_id": channel_id, + "payment_amount": payment_amount, + "proof_commitment": proof_commitment_hex, + "status": "simulated", + "note": ( + "This is a structural placeholder. Run " + "`tenzro escrow open-channel ...` with a funded payer " + "key to drive the live updatePaymentChannel RPC." + ), + } diff --git a/code/samples/python/tenzro_did_settlement/test_main.py b/code/samples/python/tenzro_did_settlement/test_main.py new file mode 100644 index 00000000..2fd2c31e --- /dev/null +++ b/code/samples/python/tenzro_did_settlement/test_main.py @@ -0,0 +1,310 @@ +# Copyright 2026 Tenzro Network. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""End-to-end test for the AP2 DID-based settlement sample. + +Runs the full ``main.run`` flow against a stub Tenzro JSON-RPC backend +mocked with ``responses``. This avoids any dependency on a live testnet +during CI. +""" + +from __future__ import annotations + +import json +import re +from typing import Any + +import pytest +import responses +from responses import matchers + +import main as sample_main +from main import ( + DELEGATE_DID, + PRINCIPAL_DID, + build_cart_mandate, + build_intent_mandate, + build_payment_mandate, + cents_to_float, + compute_zk_commitment, + local_validate_mandates, + to_tenzro_cart_vdc, + to_tenzro_intent_vdc, +) +from tenzro_client import TenzroClient + +STUB_RPC = "https://rpc.stub.tenzro.test" + + +# ---------------------------------------------------------------------- +# Stub RPC fixtures +# ---------------------------------------------------------------------- + + +def _rpc_response(rpc_id: int, result: Any) -> dict[str, Any]: + return {"jsonrpc": "2.0", "id": rpc_id, "result": result} + + +def _stub_resolve_human(rsps: responses.RequestsMock) -> None: + rsps.add( + responses.POST, + STUB_RPC, + match=[ + matchers.json_params_matcher( + { + "jsonrpc": "2.0", + "id": 1, + "method": "tenzro_resolveIdentity", + "params": [{"did": PRINCIPAL_DID}], + } + ) + ], + json=_rpc_response( + 1, + { + "identity_type": "Human", + "kyc_tier": "Basic", + "controller_did": None, + }, + ), + status=200, + ) + + +def _stub_resolve_machine(rsps: responses.RequestsMock) -> None: + rsps.add( + responses.POST, + STUB_RPC, + match=[ + matchers.json_params_matcher( + { + "jsonrpc": "2.0", + "id": 2, + "method": "tenzro_resolveIdentity", + "params": [{"did": DELEGATE_DID}], + } + ) + ], + json=_rpc_response( + 2, + { + "identity_type": "Machine", + "kyc_tier": None, + "controller_did": PRINCIPAL_DID, + }, + ), + status=200, + ) + + +def _stub_validate_mandate_pair(rsps: responses.RequestsMock) -> None: + """Match any ``tenzro_ap2ValidateMandatePair`` POST and return ``valid: true``. + + We don't pin the exact body because the mandate IDs and timestamps + are non-deterministic across test runs. + """ + + def _matcher(req: Any) -> tuple[bool, str]: + try: + body = json.loads(req.body) + except Exception as e: # noqa: BLE001 + return False, f"non-json body: {e}" + ok = body.get("method") == "tenzro_ap2ValidateMandatePair" + return ok, "method ok" if ok else f"method={body.get('method')}" + + rsps.add( + responses.POST, + STUB_RPC, + match=[_matcher], + json={ + "jsonrpc": "2.0", + "id": 0, # responses does not let us know the id ahead of time + "result": { + "valid": True, + "delegation_enforced": True, + "principal_did": PRINCIPAL_DID, + "agent_did": DELEGATE_DID, + }, + }, + status=200, + ) + + +def _stub_create_zk_proof(rsps: responses.RequestsMock) -> None: + def _matcher(req: Any) -> tuple[bool, str]: + try: + body = json.loads(req.body) + except Exception as e: # noqa: BLE001 + return False, f"non-json body: {e}" + if body.get("method") != "tenzro_createZkProof": + return False, f"method={body.get('method')}" + params = body.get("params", [{}])[0] + return ( + params.get("circuit_id") == "settlement", + f"circuit_id={params.get('circuit_id')}", + ) + + rsps.add( + responses.POST, + STUB_RPC, + match=[_matcher], + json={ + "jsonrpc": "2.0", + "id": 0, + "result": { + "circuit_id": "settlement", + "proof": "0xdeadbeef", + "public_inputs": ["0xcafebabe", "0xfeedface"], + "proof_size_bytes": 4, + "created_at": "2026-05-02T00:00:00Z", + }, + }, + status=200, + ) + + +# ---------------------------------------------------------------------- +# Unit tests for the building blocks +# ---------------------------------------------------------------------- + + +def test_intent_and_cart_build_and_round_trip() -> None: + intent = build_intent_mandate() + cart = build_cart_mandate() + payment = build_payment_mandate(cart) + + intent_vdc = to_tenzro_intent_vdc( + intent, principal_did=PRINCIPAL_DID, agent_did=DELEGATE_DID + ) + cart_vdc = to_tenzro_cart_vdc( + cart, + intent_mandate_id=intent_vdc["payload"]["mandate_id"], + agent_did=DELEGATE_DID, + merchant_did="did:tenzro:machine:bookshop", + ) + + # AP2 SDK objects round-tripped via Pydantic + assert intent.merchants == ["did:tenzro:machine:bookshop"] + assert len(cart.contents.payment_request.details.display_items) == 2 + assert cart.contents.payment_request.details.total.amount.value == cents_to_float( + 3_750 + ) + assert ( + payment.payment_mandate_contents.payment_response.method_name + == "tenzro:micropayment-channel" + ) + + # Tenzro VDC projections + assert cart_vdc["payload"]["total_amount"] == 3_750 + assert sum(it["total"] for it in cart_vdc["payload"]["items"]) == 3_750 + assert intent_vdc["payload"]["max_amount"] == 5_000 + + +def test_local_validation_passes_within_all_ceilings() -> None: + intent = build_intent_mandate() + cart = build_cart_mandate() + intent_vdc = to_tenzro_intent_vdc( + intent, principal_did=PRINCIPAL_DID, agent_did=DELEGATE_DID + ) + cart_vdc = to_tenzro_cart_vdc( + cart, + intent_mandate_id=intent_vdc["payload"]["mandate_id"], + agent_did=DELEGATE_DID, + merchant_did="did:tenzro:machine:bookshop", + ) + + overall, results = local_validate_mandates( + intent_vdc, + cart_vdc, + delegation_cap_cents=10_000, + daily_remaining_cents=5_000, + ) + assert overall, results + labels = [label for label, _ok, _ in results] + assert labels == [ + "AP2 IntentMandate constraints", + "AP2 CartMandate consistency", + "TDIP DelegationScope", + "TDIP SpendingPolicy", + ] + + +def test_local_validation_rejects_when_daily_window_exceeded() -> None: + intent = build_intent_mandate() + cart = build_cart_mandate() + intent_vdc = to_tenzro_intent_vdc( + intent, principal_did=PRINCIPAL_DID, agent_did=DELEGATE_DID + ) + cart_vdc = to_tenzro_cart_vdc( + cart, + intent_mandate_id=intent_vdc["payload"]["mandate_id"], + agent_did=DELEGATE_DID, + merchant_did="did:tenzro:machine:bookshop", + ) + overall, results = local_validate_mandates( + intent_vdc, + cart_vdc, + delegation_cap_cents=10_000, + daily_remaining_cents=1_000, # only $10 left in daily window + ) + assert not overall + failed = [label for label, ok, _ in results if not ok] + assert failed == ["TDIP SpendingPolicy"] + + +def test_compute_zk_commitment_matches_expected_shape() -> None: + commitment = compute_zk_commitment( + circuit_id="settlement", + proof_hex="0xdeadbeef", + public_inputs_hex=["0xcafebabe", "0xfeedface"], + ) + assert commitment.startswith("0x") + assert len(commitment) == 2 + 64 # SHA-256 hex + + +# ---------------------------------------------------------------------- +# End-to-end via the stub RPC +# ---------------------------------------------------------------------- + + +def test_run_end_to_end_against_stub_rpc(capsys: pytest.CaptureFixture[str]) -> None: + with responses.RequestsMock() as rsps: + _stub_resolve_human(rsps) + _stub_resolve_machine(rsps) + # validate + create_zk_proof are matcher-based (any-id) + _stub_validate_mandate_pair(rsps) + _stub_create_zk_proof(rsps) + + client = TenzroClient(rpc_url=STUB_RPC) + rc = sample_main.run(client, offline=False) + + assert rc == 0 + captured = capsys.readouterr().out + # Spot-check that the four ceilings + commitment were printed + assert "AP2 IntentMandate constraints" in captured + assert "AP2 CartMandate consistency" in captured + assert "TDIP DelegationScope" in captured + assert "TDIP SpendingPolicy" in captured + assert re.search(r"commitment \(sha256\)\s+0x[0-9a-f]{64}", captured) + assert "tenzro:micropayment-channel" in captured + + +def test_run_offline_mode_skips_rpc(capsys: pytest.CaptureFixture[str]) -> None: + # No responses fixture: any HTTP call would raise. + client = TenzroClient(rpc_url="https://invalid.tenzro.test") + rc = sample_main.run(client, offline=True) + assert rc == 0 + captured = capsys.readouterr().out + assert "offline: literal DIDs only" in captured + assert "offline mode — skipping create_zk_proof RPC" in captured