diff --git a/python/x402/changelog.d/+batch-settlement-foundation.feature.md b/python/x402/changelog.d/+batch-settlement-foundation.feature.md new file mode 100644 index 0000000000..c15fdbf403 --- /dev/null +++ b/python/x402/changelog.d/+batch-settlement-foundation.feature.md @@ -0,0 +1 @@ +Add the foundation for the Python port of the batch-settlement payment scheme: contract addresses, EIP-712 type definitions, and ABI declarations, mirroring the TypeScript SDK introduced in [#2061](https://github.com/x402-foundation/x402/pull/2061). Types, encoding, signer protocol, utilities, and EIP-712 cross-language fixtures land in subsequent stacked PRs. diff --git a/python/x402/mechanisms/evm/batch_settlement/__init__.py b/python/x402/mechanisms/evm/batch_settlement/__init__.py new file mode 100644 index 0000000000..402f0e386c --- /dev/null +++ b/python/x402/mechanisms/evm/batch_settlement/__init__.py @@ -0,0 +1,111 @@ +"""x402 batch-settlement mechanism (Python port). + +Mirrors ``typescript/packages/mechanisms/evm/src/batch-settlement/``. + +This package is being introduced incrementally; PR1 lands the foundation +(constants, ABI, types, encoding helpers, signer protocol). Client, +facilitator, server, storage, and recovery follow in stacked PRs (see +``docs/x402/batch-settlement/plan.md`` in the project tracker). +""" + +from .abi import ( + batch_settlement_abi, + channel_config_components, + erc20_balance_of_abi, +) +from .constants import ( + BATCH_SETTLEMENT_ADDRESS, + BATCH_SETTLEMENT_DOMAIN, + BATCH_SETTLEMENT_SCHEME, + CHANNEL_CONFIG_TYPEHASH, + ERC3009_DEPOSIT_COLLECTOR_ADDRESS, + MAX_WITHDRAW_DELAY, + MIN_WITHDRAW_DELAY, + PERMIT2_DEPOSIT_COLLECTOR_ADDRESS, + batch_permit2_witness_types, + channel_config_types, + claim_batch_types, + receive_authorization_types, + refund_types, + voucher_types, +) +from .types import ( + BatchSettlementAssetTransferMethod, + BatchSettlementChannelStateExtra, + BatchSettlementClaimPayload, + BatchSettlementDepositAuthorization, + BatchSettlementDepositPayload, + BatchSettlementEnrichedRefundPayload, + BatchSettlementErc3009Authorization, + BatchSettlementFacilitatorSettlePayload, + BatchSettlementPayload, + BatchSettlementPaymentRequirementsExtra, + BatchSettlementPaymentResponseExtra, + BatchSettlementPermit2Authorization, + BatchSettlementPermit2Permitted, + BatchSettlementPermit2Witness, + BatchSettlementRefundPayload, + BatchSettlementSettlePayload, + BatchSettlementVoucherClaim, + BatchSettlementVoucherFields, + BatchSettlementVoucherPayload, + BatchSettlementVoucherStateExtra, + ChannelConfig, + ChannelState, + parse_facilitator_payload, + parse_payload, +) + +__all__ = [ + # Constants + "BATCH_SETTLEMENT_SCHEME", + "BATCH_SETTLEMENT_ADDRESS", + "ERC3009_DEPOSIT_COLLECTOR_ADDRESS", + "PERMIT2_DEPOSIT_COLLECTOR_ADDRESS", + "MIN_WITHDRAW_DELAY", + "MAX_WITHDRAW_DELAY", + "BATCH_SETTLEMENT_DOMAIN", + "CHANNEL_CONFIG_TYPEHASH", + "channel_config_types", + "voucher_types", + "refund_types", + "claim_batch_types", + "receive_authorization_types", + "batch_permit2_witness_types", + # ABI + "channel_config_components", + "batch_settlement_abi", + "erc20_balance_of_abi", + # Types — inner state + "ChannelState", + # Types — wire structs + "ChannelConfig", + "BatchSettlementErc3009Authorization", + "BatchSettlementPermit2Permitted", + "BatchSettlementPermit2Witness", + "BatchSettlementPermit2Authorization", + "BatchSettlementVoucherFields", + "BatchSettlementDepositAuthorization", + "BatchSettlementAssetTransferMethod", + # Types — voucher claim + "BatchSettlementVoucherClaim", + # Types — extras + "BatchSettlementChannelStateExtra", + "BatchSettlementVoucherStateExtra", + "BatchSettlementPaymentRequirementsExtra", + "BatchSettlementPaymentResponseExtra", + # Types — payloads (client) + "BatchSettlementDepositPayload", + "BatchSettlementVoucherPayload", + "BatchSettlementRefundPayload", + # Types — payloads (facilitator) + "BatchSettlementClaimPayload", + "BatchSettlementSettlePayload", + "BatchSettlementEnrichedRefundPayload", + # Types — union aliases + "BatchSettlementPayload", + "BatchSettlementFacilitatorSettlePayload", + # Types — parsers + "parse_payload", + "parse_facilitator_payload", +] diff --git a/python/x402/mechanisms/evm/batch_settlement/abi.py b/python/x402/mechanisms/evm/batch_settlement/abi.py new file mode 100644 index 0000000000..837244a96d --- /dev/null +++ b/python/x402/mechanisms/evm/batch_settlement/abi.py @@ -0,0 +1,235 @@ +"""ABI definitions for the x402 batch-settlement contracts. + +Mirrors ``typescript/packages/mechanisms/evm/src/batch-settlement/abi.ts``. +Compatible with ``eth_abi`` for ABI encoding/decoding and ``web3.py`` for +contract calls. Module-level lists and dicts are normative; do not mutate +at runtime (``Final`` only prevents name rebinding, not container mutation). +""" + +from typing import Any, Final + +channel_config_components: Final[list[dict[str, str]]] = [ + {"name": "payer", "type": "address"}, + {"name": "payerAuthorizer", "type": "address"}, + {"name": "receiver", "type": "address"}, + {"name": "receiverAuthorizer", "type": "address"}, + {"name": "token", "type": "address"}, + {"name": "withdrawDelay", "type": "uint40"}, + {"name": "salt", "type": "bytes32"}, +] + +_voucher_claim_components: Final[list[dict[str, Any]]] = [ + { + "name": "voucher", + "type": "tuple", + "components": [ + { + "name": "channel", + "type": "tuple", + "components": channel_config_components, + }, + {"name": "maxClaimableAmount", "type": "uint128"}, + ], + }, + {"name": "signature", "type": "bytes"}, + {"name": "totalClaimed", "type": "uint128"}, +] + +batch_settlement_abi: Final[list[dict[str, Any]]] = [ + { + "type": "function", + "name": "multicall", + "inputs": [{"name": "data", "type": "bytes[]"}], + "outputs": [{"name": "results", "type": "bytes[]"}], + "stateMutability": "nonpayable", + }, + { + "type": "function", + "name": "deposit", + "inputs": [ + {"name": "config", "type": "tuple", "components": channel_config_components}, + {"name": "amount", "type": "uint128"}, + {"name": "collector", "type": "address"}, + {"name": "collectorData", "type": "bytes"}, + ], + "outputs": [], + "stateMutability": "nonpayable", + }, + { + "type": "function", + "name": "claim", + "inputs": [ + {"name": "voucherClaims", "type": "tuple[]", "components": _voucher_claim_components}, + ], + "outputs": [], + "stateMutability": "nonpayable", + }, + { + "type": "function", + "name": "claimWithSignature", + "inputs": [ + {"name": "voucherClaims", "type": "tuple[]", "components": _voucher_claim_components}, + {"name": "authorizerSignature", "type": "bytes"}, + ], + "outputs": [], + "stateMutability": "nonpayable", + }, + { + "type": "function", + "name": "settle", + "inputs": [ + {"name": "receiver", "type": "address"}, + {"name": "token", "type": "address"}, + ], + "outputs": [], + "stateMutability": "nonpayable", + }, + { + "type": "function", + "name": "initiateWithdraw", + "inputs": [ + {"name": "config", "type": "tuple", "components": channel_config_components}, + {"name": "amount", "type": "uint128"}, + ], + "outputs": [], + "stateMutability": "nonpayable", + }, + { + "type": "function", + "name": "finalizeWithdraw", + "inputs": [ + {"name": "config", "type": "tuple", "components": channel_config_components}, + ], + "outputs": [], + "stateMutability": "nonpayable", + }, + { + "type": "function", + "name": "refund", + "inputs": [ + {"name": "config", "type": "tuple", "components": channel_config_components}, + {"name": "amount", "type": "uint128"}, + ], + "outputs": [], + "stateMutability": "nonpayable", + }, + { + "type": "function", + "name": "refundWithSignature", + "inputs": [ + {"name": "config", "type": "tuple", "components": channel_config_components}, + {"name": "amount", "type": "uint128"}, + {"name": "nonce", "type": "uint256"}, + {"name": "receiverAuthorizerSignature", "type": "bytes"}, + ], + "outputs": [], + "stateMutability": "nonpayable", + }, + { + "type": "function", + "name": "getChannelId", + "inputs": [ + {"name": "config", "type": "tuple", "components": channel_config_components}, + ], + "outputs": [{"name": "", "type": "bytes32"}], + "stateMutability": "view", + }, + { + "type": "function", + "name": "CHANNEL_CONFIG_TYPEHASH", + "inputs": [], + "outputs": [{"name": "", "type": "bytes32"}], + "stateMutability": "view", + }, + { + "type": "function", + "name": "channels", + "inputs": [{"name": "channelId", "type": "bytes32"}], + "outputs": [ + {"name": "balance", "type": "uint128"}, + {"name": "totalClaimed", "type": "uint128"}, + ], + "stateMutability": "view", + }, + { + "type": "function", + "name": "pendingWithdrawals", + "inputs": [{"name": "channelId", "type": "bytes32"}], + "outputs": [ + {"name": "amount", "type": "uint128"}, + {"name": "initiatedAt", "type": "uint40"}, + ], + "stateMutability": "view", + }, + { + "type": "function", + "name": "receivers", + "inputs": [ + {"name": "receiver", "type": "address"}, + {"name": "token", "type": "address"}, + ], + "outputs": [ + {"name": "totalClaimed", "type": "uint128"}, + {"name": "totalSettled", "type": "uint128"}, + ], + "stateMutability": "view", + }, + { + "type": "function", + "name": "getVoucherDigest", + "inputs": [ + {"name": "channelId", "type": "bytes32"}, + {"name": "maxClaimableAmount", "type": "uint128"}, + ], + "outputs": [{"name": "", "type": "bytes32"}], + "stateMutability": "view", + }, + { + "type": "function", + "name": "getRefundDigest", + "inputs": [ + {"name": "channelId", "type": "bytes32"}, + {"name": "nonce", "type": "uint256"}, + {"name": "amount", "type": "uint128"}, + ], + "outputs": [{"name": "", "type": "bytes32"}], + "stateMutability": "view", + }, + { + "type": "function", + "name": "refundNonce", + "inputs": [{"name": "channelId", "type": "bytes32"}], + "outputs": [{"name": "", "type": "uint256"}], + "stateMutability": "view", + }, + { + "type": "function", + "name": "getClaimBatchDigest", + "inputs": [ + {"name": "voucherClaims", "type": "tuple[]", "components": _voucher_claim_components}, + ], + "outputs": [{"name": "", "type": "bytes32"}], + "stateMutability": "view", + }, + { + "type": "event", + "name": "Settled", + "inputs": [ + {"name": "receiver", "type": "address", "indexed": True}, + {"name": "token", "type": "address", "indexed": True}, + {"name": "sender", "type": "address", "indexed": True}, + {"name": "amount", "type": "uint128", "indexed": False}, + ], + "anonymous": False, + }, +] + +erc20_balance_of_abi: Final[list[dict[str, Any]]] = [ + { + "type": "function", + "name": "balanceOf", + "inputs": [{"name": "account", "type": "address"}], + "outputs": [{"name": "", "type": "uint256"}], + "stateMutability": "view", + }, +] diff --git a/python/x402/mechanisms/evm/batch_settlement/constants.py b/python/x402/mechanisms/evm/batch_settlement/constants.py new file mode 100644 index 0000000000..16fe79841c --- /dev/null +++ b/python/x402/mechanisms/evm/batch_settlement/constants.py @@ -0,0 +1,108 @@ +"""Constants for the x402 batch-settlement mechanism. + +Mirrors ``typescript/packages/mechanisms/evm/src/batch-settlement/constants.ts``. +Wire field names (camelCase) inside EIP-712 type dictionaries are normative +for digest equivalence with the TypeScript SDK and must not be reordered or +renamed. Module-level dicts and lists are normative; do not mutate at runtime +(``Final`` only prevents name rebinding, not container mutation). +""" + +from typing import Final + +try: + from eth_utils import keccak # type: ignore[attr-defined] +except ImportError as e: + raise ImportError( + "EVM mechanism requires ethereum packages. Install with: pip install x402[evm]" + ) from e + +BATCH_SETTLEMENT_SCHEME: Final[str] = "batch-settlement" + +BATCH_SETTLEMENT_ADDRESS: Final[str] = "0x4020074e9dF2ce1deE5A9C1b5c3f541D02a10003" +ERC3009_DEPOSIT_COLLECTOR_ADDRESS: Final[str] = "0x4020806089470a89826cB9fB1f4059150b550004" +PERMIT2_DEPOSIT_COLLECTOR_ADDRESS: Final[str] = "0x4020425FAf3B746C082C2f942b4E5159887B0005" + +# Onchain enforced bounds: 15 minutes ≤ withdrawDelay ≤ 30 days. +MIN_WITHDRAW_DELAY: Final[int] = 900 +MAX_WITHDRAW_DELAY: Final[int] = 2_592_000 + +BATCH_SETTLEMENT_DOMAIN: Final[dict[str, str]] = { + "name": "x402 Batch Settlement", + "version": "1", +} + +CHANNEL_CONFIG_TYPEHASH: Final[bytes] = keccak( + text=( + "ChannelConfig(" + "address payer," + "address payerAuthorizer," + "address receiver," + "address receiverAuthorizer," + "address token," + "uint40 withdrawDelay," + "bytes32 salt" + ")" + ) +) + +channel_config_types: Final[dict[str, list[dict[str, str]]]] = { + "ChannelConfig": [ + {"name": "payer", "type": "address"}, + {"name": "payerAuthorizer", "type": "address"}, + {"name": "receiver", "type": "address"}, + {"name": "receiverAuthorizer", "type": "address"}, + {"name": "token", "type": "address"}, + {"name": "withdrawDelay", "type": "uint40"}, + {"name": "salt", "type": "bytes32"}, + ], +} + +voucher_types: Final[dict[str, list[dict[str, str]]]] = { + "Voucher": [ + {"name": "channelId", "type": "bytes32"}, + {"name": "maxClaimableAmount", "type": "uint128"}, + ], +} + +refund_types: Final[dict[str, list[dict[str, str]]]] = { + "Refund": [ + {"name": "channelId", "type": "bytes32"}, + {"name": "nonce", "type": "uint256"}, + {"name": "amount", "type": "uint128"}, + ], +} + +claim_batch_types: Final[dict[str, list[dict[str, str]]]] = { + "ClaimBatch": [{"name": "claims", "type": "ClaimEntry[]"}], + "ClaimEntry": [ + {"name": "channelId", "type": "bytes32"}, + {"name": "maxClaimableAmount", "type": "uint128"}, + {"name": "totalClaimed", "type": "uint128"}, + ], +} + +receive_authorization_types: Final[dict[str, list[dict[str, str]]]] = { + "ReceiveWithAuthorization": [ + {"name": "from", "type": "address"}, + {"name": "to", "type": "address"}, + {"name": "value", "type": "uint256"}, + {"name": "validAfter", "type": "uint256"}, + {"name": "validBefore", "type": "uint256"}, + {"name": "nonce", "type": "bytes32"}, + ], +} + +batch_permit2_witness_types: Final[dict[str, list[dict[str, str]]]] = { + "PermitWitnessTransferFrom": [ + {"name": "permitted", "type": "TokenPermissions"}, + {"name": "spender", "type": "address"}, + {"name": "nonce", "type": "uint256"}, + {"name": "deadline", "type": "uint256"}, + {"name": "witness", "type": "DepositWitness"}, + ], + "TokenPermissions": [ + {"name": "token", "type": "address"}, + {"name": "amount", "type": "uint256"}, + ], + "DepositWitness": [{"name": "channelId", "type": "bytes32"}], +} diff --git a/python/x402/mechanisms/evm/batch_settlement/types.py b/python/x402/mechanisms/evm/batch_settlement/types.py new file mode 100644 index 0000000000..138a6e3d32 --- /dev/null +++ b/python/x402/mechanisms/evm/batch_settlement/types.py @@ -0,0 +1,311 @@ +"""Wire-format types for the x402 batch-settlement mechanism. + +Mirrors ``typescript/packages/mechanisms/evm/src/batch-settlement/types.ts``. + +Validation philosophy: this module declares Pydantic models for type-checking +and JSON serialization, but wire-level invariants (XOR deposit auth, hex +format, EIP-712 byte equivalence, integer-as-string) are enforced through +cross-language fixture tests (Layer 2 invariants) rather than Pydantic +Annotated validators or discriminated unions. This mirrors the pre-existing +``exact`` / ``upto`` mechanism conventions. + +Wire spec conventions: +- ``uint256`` / large integers (``balance``, ``totalClaimed``, ``refundNonce``, + ``maxClaimableAmount``, ``amount``, ``validAfter``/``validBefore``, + ``deadline``) are carried as **str** on the wire to avoid JSON precision + loss; conversion to ``int`` happens at call sites that need arithmetic. +- ``uint40`` / Unix timestamps (``withdrawDelay``, ``withdrawRequestedAt``) + are carried as **int** on the wire (fits safely in JSON number). +- Unknown wire fields are silently dropped (``BaseX402Model`` default + ``extra="ignore"``) — TS interop relies on schema strictness at the + cross-language fixture layer, not on Python forbidding extras. +""" + +from typing import Any, Literal + +from pydantic import Field + +from x402.schemas.base import BaseX402Model + +# --- Inner state (not on the wire) --- + + +class ChannelState(BaseX402Model): + """Internal channel state. Not serialized to the wire.""" + + balance: int + total_claimed: int + withdraw_requested_at: int + refund_nonce: int + + +# --- Wire structs --- + + +class ChannelConfig(BaseX402Model): + """EIP-712 ChannelConfig struct mirror.""" + + payer: str + payer_authorizer: str + receiver: str + receiver_authorizer: str + token: str + withdraw_delay: int + salt: str + + +class BatchSettlementErc3009Authorization(BaseX402Model): + valid_after: str + valid_before: str + salt: str + signature: str + + +class BatchSettlementPermit2Permitted(BaseX402Model): + token: str + amount: str + + +class BatchSettlementPermit2Witness(BaseX402Model): + channel_id: str + + +class BatchSettlementPermit2Authorization(BaseX402Model): + from_: str = Field(alias="from") + permitted: BatchSettlementPermit2Permitted + spender: str + nonce: str + deadline: str + witness: BatchSettlementPermit2Witness + signature: str + + +class BatchSettlementVoucherFields(BaseX402Model): + channel_id: str + max_claimable_amount: str + signature: str + + +class BatchSettlementDepositAuthorization(BaseX402Model): + """One of erc3009 / permit2 (XOR enforced via parse_payload).""" + + erc3009_authorization: BatchSettlementErc3009Authorization | None = None + permit2_authorization: BatchSettlementPermit2Authorization | None = None + + +BatchSettlementAssetTransferMethod = Literal["eip3009", "permit2"] + + +# --- Voucher claim (used in claim/refund) --- + + +class _VoucherClaimVoucher(BaseX402Model): + channel: ChannelConfig + max_claimable_amount: str + + +class BatchSettlementVoucherClaim(BaseX402Model): + voucher: _VoucherClaimVoucher + signature: str + total_claimed: str + + +# --- Extras (wire metadata) --- + + +class BatchSettlementChannelStateExtra(BaseX402Model): + channel_id: str + balance: str + total_claimed: str + withdraw_requested_at: int + refund_nonce: str + charged_cumulative_amount: str | None = None + + +class BatchSettlementVoucherStateExtra(BaseX402Model): + signed_max_claimable: str | None = None + signature: str | None = None + + +class BatchSettlementPaymentRequirementsExtra(BaseX402Model): + receiver_authorizer: str + withdraw_delay: int + name: str + version: str + asset_transfer_method: BatchSettlementAssetTransferMethod | None = None + channel_state: BatchSettlementChannelStateExtra | None = None + voucher_state: BatchSettlementVoucherStateExtra | None = None + + +class BatchSettlementPaymentResponseExtra(BaseX402Model): + charged_amount: str | None = None + channel_state: BatchSettlementChannelStateExtra | None = None + voucher_state: BatchSettlementVoucherStateExtra | None = None + + +# --- Client-side payloads (3 types) --- + + +class _DepositInner(BaseX402Model): + amount: str + authorization: BatchSettlementDepositAuthorization + + +class BatchSettlementDepositPayload(BaseX402Model): + type: Literal["deposit"] = "deposit" + channel_config: ChannelConfig + voucher: BatchSettlementVoucherFields + deposit: _DepositInner + + +class BatchSettlementVoucherPayload(BaseX402Model): + type: Literal["voucher"] = "voucher" + channel_config: ChannelConfig + voucher: BatchSettlementVoucherFields + + +class BatchSettlementRefundPayload(BaseX402Model): + type: Literal["refund"] = "refund" + channel_config: ChannelConfig + voucher: BatchSettlementVoucherFields + amount: str | None = None + + +# --- Facilitator-side payloads --- + + +class BatchSettlementClaimPayload(BaseX402Model): + type: Literal["claim"] = "claim" + claims: list[BatchSettlementVoucherClaim] + claim_authorizer_signature: str | None = None + + +class BatchSettlementSettlePayload(BaseX402Model): + type: Literal["settle"] = "settle" + receiver: str + token: str + + +class BatchSettlementEnrichedRefundPayload(BaseX402Model): + """Refund payload with mandatory enriched fields for facilitator-side settlement. + + Shares ``type`` literal with :class:`BatchSettlementRefundPayload`; runtime + narrowing happens in :func:`parse_facilitator_payload` by the presence of + the mandatory enriched fields. + """ + + type: Literal["refund"] = "refund" + channel_config: ChannelConfig + voucher: BatchSettlementVoucherFields + amount: str + refund_nonce: str + claims: list[BatchSettlementVoucherClaim] + refund_authorizer_signature: str | None = None + claim_authorizer_signature: str | None = None + + +# --- Union type aliases (no Annotated discriminator) --- + +BatchSettlementPayload = ( + BatchSettlementDepositPayload | BatchSettlementVoucherPayload | BatchSettlementRefundPayload +) + +BatchSettlementFacilitatorSettlePayload = ( + BatchSettlementDepositPayload + | BatchSettlementClaimPayload + | BatchSettlementSettlePayload + | BatchSettlementEnrichedRefundPayload +) + + +# --- Manual parsing helpers --- + + +def _validate_deposit_xor(auth: BatchSettlementDepositAuthorization) -> None: + has_erc = auth.erc3009_authorization is not None + has_p2 = auth.permit2_authorization is not None + if has_erc == has_p2: + raise ValueError( + "BatchSettlementDepositAuthorization: exactly one of " + "erc3009Authorization or permit2Authorization must be set" + ) + + +def parse_payload(data: dict[str, Any]) -> BatchSettlementPayload: + """Discriminate a client-side payload on ``type`` and validate XOR auth.""" + type_value = data.get("type") + if type_value == "deposit": + payload = BatchSettlementDepositPayload.model_validate(data) + _validate_deposit_xor(payload.deposit.authorization) + return payload + if type_value == "voucher": + return BatchSettlementVoucherPayload.model_validate(data) + if type_value == "refund": + return BatchSettlementRefundPayload.model_validate(data) + raise ValueError(f"unknown BatchSettlementPayload type: {type_value!r}") + + +def parse_facilitator_payload( + data: dict[str, Any], +) -> BatchSettlementFacilitatorSettlePayload: + """Discriminate a facilitator-side payload, including enriched refund. + + A ``"refund"`` payload here must carry the enriched mandatory fields + (``amount`` / ``refundNonce`` / ``claims``); plain client-side refund + is not a valid facilitator payload. + """ + type_value = data.get("type") + if type_value == "deposit": + payload = BatchSettlementDepositPayload.model_validate(data) + _validate_deposit_xor(payload.deposit.authorization) + return payload + if type_value == "claim": + return BatchSettlementClaimPayload.model_validate(data) + if type_value == "settle": + return BatchSettlementSettlePayload.model_validate(data) + if type_value == "refund": + # Treat ``null`` and absent the same here so the caller sees a single + # ``ValueError`` for "not an enriched refund" instead of mixing in a + # Pydantic ``ValidationError`` for ``amount: str`` rejecting ``None``. + if all(data.get(k) is not None for k in ("amount", "refundNonce", "claims")): + return BatchSettlementEnrichedRefundPayload.model_validate(data) + raise ValueError( + "facilitator refund payload missing mandatory fields (amount/refundNonce/claims)" + ) + raise ValueError(f"unknown BatchSettlementFacilitatorSettlePayload type: {type_value!r}") + + +__all__ = [ + # Inner state + "ChannelState", + # Wire structs + "ChannelConfig", + "BatchSettlementErc3009Authorization", + "BatchSettlementPermit2Permitted", + "BatchSettlementPermit2Witness", + "BatchSettlementPermit2Authorization", + "BatchSettlementVoucherFields", + "BatchSettlementDepositAuthorization", + "BatchSettlementAssetTransferMethod", + # Voucher claim + "BatchSettlementVoucherClaim", + # Extras + "BatchSettlementChannelStateExtra", + "BatchSettlementVoucherStateExtra", + "BatchSettlementPaymentRequirementsExtra", + "BatchSettlementPaymentResponseExtra", + # Payloads (client) + "BatchSettlementDepositPayload", + "BatchSettlementVoucherPayload", + "BatchSettlementRefundPayload", + # Payloads (facilitator) + "BatchSettlementClaimPayload", + "BatchSettlementSettlePayload", + "BatchSettlementEnrichedRefundPayload", + # Union aliases + "BatchSettlementPayload", + "BatchSettlementFacilitatorSettlePayload", + # Parsers + "parse_payload", + "parse_facilitator_payload", +] diff --git a/python/x402/tests/unit/mechanisms/evm/batch_settlement/__init__.py b/python/x402/tests/unit/mechanisms/evm/batch_settlement/__init__.py new file mode 100644 index 0000000000..cc87593363 --- /dev/null +++ b/python/x402/tests/unit/mechanisms/evm/batch_settlement/__init__.py @@ -0,0 +1 @@ +"""Tests for x402.mechanisms.evm.batch_settlement (PR1 foundation).""" diff --git a/python/x402/tests/unit/mechanisms/evm/batch_settlement/test_abi.py b/python/x402/tests/unit/mechanisms/evm/batch_settlement/test_abi.py new file mode 100644 index 0000000000..3b99cf6691 --- /dev/null +++ b/python/x402/tests/unit/mechanisms/evm/batch_settlement/test_abi.py @@ -0,0 +1,105 @@ +"""ABI shape tests for batch-settlement. + +Verifies that ``batch_settlement_abi`` exposes the same function and event +names as the TypeScript SDK (``abi.ts``), and that the helper component +``_voucher_claim_components`` is module-private. +""" + +try: + from eth_utils import is_checksum_address # type: ignore[attr-defined] # noqa: F401 +except ImportError: + import pytest + + pytest.skip("batch_settlement requires eth_utils", allow_module_level=True) + +from x402.mechanisms.evm import batch_settlement +from x402.mechanisms.evm.batch_settlement import ( + batch_settlement_abi, + channel_config_components, + erc20_balance_of_abi, +) + +EXPECTED_FUNCTIONS = [ + "multicall", + "deposit", + "claim", + "claimWithSignature", + "settle", + "initiateWithdraw", + "finalizeWithdraw", + "refund", + "refundWithSignature", + "getChannelId", + "CHANNEL_CONFIG_TYPEHASH", + "channels", + "pendingWithdrawals", + "receivers", + "getVoucherDigest", + "getRefundDigest", + "refundNonce", + "getClaimBatchDigest", +] + +EXPECTED_EVENTS = ["Settled"] + + +def _names_by_kind(kind: str) -> list[str]: + return [item["name"] for item in batch_settlement_abi if item.get("type") == kind] + + +def test_function_names_match_ts() -> None: + assert _names_by_kind("function") == EXPECTED_FUNCTIONS + + +def test_event_names_match_ts() -> None: + assert _names_by_kind("event") == EXPECTED_EVENTS + + +def test_settled_event_indexed_topics() -> None: + """Settled is indexed on (receiver, token, sender); amount is non-indexed (D6 / EIP-712 follow-on).""" + settled = next(item for item in batch_settlement_abi if item.get("name") == "Settled") + indexed = [(f["name"], f["indexed"]) for f in settled["inputs"]] + assert indexed == [ + ("receiver", True), + ("token", True), + ("sender", True), + ("amount", False), + ] + + +def test_channel_config_components_field_order() -> None: + """Components list is shared with EIP-712 ChannelConfig; order is normative.""" + assert [c["name"] for c in channel_config_components] == [ + "payer", + "payerAuthorizer", + "receiver", + "receiverAuthorizer", + "token", + "withdrawDelay", + "salt", + ] + + +def test_erc20_balance_of_signature() -> None: + assert erc20_balance_of_abi == [ + { + "type": "function", + "name": "balanceOf", + "inputs": [{"name": "account", "type": "address"}], + "outputs": [{"name": "", "type": "uint256"}], + "stateMutability": "view", + }, + ] + + +def test_voucher_claim_components_is_private() -> None: + """``_voucher_claim_components`` is an internal helper; not part of the public surface.""" + assert "_voucher_claim_components" not in batch_settlement.__all__ + assert not hasattr(batch_settlement, "voucher_claim_components") + + +def test_deposit_collector_data_is_opaque_bytes() -> None: + """deposit's collectorData is intentionally ``bytes`` (collector-specific encoding, D5).""" + deposit = next(item for item in batch_settlement_abi if item.get("name") == "deposit") + collector_data = next(i for i in deposit["inputs"] if i["name"] == "collectorData") + assert collector_data["type"] == "bytes" diff --git a/python/x402/tests/unit/mechanisms/evm/batch_settlement/test_constants.py b/python/x402/tests/unit/mechanisms/evm/batch_settlement/test_constants.py new file mode 100644 index 0000000000..02d7d84eeb --- /dev/null +++ b/python/x402/tests/unit/mechanisms/evm/batch_settlement/test_constants.py @@ -0,0 +1,133 @@ +"""Sanity tests for batch-settlement constants. + +Cross-language EIP-712 byte equivalence (Layer 2) lands in subsequent PRs +once the TypeScript-side fixture generator is in place. These tests cover +shape, ordering, and self-consistency only. +""" + +try: + from eth_utils import is_checksum_address, keccak # type: ignore[attr-defined] +except ImportError: + import pytest + + pytest.skip("batch_settlement requires eth_utils", allow_module_level=True) + +import pytest + +from x402.mechanisms.evm.batch_settlement import ( + BATCH_SETTLEMENT_ADDRESS, + BATCH_SETTLEMENT_DOMAIN, + BATCH_SETTLEMENT_SCHEME, + CHANNEL_CONFIG_TYPEHASH, + ERC3009_DEPOSIT_COLLECTOR_ADDRESS, + MAX_WITHDRAW_DELAY, + MIN_WITHDRAW_DELAY, + PERMIT2_DEPOSIT_COLLECTOR_ADDRESS, + batch_permit2_witness_types, + channel_config_types, + claim_batch_types, + receive_authorization_types, + refund_types, + voucher_types, +) + + +def test_scheme_identifier() -> None: + assert BATCH_SETTLEMENT_SCHEME == "batch-settlement" + + +@pytest.mark.parametrize( + "address", + [ + BATCH_SETTLEMENT_ADDRESS, + ERC3009_DEPOSIT_COLLECTOR_ADDRESS, + PERMIT2_DEPOSIT_COLLECTOR_ADDRESS, + ], +) +def test_address_is_eip55_checksummed(address: str) -> None: + assert is_checksum_address(address), f"{address} is not EIP-55 checksummed" + + +def test_withdraw_delay_window() -> None: + assert MIN_WITHDRAW_DELAY == 900 + assert MAX_WITHDRAW_DELAY == 2_592_000 + assert MIN_WITHDRAW_DELAY < MAX_WITHDRAW_DELAY + + +def test_eip712_domain_shape() -> None: + assert BATCH_SETTLEMENT_DOMAIN == { + "name": "x402 Batch Settlement", + "version": "1", + } + + +def test_channel_config_typehash_matches_canonical_signature() -> None: + canonical = ( + "ChannelConfig(" + "address payer,address payerAuthorizer,address receiver," + "address receiverAuthorizer,address token," + "uint40 withdrawDelay,bytes32 salt)" + ) + assert CHANNEL_CONFIG_TYPEHASH == keccak(text=canonical) + + +def test_channel_config_types_field_order() -> None: + fields = channel_config_types["ChannelConfig"] + assert [f["name"] for f in fields] == [ + "payer", + "payerAuthorizer", + "receiver", + "receiverAuthorizer", + "token", + "withdrawDelay", + "salt", + ] + + +def test_voucher_types_field_order() -> None: + fields = voucher_types["Voucher"] + assert [f["name"] for f in fields] == ["channelId", "maxClaimableAmount"] + + +def test_refund_types_field_order() -> None: + fields = refund_types["Refund"] + assert [f["name"] for f in fields] == ["channelId", "nonce", "amount"] + + +def test_claim_batch_types_nested_struct() -> None: + assert claim_batch_types["ClaimBatch"] == [{"name": "claims", "type": "ClaimEntry[]"}] + entry_fields = claim_batch_types["ClaimEntry"] + assert [f["name"] for f in entry_fields] == [ + "channelId", + "maxClaimableAmount", + "totalClaimed", + ] + + +def test_receive_authorization_types_field_order() -> None: + """ERC-3009 ReceiveWithAuthorization field order is normative for D11 signatures.""" + fields = receive_authorization_types["ReceiveWithAuthorization"] + assert [f["name"] for f in fields] == [ + "from", + "to", + "value", + "validAfter", + "validBefore", + "nonce", + ] + + +def test_batch_permit2_witness_types_nested_structs() -> None: + """Permit2 deposit witness has three nested structs; nesting and order are normative.""" + permit_fields = batch_permit2_witness_types["PermitWitnessTransferFrom"] + assert [f["name"] for f in permit_fields] == [ + "permitted", + "spender", + "nonce", + "deadline", + "witness", + ] + token_fields = batch_permit2_witness_types["TokenPermissions"] + assert [f["name"] for f in token_fields] == ["token", "amount"] + witness_fields = batch_permit2_witness_types["DepositWitness"] + assert [f["name"] for f in witness_fields] == ["channelId"] diff --git a/python/x402/tests/unit/mechanisms/evm/batch_settlement/test_types.py b/python/x402/tests/unit/mechanisms/evm/batch_settlement/test_types.py new file mode 100644 index 0000000000..3c6b08fe6a --- /dev/null +++ b/python/x402/tests/unit/mechanisms/evm/batch_settlement/test_types.py @@ -0,0 +1,446 @@ +"""Wire-format type tests for batch-settlement (D1 / D4 / D5 / D7 / D8). + +Wire-level byte equivalence with the TypeScript SDK (Layer 2 invariants +L2.1-L2.11) lands in subsequent PRs alongside the cross-language fixture +generator. These tests cover Pydantic-level wire shape, alias handling, +optional-field semantics, manual discrimination, and XOR auth validation. +""" + +try: + from eth_utils import is_checksum_address # type: ignore[attr-defined] # noqa: F401 +except ImportError: + import pytest + + pytest.skip("batch_settlement requires eth_utils", allow_module_level=True) + +from collections.abc import Callable +from typing import Any + +import pytest + +from x402.mechanisms.evm.batch_settlement.types import ( + BatchSettlementClaimPayload, + BatchSettlementDepositAuthorization, + BatchSettlementDepositPayload, + BatchSettlementEnrichedRefundPayload, + BatchSettlementErc3009Authorization, + BatchSettlementPaymentRequirementsExtra, + BatchSettlementPermit2Authorization, + BatchSettlementPermit2Permitted, + BatchSettlementPermit2Witness, + BatchSettlementRefundPayload, + BatchSettlementSettlePayload, + BatchSettlementVoucherClaim, + BatchSettlementVoucherFields, + BatchSettlementVoucherPayload, + ChannelConfig, + _validate_deposit_xor, + parse_facilitator_payload, + parse_payload, +) + +# --- Fixtures --- + +ADDR_PAYER = "0x1111111111111111111111111111111111111111" +ADDR_PAYER_AUTHORIZER = "0x2222222222222222222222222222222222222222" +ADDR_RECEIVER = "0x3333333333333333333333333333333333333333" +ADDR_RECEIVER_AUTHORIZER = "0x4444444444444444444444444444444444444444" +ADDR_TOKEN = "0x5555555555555555555555555555555555555555" +SALT = "0x" + "ab" * 32 +CHANNEL_ID = "0x" + "cd" * 32 +SIGNATURE = "0x" + "ef" * 65 + + +def _channel_config_dict() -> dict[str, Any]: + return { + "payer": ADDR_PAYER, + "payerAuthorizer": ADDR_PAYER_AUTHORIZER, + "receiver": ADDR_RECEIVER, + "receiverAuthorizer": ADDR_RECEIVER_AUTHORIZER, + "token": ADDR_TOKEN, + "withdrawDelay": 900, + "salt": SALT, + } + + +def _voucher_dict() -> dict[str, Any]: + return { + "channelId": CHANNEL_ID, + "maxClaimableAmount": "1000000", + "signature": SIGNATURE, + } + + +def _erc3009_auth_dict() -> dict[str, Any]: + return { + "validAfter": "1700000000", + "validBefore": "1700003600", + "salt": SALT, + "signature": SIGNATURE, + } + + +def _permit2_auth_dict() -> dict[str, Any]: + return { + "from": ADDR_PAYER, + "permitted": {"token": ADDR_TOKEN, "amount": "1000000"}, + "spender": ADDR_RECEIVER, + "nonce": "1", + "deadline": "1700003600", + "witness": {"channelId": CHANNEL_ID}, + "signature": SIGNATURE, + } + + +def _deposit_payload_dict(*, permit2: bool = False) -> dict[str, Any]: + auth = ( + {"permit2Authorization": _permit2_auth_dict()} + if permit2 + else {"erc3009Authorization": _erc3009_auth_dict()} + ) + return { + "type": "deposit", + "channelConfig": _channel_config_dict(), + "voucher": _voucher_dict(), + "deposit": {"amount": "1000000", "authorization": auth}, + } + + +def _voucher_payload_dict() -> dict[str, Any]: + return { + "type": "voucher", + "channelConfig": _channel_config_dict(), + "voucher": _voucher_dict(), + } + + +def _refund_payload_dict(*, with_amount: bool = False) -> dict[str, Any]: + d = { + "type": "refund", + "channelConfig": _channel_config_dict(), + "voucher": _voucher_dict(), + } + if with_amount: + d["amount"] = "500000" + return d + + +def _voucher_claim_dict() -> dict[str, Any]: + return { + "voucher": { + "channel": _channel_config_dict(), + "maxClaimableAmount": "1000000", + }, + "signature": SIGNATURE, + "totalClaimed": "500000", + } + + +# --- D4: camelCase ↔ snake_case alias handling --- + + +def test_channel_config_round_trips_camel_case() -> None: + cfg = ChannelConfig.model_validate(_channel_config_dict()) + assert cfg.payer_authorizer == ADDR_PAYER_AUTHORIZER + assert cfg.withdraw_delay == 900 + dumped = cfg.model_dump(by_alias=True) + assert "payerAuthorizer" in dumped + assert "withdrawDelay" in dumped + + +def test_permit2_from_alias_maps_to_underscore() -> None: + """``from`` is reserved in Python; we use ``from_`` with explicit alias.""" + auth = BatchSettlementPermit2Authorization.model_validate(_permit2_auth_dict()) + assert auth.from_ == ADDR_PAYER + dumped = auth.model_dump(by_alias=True) + assert dumped["from"] == ADDR_PAYER + assert "from_" not in dumped + + +def test_permit2_from_alias_used_by_default_serialization() -> None: + """``BaseX402Model.serialize_by_alias=True`` should emit ``"from"`` even without ``by_alias``.""" + auth = BatchSettlementPermit2Authorization.model_validate(_permit2_auth_dict()) + dumped_default = auth.model_dump() + assert dumped_default["from"] == ADDR_PAYER + assert "from_" not in dumped_default + + +def test_voucher_fields_round_trip() -> None: + vf = BatchSettlementVoucherFields.model_validate(_voucher_dict()) + assert vf.channel_id == CHANNEL_ID + assert vf.max_claimable_amount == "1000000" + assert vf.model_dump(by_alias=True) == _voucher_dict() + + +# --- D5: Optional 三状態 (absent / explicit null / present) --- + + +def test_optional_field_absent_round_trips_to_absent_when_excluded() -> None: + """absent on input → None attribute → absent on output (exclude_none=True).""" + payload = BatchSettlementRefundPayload.model_validate(_refund_payload_dict()) + assert payload.amount is None + dumped = payload.model_dump(by_alias=True, exclude_none=True) + assert "amount" not in dumped + + +def test_optional_field_dumps_null_by_default() -> None: + """absent on input → None → ``null`` on output when exclude_none=False (default). + + D5 contract: callers MUST pass ``exclude_none=True`` to match the TS wire + (which drops ``undefined`` keys); this test pins the default behavior so + the call-site discipline is intentional rather than incidental. + """ + payload = BatchSettlementRefundPayload.model_validate(_refund_payload_dict()) + dumped = payload.model_dump(by_alias=True) + assert dumped["amount"] is None + + +def test_optional_field_accepts_explicit_null() -> None: + """``"amount": null`` is accepted defensively (#1762).""" + data = _refund_payload_dict() + data["amount"] = None + payload = BatchSettlementRefundPayload.model_validate(data) + assert payload.amount is None + + +def test_optional_field_present_round_trips() -> None: + payload = BatchSettlementRefundPayload.model_validate(_refund_payload_dict(with_amount=True)) + assert payload.amount == "500000" + dumped = payload.model_dump(by_alias=True, exclude_none=True) + assert dumped["amount"] == "500000" + + +def test_payment_requirements_extra_optional_collapse() -> None: + """All optional state extras absent → exclude_none drops them all.""" + pre = BatchSettlementPaymentRequirementsExtra.model_validate( + { + "receiverAuthorizer": ADDR_RECEIVER_AUTHORIZER, + "withdrawDelay": 900, + "name": "x402 Batch Settlement", + "version": "1", + } + ) + dumped = pre.model_dump(by_alias=True, exclude_none=True) + assert dumped == { + "receiverAuthorizer": ADDR_RECEIVER_AUTHORIZER, + "withdrawDelay": 900, + "name": "x402 Batch Settlement", + "version": "1", + } + + +# --- D7: parse_payload manual discrimination --- + + +def test_parse_payload_deposit() -> None: + payload = parse_payload(_deposit_payload_dict()) + assert isinstance(payload, BatchSettlementDepositPayload) + assert payload.type == "deposit" + + +def test_parse_payload_deposit_permit2() -> None: + payload = parse_payload(_deposit_payload_dict(permit2=True)) + assert isinstance(payload, BatchSettlementDepositPayload) + assert payload.deposit.authorization.permit2_authorization is not None + + +def test_parse_payload_voucher() -> None: + payload = parse_payload(_voucher_payload_dict()) + assert isinstance(payload, BatchSettlementVoucherPayload) + assert payload.type == "voucher" + + +def test_parse_payload_refund() -> None: + payload = parse_payload(_refund_payload_dict()) + assert isinstance(payload, BatchSettlementRefundPayload) + assert payload.type == "refund" + + +def test_parse_payload_unknown_type_raises() -> None: + with pytest.raises(ValueError, match="unknown BatchSettlementPayload type"): + parse_payload({"type": "frob"}) + + +def test_parse_payload_missing_type_raises() -> None: + with pytest.raises(ValueError, match="unknown BatchSettlementPayload type"): + parse_payload({}) + + +# --- D7 facilitator-side: parse_facilitator_payload --- + + +def test_parse_facilitator_payload_claim() -> None: + data = {"type": "claim", "claims": [_voucher_claim_dict()]} + payload = parse_facilitator_payload(data) + assert isinstance(payload, BatchSettlementClaimPayload) + assert len(payload.claims) == 1 + assert isinstance(payload.claims[0], BatchSettlementVoucherClaim) + + +def test_parse_facilitator_payload_settle() -> None: + data = {"type": "settle", "receiver": ADDR_RECEIVER, "token": ADDR_TOKEN} + payload = parse_facilitator_payload(data) + assert isinstance(payload, BatchSettlementSettlePayload) + + +def test_parse_facilitator_payload_enriched_refund() -> None: + data = { + "type": "refund", + "channelConfig": _channel_config_dict(), + "voucher": _voucher_dict(), + "amount": "500000", + "refundNonce": "1", + "claims": [_voucher_claim_dict()], + } + payload = parse_facilitator_payload(data) + assert isinstance(payload, BatchSettlementEnrichedRefundPayload) + assert payload.amount == "500000" + assert payload.refund_nonce == "1" + + +def test_parse_facilitator_payload_plain_refund_rejected() -> None: + """Plain (client-side) refund must be rejected by the facilitator parser.""" + with pytest.raises(ValueError, match="missing mandatory fields"): + parse_facilitator_payload(_refund_payload_dict()) + + +def test_parse_facilitator_payload_refund_with_null_mandatory_rejected() -> None: + """``null`` on a mandatory enriched field must surface as the same ValueError. + + Without this, ``data.get(k) is not None`` collapses ``null`` and absent into + one branch; otherwise the caller would see a Pydantic ``ValidationError`` + for ``amount: str`` rejecting ``None`` and a plain ``ValueError`` for the + absent case — two exception types for the same wire-shape failure. + """ + data = { + "type": "refund", + "channelConfig": _channel_config_dict(), + "voucher": _voucher_dict(), + "amount": None, + "refundNonce": "1", + "claims": [_voucher_claim_dict()], + } + with pytest.raises(ValueError, match="missing mandatory fields"): + parse_facilitator_payload(data) + + +def test_parse_facilitator_payload_unknown_type_raises() -> None: + with pytest.raises(ValueError, match="unknown BatchSettlementFacilitatorSettlePayload type"): + parse_facilitator_payload({"type": "frob"}) + + +# --- D8: XOR deposit authorization --- + + +def test_deposit_xor_erc3009_only_passes() -> None: + auth = BatchSettlementDepositAuthorization.model_validate( + {"erc3009Authorization": _erc3009_auth_dict()} + ) + _validate_deposit_xor(auth) + + +def test_deposit_xor_permit2_only_passes() -> None: + auth = BatchSettlementDepositAuthorization.model_validate( + {"permit2Authorization": _permit2_auth_dict()} + ) + _validate_deposit_xor(auth) + + +def test_deposit_xor_both_set_raises() -> None: + auth = BatchSettlementDepositAuthorization.model_validate( + { + "erc3009Authorization": _erc3009_auth_dict(), + "permit2Authorization": _permit2_auth_dict(), + } + ) + with pytest.raises(ValueError, match="exactly one of"): + _validate_deposit_xor(auth) + + +def test_deposit_xor_both_unset_raises() -> None: + auth = BatchSettlementDepositAuthorization.model_validate({}) + with pytest.raises(ValueError, match="exactly one of"): + _validate_deposit_xor(auth) + + +def test_parse_payload_deposit_with_both_auth_raises() -> None: + data = _deposit_payload_dict() + data["deposit"]["authorization"]["permit2Authorization"] = _permit2_auth_dict() + with pytest.raises(ValueError, match="exactly one of"): + parse_payload(data) + + +def test_parse_payload_deposit_with_no_auth_raises() -> None: + data = _deposit_payload_dict() + data["deposit"]["authorization"] = {} + with pytest.raises(ValueError, match="exactly one of"): + parse_payload(data) + + +# --- D1: Integer-as-string (large integer round-trip without IntStr) --- + + +def test_large_integer_round_trips_as_str() -> None: + """Wire format keeps large ints as str; Python str field preserves them losslessly.""" + big = "123456789012345678901234567890" + data = _voucher_dict() + data["maxClaimableAmount"] = big + vf = BatchSettlementVoucherFields.model_validate(data) + assert vf.max_claimable_amount == big + assert int(vf.max_claimable_amount) == int(big) + assert vf.model_dump(by_alias=True)["maxClaimableAmount"] == big + + +# --- Round-trip stability --- + + +@pytest.mark.parametrize( + "data_factory", + [ + _deposit_payload_dict, + _voucher_payload_dict, + _refund_payload_dict, + lambda: _refund_payload_dict(with_amount=True), + lambda: _deposit_payload_dict(permit2=True), + ], +) +def test_payload_round_trip_is_stable(data_factory: Callable[[], dict[str, Any]]) -> None: + """parse → dump → parse yields the same wire dict (exclude_none normalization).""" + original = data_factory() + parsed = parse_payload(original) + dumped = parsed.model_dump(by_alias=True, exclude_none=True) + reparsed = parse_payload(dumped) + assert reparsed.model_dump(by_alias=True, exclude_none=True) == dumped + + +# --- Erc3009 / Permit2 standalone round-trips --- + + +def test_erc3009_authorization_round_trip() -> None: + auth = BatchSettlementErc3009Authorization.model_validate(_erc3009_auth_dict()) + assert auth.model_dump(by_alias=True) == _erc3009_auth_dict() + + +def test_permit2_authorization_round_trip() -> None: + auth = BatchSettlementPermit2Authorization.model_validate(_permit2_auth_dict()) + dumped = auth.model_dump(by_alias=True) + assert dumped == _permit2_auth_dict() + assert isinstance(auth.permitted, BatchSettlementPermit2Permitted) + assert isinstance(auth.witness, BatchSettlementPermit2Witness) + + +# --- extra="ignore" wire contract (BaseX402Model default) --- + + +def test_unknown_wire_field_is_silently_dropped() -> None: + """``BaseX402Model`` inherits Pydantic's default ``extra="ignore"``. + + This pins the contract: unknown wire fields are dropped, not rejected. + The cross-language fixture layer (Layer 2 L2.x) is responsible for the + schema-strictness side of TS↔Python interop. + """ + data = {**_voucher_dict(), "futureField": "should-be-dropped"} + vf = BatchSettlementVoucherFields.model_validate(data) + dumped = vf.model_dump(by_alias=True) + assert "futureField" not in dumped + assert dumped == _voucher_dict() diff --git a/scripts/check.sh b/scripts/check.sh new file mode 100755 index 0000000000..25194c106c --- /dev/null +++ b/scripts/check.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +# Layer 1 universal gate for the batch-settlement Python port. +# +# Usage (run from the repository root): +# bash scripts/check.sh +# +# Steps (PR1 scope): +# 1. ruff lint +# 2. ruff format check +# 3. mypy (scoped to the package, --follow-imports=silent) +# 4. pytest +# 5. commit signing (warn-only until PR submission) +# 6. towncrier changelog fragment present + +set -euo pipefail + +cd "$(git rev-parse --show-toplevel)/python/x402" + +PKG=mechanisms/evm/batch_settlement +TESTS=tests/unit/mechanisms/evm/batch_settlement + +echo "[1/6] ruff lint..." +uv run ruff check "$PKG" "$TESTS" + +echo "[2/6] ruff format check..." +uv run ruff format --check "$PKG" "$TESTS" + +echo "[3/6] mypy..." +uv run mypy --follow-imports=silent "$PKG" "$TESTS" + +echo "[4/6] pytest..." +uv run pytest "$TESTS" -v + +echo "[5/6] commit signing (HEAD)..." +if git log --show-signature -1 2>&1 | grep -qE "Good (signature|.*signature)"; then + echo " ✅ signed" +else + echo " ⚠ HEAD not verified as signed (PR submission requires signed commits)" +fi + +echo "[6/6] towncrier changelog fragment..." +# Only count fragments with valid towncrier suffixes (per pyproject.toml). +if compgen -G "changelog.d/*.feature.md" > /dev/null \ + || compgen -G "changelog.d/*.bugfix.md" > /dev/null \ + || compgen -G "changelog.d/*.doc.md" > /dev/null \ + || compgen -G "changelog.d/*.removal.md" > /dev/null \ + || compgen -G "changelog.d/*.misc.md" > /dev/null; then + echo " ✅ found" +else + echo " ❌ no changelog fragment in python/x402/changelog.d/" >&2 + exit 1 +fi + +echo "" +echo "✅ all checks passed"