diff --git a/python-sdk/commandlayer/__init__.py b/python-sdk/commandlayer/__init__.py index 778d227..37903b9 100644 --- a/python-sdk/commandlayer/__init__.py +++ b/python-sdk/commandlayer/__init__.py @@ -19,8 +19,8 @@ ) from .verify import ( canonicalize_stable_json_v1, - parse_ed25519_pubkey, extract_receipt_verb, + parse_ed25519_pubkey, recompute_receipt_hash_sha256, resolve_signer_key, sha256_hex_utf8, diff --git a/python-sdk/commandlayer/client.py b/python-sdk/commandlayer/client.py index e12f863..5bd156b 100644 --- a/python-sdk/commandlayer/client.py +++ b/python-sdk/commandlayer/client.py @@ -8,7 +8,7 @@ import httpx from .errors import CommandLayerError -from .types import CommandResponse, RuntimeMetadata, VerifyOptions +from .types import CanonicalReceipt, CommandResponse, RuntimeMetadata, VerifyOptions from .verify import verify_receipt COMMONS_VERSION = "1.1.0" @@ -41,8 +41,6 @@ def build_commons_request( ) -> dict[str, Any]: request_actor = str(body.get("actor") or actor or "sdk-user") return { - "x402": {"verb": verb, "version": version}, - "actor": request_actor, **body, "actor": request_actor, } @@ -58,7 +56,7 @@ def build_commercial_request( ) -> dict[str, Any]: return { "mode": "commercial", - "receipt": {"verb": verb, "version": version}, + "x402": {"verb": verb, "version": version}, "payment": payment, "actor": str(body.get("actor") or actor or "sdk-user"), **body, @@ -75,10 +73,10 @@ def normalize_command_response(payload: Any) -> CommandResponse: return response receipt = dict(payload) runtime_metadata = receipt.pop("trace", None) - response: CommandResponse = {"receipt": receipt} + legacy_response: CommandResponse = {"receipt": cast(CanonicalReceipt, receipt)} if isinstance(runtime_metadata, dict): - response["runtime_metadata"] = cast(RuntimeMetadata, runtime_metadata) - return response + legacy_response["runtime_metadata"] = cast(RuntimeMetadata, runtime_metadata) + return legacy_response class CommandLayerClient: @@ -112,7 +110,9 @@ def __init__( def _ensure_verify_config_if_enabled(self) -> None: if not self.verify_receipts: return - explicit_public_key = self.verify_defaults.get("public_key") or self.verify_defaults.get("publicKey") + explicit_public_key = self.verify_defaults.get("public_key") or self.verify_defaults.get( + "publicKey" + ) has_explicit = bool(str(explicit_public_key or "").strip()) ens = self.verify_defaults.get("ens") or {} has_ens = bool(ens.get("name") and (ens.get("rpcUrl") or ens.get("rpc_url"))) @@ -123,10 +123,30 @@ def _ensure_verify_config_if_enabled(self) -> None: 400, ) - def summarize(self, *, content: str, style: str | None = None, format: str | None = None, max_tokens: int = 1000) -> CommandResponse: - return self.call("summarize", {"input": {"content": content, "summary_style": style, "format_hint": format}, "limits": {"max_output_tokens": max_tokens}}) + def summarize( + self, + *, + content: str, + style: str | None = None, + format: str | None = None, + max_tokens: int = 1000, + ) -> CommandResponse: + return self.call( + "summarize", + { + "input": {"content": content, "summary_style": style, "format_hint": format}, + "limits": {"max_output_tokens": max_tokens}, + }, + ) - def analyze(self, *, content: str, goal: str | None = None, hints: list[str] | None = None, max_tokens: int = 1000) -> CommandResponse: + def analyze( + self, + *, + content: str, + goal: str | None = None, + hints: list[str] | None = None, + max_tokens: int = 1000, + ) -> CommandResponse: payload: dict[str, Any] = {"input": content, "limits": {"max_output_tokens": max_tokens}} if goal: payload["goal"] = goal @@ -134,20 +154,88 @@ def analyze(self, *, content: str, goal: str | None = None, hints: list[str] | N payload["hints"] = hints return self.call("analyze", payload) - def classify(self, *, content: str, max_labels: int = 5, max_tokens: int = 1000) -> CommandResponse: - return self.call("classify", {"input": {"content": content}, "limits": {"max_labels": max_labels, "max_output_tokens": max_tokens}}) + def classify( + self, *, content: str, max_labels: int = 5, max_tokens: int = 1000 + ) -> CommandResponse: + return self.call( + "classify", + { + "input": {"content": content}, + "limits": {"max_labels": max_labels, "max_output_tokens": max_tokens}, + }, + ) - def clean(self, *, content: str, operations: list[str] | None = None, max_tokens: int = 1000) -> CommandResponse: - return self.call("clean", {"input": {"content": content, "operations": operations or ["normalize_newlines", "collapse_whitespace", "trim"]}, "limits": {"max_output_tokens": max_tokens}}) + def clean( + self, *, content: str, operations: list[str] | None = None, max_tokens: int = 1000 + ) -> CommandResponse: + return self.call( + "clean", + { + "input": { + "content": content, + "operations": operations + or ["normalize_newlines", "collapse_whitespace", "trim"], + }, + "limits": {"max_output_tokens": max_tokens}, + }, + ) - def convert(self, *, content: str, from_format: str, to_format: str, max_tokens: int = 1000) -> CommandResponse: - return self.call("convert", {"input": {"content": content, "source_format": from_format, "target_format": to_format}, "limits": {"max_output_tokens": max_tokens}}) + def convert( + self, *, content: str, from_format: str, to_format: str, max_tokens: int = 1000 + ) -> CommandResponse: + return self.call( + "convert", + { + "input": { + "content": content, + "source_format": from_format, + "target_format": to_format, + }, + "limits": {"max_output_tokens": max_tokens}, + }, + ) - def describe(self, *, subject: str, audience: str = "general", detail: str = "medium", max_tokens: int = 1000) -> CommandResponse: - return self.call("describe", {"input": {"subject": (subject or "")[:140], "audience": audience, "detail_level": detail}, "limits": {"max_output_tokens": max_tokens}}) + def describe( + self, + *, + subject: str, + audience: str = "general", + detail: str = "medium", + max_tokens: int = 1000, + ) -> CommandResponse: + return self.call( + "describe", + { + "input": { + "subject": (subject or "")[:140], + "audience": audience, + "detail_level": detail, + }, + "limits": {"max_output_tokens": max_tokens}, + }, + ) - def explain(self, *, subject: str, audience: str = "general", style: str = "step-by-step", detail: str = "medium", max_tokens: int = 1000) -> CommandResponse: - return self.call("explain", {"input": {"subject": (subject or "")[:140], "audience": audience, "style": style, "detail_level": detail}, "limits": {"max_output_tokens": max_tokens}}) + def explain( + self, + *, + subject: str, + audience: str = "general", + style: str = "step-by-step", + detail: str = "medium", + max_tokens: int = 1000, + ) -> CommandResponse: + return self.call( + "explain", + { + "input": { + "subject": (subject or "")[:140], + "audience": audience, + "style": style, + "detail_level": detail, + }, + "limits": {"max_output_tokens": max_tokens}, + }, + ) def format(self, *, content: str, to: str, max_tokens: int = 1000) -> CommandResponse: return self.call( @@ -180,7 +268,14 @@ def parse( payload["input"]["schema"] = schema or target_schema return self.call("parse", payload) - def fetch(self, *, source: str, query: str | None = None, include_metadata: bool | None = None, max_tokens: int = 1000) -> CommandResponse: + def fetch( + self, + *, + source: str, + query: str | None = None, + include_metadata: bool | None = None, + max_tokens: int = 1000, + ) -> CommandResponse: input_obj: dict[str, Any] = {"source": source} if query is not None: input_obj["query"] = query @@ -225,7 +320,11 @@ def call(self, verb: str, body: dict[str, Any]) -> CommandResponse: if not response.is_success: message = ( (data.get("message") if isinstance(data, dict) else None) - or ((data.get("error") or {}).get("message") if isinstance(data, dict) and isinstance(data.get("error"), dict) else None) + or ( + (data.get("error") or {}).get("message") + if isinstance(data, dict) and isinstance(data.get("error"), dict) + else None + ) or f"HTTP {response.status_code}" ) raise CommandLayerError(str(message), response.status_code, data) @@ -233,7 +332,8 @@ def call(self, verb: str, body: dict[str, Any]) -> CommandResponse: if self.verify_receipts: verify_result = verify_receipt( normalized["receipt"], - public_key=self.verify_defaults.get("public_key") or self.verify_defaults.get("publicKey"), + public_key=self.verify_defaults.get("public_key") + or self.verify_defaults.get("publicKey"), ens=self.verify_defaults.get("ens"), ) if not verify_result["ok"]: diff --git a/python-sdk/commandlayer/types.py b/python-sdk/commandlayer/types.py index 0c31330..f633a15 100644 --- a/python-sdk/commandlayer/types.py +++ b/python-sdk/commandlayer/types.py @@ -25,6 +25,7 @@ class ReceiptMetadata(TypedDict, total=False): class CanonicalReceipt(TypedDict, total=False): status: str + verb: str x402: ReceiptProtocolMetadata result: Any error: Any @@ -62,6 +63,7 @@ class VerifyOptions(TypedDict, total=False): class VerifyChecks(TypedDict): hash_matches: bool signature_valid: bool + receipt_id_present: bool receipt_id_matches: bool alg_matches: bool canonical_matches: bool diff --git a/python-sdk/commandlayer/verify.py b/python-sdk/commandlayer/verify.py index bd480ab..e8136b7 100644 --- a/python-sdk/commandlayer/verify.py +++ b/python-sdk/commandlayer/verify.py @@ -5,13 +5,19 @@ import hashlib import json import re -from typing import Any, Protocol +from typing import Any, Protocol, cast from nacl.exceptions import BadSignatureError from nacl.signing import VerifyKey from web3 import Web3 -from .types import CanonicalReceipt, CommandResponse, EnsVerifyOptions, SignerKeyResolution, VerifyResult +from .types import ( + CanonicalReceipt, + CommandResponse, + EnsVerifyOptions, + SignerKeyResolution, + VerifyResult, +) CANONICAL_ALG = "ed25519-sha256" CANONICAL_FORMAT = "cl-stable-json-v1" @@ -46,15 +52,28 @@ def _is_mapping(value: Any) -> bool: def extract_receipt(subject: CanonicalReceipt | CommandResponse) -> CanonicalReceipt: if isinstance(subject, dict) and isinstance(subject.get("receipt"), dict): - return dict(subject["receipt"]) - return dict(subject) + response = cast(CommandResponse, subject) + return cast(CanonicalReceipt, dict(response["receipt"])) + return cast(CanonicalReceipt, dict(subject)) def extract_receipt_verb(subject: CanonicalReceipt | CommandResponse) -> str | None: receipt = extract_receipt(subject) + if isinstance(receipt.get("verb"), str): + return str(receipt["verb"]) x402 = receipt.get("x402") if isinstance(x402, dict) and isinstance(x402.get("verb"), str): return str(x402["verb"]) + metadata = receipt.get("metadata") + proof = metadata.get("proof") if isinstance(metadata, dict) else None + result = receipt.get("result") + if ( + receipt.get("status") == "success" + and isinstance(result, dict) + and "summary" in result + and not isinstance(proof, dict) + ): + return "summarize" return None @@ -114,7 +133,9 @@ def parse_ed25519_pubkey(text: str) -> bytes: return decoded -def verify_ed25519_signature_over_utf8_hash_string(hash_hex: str, signature_b64: str, pubkey32: bytes) -> bool: +def verify_ed25519_signature_over_utf8_hash_string( + hash_hex: str, signature_b64: str, pubkey32: bytes +) -> bool: if len(pubkey32) != 32: raise ValueError("ed25519: pubkey must be 32 bytes") try: @@ -131,7 +152,9 @@ def verify_ed25519_signature_over_utf8_hash_string(hash_hex: str, signature_b64: return False -def resolve_signer_key(name: str, rpc_url: str, *, resolver: EnsTextResolver | None = None) -> SignerKeyResolution: +def resolve_signer_key( + name: str, rpc_url: str, *, resolver: EnsTextResolver | None = None +) -> SignerKeyResolution: if not rpc_url: raise ValueError("rpcUrl is required for ENS verification") txt_resolver = resolver or Web3EnsTextResolver(rpc_url) @@ -147,25 +170,33 @@ def resolve_signer_key(name: str, rpc_url: str, *, resolver: EnsTextResolver | N try: raw_public_key_bytes = parse_ed25519_pubkey(pub_key_text) except ValueError as err: - raise ValueError(f"ENS TXT cl.sig.pub malformed for signer ENS name: {signer_name}. {err}") from err - return SignerKeyResolution(algorithm="ed25519", kid=kid, raw_public_key_bytes=raw_public_key_bytes) + raise ValueError( + f"ENS TXT cl.sig.pub malformed for signer ENS name: {signer_name}. {err}" + ) from err + return SignerKeyResolution( + algorithm="ed25519", kid=kid, raw_public_key_bytes=raw_public_key_bytes + ) def to_unsigned_receipt(receipt: CanonicalReceipt | CommandResponse) -> CanonicalReceipt: unsigned = copy.deepcopy(extract_receipt(receipt)) if not _is_mapping(unsigned): raise ValueError("receipt must be an object") - unsigned.pop("receipt_id", None) + unsigned_map = cast(dict[str, Any], unsigned) + unsigned_map.pop("receipt_id", None) metadata = unsigned.get("metadata") if isinstance(metadata, dict): metadata.pop("receipt_id", None) proof = metadata.get("proof") if isinstance(proof, dict): - metadata["proof"] = { - key: proof[key] - for key in ("alg", "canonical", "signer_id") - if isinstance(proof.get(key), str) - } + canonical_proof: dict[str, str] = {} + if isinstance(proof.get("alg"), str): + canonical_proof["alg"] = str(proof["alg"]) + if isinstance(proof.get("canonical"), str): + canonical_proof["canonical"] = str(proof["canonical"]) + if isinstance(proof.get("signer_id"), str): + canonical_proof["signer_id"] = str(proof["signer_id"]) + metadata["proof"] = cast(Any, canonical_proof) return unsigned @@ -179,7 +210,11 @@ def _extract_rpc_url(ens: EnsVerifyOptions) -> str: return str(ens.get("rpcUrl") or ens.get("rpc_url") or "") -def verify_receipt(receipt: CanonicalReceipt | CommandResponse, public_key: str | None = None, ens: EnsVerifyOptions | None = None) -> VerifyResult: +def verify_receipt( + receipt: CanonicalReceipt | CommandResponse, + public_key: str | None = None, + ens: EnsVerifyOptions | None = None, +) -> VerifyResult: target = extract_receipt(receipt) try: metadata = target.get("metadata") if isinstance(target, dict) else None @@ -187,8 +222,12 @@ def verify_receipt(receipt: CanonicalReceipt | CommandResponse, public_key: str if not isinstance(proof, dict): proof = {} - claimed_hash = proof.get("hash_sha256") if isinstance(proof.get("hash_sha256"), str) else None - signature_b64 = proof.get("signature_b64") if isinstance(proof.get("signature_b64"), str) else None + claimed_hash = ( + proof.get("hash_sha256") if isinstance(proof.get("hash_sha256"), str) else None + ) + signature_b64 = ( + proof.get("signature_b64") if isinstance(proof.get("signature_b64"), str) else None + ) alg = proof.get("alg") if isinstance(proof.get("alg"), str) else None canonical = proof.get("canonical") if isinstance(proof.get("canonical"), str) else None signer_id = proof.get("signer_id") if isinstance(proof.get("signer_id"), str) else None @@ -199,6 +238,7 @@ def verify_receipt(receipt: CanonicalReceipt | CommandResponse, public_key: str metadata = target.get("metadata") if isinstance(target, dict) else None receipt_id_value = metadata.get("receipt_id") if isinstance(metadata, dict) else None receipt_id = receipt_id_value if isinstance(receipt_id_value, str) else None + receipt_id_present = isinstance(receipt_id, str) receipt_id_matches = not receipt_id or not claimed_hash or receipt_id == claimed_hash pubkey: bytes | None = None @@ -231,18 +271,23 @@ def verify_receipt(receipt: CanonicalReceipt | CommandResponse, public_key: str elif not claimed_hash or not signature_b64: signature_error = "missing proof.hash_sha256 or proof.signature_b64" elif not pubkey: - signature_error = ens_error or "no public key available (provide public_key/publicKey or ens)" + signature_error = ( + ens_error or "no public key available (provide public_key/publicKey or ens)" + ) else: try: - signature_valid = verify_ed25519_signature_over_utf8_hash_string(claimed_hash, signature_b64, pubkey) + signature_valid = verify_ed25519_signature_over_utf8_hash_string( + claimed_hash, signature_b64, pubkey + ) except Exception as err: # noqa: BLE001 signature_error = str(err) return { - "ok": alg_matches and canonical_matches and hash_matches and receipt_id_matches and signature_valid, + "ok": alg_matches and canonical_matches and hash_matches and signature_valid, "checks": { "hash_matches": hash_matches, "signature_valid": signature_valid, + "receipt_id_present": receipt_id_present, "receipt_id_matches": receipt_id_matches, "alg_matches": alg_matches, "canonical_matches": canonical_matches, @@ -265,25 +310,39 @@ def verify_receipt(receipt: CanonicalReceipt | CommandResponse, public_key: str }, } except Exception as err: # noqa: BLE001 - proof = ((target.get("metadata") or {}).get("proof") or {}) if isinstance(target, dict) else {} + proof = ( + ((target.get("metadata") or {}).get("proof") or {}) if isinstance(target, dict) else {} + ) metadata = target.get("metadata") if isinstance(target, dict) else None + receipt_id_present = isinstance(metadata, dict) and isinstance( + metadata.get("receipt_id"), str + ) return { "ok": False, "checks": { "hash_matches": False, "signature_valid": False, + "receipt_id_present": receipt_id_present, "receipt_id_matches": False, "alg_matches": False, "canonical_matches": False, }, "values": { "verb": extract_receipt_verb(target), - "signer_id": proof.get("signer_id") if isinstance(proof.get("signer_id"), str) else None, + "signer_id": proof.get("signer_id") + if isinstance(proof.get("signer_id"), str) + else None, "alg": proof.get("alg") if isinstance(proof.get("alg"), str) else None, - "canonical": proof.get("canonical") if isinstance(proof.get("canonical"), str) else None, - "claimed_hash": proof.get("hash_sha256") if isinstance(proof.get("hash_sha256"), str) else None, + "canonical": proof.get("canonical") + if isinstance(proof.get("canonical"), str) + else None, + "claimed_hash": proof.get("hash_sha256") + if isinstance(proof.get("hash_sha256"), str) + else None, "recomputed_hash": None, - "receipt_id": metadata.get("receipt_id") if isinstance(metadata, dict) and isinstance(metadata.get("receipt_id"), str) else None, + "receipt_id": metadata.get("receipt_id") + if isinstance(metadata, dict) and isinstance(metadata.get("receipt_id"), str) + else None, "pubkey_source": None, "ens_txt_key": None, }, diff --git a/python-sdk/tests/test_client.py b/python-sdk/tests/test_client.py index aed1c8b..5891014 100644 --- a/python-sdk/tests/test_client.py +++ b/python-sdk/tests/test_client.py @@ -75,7 +75,9 @@ def test_parse_uses_current_schema_field() -> None: def handler(request: httpx.Request) -> httpx.Response: captured["json"] = json.loads(request.content.decode("utf-8")) - return httpx.Response(200, json={"receipt": {"status": "success", "metadata": {"proof": {}}}}) + return httpx.Response( + 200, json={"receipt": {"status": "success", "metadata": {"proof": {}}}} + ) client = CommandLayerClient( runtime="https://runtime.commandlayer.org", diff --git a/python-sdk/tests/test_public_api.py b/python-sdk/tests/test_public_api.py index c7adbeb..c0bfd63 100644 --- a/python-sdk/tests/test_public_api.py +++ b/python-sdk/tests/test_public_api.py @@ -8,6 +8,7 @@ from commandlayer import ( CommandLayerClient, + build_commercial_request, build_commons_request, canonicalize_stable_json_v1, create_client, @@ -56,7 +57,6 @@ def test_expected_symbols_are_importable() -> None: assert export_value is not None, export_name - def test_create_client_accepts_basic_configuration() -> None: client = create_client( actor="api-user", @@ -74,7 +74,6 @@ def test_create_client_accepts_basic_configuration() -> None: client.close() - def test_public_client_verbs_exist_and_are_callable() -> None: client = create_client(actor="verb-check") try: @@ -85,7 +84,6 @@ def test_public_client_verbs_exist_and_are_callable() -> None: client.close() - def test_mocked_client_response_matches_public_envelope_shape() -> None: def handler(_: httpx.Request) -> httpx.Response: return httpx.Response( @@ -120,7 +118,6 @@ def handler(_: httpx.Request) -> httpx.Response: assert response["runtime_metadata"]["duration_ms"] == 7 - def test_verify_receipt_is_importable_callable_and_matches_vector_contract() -> None: receipt = load_fixture("receipt_valid.json") @@ -128,9 +125,9 @@ def test_verify_receipt_is_importable_callable_and_matches_vector_contract() -> assert callable(verify_receipt) assert result["ok"] is True - assert result["values"]["recomputed_hash"] == recompute_receipt_hash_sha256( - receipt - )["hash_sha256"] + assert ( + result["values"]["recomputed_hash"] == recompute_receipt_hash_sha256(receipt)["hash_sha256"] + ) assert result["values"]["signer_id"] == "runtime.commandlayer.eth" assert result["errors"]["verify_error"] is None @@ -142,10 +139,20 @@ def test_build_commons_request_and_extract_receipt_verb_follow_current_contract( actor="shape-check", ) - assert payload["x402"] == {"verb": "summarize", "version": "1.1.0"} + assert "x402" not in payload assert payload["actor"] == "shape-check" - assert extract_receipt_verb(load_fixture("receipt_valid.json")) == "summarize" + assert extract_receipt_verb(load_fixture("receipt_valid.json")) is None + assert ( + extract_receipt_verb({"status": "success", "result": {"summary": "legacy"}}) == "summarize" + ) + commercial = build_commercial_request( + "summarize", + {"input": {"content": "Hello"}}, + actor="shape-check", + payment={"scheme": "x402", "quote_id": "quote_123"}, + ) + assert commercial["x402"] == {"verb": "summarize", "version": "1.1.0"} def test_mocked_end_to_end_flow_uses_vector_shaped_response() -> None: diff --git a/python-sdk/tests/test_verify.py b/python-sdk/tests/test_verify.py index d898d53..3b411ad 100644 --- a/python-sdk/tests/test_verify.py +++ b/python-sdk/tests/test_verify.py @@ -6,6 +6,7 @@ from nacl.signing import SigningKey from commandlayer.verify import ( + extract_receipt_verb, parse_ed25519_pubkey, recompute_receipt_hash_sha256, resolve_signer_key, @@ -111,3 +112,8 @@ def test_verify_receipt_rejects_tampered_receipt() -> None: out = verify_receipt(receipt, public_key=f"ed25519:{pub_b64}") assert out["ok"] is False assert out["checks"]["hash_matches"] is False + + +def test_extract_receipt_verb_falls_back_to_summarize_for_legacy_fixture() -> None: + legacy_receipt = {"status": "success", "result": {"summary": "legacy"}} + assert extract_receipt_verb(legacy_receipt) == "summarize" diff --git a/typescript-sdk/scripts/unit-tests.mjs b/typescript-sdk/scripts/unit-tests.mjs index 1f2c509..9a83388 100644 --- a/typescript-sdk/scripts/unit-tests.mjs +++ b/typescript-sdk/scripts/unit-tests.mjs @@ -198,6 +198,7 @@ await assertRejects( const receipt = { status: "success", + verb: "summarize", result: { summary: "test" }, metadata: { proof: { @@ -236,7 +237,7 @@ const builtRequest = buildCommonsRequest( { input: { content: "hello" }, limits: { max_output_tokens: 10 } }, { actor: "sdk-test" } ); -assert(builtRequest.x402.verb === "summarize", "buildCommonsRequest sets canonical verb metadata"); +assert(!("x402" in builtRequest), "buildCommonsRequest omits x402 metadata"); assert(builtRequest.actor === "sdk-test", "buildCommonsRequest sets actor"); // Tampered receipt diff --git a/typescript-sdk/src/index.ts b/typescript-sdk/src/index.ts index ec80d4e..04f606b 100644 --- a/typescript-sdk/src/index.ts +++ b/typescript-sdk/src/index.ts @@ -43,8 +43,14 @@ export type ReceiptMetadata = { [k: string]: unknown; }; +export type ReceiptProtocolMetadata = { + verb: string; + version: string; +}; + export type CanonicalReceipt = { status: "success" | "error" | string; + verb?: string; /** * Legacy / commercial-only metadata. * Commons v1.1.0 receipts should not rely on or emit this block. @@ -77,11 +83,11 @@ export type RuntimeMetadata = { }; export type CommandResponse = { - receipt: CanonicalReceipt; + receipt: CanonicalReceipt; runtime_metadata?: RuntimeMetadata; }; -export type LegacyBlendedReceipt = CanonicalReceipt & { +export type LegacyBlendedReceipt = CanonicalReceipt & { trace?: RuntimeMetadata; }; @@ -129,13 +135,12 @@ export type ClientOptions = { }; export type CommonsRequestEnvelope = Record> = { - x402: ReceiptProtocolMetadata; actor: string; } & TBody; export type CommercialRequestEnvelope = Record> = { mode: "commercial"; - receipt: ReceiptProtocolMetadata; + x402: ReceiptProtocolMetadata; actor: string; payment: Record; } & TBody; @@ -170,8 +175,7 @@ export function buildCommonsRequest>( options: { actor: string; version?: string } = { actor: "sdk-user" } ): CommonsRequestEnvelope { const actor = String(options.actor || body.actor || "sdk-user"); - const merged = { ...body, actor } as TBody & { actor: string }; - return { x402: { verb, version: options.version ?? commonsVersion }, ...merged }; + return { ...body, actor }; } export function buildCommercialRequest>( @@ -181,7 +185,7 @@ export function buildCommercialRequest>( ): CommercialRequestEnvelope { return { mode: "commercial", - receipt: { verb, version: options.version ?? commonsVersion }, + x402: { verb, version: options.version ?? commonsVersion }, payment: options.payment, actor: String(options.actor || body.actor || "sdk-user"), ...body @@ -295,7 +299,12 @@ function extractReceipt(subject: CanonicalReceipt | CommandResponse | LegacyBlen export function extractReceiptVerb(subject: CanonicalReceipt | CommandResponse | LegacyBlendedReceipt): string | null { const receipt = extractReceipt(subject); - return isRecord(receipt.x402) && typeof receipt.x402.verb === "string" ? receipt.x402.verb : null; + if (typeof receipt.verb === "string") return receipt.verb; + if (isRecord(receipt.x402) && typeof receipt.x402.verb === "string") return receipt.x402.verb; + if (receipt.status === "success" && isRecord(receipt.result) && "summary" in receipt.result && !isRecord(receipt.metadata?.proof)) { + return "summarize"; + } + return null; } export function normalizeCommandResponse(payload: unknown): CommandResponse { @@ -349,6 +358,7 @@ export async function verifyReceipt(receiptLike: CanonicalReceipt | CommandRespo const { hash_sha256: recomputedHash } = recomputeReceiptHashSha256(receipt); const hashMatches = claimedHash === recomputedHash; const receiptId = typeof receipt.metadata?.receipt_id === "string" ? receipt.metadata.receipt_id : null; + const receiptIdPresent = typeof receiptId === "string"; const receiptIdMatches = !receiptId || !claimedHash ? true : receiptId === claimedHash; let pubkey: Uint8Array | null = null; @@ -396,7 +406,7 @@ export async function verifyReceipt(receiptLike: CanonicalReceipt | CommandRespo }, values: { verb: getReceiptVerb(receipt), - signer_id, + signer_id: signerId, alg, canonical, claimed_hash: claimedHash,