From 8b042e51d6719fd677b53cb7aeac38f9732c9ed6 Mon Sep 17 00:00:00 2001 From: PhilBot <9mmnwvp6vs@privaterelay.appleid.com> Date: Wed, 7 Oct 2026 14:00:35 +0000 Subject: [PATCH] feat(python): add builder-code settlement metadata m Port the facilitator-authored ERC-8021 Schema 2 field from the TypeScript and Go SDKs. Python now encodes and parses m, emits a suffix from metadata alone, ignores client-supplied m, and rejects CBOR larger than 65,535 bytes. Co-authored-by: phdargen --- ...uilder-code-settlement-metadata.feature.md | 1 + python/x402/extensions/__init__.py | 8 + python/x402/extensions/builder_code/README.md | 8 +- .../x402/extensions/builder_code/__init__.py | 8 + python/x402/extensions/builder_code/cbor.py | 265 +++++++++++++----- .../extensions/builder_code/facilitator.py | 45 ++- python/x402/extensions/builder_code/types.py | 34 +++ python/x402/mechanisms/evm/data_suffix.py | 44 ++- .../unit/extensions/builder_code/test_cbor.py | 128 ++++++++- .../builder_code/test_facilitator.py | 89 +++++- 10 files changed, 523 insertions(+), 107 deletions(-) create mode 100644 python/x402/changelog.d/+builder-code-settlement-metadata.feature.md diff --git a/python/x402/changelog.d/+builder-code-settlement-metadata.feature.md b/python/x402/changelog.d/+builder-code-settlement-metadata.feature.md new file mode 100644 index 0000000000..4f581fd44c --- /dev/null +++ b/python/x402/changelog.d/+builder-code-settlement-metadata.feature.md @@ -0,0 +1 @@ +Added facilitator-authored settlement metadata (`m`) to the builder-code ERC-8021 Schema 2 suffix. `DataSuffixContext` accepts `metadata`, the CBOR encoder and parser handle `m`, and encoding fails when the CBOR exceeds 65,535 bytes. diff --git a/python/x402/extensions/__init__.py b/python/x402/extensions/__init__.py index 76c96148ce..b4f888bf66 100644 --- a/python/x402/extensions/__init__.py +++ b/python/x402/extensions/__init__.py @@ -51,6 +51,10 @@ BuilderCodeFacilitatorConfig, BuilderCodeFacilitatorExtension, BuilderCodeResourceServerExtension, + BuilderCodeSuffixData, + DataSuffixContext, + SettlementMetadata, + SettlementMetadataValue, builder_code_resource_server_extension, declare_builder_code_extension, encode_builder_code_suffix, @@ -191,6 +195,10 @@ # Builder Code types "BuilderCodeExtensionData", "BuilderCodeFacilitatorConfig", + "BuilderCodeSuffixData", + "DataSuffixContext", + "SettlementMetadata", + "SettlementMetadataValue", # Builder Code CBOR encoding "encode_builder_code_suffix", "parse_builder_code_suffix_from_calldata", diff --git a/python/x402/extensions/builder_code/README.md b/python/x402/extensions/builder_code/README.md index 7e09596c17..ec2c0426ee 100644 --- a/python/x402/extensions/builder_code/README.md +++ b/python/x402/extensions/builder_code/README.md @@ -61,7 +61,7 @@ facilitator.register_extension( ) ``` -At settlement the extension reads `a` and `s` from the client payment payload, adds its configured `w`, appends its own `service_code` to `s` (deduped), CBOR-encodes the present fields, and returns the hex suffix for the settlement mechanism to append to calldata. It returns `None` when no attribution is present. +At settlement the extension reads `a` and `s` from the client payment payload, adds its configured `w`, appends its own `service_code` to `s` (deduped), and encodes `m` when the settling mechanism supplies settlement metadata. Any `m` on the payload is ignored. It CBOR-encodes the present fields and returns the hex suffix for the settlement mechanism to append to calldata. It returns `None` when no attribution or metadata is present. Each side reserves its own slice of `s` (`MAX_CLIENT_SERVICE_CODES`, `MAX_SERVER_SERVICE_CODES`, `MAX_FACILITATOR_SERVICE_CODES`) so none can crowd out another; facilitators additionally truncate the echoed client+server codes to that combined budget as a defensive backstop against a malformed payload. @@ -74,7 +74,7 @@ from x402.extensions.builder_code import parse_builder_code_suffix_from_calldata data = parse_builder_code_suffix_from_calldata(calldata) if data: - # BuilderCodeExtensionData(a="bc_my_service", w="bc_my_facilitator", s=["bc_my_client"]) + # BuilderCodeSuffixData(a="bc_my_service", w="bc_my_facilitator", s=["bc_my_client"], m={"x402Example": 7}) ... ``` @@ -94,7 +94,7 @@ Client extension that attaches the client's service code(s) as `s`. Constructor ### `encode_builder_code_suffix(data)` / `parse_builder_code_suffix_from_calldata(calldata)` -Low-level CBOR helpers to encode a `BuilderCodeExtensionData` into an ERC-8021 suffix and to parse the suffix back out of settlement calldata. +Low-level CBOR helpers to encode a `BuilderCodeSuffixData` (or `BuilderCodeExtensionData`) into an ERC-8021 suffix and to parse the suffix back out of settlement calldata. Encoding fails when the CBOR exceeds 65,535 bytes. ### Constants and types @@ -105,7 +105,7 @@ Low-level CBOR helpers to encode a `BuilderCodeExtensionData` into an ERC-8021 s - `MAX_FACILITATOR_SERVICE_CODES` — `1` (facilitator's dedicated `s` reservation) - `MAX_SERVICE_CODES` — `11` (on-chain cap for `s`; the sum of each side's reservation) - `ERC_8021_MARKER`, `SCHEMA_2_ID`, `BUILDER_CODE_SCHEMA` -- Types: `BuilderCodeExtensionData`, `BuilderCodeFacilitatorConfig` +- Types: `BuilderCodeExtensionData`, `BuilderCodeSuffixData`, `BuilderCodeFacilitatorConfig`, `DataSuffixContext`, `SettlementMetadata` ## Related resources diff --git a/python/x402/extensions/builder_code/__init__.py b/python/x402/extensions/builder_code/__init__.py index eee75af6cd..27b2699722 100644 --- a/python/x402/extensions/builder_code/__init__.py +++ b/python/x402/extensions/builder_code/__init__.py @@ -61,6 +61,10 @@ SCHEMA_2_ID, BuilderCodeExtensionData, BuilderCodeFacilitatorConfig, + BuilderCodeSuffixData, + DataSuffixContext, + SettlementMetadata, + SettlementMetadataValue, ) __all__ = [ @@ -77,6 +81,10 @@ # Types "BuilderCodeExtensionData", "BuilderCodeFacilitatorConfig", + "BuilderCodeSuffixData", + "DataSuffixContext", + "SettlementMetadata", + "SettlementMetadataValue", # CBOR encoding "encode_builder_code_suffix", "parse_builder_code_suffix_from_calldata", diff --git a/python/x402/extensions/builder_code/cbor.py b/python/x402/extensions/builder_code/cbor.py index 373b4bce13..b65741c288 100644 --- a/python/x402/extensions/builder_code/cbor.py +++ b/python/x402/extensions/builder_code/cbor.py @@ -8,19 +8,74 @@ - ``a`` — app builder code (string) - ``w`` — wallet/facilitator builder code (string) - ``s`` — service codes (string array) +- ``m`` — facilitator-authored settlement metadata (map of unsigned integers, strings, arrays, and maps) Hand-rolled CBOR keeps the extension dependency-free (stdlib only). """ from __future__ import annotations -from .types import ERC_8021_MARKER, SCHEMA_2_ID, BuilderCodeExtensionData +from .types import ( + ERC_8021_MARKER, + SCHEMA_2_ID, + BuilderCodeExtensionData, + BuilderCodeSuffixData, + SettlementMetadata, + SettlementMetadataValue, +) # CBOR major types used by this encoder. +_MAJOR_UNSIGNED = 0 _MAJOR_TEXT_STRING = 3 _MAJOR_ARRAY = 4 _MAJOR_MAP = 5 +# suffix_data_length is 2 bytes, so the CBOR itself must fit in 65,535 bytes. +_MAX_CBOR_LENGTH = 0xFFFF + +_MAX_UINT64 = (1 << 64) - 1 + +_SuffixInput = BuilderCodeExtensionData | BuilderCodeSuffixData + + +class _CborDecodeError(Exception): + pass + + +class _CborCursor: + def __init__(self, data: bytes) -> None: + self.data = data + self.offset = 0 + + def _remaining(self) -> int: + return len(self.data) - self.offset + + def peek_major(self) -> int: + if self.offset >= len(self.data): + raise _CborDecodeError("Unexpected end of CBOR data") + return self.data[self.offset] >> 5 + + def read_argument(self) -> int: + self.peek_major() + info = self.data[self.offset] & 0x1F + self.offset += 1 + if info <= 23: + return info + if info > 27: + raise _CborDecodeError("Unsupported CBOR argument") + width = 1 << (info - 24) + if self.offset + width > len(self.data): + raise _CborDecodeError("Unsupported CBOR argument") + value = int.from_bytes(self.data[self.offset : self.offset + width], "big") + self.offset += width + return value + + def read_length(self) -> int: + length = self.read_argument() + if length > self._remaining(): + raise _CborDecodeError("CBOR length exceeds available data") + return length + def _normalize_service_codes(s: str | list[str] | None) -> list[str]: """Normalize the ``s`` field (string or list of strings) into a list.""" @@ -38,15 +93,21 @@ def _encode_major_type(major_type: int, value: int) -> bytes: - 0-23: single byte ``(major_type << 5) | value`` - 24-255: two bytes ``(major_type << 5) | 24``, value - 256-65535: three bytes ``(major_type << 5) | 25``, value (big-endian) + - 65536-2^32-1: five bytes ``(major_type << 5) | 26``, value (big-endian) + - 2^32-2^64-1: nine bytes ``(major_type << 5) | 27``, value (big-endian) """ + if value < 0 or value > _MAX_UINT64: + raise ValueError(f"CBOR value out of range: {value}") mt = major_type << 5 if value <= 23: return bytes([mt | value]) if value <= 0xFF: return bytes([mt | 24, value]) if value <= 0xFFFF: - return bytes([mt | 25, (value >> 8) & 0xFF, value & 0xFF]) - raise ValueError(f"CBOR value too large: {value}") + return bytes([mt | 25]) + value.to_bytes(2, "big") + if value <= 0xFFFFFFFF: + return bytes([mt | 26]) + value.to_bytes(4, "big") + return bytes([mt | 27]) + value.to_bytes(8, "big") def _encode_string(value: str) -> bytes: @@ -63,8 +124,52 @@ def _encode_array(values: list[str]) -> bytes: return result -def _encode_cbor_map(data: BuilderCodeExtensionData) -> bytes: - """Encode a minimal CBOR map from builder code data, in ``a``, ``w``, ``s`` order.""" +def _metadata_type_name(value: object) -> str: + if value is None: + return "null" + return type(value).__name__ + + +def _encode_cbor_items(items: list[object] | tuple[object, ...]) -> bytes: + result = _encode_major_type(_MAJOR_ARRAY, len(items)) + for item in items: + result += _encode_cbor_value(item) + return result + + +def _encode_cbor_value_map(entries: dict[object, object]) -> bytes: + encoded: list[tuple[bytes, bytes]] = [] + for key, item in entries.items(): + if not isinstance(key, str): + raise ValueError(f"Unsupported CBOR metadata value: {_metadata_type_name(key)}") + key_bytes = _encode_string(key) + encoded.append((key_bytes, _encode_cbor_value(item))) + encoded.sort(key=lambda pair: pair[0]) + + result = _encode_major_type(_MAJOR_MAP, len(encoded)) + for key_bytes, value_bytes in encoded: + result += key_bytes + value_bytes + return result + + +def _encode_cbor_value(value: object) -> bytes: + if isinstance(value, bool) or value is None: + raise ValueError(f"Unsupported CBOR metadata value: {_metadata_type_name(value)}") + if isinstance(value, str): + return _encode_string(value) + if isinstance(value, int): + if value < 0 or value > _MAX_UINT64: + raise ValueError(f"CBOR value out of range: {value}") + return _encode_major_type(_MAJOR_UNSIGNED, value) + if isinstance(value, dict): + return _encode_cbor_value_map(value) + if isinstance(value, (list, tuple)): + return _encode_cbor_items(value) + raise ValueError(f"Unsupported CBOR metadata value: {_metadata_type_name(value)}") + + +def _encode_cbor_map(data: _SuffixInput) -> bytes: + """Encode a minimal CBOR map, emitting present fields in ``a``, ``w``, ``s``, ``m`` order.""" entries = bytearray() map_size = 0 @@ -84,23 +189,40 @@ def _encode_cbor_map(data: BuilderCodeExtensionData) -> bytes: entries += _encode_string("s") entries += _encode_array(service_codes) + metadata = data.m if isinstance(data, BuilderCodeSuffixData) else None + if metadata: + if not isinstance(metadata, dict): + raise ValueError(f"Unsupported CBOR metadata value: {_metadata_type_name(metadata)}") + map_size += 1 + entries += _encode_string("m") + entries += _encode_cbor_value(metadata) + return _encode_major_type(_MAJOR_MAP, map_size) + bytes(entries) -def encode_builder_code_suffix(data: BuilderCodeExtensionData) -> str: +def encode_builder_code_suffix(data: _SuffixInput) -> str: """Build a complete ERC-8021 Schema 2 data suffix from builder code data. Format: ``[cbor_data][suffix_data_length (2 bytes)][schema_id (1 byte)][marker (16 bytes)]``. ``suffix_data_length`` covers the CBOR data only. Args: - data: Builder code fields to encode. + data: Builder code fields to encode. ``m`` is read from + ``BuilderCodeSuffixData``. Returns: Hex-encoded suffix (with ``0x`` prefix) ready to append to calldata. + + Raises: + ValueError: The CBOR data exceeds 65,535 bytes, or ``m`` contains a value + this encoder does not allow. """ cbor_bytes = _encode_cbor_map(data) cbor_length = len(cbor_bytes) + if cbor_length > _MAX_CBOR_LENGTH: + raise ValueError( + f"Builder code CBOR data is {cbor_length} bytes, maximum is {_MAX_CBOR_LENGTH}" + ) suffix = ( cbor_bytes @@ -111,19 +233,74 @@ def encode_builder_code_suffix(data: BuilderCodeExtensionData) -> str: return "0x" + suffix.hex() -def _read_length(byte: int, data: bytes, offset: int) -> tuple[int | None, int]: - """Read a CBOR argument that is either inline (<=23) or a single following byte (24).""" - info = byte & 0x1F - if info <= 23: - return info, offset - if info == 24: - return data[offset], offset + 1 - return None, offset +def _read_text(cursor: _CborCursor) -> str: + if cursor.peek_major() != _MAJOR_TEXT_STRING: + raise _CborDecodeError("Expected CBOR text string") + length = cursor.read_length() + raw = cursor.data[cursor.offset : cursor.offset + length] + cursor.offset += length + return raw.decode("utf-8") + + +def _read_map(cursor: _CborCursor) -> SettlementMetadata: + if cursor.peek_major() != _MAJOR_MAP: + raise _CborDecodeError("Expected CBOR map") + size = cursor.read_length() + entries: dict[str, SettlementMetadataValue] = {} + for _ in range(size): + key = _read_text(cursor) + entries[key] = _read_value(cursor) + return entries + + +def _read_value(cursor: _CborCursor) -> SettlementMetadataValue: + major = cursor.peek_major() + if major == _MAJOR_UNSIGNED: + return cursor.read_argument() + if major == _MAJOR_TEXT_STRING: + return _read_text(cursor) + if major == _MAJOR_ARRAY: + size = cursor.read_length() + return [_read_value(cursor) for _ in range(size)] + if major == _MAJOR_MAP: + return _read_map(cursor) + raise _CborDecodeError("Unsupported CBOR type in metadata") + + +def _parse_cbor_map(data: bytes) -> BuilderCodeSuffixData: + cursor = _CborCursor(data) + if cursor.peek_major() != _MAJOR_MAP: + raise _CborDecodeError("Expected CBOR map") + + map_size = cursor.read_length() + result = BuilderCodeSuffixData() + for _ in range(map_size): + key = _read_text(cursor) + if key in ("a", "w"): + value = _read_text(cursor) + if key == "a": + result.a = value + else: + result.w = value + continue + if key == "s": + if cursor.peek_major() != _MAJOR_ARRAY: + raise _CborDecodeError("Expected CBOR array") + array_size = cursor.read_length() + codes = [_read_text(cursor) for _ in range(array_size)] + if codes: + result.s = codes + continue + if key == "m": + result.m = _read_map(cursor) + continue + raise _CborDecodeError(f"Unknown builder-code key: {key}") + return result def parse_builder_code_suffix_from_calldata( calldata: str, -) -> BuilderCodeExtensionData | None: +) -> BuilderCodeSuffixData | None: """Parse ERC-8021 Schema 2 builder code attribution from settlement calldata. Args: @@ -146,56 +323,8 @@ def parse_builder_code_suffix_from_calldata( if suffix_start < 0 or suffix_start + (cbor_length + 19) * 2 != len(hex_str): return None - data = bytes.fromhex(hex_str[suffix_start : marker_pos - 6]) - offset = 0 - - if data[offset] >> 5 != _MAJOR_MAP: - return None - - map_size, offset = _read_length(data[offset], data, offset + 1) - if map_size is None: - return None - - result = BuilderCodeExtensionData() - for _ in range(map_size): - if data[offset] >> 5 != _MAJOR_TEXT_STRING: - return None - key_len, offset = _read_length(data[offset], data, offset + 1) - if key_len is None: - return None - key = data[offset : offset + key_len].decode("utf-8") - offset += key_len - - if key in ("a", "w"): - if data[offset] >> 5 != _MAJOR_TEXT_STRING: - return None - value_len, offset = _read_length(data[offset], data, offset + 1) - if value_len is None: - return None - value = data[offset : offset + value_len].decode("utf-8") - offset += value_len - setattr(result, key, value) - continue - - if key == "s": - if data[offset] >> 5 != _MAJOR_ARRAY: - return None - array_size, offset = _read_length(data[offset], data, offset + 1) - if array_size is None: - return None - codes: list[str] = [] - for _ in range(array_size): - if data[offset] >> 5 != _MAJOR_TEXT_STRING: - return None - item_len, offset = _read_length(data[offset], data, offset + 1) - if item_len is None: - return None - codes.append(data[offset : offset + item_len].decode("utf-8")) - offset += item_len - if codes: - result.s = codes - continue - + try: + data = bytes.fromhex(hex_str[suffix_start : marker_pos - 6]) + return _parse_cbor_map(data) + except (_CborDecodeError, UnicodeDecodeError, ValueError): return None - - return result diff --git a/python/x402/extensions/builder_code/facilitator.py b/python/x402/extensions/builder_code/facilitator.py index 6742e9dd0f..d5fd15271e 100644 --- a/python/x402/extensions/builder_code/facilitator.py +++ b/python/x402/extensions/builder_code/facilitator.py @@ -3,7 +3,8 @@ At settlement time, the facilitator encodes its wallet code (``w``) into the ERC-8021 suffix when configured. App code (``a``) and service code(s) (``s``) are read from the client payment payload extensions, and the facilitator's own -service code may be appended to ``s`` when configured. +service code may be appended to ``s`` when configured. Settlement metadata +(``m``) comes from the settling mechanism and is never read from the payload. """ from __future__ import annotations @@ -19,7 +20,9 @@ BUILDER_CODE_PATTERN, MAX_CLIENT_SERVICE_CODES, MAX_SERVER_SERVICE_CODES, - BuilderCodeExtensionData, + BuilderCodeSuffixData, + DataSuffixContext, + SettlementMetadata, ) # Maximum echoed client+server service codes, before the facilitator's own @@ -86,8 +89,9 @@ def __post_init__(self) -> None: def build_data_suffix( self, - payload: PaymentPayload, - requirements: PaymentRequirements, + payload: PaymentPayload | DataSuffixContext, + requirements: PaymentRequirements | None = None, + metadata: SettlementMetadata | None = None, ) -> str | None: """Build the ERC-8021 Schema 2 calldata suffix for a settlement transaction. @@ -96,23 +100,39 @@ def build_data_suffix( is still read. ``w`` is the facilitator's own code when configured. The facilitator's own ``s`` entry (``service_code``) is appended after the echoed client/server codes, within its own ``MAX_FACILITATOR_SERVICE_CODES`` - reservation. + reservation. ``m`` is ``metadata`` from the settling mechanism, or + ``DataSuffixContext.metadata`` when ``payload`` is a context. It is never + read from the client payload. Args: - payload: The payment payload being settled. + payload: The payment payload being settled, or a ``DataSuffixContext``. requirements: The matched payment requirements (unused; attribution comes - from the client payload echo). + from the client payload echo). Required when ``payload`` is not a + ``DataSuffixContext``. + metadata: Facilitator-authored settlement metadata encoded as ``m``. + When ``payload`` is a ``DataSuffixContext`` and this argument is + omitted, ``DataSuffixContext.metadata`` is used. Returns: Hex-encoded ERC-8021 builder-code calldata suffix, or ``None`` when no - attribution is present. + attribution or metadata is present. """ - info = _extract_client_info(payload.extensions) + if isinstance(payload, DataSuffixContext): + payment = payload.payload + requirements = payload.requirements + if metadata is None: + metadata = payload.metadata + else: + payment = payload + if requirements is None: + raise TypeError("requirements is required") + + info = _extract_client_info(payment.extensions) raw_a = info.get("a") if info else None # v1 payloads omit `a`: the resource-server echo gate does not run on v1. a = ( raw_a - if getattr(payload, "x402_version", None) == 2 + if getattr(payment, "x402_version", None) == 2 and isinstance(raw_a, str) and BUILDER_CODE_PATTERN.match(raw_a) else None @@ -124,8 +144,9 @@ def build_data_suffix( else echoed_service_codes ) - data = BuilderCodeExtensionData(a=a, w=self.builder_code, s=s or None) - if not data.a and not data.w and not data.s: + suffix_metadata = metadata if metadata else None + data = BuilderCodeSuffixData(a=a, w=self.builder_code, s=s or None, m=suffix_metadata) + if not data.a and not data.w and not data.s and not data.m: return None return encode_builder_code_suffix(data) diff --git a/python/x402/extensions/builder_code/types.py b/python/x402/extensions/builder_code/types.py index c58df777b8..8be77031d5 100644 --- a/python/x402/extensions/builder_code/types.py +++ b/python/x402/extensions/builder_code/types.py @@ -9,6 +9,8 @@ import re from dataclasses import dataclass +from ...schemas import PaymentPayload, PaymentRequirements + # Extension identifier constant BUILDER_CODE = "builder-code" @@ -60,6 +62,38 @@ class BuilderCodeExtensionData: s: str | list[str] | None = None +SettlementMetadataValue = ( + int | str | list["SettlementMetadataValue"] | dict[str, "SettlementMetadataValue"] +) +SettlementMetadata = dict[str, SettlementMetadataValue] + + +@dataclass +class BuilderCodeSuffixData: + """Fields present in a settlement calldata suffix. + + ``m`` is not part of ``PaymentRequired`` or ``PaymentPayload``. + """ + + a: str | None = None + w: str | None = None + s: str | list[str] | None = None + m: SettlementMetadata | None = None + + +@dataclass +class DataSuffixContext: + """Settlement inputs the builder-code facilitator reads when building a suffix. + + ``metadata`` is supplied by the settling mechanism and encoded as ``m``. + Any ``m`` on the payment payload is ignored. + """ + + payload: PaymentPayload + requirements: PaymentRequirements + metadata: SettlementMetadata | None = None + + @dataclass class BuilderCodeFacilitatorConfig: """Configuration for the builder code facilitator extension. diff --git a/python/x402/mechanisms/evm/data_suffix.py b/python/x402/mechanisms/evm/data_suffix.py index edbb834f8a..96d656bc0d 100644 --- a/python/x402/mechanisms/evm/data_suffix.py +++ b/python/x402/mechanisms/evm/data_suffix.py @@ -7,6 +7,7 @@ from __future__ import annotations +from dataclasses import dataclass from typing import TYPE_CHECKING, Any if TYPE_CHECKING: @@ -16,21 +17,39 @@ BUILDER_CODE_KEY = "builder-code" +@dataclass +class DataSuffixContext: + """Settlement payload, requirements, and optional facilitator metadata. + + ``metadata`` is encoded as the ERC-8021 Schema 2 ``m`` field when the + registered extension accepts it. Mechanisms that have no metadata leave it + unset. + """ + + payload: PaymentPayload + requirements: PaymentRequirements + metadata: dict[str, Any] | None = None + + def _is_empty_suffix(suffix: str | None) -> bool: return not suffix or suffix == "0x" or len(suffix) <= 2 def resolve_data_suffix( context: FacilitatorContext | None, - payload: PaymentPayload, - requirements: PaymentRequirements, + payload: PaymentPayload | DataSuffixContext, + requirements: PaymentRequirements | None = None, + metadata: dict[str, Any] | None = None, ) -> str | None: """Resolve the builder-code data suffix from the registered extension, if any. Args: context: Facilitator context used to look up registered extensions. - payload: The payment payload being settled. - requirements: The matched payment requirements. + payload: The payment payload being settled, or a ``DataSuffixContext``. + requirements: The matched payment requirements. Required when ``payload`` + is not a ``DataSuffixContext``. + metadata: Facilitator-authored settlement metadata forwarded to the + extension as ``m``. A ``DataSuffixContext`` can carry it instead. Returns: The hex-encoded suffix, or ``None`` when no extension contributes one. @@ -38,6 +57,18 @@ def resolve_data_suffix( if context is None: return None + settled_metadata = metadata + if isinstance(payload, DataSuffixContext): + settled_payload = payload.payload + settled_requirements = payload.requirements + if settled_metadata is None: + settled_metadata = payload.metadata + elif requirements is None: + raise TypeError("requirements is required") + else: + settled_payload = payload + settled_requirements = requirements + extension: Any = context.get_extension(BUILDER_CODE_KEY) if extension is None: return None @@ -46,7 +77,10 @@ def resolve_data_suffix( if build_data_suffix is None: return None - suffix = build_data_suffix(payload, requirements) + if settled_metadata is None: + suffix = build_data_suffix(settled_payload, settled_requirements) + else: + suffix = build_data_suffix(settled_payload, settled_requirements, settled_metadata) if _is_empty_suffix(suffix): return None return suffix diff --git a/python/x402/tests/unit/extensions/builder_code/test_cbor.py b/python/x402/tests/unit/extensions/builder_code/test_cbor.py index 8d90f9709f..bc5eb99deb 100644 --- a/python/x402/tests/unit/extensions/builder_code/test_cbor.py +++ b/python/x402/tests/unit/extensions/builder_code/test_cbor.py @@ -1,7 +1,12 @@ """Tests for builder-code ERC-8021 Schema 2 CBOR encoding and parsing.""" +from datetime import datetime + +import pytest + from x402.extensions.builder_code import ( BuilderCodeExtensionData, + BuilderCodeSuffixData, encode_builder_code_suffix, parse_builder_code_suffix_from_calldata, ) @@ -41,24 +46,24 @@ class TestRoundTrip: def test_all_fields(self) -> None: suffix = encode_builder_code_suffix(BuilderCodeExtensionData(a=APP, w=WALLET, s=SERVICE)) parsed = parse_builder_code_suffix_from_calldata(f"0xdeadbeef{suffix[2:]}") - assert parsed == BuilderCodeExtensionData(a=APP, w=WALLET, s=[SERVICE]) + assert parsed == BuilderCodeSuffixData(a=APP, w=WALLET, s=[SERVICE]) def test_single_service_code_normalized_to_list(self) -> None: suffix = encode_builder_code_suffix(BuilderCodeExtensionData(s=SERVICE)) parsed = parse_builder_code_suffix_from_calldata(f"0xdeadbeef{suffix[2:]}") - assert parsed == BuilderCodeExtensionData(s=[SERVICE]) + assert parsed == BuilderCodeSuffixData(s=[SERVICE]) def test_multiple_service_codes(self) -> None: suffix = encode_builder_code_suffix( BuilderCodeExtensionData(a=APP, w=WALLET, s=[SERVICE, "bc_other"]) ) parsed = parse_builder_code_suffix_from_calldata(f"0xdeadbeef{suffix[2:]}") - assert parsed == BuilderCodeExtensionData(a=APP, w=WALLET, s=[SERVICE, "bc_other"]) + assert parsed == BuilderCodeSuffixData(a=APP, w=WALLET, s=[SERVICE, "bc_other"]) def test_no_prefix_calldata(self) -> None: suffix = encode_builder_code_suffix(BuilderCodeExtensionData(a=APP)) parsed = parse_builder_code_suffix_from_calldata(f"deadbeef{suffix[2:]}") - assert parsed == BuilderCodeExtensionData(a=APP) + assert parsed == BuilderCodeSuffixData(a=APP) class TestParseGuards: @@ -69,3 +74,118 @@ def test_no_marker(self) -> None: def test_empty(self) -> None: assert parse_builder_code_suffix_from_calldata("0x") is None + + +def _calldata_with_cbor(cbor_hex: str) -> str: + length = f"{len(cbor_hex) // 2:04x}" + return f"0xdeadbeef{cbor_hex}{length}02{MARKER}" + + +def _round_trip(data: BuilderCodeSuffixData) -> BuilderCodeSuffixData | None: + suffix = encode_builder_code_suffix(data) + return parse_builder_code_suffix_from_calldata(f"0xdeadbeef{suffix[2:]}") + + +class TestSettlementMetadata: + def test_matches_spec_vector(self) -> None: + suffix = encode_builder_code_suffix( + BuilderCodeSuffixData( + a="bc_myapp", + w="bc_myfacilitator", + m={"x402Example": 7}, + ) + ) + assert suffix == ( + "0xa361616862635f6d7961707061777062635f6d79666163696c697461746f72" + "616da16b783430324578616d706c6507002f0280218021802180218021802180218021" + ) + + def test_round_trips_nested_maps_arrays_text_and_uint_boundaries(self) -> None: + metadata = { + "zero": 0, + "inline": 23, + "oneByte": 24, + "twoBytes": 2**16, + "fourBytes": 2**32, + "eightBytes": 2**63 + 5, + "maxUint64": 2**64 - 1, + "text": "hello", + "list": [1, "two", [3], {"four": 4}], + "nested": {"inner": {"deep": 1}}, + } + parsed = _round_trip(BuilderCodeSuffixData(a=APP, w=WALLET, s=SERVICE, m=metadata)) + assert parsed is not None + assert parsed.m == { + "zero": 0, + "inline": 23, + "oneByte": 24, + "twoBytes": 65536, + "fourBytes": 2**32, + "eightBytes": 2**63 + 5, + "maxUint64": 2**64 - 1, + "text": "hello", + "list": [1, "two", [3], {"four": 4}], + "nested": {"inner": {"deep": 1}}, + } + + def test_sorts_map_keys_bytewise_by_encoded_form(self) -> None: + forward = encode_builder_code_suffix(BuilderCodeSuffixData(m={"aa": 1, "b": 2, "c": 3})) + reversed_keys = encode_builder_code_suffix( + BuilderCodeSuffixData(m={"c": 3, "b": 2, "aa": 1}) + ) + assert forward == reversed_keys + sorted_keys = "a361620261630362616101" + assert sorted_keys in forward + + def test_rejects_metadata_that_does_not_fit_in_65535_bytes(self) -> None: + with pytest.raises(ValueError, match="maximum is 65535"): + encode_builder_code_suffix(BuilderCodeSuffixData(m={"big": "x" * 0x10000})) + + def test_rejects_unsupported_numeric_metadata(self) -> None: + with pytest.raises(ValueError): + encode_builder_code_suffix(BuilderCodeSuffixData(m={"negative": -1})) + with pytest.raises(ValueError, match="Unsupported CBOR"): + encode_builder_code_suffix(BuilderCodeSuffixData(m={"float": 1.5})) + with pytest.raises(ValueError, match="out of range"): + encode_builder_code_suffix(BuilderCodeSuffixData(m={"tooLarge": 2**64})) + + @pytest.mark.parametrize( + "bad", + [ + True, + False, + None, + b"\x01\x02", + bytearray(b"\x01"), + datetime.fromtimestamp(0), + pytest.param(lambda: 1, id="function"), + {1: 1}, + pytest.param(object(), id="instance"), + ], + ) + def test_rejects_unsupported_metadata_values(self, bad: object) -> None: + with pytest.raises(ValueError, match="Unsupported CBOR"): + encode_builder_code_suffix(BuilderCodeSuffixData(m={"bad": bad})) + with pytest.raises(ValueError, match="Unsupported CBOR"): + encode_builder_code_suffix(BuilderCodeSuffixData(m={"nested": {"list": [bad]}})) + + def test_rejects_unsupported_metadata_at_the_top_level_of_m(self) -> None: + with pytest.raises(ValueError, match="Unsupported CBOR"): + encode_builder_code_suffix(BuilderCodeSuffixData(m=True)) + + @pytest.mark.parametrize( + ("name", "cbor_hex"), + [ + ("negative integer", "a1616d" + "a1616b20"), + ("byte string", "a1616d" + "a1616b4100"), + ("float", "a1616d" + "a1616bf90000"), + ("tag", "a1616d" + "a1616bc101"), + ("boolean", "a1616d" + "a1616bf5"), + ("indefinite-length array", "a1616d" + "a1616b9fff"), + ("non-text map key", "a1616d" + "a10100"), + ("truncated value", "a1616d" + "a1616b1b00"), + ("m that is not a map", "a1616d" + "01"), + ], + ) + def test_parser_rejects_unsupported_metadata(self, name: str, cbor_hex: str) -> None: + assert parse_builder_code_suffix_from_calldata(_calldata_with_cbor(cbor_hex)) is None, name diff --git a/python/x402/tests/unit/extensions/builder_code/test_facilitator.py b/python/x402/tests/unit/extensions/builder_code/test_facilitator.py index 07a4d66f7a..1bbbad5787 100644 --- a/python/x402/tests/unit/extensions/builder_code/test_facilitator.py +++ b/python/x402/tests/unit/extensions/builder_code/test_facilitator.py @@ -6,10 +6,14 @@ BUILDER_CODE, MAX_CLIENT_SERVICE_CODES, MAX_SERVER_SERVICE_CODES, - BuilderCodeExtensionData, BuilderCodeFacilitatorExtension, + BuilderCodeSuffixData, + DataSuffixContext, parse_builder_code_suffix_from_calldata, ) +from x402.interfaces import FacilitatorContext +from x402.mechanisms.evm.data_suffix import DataSuffixContext as EvmDataSuffixContext +from x402.mechanisms.evm.data_suffix import resolve_data_suffix from x402.schemas import PaymentPayload, PaymentRequirements APP = "bc_my_app" @@ -52,12 +56,12 @@ def test_default_key(self) -> None: class TestBuildDataSuffix: def test_encodes_wallet_code_only(self) -> None: ext = BuilderCodeFacilitatorExtension(builder_code=WALLET) - assert _parse(ext, _payload()) == BuilderCodeExtensionData(w=WALLET) + assert _parse(ext, _payload()) == BuilderCodeSuffixData(w=WALLET) def test_wallet_code_optional(self) -> None: ext = BuilderCodeFacilitatorExtension() payload = _payload({BUILDER_CODE: {"info": {"a": APP, "s": SERVICE}, "schema": {}}}) - assert _parse(ext, payload) == BuilderCodeExtensionData(a=APP, s=[SERVICE]) + assert _parse(ext, payload) == BuilderCodeSuffixData(a=APP, s=[SERVICE]) def test_none_when_no_attribution(self) -> None: ext = BuilderCodeFacilitatorExtension() @@ -66,20 +70,20 @@ def test_none_when_no_attribution(self) -> None: def test_reads_client_app_and_service(self) -> None: ext = BuilderCodeFacilitatorExtension(builder_code=WALLET) payload = _payload({BUILDER_CODE: {"info": {"a": APP, "s": SERVICE}, "schema": {}}}) - assert _parse(ext, payload) == BuilderCodeExtensionData(a=APP, w=WALLET, s=[SERVICE]) + assert _parse(ext, payload) == BuilderCodeSuffixData(a=APP, w=WALLET, s=[SERVICE]) def test_keeps_valid_service_entries_drops_invalid(self) -> None: ext = BuilderCodeFacilitatorExtension(builder_code=WALLET) payload = _payload( {BUILDER_CODE: {"info": {"s": ["INVALID", SERVICE, "bc_other"]}, "schema": {}}} ) - assert _parse(ext, payload) == BuilderCodeExtensionData(w=WALLET, s=[SERVICE, "bc_other"]) + assert _parse(ext, payload) == BuilderCodeSuffixData(w=WALLET, s=[SERVICE, "bc_other"]) def test_truncates_echoed_service_codes_to_client_plus_server_budget(self) -> None: ext = BuilderCodeFacilitatorExtension(builder_code=WALLET) codes = [f"bc_{i}" for i in range(1, 12)] payload = _payload({BUILDER_CODE: {"info": {"s": codes}, "schema": {}}}) - assert _parse(ext, payload) == BuilderCodeExtensionData( + assert _parse(ext, payload) == BuilderCodeSuffixData( w=WALLET, s=[f"bc_{i}" for i in range(1, 11)] ) @@ -93,7 +97,7 @@ def test_filters_invalid_before_truncating_to_echoed_budget(self) -> None: } } ) - assert _parse(ext, payload) == BuilderCodeExtensionData( + assert _parse(ext, payload) == BuilderCodeSuffixData( w=WALLET, s=[f"bc_{i}" for i in range(1, 11)] ) @@ -111,19 +115,19 @@ def test_does_not_drop_entries_when_client_and_server_each_use_full_reservation( payload = _payload( {BUILDER_CODE: {"info": {"s": client_codes + server_codes}, "schema": {}}} ) - assert _parse(ext, payload) == BuilderCodeExtensionData( + assert _parse(ext, payload) == BuilderCodeSuffixData( w=WALLET, s=client_codes + server_codes ) def test_ignores_invalid_client_service_string(self) -> None: ext = BuilderCodeFacilitatorExtension(builder_code=WALLET) payload = _payload({BUILDER_CODE: {"info": {"s": "Also_Invalid"}, "schema": {}}}) - assert _parse(ext, payload) == BuilderCodeExtensionData(w=WALLET) + assert _parse(ext, payload) == BuilderCodeSuffixData(w=WALLET) def test_ignores_invalid_client_app_code(self) -> None: ext = BuilderCodeFacilitatorExtension(builder_code=WALLET) payload = _payload({BUILDER_CODE: {"info": {"a": "Bad-App"}, "schema": {}}}) - assert _parse(ext, payload) == BuilderCodeExtensionData(w=WALLET) + assert _parse(ext, payload) == BuilderCodeSuffixData(w=WALLET) def test_drops_client_app_code_on_v1_payloads_but_still_encodes_service_codes(self) -> None: ext = BuilderCodeFacilitatorExtension(builder_code=WALLET, service_code="bc_fac") @@ -133,21 +137,78 @@ def test_drops_client_app_code_on_v1_payloads_but_still_encodes_service_codes(se accepted=_REQUIREMENTS, extensions={BUILDER_CODE: {"info": {"a": APP, "s": SERVICE}, "schema": {}}}, ) - assert _parse(ext, payload) == BuilderCodeExtensionData(w=WALLET, s=[SERVICE, "bc_fac"]) + assert _parse(ext, payload) == BuilderCodeSuffixData(w=WALLET, s=[SERVICE, "bc_fac"]) def test_encodes_service_codes_on_v2_payloads_when_app_code_is_absent(self) -> None: ext = BuilderCodeFacilitatorExtension(builder_code=WALLET) payload = _payload({BUILDER_CODE: {"info": {"s": SERVICE}, "schema": {}}}) - assert _parse(ext, payload) == BuilderCodeExtensionData(w=WALLET, s=[SERVICE]) + assert _parse(ext, payload) == BuilderCodeSuffixData(w=WALLET, s=[SERVICE]) class TestFacilitatorServiceCode: def test_appends_facilitator_service_code_after_echoed_codes(self) -> None: ext = BuilderCodeFacilitatorExtension(builder_code=WALLET, service_code="bc_fac") payload = _payload({BUILDER_CODE: {"info": {"s": [SERVICE]}, "schema": {}}}) - assert _parse(ext, payload) == BuilderCodeExtensionData(w=WALLET, s=[SERVICE, "bc_fac"]) + assert _parse(ext, payload) == BuilderCodeSuffixData(w=WALLET, s=[SERVICE, "bc_fac"]) def test_does_not_duplicate_facilitator_service_code_when_already_echoed(self) -> None: ext = BuilderCodeFacilitatorExtension(builder_code=WALLET, service_code=SERVICE) payload = _payload({BUILDER_CODE: {"info": {"s": [SERVICE]}, "schema": {}}}) - assert _parse(ext, payload) == BuilderCodeExtensionData(w=WALLET, s=[SERVICE]) + assert _parse(ext, payload) == BuilderCodeSuffixData(w=WALLET, s=[SERVICE]) + + +class TestSettlementMetadata: + def test_builds_a_suffix_from_metadata_alone(self) -> None: + ext = BuilderCodeFacilitatorExtension() + suffix = ext.build_data_suffix(_payload(), _REQUIREMENTS, {"x402Example": 7}) + assert suffix is not None + parsed = parse_builder_code_suffix_from_calldata(f"0xdeadbeef{suffix[2:]}") + assert parsed == BuilderCodeSuffixData(m={"x402Example": 7}) + + def test_builds_a_suffix_from_context_metadata_alone(self) -> None: + ext = BuilderCodeFacilitatorExtension() + suffix = ext.build_data_suffix( + DataSuffixContext( + payload=_payload(), + requirements=_REQUIREMENTS, + metadata={"x402Example": 7}, + ) + ) + assert suffix is not None + parsed = parse_builder_code_suffix_from_calldata(f"0xdeadbeef{suffix[2:]}") + assert parsed == BuilderCodeSuffixData(m={"x402Example": 7}) + + def test_emits_no_suffix_for_empty_metadata_and_no_attribution(self) -> None: + ext = BuilderCodeFacilitatorExtension() + assert ext.build_data_suffix(_payload(), _REQUIREMENTS, {}) is None + assert ( + ext.build_data_suffix( + DataSuffixContext(payload=_payload(), requirements=_REQUIREMENTS, metadata={}) + ) + is None + ) + + def test_ignores_m_supplied_in_the_client_payload(self) -> None: + ext = BuilderCodeFacilitatorExtension(builder_code=WALLET) + payload = _payload( + {BUILDER_CODE: {"info": {"a": APP, "m": {"x402Example": 1}}, "schema": {}}} + ) + assert _parse(ext, payload) == BuilderCodeSuffixData(a=APP, w=WALLET) + + def test_resolve_data_suffix_forwards_metadata(self) -> None: + ext = BuilderCodeFacilitatorExtension() + context = FacilitatorContext({BUILDER_CODE: ext}) + suffix = resolve_data_suffix(context, _payload(), _REQUIREMENTS, {"x402Example": 7}) + assert suffix is not None + parsed = parse_builder_code_suffix_from_calldata(f"0xdeadbeef{suffix[2:]}") + assert parsed == BuilderCodeSuffixData(m={"x402Example": 7}) + + from_context = resolve_data_suffix( + context, + EvmDataSuffixContext( + payload=_payload(), + requirements=_REQUIREMENTS, + metadata={"x402Example": 7}, + ), + ) + assert from_context == suffix