From a50ab5423abb30fe09804fbbf4d861c9038973db Mon Sep 17 00:00:00 2001 From: Greg Soucy Date: Thu, 23 Apr 2026 23:15:03 -0400 Subject: [PATCH] Reapply PR38 fixes and keep PR39 template suite discovery --- python-sdk/commandlayer/__init__.py | 2 +- python-sdk/commandlayer/client.py | 148 ++++++++++++++++++---- python-sdk/commandlayer/types.py | 2 + python-sdk/commandlayer/verify.py | 96 ++++++++++---- python-sdk/tests/test_client.py | 4 +- python-sdk/tests/test_public_api.py | 13 +- typescript-sdk/scripts/template-tests.mjs | 36 +++++- typescript-sdk/scripts/unit-tests.mjs | 3 +- typescript-sdk/src/index.ts | 28 ++-- 9 files changed, 258 insertions(+), 74 deletions(-) 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..56ccce8 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,21 @@ 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"]) + result = receipt.get("result") + if receipt.get("status") == "success" and isinstance(result, dict) and "summary" in result: + return "summarize" return None @@ -114,7 +126,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 +145,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 +163,29 @@ 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) 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) - } + sanitized_proof: dict[str, str] = {} + for key in ("alg", "canonical", "signer_id"): + value = proof.get(key) + if isinstance(value, str): + sanitized_proof[key] = value + metadata["proof"] = cast(Any, sanitized_proof) return unsigned @@ -179,7 +199,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 +211,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 +227,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 = bool(receipt_id) receipt_id_matches = not receipt_id or not claimed_hash or receipt_id == claimed_hash pubkey: bytes | None = None @@ -231,18 +260,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 +299,37 @@ 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 return { "ok": False, "checks": { "hash_matches": False, "signature_valid": False, + "receipt_id_present": isinstance(metadata, dict) + and isinstance(metadata.get("receipt_id"), str), "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..21c64df 100644 --- a/python-sdk/tests/test_public_api.py +++ b/python-sdk/tests/test_public_api.py @@ -56,7 +56,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 +73,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 +83,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 +117,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 +124,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,12 +138,11 @@ 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 payload["actor"] == "shape-check" + assert "x402" not in payload assert extract_receipt_verb(load_fixture("receipt_valid.json")) == "summarize" - def test_mocked_end_to_end_flow_uses_vector_shaped_response() -> None: receipt = load_fixture("receipt_valid.json") diff --git a/typescript-sdk/scripts/template-tests.mjs b/typescript-sdk/scripts/template-tests.mjs index dc49d5d..839813b 100644 --- a/typescript-sdk/scripts/template-tests.mjs +++ b/typescript-sdk/scripts/template-tests.mjs @@ -1,14 +1,40 @@ import { spawnSync } from "node:child_process"; +import { existsSync, readdirSync } from "node:fs"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +const repoRoot = fileURLToPath(new URL("../..", import.meta.url)); const suites = [ - "runtime/tests/*.test.mjs", - "typescript-sdk/tests/*.test.mjs" + { dir: "runtime/tests", optional: true }, + { dir: "typescript-sdk/tests", optional: false } ]; -for (const pattern of suites) { - const run = spawnSync("node", ["--test", pattern], { +for (const suite of suites) { + const suiteDir = join(repoRoot, suite.dir); + if (!existsSync(suiteDir)) { + if (suite.optional) { + continue; + } + console.error(`Required test suite directory is missing: ${suite.dir}`); + process.exit(1); + } + + const suiteFiles = readdirSync(suiteDir) + .filter((file) => file.endsWith(".test.mjs")) + .sort() + .map((file) => join(suite.dir, file)); + + if (suiteFiles.length === 0) { + if (suite.optional) { + continue; + } + console.error(`No test files found in required suite: ${suite.dir}`); + process.exit(1); + } + + const run = spawnSync("node", ["--test", ...suiteFiles], { stdio: "inherit", - cwd: new URL("../..", import.meta.url) + cwd: repoRoot }); if (run.status !== 0) { process.exit(run.status ?? 1); diff --git a/typescript-sdk/scripts/unit-tests.mjs b/typescript-sdk/scripts/unit-tests.mjs index 1f2c509..46255f7 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,8 +237,8 @@ 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(builtRequest.actor === "sdk-test", "buildCommonsRequest sets actor"); +assert(!("x402" in builtRequest), "buildCommonsRequest omits legacy x402 envelope"); // Tampered receipt const tamperedReceipt = JSON.parse(JSON.stringify(receipt)); diff --git a/typescript-sdk/src/index.ts b/typescript-sdk/src/index.ts index ec80d4e..f0fcd31 100644 --- a/typescript-sdk/src/index.ts +++ b/typescript-sdk/src/index.ts @@ -77,14 +77,19 @@ export type RuntimeMetadata = { }; export type CommandResponse = { - receipt: CanonicalReceipt; + receipt: CanonicalReceipt; runtime_metadata?: RuntimeMetadata; }; -export type LegacyBlendedReceipt = CanonicalReceipt & { +export type LegacyBlendedReceipt = CanonicalReceipt & { trace?: RuntimeMetadata; }; +export type ReceiptProtocolMetadata = { + verb?: string; + version?: string; +}; + export type VerifyChecks = { hash_matches: boolean; signature_valid: boolean; @@ -129,13 +134,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; @@ -171,7 +175,7 @@ export function buildCommonsRequest>( ): 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 merged; } 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 @@ -283,6 +287,13 @@ export async function resolveSignerKey(name: string, rpcUrl: string): Promise(payload: unknown): CommandResponse { @@ -349,6 +360,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" && receiptId.length > 0; const receiptIdMatches = !receiptId || !claimedHash ? true : receiptId === claimedHash; let pubkey: Uint8Array | null = null; @@ -396,7 +408,7 @@ export async function verifyReceipt(receiptLike: CanonicalReceipt | CommandRespo }, values: { verb: getReceiptVerb(receipt), - signer_id, + signer_id: signerId, alg, canonical, claimed_hash: claimedHash,