diff --git a/README.md b/README.md index f71f048..d5ef11c 100644 --- a/README.md +++ b/README.md @@ -452,6 +452,125 @@ wire dictionaries from upstream models. Upstream fills omitted requirement are preserved. These typed models do not preserve arbitrary unknown top-level fields. Parsing a model or creating an identifier does not verify or settle a payment. +## x402 Buyer + +Install `inflowpay[x402]` to pay with an InFlow account. `Buyer.create()` loads +the account's supported payment methods before returning. Supply your buyer API +key or access-token provider through `ClientOptions`; those credentials are sent +to InFlow, not to the merchant. + +```python +import asyncio +import os + +import httpx +from x402.http.clients.httpx import x402AsyncTransport + +from inflowpay import ClientOptions +from inflowpay.x402.buyer import Buyer + + +async def main(): + async with ( + await Buyer.create( + ClientOptions(api_key=os.environ["INFLOW_API_KEY"], environment="sandbox") + ) as buyer, + httpx.AsyncClient(transport=x402AsyncTransport(buyer), follow_redirects=False) as http, + ): + response = await http.get(os.environ["PAID_RESOURCE_URL"]) + response.raise_for_status() + print(response.text) + + +asyncio.run(main()) +``` + +The upstream transport reads the merchant's payment requirements, asks the Buyer +for a payment payload, and retries the resource request with that payload. An +InFlow-managed payment can wait for the account owner to approve it. The default +approval wait is 15 minutes, polling every 5 seconds; configure `pending_timeout` +and `poll_interval` in seconds on `Buyer.create()`. + +### Show an approval before waiting + +Use `await buyer.prepare(requirement, resource)` when your application needs the +`approval_id` and `transaction_id` before waiting. Pass a selected upstream +`PaymentRequirements` and `ResourceInfo`. Then call `await payment.await_payload()` +to obtain `encoded_payload`, `payment_payload`, and `transaction_id`, or +`await payment.cancel()` to request approval cancellation. + +Repeated waits share the completed payload and run completion hooks once. A +timeout before receiving a payload allows another wait on the same handle without +creating a second approval. Cancelling an individual waiting task does not cancel +the server approval; neither does closing the Buyer. The application owns that +decision in the two-phase flow. The automatic `create_payment_payload()` flow +requests cancellation if it fails before receiving a signed payload. + +### Payment selection and spending controls + +`inflowpay.x402.buyer.Buyer` supports two signing paths. It requests an +InFlow-managed payment when InFlow supports the offered payment requirement. +Otherwise, it passes the request to an external-wallet scheme registered with +the upstream x402 client. + +Use `register_policy()` to filter payment requirements for either path. For +InFlow-managed payments, the account's server-side policies and approval process +also apply. The upstream `set_spend_controls()` settings apply only to +external-wallet payments; they do not cap an InFlow-managed payment. This is the +same separation used by the InFlow Node SDK. If your application needs a local +amount limit for managed payments, enforce it in a registered payment policy. + +The explicit `prepare(requirement, resource)` method uses the requirement you +provide, rather than selecting among offers or running selection policies. Apply +your application's selection policy before calling it. InFlow's server-side +policies and approvals still apply. + +### External wallets + +Install `inflowpay[evm]` or `inflowpay[svm]` and register an upstream signing scheme +with `buyer.register(network, scheme)` to allow external-wallet payments. The +Buyer first selects a supported InFlow payment; otherwise it delegates to the +registered upstream schemes. `prefer` controls the managed scheme order and +defaults to `("balance", "exact")`. Managed signing does not accept Permit2 +requirements; those use an external wallet. + +InFlow-managed payment requests use asynchronous network calls. External-wallet +payments run through the upstream Python x402 signing implementation. Its Solana +signer performs synchronous network requests for mint metadata and, when needed, +a recent blockhash. Those requests block other tasks on the same event loop even +when the caller awaits `create_payment_payload()`. The upstream TypeScript Solana +signer awaits these network requests instead. + +The Python Buyer preserves upstream signing behavior; it does not move wallet +signers into background threads. Applications using external wallets should +account for this blocking work when sharing an event loop with other requests. +See [upstream issue #3649](https://github.com/x402-foundation/x402/issues/3649) +for the reproduction and comparison with the TypeScript implementation. + +### EIP-7702 sponsorship + +`inflowpay.x402.eip7702.SponsorshipExtension` supports external EVM wallets when +the merchant advertises `inflowEip7702GasSponsoring` for an exact Permit2 payment. +Install the `evm` extra. Construct the extension with anonymous `ClientOptions`, +a `SponsorshipSigner`, and an asynchronous consent callback, then register it with +`buyer.register_extension(extension)`. Keep the extension's asynchronous context +manager open while creating payments; it owns a separate HTTP client. + +The signer provides its address and three asynchronous methods: `allowance()` +reads the token allowance; `sign_message()` signs the supplied operation hash +using Ethereum's personal-message prefix; and `sign_authorization()` signs the +supplied EIP-7702 authorization. Both signing methods return 65-byte signatures +with recovery ID 27 or 28. The signer must belong to the wallet registered with +the upstream payment scheme. + +Sponsorship is skipped when the existing Permit2 allowance covers the payment. +Otherwise, the extension requests preparation from InFlow and verifies the +returned contracts, payment calls, operation hash, and expiry before signing. +If account delegation is required, the consent callback must return `True` only +after the owner agrees: delegation persists even if the payment fails. The +extension returns signed data; it does not broadcast a transaction. Availability +depends on the InFlow environment's sponsorship endpoint and supported networks. + ## x402 facilitator capabilities Facilitator capabilities describe the payment schemes, networks, and extensions diff --git a/scripts/verify_distribution.py b/scripts/verify_distribution.py index 6844fee..c103df0 100644 --- a/scripts/verify_distribution.py +++ b/scripts/verify_distribution.py @@ -24,6 +24,7 @@ from x402.http.middleware.fastapi import payment_middleware from x402.mechanisms.evm.exact import ExactEvmClientScheme from x402.mechanisms.svm.exact import ExactSvmClientScheme +from inflowpay.x402.eip7702 import SponsorshipExtension, SponsorshipSigner import x402.mcp """ @@ -69,6 +70,15 @@ async def check_seller(): assert PaymentRequirements is UpstreamRequirements entry = payment_identifier_entry(declare_payment_identifier(), generate_payment_id()) assert entry is not None and entry['info']['required'] is False +from inflowpay.x402.buyer import Buyer +from inflowpay import ClientOptions +import asyncio +import httpx +async def check_buyer(): + transport = httpx.MockTransport(lambda request: httpx.Response(200, json={'kinds': []})) + async with await Buyer.create(ClientOptions(transport=transport)) as buyer: + assert (await buyer.get_supported()).kinds == [] +asyncio.run(check_buyer()) for name in ('mpp', 'mcp', 'web3', 'solana', 'fastapi', 'rfc8785'): assert util.find_spec(name) is None, name """ diff --git a/src/inflowpay/x402/_payment.py b/src/inflowpay/x402/_payment.py new file mode 100644 index 0000000..180876a --- /dev/null +++ b/src/inflowpay/x402/_payment.py @@ -0,0 +1,185 @@ +from __future__ import annotations + +import asyncio +import math +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from typing import cast +from urllib.parse import quote + +from x402.schemas import PaymentPayload + +from .._runtime import Client +from ..errors import InflowApiError + + +class X402PaymentError(Exception): + def __init__(self, code: str, message: str, *, status: str | None = None) -> None: + self.code = code + self.status = status + super().__init__(message) + + +def response_object(value: object) -> dict[str, object]: + if not isinstance(value, dict): + raise X402PaymentError("invalid-response", "Expected an x402 response object") + return cast(dict[str, object], value) + + +def response_string(value: object) -> str: + if not isinstance(value, str) or not value: + raise X402PaymentError("invalid-response", "Missing x402 response field") + return value + + +def validate_wait(poll_interval: float, timeout: float) -> None: + if any(not math.isfinite(value) or value < 0 for value in (poll_interval, timeout)): + raise ValueError("Polling settings must be finite and nonnegative") + + +@dataclass(frozen=True) +class EncodedPayment: + encoded_payload: str + payment_payload: PaymentPayload + transaction_id: str + + +def _observe_completion(task: asyncio.Task[EncodedPayment]) -> None: + # Hooks may finish after the last waiter cancels. Keep their error available for + # a later wait without emitting an unobserved-task exception in the meantime. + if not task.cancelled(): + task.exception() + + +class PreparedPayment: + def __init__( + self, + client: Client, + created: dict[str, object], + *, + poll_interval: float, + timeout: float, + after: Callable[[PaymentPayload], Awaitable[None]], + ) -> None: + self.transaction_id = response_string(created.get("transactionId")) + self.approval_id = response_string(created.get("approvalId")) + self._approved = created.get("approvalStatus") == "APPROVED" + self._client = client + self._poll_interval = poll_interval + self._timeout = timeout + self._after = after + self._completion: asyncio.Task[EncodedPayment] | None = None + self._received = False + self._cancelled = False + self._cancellation: asyncio.Task[None] | None = None + self._waiters = 0 + + async def status(self) -> str: + return response_string((await self._read()).get("status")) + + async def _read(self) -> dict[str, object]: + return response_object( + await self._client.request( + "GET", f"/v1/transactions/{quote(self.transaction_id, safe='')}/x402" + ) + ) + + async def cancel(self) -> None: + self._cancelled = True + if self._completion is not None and not self._completion.done(): + self._completion.cancel() + if self._cancellation is None: + self._cancellation = asyncio.create_task(self._client.cancel_approval(self.approval_id)) + await asyncio.shield(self._cancellation) + + async def await_payload( + self, *, poll_interval: float | None = None, timeout: float | None = None + ) -> EncodedPayment: + if self._cancelled: + raise X402PaymentError("payment-cancelled", "x402 payment cancelled") + interval = self._poll_interval if poll_interval is None else poll_interval + budget = self._timeout if timeout is None else timeout + validate_wait(interval, budget) + if self._completion is None: + self._completion = asyncio.create_task(self._resolve(interval, budget)) + self._completion.add_done_callback(_observe_completion) + completion = self._completion + self._waiters += 1 + try: + # A cancelled waiter must not cancel another caller's wait for this same payment. + result = await asyncio.shield(completion) + return EncodedPayment( + result.encoded_payload, + result.payment_payload.model_copy(deep=True), + result.transaction_id, + ) + except asyncio.CancelledError: + if self._cancelled: + raise X402PaymentError("payment-cancelled", "x402 payment cancelled") from None + raise + finally: + self._waiters -= 1 + if self._waiters == 0 and not self._received and not completion.done(): + completion.cancel() + await asyncio.gather(completion, return_exceptions=True) + if completion.done() and not self._received and self._completion is completion: + self._completion = None + + async def _resolve(self, interval: float, timeout: float) -> EncodedPayment: + deadline = asyncio.get_running_loop().time() + timeout + budget = asyncio.timeout_at(deadline) + first = self._approved + try: + async with budget: + while True: + if asyncio.get_running_loop().time() >= deadline: + raise X402PaymentError("payment-timeout", "x402 payment wait timed out") + try: + response = await self._read() + except InflowApiError as error: + if error.http_status not in (0, 429) and error.http_status < 500: + raise + response = {} + if asyncio.get_running_loop().time() >= deadline: + raise X402PaymentError("payment-timeout", "x402 payment wait timed out") + if ( + response.get("encodedPayload") is not None + and response.get("paymentPayload") is not None + ): + result = EncodedPayment( + response_string(response["encodedPayload"]), + PaymentPayload.model_validate(response["paymentPayload"]), + self.transaction_id, + ) + if result.payment_payload.x402_version != 2: + raise X402PaymentError("invalid-response", "Expected x402 version 2") + self._received = True + break + status = response.get("status") + if status in ("DECLINED", "EXPIRED", "GENERAL_ERROR", "INSUFFICIENT_FUNDS"): + raise X402PaymentError( + "payment-failed", "x402 payment failed", status=status + ) + if first: + first = False + else: + await asyncio.sleep(interval) + except TimeoutError as error: + if budget.expired(): + raise X402PaymentError("payment-timeout", "x402 payment wait timed out") from error + raise + # Cache completion, including hook errors: repeated waits must not repeat hook side effects. + await self._after(result.payment_payload.model_copy(deep=True)) + if self._cancelled: + raise X402PaymentError("payment-cancelled", "x402 payment cancelled") + return result + + async def _close(self) -> None: + # Closing the client stops local waits; the caller still owns two-phase approvals. + self._cancelled = True + if self._completion is not None and not self._completion.done(): + self._completion.cancel() + if self._completion is not None: + await asyncio.gather(self._completion, return_exceptions=True) + if self._cancellation is not None: + await asyncio.shield(self._cancellation) diff --git a/src/inflowpay/x402/buyer.py b/src/inflowpay/x402/buyer.py new file mode 100644 index 0000000..6b1ec21 --- /dev/null +++ b/src/inflowpay/x402/buyer.py @@ -0,0 +1,383 @@ +from __future__ import annotations + +import asyncio +import inspect +import re +from collections.abc import AsyncIterator, Callable, Mapping, Sequence +from copy import deepcopy +from types import TracebackType +from typing import Any, Self, cast +from urllib.parse import quote +from weakref import WeakSet + +from x402 import x402Client +from x402.schemas import ( + AbortResult, + NoMatchingRequirementsError, + PaymentAbortedError, + PaymentCreatedContext, + PaymentCreationContext, + PaymentCreationFailureContext, + PaymentPayload, + PaymentPayloadV1, + PaymentRequired, + PaymentRequiredV1, + PaymentRequirements, + RecoveredPayloadResult, + ResourceInfo, + SupportedResponse, +) + +from .._runtime import Client +from ..errors import InflowApiError +from ..options import ClientOptions +from ._core import PAYMENT_IDENTIFIER, read_payment_identifier, validate_payment_id +from ._payment import ( + EncodedPayment, + PreparedPayment, + X402PaymentError, + response_object, + validate_wait, +) + +__all__ = ["Buyer", "EncodedPayment", "PreparedPayment", "X402PaymentError"] + + +def _atomic(value: str) -> int | None: + matched = re.fullmatch(r"(-?)([0-9]+)(?:\.([0-9]+))?", value.strip()) + if matched is None: + return None + fraction = ((matched[3] or "") + "0" * 18)[:18] + return int(matched[1] + matched[2] + fraction) + + +class Buyer(x402Client): + def __init__( + self, + client: Client, + supported: SupportedResponse, + *, + prefer: Sequence[str], + poll_interval: float, + pending_timeout: float, + ) -> None: + super().__init__() + self._client = client + self._supported = supported.model_copy(deep=True) + self._expires = asyncio.get_running_loop().time() + 3600 + self._refresh: asyncio.Task[None] | None = None + self._prefer = tuple(prefer) + self._poll_interval = poll_interval + self._pending_timeout = pending_timeout + self._payments: WeakSet[PreparedPayment] = WeakSet() + self._closed = False + + @classmethod + async def create( + cls, + options: ClientOptions, + *, + prefer: Sequence[str] = ("balance", "exact"), + poll_interval: float = 5, + pending_timeout: float = 900, + ) -> Self: + validate_wait(poll_interval, pending_timeout) + client = Client(options) + try: + supported = SupportedResponse.model_validate( + await client.request("GET", "/v1/transactions/x402-supported") + ) + return cls( + client, + supported, + prefer=prefer, + poll_interval=poll_interval, + pending_timeout=pending_timeout, + ) + except BaseException: + await client.aclose() + raise + + async def __aenter__(self) -> Self: + self._check_open() + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + traceback: TracebackType | None, + ) -> None: + await self.aclose() + + def _check_open(self) -> None: + if self._closed: + raise RuntimeError("x402 Buyer is closed") + + async def aclose(self) -> None: + self._closed = True + try: + if self._refresh is not None: + self._refresh.cancel() + await asyncio.gather(self._refresh, return_exceptions=True) + await asyncio.gather(*(payment._close() for payment in tuple(self._payments))) + finally: + self._payments.clear() + await self._client.aclose() + + async def get_supported(self, *, refresh: bool = False) -> SupportedResponse: + self._check_open() + if refresh or asyncio.get_running_loop().time() >= self._expires: + if self._refresh is None: + self._refresh = asyncio.create_task(self._refresh_supported()) + self._refresh.add_done_callback(self._finish_refresh) + await asyncio.shield(self._refresh) + return self._supported.model_copy(deep=True) + + async def _refresh_supported(self) -> None: + fresh = SupportedResponse.model_validate( + await self._client.request("GET", "/v1/transactions/x402-supported") + ) + self._supported = fresh + self._expires = asyncio.get_running_loop().time() + 3600 + + def _finish_refresh(self, task: asyncio.Task[None]) -> None: + if not task.cancelled(): + task.exception() + self._refresh = None + + def supports(self, requirement: PaymentRequirements) -> bool: + return requirement.extra.get("assetTransferMethod") != "permit2" and any( + kind.x402_version == 2 + and kind.scheme == requirement.scheme + and kind.network == requirement.network + for kind in self._supported.kinds + ) + + async def select_inflow_requirement( + self, required: PaymentRequired + ) -> PaymentRequirements | None: + self._check_open() + if required.x402_version != 2: + raise ValueError("InFlow managed payments require x402 version 2") + candidates = [ + r.model_copy(deep=True) + for r in required.accepts + if r.scheme in self._prefer and self.supports(r) + ] + if not candidates: + return None + # Match Node: upstream spend controls govern external wallets. Managed payments + # use registered policies here and InFlow's account approval/policies on the server. + for policy in tuple(self._policies): + candidates = [ + PaymentRequirements.model_validate(r) for r in policy(2, [r for r in candidates]) + ] + if not candidates: + raise NoMatchingRequirementsError( + "All managed requirements filtered out by policies" + ) + for scheme in self._prefer: + matches = [r for r in candidates if r.scheme == scheme and self.supports(r)] + if matches: + if scheme == "balance" and len(matches) > 1: + affordable = await self._affordable(matches) + if affordable is not None: + return affordable + return matches[0] + raise NoMatchingRequirementsError( + "Payment policies returned no supported InFlow requirement" + ) + + async def _affordable( + self, requirements: list[PaymentRequirements] + ) -> PaymentRequirements | None: + try: + response = response_object(await self._client.request("GET", "/v1/balances")) + except (InflowApiError, X402PaymentError): + return None + balances = response.get("balances") + if not isinstance(balances, list): + return None + available: dict[str, int] = {} + for balance in balances: + if ( + isinstance(balance, dict) + and isinstance(balance.get("currency"), str) + and isinstance(balance.get("available"), str) + ): + amount = _atomic(balance["available"]) + if amount is not None: + available[balance["currency"]] = amount + for requirement in requirements: + currency = requirement.extra.get("assetName") + if isinstance(currency, str) and currency in available: + try: + amount = int(requirement.amount) + except ValueError: + continue + if available[currency] >= amount: + return requirement + return None + + async def _hooks(self, phase: str, context: PaymentCreationContext) -> AsyncIterator[object]: + # Upstream types use Any for extension hooks. Do not run external scheme hooks + # on a payment signed by InFlow rather than that external scheme. + hooks: list[Callable[..., Any]] = list(getattr(self, f"_{phase}_hooks")) + # Managed contexts are constructed exclusively from V2 PaymentRequired. + declarations = cast(PaymentRequired, context.payment_required).extensions or {} + for extension in self.get_extensions(): + hook = getattr( + getattr(extension, "hooks", None), "on_" + phase.removeprefix("on_"), None + ) + if extension.key in declarations and hook is not None: + declaration = declarations[extension.key] + hooks.append( + lambda ctx, hook=hook, declaration=declaration: hook(deepcopy(declaration), ctx) + ) + for hook in hooks: + # Preserve the original exception; application exceptions need not support copying. + memo = ( + {id(context.error): context.error} + if isinstance(context, PaymentCreationFailureContext) + else {} + ) + result = hook(deepcopy(context, memo)) + yield await result if inspect.isawaitable(result) else result + + async def _before(self, context: PaymentCreationContext) -> None: + async for result in self._hooks("before_payment_creation", context): + if isinstance(result, AbortResult): + raise PaymentAbortedError(result.reason) + + async def _after(self, context: PaymentCreationContext, payload: PaymentPayload) -> None: + created = PaymentCreatedContext( + payment_required=context.payment_required, + selected_requirements=context.selected_requirements, + payment_payload=payload, + ) + async for _ in self._hooks("after_payment_creation", created): + pass + + async def prepare( + self, + requirement: PaymentRequirements, + resource: ResourceInfo, + *, + payment_id: str | None = None, + extensions: Mapping[str, object] | None = None, + transaction_request_extensions: Mapping[str, object] | None = None, + ) -> PreparedPayment: + self._check_open() + if not self.supports(requirement): + raise X402PaymentError( + "unsupported-capability", "InFlow cannot sign this payment requirement" + ) + if payment_id is not None and not validate_payment_id(payment_id): + raise ValueError("Invalid payment identifier") + selected = requirement.model_copy(deep=True) + required = PaymentRequired( + accepts=[selected], + resource=resource.model_copy(deep=True), + extensions=deepcopy(dict(extensions or {})), + ) + context = PaymentCreationContext(payment_required=required, selected_requirements=selected) + await self._before(context) + return await self._prepare(context, payment_id, transaction_request_extensions) + + async def _prepare( + self, + context: PaymentCreationContext, + payment_id: str | None, + extra: Mapping[str, object] | None, + ) -> PreparedPayment: + body = deepcopy(dict(extra or {})) + required = cast(PaymentRequired, context.payment_required) + body.update( + accept=context.selected_requirements.model_dump(by_alias=True, exclude_none=True), + resource=required.resource.model_dump(by_alias=True, exclude_none=True) + if required.resource + else None, + x402Version=2, + ) + if payment_id is not None: + body["remotePaymentId"] = payment_id + # Creation is not retried: without a remotePaymentId it creates a second approval. + created = response_object( + await self._client.request("POST", "/v1/transactions/x402", body=body) + ) + + async def after(payload: PaymentPayload) -> None: + await self._after(context, payload) + + prepared = PreparedPayment( + self._client, + created, + poll_interval=self._poll_interval, + timeout=self._pending_timeout, + after=after, + ) + self._payments.add(prepared) + return prepared + + async def get_x402_payload(self, transaction_id: str) -> dict[str, object]: + self._check_open() + return response_object( + await self._client.request( + "GET", f"/v1/transactions/{quote(transaction_id, safe='')}/x402" + ) + ) + + async def cancel_approval(self, approval_id: str) -> None: + await self._client.cancel_approval(approval_id) + + async def create_payment_payload( + self, + payment_required: PaymentRequired | PaymentRequiredV1, + resource: ResourceInfo | None = None, + extensions: dict[str, Any] | None = None, + ) -> PaymentPayload | PaymentPayloadV1: + self._check_open() + if not isinstance(payment_required, PaymentRequired) or payment_required.x402_version != 2: + raise ValueError("InFlow Buyer requires x402 version 2") + required = payment_required.model_copy(deep=True) + if resource is not None: + required.resource = resource.model_copy(deep=True) + if extensions is not None: + required.extensions = deepcopy(extensions) + selected = await self.select_inflow_requirement(required) + if selected is None: + # Preserve upstream wallet execution; its Solana network calls are synchronous. + payload = await super().create_payment_payload(required) + if not isinstance(payload, PaymentPayload) or payload.x402_version != 2: + raise X402PaymentError("invalid-response", "Expected x402 version 2") + declaration = read_payment_identifier( + (required.extensions or {}).get(PAYMENT_IDENTIFIER) + ) + if declaration is not None and declaration["info"]["required"] is True: + entry = read_payment_identifier((payload.extensions or {}).get(PAYMENT_IDENTIFIER)) + if entry is None or not validate_payment_id(entry["info"].get("id")): + raise X402PaymentError( + "invalid-input", "Required payment identifier was not produced" + ) + return payload + context = PaymentCreationContext(payment_required=required, selected_requirements=selected) + await self._before(context) + prepared: PreparedPayment | None = None + try: + prepared = await self._prepare(context, None, None) + return (await prepared.await_payload()).payment_payload + except BaseException as error: + if prepared is not None: + # An after-hook failure does not undo a payment already signed by InFlow. + if not prepared._received: + await prepared.cancel() + self._payments.discard(prepared) + if isinstance(error, Exception): + failed = PaymentCreationFailureContext( + payment_required=required, selected_requirements=selected, error=error + ) + async for result in self._hooks("on_payment_creation_failure", failed): + if isinstance(result, RecoveredPayloadResult): + return result.payload + raise diff --git a/src/inflowpay/x402/eip7702.py b/src/inflowpay/x402/eip7702.py new file mode 100644 index 0000000..b67f469 --- /dev/null +++ b/src/inflowpay/x402/eip7702.py @@ -0,0 +1,331 @@ +from __future__ import annotations + +import re +import time +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from types import TracebackType +from typing import Protocol, Self + +from eth_abi.abi import encode +from eth_account.typed_transactions.set_code_transaction import Authorization as EvmAuthorization +from eth_keys.datatypes import Signature +from eth_utils.crypto import keccak +from x402.schemas import PaymentPayload, PaymentRequired + +from .._runtime import Client +from ..options import ClientOptions +from ._core import INFLOW_EIP7702_GAS_SPONSORING +from ._payment import response_object + +PERMIT2 = "0x000000000022d473030f116ddee9f6b43ac78ba3" +PROXY = "0x402085c248eea27d92e8b30b2c58ed07f9e20001" +DELEGATION = "0x77021100bd87b7008e5e1989d0eb38555d0d0000" +ENTRY_POINT = "0x0000000071727de22e5e9d8baf0edac6f37da032" + + +@dataclass(frozen=True) +class Authorization: + address: str + chain_id: int + nonce: int + + +class SponsorshipSigner(Protocol): + @property + def address(self) -> str: ... + + async def allowance(self, token: str, owner: str, spender: str) -> int: ... + + async def sign_message(self, operation_hash: bytes) -> bytes: + """Sign the hash using Ethereum's personal-message prefix.""" + ... + + async def sign_authorization(self, authorization: Authorization) -> bytes: + """Return the delegation signature as 65 bytes: r, s, and recovery ID 27 or 28.""" + ... + + +def _require(condition: bool, detail: str) -> None: + if not condition: + raise ValueError(f"Invalid EIP-7702 {detail}") + + +def _address(value: object) -> str: + _require( + isinstance(value, str) and re.fullmatch(r"0x[0-9a-fA-F]{40}", value) is not None, "address" + ) + return str(value).lower() + + +def _bytes(value: object, size: int | None = None) -> bytes: + _require( + isinstance(value, str) and re.fullmatch(r"0x(?:[0-9a-fA-F]{2})*", value) is not None, + "bytes", + ) + result = bytes.fromhex(str(value)[2:]) + _require(size is None or len(result) == size, "byte length") + return result + + +def _integer(value: object) -> int: + _require(type(value) is int and 0 <= value <= 9007199254740991, "integer") + assert isinstance(value, int) + return value + + +def _number(value: object, *, hexadecimal: bool = False, bits: int = 256) -> int: + pattern = r"0x(?:0|[1-9a-f][0-9a-f]*)" if hexadecimal else r"(?:0|[1-9][0-9]*)" + _require(isinstance(value, str) and re.fullmatch(pattern, value) is not None, "quantity") + result = int(str(value), 16 if hexadecimal else 10) + _require(result < 1 << bits, "quantity range") + return result + + +def _call(signature: str, types: list[str], values: list[object]) -> bytes: + return keccak(text=signature)[:4] + encode(types, values) + + +def _payment_batch(payment: PaymentPayload, owner: str) -> tuple[str, int, int, int, bytes]: + requirement = payment.accepted + _require( + payment.x402_version == 2 + and re.fullmatch(r"eip155:[1-9][0-9]*", requirement.network) is not None, + "payment network", + ) + chain_id = _integer(int(requirement.network[7:])) + authorization = response_object(payment.payload.get("permit2Authorization")) + permitted = response_object(authorization.get("permitted")) + witness = response_object(authorization.get("witness")) + asset = _address(permitted.get("token")) + amount = _number(permitted.get("amount")) + deadline = _number(authorization.get("deadline")) + _require( + amount > 0 + and amount == _number(requirement.amount) + and asset == _address(requirement.asset) + and _address(authorization.get("from")) == owner + and _address(authorization.get("spender")) == PROXY + and _address(requirement.extra.get("permit2Proxy")) == PROXY + and _address(witness.get("to")) == _address(requirement.pay_to), + "payment authorization", + ) + _require(deadline > int(time.time()), "payment deadline") + settle = _call( + "settle(((address,uint256),uint256,uint256),address,(address,uint256),bytes)", + ["((address,uint256),uint256,uint256)", "address", "(address,uint256)", "bytes"], + [ + ((asset, amount), _number(authorization.get("nonce")), deadline), + owner, + (_address(witness.get("to")), _number(witness.get("validAfter"))), + _bytes(payment.payload.get("signature"), 65), + ], + ) + approve = _call("approve(address,uint256)", ["address", "uint256"], [PERMIT2, amount]) + batch = _call( + "executeBatch((address,uint256,bytes)[])", + ["(address,uint256,bytes)[]"], + [[(asset, 0, approve), (PROXY, 0, settle)]], + ) + return asset, amount, deadline, chain_id, batch + + +def _operation_hash(value: object, owner: str, batch: bytes, chain_id: int) -> bytes: + operation = response_object(value) + _require( + set(operation) + == { + "sender", + "nonce", + "callData", + "callGasLimit", + "verificationGasLimit", + "preVerificationGas", + "maxFeePerGas", + "maxPriorityFeePerGas", + "paymaster", + "paymasterData", + "paymasterVerificationGasLimit", + "paymasterPostOpGasLimit", + }, + "operation fields", + ) + _require( + _address(operation["sender"]) == owner and _bytes(operation["callData"]) == batch, + "operation payment", + ) + nonce = _number(operation["nonce"], hexadecimal=True) + _require(nonce >> 64 == 1, "account nonce key") + call_gas = _number(operation["callGasLimit"], hexadecimal=True, bits=128) + verification_gas = _number(operation["verificationGasLimit"], hexadecimal=True, bits=128) + _require( + call_gas > 0 + and verification_gas > 0 + and _address(operation["paymaster"]) == "0x" + "00" * 20 + and operation["paymasterData"] == "0x" + and all( + operation[key] == "0x0" + for key in ( + "preVerificationGas", + "maxFeePerGas", + "maxPriorityFeePerGas", + "paymasterVerificationGasLimit", + "paymasterPostOpGasLimit", + ) + ), + "bundler sponsorship profile", + ) + # InFlow pins EntryPoint 0.7 with empty initCode and paymasterAndData. + packed = encode( + ["address", "uint256", "bytes32", "bytes32", "bytes32", "uint256", "bytes32", "bytes32"], + [ + owner, + nonce, + keccak(b""), + keccak(batch), + verification_gas.to_bytes(16) + call_gas.to_bytes(16), + 0, + bytes(32), + keccak(b""), + ], + ) + return keccak( + encode(["bytes32", "address", "uint256"], [keccak(packed), ENTRY_POINT, chain_id]) + ) + + +def _signature(value: bytes, message_hash: bytes, owner: str) -> str: + _require(isinstance(value, bytes) and len(value) == 65 and value[64] in (27, 28), "signature") + signature = Signature(value[:64] + bytes([value[64] - 27])) + _require( + signature.recover_public_key_from_msg_hash(message_hash).to_address() == owner, + "signature owner", + ) + return "0x" + value.hex() + + +class SponsorshipExtension: + key = INFLOW_EIP7702_GAS_SPONSORING + hooks = None + transport_hooks = None + + def __init__( + self, + options: ClientOptions, + signer: SponsorshipSigner, + consent: Callable[[Authorization], Awaitable[bool]], + ) -> None: + # This public endpoint is independent of the managed Buyer's account credentials. + if options.api_key is not None or options.access_token is not None: + raise ValueError("EIP-7702 sponsorship uses anonymous ClientOptions") + self._owner = _address(signer.address) + self._signer = signer + self._consent = consent + self._client = Client(options) + + async def __aenter__(self) -> Self: + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + traceback: TracebackType | None, + ) -> None: + await self.aclose() + + async def aclose(self) -> None: + await self._client.aclose() + + async def enrich_payment_payload( + self, payment_payload: PaymentPayload, payment_required: PaymentRequired + ) -> PaymentPayload: + declaration = (payment_required.extensions or {}).get(self.key) + if ( + declaration is None + or payment_payload.accepted.scheme != "exact" + or payment_payload.accepted.extra.get("assetTransferMethod") != "permit2" + ): + return payment_payload + declaration = response_object(declaration) + info = response_object(declaration.get("info")) + _require( + set(declaration) == {"info"} and set(info) == {"version"} and info["version"] == "1", + "declaration", + ) + payment = payment_payload.model_copy(deep=True) + asset, amount, deadline, chain_id, batch = _payment_batch(payment, self._owner) + allowance = await self._signer.allowance(asset, self._owner, PERMIT2) + _require(type(allowance) is int and allowance >= 0, "allowance") + if allowance >= amount: + return payment_payload + prepared = response_object( + await self._client.request( + "POST", + "/v1/x402/eip7702/prepare", + body={ + "paymentPayload": payment.model_dump(by_alias=True, exclude_none=True), + "paymentRequirements": payment.accepted.model_dump( + by_alias=True, exclude_none=True + ), + }, + ) + ) + identifier = prepared.get("sponsorshipId") + _require( + isinstance(identifier, str) + and re.fullmatch(r"[0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}", identifier) + is not None, + "sponsorship identifier", + ) + _require( + _integer(prepared.get("chainId")) == chain_id + and _address(prepared.get("entryPoint")) == ENTRY_POINT + and prepared.get("entryPointVersion") == "0.7" + and _address(prepared.get("delegation")) == DELEGATION, + "preparation contracts", + ) + operation_hash = _operation_hash( + prepared.get("userOperation"), self._owner, batch, chain_id + ) + _require(_bytes(prepared.get("userOperationHash"), 32) == operation_hash, "operation hash") + expires = _integer(prepared.get("expiresAt")) + + def check_expiry() -> None: + _require(int(time.time()) < expires <= deadline, "sponsorship expiry") + + check_expiry() + signed: dict[str, object] = {"version": "1", "sponsorshipId": identifier} + if "authorization" in prepared: + fields = response_object(prepared["authorization"]) + _require(set(fields) == {"address", "chainId", "nonce"}, "authorization fields") + authorization = Authorization( + _address(fields["address"]), _integer(fields["chainId"]), _integer(fields["nonce"]) + ) + _require( + authorization.address == DELEGATION and authorization.chain_id == chain_id, + "delegation authorization", + ) + # Delegation persists even if payment execution fails; consent is mandatory. + _require(await self._consent(authorization), "delegation consent") + check_expiry() + authorization_hash = bytes( + EvmAuthorization( + chainId=chain_id, + address=bytes.fromhex(DELEGATION[2:]), + nonce=authorization.nonce, + ).hash() + ) + signed["authorizationSignature"] = _signature( + await self._signer.sign_authorization(authorization), + authorization_hash, + self._owner, + ) + check_expiry() + signature = await self._signer.sign_message(operation_hash) + signed["signature"] = _signature( + signature, keccak(b"\x19Ethereum Signed Message:\n32" + operation_hash), self._owner + ) + check_expiry() + payment.extensions = {**(payment.extensions or {}), self.key: {"info": signed}} + return payment diff --git a/tests/fixtures/eip7702.json b/tests/fixtures/eip7702.json new file mode 100644 index 0000000..4610d4d --- /dev/null +++ b/tests/fixtures/eip7702.json @@ -0,0 +1,97 @@ +{ + "required": { + "x402Version": 2, + "resource": { + "url": "https://merchant.example/paid" + }, + "accepts": [ + { + "scheme": "exact", + "network": "eip155:8453", + "asset": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + "amount": "123", + "payTo": "0x5050A4F4b3f9338C3472dcC01A87C76A144b3c9c", + "maxTimeoutSeconds": 300, + "extra": { + "assetTransferMethod": "permit2", + "permit2Proxy": "0x402085c248EeA27D92E8b30b2C58ed07f9E20001" + } + } + ], + "extensions": { + "inflowEip7702GasSponsoring": { + "info": { + "version": "1" + } + } + } + }, + "payment": { + "x402Version": 2, + "accepted": { + "scheme": "exact", + "network": "eip155:8453", + "asset": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + "amount": "123", + "payTo": "0x5050A4F4b3f9338C3472dcC01A87C76A144b3c9c", + "maxTimeoutSeconds": 300, + "extra": { + "assetTransferMethod": "permit2", + "permit2Proxy": "0x402085c248EeA27D92E8b30b2C58ed07f9E20001" + } + }, + "payload": { + "permit2Authorization": { + "from": "0x1a642f0E3c3aF545E7AcBD38b07251B3990914F1", + "permitted": { + "token": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + "amount": "123" + }, + "spender": "0x402085c248EeA27D92E8b30b2C58ed07f9E20001", + "nonce": "7", + "deadline": "2000000300", + "witness": { + "to": "0x5050A4F4b3f9338C3472dcC01A87C76A144b3c9c", + "validAfter": "0" + } + }, + "signature": "0xf841cdf2c79608dce1ea98a6ead8daff0c7280a6fb63146d8e51929c4be728b9745173b6cccdb78f200becc3e91bb7a383063129e108c971de9d8a222e7b75b21c" + }, + "extensions": { + "inflowEip7702GasSponsoring": { + "info": { + "version": "1" + } + } + } + }, + "prepared": { + "sponsorshipId": "11111111-2222-4333-8444-555555555555", + "chainId": 8453, + "entryPoint": "0x0000000071727De22E5E9d8BAf0edAc6f37da032", + "entryPointVersion": "0.7", + "delegation": "0x77021100bD87b7008E5E1989d0eB38555d0d0000", + "authorization": { + "address": "0x77021100bD87b7008E5E1989d0eB38555d0d0000", + "chainId": 8453, + "nonce": 0 + }, + "userOperation": { + "sender": "0x1a642f0E3c3aF545E7AcBD38b07251B3990914F1", + "nonce": "0x10000000000000000", + "callData": "0x34fcd5be0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000120000000000000000000000000833589fcd6edb6e08f4c7c32d4f71b54bda02913000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000044095ea7b3000000000000000000000000000000000022d473030f116ddee9f6b43ac78ba3000000000000000000000000000000000000000000000000000000000000007b00000000000000000000000000000000000000000000000000000000000000000000000000000000402085c248eea27d92e8b30b2c58ed07f9e2000100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000060000000000000000000000000000000000000000000000000000000000000018413cd3b53000000000000000000000000833589fcd6edb6e08f4c7c32d4f71b54bda02913000000000000000000000000000000000000000000000000000000000000007b0000000000000000000000000000000000000000000000000000000000000007000000000000000000000000000000000000000000000000000000007735952c0000000000000000000000001a642f0e3c3af545e7acbd38b07251b3990914f10000000000000000000000005050a4f4b3f9338c3472dcc01a87c76a144b3c9c000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000041f841cdf2c79608dce1ea98a6ead8daff0c7280a6fb63146d8e51929c4be728b9745173b6cccdb78f200becc3e91bb7a383063129e108c971de9d8a222e7b75b21c0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + "callGasLimit": "0x493e0", + "verificationGasLimit": "0x186a0", + "preVerificationGas": "0x0", + "maxFeePerGas": "0x0", + "maxPriorityFeePerGas": "0x0", + "paymaster": "0x0000000000000000000000000000000000000000", + "paymasterData": "0x", + "paymasterVerificationGasLimit": "0x0", + "paymasterPostOpGasLimit": "0x0" + }, + "userOperationHash": "0xf4e01320fcc9ce07827b343fde767b27af7ab32d9abfe11614ee4b42f0cf9cf1", + "expiresAt": 2000000120 + }, + "operationSignature": "0x1df2ea2149502b90e2d57f065c2a6a9d2fadb146c220fd23e37001c2ee2416a935829c84cf6ccf8522d4330b645a1e3fa8feb09d7ae2705d44027fae7055fb1d1c" +} diff --git a/tests/fixtures/x402-buyer.json b/tests/fixtures/x402-buyer.json new file mode 100644 index 0000000..c223483 --- /dev/null +++ b/tests/fixtures/x402-buyer.json @@ -0,0 +1,3658 @@ +{ + "cases": [ + { + "id": "x402.buyer.ready-balance", + "suite": "x402-buyer", + "operation": "x402.buyer.sign", + "input": { + "api_key": "test-only-buyer-key", + "requirement": { + "scheme": "balance", + "network": "inflow:1", + "asset": "USDC", + "amount": "100000000", + "payTo": "11111111-1111-4111-8111-111111111111", + "maxTimeoutSeconds": 300, + "extra": {} + }, + "context": { + "resource": { + "url": "https://seller.example/data", + "mimeType": "application/json" + }, + "x402Version": 2 + }, + "payment_id": "pay_0123456789abcdef0123456789abcdef" + }, + "expect": { + "result": { + "encodedPayload": "eyJ4NDAyVmVyc2lvbiI6MiwiYWNjZXB0ZWQiOnsic2NoZW1lIjoiYmFsYW5jZSIsIm5ldHdvcmsiOiJpbmZsb3c6MSIsImFzc2V0IjoiVVNEQyIsImFtb3VudCI6IjEwMDAwMDAwMCIsInBheVRvIjoiMTExMTExMTEtMTExMS00MTExLTgxMTEtMTExMTExMTExMTExIiwibWF4VGltZW91dFNlY29uZHMiOjMwMCwiZXh0cmEiOnt9fSwicmVzb3VyY2UiOnsidXJsIjoiaHR0cHM6Ly9zZWxsZXIuZXhhbXBsZS9kYXRhIiwibWltZVR5cGUiOiJhcHBsaWNhdGlvbi9qc29uIn0sInBheWxvYWQiOnsidHJhbnNhY3Rpb25JZCI6IjIyMjIyMjIyLTIyMjItNDIyMi04MjIyLTIyMjIyMjIyMjIyMiJ9LCJleHRlbnNpb25zIjp7InBheW1lbnQtaWRlbnRpZmllciI6eyJpbmZvIjp7InJlcXVpcmVkIjpmYWxzZSwiaWQiOiJwYXlfMDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWYifSwic2NoZW1hIjp7IiRzY2hlbWEiOiJodHRwczovL2pzb24tc2NoZW1hLm9yZy9kcmFmdC8yMDIwLTEyL3NjaGVtYSIsInR5cGUiOiJvYmplY3QiLCJwcm9wZXJ0aWVzIjp7ImlkIjp7InR5cGUiOiJzdHJpbmciLCJtaW5MZW5ndGgiOjE2LCJtYXhMZW5ndGgiOjEyOCwicGF0dGVybiI6Il5bYS16QS1aMC05Xy1dKyQifSwicmVxdWlyZWQiOnsidHlwZSI6ImJvb2xlYW4ifX0sInJlcXVpcmVkIjpbInJlcXVpcmVkIl19fX19", + "paymentPayload": { + "x402Version": 2, + "accepted": { + "scheme": "balance", + "network": "inflow:1", + "asset": "USDC", + "amount": "100000000", + "payTo": "11111111-1111-4111-8111-111111111111", + "maxTimeoutSeconds": 300, + "extra": {} + }, + "resource": { + "url": "https://seller.example/data", + "mimeType": "application/json" + }, + "payload": { + "transactionId": "22222222-2222-4222-8222-222222222222" + }, + "extensions": { + "payment-identifier": { + "info": { + "required": false, + "id": "pay_0123456789abcdef0123456789abcdef" + }, + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 16, + "maxLength": 128, + "pattern": "^[a-zA-Z0-9_-]+$" + }, + "required": { + "type": "boolean" + } + }, + "required": [ + "required" + ] + } + } + } + }, + "transactionId": "22222222-2222-4222-8222-222222222222" + } + }, + "platform": { + "exchanges": [ + { + "request": { + "method": "GET", + "path": "/v1/transactions/x402-supported", + "headers": { + "x-api-key": "test-only-buyer-key" + } + }, + "response": { + "status": 200, + "json": { + "kinds": [ + { + "scheme": "balance", + "network": "inflow:1", + "x402Version": 2 + }, + { + "scheme": "exact", + "network": "eip155:8453", + "x402Version": 2 + } + ] + } + } + }, + { + "request": { + "method": "POST", + "path": "/v1/transactions/x402", + "headers": { + "x-api-key": "test-only-buyer-key" + }, + "json": { + "accept": { + "scheme": "balance", + "network": "inflow:1", + "asset": "USDC", + "amount": "100000000", + "payTo": "11111111-1111-4111-8111-111111111111", + "maxTimeoutSeconds": 300, + "extra": {} + }, + "resource": { + "url": "https://seller.example/data", + "mimeType": "application/json" + }, + "x402Version": 2, + "remotePaymentId": "pay_0123456789abcdef0123456789abcdef" + } + }, + "response": { + "status": 200, + "json": { + "amount": "1", + "currency": "USDC", + "approvalId": "33333333-3333-4333-8333-333333333333", + "approvalStatus": "PENDING", + "resource": { + "url": "https://seller.example/data", + "mimeType": "application/json" + }, + "transactionId": "22222222-2222-4222-8222-222222222222" + } + } + }, + { + "request": { + "method": "GET", + "path": "/v1/transactions/22222222-2222-4222-8222-222222222222/x402", + "headers": { + "x-api-key": "test-only-buyer-key" + } + }, + "response": { + "status": 200, + "json": { + "status": "PENDING", + "encodedPayload": "eyJ4NDAyVmVyc2lvbiI6MiwiYWNjZXB0ZWQiOnsic2NoZW1lIjoiYmFsYW5jZSIsIm5ldHdvcmsiOiJpbmZsb3c6MSIsImFzc2V0IjoiVVNEQyIsImFtb3VudCI6IjEwMDAwMDAwMCIsInBheVRvIjoiMTExMTExMTEtMTExMS00MTExLTgxMTEtMTExMTExMTExMTExIiwibWF4VGltZW91dFNlY29uZHMiOjMwMCwiZXh0cmEiOnt9fSwicmVzb3VyY2UiOnsidXJsIjoiaHR0cHM6Ly9zZWxsZXIuZXhhbXBsZS9kYXRhIiwibWltZVR5cGUiOiJhcHBsaWNhdGlvbi9qc29uIn0sInBheWxvYWQiOnsidHJhbnNhY3Rpb25JZCI6IjIyMjIyMjIyLTIyMjItNDIyMi04MjIyLTIyMjIyMjIyMjIyMiJ9LCJleHRlbnNpb25zIjp7InBheW1lbnQtaWRlbnRpZmllciI6eyJpbmZvIjp7InJlcXVpcmVkIjpmYWxzZSwiaWQiOiJwYXlfMDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWYifSwic2NoZW1hIjp7IiRzY2hlbWEiOiJodHRwczovL2pzb24tc2NoZW1hLm9yZy9kcmFmdC8yMDIwLTEyL3NjaGVtYSIsInR5cGUiOiJvYmplY3QiLCJwcm9wZXJ0aWVzIjp7ImlkIjp7InR5cGUiOiJzdHJpbmciLCJtaW5MZW5ndGgiOjE2LCJtYXhMZW5ndGgiOjEyOCwicGF0dGVybiI6Il5bYS16QS1aMC05Xy1dKyQifSwicmVxdWlyZWQiOnsidHlwZSI6ImJvb2xlYW4ifX0sInJlcXVpcmVkIjpbInJlcXVpcmVkIl19fX19", + "paymentPayload": { + "x402Version": 2, + "accepted": { + "scheme": "balance", + "network": "inflow:1", + "asset": "USDC", + "amount": "100000000", + "payTo": "11111111-1111-4111-8111-111111111111", + "maxTimeoutSeconds": 300, + "extra": {} + }, + "resource": { + "url": "https://seller.example/data", + "mimeType": "application/json" + }, + "payload": { + "transactionId": "22222222-2222-4222-8222-222222222222" + }, + "extensions": { + "payment-identifier": { + "info": { + "required": false, + "id": "pay_0123456789abcdef0123456789abcdef" + }, + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 16, + "maxLength": 128, + "pattern": "^[a-zA-Z0-9_-]+$" + }, + "required": { + "type": "boolean" + } + }, + "required": [ + "required" + ] + } + } + } + } + } + } + } + ] + } + }, + { + "id": "x402.buyer.ready-exact", + "suite": "x402-buyer", + "operation": "x402.buyer.sign", + "input": { + "api_key": "test-only-buyer-key", + "requirement": { + "scheme": "exact", + "network": "eip155:8453", + "asset": "0x3333333333333333333333333333333333333333", + "amount": "1000000", + "payTo": "0x1111111111111111111111111111111111111111", + "maxTimeoutSeconds": 300, + "extra": { + "assetTransferMethod": "eip3009", + "name": "USDC", + "version": "2" + } + }, + "context": { + "resource": { + "url": "https://seller.example/data", + "mimeType": "application/json" + }, + "x402Version": 2 + }, + "payment_id": "pay_0123456789abcdef0123456789abcdef" + }, + "expect": { + "result": { + "encodedPayload": "eyJ4NDAyVmVyc2lvbiI6MiwiYWNjZXB0ZWQiOnsic2NoZW1lIjoiZXhhY3QiLCJuZXR3b3JrIjoiZWlwMTU1Ojg0NTMiLCJhc3NldCI6IjB4MzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMyIsImFtb3VudCI6IjEwMDAwMDAiLCJwYXlUbyI6IjB4MTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMSIsIm1heFRpbWVvdXRTZWNvbmRzIjozMDAsImV4dHJhIjp7ImFzc2V0VHJhbnNmZXJNZXRob2QiOiJlaXAzMDA5IiwibmFtZSI6IlVTREMiLCJ2ZXJzaW9uIjoiMiJ9fSwicmVzb3VyY2UiOnsidXJsIjoiaHR0cHM6Ly9zZWxsZXIuZXhhbXBsZS9kYXRhIiwibWltZVR5cGUiOiJhcHBsaWNhdGlvbi9qc29uIn0sInBheWxvYWQiOnsic2lnbmF0dXJlIjoiMHhhYmFiYWJhYmFiYWJhYmFiYWJhYmFiYWJhYmFiYWJhYmFiYWJhYmFiYWJhYmFiYWJhYmFiYWJhYmFiYWJhYmFiYWJhYmFiYWJhYmFiYWJhYmFiYWJhYmFiYWJhYmFiYWJhYmFiYWJhYmFiYWJhYmFiYWJhYmFiYWJhYmFiYWJhYmFiIiwiYXV0aG9yaXphdGlvbiI6eyJmcm9tIjoiMHgyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyIiwidG8iOiIweDExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTEiLCJ2YWx1ZSI6IjEwMDAwMDAiLCJ2YWxpZEFmdGVyIjoiMTcwMDAwMDAwMCIsInZhbGlkQmVmb3JlIjoiMTcwMDAwMDMwMCIsIm5vbmNlIjoiMHgxMjEyMTIxMjEyMTIxMjEyMTIxMjEyMTIxMjEyMTIxMjEyMTIxMjEyMTIxMjEyMTIxMjEyMTIxMjEyMTIxMjEyIn19LCJleHRlbnNpb25zIjp7InBheW1lbnQtaWRlbnRpZmllciI6eyJpbmZvIjp7InJlcXVpcmVkIjpmYWxzZSwiaWQiOiJwYXlfMDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWYifSwic2NoZW1hIjp7IiRzY2hlbWEiOiJodHRwczovL2pzb24tc2NoZW1hLm9yZy9kcmFmdC8yMDIwLTEyL3NjaGVtYSIsInR5cGUiOiJvYmplY3QiLCJwcm9wZXJ0aWVzIjp7ImlkIjp7InR5cGUiOiJzdHJpbmciLCJtaW5MZW5ndGgiOjE2LCJtYXhMZW5ndGgiOjEyOCwicGF0dGVybiI6Il5bYS16QS1aMC05Xy1dKyQifSwicmVxdWlyZWQiOnsidHlwZSI6ImJvb2xlYW4ifX0sInJlcXVpcmVkIjpbInJlcXVpcmVkIl19fX19", + "paymentPayload": { + "x402Version": 2, + "accepted": { + "scheme": "exact", + "network": "eip155:8453", + "asset": "0x3333333333333333333333333333333333333333", + "amount": "1000000", + "payTo": "0x1111111111111111111111111111111111111111", + "maxTimeoutSeconds": 300, + "extra": { + "assetTransferMethod": "eip3009", + "name": "USDC", + "version": "2" + } + }, + "resource": { + "url": "https://seller.example/data", + "mimeType": "application/json" + }, + "payload": { + "signature": "0xababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab", + "authorization": { + "from": "0x2222222222222222222222222222222222222222", + "to": "0x1111111111111111111111111111111111111111", + "value": "1000000", + "validAfter": "1700000000", + "validBefore": "1700000300", + "nonce": "0x1212121212121212121212121212121212121212121212121212121212121212" + } + }, + "extensions": { + "payment-identifier": { + "info": { + "required": false, + "id": "pay_0123456789abcdef0123456789abcdef" + }, + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 16, + "maxLength": 128, + "pattern": "^[a-zA-Z0-9_-]+$" + }, + "required": { + "type": "boolean" + } + }, + "required": [ + "required" + ] + } + } + } + }, + "transactionId": "22222222-2222-4222-8222-222222222222" + } + }, + "platform": { + "exchanges": [ + { + "request": { + "method": "GET", + "path": "/v1/transactions/x402-supported", + "headers": { + "x-api-key": "test-only-buyer-key" + } + }, + "response": { + "status": 200, + "json": { + "kinds": [ + { + "scheme": "balance", + "network": "inflow:1", + "x402Version": 2 + }, + { + "scheme": "exact", + "network": "eip155:8453", + "x402Version": 2 + } + ] + } + } + }, + { + "request": { + "method": "POST", + "path": "/v1/transactions/x402", + "headers": { + "x-api-key": "test-only-buyer-key" + }, + "json": { + "accept": { + "scheme": "exact", + "network": "eip155:8453", + "asset": "0x3333333333333333333333333333333333333333", + "amount": "1000000", + "payTo": "0x1111111111111111111111111111111111111111", + "maxTimeoutSeconds": 300, + "extra": { + "assetTransferMethod": "eip3009", + "name": "USDC", + "version": "2" + } + }, + "resource": { + "url": "https://seller.example/data", + "mimeType": "application/json" + }, + "x402Version": 2, + "remotePaymentId": "pay_0123456789abcdef0123456789abcdef" + } + }, + "response": { + "status": 200, + "json": { + "amount": "1", + "currency": "USDC", + "approvalId": "33333333-3333-4333-8333-333333333333", + "approvalStatus": "PENDING", + "resource": { + "url": "https://seller.example/data", + "mimeType": "application/json" + }, + "transactionId": "22222222-2222-4222-8222-222222222222" + } + } + }, + { + "request": { + "method": "GET", + "path": "/v1/transactions/22222222-2222-4222-8222-222222222222/x402", + "headers": { + "x-api-key": "test-only-buyer-key" + } + }, + "response": { + "status": 200, + "json": { + "status": "PENDING", + "encodedPayload": "eyJ4NDAyVmVyc2lvbiI6MiwiYWNjZXB0ZWQiOnsic2NoZW1lIjoiZXhhY3QiLCJuZXR3b3JrIjoiZWlwMTU1Ojg0NTMiLCJhc3NldCI6IjB4MzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMyIsImFtb3VudCI6IjEwMDAwMDAiLCJwYXlUbyI6IjB4MTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMSIsIm1heFRpbWVvdXRTZWNvbmRzIjozMDAsImV4dHJhIjp7ImFzc2V0VHJhbnNmZXJNZXRob2QiOiJlaXAzMDA5IiwibmFtZSI6IlVTREMiLCJ2ZXJzaW9uIjoiMiJ9fSwicmVzb3VyY2UiOnsidXJsIjoiaHR0cHM6Ly9zZWxsZXIuZXhhbXBsZS9kYXRhIiwibWltZVR5cGUiOiJhcHBsaWNhdGlvbi9qc29uIn0sInBheWxvYWQiOnsic2lnbmF0dXJlIjoiMHhhYmFiYWJhYmFiYWJhYmFiYWJhYmFiYWJhYmFiYWJhYmFiYWJhYmFiYWJhYmFiYWJhYmFiYWJhYmFiYWJhYmFiYWJhYmFiYWJhYmFiYWJhYmFiYWJhYmFiYWJhYmFiYWJhYmFiYWJhYmFiYWJhYmFiYWJhYmFiYWJhYmFiYWJhYmFiIiwiYXV0aG9yaXphdGlvbiI6eyJmcm9tIjoiMHgyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyIiwidG8iOiIweDExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTEiLCJ2YWx1ZSI6IjEwMDAwMDAiLCJ2YWxpZEFmdGVyIjoiMTcwMDAwMDAwMCIsInZhbGlkQmVmb3JlIjoiMTcwMDAwMDMwMCIsIm5vbmNlIjoiMHgxMjEyMTIxMjEyMTIxMjEyMTIxMjEyMTIxMjEyMTIxMjEyMTIxMjEyMTIxMjEyMTIxMjEyMTIxMjEyMTIxMjEyIn19LCJleHRlbnNpb25zIjp7InBheW1lbnQtaWRlbnRpZmllciI6eyJpbmZvIjp7InJlcXVpcmVkIjpmYWxzZSwiaWQiOiJwYXlfMDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWYifSwic2NoZW1hIjp7IiRzY2hlbWEiOiJodHRwczovL2pzb24tc2NoZW1hLm9yZy9kcmFmdC8yMDIwLTEyL3NjaGVtYSIsInR5cGUiOiJvYmplY3QiLCJwcm9wZXJ0aWVzIjp7ImlkIjp7InR5cGUiOiJzdHJpbmciLCJtaW5MZW5ndGgiOjE2LCJtYXhMZW5ndGgiOjEyOCwicGF0dGVybiI6Il5bYS16QS1aMC05Xy1dKyQifSwicmVxdWlyZWQiOnsidHlwZSI6ImJvb2xlYW4ifX0sInJlcXVpcmVkIjpbInJlcXVpcmVkIl19fX19", + "paymentPayload": { + "x402Version": 2, + "accepted": { + "scheme": "exact", + "network": "eip155:8453", + "asset": "0x3333333333333333333333333333333333333333", + "amount": "1000000", + "payTo": "0x1111111111111111111111111111111111111111", + "maxTimeoutSeconds": 300, + "extra": { + "assetTransferMethod": "eip3009", + "name": "USDC", + "version": "2" + } + }, + "resource": { + "url": "https://seller.example/data", + "mimeType": "application/json" + }, + "payload": { + "signature": "0xababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab", + "authorization": { + "from": "0x2222222222222222222222222222222222222222", + "to": "0x1111111111111111111111111111111111111111", + "value": "1000000", + "validAfter": "1700000000", + "validBefore": "1700000300", + "nonce": "0x1212121212121212121212121212121212121212121212121212121212121212" + } + }, + "extensions": { + "payment-identifier": { + "info": { + "required": false, + "id": "pay_0123456789abcdef0123456789abcdef" + }, + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 16, + "maxLength": 128, + "pattern": "^[a-zA-Z0-9_-]+$" + }, + "required": { + "type": "boolean" + } + }, + "required": [ + "required" + ] + } + } + } + } + } + } + } + ] + } + }, + { + "id": "x402.buyer.wait-initiated", + "suite": "x402-buyer", + "operation": "x402.buyer.sign", + "input": { + "api_key": "test-only-buyer-key", + "requirement": { + "scheme": "balance", + "network": "inflow:1", + "asset": "USDC", + "amount": "100000000", + "payTo": "11111111-1111-4111-8111-111111111111", + "maxTimeoutSeconds": 300, + "extra": {} + }, + "context": { + "resource": { + "url": "https://seller.example/data", + "mimeType": "application/json" + }, + "x402Version": 2 + }, + "payment_id": "pay_0123456789abcdef0123456789abcdef" + }, + "expect": { + "result": { + "encodedPayload": "eyJ4NDAyVmVyc2lvbiI6MiwiYWNjZXB0ZWQiOnsic2NoZW1lIjoiYmFsYW5jZSIsIm5ldHdvcmsiOiJpbmZsb3c6MSIsImFzc2V0IjoiVVNEQyIsImFtb3VudCI6IjEwMDAwMDAwMCIsInBheVRvIjoiMTExMTExMTEtMTExMS00MTExLTgxMTEtMTExMTExMTExMTExIiwibWF4VGltZW91dFNlY29uZHMiOjMwMCwiZXh0cmEiOnt9fSwicmVzb3VyY2UiOnsidXJsIjoiaHR0cHM6Ly9zZWxsZXIuZXhhbXBsZS9kYXRhIiwibWltZVR5cGUiOiJhcHBsaWNhdGlvbi9qc29uIn0sInBheWxvYWQiOnsidHJhbnNhY3Rpb25JZCI6IjIyMjIyMjIyLTIyMjItNDIyMi04MjIyLTIyMjIyMjIyMjIyMiJ9LCJleHRlbnNpb25zIjp7InBheW1lbnQtaWRlbnRpZmllciI6eyJpbmZvIjp7InJlcXVpcmVkIjpmYWxzZSwiaWQiOiJwYXlfMDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWYifSwic2NoZW1hIjp7IiRzY2hlbWEiOiJodHRwczovL2pzb24tc2NoZW1hLm9yZy9kcmFmdC8yMDIwLTEyL3NjaGVtYSIsInR5cGUiOiJvYmplY3QiLCJwcm9wZXJ0aWVzIjp7ImlkIjp7InR5cGUiOiJzdHJpbmciLCJtaW5MZW5ndGgiOjE2LCJtYXhMZW5ndGgiOjEyOCwicGF0dGVybiI6Il5bYS16QS1aMC05Xy1dKyQifSwicmVxdWlyZWQiOnsidHlwZSI6ImJvb2xlYW4ifX0sInJlcXVpcmVkIjpbInJlcXVpcmVkIl19fX19", + "paymentPayload": { + "x402Version": 2, + "accepted": { + "scheme": "balance", + "network": "inflow:1", + "asset": "USDC", + "amount": "100000000", + "payTo": "11111111-1111-4111-8111-111111111111", + "maxTimeoutSeconds": 300, + "extra": {} + }, + "resource": { + "url": "https://seller.example/data", + "mimeType": "application/json" + }, + "payload": { + "transactionId": "22222222-2222-4222-8222-222222222222" + }, + "extensions": { + "payment-identifier": { + "info": { + "required": false, + "id": "pay_0123456789abcdef0123456789abcdef" + }, + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 16, + "maxLength": 128, + "pattern": "^[a-zA-Z0-9_-]+$" + }, + "required": { + "type": "boolean" + } + }, + "required": [ + "required" + ] + } + } + } + }, + "transactionId": "22222222-2222-4222-8222-222222222222" + } + }, + "platform": { + "exchanges": [ + { + "request": { + "method": "GET", + "path": "/v1/transactions/x402-supported", + "headers": { + "x-api-key": "test-only-buyer-key" + } + }, + "response": { + "status": 200, + "json": { + "kinds": [ + { + "scheme": "balance", + "network": "inflow:1", + "x402Version": 2 + }, + { + "scheme": "exact", + "network": "eip155:8453", + "x402Version": 2 + } + ] + } + } + }, + { + "request": { + "method": "POST", + "path": "/v1/transactions/x402", + "headers": { + "x-api-key": "test-only-buyer-key" + }, + "json": { + "accept": { + "scheme": "balance", + "network": "inflow:1", + "asset": "USDC", + "amount": "100000000", + "payTo": "11111111-1111-4111-8111-111111111111", + "maxTimeoutSeconds": 300, + "extra": {} + }, + "resource": { + "url": "https://seller.example/data", + "mimeType": "application/json" + }, + "x402Version": 2, + "remotePaymentId": "pay_0123456789abcdef0123456789abcdef" + } + }, + "response": { + "status": 200, + "json": { + "amount": "1", + "currency": "USDC", + "approvalId": "33333333-3333-4333-8333-333333333333", + "approvalStatus": "PENDING", + "resource": { + "url": "https://seller.example/data", + "mimeType": "application/json" + }, + "transactionId": "22222222-2222-4222-8222-222222222222" + } + } + }, + { + "request": { + "method": "GET", + "path": "/v1/transactions/22222222-2222-4222-8222-222222222222/x402", + "headers": { + "x-api-key": "test-only-buyer-key" + } + }, + "response": { + "status": 200, + "json": { + "status": "INITIATED" + } + } + }, + { + "request": { + "method": "GET", + "path": "/v1/transactions/22222222-2222-4222-8222-222222222222/x402", + "headers": { + "x-api-key": "test-only-buyer-key" + } + }, + "response": { + "status": 200, + "json": { + "status": "PENDING", + "encodedPayload": "eyJ4NDAyVmVyc2lvbiI6MiwiYWNjZXB0ZWQiOnsic2NoZW1lIjoiYmFsYW5jZSIsIm5ldHdvcmsiOiJpbmZsb3c6MSIsImFzc2V0IjoiVVNEQyIsImFtb3VudCI6IjEwMDAwMDAwMCIsInBheVRvIjoiMTExMTExMTEtMTExMS00MTExLTgxMTEtMTExMTExMTExMTExIiwibWF4VGltZW91dFNlY29uZHMiOjMwMCwiZXh0cmEiOnt9fSwicmVzb3VyY2UiOnsidXJsIjoiaHR0cHM6Ly9zZWxsZXIuZXhhbXBsZS9kYXRhIiwibWltZVR5cGUiOiJhcHBsaWNhdGlvbi9qc29uIn0sInBheWxvYWQiOnsidHJhbnNhY3Rpb25JZCI6IjIyMjIyMjIyLTIyMjItNDIyMi04MjIyLTIyMjIyMjIyMjIyMiJ9LCJleHRlbnNpb25zIjp7InBheW1lbnQtaWRlbnRpZmllciI6eyJpbmZvIjp7InJlcXVpcmVkIjpmYWxzZSwiaWQiOiJwYXlfMDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWYifSwic2NoZW1hIjp7IiRzY2hlbWEiOiJodHRwczovL2pzb24tc2NoZW1hLm9yZy9kcmFmdC8yMDIwLTEyL3NjaGVtYSIsInR5cGUiOiJvYmplY3QiLCJwcm9wZXJ0aWVzIjp7ImlkIjp7InR5cGUiOiJzdHJpbmciLCJtaW5MZW5ndGgiOjE2LCJtYXhMZW5ndGgiOjEyOCwicGF0dGVybiI6Il5bYS16QS1aMC05Xy1dKyQifSwicmVxdWlyZWQiOnsidHlwZSI6ImJvb2xlYW4ifX0sInJlcXVpcmVkIjpbInJlcXVpcmVkIl19fX19", + "paymentPayload": { + "x402Version": 2, + "accepted": { + "scheme": "balance", + "network": "inflow:1", + "asset": "USDC", + "amount": "100000000", + "payTo": "11111111-1111-4111-8111-111111111111", + "maxTimeoutSeconds": 300, + "extra": {} + }, + "resource": { + "url": "https://seller.example/data", + "mimeType": "application/json" + }, + "payload": { + "transactionId": "22222222-2222-4222-8222-222222222222" + }, + "extensions": { + "payment-identifier": { + "info": { + "required": false, + "id": "pay_0123456789abcdef0123456789abcdef" + }, + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 16, + "maxLength": 128, + "pattern": "^[a-zA-Z0-9_-]+$" + }, + "required": { + "type": "boolean" + } + }, + "required": [ + "required" + ] + } + } + } + } + } + } + } + ] + } + }, + { + "id": "x402.buyer.wait-pending", + "suite": "x402-buyer", + "operation": "x402.buyer.sign", + "input": { + "api_key": "test-only-buyer-key", + "requirement": { + "scheme": "balance", + "network": "inflow:1", + "asset": "USDC", + "amount": "100000000", + "payTo": "11111111-1111-4111-8111-111111111111", + "maxTimeoutSeconds": 300, + "extra": {} + }, + "context": { + "resource": { + "url": "https://seller.example/data", + "mimeType": "application/json" + }, + "x402Version": 2 + }, + "payment_id": "pay_0123456789abcdef0123456789abcdef" + }, + "expect": { + "result": { + "encodedPayload": "eyJ4NDAyVmVyc2lvbiI6MiwiYWNjZXB0ZWQiOnsic2NoZW1lIjoiYmFsYW5jZSIsIm5ldHdvcmsiOiJpbmZsb3c6MSIsImFzc2V0IjoiVVNEQyIsImFtb3VudCI6IjEwMDAwMDAwMCIsInBheVRvIjoiMTExMTExMTEtMTExMS00MTExLTgxMTEtMTExMTExMTExMTExIiwibWF4VGltZW91dFNlY29uZHMiOjMwMCwiZXh0cmEiOnt9fSwicmVzb3VyY2UiOnsidXJsIjoiaHR0cHM6Ly9zZWxsZXIuZXhhbXBsZS9kYXRhIiwibWltZVR5cGUiOiJhcHBsaWNhdGlvbi9qc29uIn0sInBheWxvYWQiOnsidHJhbnNhY3Rpb25JZCI6IjIyMjIyMjIyLTIyMjItNDIyMi04MjIyLTIyMjIyMjIyMjIyMiJ9LCJleHRlbnNpb25zIjp7InBheW1lbnQtaWRlbnRpZmllciI6eyJpbmZvIjp7InJlcXVpcmVkIjpmYWxzZSwiaWQiOiJwYXlfMDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWYifSwic2NoZW1hIjp7IiRzY2hlbWEiOiJodHRwczovL2pzb24tc2NoZW1hLm9yZy9kcmFmdC8yMDIwLTEyL3NjaGVtYSIsInR5cGUiOiJvYmplY3QiLCJwcm9wZXJ0aWVzIjp7ImlkIjp7InR5cGUiOiJzdHJpbmciLCJtaW5MZW5ndGgiOjE2LCJtYXhMZW5ndGgiOjEyOCwicGF0dGVybiI6Il5bYS16QS1aMC05Xy1dKyQifSwicmVxdWlyZWQiOnsidHlwZSI6ImJvb2xlYW4ifX0sInJlcXVpcmVkIjpbInJlcXVpcmVkIl19fX19", + "paymentPayload": { + "x402Version": 2, + "accepted": { + "scheme": "balance", + "network": "inflow:1", + "asset": "USDC", + "amount": "100000000", + "payTo": "11111111-1111-4111-8111-111111111111", + "maxTimeoutSeconds": 300, + "extra": {} + }, + "resource": { + "url": "https://seller.example/data", + "mimeType": "application/json" + }, + "payload": { + "transactionId": "22222222-2222-4222-8222-222222222222" + }, + "extensions": { + "payment-identifier": { + "info": { + "required": false, + "id": "pay_0123456789abcdef0123456789abcdef" + }, + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 16, + "maxLength": 128, + "pattern": "^[a-zA-Z0-9_-]+$" + }, + "required": { + "type": "boolean" + } + }, + "required": [ + "required" + ] + } + } + } + }, + "transactionId": "22222222-2222-4222-8222-222222222222" + } + }, + "platform": { + "exchanges": [ + { + "request": { + "method": "GET", + "path": "/v1/transactions/x402-supported", + "headers": { + "x-api-key": "test-only-buyer-key" + } + }, + "response": { + "status": 200, + "json": { + "kinds": [ + { + "scheme": "balance", + "network": "inflow:1", + "x402Version": 2 + }, + { + "scheme": "exact", + "network": "eip155:8453", + "x402Version": 2 + } + ] + } + } + }, + { + "request": { + "method": "POST", + "path": "/v1/transactions/x402", + "headers": { + "x-api-key": "test-only-buyer-key" + }, + "json": { + "accept": { + "scheme": "balance", + "network": "inflow:1", + "asset": "USDC", + "amount": "100000000", + "payTo": "11111111-1111-4111-8111-111111111111", + "maxTimeoutSeconds": 300, + "extra": {} + }, + "resource": { + "url": "https://seller.example/data", + "mimeType": "application/json" + }, + "x402Version": 2, + "remotePaymentId": "pay_0123456789abcdef0123456789abcdef" + } + }, + "response": { + "status": 200, + "json": { + "amount": "1", + "currency": "USDC", + "approvalId": "33333333-3333-4333-8333-333333333333", + "approvalStatus": "PENDING", + "resource": { + "url": "https://seller.example/data", + "mimeType": "application/json" + }, + "transactionId": "22222222-2222-4222-8222-222222222222" + } + } + }, + { + "request": { + "method": "GET", + "path": "/v1/transactions/22222222-2222-4222-8222-222222222222/x402", + "headers": { + "x-api-key": "test-only-buyer-key" + } + }, + "response": { + "status": 200, + "json": { + "status": "PENDING" + } + } + }, + { + "request": { + "method": "GET", + "path": "/v1/transactions/22222222-2222-4222-8222-222222222222/x402", + "headers": { + "x-api-key": "test-only-buyer-key" + } + }, + "response": { + "status": 200, + "json": { + "status": "PENDING", + "encodedPayload": "eyJ4NDAyVmVyc2lvbiI6MiwiYWNjZXB0ZWQiOnsic2NoZW1lIjoiYmFsYW5jZSIsIm5ldHdvcmsiOiJpbmZsb3c6MSIsImFzc2V0IjoiVVNEQyIsImFtb3VudCI6IjEwMDAwMDAwMCIsInBheVRvIjoiMTExMTExMTEtMTExMS00MTExLTgxMTEtMTExMTExMTExMTExIiwibWF4VGltZW91dFNlY29uZHMiOjMwMCwiZXh0cmEiOnt9fSwicmVzb3VyY2UiOnsidXJsIjoiaHR0cHM6Ly9zZWxsZXIuZXhhbXBsZS9kYXRhIiwibWltZVR5cGUiOiJhcHBsaWNhdGlvbi9qc29uIn0sInBheWxvYWQiOnsidHJhbnNhY3Rpb25JZCI6IjIyMjIyMjIyLTIyMjItNDIyMi04MjIyLTIyMjIyMjIyMjIyMiJ9LCJleHRlbnNpb25zIjp7InBheW1lbnQtaWRlbnRpZmllciI6eyJpbmZvIjp7InJlcXVpcmVkIjpmYWxzZSwiaWQiOiJwYXlfMDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWYifSwic2NoZW1hIjp7IiRzY2hlbWEiOiJodHRwczovL2pzb24tc2NoZW1hLm9yZy9kcmFmdC8yMDIwLTEyL3NjaGVtYSIsInR5cGUiOiJvYmplY3QiLCJwcm9wZXJ0aWVzIjp7ImlkIjp7InR5cGUiOiJzdHJpbmciLCJtaW5MZW5ndGgiOjE2LCJtYXhMZW5ndGgiOjEyOCwicGF0dGVybiI6Il5bYS16QS1aMC05Xy1dKyQifSwicmVxdWlyZWQiOnsidHlwZSI6ImJvb2xlYW4ifX0sInJlcXVpcmVkIjpbInJlcXVpcmVkIl19fX19", + "paymentPayload": { + "x402Version": 2, + "accepted": { + "scheme": "balance", + "network": "inflow:1", + "asset": "USDC", + "amount": "100000000", + "payTo": "11111111-1111-4111-8111-111111111111", + "maxTimeoutSeconds": 300, + "extra": {} + }, + "resource": { + "url": "https://seller.example/data", + "mimeType": "application/json" + }, + "payload": { + "transactionId": "22222222-2222-4222-8222-222222222222" + }, + "extensions": { + "payment-identifier": { + "info": { + "required": false, + "id": "pay_0123456789abcdef0123456789abcdef" + }, + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 16, + "maxLength": 128, + "pattern": "^[a-zA-Z0-9_-]+$" + }, + "required": { + "type": "boolean" + } + }, + "required": [ + "required" + ] + } + } + } + } + } + } + } + ] + } + }, + { + "id": "x402.buyer.wait-processing", + "suite": "x402-buyer", + "operation": "x402.buyer.sign", + "input": { + "api_key": "test-only-buyer-key", + "requirement": { + "scheme": "balance", + "network": "inflow:1", + "asset": "USDC", + "amount": "100000000", + "payTo": "11111111-1111-4111-8111-111111111111", + "maxTimeoutSeconds": 300, + "extra": {} + }, + "context": { + "resource": { + "url": "https://seller.example/data", + "mimeType": "application/json" + }, + "x402Version": 2 + }, + "payment_id": "pay_0123456789abcdef0123456789abcdef" + }, + "expect": { + "result": { + "encodedPayload": "eyJ4NDAyVmVyc2lvbiI6MiwiYWNjZXB0ZWQiOnsic2NoZW1lIjoiYmFsYW5jZSIsIm5ldHdvcmsiOiJpbmZsb3c6MSIsImFzc2V0IjoiVVNEQyIsImFtb3VudCI6IjEwMDAwMDAwMCIsInBheVRvIjoiMTExMTExMTEtMTExMS00MTExLTgxMTEtMTExMTExMTExMTExIiwibWF4VGltZW91dFNlY29uZHMiOjMwMCwiZXh0cmEiOnt9fSwicmVzb3VyY2UiOnsidXJsIjoiaHR0cHM6Ly9zZWxsZXIuZXhhbXBsZS9kYXRhIiwibWltZVR5cGUiOiJhcHBsaWNhdGlvbi9qc29uIn0sInBheWxvYWQiOnsidHJhbnNhY3Rpb25JZCI6IjIyMjIyMjIyLTIyMjItNDIyMi04MjIyLTIyMjIyMjIyMjIyMiJ9LCJleHRlbnNpb25zIjp7InBheW1lbnQtaWRlbnRpZmllciI6eyJpbmZvIjp7InJlcXVpcmVkIjpmYWxzZSwiaWQiOiJwYXlfMDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWYifSwic2NoZW1hIjp7IiRzY2hlbWEiOiJodHRwczovL2pzb24tc2NoZW1hLm9yZy9kcmFmdC8yMDIwLTEyL3NjaGVtYSIsInR5cGUiOiJvYmplY3QiLCJwcm9wZXJ0aWVzIjp7ImlkIjp7InR5cGUiOiJzdHJpbmciLCJtaW5MZW5ndGgiOjE2LCJtYXhMZW5ndGgiOjEyOCwicGF0dGVybiI6Il5bYS16QS1aMC05Xy1dKyQifSwicmVxdWlyZWQiOnsidHlwZSI6ImJvb2xlYW4ifX0sInJlcXVpcmVkIjpbInJlcXVpcmVkIl19fX19", + "paymentPayload": { + "x402Version": 2, + "accepted": { + "scheme": "balance", + "network": "inflow:1", + "asset": "USDC", + "amount": "100000000", + "payTo": "11111111-1111-4111-8111-111111111111", + "maxTimeoutSeconds": 300, + "extra": {} + }, + "resource": { + "url": "https://seller.example/data", + "mimeType": "application/json" + }, + "payload": { + "transactionId": "22222222-2222-4222-8222-222222222222" + }, + "extensions": { + "payment-identifier": { + "info": { + "required": false, + "id": "pay_0123456789abcdef0123456789abcdef" + }, + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 16, + "maxLength": 128, + "pattern": "^[a-zA-Z0-9_-]+$" + }, + "required": { + "type": "boolean" + } + }, + "required": [ + "required" + ] + } + } + } + }, + "transactionId": "22222222-2222-4222-8222-222222222222" + } + }, + "platform": { + "exchanges": [ + { + "request": { + "method": "GET", + "path": "/v1/transactions/x402-supported", + "headers": { + "x-api-key": "test-only-buyer-key" + } + }, + "response": { + "status": 200, + "json": { + "kinds": [ + { + "scheme": "balance", + "network": "inflow:1", + "x402Version": 2 + }, + { + "scheme": "exact", + "network": "eip155:8453", + "x402Version": 2 + } + ] + } + } + }, + { + "request": { + "method": "POST", + "path": "/v1/transactions/x402", + "headers": { + "x-api-key": "test-only-buyer-key" + }, + "json": { + "accept": { + "scheme": "balance", + "network": "inflow:1", + "asset": "USDC", + "amount": "100000000", + "payTo": "11111111-1111-4111-8111-111111111111", + "maxTimeoutSeconds": 300, + "extra": {} + }, + "resource": { + "url": "https://seller.example/data", + "mimeType": "application/json" + }, + "x402Version": 2, + "remotePaymentId": "pay_0123456789abcdef0123456789abcdef" + } + }, + "response": { + "status": 200, + "json": { + "amount": "1", + "currency": "USDC", + "approvalId": "33333333-3333-4333-8333-333333333333", + "approvalStatus": "PENDING", + "resource": { + "url": "https://seller.example/data", + "mimeType": "application/json" + }, + "transactionId": "22222222-2222-4222-8222-222222222222" + } + } + }, + { + "request": { + "method": "GET", + "path": "/v1/transactions/22222222-2222-4222-8222-222222222222/x402", + "headers": { + "x-api-key": "test-only-buyer-key" + } + }, + "response": { + "status": 200, + "json": { + "status": "PROCESSING" + } + } + }, + { + "request": { + "method": "GET", + "path": "/v1/transactions/22222222-2222-4222-8222-222222222222/x402", + "headers": { + "x-api-key": "test-only-buyer-key" + } + }, + "response": { + "status": 200, + "json": { + "status": "PENDING", + "encodedPayload": "eyJ4NDAyVmVyc2lvbiI6MiwiYWNjZXB0ZWQiOnsic2NoZW1lIjoiYmFsYW5jZSIsIm5ldHdvcmsiOiJpbmZsb3c6MSIsImFzc2V0IjoiVVNEQyIsImFtb3VudCI6IjEwMDAwMDAwMCIsInBheVRvIjoiMTExMTExMTEtMTExMS00MTExLTgxMTEtMTExMTExMTExMTExIiwibWF4VGltZW91dFNlY29uZHMiOjMwMCwiZXh0cmEiOnt9fSwicmVzb3VyY2UiOnsidXJsIjoiaHR0cHM6Ly9zZWxsZXIuZXhhbXBsZS9kYXRhIiwibWltZVR5cGUiOiJhcHBsaWNhdGlvbi9qc29uIn0sInBheWxvYWQiOnsidHJhbnNhY3Rpb25JZCI6IjIyMjIyMjIyLTIyMjItNDIyMi04MjIyLTIyMjIyMjIyMjIyMiJ9LCJleHRlbnNpb25zIjp7InBheW1lbnQtaWRlbnRpZmllciI6eyJpbmZvIjp7InJlcXVpcmVkIjpmYWxzZSwiaWQiOiJwYXlfMDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWYifSwic2NoZW1hIjp7IiRzY2hlbWEiOiJodHRwczovL2pzb24tc2NoZW1hLm9yZy9kcmFmdC8yMDIwLTEyL3NjaGVtYSIsInR5cGUiOiJvYmplY3QiLCJwcm9wZXJ0aWVzIjp7ImlkIjp7InR5cGUiOiJzdHJpbmciLCJtaW5MZW5ndGgiOjE2LCJtYXhMZW5ndGgiOjEyOCwicGF0dGVybiI6Il5bYS16QS1aMC05Xy1dKyQifSwicmVxdWlyZWQiOnsidHlwZSI6ImJvb2xlYW4ifX0sInJlcXVpcmVkIjpbInJlcXVpcmVkIl19fX19", + "paymentPayload": { + "x402Version": 2, + "accepted": { + "scheme": "balance", + "network": "inflow:1", + "asset": "USDC", + "amount": "100000000", + "payTo": "11111111-1111-4111-8111-111111111111", + "maxTimeoutSeconds": 300, + "extra": {} + }, + "resource": { + "url": "https://seller.example/data", + "mimeType": "application/json" + }, + "payload": { + "transactionId": "22222222-2222-4222-8222-222222222222" + }, + "extensions": { + "payment-identifier": { + "info": { + "required": false, + "id": "pay_0123456789abcdef0123456789abcdef" + }, + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 16, + "maxLength": 128, + "pattern": "^[a-zA-Z0-9_-]+$" + }, + "required": { + "type": "boolean" + } + }, + "required": [ + "required" + ] + } + } + } + } + } + } + } + ] + } + }, + { + "id": "x402.buyer.wait-settled", + "suite": "x402-buyer", + "operation": "x402.buyer.sign", + "input": { + "api_key": "test-only-buyer-key", + "requirement": { + "scheme": "balance", + "network": "inflow:1", + "asset": "USDC", + "amount": "100000000", + "payTo": "11111111-1111-4111-8111-111111111111", + "maxTimeoutSeconds": 300, + "extra": {} + }, + "context": { + "resource": { + "url": "https://seller.example/data", + "mimeType": "application/json" + }, + "x402Version": 2 + }, + "payment_id": "pay_0123456789abcdef0123456789abcdef" + }, + "expect": { + "result": { + "encodedPayload": "eyJ4NDAyVmVyc2lvbiI6MiwiYWNjZXB0ZWQiOnsic2NoZW1lIjoiYmFsYW5jZSIsIm5ldHdvcmsiOiJpbmZsb3c6MSIsImFzc2V0IjoiVVNEQyIsImFtb3VudCI6IjEwMDAwMDAwMCIsInBheVRvIjoiMTExMTExMTEtMTExMS00MTExLTgxMTEtMTExMTExMTExMTExIiwibWF4VGltZW91dFNlY29uZHMiOjMwMCwiZXh0cmEiOnt9fSwicmVzb3VyY2UiOnsidXJsIjoiaHR0cHM6Ly9zZWxsZXIuZXhhbXBsZS9kYXRhIiwibWltZVR5cGUiOiJhcHBsaWNhdGlvbi9qc29uIn0sInBheWxvYWQiOnsidHJhbnNhY3Rpb25JZCI6IjIyMjIyMjIyLTIyMjItNDIyMi04MjIyLTIyMjIyMjIyMjIyMiJ9LCJleHRlbnNpb25zIjp7InBheW1lbnQtaWRlbnRpZmllciI6eyJpbmZvIjp7InJlcXVpcmVkIjpmYWxzZSwiaWQiOiJwYXlfMDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWYifSwic2NoZW1hIjp7IiRzY2hlbWEiOiJodHRwczovL2pzb24tc2NoZW1hLm9yZy9kcmFmdC8yMDIwLTEyL3NjaGVtYSIsInR5cGUiOiJvYmplY3QiLCJwcm9wZXJ0aWVzIjp7ImlkIjp7InR5cGUiOiJzdHJpbmciLCJtaW5MZW5ndGgiOjE2LCJtYXhMZW5ndGgiOjEyOCwicGF0dGVybiI6Il5bYS16QS1aMC05Xy1dKyQifSwicmVxdWlyZWQiOnsidHlwZSI6ImJvb2xlYW4ifX0sInJlcXVpcmVkIjpbInJlcXVpcmVkIl19fX19", + "paymentPayload": { + "x402Version": 2, + "accepted": { + "scheme": "balance", + "network": "inflow:1", + "asset": "USDC", + "amount": "100000000", + "payTo": "11111111-1111-4111-8111-111111111111", + "maxTimeoutSeconds": 300, + "extra": {} + }, + "resource": { + "url": "https://seller.example/data", + "mimeType": "application/json" + }, + "payload": { + "transactionId": "22222222-2222-4222-8222-222222222222" + }, + "extensions": { + "payment-identifier": { + "info": { + "required": false, + "id": "pay_0123456789abcdef0123456789abcdef" + }, + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 16, + "maxLength": 128, + "pattern": "^[a-zA-Z0-9_-]+$" + }, + "required": { + "type": "boolean" + } + }, + "required": [ + "required" + ] + } + } + } + }, + "transactionId": "22222222-2222-4222-8222-222222222222" + } + }, + "platform": { + "exchanges": [ + { + "request": { + "method": "GET", + "path": "/v1/transactions/x402-supported", + "headers": { + "x-api-key": "test-only-buyer-key" + } + }, + "response": { + "status": 200, + "json": { + "kinds": [ + { + "scheme": "balance", + "network": "inflow:1", + "x402Version": 2 + }, + { + "scheme": "exact", + "network": "eip155:8453", + "x402Version": 2 + } + ] + } + } + }, + { + "request": { + "method": "POST", + "path": "/v1/transactions/x402", + "headers": { + "x-api-key": "test-only-buyer-key" + }, + "json": { + "accept": { + "scheme": "balance", + "network": "inflow:1", + "asset": "USDC", + "amount": "100000000", + "payTo": "11111111-1111-4111-8111-111111111111", + "maxTimeoutSeconds": 300, + "extra": {} + }, + "resource": { + "url": "https://seller.example/data", + "mimeType": "application/json" + }, + "x402Version": 2, + "remotePaymentId": "pay_0123456789abcdef0123456789abcdef" + } + }, + "response": { + "status": 200, + "json": { + "amount": "1", + "currency": "USDC", + "approvalId": "33333333-3333-4333-8333-333333333333", + "approvalStatus": "PENDING", + "resource": { + "url": "https://seller.example/data", + "mimeType": "application/json" + }, + "transactionId": "22222222-2222-4222-8222-222222222222" + } + } + }, + { + "request": { + "method": "GET", + "path": "/v1/transactions/22222222-2222-4222-8222-222222222222/x402", + "headers": { + "x-api-key": "test-only-buyer-key" + } + }, + "response": { + "status": 200, + "json": { + "status": "SETTLED" + } + } + }, + { + "request": { + "method": "GET", + "path": "/v1/transactions/22222222-2222-4222-8222-222222222222/x402", + "headers": { + "x-api-key": "test-only-buyer-key" + } + }, + "response": { + "status": 200, + "json": { + "status": "PENDING", + "encodedPayload": "eyJ4NDAyVmVyc2lvbiI6MiwiYWNjZXB0ZWQiOnsic2NoZW1lIjoiYmFsYW5jZSIsIm5ldHdvcmsiOiJpbmZsb3c6MSIsImFzc2V0IjoiVVNEQyIsImFtb3VudCI6IjEwMDAwMDAwMCIsInBheVRvIjoiMTExMTExMTEtMTExMS00MTExLTgxMTEtMTExMTExMTExMTExIiwibWF4VGltZW91dFNlY29uZHMiOjMwMCwiZXh0cmEiOnt9fSwicmVzb3VyY2UiOnsidXJsIjoiaHR0cHM6Ly9zZWxsZXIuZXhhbXBsZS9kYXRhIiwibWltZVR5cGUiOiJhcHBsaWNhdGlvbi9qc29uIn0sInBheWxvYWQiOnsidHJhbnNhY3Rpb25JZCI6IjIyMjIyMjIyLTIyMjItNDIyMi04MjIyLTIyMjIyMjIyMjIyMiJ9LCJleHRlbnNpb25zIjp7InBheW1lbnQtaWRlbnRpZmllciI6eyJpbmZvIjp7InJlcXVpcmVkIjpmYWxzZSwiaWQiOiJwYXlfMDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWYifSwic2NoZW1hIjp7IiRzY2hlbWEiOiJodHRwczovL2pzb24tc2NoZW1hLm9yZy9kcmFmdC8yMDIwLTEyL3NjaGVtYSIsInR5cGUiOiJvYmplY3QiLCJwcm9wZXJ0aWVzIjp7ImlkIjp7InR5cGUiOiJzdHJpbmciLCJtaW5MZW5ndGgiOjE2LCJtYXhMZW5ndGgiOjEyOCwicGF0dGVybiI6Il5bYS16QS1aMC05Xy1dKyQifSwicmVxdWlyZWQiOnsidHlwZSI6ImJvb2xlYW4ifX0sInJlcXVpcmVkIjpbInJlcXVpcmVkIl19fX19", + "paymentPayload": { + "x402Version": 2, + "accepted": { + "scheme": "balance", + "network": "inflow:1", + "asset": "USDC", + "amount": "100000000", + "payTo": "11111111-1111-4111-8111-111111111111", + "maxTimeoutSeconds": 300, + "extra": {} + }, + "resource": { + "url": "https://seller.example/data", + "mimeType": "application/json" + }, + "payload": { + "transactionId": "22222222-2222-4222-8222-222222222222" + }, + "extensions": { + "payment-identifier": { + "info": { + "required": false, + "id": "pay_0123456789abcdef0123456789abcdef" + }, + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 16, + "maxLength": 128, + "pattern": "^[a-zA-Z0-9_-]+$" + }, + "required": { + "type": "boolean" + } + }, + "required": [ + "required" + ] + } + } + } + } + } + } + } + ] + } + }, + { + "id": "x402.buyer.failure-declined", + "suite": "x402-buyer", + "operation": "x402.buyer.sign", + "input": { + "api_key": "test-only-buyer-key", + "requirement": { + "scheme": "balance", + "network": "inflow:1", + "asset": "USDC", + "amount": "100000000", + "payTo": "11111111-1111-4111-8111-111111111111", + "maxTimeoutSeconds": 300, + "extra": {} + }, + "context": { + "resource": { + "url": "https://seller.example/data", + "mimeType": "application/json" + }, + "x402Version": 2 + }, + "payment_id": "pay_0123456789abcdef0123456789abcdef" + }, + "expect": { + "error": { + "code": "payment-failed", + "message": "Payment failed.", + "details": { + "status": "DECLINED" + } + } + }, + "platform": { + "exchanges": [ + { + "request": { + "method": "GET", + "path": "/v1/transactions/x402-supported", + "headers": { + "x-api-key": "test-only-buyer-key" + } + }, + "response": { + "status": 200, + "json": { + "kinds": [ + { + "scheme": "balance", + "network": "inflow:1", + "x402Version": 2 + }, + { + "scheme": "exact", + "network": "eip155:8453", + "x402Version": 2 + } + ] + } + } + }, + { + "request": { + "method": "POST", + "path": "/v1/transactions/x402", + "headers": { + "x-api-key": "test-only-buyer-key" + }, + "json": { + "accept": { + "scheme": "balance", + "network": "inflow:1", + "asset": "USDC", + "amount": "100000000", + "payTo": "11111111-1111-4111-8111-111111111111", + "maxTimeoutSeconds": 300, + "extra": {} + }, + "resource": { + "url": "https://seller.example/data", + "mimeType": "application/json" + }, + "x402Version": 2, + "remotePaymentId": "pay_0123456789abcdef0123456789abcdef" + } + }, + "response": { + "status": 200, + "json": { + "amount": "1", + "currency": "USDC", + "approvalId": "33333333-3333-4333-8333-333333333333", + "approvalStatus": "PENDING", + "resource": { + "url": "https://seller.example/data", + "mimeType": "application/json" + }, + "transactionId": "22222222-2222-4222-8222-222222222222" + } + } + }, + { + "request": { + "method": "GET", + "path": "/v1/transactions/22222222-2222-4222-8222-222222222222/x402", + "headers": { + "x-api-key": "test-only-buyer-key" + } + }, + "response": { + "status": 200, + "json": { + "status": "DECLINED" + } + } + } + ] + } + }, + { + "id": "x402.buyer.failure-expired", + "suite": "x402-buyer", + "operation": "x402.buyer.sign", + "input": { + "api_key": "test-only-buyer-key", + "requirement": { + "scheme": "balance", + "network": "inflow:1", + "asset": "USDC", + "amount": "100000000", + "payTo": "11111111-1111-4111-8111-111111111111", + "maxTimeoutSeconds": 300, + "extra": {} + }, + "context": { + "resource": { + "url": "https://seller.example/data", + "mimeType": "application/json" + }, + "x402Version": 2 + }, + "payment_id": "pay_0123456789abcdef0123456789abcdef" + }, + "expect": { + "error": { + "code": "payment-failed", + "message": "Payment failed.", + "details": { + "status": "EXPIRED" + } + } + }, + "platform": { + "exchanges": [ + { + "request": { + "method": "GET", + "path": "/v1/transactions/x402-supported", + "headers": { + "x-api-key": "test-only-buyer-key" + } + }, + "response": { + "status": 200, + "json": { + "kinds": [ + { + "scheme": "balance", + "network": "inflow:1", + "x402Version": 2 + }, + { + "scheme": "exact", + "network": "eip155:8453", + "x402Version": 2 + } + ] + } + } + }, + { + "request": { + "method": "POST", + "path": "/v1/transactions/x402", + "headers": { + "x-api-key": "test-only-buyer-key" + }, + "json": { + "accept": { + "scheme": "balance", + "network": "inflow:1", + "asset": "USDC", + "amount": "100000000", + "payTo": "11111111-1111-4111-8111-111111111111", + "maxTimeoutSeconds": 300, + "extra": {} + }, + "resource": { + "url": "https://seller.example/data", + "mimeType": "application/json" + }, + "x402Version": 2, + "remotePaymentId": "pay_0123456789abcdef0123456789abcdef" + } + }, + "response": { + "status": 200, + "json": { + "amount": "1", + "currency": "USDC", + "approvalId": "33333333-3333-4333-8333-333333333333", + "approvalStatus": "PENDING", + "resource": { + "url": "https://seller.example/data", + "mimeType": "application/json" + }, + "transactionId": "22222222-2222-4222-8222-222222222222" + } + } + }, + { + "request": { + "method": "GET", + "path": "/v1/transactions/22222222-2222-4222-8222-222222222222/x402", + "headers": { + "x-api-key": "test-only-buyer-key" + } + }, + "response": { + "status": 200, + "json": { + "status": "EXPIRED" + } + } + } + ] + } + }, + { + "id": "x402.buyer.failure-general_error", + "suite": "x402-buyer", + "operation": "x402.buyer.sign", + "input": { + "api_key": "test-only-buyer-key", + "requirement": { + "scheme": "balance", + "network": "inflow:1", + "asset": "USDC", + "amount": "100000000", + "payTo": "11111111-1111-4111-8111-111111111111", + "maxTimeoutSeconds": 300, + "extra": {} + }, + "context": { + "resource": { + "url": "https://seller.example/data", + "mimeType": "application/json" + }, + "x402Version": 2 + }, + "payment_id": "pay_0123456789abcdef0123456789abcdef" + }, + "expect": { + "error": { + "code": "payment-failed", + "message": "Payment failed.", + "details": { + "status": "GENERAL_ERROR" + } + } + }, + "platform": { + "exchanges": [ + { + "request": { + "method": "GET", + "path": "/v1/transactions/x402-supported", + "headers": { + "x-api-key": "test-only-buyer-key" + } + }, + "response": { + "status": 200, + "json": { + "kinds": [ + { + "scheme": "balance", + "network": "inflow:1", + "x402Version": 2 + }, + { + "scheme": "exact", + "network": "eip155:8453", + "x402Version": 2 + } + ] + } + } + }, + { + "request": { + "method": "POST", + "path": "/v1/transactions/x402", + "headers": { + "x-api-key": "test-only-buyer-key" + }, + "json": { + "accept": { + "scheme": "balance", + "network": "inflow:1", + "asset": "USDC", + "amount": "100000000", + "payTo": "11111111-1111-4111-8111-111111111111", + "maxTimeoutSeconds": 300, + "extra": {} + }, + "resource": { + "url": "https://seller.example/data", + "mimeType": "application/json" + }, + "x402Version": 2, + "remotePaymentId": "pay_0123456789abcdef0123456789abcdef" + } + }, + "response": { + "status": 200, + "json": { + "amount": "1", + "currency": "USDC", + "approvalId": "33333333-3333-4333-8333-333333333333", + "approvalStatus": "PENDING", + "resource": { + "url": "https://seller.example/data", + "mimeType": "application/json" + }, + "transactionId": "22222222-2222-4222-8222-222222222222" + } + } + }, + { + "request": { + "method": "GET", + "path": "/v1/transactions/22222222-2222-4222-8222-222222222222/x402", + "headers": { + "x-api-key": "test-only-buyer-key" + } + }, + "response": { + "status": 200, + "json": { + "status": "GENERAL_ERROR" + } + } + } + ] + } + }, + { + "id": "x402.buyer.failure-insufficient_funds", + "suite": "x402-buyer", + "operation": "x402.buyer.sign", + "input": { + "api_key": "test-only-buyer-key", + "requirement": { + "scheme": "balance", + "network": "inflow:1", + "asset": "USDC", + "amount": "100000000", + "payTo": "11111111-1111-4111-8111-111111111111", + "maxTimeoutSeconds": 300, + "extra": {} + }, + "context": { + "resource": { + "url": "https://seller.example/data", + "mimeType": "application/json" + }, + "x402Version": 2 + }, + "payment_id": "pay_0123456789abcdef0123456789abcdef" + }, + "expect": { + "error": { + "code": "payment-failed", + "message": "Payment failed.", + "details": { + "status": "INSUFFICIENT_FUNDS" + } + } + }, + "platform": { + "exchanges": [ + { + "request": { + "method": "GET", + "path": "/v1/transactions/x402-supported", + "headers": { + "x-api-key": "test-only-buyer-key" + } + }, + "response": { + "status": 200, + "json": { + "kinds": [ + { + "scheme": "balance", + "network": "inflow:1", + "x402Version": 2 + }, + { + "scheme": "exact", + "network": "eip155:8453", + "x402Version": 2 + } + ] + } + } + }, + { + "request": { + "method": "POST", + "path": "/v1/transactions/x402", + "headers": { + "x-api-key": "test-only-buyer-key" + }, + "json": { + "accept": { + "scheme": "balance", + "network": "inflow:1", + "asset": "USDC", + "amount": "100000000", + "payTo": "11111111-1111-4111-8111-111111111111", + "maxTimeoutSeconds": 300, + "extra": {} + }, + "resource": { + "url": "https://seller.example/data", + "mimeType": "application/json" + }, + "x402Version": 2, + "remotePaymentId": "pay_0123456789abcdef0123456789abcdef" + } + }, + "response": { + "status": 200, + "json": { + "amount": "1", + "currency": "USDC", + "approvalId": "33333333-3333-4333-8333-333333333333", + "approvalStatus": "PENDING", + "resource": { + "url": "https://seller.example/data", + "mimeType": "application/json" + }, + "transactionId": "22222222-2222-4222-8222-222222222222" + } + } + }, + { + "request": { + "method": "GET", + "path": "/v1/transactions/22222222-2222-4222-8222-222222222222/x402", + "headers": { + "x-api-key": "test-only-buyer-key" + } + }, + "response": { + "status": 200, + "json": { + "status": "INSUFFICIENT_FUNDS" + } + } + } + ] + } + }, + { + "id": "x402.buyer.transient-poll-failure", + "suite": "x402-buyer", + "operation": "x402.buyer.sign", + "input": { + "api_key": "test-only-buyer-key", + "requirement": { + "scheme": "balance", + "network": "inflow:1", + "asset": "USDC", + "amount": "100000000", + "payTo": "11111111-1111-4111-8111-111111111111", + "maxTimeoutSeconds": 300, + "extra": {} + }, + "context": { + "resource": { + "url": "https://seller.example/data", + "mimeType": "application/json" + }, + "x402Version": 2 + }, + "payment_id": "pay_0123456789abcdef0123456789abcdef" + }, + "expect": { + "result": { + "encodedPayload": "eyJ4NDAyVmVyc2lvbiI6MiwiYWNjZXB0ZWQiOnsic2NoZW1lIjoiYmFsYW5jZSIsIm5ldHdvcmsiOiJpbmZsb3c6MSIsImFzc2V0IjoiVVNEQyIsImFtb3VudCI6IjEwMDAwMDAwMCIsInBheVRvIjoiMTExMTExMTEtMTExMS00MTExLTgxMTEtMTExMTExMTExMTExIiwibWF4VGltZW91dFNlY29uZHMiOjMwMCwiZXh0cmEiOnt9fSwicmVzb3VyY2UiOnsidXJsIjoiaHR0cHM6Ly9zZWxsZXIuZXhhbXBsZS9kYXRhIiwibWltZVR5cGUiOiJhcHBsaWNhdGlvbi9qc29uIn0sInBheWxvYWQiOnsidHJhbnNhY3Rpb25JZCI6IjIyMjIyMjIyLTIyMjItNDIyMi04MjIyLTIyMjIyMjIyMjIyMiJ9LCJleHRlbnNpb25zIjp7InBheW1lbnQtaWRlbnRpZmllciI6eyJpbmZvIjp7InJlcXVpcmVkIjpmYWxzZSwiaWQiOiJwYXlfMDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWYifSwic2NoZW1hIjp7IiRzY2hlbWEiOiJodHRwczovL2pzb24tc2NoZW1hLm9yZy9kcmFmdC8yMDIwLTEyL3NjaGVtYSIsInR5cGUiOiJvYmplY3QiLCJwcm9wZXJ0aWVzIjp7ImlkIjp7InR5cGUiOiJzdHJpbmciLCJtaW5MZW5ndGgiOjE2LCJtYXhMZW5ndGgiOjEyOCwicGF0dGVybiI6Il5bYS16QS1aMC05Xy1dKyQifSwicmVxdWlyZWQiOnsidHlwZSI6ImJvb2xlYW4ifX0sInJlcXVpcmVkIjpbInJlcXVpcmVkIl19fX19", + "paymentPayload": { + "x402Version": 2, + "accepted": { + "scheme": "balance", + "network": "inflow:1", + "asset": "USDC", + "amount": "100000000", + "payTo": "11111111-1111-4111-8111-111111111111", + "maxTimeoutSeconds": 300, + "extra": {} + }, + "resource": { + "url": "https://seller.example/data", + "mimeType": "application/json" + }, + "payload": { + "transactionId": "22222222-2222-4222-8222-222222222222" + }, + "extensions": { + "payment-identifier": { + "info": { + "required": false, + "id": "pay_0123456789abcdef0123456789abcdef" + }, + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 16, + "maxLength": 128, + "pattern": "^[a-zA-Z0-9_-]+$" + }, + "required": { + "type": "boolean" + } + }, + "required": [ + "required" + ] + } + } + } + }, + "transactionId": "22222222-2222-4222-8222-222222222222" + } + }, + "platform": { + "exchanges": [ + { + "request": { + "method": "GET", + "path": "/v1/transactions/x402-supported", + "headers": { + "x-api-key": "test-only-buyer-key" + } + }, + "response": { + "status": 200, + "json": { + "kinds": [ + { + "scheme": "balance", + "network": "inflow:1", + "x402Version": 2 + }, + { + "scheme": "exact", + "network": "eip155:8453", + "x402Version": 2 + } + ] + } + } + }, + { + "request": { + "method": "POST", + "path": "/v1/transactions/x402", + "headers": { + "x-api-key": "test-only-buyer-key" + }, + "json": { + "accept": { + "scheme": "balance", + "network": "inflow:1", + "asset": "USDC", + "amount": "100000000", + "payTo": "11111111-1111-4111-8111-111111111111", + "maxTimeoutSeconds": 300, + "extra": {} + }, + "resource": { + "url": "https://seller.example/data", + "mimeType": "application/json" + }, + "x402Version": 2, + "remotePaymentId": "pay_0123456789abcdef0123456789abcdef" + } + }, + "response": { + "status": 200, + "json": { + "amount": "1", + "currency": "USDC", + "approvalId": "33333333-3333-4333-8333-333333333333", + "approvalStatus": "PENDING", + "resource": { + "url": "https://seller.example/data", + "mimeType": "application/json" + }, + "transactionId": "22222222-2222-4222-8222-222222222222" + } + } + }, + { + "request": { + "method": "GET", + "path": "/v1/transactions/22222222-2222-4222-8222-222222222222/x402", + "headers": { + "x-api-key": "test-only-buyer-key" + } + }, + "response": { + "status": 503, + "json": { + "code": "TEMPORARY_ERROR" + } + } + }, + { + "request": { + "method": "GET", + "path": "/v1/transactions/22222222-2222-4222-8222-222222222222/x402", + "headers": { + "x-api-key": "test-only-buyer-key" + } + }, + "response": { + "status": 200, + "json": { + "status": "PENDING", + "encodedPayload": "eyJ4NDAyVmVyc2lvbiI6MiwiYWNjZXB0ZWQiOnsic2NoZW1lIjoiYmFsYW5jZSIsIm5ldHdvcmsiOiJpbmZsb3c6MSIsImFzc2V0IjoiVVNEQyIsImFtb3VudCI6IjEwMDAwMDAwMCIsInBheVRvIjoiMTExMTExMTEtMTExMS00MTExLTgxMTEtMTExMTExMTExMTExIiwibWF4VGltZW91dFNlY29uZHMiOjMwMCwiZXh0cmEiOnt9fSwicmVzb3VyY2UiOnsidXJsIjoiaHR0cHM6Ly9zZWxsZXIuZXhhbXBsZS9kYXRhIiwibWltZVR5cGUiOiJhcHBsaWNhdGlvbi9qc29uIn0sInBheWxvYWQiOnsidHJhbnNhY3Rpb25JZCI6IjIyMjIyMjIyLTIyMjItNDIyMi04MjIyLTIyMjIyMjIyMjIyMiJ9LCJleHRlbnNpb25zIjp7InBheW1lbnQtaWRlbnRpZmllciI6eyJpbmZvIjp7InJlcXVpcmVkIjpmYWxzZSwiaWQiOiJwYXlfMDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWYifSwic2NoZW1hIjp7IiRzY2hlbWEiOiJodHRwczovL2pzb24tc2NoZW1hLm9yZy9kcmFmdC8yMDIwLTEyL3NjaGVtYSIsInR5cGUiOiJvYmplY3QiLCJwcm9wZXJ0aWVzIjp7ImlkIjp7InR5cGUiOiJzdHJpbmciLCJtaW5MZW5ndGgiOjE2LCJtYXhMZW5ndGgiOjEyOCwicGF0dGVybiI6Il5bYS16QS1aMC05Xy1dKyQifSwicmVxdWlyZWQiOnsidHlwZSI6ImJvb2xlYW4ifX0sInJlcXVpcmVkIjpbInJlcXVpcmVkIl19fX19", + "paymentPayload": { + "x402Version": 2, + "accepted": { + "scheme": "balance", + "network": "inflow:1", + "asset": "USDC", + "amount": "100000000", + "payTo": "11111111-1111-4111-8111-111111111111", + "maxTimeoutSeconds": 300, + "extra": {} + }, + "resource": { + "url": "https://seller.example/data", + "mimeType": "application/json" + }, + "payload": { + "transactionId": "22222222-2222-4222-8222-222222222222" + }, + "extensions": { + "payment-identifier": { + "info": { + "required": false, + "id": "pay_0123456789abcdef0123456789abcdef" + }, + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 16, + "maxLength": 128, + "pattern": "^[a-zA-Z0-9_-]+$" + }, + "required": { + "type": "boolean" + } + }, + "required": [ + "required" + ] + } + } + } + } + } + } + } + ] + } + }, + { + "id": "x402.buyer.permanent-poll-401", + "suite": "x402-buyer", + "operation": "x402.buyer.sign", + "input": { + "api_key": "test-only-buyer-key", + "requirement": { + "scheme": "balance", + "network": "inflow:1", + "asset": "USDC", + "amount": "100000000", + "payTo": "11111111-1111-4111-8111-111111111111", + "maxTimeoutSeconds": 300, + "extra": {} + }, + "context": { + "resource": { + "url": "https://seller.example/data", + "mimeType": "application/json" + }, + "x402Version": 2 + }, + "payment_id": "pay_0123456789abcdef0123456789abcdef" + }, + "expect": { + "error": { + "code": "api-error", + "message": "InFlow API request failed.", + "details": { + "body": { + "errors": [ + { + "code": "DENIED", + "message": "Access denied." + } + ] + } + }, + "http_status": 401 + } + }, + "platform": { + "exchanges": [ + { + "request": { + "method": "GET", + "path": "/v1/transactions/x402-supported", + "headers": { + "x-api-key": "test-only-buyer-key" + } + }, + "response": { + "status": 200, + "json": { + "kinds": [ + { + "scheme": "balance", + "network": "inflow:1", + "x402Version": 2 + }, + { + "scheme": "exact", + "network": "eip155:8453", + "x402Version": 2 + } + ] + } + } + }, + { + "request": { + "method": "POST", + "path": "/v1/transactions/x402", + "headers": { + "x-api-key": "test-only-buyer-key" + }, + "json": { + "accept": { + "scheme": "balance", + "network": "inflow:1", + "asset": "USDC", + "amount": "100000000", + "payTo": "11111111-1111-4111-8111-111111111111", + "maxTimeoutSeconds": 300, + "extra": {} + }, + "resource": { + "url": "https://seller.example/data", + "mimeType": "application/json" + }, + "x402Version": 2, + "remotePaymentId": "pay_0123456789abcdef0123456789abcdef" + } + }, + "response": { + "status": 200, + "json": { + "amount": "1", + "currency": "USDC", + "approvalId": "33333333-3333-4333-8333-333333333333", + "approvalStatus": "PENDING", + "resource": { + "url": "https://seller.example/data", + "mimeType": "application/json" + }, + "transactionId": "22222222-2222-4222-8222-222222222222" + } + } + }, + { + "request": { + "method": "GET", + "path": "/v1/transactions/22222222-2222-4222-8222-222222222222/x402", + "headers": { + "x-api-key": "test-only-buyer-key" + } + }, + "response": { + "status": 401, + "json": { + "errors": [ + { + "code": "DENIED", + "message": "Access denied." + } + ] + } + } + } + ] + } + }, + { + "id": "x402.buyer.permanent-poll-403", + "suite": "x402-buyer", + "operation": "x402.buyer.sign", + "input": { + "api_key": "test-only-buyer-key", + "requirement": { + "scheme": "balance", + "network": "inflow:1", + "asset": "USDC", + "amount": "100000000", + "payTo": "11111111-1111-4111-8111-111111111111", + "maxTimeoutSeconds": 300, + "extra": {} + }, + "context": { + "resource": { + "url": "https://seller.example/data", + "mimeType": "application/json" + }, + "x402Version": 2 + }, + "payment_id": "pay_0123456789abcdef0123456789abcdef" + }, + "expect": { + "error": { + "code": "api-error", + "message": "InFlow API request failed.", + "details": { + "body": { + "errors": [ + { + "code": "DENIED", + "message": "Access denied." + } + ] + } + }, + "http_status": 403 + } + }, + "platform": { + "exchanges": [ + { + "request": { + "method": "GET", + "path": "/v1/transactions/x402-supported", + "headers": { + "x-api-key": "test-only-buyer-key" + } + }, + "response": { + "status": 200, + "json": { + "kinds": [ + { + "scheme": "balance", + "network": "inflow:1", + "x402Version": 2 + }, + { + "scheme": "exact", + "network": "eip155:8453", + "x402Version": 2 + } + ] + } + } + }, + { + "request": { + "method": "POST", + "path": "/v1/transactions/x402", + "headers": { + "x-api-key": "test-only-buyer-key" + }, + "json": { + "accept": { + "scheme": "balance", + "network": "inflow:1", + "asset": "USDC", + "amount": "100000000", + "payTo": "11111111-1111-4111-8111-111111111111", + "maxTimeoutSeconds": 300, + "extra": {} + }, + "resource": { + "url": "https://seller.example/data", + "mimeType": "application/json" + }, + "x402Version": 2, + "remotePaymentId": "pay_0123456789abcdef0123456789abcdef" + } + }, + "response": { + "status": 200, + "json": { + "amount": "1", + "currency": "USDC", + "approvalId": "33333333-3333-4333-8333-333333333333", + "approvalStatus": "PENDING", + "resource": { + "url": "https://seller.example/data", + "mimeType": "application/json" + }, + "transactionId": "22222222-2222-4222-8222-222222222222" + } + } + }, + { + "request": { + "method": "GET", + "path": "/v1/transactions/22222222-2222-4222-8222-222222222222/x402", + "headers": { + "x-api-key": "test-only-buyer-key" + } + }, + "response": { + "status": 403, + "json": { + "errors": [ + { + "code": "DENIED", + "message": "Access denied." + } + ] + } + } + } + ] + } + }, + { + "id": "x402.buyer.permanent-poll-404", + "suite": "x402-buyer", + "operation": "x402.buyer.sign", + "input": { + "api_key": "test-only-buyer-key", + "requirement": { + "scheme": "balance", + "network": "inflow:1", + "asset": "USDC", + "amount": "100000000", + "payTo": "11111111-1111-4111-8111-111111111111", + "maxTimeoutSeconds": 300, + "extra": {} + }, + "context": { + "resource": { + "url": "https://seller.example/data", + "mimeType": "application/json" + }, + "x402Version": 2 + }, + "payment_id": "pay_0123456789abcdef0123456789abcdef" + }, + "expect": { + "error": { + "code": "api-error", + "message": "InFlow API request failed.", + "details": { + "body": { + "errors": [ + { + "code": "DENIED", + "message": "Access denied." + } + ] + } + }, + "http_status": 404 + } + }, + "platform": { + "exchanges": [ + { + "request": { + "method": "GET", + "path": "/v1/transactions/x402-supported", + "headers": { + "x-api-key": "test-only-buyer-key" + } + }, + "response": { + "status": 200, + "json": { + "kinds": [ + { + "scheme": "balance", + "network": "inflow:1", + "x402Version": 2 + }, + { + "scheme": "exact", + "network": "eip155:8453", + "x402Version": 2 + } + ] + } + } + }, + { + "request": { + "method": "POST", + "path": "/v1/transactions/x402", + "headers": { + "x-api-key": "test-only-buyer-key" + }, + "json": { + "accept": { + "scheme": "balance", + "network": "inflow:1", + "asset": "USDC", + "amount": "100000000", + "payTo": "11111111-1111-4111-8111-111111111111", + "maxTimeoutSeconds": 300, + "extra": {} + }, + "resource": { + "url": "https://seller.example/data", + "mimeType": "application/json" + }, + "x402Version": 2, + "remotePaymentId": "pay_0123456789abcdef0123456789abcdef" + } + }, + "response": { + "status": 200, + "json": { + "amount": "1", + "currency": "USDC", + "approvalId": "33333333-3333-4333-8333-333333333333", + "approvalStatus": "PENDING", + "resource": { + "url": "https://seller.example/data", + "mimeType": "application/json" + }, + "transactionId": "22222222-2222-4222-8222-222222222222" + } + } + }, + { + "request": { + "method": "GET", + "path": "/v1/transactions/22222222-2222-4222-8222-222222222222/x402", + "headers": { + "x-api-key": "test-only-buyer-key" + } + }, + "response": { + "status": 404, + "json": { + "errors": [ + { + "code": "DENIED", + "message": "Access denied." + } + ] + } + } + } + ] + } + }, + { + "id": "x402.buyer.rate-limited-poll", + "suite": "x402-buyer", + "operation": "x402.buyer.sign", + "input": { + "api_key": "test-only-buyer-key", + "requirement": { + "scheme": "balance", + "network": "inflow:1", + "asset": "USDC", + "amount": "100000000", + "payTo": "11111111-1111-4111-8111-111111111111", + "maxTimeoutSeconds": 300, + "extra": {} + }, + "context": { + "resource": { + "url": "https://seller.example/data", + "mimeType": "application/json" + }, + "x402Version": 2 + }, + "payment_id": "pay_0123456789abcdef0123456789abcdef" + }, + "expect": { + "result": { + "encodedPayload": "eyJ4NDAyVmVyc2lvbiI6MiwiYWNjZXB0ZWQiOnsic2NoZW1lIjoiYmFsYW5jZSIsIm5ldHdvcmsiOiJpbmZsb3c6MSIsImFzc2V0IjoiVVNEQyIsImFtb3VudCI6IjEwMDAwMDAwMCIsInBheVRvIjoiMTExMTExMTEtMTExMS00MTExLTgxMTEtMTExMTExMTExMTExIiwibWF4VGltZW91dFNlY29uZHMiOjMwMCwiZXh0cmEiOnt9fSwicmVzb3VyY2UiOnsidXJsIjoiaHR0cHM6Ly9zZWxsZXIuZXhhbXBsZS9kYXRhIiwibWltZVR5cGUiOiJhcHBsaWNhdGlvbi9qc29uIn0sInBheWxvYWQiOnsidHJhbnNhY3Rpb25JZCI6IjIyMjIyMjIyLTIyMjItNDIyMi04MjIyLTIyMjIyMjIyMjIyMiJ9LCJleHRlbnNpb25zIjp7InBheW1lbnQtaWRlbnRpZmllciI6eyJpbmZvIjp7InJlcXVpcmVkIjpmYWxzZSwiaWQiOiJwYXlfMDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWYifSwic2NoZW1hIjp7IiRzY2hlbWEiOiJodHRwczovL2pzb24tc2NoZW1hLm9yZy9kcmFmdC8yMDIwLTEyL3NjaGVtYSIsInR5cGUiOiJvYmplY3QiLCJwcm9wZXJ0aWVzIjp7ImlkIjp7InR5cGUiOiJzdHJpbmciLCJtaW5MZW5ndGgiOjE2LCJtYXhMZW5ndGgiOjEyOCwicGF0dGVybiI6Il5bYS16QS1aMC05Xy1dKyQifSwicmVxdWlyZWQiOnsidHlwZSI6ImJvb2xlYW4ifX0sInJlcXVpcmVkIjpbInJlcXVpcmVkIl19fX19", + "paymentPayload": { + "x402Version": 2, + "accepted": { + "scheme": "balance", + "network": "inflow:1", + "asset": "USDC", + "amount": "100000000", + "payTo": "11111111-1111-4111-8111-111111111111", + "maxTimeoutSeconds": 300, + "extra": {} + }, + "resource": { + "url": "https://seller.example/data", + "mimeType": "application/json" + }, + "payload": { + "transactionId": "22222222-2222-4222-8222-222222222222" + }, + "extensions": { + "payment-identifier": { + "info": { + "required": false, + "id": "pay_0123456789abcdef0123456789abcdef" + }, + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 16, + "maxLength": 128, + "pattern": "^[a-zA-Z0-9_-]+$" + }, + "required": { + "type": "boolean" + } + }, + "required": [ + "required" + ] + } + } + } + }, + "transactionId": "22222222-2222-4222-8222-222222222222" + } + }, + "platform": { + "exchanges": [ + { + "request": { + "method": "GET", + "path": "/v1/transactions/x402-supported", + "headers": { + "x-api-key": "test-only-buyer-key" + } + }, + "response": { + "status": 200, + "json": { + "kinds": [ + { + "scheme": "balance", + "network": "inflow:1", + "x402Version": 2 + }, + { + "scheme": "exact", + "network": "eip155:8453", + "x402Version": 2 + } + ] + } + } + }, + { + "request": { + "method": "POST", + "path": "/v1/transactions/x402", + "headers": { + "x-api-key": "test-only-buyer-key" + }, + "json": { + "accept": { + "scheme": "balance", + "network": "inflow:1", + "asset": "USDC", + "amount": "100000000", + "payTo": "11111111-1111-4111-8111-111111111111", + "maxTimeoutSeconds": 300, + "extra": {} + }, + "resource": { + "url": "https://seller.example/data", + "mimeType": "application/json" + }, + "x402Version": 2, + "remotePaymentId": "pay_0123456789abcdef0123456789abcdef" + } + }, + "response": { + "status": 200, + "json": { + "amount": "1", + "currency": "USDC", + "approvalId": "33333333-3333-4333-8333-333333333333", + "approvalStatus": "PENDING", + "resource": { + "url": "https://seller.example/data", + "mimeType": "application/json" + }, + "transactionId": "22222222-2222-4222-8222-222222222222" + } + } + }, + { + "request": { + "method": "GET", + "path": "/v1/transactions/22222222-2222-4222-8222-222222222222/x402", + "headers": { + "x-api-key": "test-only-buyer-key" + } + }, + "response": { + "status": 429, + "json": { + "code": "RATE_LIMITED" + } + } + }, + { + "request": { + "method": "GET", + "path": "/v1/transactions/22222222-2222-4222-8222-222222222222/x402", + "headers": { + "x-api-key": "test-only-buyer-key" + } + }, + "response": { + "status": 200, + "json": { + "status": "PENDING", + "encodedPayload": "eyJ4NDAyVmVyc2lvbiI6MiwiYWNjZXB0ZWQiOnsic2NoZW1lIjoiYmFsYW5jZSIsIm5ldHdvcmsiOiJpbmZsb3c6MSIsImFzc2V0IjoiVVNEQyIsImFtb3VudCI6IjEwMDAwMDAwMCIsInBheVRvIjoiMTExMTExMTEtMTExMS00MTExLTgxMTEtMTExMTExMTExMTExIiwibWF4VGltZW91dFNlY29uZHMiOjMwMCwiZXh0cmEiOnt9fSwicmVzb3VyY2UiOnsidXJsIjoiaHR0cHM6Ly9zZWxsZXIuZXhhbXBsZS9kYXRhIiwibWltZVR5cGUiOiJhcHBsaWNhdGlvbi9qc29uIn0sInBheWxvYWQiOnsidHJhbnNhY3Rpb25JZCI6IjIyMjIyMjIyLTIyMjItNDIyMi04MjIyLTIyMjIyMjIyMjIyMiJ9LCJleHRlbnNpb25zIjp7InBheW1lbnQtaWRlbnRpZmllciI6eyJpbmZvIjp7InJlcXVpcmVkIjpmYWxzZSwiaWQiOiJwYXlfMDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWYifSwic2NoZW1hIjp7IiRzY2hlbWEiOiJodHRwczovL2pzb24tc2NoZW1hLm9yZy9kcmFmdC8yMDIwLTEyL3NjaGVtYSIsInR5cGUiOiJvYmplY3QiLCJwcm9wZXJ0aWVzIjp7ImlkIjp7InR5cGUiOiJzdHJpbmciLCJtaW5MZW5ndGgiOjE2LCJtYXhMZW5ndGgiOjEyOCwicGF0dGVybiI6Il5bYS16QS1aMC05Xy1dKyQifSwicmVxdWlyZWQiOnsidHlwZSI6ImJvb2xlYW4ifX0sInJlcXVpcmVkIjpbInJlcXVpcmVkIl19fX19", + "paymentPayload": { + "x402Version": 2, + "accepted": { + "scheme": "balance", + "network": "inflow:1", + "asset": "USDC", + "amount": "100000000", + "payTo": "11111111-1111-4111-8111-111111111111", + "maxTimeoutSeconds": 300, + "extra": {} + }, + "resource": { + "url": "https://seller.example/data", + "mimeType": "application/json" + }, + "payload": { + "transactionId": "22222222-2222-4222-8222-222222222222" + }, + "extensions": { + "payment-identifier": { + "info": { + "required": false, + "id": "pay_0123456789abcdef0123456789abcdef" + }, + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 16, + "maxLength": 128, + "pattern": "^[a-zA-Z0-9_-]+$" + }, + "required": { + "type": "boolean" + } + }, + "required": [ + "required" + ] + } + } + } + } + } + } + } + ] + } + }, + { + "id": "x402.buyer.timeout-during-poll", + "suite": "x402-buyer", + "operation": "x402.buyer.sign", + "input": { + "api_key": "test-only-buyer-key", + "requirement": { + "scheme": "balance", + "network": "inflow:1", + "asset": "USDC", + "amount": "100000000", + "payTo": "11111111-1111-4111-8111-111111111111", + "maxTimeoutSeconds": 300, + "extra": {} + }, + "context": { + "resource": { + "url": "https://seller.example/data", + "mimeType": "application/json" + }, + "x402Version": 2 + }, + "payment_id": "pay_0123456789abcdef0123456789abcdef", + "timeout_ms": 500 + }, + "expect": { + "error": { + "code": "payment-timeout", + "message": "Payment timed out." + } + }, + "platform": { + "exchanges": [ + { + "request": { + "method": "GET", + "path": "/v1/transactions/x402-supported", + "headers": { + "x-api-key": "test-only-buyer-key" + } + }, + "response": { + "status": 200, + "json": { + "kinds": [ + { + "scheme": "balance", + "network": "inflow:1", + "x402Version": 2 + }, + { + "scheme": "exact", + "network": "eip155:8453", + "x402Version": 2 + } + ] + } + } + }, + { + "request": { + "method": "POST", + "path": "/v1/transactions/x402", + "headers": { + "x-api-key": "test-only-buyer-key" + }, + "json": { + "accept": { + "scheme": "balance", + "network": "inflow:1", + "asset": "USDC", + "amount": "100000000", + "payTo": "11111111-1111-4111-8111-111111111111", + "maxTimeoutSeconds": 300, + "extra": {} + }, + "resource": { + "url": "https://seller.example/data", + "mimeType": "application/json" + }, + "x402Version": 2, + "remotePaymentId": "pay_0123456789abcdef0123456789abcdef" + } + }, + "response": { + "status": 200, + "json": { + "amount": "1", + "currency": "USDC", + "approvalId": "33333333-3333-4333-8333-333333333333", + "approvalStatus": "PENDING", + "resource": { + "url": "https://seller.example/data", + "mimeType": "application/json" + }, + "transactionId": "22222222-2222-4222-8222-222222222222" + } + } + }, + { + "request": { + "method": "GET", + "path": "/v1/transactions/22222222-2222-4222-8222-222222222222/x402", + "headers": { + "x-api-key": "test-only-buyer-key" + } + }, + "response": { + "status": 200, + "json": { + "status": "PENDING", + "encodedPayload": "eyJ4NDAyVmVyc2lvbiI6MiwiYWNjZXB0ZWQiOnsic2NoZW1lIjoiYmFsYW5jZSIsIm5ldHdvcmsiOiJpbmZsb3c6MSIsImFzc2V0IjoiVVNEQyIsImFtb3VudCI6IjEwMDAwMDAwMCIsInBheVRvIjoiMTExMTExMTEtMTExMS00MTExLTgxMTEtMTExMTExMTExMTExIiwibWF4VGltZW91dFNlY29uZHMiOjMwMCwiZXh0cmEiOnt9fSwicmVzb3VyY2UiOnsidXJsIjoiaHR0cHM6Ly9zZWxsZXIuZXhhbXBsZS9kYXRhIiwibWltZVR5cGUiOiJhcHBsaWNhdGlvbi9qc29uIn0sInBheWxvYWQiOnsidHJhbnNhY3Rpb25JZCI6IjIyMjIyMjIyLTIyMjItNDIyMi04MjIyLTIyMjIyMjIyMjIyMiJ9LCJleHRlbnNpb25zIjp7InBheW1lbnQtaWRlbnRpZmllciI6eyJpbmZvIjp7InJlcXVpcmVkIjpmYWxzZSwiaWQiOiJwYXlfMDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWYifSwic2NoZW1hIjp7IiRzY2hlbWEiOiJodHRwczovL2pzb24tc2NoZW1hLm9yZy9kcmFmdC8yMDIwLTEyL3NjaGVtYSIsInR5cGUiOiJvYmplY3QiLCJwcm9wZXJ0aWVzIjp7ImlkIjp7InR5cGUiOiJzdHJpbmciLCJtaW5MZW5ndGgiOjE2LCJtYXhMZW5ndGgiOjEyOCwicGF0dGVybiI6Il5bYS16QS1aMC05Xy1dKyQifSwicmVxdWlyZWQiOnsidHlwZSI6ImJvb2xlYW4ifX0sInJlcXVpcmVkIjpbInJlcXVpcmVkIl19fX19", + "paymentPayload": { + "x402Version": 2, + "accepted": { + "scheme": "balance", + "network": "inflow:1", + "asset": "USDC", + "amount": "100000000", + "payTo": "11111111-1111-4111-8111-111111111111", + "maxTimeoutSeconds": 300, + "extra": {} + }, + "resource": { + "url": "https://seller.example/data", + "mimeType": "application/json" + }, + "payload": { + "transactionId": "22222222-2222-4222-8222-222222222222" + }, + "extensions": { + "payment-identifier": { + "info": { + "required": false, + "id": "pay_0123456789abcdef0123456789abcdef" + }, + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 16, + "maxLength": 128, + "pattern": "^[a-zA-Z0-9_-]+$" + }, + "required": { + "type": "boolean" + } + }, + "required": [ + "required" + ] + } + } + } + } + }, + "delay_ms": 1000 + } + } + ] + } + }, + { + "id": "x402.buyer.create-not-retried", + "suite": "x402-buyer", + "operation": "x402.buyer.sign", + "input": { + "api_key": "test-only-buyer-key", + "requirement": { + "scheme": "balance", + "network": "inflow:1", + "asset": "USDC", + "amount": "100000000", + "payTo": "11111111-1111-4111-8111-111111111111", + "maxTimeoutSeconds": 300, + "extra": {} + }, + "context": { + "resource": { + "url": "https://seller.example/data", + "mimeType": "application/json" + }, + "x402Version": 2 + }, + "payment_id": "pay_0123456789abcdef0123456789abcdef" + }, + "expect": { + "error": { + "code": "api-error", + "message": "InFlow API request failed.", + "details": { + "body": { + "code": "TEMPORARY_ERROR" + } + }, + "http_status": 503 + } + }, + "platform": { + "exchanges": [ + { + "request": { + "method": "GET", + "path": "/v1/transactions/x402-supported", + "headers": { + "x-api-key": "test-only-buyer-key" + } + }, + "response": { + "status": 200, + "json": { + "kinds": [ + { + "scheme": "balance", + "network": "inflow:1", + "x402Version": 2 + }, + { + "scheme": "exact", + "network": "eip155:8453", + "x402Version": 2 + } + ] + } + } + }, + { + "request": { + "method": "POST", + "path": "/v1/transactions/x402", + "headers": { + "x-api-key": "test-only-buyer-key" + }, + "json": { + "accept": { + "scheme": "balance", + "network": "inflow:1", + "asset": "USDC", + "amount": "100000000", + "payTo": "11111111-1111-4111-8111-111111111111", + "maxTimeoutSeconds": 300, + "extra": {} + }, + "resource": { + "url": "https://seller.example/data", + "mimeType": "application/json" + }, + "x402Version": 2, + "remotePaymentId": "pay_0123456789abcdef0123456789abcdef" + } + }, + "response": { + "status": 503, + "json": { + "code": "TEMPORARY_ERROR" + } + } + } + ] + } + }, + { + "id": "x402.buyer.invalid-identifier", + "suite": "x402-buyer", + "operation": "x402.buyer.sign", + "input": { + "api_key": "test-only-buyer-key", + "requirement": { + "scheme": "balance", + "network": "inflow:1", + "asset": "USDC", + "amount": "100000000", + "payTo": "11111111-1111-4111-8111-111111111111", + "maxTimeoutSeconds": 300, + "extra": {} + }, + "context": { + "resource": { + "url": "https://seller.example/data", + "mimeType": "application/json" + }, + "x402Version": 2 + }, + "payment_id": "short" + }, + "expect": { + "error": { + "code": "invalid-input", + "message": "Invalid input." + } + }, + "platform": { + "exchanges": [ + { + "request": { + "method": "GET", + "path": "/v1/transactions/x402-supported", + "headers": { + "x-api-key": "test-only-buyer-key" + } + }, + "response": { + "status": 200, + "json": { + "kinds": [ + { + "scheme": "balance", + "network": "inflow:1", + "x402Version": 2 + }, + { + "scheme": "exact", + "network": "eip155:8453", + "x402Version": 2 + } + ] + } + } + } + ] + } + }, + { + "id": "x402.buyer.permit2-external-only", + "suite": "x402-buyer", + "operation": "x402.buyer.sign", + "input": { + "api_key": "test-only-buyer-key", + "requirement": { + "scheme": "exact", + "network": "eip155:8453", + "asset": "0x3333333333333333333333333333333333333333", + "amount": "1000000", + "payTo": "0x1111111111111111111111111111111111111111", + "maxTimeoutSeconds": 300, + "extra": { + "assetTransferMethod": "permit2", + "permit2Proxy": "0x402085c248EeA27D92E8b30b2C58ed07f9E20001" + } + }, + "context": { + "resource": { + "url": "https://seller.example/data", + "mimeType": "application/json" + }, + "x402Version": 2 + }, + "payment_id": "pay_0123456789abcdef0123456789abcdef" + }, + "expect": { + "error": { + "code": "unsupported-capability", + "message": "Unsupported payment capability." + } + }, + "platform": { + "exchanges": [ + { + "request": { + "method": "GET", + "path": "/v1/transactions/x402-supported", + "headers": { + "x-api-key": "test-only-buyer-key" + } + }, + "response": { + "status": 200, + "json": { + "kinds": [ + { + "scheme": "balance", + "network": "inflow:1", + "x402Version": 2 + }, + { + "scheme": "exact", + "network": "eip155:8453", + "x402Version": 2 + } + ] + } + } + } + ] + } + }, + { + "id": "x402.buyer.unsupported-network", + "suite": "x402-buyer", + "operation": "x402.buyer.sign", + "input": { + "api_key": "test-only-buyer-key", + "requirement": { + "scheme": "exact", + "network": "eip155:999999", + "asset": "0x3333333333333333333333333333333333333333", + "amount": "1000000", + "payTo": "0x1111111111111111111111111111111111111111", + "maxTimeoutSeconds": 300, + "extra": { + "assetTransferMethod": "eip3009", + "name": "USDC", + "version": "2" + } + }, + "context": { + "resource": { + "url": "https://seller.example/data", + "mimeType": "application/json" + }, + "x402Version": 2 + }, + "payment_id": "pay_0123456789abcdef0123456789abcdef" + }, + "expect": { + "error": { + "code": "unsupported-capability", + "message": "Unsupported payment capability." + } + }, + "platform": { + "exchanges": [ + { + "request": { + "method": "GET", + "path": "/v1/transactions/x402-supported", + "headers": { + "x-api-key": "test-only-buyer-key" + } + }, + "response": { + "status": 200, + "json": { + "kinds": [ + { + "scheme": "balance", + "network": "inflow:1", + "x402Version": 2 + }, + { + "scheme": "exact", + "network": "eip155:8453", + "x402Version": 2 + } + ] + } + } + } + ] + } + }, + { + "id": "x402.buyer.cancel", + "suite": "x402-buyer", + "operation": "x402.buyer.cancel", + "input": { + "api_key": "test-only-buyer-key", + "requirement": { + "scheme": "balance", + "network": "inflow:1", + "asset": "USDC", + "amount": "100000000", + "payTo": "11111111-1111-4111-8111-111111111111", + "maxTimeoutSeconds": 300, + "extra": {} + }, + "context": { + "resource": { + "url": "https://seller.example/data", + "mimeType": "application/json" + }, + "x402Version": 2 + }, + "payment_id": "pay_0123456789abcdef0123456789abcdef" + }, + "expect": { + "error": { + "code": "payment-cancelled", + "message": "Payment cancelled." + } + }, + "platform": { + "exchanges": [ + { + "request": { + "method": "GET", + "path": "/v1/transactions/x402-supported", + "headers": { + "x-api-key": "test-only-buyer-key" + } + }, + "response": { + "status": 200, + "json": { + "kinds": [ + { + "scheme": "balance", + "network": "inflow:1", + "x402Version": 2 + }, + { + "scheme": "exact", + "network": "eip155:8453", + "x402Version": 2 + } + ] + } + } + }, + { + "request": { + "method": "POST", + "path": "/v1/transactions/x402", + "headers": { + "x-api-key": "test-only-buyer-key" + }, + "json": { + "accept": { + "scheme": "balance", + "network": "inflow:1", + "asset": "USDC", + "amount": "100000000", + "payTo": "11111111-1111-4111-8111-111111111111", + "maxTimeoutSeconds": 300, + "extra": {} + }, + "resource": { + "url": "https://seller.example/data", + "mimeType": "application/json" + }, + "x402Version": 2, + "remotePaymentId": "pay_0123456789abcdef0123456789abcdef" + } + }, + "response": { + "status": 200, + "json": { + "amount": "1", + "currency": "USDC", + "approvalId": "33333333-3333-4333-8333-333333333333", + "approvalStatus": "PENDING", + "resource": { + "url": "https://seller.example/data", + "mimeType": "application/json" + }, + "transactionId": "22222222-2222-4222-8222-222222222222" + } + } + }, + { + "request": { + "method": "POST", + "path": "/v1/approvals/33333333-3333-4333-8333-333333333333/cancel", + "headers": { + "x-api-key": "test-only-buyer-key" + } + }, + "response": { + "status": 204 + } + } + ] + } + }, + { + "id": "x402.buyer.cancel-api-failure", + "suite": "x402-buyer", + "operation": "x402.buyer.cancel", + "input": { + "api_key": "test-only-buyer-key", + "requirement": { + "scheme": "balance", + "network": "inflow:1", + "asset": "USDC", + "amount": "100000000", + "payTo": "11111111-1111-4111-8111-111111111111", + "maxTimeoutSeconds": 300, + "extra": {} + }, + "context": { + "resource": { + "url": "https://seller.example/data", + "mimeType": "application/json" + }, + "x402Version": 2 + }, + "payment_id": "pay_0123456789abcdef0123456789abcdef" + }, + "expect": { + "error": { + "code": "payment-cancelled", + "message": "Payment cancelled." + } + }, + "platform": { + "exchanges": [ + { + "request": { + "method": "GET", + "path": "/v1/transactions/x402-supported", + "headers": { + "x-api-key": "test-only-buyer-key" + } + }, + "response": { + "status": 200, + "json": { + "kinds": [ + { + "scheme": "balance", + "network": "inflow:1", + "x402Version": 2 + }, + { + "scheme": "exact", + "network": "eip155:8453", + "x402Version": 2 + } + ] + } + } + }, + { + "request": { + "method": "POST", + "path": "/v1/transactions/x402", + "headers": { + "x-api-key": "test-only-buyer-key" + }, + "json": { + "accept": { + "scheme": "balance", + "network": "inflow:1", + "asset": "USDC", + "amount": "100000000", + "payTo": "11111111-1111-4111-8111-111111111111", + "maxTimeoutSeconds": 300, + "extra": {} + }, + "resource": { + "url": "https://seller.example/data", + "mimeType": "application/json" + }, + "x402Version": 2, + "remotePaymentId": "pay_0123456789abcdef0123456789abcdef" + } + }, + "response": { + "status": 200, + "json": { + "amount": "1", + "currency": "USDC", + "approvalId": "33333333-3333-4333-8333-333333333333", + "approvalStatus": "PENDING", + "resource": { + "url": "https://seller.example/data", + "mimeType": "application/json" + }, + "transactionId": "22222222-2222-4222-8222-222222222222" + } + } + }, + { + "request": { + "method": "POST", + "path": "/v1/approvals/33333333-3333-4333-8333-333333333333/cancel", + "headers": { + "x-api-key": "test-only-buyer-key" + } + }, + "response": { + "status": 503 + } + } + ] + } + }, + { + "id": "x402.buyer.concurrent-await", + "suite": "x402-buyer", + "operation": "x402.buyer.concurrent-await", + "input": { + "api_key": "test-only-buyer-key", + "requirement": { + "scheme": "balance", + "network": "inflow:1", + "asset": "USDC", + "amount": "100000000", + "payTo": "11111111-1111-4111-8111-111111111111", + "maxTimeoutSeconds": 300, + "extra": {} + }, + "context": { + "resource": { + "url": "https://seller.example/data", + "mimeType": "application/json" + }, + "x402Version": 2 + }, + "payment_id": "pay_0123456789abcdef0123456789abcdef" + }, + "expect": { + "result": { + "encodedPayload": "eyJ4NDAyVmVyc2lvbiI6MiwiYWNjZXB0ZWQiOnsic2NoZW1lIjoiYmFsYW5jZSIsIm5ldHdvcmsiOiJpbmZsb3c6MSIsImFzc2V0IjoiVVNEQyIsImFtb3VudCI6IjEwMDAwMDAwMCIsInBheVRvIjoiMTExMTExMTEtMTExMS00MTExLTgxMTEtMTExMTExMTExMTExIiwibWF4VGltZW91dFNlY29uZHMiOjMwMCwiZXh0cmEiOnt9fSwicmVzb3VyY2UiOnsidXJsIjoiaHR0cHM6Ly9zZWxsZXIuZXhhbXBsZS9kYXRhIiwibWltZVR5cGUiOiJhcHBsaWNhdGlvbi9qc29uIn0sInBheWxvYWQiOnsidHJhbnNhY3Rpb25JZCI6IjIyMjIyMjIyLTIyMjItNDIyMi04MjIyLTIyMjIyMjIyMjIyMiJ9LCJleHRlbnNpb25zIjp7InBheW1lbnQtaWRlbnRpZmllciI6eyJpbmZvIjp7InJlcXVpcmVkIjpmYWxzZSwiaWQiOiJwYXlfMDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWYifSwic2NoZW1hIjp7IiRzY2hlbWEiOiJodHRwczovL2pzb24tc2NoZW1hLm9yZy9kcmFmdC8yMDIwLTEyL3NjaGVtYSIsInR5cGUiOiJvYmplY3QiLCJwcm9wZXJ0aWVzIjp7ImlkIjp7InR5cGUiOiJzdHJpbmciLCJtaW5MZW5ndGgiOjE2LCJtYXhMZW5ndGgiOjEyOCwicGF0dGVybiI6Il5bYS16QS1aMC05Xy1dKyQifSwicmVxdWlyZWQiOnsidHlwZSI6ImJvb2xlYW4ifX0sInJlcXVpcmVkIjpbInJlcXVpcmVkIl19fX19", + "paymentPayload": { + "x402Version": 2, + "accepted": { + "scheme": "balance", + "network": "inflow:1", + "asset": "USDC", + "amount": "100000000", + "payTo": "11111111-1111-4111-8111-111111111111", + "maxTimeoutSeconds": 300, + "extra": {} + }, + "resource": { + "url": "https://seller.example/data", + "mimeType": "application/json" + }, + "payload": { + "transactionId": "22222222-2222-4222-8222-222222222222" + }, + "extensions": { + "payment-identifier": { + "info": { + "required": false, + "id": "pay_0123456789abcdef0123456789abcdef" + }, + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 16, + "maxLength": 128, + "pattern": "^[a-zA-Z0-9_-]+$" + }, + "required": { + "type": "boolean" + } + }, + "required": [ + "required" + ] + } + } + } + }, + "transactionId": "22222222-2222-4222-8222-222222222222" + } + }, + "platform": { + "exchanges": [ + { + "request": { + "method": "GET", + "path": "/v1/transactions/x402-supported", + "headers": { + "x-api-key": "test-only-buyer-key" + } + }, + "response": { + "status": 200, + "json": { + "kinds": [ + { + "scheme": "balance", + "network": "inflow:1", + "x402Version": 2 + }, + { + "scheme": "exact", + "network": "eip155:8453", + "x402Version": 2 + } + ] + } + } + }, + { + "request": { + "method": "POST", + "path": "/v1/transactions/x402", + "headers": { + "x-api-key": "test-only-buyer-key" + }, + "json": { + "accept": { + "scheme": "balance", + "network": "inflow:1", + "asset": "USDC", + "amount": "100000000", + "payTo": "11111111-1111-4111-8111-111111111111", + "maxTimeoutSeconds": 300, + "extra": {} + }, + "resource": { + "url": "https://seller.example/data", + "mimeType": "application/json" + }, + "x402Version": 2, + "remotePaymentId": "pay_0123456789abcdef0123456789abcdef" + } + }, + "response": { + "status": 200, + "json": { + "amount": "1", + "currency": "USDC", + "approvalId": "33333333-3333-4333-8333-333333333333", + "approvalStatus": "PENDING", + "resource": { + "url": "https://seller.example/data", + "mimeType": "application/json" + }, + "transactionId": "22222222-2222-4222-8222-222222222222" + } + } + }, + { + "request": { + "method": "GET", + "path": "/v1/transactions/22222222-2222-4222-8222-222222222222/x402", + "headers": { + "x-api-key": "test-only-buyer-key" + } + }, + "response": { + "status": 200, + "json": { + "status": "PENDING", + "encodedPayload": "eyJ4NDAyVmVyc2lvbiI6MiwiYWNjZXB0ZWQiOnsic2NoZW1lIjoiYmFsYW5jZSIsIm5ldHdvcmsiOiJpbmZsb3c6MSIsImFzc2V0IjoiVVNEQyIsImFtb3VudCI6IjEwMDAwMDAwMCIsInBheVRvIjoiMTExMTExMTEtMTExMS00MTExLTgxMTEtMTExMTExMTExMTExIiwibWF4VGltZW91dFNlY29uZHMiOjMwMCwiZXh0cmEiOnt9fSwicmVzb3VyY2UiOnsidXJsIjoiaHR0cHM6Ly9zZWxsZXIuZXhhbXBsZS9kYXRhIiwibWltZVR5cGUiOiJhcHBsaWNhdGlvbi9qc29uIn0sInBheWxvYWQiOnsidHJhbnNhY3Rpb25JZCI6IjIyMjIyMjIyLTIyMjItNDIyMi04MjIyLTIyMjIyMjIyMjIyMiJ9LCJleHRlbnNpb25zIjp7InBheW1lbnQtaWRlbnRpZmllciI6eyJpbmZvIjp7InJlcXVpcmVkIjpmYWxzZSwiaWQiOiJwYXlfMDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWYifSwic2NoZW1hIjp7IiRzY2hlbWEiOiJodHRwczovL2pzb24tc2NoZW1hLm9yZy9kcmFmdC8yMDIwLTEyL3NjaGVtYSIsInR5cGUiOiJvYmplY3QiLCJwcm9wZXJ0aWVzIjp7ImlkIjp7InR5cGUiOiJzdHJpbmciLCJtaW5MZW5ndGgiOjE2LCJtYXhMZW5ndGgiOjEyOCwicGF0dGVybiI6Il5bYS16QS1aMC05Xy1dKyQifSwicmVxdWlyZWQiOnsidHlwZSI6ImJvb2xlYW4ifX0sInJlcXVpcmVkIjpbInJlcXVpcmVkIl19fX19", + "paymentPayload": { + "x402Version": 2, + "accepted": { + "scheme": "balance", + "network": "inflow:1", + "asset": "USDC", + "amount": "100000000", + "payTo": "11111111-1111-4111-8111-111111111111", + "maxTimeoutSeconds": 300, + "extra": {} + }, + "resource": { + "url": "https://seller.example/data", + "mimeType": "application/json" + }, + "payload": { + "transactionId": "22222222-2222-4222-8222-222222222222" + }, + "extensions": { + "payment-identifier": { + "info": { + "required": false, + "id": "pay_0123456789abcdef0123456789abcdef" + }, + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 16, + "maxLength": 128, + "pattern": "^[a-zA-Z0-9_-]+$" + }, + "required": { + "type": "boolean" + } + }, + "required": [ + "required" + ] + } + } + } + } + } + } + } + ] + } + }, + { + "id": "x402.buyer.timeout", + "suite": "x402-buyer", + "operation": "x402.buyer.sign", + "input": { + "api_key": "test-only-buyer-key", + "requirement": { + "scheme": "balance", + "network": "inflow:1", + "asset": "USDC", + "amount": "100000000", + "payTo": "11111111-1111-4111-8111-111111111111", + "maxTimeoutSeconds": 300, + "extra": {} + }, + "context": { + "resource": { + "url": "https://seller.example/data", + "mimeType": "application/json" + }, + "x402Version": 2 + }, + "payment_id": "pay_0123456789abcdef0123456789abcdef", + "timeout_ms": 1000, + "poll_interval_ms": 1100 + }, + "expect": { + "error": { + "code": "payment-timeout", + "message": "Payment timed out." + } + }, + "platform": { + "exchanges": [ + { + "request": { + "method": "GET", + "path": "/v1/transactions/x402-supported", + "headers": { + "x-api-key": "test-only-buyer-key" + } + }, + "response": { + "status": 200, + "json": { + "kinds": [ + { + "scheme": "balance", + "network": "inflow:1", + "x402Version": 2 + }, + { + "scheme": "exact", + "network": "eip155:8453", + "x402Version": 2 + } + ] + } + } + }, + { + "request": { + "method": "POST", + "path": "/v1/transactions/x402", + "headers": { + "x-api-key": "test-only-buyer-key" + }, + "json": { + "accept": { + "scheme": "balance", + "network": "inflow:1", + "asset": "USDC", + "amount": "100000000", + "payTo": "11111111-1111-4111-8111-111111111111", + "maxTimeoutSeconds": 300, + "extra": {} + }, + "resource": { + "url": "https://seller.example/data", + "mimeType": "application/json" + }, + "x402Version": 2, + "remotePaymentId": "pay_0123456789abcdef0123456789abcdef" + } + }, + "response": { + "status": 200, + "json": { + "amount": "1", + "currency": "USDC", + "approvalId": "33333333-3333-4333-8333-333333333333", + "approvalStatus": "PENDING", + "resource": { + "url": "https://seller.example/data", + "mimeType": "application/json" + }, + "transactionId": "22222222-2222-4222-8222-222222222222" + } + } + }, + { + "request": { + "method": "GET", + "path": "/v1/transactions/22222222-2222-4222-8222-222222222222/x402", + "headers": { + "x-api-key": "test-only-buyer-key" + } + }, + "response": { + "status": 200, + "json": { + "status": "INITIATED" + } + } + } + ] + } + } + ] +} diff --git a/tests/test_x402_buyer.py b/tests/test_x402_buyer.py new file mode 100644 index 0000000..5e02b13 --- /dev/null +++ b/tests/test_x402_buyer.py @@ -0,0 +1,949 @@ +import asyncio +import base64 +import json +from contextlib import suppress +from copy import deepcopy +from pathlib import Path +from typing import Any, cast + +import httpx +import pytest +from x402.schemas import ( + AbortResult, + NoMatchingRequirementsError, + PaymentAbortedError, + PaymentCreatedContext, + PaymentCreationContext, + PaymentCreationFailureContext, + PaymentPayload, + PaymentRequired, + PaymentRequirements, + RecoveredPayloadResult, + ResourceInfo, +) + +from inflowpay import ClientOptions, InflowApiError +from inflowpay.x402.buyer import Buyer, X402PaymentError +from test_mpp_buyer import Exchange, server + +# JSON fixtures retain the public contract's wire field names. +CASES: list[dict[str, Any]] = json.loads( + Path(__file__).with_name("fixtures").joinpath("x402-buyer.json").read_text() +)["cases"] +REQUIREMENT = PaymentRequirements.model_validate(CASES[0]["input"]["requirement"]) +RESOURCE = ResourceInfo(url="https://seller.example/data", mime_type="application/json") +SUPPORTED = {"kinds": [{"scheme": "balance", "network": "inflow:1", "x402Version": 2}]} +CREATED = {"transactionId": "transaction", "approvalId": "approval", "approvalStatus": "PENDING"} + + +def ready() -> dict[str, object]: + payload = PaymentPayload(accepted=REQUIREMENT, payload={"transactionId": "transaction"}) + data = payload.model_dump(by_alias=True, exclude_none=True) + return { + "status": "PENDING", + "encodedPayload": base64.b64encode(json.dumps(data).encode()).decode(), + "paymentPayload": data, + } + + +def required(*requirements: PaymentRequirements) -> PaymentRequired: + return PaymentRequired(accepts=list(requirements or (REQUIREMENT,)), resource=RESOURCE) + + +class Platform(httpx.AsyncBaseTransport): + def __init__(self) -> None: + self.requests: list[httpx.Request] = [] + self.polls: list[object] = [ready()] + self.closed = False + self.created = deepcopy(CREATED) + self.supported: object = deepcopy(SUPPORTED) + self.balances: object = {"balances": []} + self.poll_started = asyncio.Event() + self.release = asyncio.Event() + self.block = False + + async def handle_async_request(self, request: httpx.Request) -> httpx.Response: + self.requests.append(request) + path = request.url.path + if path.endswith("x402-supported"): + result = self.supported + elif path == "/v1/transactions/x402": + result = self.created + elif path.endswith("/cancel"): + return httpx.Response(204) + elif path == "/v1/balances": + result = self.balances + else: + self.poll_started.set() + if self.block: + await self.release.wait() + result = self.polls.pop(0) + if isinstance(result, Exception): + raise result + if isinstance(result, httpx.Response): + return result + return httpx.Response(200, json=result) + + async def aclose(self) -> None: + self.closed = True + + +async def buyer(platform: Platform) -> Buyer: + return await Buyer.create( + ClientOptions(api_key="test-key", transport=platform), poll_interval=0, pending_timeout=1 + ) + + +@pytest.mark.parametrize("case", CASES, ids=[case["id"] for case in CASES]) +async def test_shared_buyer(case: dict[str, Any]) -> None: + data = case["input"] + before = deepcopy(case) + async with ( + server(cast(list[Exchange], case["platform"]["exchanges"])) as base, + await Buyer.create( + ClientOptions(api_key=data["api_key"], base_url=base), + poll_interval=data.get("poll_interval_ms", 1) / 1000, + pending_timeout=data.get("timeout_ms", 2000) / 1000, + ) as client, + ): + try: + payment = await client.prepare( + PaymentRequirements.model_validate(data["requirement"]), + ResourceInfo.model_validate(data["context"]["resource"]), + payment_id=data["payment_id"], + ) + if case["operation"] == "x402.buyer.cancel": + await payment.cancel() + if case["operation"] == "x402.buyer.concurrent-await": + first, second = await asyncio.gather( + payment.await_payload(), payment.await_payload() + ) + assert first == second + result = first + else: + result = await payment.await_payload() + observed: dict[str, object] = { + "result": { + "encodedPayload": result.encoded_payload, + "paymentPayload": result.payment_payload.model_dump( + by_alias=True, exclude_none=True + ), + "transactionId": result.transaction_id, + } + } + except X402PaymentError as error: + failure: dict[str, object] = {"code": error.code} + if error.status is not None: + failure["details"] = {"status": error.status} + observed = {"error": failure} + except InflowApiError as error: + observed = { + "error": { + "code": "api-error", + "http_status": error.http_status, + "details": {"body": error.body}, + } + } + except ValueError: + observed = {"error": {"code": "invalid-input"}} + expected = deepcopy(case["expect"]) + if "error" in expected: + expected["error"].pop("message") + assert observed == expected + assert case == before + + +async def test_control_scopes_and_hooks() -> None: + platform = Platform() + calls: list[str] = [] + async with await buyer(platform) as client: + client.set_spend_controls({"allowed_assets": [], "max_amount_per_payment": "$0"}) + + def policy(version: int, entries: list[Any]) -> list[Any]: + assert version == 2 + calls.append("policy") + return entries + + async def before(context: PaymentCreationContext) -> None: + calls.append("before") + assert isinstance(context.selected_requirements, PaymentRequirements) + context.selected_requirements.amount = "999" + + def after(context: PaymentCreatedContext) -> None: + calls.append("after") + context.payment_payload.payload.clear() + + client.register_policy(policy).on_before_payment_creation(before).on_after_payment_creation( + after + ) + payload = await client.create_payment_payload(required()) + assert payload.payload == {"transactionId": "transaction"} + assert calls == ["policy", "before", "after"] + creation = json.loads(platform.requests[1].content) + assert creation["accept"]["amount"] == REQUIREMENT.amount + assert platform.closed + + +@pytest.mark.parametrize("output", [[], [REQUIREMENT.model_copy(update={"network": "elsewhere"})]]) +async def test_policy_rejection_and_unsupported_policy_output(output: list[Any]) -> None: + platform = Platform() + async with await buyer(platform) as client: + client.register_policy(lambda _v, _r: output) + with pytest.raises(NoMatchingRequirementsError): + await client.create_payment_payload(required()) + assert len(platform.requests) == 1 + + +async def test_before_abort() -> None: + platform = Platform() + async with await buyer(platform) as client: + client.on_before_payment_creation(lambda _: AbortResult(reason="declined")) + with pytest.raises(PaymentAbortedError): + await client.prepare(REQUIREMENT, RESOURCE) + assert len(platform.requests) == 1 + + +async def test_shared_waits_and_independent_results() -> None: + platform = Platform() + platform.block = True + async with await buyer(platform) as client: + count = 0 + + def after(_: PaymentCreatedContext) -> None: + nonlocal count + count += 1 + + client.on_after_payment_creation(after) + prepared = await client.prepare(REQUIREMENT, RESOURCE) + first = asyncio.create_task(prepared.await_payload()) + await platform.poll_started.wait() + second = asyncio.create_task(prepared.await_payload(timeout=0)) + await asyncio.sleep(0) + first.cancel() + with pytest.raises(asyncio.CancelledError): + await first + platform.release.set() + result = await second + result.payment_payload.payload.clear() + assert (await prepared.await_payload()).payment_payload.payload + assert count == 1 + assert len(platform.requests) == 3 + + +async def test_cancel_wait_and_resume_without_server_cancel() -> None: + platform = Platform() + platform.block = True + async with await buyer(platform) as client: + prepared = await client.prepare(REQUIREMENT, RESOURCE) + task = asyncio.create_task(prepared.await_payload()) + await platform.poll_started.wait() + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + platform.release.set() + assert (await prepared.await_payload()).transaction_id == "transaction" + assert not any(r.url.path.endswith("/cancel") for r in platform.requests) + + +async def test_explicit_cancel_and_close() -> None: + platform = Platform() + platform.block = True + client = await buyer(platform) + prepared = await client.prepare(REQUIREMENT, RESOURCE) + task = asyncio.create_task(prepared.await_payload()) + await platform.poll_started.wait() + await asyncio.gather(prepared.cancel(), prepared.cancel()) + with pytest.raises(X402PaymentError, match="cancelled"): + await task + await client.aclose() + assert sum(r.url.path.endswith("/cancel") for r in platform.requests) == 1 + assert platform.closed + + +async def test_one_shot_failure_cleanup_and_recovery() -> None: + platform = Platform() + platform.polls = [{"status": "DECLINED"}] + async with await buyer(platform) as client: + + def recover(context: PaymentCreationFailureContext) -> RecoveredPayloadResult: + assert isinstance(context.error, X402PaymentError) + return RecoveredPayloadResult( + PaymentPayload(accepted=REQUIREMENT, payload={"recovered": True}) + ) + + client.on_payment_creation_failure(lambda _: None).on_payment_creation_failure(recover) + assert (await client.create_payment_payload(required())).payload == {"recovered": True} + assert platform.requests[-1].url.path.endswith("/cancel") + + +async def test_after_failure_not_repeated() -> None: + platform = Platform() + calls = 0 + async with await buyer(platform) as client: + + def after(_: PaymentCreatedContext) -> None: + nonlocal calls + calls += 1 + raise RuntimeError("hook failed") + + client.on_after_payment_creation(after) + prepared = await client.prepare(REQUIREMENT, RESOURCE) + for _ in range(2): + with pytest.raises(RuntimeError, match="hook failed"): + await prepared.await_payload() + assert calls == 1 + + +async def test_failure_hooks_preserve_exception_and_isolate_context() -> None: + class ApplicationError(Exception): + def __init__(self, code: int, detail: str) -> None: + self.code = code + super().__init__(detail) + + error = ApplicationError(7, "application failure") + platform = Platform() + platform.polls = [error] + seen: list[Exception] = [] + async with await buyer(platform) as client: + + def first(context: PaymentCreationFailureContext) -> None: + seen.append(context.error) + assert isinstance(context.selected_requirements, PaymentRequirements) + context.selected_requirements.amount = "999" + + def second(context: PaymentCreationFailureContext) -> None: + seen.append(context.error) + assert isinstance(context.selected_requirements, PaymentRequirements) + assert context.selected_requirements.amount == REQUIREMENT.amount + + client.on_payment_creation_failure(first).on_payment_creation_failure(second) + with pytest.raises(ApplicationError) as caught: + await client.create_payment_payload(required()) + assert caught.value is error + assert seen == [error, error] + + +@pytest.mark.parametrize("blocked", [False, True]) +async def test_external_wallet_spend_controls_with_real_signer(blocked: bool) -> None: + from eth_account import Account + from eth_account.messages import encode_typed_data + from x402.mechanisms.evm.exact import ExactEvmScheme + + account = Account.from_key("0x" + "11" * 32) + requirement = PaymentRequirements( + scheme="exact", + network="eip155:8453", + asset="0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + amount="100000", + pay_to="0x" + "22" * 20, + max_timeout_seconds=60, + extra={"name": "USD Coin", "version": "2"}, + ) + platform = Platform() + async with await buyer(platform) as client: + client.register(requirement.network, ExactEvmScheme(account)) + if blocked: + client.set_spend_controls({"max_amount_per_payment": "$0"}) + with pytest.raises(NoMatchingRequirementsError, match="spend_controls"): + await client.create_payment_payload(required(requirement)) + else: + payment = await client.create_payment_payload(required(requirement)) + authorization = payment.payload["authorization"] + message = { + "from": authorization["from"], + "to": authorization["to"], + "value": int(authorization["value"]), + "validAfter": int(authorization["validAfter"]), + "validBefore": int(authorization["validBefore"]), + "nonce": authorization["nonce"], + } + typed = encode_typed_data( + domain_data={ + "name": "USD Coin", + "version": "2", + "chainId": 8453, + "verifyingContract": requirement.asset, + }, + message_types={ + "TransferWithAuthorization": [ + {"name": "from", "type": "address"}, + {"name": "to", "type": "address"}, + {"name": "value", "type": "uint256"}, + {"name": "validAfter", "type": "uint256"}, + {"name": "validBefore", "type": "uint256"}, + {"name": "nonce", "type": "bytes32"}, + ] + }, + message_data=message, + ) + assert ( + Account.recover_message(typed, signature=payment.payload["signature"]) + == account.address + ) + assert message["value"] == 100000 + assert len(platform.requests) == 1 + + +@pytest.mark.parametrize("setting", [float("inf"), float("nan"), -1]) +async def test_invalid_wait_settings(setting: float) -> None: + with pytest.raises(ValueError): + await Buyer.create(ClientOptions(), poll_interval=setting) + + +async def test_setup_failure_closes_transport() -> None: + platform = Platform() + platform.supported = httpx.Response(403, json={"code": "denied"}) + with pytest.raises(InflowApiError): + await buyer(platform) + assert platform.closed + + +async def test_capability_refresh_and_copy_isolation() -> None: + platform = Platform() + client = await buyer(platform) + snapshot = await client.get_supported() + snapshot.kinds.clear() + assert client.supports(REQUIREMENT) + platform.supported = {"kinds": []} + await client.get_supported(refresh=True) + assert not client.supports(REQUIREMENT) + platform.supported = httpx.Response(503) + with pytest.raises(InflowApiError): + await client.get_supported(refresh=True) + assert (await client.get_supported()).kinds == [] + await client.aclose() + with pytest.raises(RuntimeError, match="closed"): + await client.get_supported() + + +@pytest.mark.parametrize( + ("balances", "expected"), + [ + ( + { + "balances": [ + {"currency": "USDC", "available": "0.5"}, + {"currency": "USDT", "available": "2.0"}, + ] + }, + "USDT", + ), + ( + { + "balances": [ + {"currency": "USDC", "available": "-1"}, + {"currency": "USDT", "available": "bad"}, + {}, + ] + }, + "USDC", + ), + ({"balances": [{"currency": "USDC", "available": "1"}]}, "USDC"), + ({"balances": []}, "USDC"), + ({}, "USDC"), + ([], "USDC"), + (httpx.Response(503), "USDC"), + ], +) +async def test_balance_selection(balances: object, expected: str) -> None: + platform = Platform() + platform.balances = balances + first = REQUIREMENT.model_copy( + update={"amount": "1000000000000000000", "extra": {"assetName": "USDC"}} + ) + second = first.model_copy(update={"extra": {"assetName": "USDT"}}) + async with await buyer(platform) as client: + selected = await client.select_inflow_requirement(required(first, second)) + assert selected is not None + assert selected.extra["assetName"] == expected + + +async def test_balance_selection_invalid_amount_and_preference() -> None: + platform = Platform() + platform.balances = {"balances": [{"currency": "USDC", "available": "2"}]} + first = REQUIREMENT.model_copy(update={"amount": "bad", "extra": {"assetName": "USDC"}}) + second = REQUIREMENT.model_copy(update={"extra": {"assetName": "USDC"}}) + async with await buyer(platform) as client: + assert await client.select_inflow_requirement(required(first, second)) == second + client._prefer = ("unsupported", "balance") + assert await client.select_inflow_requirement(required()) == REQUIREMENT + + +async def test_preparation_body_and_status() -> None: + platform = Platform() + platform.polls = [{"status": "APPROVED"}, ready()] + async with await buyer(platform) as client: + payment = await client.prepare( + REQUIREMENT, + RESOURCE, + transaction_request_extensions={ + "serviceId": "service", + "accept": "bad", + "x402Version": 1, + }, + ) + body = json.loads(platform.requests[1].content) + assert body["serviceId"] == "service" + assert body["accept"]["scheme"] == "balance" + assert body["x402Version"] == 2 + assert await payment.status() == "APPROVED" + assert await client.get_x402_payload("transaction") == ready() + + +async def test_unsupported_version_and_overrides() -> None: + platform = Platform() + async with await buyer(platform) as client: + incompatible = required().model_copy(update={"x402_version": 3}) + with pytest.raises(ValueError): + await client.create_payment_payload(incompatible) + with pytest.raises(ValueError): + await client.select_inflow_requirement(incompatible) + alternate = ResourceInfo(url="https://merchant.example/other") + await client.create_payment_payload(required(), resource=alternate, extensions={}) + assert json.loads(platform.requests[1].content)["resource"]["url"] == alternate.url + + +async def test_extension_hooks_only_when_advertised() -> None: + from types import SimpleNamespace + + from x402.schemas.extensions import ClientExtension + + platform = Platform() + calls: list[str] = [] + + async def hook(declaration: object, context: PaymentCreationContext) -> None: + calls.append("extension") + assert declaration == {"info": {"value": 1}} + + # Upstream accepts duck-typed extensions with optional hook members. + extension = cast( + ClientExtension, + SimpleNamespace(key="example", hooks=SimpleNamespace(on_before_payment_creation=hook)), + ) + async with await buyer(platform) as client: + client.register_extension(extension) + await client.create_payment_payload( + required(), extensions={"example": {"info": {"value": 1}}} + ) + assert calls == ["extension"] + + +async def test_one_shot_cancelled_wait_cancels_approval() -> None: + platform = Platform() + platform.block = True + async with await buyer(platform) as client: + task = asyncio.create_task(client.create_payment_payload(required())) + await platform.poll_started.wait() + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + assert platform.requests[-1].url.path.endswith("/cancel") + + +async def test_after_hook_failure_does_not_cancel_signed_payment() -> None: + platform = Platform() + async with await buyer(platform) as client: + + def after(_: PaymentCreatedContext) -> None: + raise RuntimeError("application hook") + + client.on_after_payment_creation(after) + with pytest.raises(RuntimeError, match="application hook"): + await client.create_payment_payload(required()) + assert not any(r.url.path.endswith("/cancel") for r in platform.requests) + + +async def test_close_stops_pending_wait_without_cancelling_server_approval() -> None: + platform = Platform() + platform.block = True + client = await buyer(platform) + payment = await client.prepare(REQUIREMENT, RESOURCE) + waiting = asyncio.create_task(payment.await_payload()) + await platform.poll_started.wait() + await client.aclose() + with pytest.raises(X402PaymentError, match="cancelled"): + await waiting + assert not any(r.url.path.endswith("/cancel") for r in platform.requests) + + +@pytest.mark.parametrize( + "response", + [ + [], + {"status": "APPROVED", "paymentPayload": {}, "encodedPayload": ""}, + { + "status": "APPROVED", + "paymentPayload": { + "x402Version": 3, + "accepted": REQUIREMENT.model_dump(by_alias=True), + "payload": {}, + }, + "encodedPayload": "eA==", + }, + ], +) +async def test_invalid_payload(response: object) -> None: + platform = Platform() + platform.polls = [response] + async with await buyer(platform) as client: + with pytest.raises(X402PaymentError): + await client.create_payment_payload(required()) + + +async def test_bounded_wait_and_native_provider_timeout() -> None: + platform = Platform() + calls = 0 + + async def token() -> str: + nonlocal calls + calls += 1 + if calls > 2: + raise TimeoutError("provider timeout") + return "test-token" + + async with await Buyer.create(ClientOptions(access_token=token, transport=platform)) as client: + payment = await client.prepare(REQUIREMENT, RESOURCE) + with pytest.raises(X402PaymentError, match="timed out"): + await payment.await_payload(timeout=0) + with pytest.raises(TimeoutError, match="provider timeout"): + await payment.await_payload() + + +@pytest.mark.parametrize("status", [200, 402, 307]) +async def test_upstream_http_payment_replay(status: int) -> None: + from x402.http.clients.httpx import x402AsyncTransport + + platform = Platform() + requests: list[httpx.Request] = [] + + async def merchant(request: httpx.Request) -> httpx.Response: + requests.append(request) + assert "x-api-key" not in request.headers + assert request.headers["authorization"] == "Bearer application-token" + assert request.content == b'{"query":"example"}' + if len(requests) == 1: + return httpx.Response( + 402, + headers={ + "PAYMENT-REQUIRED": base64.b64encode( + required().model_dump_json(by_alias=True).encode() + ).decode() + }, + ) + payment = json.loads(base64.b64decode(request.headers["payment-signature"])) + assert payment["payload"]["transactionId"] == "transaction" + return httpx.Response( + status, headers={"Location": "https://other.example/"}, content=b"result" + ) + + async def body() -> Any: + yield b'{"query":' + yield b'"example"}' + + async with ( + await buyer(platform) as client, + httpx.AsyncClient( + transport=x402AsyncTransport(client, httpx.MockTransport(merchant)), + follow_redirects=False, + ) as http, + ): + response = await http.post( + RESOURCE.url, content=body(), headers={"Authorization": "Bearer application-token"} + ) + assert response.status_code == status + assert len(requests) == 2 + assert sum(r.url.path == "/v1/transactions/x402" for r in platform.requests) == 1 + + +async def test_upstream_mcp_payment_flow() -> None: + from datetime import timedelta + + from mcp.types import CallToolResult + from x402.mcp.client import x402MCPSession + from x402.mcp.constants import MCP_PAYMENT_META_KEY + + calls: list[dict[str, Any] | None] = [] + + class Session: + async def call_tool( + self, + name: str, + arguments: dict[str, Any], + read_timeout_seconds: timedelta, + meta: dict[str, Any] | None = None, + ) -> CallToolResult: + assert name == "search" and arguments == {"query": "example"} + calls.append(meta) + if meta is None: + return CallToolResult( + content=[], + isError=True, + structuredContent=required().model_dump(by_alias=True, exclude_none=True), + ) + assert meta[MCP_PAYMENT_META_KEY]["payload"] == {"transactionId": "transaction"} + return CallToolResult(content=[], isError=False) + + async with await buyer(Platform()) as client: + session = x402MCPSession(Session(), client) + result = await session.call_tool("search", {"query": "example"}) + assert result.payment_made and not result.is_error + assert len(calls) == 2 + + +async def test_approved_creation_polls_again_without_interval_delay() -> None: + platform = Platform() + platform.created["approvalStatus"] = "APPROVED" + platform.polls = [{"status": "PENDING"}, ready()] + async with await buyer(platform) as client: + payment = await client.prepare(REQUIREMENT, RESOURCE) + assert ( + await payment.await_payload(poll_interval=60, timeout=1) + ).transaction_id == "transaction" + + +async def test_creation_failure_reaches_failure_hook_without_cancellation() -> None: + class FailingPlatform(Platform): + async def handle_async_request(self, request: httpx.Request) -> httpx.Response: + if request.url.path == "/v1/transactions/x402": + self.requests.append(request) + return httpx.Response(503) + return await super().handle_async_request(request) + + platform = FailingPlatform() + async with await buyer(platform) as client: + with pytest.raises(InflowApiError): + await client.create_payment_payload(required()) + assert len(platform.requests) == 2 + + +async def test_public_cancel_approval() -> None: + platform = Platform() + async with await buyer(platform) as client: + await client.cancel_approval("approval") + assert platform.requests[-1].url.path.endswith("/approval/cancel") + + +async def test_refresh_requests_share_successful_fetch() -> None: + class BlockingPlatform(Platform): + async def handle_async_request(self, request: httpx.Request) -> httpx.Response: + if request.url.path.endswith("x402-supported") and self.requests: + self.poll_started.set() + await self.release.wait() + return await super().handle_async_request(request) + + platform = BlockingPlatform() + async with await buyer(platform) as client: + first = asyncio.create_task(client.get_supported(refresh=True)) + await platform.poll_started.wait() + second = asyncio.create_task(client.get_supported(refresh=True)) + await asyncio.sleep(0) + platform.release.set() + assert await first == await second + assert len(platform.requests) == 2 + + +async def test_late_transport_success_is_not_a_successful_wait() -> None: + class SlowPlatform(Platform): + async def handle_async_request(self, request: httpx.Request) -> httpx.Response: + if request.url.path.endswith("/x402") and request.method == "GET": + with suppress(asyncio.CancelledError): + await asyncio.sleep(5) + return await super().handle_async_request(request) + + async with await buyer(SlowPlatform()) as client: + payment = await client.prepare(REQUIREMENT, RESOURCE) + with pytest.raises(X402PaymentError, match="timed out"): + await payment.await_payload(timeout=0.01) + + +@pytest.mark.parametrize("outcome", ["failure", "cancel-waiter", "close"]) +async def test_concurrent_refresh_lifecycle(outcome: str) -> None: + class BlockingPlatform(Platform): + async def handle_async_request(self, request: httpx.Request) -> httpx.Response: + if request.url.path.endswith("x402-supported") and self.requests: + self.poll_started.set() + await self.release.wait() + return await super().handle_async_request(request) + + platform = BlockingPlatform() + async with await buyer(platform) as client: + first = asyncio.create_task(client.get_supported(refresh=True)) + await platform.poll_started.wait() + second = asyncio.create_task(client.get_supported(refresh=True)) + await asyncio.sleep(0) + if outcome == "failure": + platform.supported = httpx.Response(503) + platform.release.set() + results = await asyncio.gather(first, second, return_exceptions=True) + assert isinstance(results[0], InflowApiError) + assert results[0] is results[1] + assert len(platform.requests) == 2 + assert client.supports(REQUIREMENT) + platform.supported = SUPPORTED + await client.get_supported(refresh=True) + assert len(platform.requests) == 3 + elif outcome == "cancel-waiter": + first.cancel() + with pytest.raises(asyncio.CancelledError): + await first + platform.release.set() + assert (await second).kinds + assert len(platform.requests) == 2 + else: + await client.aclose() + for task in (first, second): + with pytest.raises(asyncio.CancelledError): + await task + + +async def test_expired_capabilities_refresh() -> None: + platform = Platform() + async with await buyer(platform) as client: + client._expires = 0 + platform.supported = {"kinds": []} + assert (await client.get_supported()).kinds == [] + assert len(platform.requests) == 2 + + +async def test_cancel_during_hook_even_if_hook_swallows_cancellation() -> None: + started = asyncio.Event() + platform = Platform() + async with await buyer(platform) as client: + + async def after(_: PaymentCreatedContext) -> None: + started.set() + with suppress(asyncio.CancelledError): + await asyncio.Event().wait() + await asyncio.sleep(0) + + client.on_after_payment_creation(after) + payment = await client.prepare(REQUIREMENT, RESOURCE) + task = asyncio.create_task(payment.await_payload()) + await started.wait() + await payment.cancel() + with pytest.raises(X402PaymentError, match="cancelled"): + await task + assert payment._completion is not None and not payment._completion.cancelled() + + +@pytest.mark.parametrize("outcome", ["missing-identifier", "identifier", "version"]) +async def test_external_extension_output(outcome: str) -> None: + from eth_account import Account + from x402.mechanisms.evm.exact import ExactEvmScheme + + from inflowpay.x402 import declare_payment_identifier, generate_payment_id + + class Extension: + key = "payment-identifier" + hooks = None + transport_hooks = None + + def enrich_payment_payload(self, payment_payload: Any, payment_required: Any) -> Any: + if outcome == "version": + return payment_payload.model_copy(update={"x402_version": 3}) + if outcome == "identifier": + payment_payload.extensions[self.key]["info"]["id"] = generate_payment_id() + return payment_payload + + declaration = declare_payment_identifier() + declaration["info"]["required"] = True + offer = REQUIREMENT.model_copy( + update={ + "scheme": "exact", + "network": "eip155:8453", + "asset": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + "amount": "1", + "pay_to": "0x" + "22" * 20, + "extra": {"name": "USD Coin", "version": "2"}, + } + ) + async with await buyer(Platform()) as client: + client.register(offer.network, ExactEvmScheme(Account.from_key("0x" + "11" * 32))) + client.register_extension(Extension()) + if outcome == "identifier": + payment = await client.create_payment_payload( + required(offer), extensions={"payment-identifier": declaration} + ) + assert isinstance(payment, PaymentPayload) and payment.extensions + else: + with pytest.raises(X402PaymentError): + await client.create_payment_payload( + required(offer), extensions={"payment-identifier": declaration} + ) + + +@pytest.mark.parametrize("scheme", ["exact", "upto"]) +async def test_real_external_permit2_and_eip2612(scheme: str) -> None: + from eth_account import Account + from eth_account.messages import encode_typed_data + from x402.mechanisms.evm.exact import ExactEvmScheme + from x402.mechanisms.evm.signers import EthAccountSigner + from x402.mechanisms.evm.upto import UptoEvmScheme + + account = Account.from_key("0x" + "11" * 32) + reads: list[str] = [] + + class Signer(EthAccountSigner): + def read_contract(self, address: str, abi: Any, function_name: str, *args: Any) -> Any: + reads.append(function_name) + return 7 if function_name == "nonces" else 0 + + offer = REQUIREMENT.model_copy( + update={ + "scheme": scheme, + "network": "eip155:8453", + "asset": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + "amount": "123", + "pay_to": "0x" + "22" * 20, + "extra": { + "assetTransferMethod": "permit2", + "name": "USD Coin", + "version": "2", + "facilitatorAddress": "0x" + "33" * 20, + }, + } + ) + declaration = {"eip2612GasSponsoring": {"info": {"version": "1"}, "schema": {"type": "object"}}} + platform = Platform() + async with await buyer(platform) as client: + signer = Signer(account) + client.register( + offer.network, ExactEvmScheme(signer) if scheme == "exact" else UptoEvmScheme(signer) + ) + payload = await client.create_payment_payload(required(offer), extensions=declaration) + assert isinstance(payload, PaymentPayload) and payload.extensions + assert payload.accepted.scheme == scheme + permit = payload.extensions["eip2612GasSponsoring"]["info"] + typed = encode_typed_data( + domain_data={ + "name": "USD Coin", + "version": "2", + "chainId": 8453, + "verifyingContract": offer.asset, + }, + message_types={ + "Permit": [ + {"name": "owner", "type": "address"}, + {"name": "spender", "type": "address"}, + {"name": "value", "type": "uint256"}, + {"name": "nonce", "type": "uint256"}, + {"name": "deadline", "type": "uint256"}, + ] + }, + message_data={ + "owner": account.address, + "spender": "0x000000000022D473030F116dDEE9F6B43aC78BA3", + "value": 123, + "nonce": 7, + "deadline": int(permit["deadline"]), + }, + ) + assert Account.recover_message(typed, signature=permit["signature"]) == account.address + assert payload.extensions["eip2612GasSponsoring"]["schema"] == {"type": "object"} + if scheme == "upto": + assert ( + payload.payload["permit2Authorization"]["witness"]["facilitator"] + == "0x" + "33" * 20 + ) + assert reads == ["allowance", "nonces"] + assert len(platform.requests) == 1 diff --git a/tests/test_x402_sponsorship.py b/tests/test_x402_sponsorship.py new file mode 100644 index 0000000..66fb897 --- /dev/null +++ b/tests/test_x402_sponsorship.py @@ -0,0 +1,271 @@ +import json +from copy import deepcopy +from pathlib import Path +from typing import Any + +import httpx +import pytest +from eth_account import Account +from eth_account.messages import encode_defunct +from x402.schemas import PaymentPayload, PaymentRequired + +from inflowpay import ClientOptions +from inflowpay.x402.eip7702 import Authorization, SponsorshipExtension + +# Generated with Node's viem encoder and EntryPoint 0.7 hash implementation. +VECTOR: dict[str, Any] = json.loads( + Path(__file__).with_name("fixtures").joinpath("eip7702.json").read_text() +) + + +class Signer: + def __init__(self) -> None: + self.account = Account.from_key("0x" + "01" * 32) + self.address = self.account.address + self.available = 0 + self.calls: list[str] = [] + self.wrong_message = False + self.wrong_authorization = False + + async def allowance(self, token: str, owner: str, spender: str) -> int: + self.calls.append("allowance") + assert token == VECTOR["payment"]["accepted"]["asset"].lower() + assert owner == self.address.lower() + assert spender == "0x000000000022d473030f116ddee9f6b43ac78ba3" + return self.available + + async def sign_message(self, operation_hash: bytes) -> bytes: + self.calls.append("message") + account = Account.from_key("0x" + "02" * 32) if self.wrong_message else self.account + return bytes(account.sign_message(encode_defunct(operation_hash)).signature) + + async def sign_authorization(self, authorization: Authorization) -> bytes: + self.calls.append("authorization") + account = Account.from_key("0x" + "02" * 32) if self.wrong_authorization else self.account + # eth-account's LocalAccount annotation says SignedMessage, but Account's + # authorization method returns the actual EIP-7702 SignedSetCodeAuthorization. + signed = Account.sign_authorization( + { + "address": authorization.address, + "chainId": authorization.chain_id, + "nonce": authorization.nonce, + }, + account.key, + ) + return bytes(signed.r.to_bytes(32) + signed.s.to_bytes(32) + bytes([signed.y_parity + 27])) + + +class Fixture: + def __init__(self) -> None: + self.payment = PaymentPayload.model_validate(deepcopy(VECTOR["payment"])) + self.required = PaymentRequired.model_validate(deepcopy(VECTOR["required"])) + self.prepared = deepcopy(VECTOR["prepared"]) + self.signer = Signer() + self.consented = True + self.requests: list[httpx.Request] = [] + self.extension = SponsorshipExtension( + ClientOptions(environment="sandbox", transport=httpx.MockTransport(self.respond)), + self.signer, + self.consent, + ) + + async def consent(self, authorization: Authorization) -> bool: + self.signer.calls.append("consent") + assert authorization.chain_id == 8453 + return self.consented + + def respond(self, request: httpx.Request) -> httpx.Response: + self.requests.append(request) + assert str(request.url) == "https://sandbox.inflowpay.ai/v1/x402/eip7702/prepare" + assert request.method == "POST" + assert "authorization" not in request.headers + assert "x-api-key" not in request.headers + body = json.loads(request.content) + assert body["paymentPayload"] == self.payment.model_dump(by_alias=True, exclude_none=True) + assert body["paymentRequirements"] == self.payment.accepted.model_dump( + by_alias=True, exclude_none=True + ) + return httpx.Response(200, json=self.prepared) + + +@pytest.fixture(autouse=True) +def clock(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr("inflowpay.x402.eip7702.time.time", lambda: 2000000000) + + +@pytest.mark.parametrize("already_delegated", [False, True]) +async def test_node_vector_and_real_signatures(already_delegated: bool) -> None: + fixture = Fixture() + if already_delegated: + del fixture.prepared["authorization"] + before = fixture.payment.model_copy(deep=True) + async with fixture.extension: + result = await fixture.extension.enrich_payment_payload(fixture.payment, fixture.required) + assert fixture.payment == before + assert result.extensions is not None + info = result.extensions[fixture.extension.key]["info"] + assert info["signature"] == VECTOR["operationSignature"] + assert info["sponsorshipId"] == VECTOR["prepared"]["sponsorshipId"] + if already_delegated: + assert "authorizationSignature" not in info + assert fixture.signer.calls == ["allowance", "message"] + else: + # Same delegation signed independently by Node's viem account. + assert ( + info["authorizationSignature"] + == "0x4dfa6bbf52578eae02c4879a2c8a2fd9cad9eed0a685f34d7a1cd6562e25ba833" + "0e75be28dc48ef292a00dc9bc7262c5c27276b3d805aa70e53d20741fac51001c" + ) + assert fixture.signer.calls == ["allowance", "consent", "authorization", "message"] + + +async def test_upstream_signer_and_registered_sponsorship(monkeypatch: pytest.MonkeyPatch) -> None: + from x402.mechanisms.evm.exact import ExactEvmScheme + + from inflowpay.x402.buyer import Buyer + + fixture = Fixture() + fixture.payment.resource = fixture.required.resource + monkeypatch.setattr("x402.mechanisms.evm.exact.permit2_utils.create_permit2_nonce", lambda: "7") + platform = httpx.MockTransport(lambda request: httpx.Response(200, json={"kinds": []})) + async with ( + fixture.extension, + await Buyer.create(ClientOptions(transport=platform)) as buyer, + ): + buyer.register("eip155:8453", ExactEvmScheme(fixture.signer.account)) + buyer.register_extension(fixture.extension) + result = await buyer.create_payment_payload(fixture.required) + assert isinstance(result, PaymentPayload) + assert result.payload == fixture.payment.payload + assert result.extensions is not None + assert ( + result.extensions[fixture.extension.key]["info"]["signature"] + == VECTOR["operationSignature"] + ) + assert len(fixture.requests) == 1 + + +@pytest.mark.parametrize( + "reason", ["absent", "other-scheme", "not-permit2", "sufficient-allowance"] +) +async def test_no_sponsorship_when_unnecessary(reason: str) -> None: + fixture = Fixture() + if reason == "absent": + fixture.required.extensions = None + elif reason == "other-scheme": + fixture.payment.accepted.scheme = "upto" + elif reason == "not-permit2": + fixture.payment.accepted.extra = {} + else: + fixture.signer.available = 123 + async with fixture.extension: + assert ( + await fixture.extension.enrich_payment_payload(fixture.payment, fixture.required) + is fixture.payment + ) + assert not fixture.requests + + +@pytest.mark.parametrize( + ("path", "value"), + [ + (("sponsorshipId",), "invalid"), + (("chainId",), 1), + (("chainId",), True), + (("chainId",), -1), + (("chainId",), 9007199254740992), + (("entryPoint",), "0x" + "00" * 20), + (("entryPointVersion",), "0.8"), + (("delegation",), "0x" + "00" * 20), + (("userOperationHash",), "0x" + "00" * 32), + (("userOperationHash",), "0x12"), + (("userOperationHash",), "not-hex"), + (("expiresAt",), 2000000000), + (("expiresAt",), 2000000301), + (("authorization", "nonce"), -1), + (("authorization", "chainId"), 1), + (("authorization", "address"), "0x" + "00" * 20), + (("authorization", "extra"), 1), + (("userOperation", "extra"), "0x0"), + (("userOperation", "sender"), "0x" + "00" * 20), + (("userOperation", "callData"), "0x"), + (("userOperation", "nonce"), "0x0"), + (("userOperation", "nonce"), "0x01"), + (("userOperation", "callGasLimit"), hex(1 << 128)), + (("userOperation", "callGasLimit"), "0x0"), + (("userOperation", "verificationGasLimit"), "0x0"), + (("userOperation", "paymaster"), "0x" + "11" * 20), + (("userOperation", "paymasterData"), "0x00"), + (("userOperation", "maxFeePerGas"), "0x1"), + (("userOperation", "preVerificationGas"), "0x1"), + ], +) +async def test_untrusted_preparation_rejected_before_signing( + path: tuple[str, ...], value: object +) -> None: + fixture = Fixture() + target = fixture.prepared + for key in path[:-1]: + target = target[key] + target[path[-1]] = value + async with fixture.extension: + with pytest.raises(ValueError): + await fixture.extension.enrich_payment_payload(fixture.payment, fixture.required) + assert fixture.signer.calls == ["allowance"] + + +@pytest.mark.parametrize( + ("path", "value"), + [ + (("accepted", "network"), "inflow:1"), + (("accepted", "amount"), "124"), + (("accepted", "asset"), "invalid"), + (("payload", "permit2Authorization", "permitted", "amount"), "0"), + (("payload", "permit2Authorization", "from"), "0x" + "00" * 20), + (("payload", "permit2Authorization", "deadline"), "2000000000"), + (("payload", "permit2Authorization", "nonce"), "-1"), + (("payload", "signature"), "0x11"), + ], +) +async def test_bad_payment_never_requests_sponsorship(path: tuple[str, ...], value: object) -> None: + fixture = Fixture() + data = deepcopy(VECTOR["payment"]) + target = data + for key in path[:-1]: + target = target[key] + target[path[-1]] = value + fixture.payment = PaymentPayload.model_validate(data) + async with fixture.extension: + with pytest.raises(ValueError): + await fixture.extension.enrich_payment_payload(fixture.payment, fixture.required) + assert not fixture.requests + + +@pytest.mark.parametrize( + "reason", ["consent", "allowance", "message", "authorization", "declaration"] +) +async def test_signer_and_consent_failures(reason: str) -> None: + fixture = Fixture() + if reason == "consent": + fixture.consented = False + elif reason == "allowance": + fixture.signer.available = -1 + elif reason == "message": + fixture.signer.wrong_message = True + elif reason == "authorization": + fixture.signer.wrong_authorization = True + else: + fixture.required.extensions = {fixture.extension.key: {"info": {"version": "2"}}} + async with fixture.extension: + with pytest.raises(ValueError): + await fixture.extension.enrich_payment_payload(fixture.payment, fixture.required) + + +def test_sponsorship_rejects_platform_credentials() -> None: + signer = Signer() + + async def consent(_: Authorization) -> bool: + return True + + with pytest.raises(ValueError, match="anonymous"): + SponsorshipExtension(ClientOptions(api_key="test-key"), signer, consent)