diff --git a/sdk/python/src/tinyplace/__init__.py b/sdk/python/src/tinyplace/__init__.py index a3c0933c..1014d200 100644 --- a/sdk/python/src/tinyplace/__init__.py +++ b/sdk/python/src/tinyplace/__init__.py @@ -22,10 +22,17 @@ from .safe import as_bool, as_dict, as_int, as_list, as_str, field, list_field from .signer import LocalSigner, Signer from .solana import ( + FACILITATOR_COMPUTE_UNIT_LIMIT, + FACILITATOR_COMPUTE_UNIT_PRICE_MICRO_LAMPORTS, + SOLANA_COMPUTE_BUDGET_PROGRAM_ID, SOLANA_MAINNET_NETWORK, SOLANA_NATIVE_ASSET, SOLANA_USDC_MINT, SOLANA_WSOL_MINT, + build_delegated_x402_envelope, + build_delegated_x402_payment_header, + build_payer_signed_delegated_tx, + encode_delegated_x402_header, execute_solana_payment, execute_solana_x402_payment, is_likely_mint_address, @@ -35,8 +42,11 @@ from .x402 import ( build_canonical_message, build_x402_payment_authorization, + build_x402_payment_envelope, build_x402_payment_map, build_x402_payment_payload, + encode_x402_payment_header, + X402_PAYMENT_HEADER, generate_nonce, sign_x402_authorization, x402_authorization_to_payment_map, @@ -51,6 +61,9 @@ "PaymentChallenge", "PaymentRequiredChallenge", "RetryOptions", + "FACILITATOR_COMPUTE_UNIT_LIMIT", + "FACILITATOR_COMPUTE_UNIT_PRICE_MICRO_LAMPORTS", + "SOLANA_COMPUTE_BUDGET_PROGRAM_ID", "SOLANA_MAINNET_NETWORK", "SOLANA_NATIVE_ASSET", "SOLANA_USDC_MINT", @@ -68,9 +81,16 @@ "list_field", "build_canonical_message", "build_auth_header", + "build_delegated_x402_envelope", + "build_delegated_x402_payment_header", + "build_payer_signed_delegated_tx", + "encode_delegated_x402_header", "build_x402_payment_authorization", + "build_x402_payment_envelope", "build_x402_payment_map", "build_x402_payment_payload", + "encode_x402_payment_header", + "X402_PAYMENT_HEADER", "canonical_payload", "derive_crypto_id", "execute_solana_payment", diff --git a/sdk/python/src/tinyplace/api/bounties.py b/sdk/python/src/tinyplace/api/bounties.py index 61532b37..4eb0092e 100644 --- a/sdk/python/src/tinyplace/api/bounties.py +++ b/sdk/python/src/tinyplace/api/bounties.py @@ -5,8 +5,13 @@ from ..http import HttpClient, TinyPlaceError, encode from ..signer import Signer -from ..solana import SOLANA_MAINNET_NETWORK, SOLANA_USDC_MINT, execute_solana_x402_payment +from ..solana import ( + SOLANA_MAINNET_NETWORK, + SOLANA_USDC_MINT, + build_delegated_x402_payment_header, +) from ..types import Json, JsonDict, Query +from ..x402 import X402_PAYMENT_HEADER DEFAULT_FUND_ATTEMPTS = 30 DEFAULT_FUND_INTERVAL_MS = 3000 @@ -42,9 +47,10 @@ async def list(self, params: Query = None) -> JsonDict: async def get(self, bounty_id: str) -> Json: return await self._http.get(f"/bounties/{encode(bounty_id)}") - async def create(self, request: JsonDict) -> Json: + async def create(self, request: JsonDict, *, payment_header: str | None = None) -> Json: + headers = {X402_PAYMENT_HEADER: payment_header} if payment_header else None return await self._http.post_directory_auth_as( - "/bounties", str(request.get("creator") or ""), request + "/bounties", str(request.get("creator") or ""), request, headers=headers ) async def create_with_solana_payment( @@ -61,10 +67,12 @@ async def create_with_solana_payment( ) -> dict[str, Any]: """Create AND fund a bounty in one x402 flow, settling the reward on chain. - ``POST /bounties`` is a combined create+fund: probe it for the 402 - challenge, pay the reward into the escrow wallet on chain, then re-create - with the signed payment attached — the bounty is returned already open - for submissions. Mirrors ``registry.register_with_solana_payment``. + ``POST /bounties`` is a combined create+fund: probe it for the canonical + x402 v2 402 challenge (``accepts[0]``), build the sponsored SPL transfer + whose fee payer is the facilitator, wrap it in the standard envelope, and + re-create with the ``PAYMENT-SIGNATURE`` header attached — no body + ``payment``. The bounty is returned already open for submissions. Mirrors + ``registry.register_with_solana_payment``. """ if self._signer is None: raise ValueError("create_with_solana_payment requires a signer") @@ -76,31 +84,35 @@ async def create_with_solana_payment( recipient = challenge.get("to") if not amount or not recipient: raise ValueError("bounty create challenge is missing amount or recipient") - execution = await execute_solana_x402_payment( - signer=self._signer, + challenge_metadata = challenge.get("metadata") or {} + challenge_network = challenge.get("network") or network or SOLANA_MAINNET_NETWORK + challenge_asset = challenge.get("asset") or "USDC" + fee_payer = challenge_metadata.get("feePayer") + if not fee_payer: + raise ValueError( + "bounty create challenge is missing the facilitator fee payer " + "(accepts[].extra.feePayer)" + ) + # SPL rewards (USDC/CASH) settle gaslessly through the facilitator: build a + # payer-signed delegated SPL transfer whose fee payer is the facilitator + # (from the 402 challenge), wrap it in the standard x402 v2 envelope, and + # submit it via the PAYMENT-SIGNATURE header — no body ``payment`` map and + # no proprietary ``metadata.delegatedTx``. + payment_header = await build_delegated_x402_payment_header( rpc_url=rpc_url, - secret_key=secret_key, + fee_payer=str(fee_payer), mint=mint or SOLANA_USDC_MINT, decimals=decimals, + secret_key=secret_key, payment={ - "scheme": challenge.get("scheme", "exact"), - "network": challenge.get("network") or network or SOLANA_MAINNET_NETWORK, - "asset": challenge.get("asset") or "USDC", + "network": challenge_network, + "asset": challenge_asset, "amount": amount, - "from": creator, "to": recipient, - "nonce": challenge.get("nonce"), - "expiresAt": challenge.get("expiresAt"), - "metadata": { - **(challenge.get("metadata") or {}), - "kind": "bounty-fund", - }, }, ) - bounty = await self._create_retrying( - {**request, "payment": execution["payment"]}, attempts, interval_ms - ) - return {"bounty": bounty, "payment": execution} + bounty = await self._create_retrying(request, payment_header, attempts, interval_ms) + return {"bounty": bounty, "paymentHeader": payment_header} async def _create_challenge(self, request: JsonDict) -> dict[str, Any]: """Probe ``POST /bounties`` once (no payment) to surface the 402 challenge. @@ -116,15 +128,17 @@ async def _create_challenge(self, request: JsonDict) -> dict[str, Any]: raise raise ValueError("bounty create did not return a payment challenge") - async def _create_retrying(self, request: JsonDict, attempts: int, interval_ms: int) -> Json: - # The payment is already on chain; retry create (same signed payment map) - # only through confirmation lag. Unlike fund, do NOT recover-on-5xx: each - # create mints a fresh bountyId, so blind retries could double-create — - # the per-payer nonce replay protection guards the on-chain transfer. + async def _create_retrying( + self, request: JsonDict, payment_header: str, attempts: int, interval_ms: int + ) -> Json: + # The payment is already on chain; retry create (same signed payment + # header) only through confirmation lag. Unlike fund, do NOT recover-on-5xx: + # each create mints a fresh bountyId, so blind retries could double-create — + # the on-chain transfer's signature guards against double-settlement. attempts = max(1, attempts) for attempt in range(attempts): try: - return await self.create(request) + return await self.create(request, payment_header=payment_header) except TinyPlaceError as exc: if attempt == attempts - 1 or not _should_retry_fund(exc): raise diff --git a/sdk/python/src/tinyplace/api/registry.py b/sdk/python/src/tinyplace/api/registry.py index 910c4f4a..022adf10 100644 --- a/sdk/python/src/tinyplace/api/registry.py +++ b/sdk/python/src/tinyplace/api/registry.py @@ -11,9 +11,10 @@ SOLANA_MAINNET_NETWORK, SOLANA_USDC_MINT, USDC_DECIMALS, - execute_solana_x402_payment, + build_delegated_x402_payment_header, ) from ..types import Json, JsonDict +from ..x402 import X402_PAYMENT_HEADER DEFAULT_REGISTRATION_ATTEMPTS = 30 DEFAULT_REGISTRATION_INTERVAL_MS = 3000 @@ -25,14 +26,15 @@ def __init__(self, http: HttpClient, signer: Signer | None = None) -> None: self._http = http self._signer = signer - async def register(self, request: JsonDict) -> Json: + async def register(self, request: JsonDict, *, payment_header: str | None = None) -> Json: request = _normalize_register_request(request) if self._signer and not request.get("signature"): request["signature"] = await sign_fresh_canonical_payload( self._signer, _registration_signature_payload(request), ) - return await self._http.post_public("/registry/names", request) + headers = {X402_PAYMENT_HEADER: payment_header} if payment_header else None + return await self._http.post_public("/registry/names", request, headers=headers) async def register_with_solana_payment( self, @@ -46,13 +48,16 @@ async def register_with_solana_payment( attempts: int = DEFAULT_REGISTRATION_ATTEMPTS, interval_ms: int = DEFAULT_REGISTRATION_INTERVAL_MS, ) -> JsonDict: - """Register ``request``, settling the x402 fee with an on-chain Solana payment. + """Register ``request``, settling the x402 fee with a sponsored Solana payment. - Probes the registration to read the 402 payment challenge, executes the - SPL/USDC (or native-SOL) transfer on chain, then retries the - registration with the signed x402 payment map attached — polling through - the brief window where the chain hasn't yet confirmed the transfer. - Mirrors the TS SDK's ``registerWithSolanaPayment``. + Probes the registration to read the canonical x402 v2 402 challenge + (``accepts[0]``: ``network``/``amount``/``asset``-mint/``payTo``/ + ``extra.feePayer``), builds the partially-signed SPL ``TransferChecked`` + whose fee payer is the facilitator (so the payer needs no SOL for gas), + wraps it in the standard ``PaymentPayload`` envelope, and submits it via + the ``PAYMENT-SIGNATURE`` header — with **no** ``payment`` field in the + request body. Polls through the brief window where the chain hasn't yet + confirmed the transfer. Mirrors the TS SDK's ``registerWithSolanaPayment``. """ if self._signer is None: raise ValueError("register_with_solana_payment requires a signer") @@ -63,50 +68,54 @@ async def register_with_solana_payment( if not amount or not recipient: raise ValueError("registration payment challenge is missing amount or recipient") - execution = await execute_solana_x402_payment( - signer=self._signer, + challenge_metadata = challenge.get("metadata") or {} + challenge_network = challenge.get("network") or network or SOLANA_MAINNET_NETWORK + # The registration fee is USDC: default to the USDC mint so a challenge + # that names the asset by its SPL mint address (rather than the literal + # "USDC" symbol) still settles. Callers override for devnet / custom + # deployments. + challenge_asset = challenge.get("asset") or "USDC" + fee_payer = challenge_metadata.get("feePayer") + if not fee_payer: + raise ValueError( + "registration payment challenge is missing the facilitator fee payer " + "(accepts[].extra.feePayer)" + ) + + # The USDC registration fee settles gaslessly through the facilitator: a + # payer-signed delegated SPL transfer whose fee payer is the facilitator + # (from the 402 challenge), wrapped in the standard x402 v2 envelope and + # carried in the PAYMENT-SIGNATURE header — no body ``payment`` map and + # no proprietary ``metadata.delegatedTx``. + payment_header = await build_delegated_x402_payment_header( rpc_url=rpc_url, - secret_key=secret_key, - # The registration fee is USDC: default to the USDC mint so a - # challenge that names the asset by its SPL mint address (rather than - # the literal "USDC" symbol) still settles. Callers override for - # devnet / custom deployments. + fee_payer=str(fee_payer), mint=mint or SOLANA_USDC_MINT, decimals=decimals, + secret_key=secret_key, payment={ - "scheme": challenge.get("scheme", "exact"), - "network": challenge.get("network") or network or SOLANA_MAINNET_NETWORK, - "asset": challenge.get("asset") or "USDC", + "network": challenge_network, + "asset": challenge_asset, "amount": amount, - "from": normalized.get("cryptoId") or self._signer.agent_id, "to": recipient, - "nonce": challenge.get("nonce"), - "expiresAt": challenge.get("expiresAt"), - "metadata": { - **(challenge.get("metadata") or {}), - "identity": normalized["username"], - "purpose": "registration", - }, - "publicKeyBase64": normalized.get("publicKey"), }, ) try: identity = await self._register_retrying_payment( - {**normalized, "payment": execution["payment"]}, attempts, interval_ms + normalized, payment_header, attempts, interval_ms ) except TinyPlaceError as exc: # The payment is already on chain. A 5xx can still come back after # the identity was persisted, so check whether the handle now exists # before failing — re-driving the flow would otherwise risk paying - # twice. Re-raise (with the payment attached) only if it truly wasn't - # created. + # twice. Re-raise only if it truly wasn't created. identity = await self._recover_registered_identity(normalized["username"], exc) if identity is None: raise return { "identity": identity, - "payment": execution["payment"], - "onChainTx": execution["signature"], + "paymentHeader": payment_header, + "onChainTx": None, } async def _recover_registered_identity( @@ -133,12 +142,12 @@ async def _registration_payment_challenge(self, request: JsonDict) -> dict[str, raise ValueError("registration did not return a payment challenge") async def _register_retrying_payment( - self, request: JsonDict, attempts: int, interval_ms: int + self, request: JsonDict, payment_header: str, attempts: int, interval_ms: int ) -> Json: attempts = max(1, attempts) for attempt in range(attempts): try: - return await self.register(request) + return await self.register(request, payment_header=payment_header) except TinyPlaceError as exc: if attempt == attempts - 1 or not _should_retry_registration(exc): raise diff --git a/sdk/python/src/tinyplace/http.py b/sdk/python/src/tinyplace/http.py index f4c0b8ff..25f54aa0 100644 --- a/sdk/python/src/tinyplace/http.py +++ b/sdk/python/src/tinyplace/http.py @@ -5,6 +5,7 @@ import json import random from dataclasses import dataclass +from importlib import metadata as importlib_metadata from typing import Any, Awaitable, Callable from urllib.parse import quote, urlencode @@ -14,6 +15,24 @@ from .signer import Signer from .types import Headers, Json, JsonDict, Query +# HEADER_SDK_CLIENT identifies this first-party SDK to the backend so it can +# serve the legacy x402 challenge shape during the standardization migration; +# standard clients omit it and receive a clean x402 v2 challenge. SDK_CLIENT's +# version is derived from the installed package metadata (the pyproject.toml +# version — single source of truth), falling back to 0.0.0 when the package is +# not installed (e.g. an uninstalled source checkout). +HEADER_SDK_CLIENT = "X-Tinyplace-SDK" + + +def _sdk_version() -> str: + try: + return importlib_metadata.version("tinyplace") + except importlib_metadata.PackageNotFoundError: + return "0.0.0" + + +SDK_CLIENT = f"py/{_sdk_version()}" + AuthInvalidHook = Callable[[int, Json], None] #: Default per-request timeout in seconds when none is configured. @@ -160,8 +179,10 @@ async def get_agent_auth(self, path: str, query: Query = None) -> Json: async def post(self, path: str, body: Json = None) -> Json: return await self._request("POST", path, body=body, auth="signed") - async def post_public(self, path: str, body: Json = None) -> Json: - return await self._request("POST", path, body=body) + async def post_public( + self, path: str, body: Json = None, headers: Headers | None = None + ) -> Json: + return await self._request("POST", path, body=body, headers=headers) async def post_admin(self, path: str, body: Json = None) -> Json: return await self._request("POST", path, body=body, auth="admin") @@ -202,8 +223,12 @@ async def graphql( raise TinyPlaceError(200, result, f"GraphQL error: {message}") return result.get("data") if isinstance(result, dict) else None - async def post_directory_auth_as(self, path: str, actor: str, body: Json = None) -> Json: - return await self._request("POST", path, body=body, auth="directory", actor=actor) + async def post_directory_auth_as( + self, path: str, actor: str, body: Json = None, headers: Headers | None = None + ) -> Json: + return await self._request( + "POST", path, body=body, auth="directory", actor=actor, headers=headers + ) async def put(self, path: str, body: Json = None) -> Json: return await self._request("PUT", path, body=body, auth="signed") @@ -260,7 +285,11 @@ async def _request( while True: # Re-sign on every attempt so retries carry a fresh timestamp/nonce # and are never rejected as a replay. - request_headers = {"Content-Type": "application/json", **(headers or {})} + request_headers = { + "Content-Type": "application/json", + HEADER_SDK_CLIENT: SDK_CLIENT, + **(headers or {}), + } await self._apply_auth( request_headers, auth, method, request_uri, body_text, actor ) @@ -382,21 +411,67 @@ def _build_query(query: Query) -> str: def _payment_required_from_body(body: Json) -> PaymentRequiredChallenge | None: - if isinstance(body, dict) and isinstance(body.get("payment"), dict): - return PaymentRequiredChallenge(error=body.get("error"), payment=body["payment"]) - return None + return _challenge_from_object(body) def _payment_required_from_header(headers: Headers) -> PaymentRequiredChallenge | None: value = headers.get("X-Payment-Required") or headers.get("x-payment-required") if not value: return None - parsed = _decode_payment_header(value) - if isinstance(parsed, dict) and isinstance(parsed.get("payment"), dict): - return PaymentRequiredChallenge(error=parsed.get("error"), payment=parsed["payment"]) + return _challenge_from_object(_decode_payment_header(value)) + + +def _challenge_from_object(value: Json) -> PaymentRequiredChallenge | None: + """Parse a 402 challenge, preferring the standard x402 v2 ``accepts[]``. + + Falls back to the legacy top-level ``payment`` object (removed in Phase 2 + once the standard path is the only one in the field). + """ + if not isinstance(value, dict): + return None + payment = _challenge_payment_from_accepts(value) + if payment is not None: + return PaymentRequiredChallenge(error=value.get("error"), payment=payment) + if isinstance(value.get("payment"), dict): + return PaymentRequiredChallenge(error=value.get("error"), payment=value["payment"]) return None +def _challenge_payment_from_accepts(value: dict[str, Any]) -> dict[str, Any] | None: + """Map the first standard ``accepts[]`` entry onto the flat payment dict. + + The payer-binding fields (``from``/``nonce``/``expiresAt``) are promoted out + of ``extra`` to the top level; the rest of ``extra`` becomes the signed + metadata, exactly mirroring the legacy ``payment`` shape. + """ + accepts = value.get("accepts") + if not isinstance(accepts, list) or not accepts: + return None + entry = accepts[0] + if not isinstance(entry, dict): + return None + extra = entry.get("extra") if isinstance(entry.get("extra"), dict) else {} + binding_keys = ("from", "nonce", "expiresAt") + + payment: dict[str, Any] = {} + for key in ("scheme", "network", "asset", "amount"): + if isinstance(entry.get(key), str): + payment[key] = entry[key] + if isinstance(entry.get("payTo"), str): + payment["to"] = entry["payTo"] + for key in binding_keys: + if isinstance(extra.get(key), str): + payment[key] = extra[key] + metadata = { + key: value_ + for key, value_ in extra.items() + if key not in binding_keys and isinstance(value_, str) + } + if metadata: + payment["metadata"] = metadata + return payment + + def _decode_payment_header(value: str) -> Any: """Decode an ``X-Payment-Required`` header to its JSON object. diff --git a/sdk/python/src/tinyplace/solana.py b/sdk/python/src/tinyplace/solana.py index 67ea7c40..16b00d78 100644 --- a/sdk/python/src/tinyplace/solana.py +++ b/sdk/python/src/tinyplace/solana.py @@ -2,6 +2,7 @@ import asyncio import base64 +import json import re from typing import Any, Awaitable, Callable @@ -24,8 +25,14 @@ # explicit mint instead. SOLANA_USDC_MINT = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v" SOLANA_TOKEN_PROGRAM_ID = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" +# The ComputeBudget program (sets the compute unit limit + price). +SOLANA_COMPUTE_BUDGET_PROGRAM_ID = "ComputeBudget111111111111111111111111111111" USDC_DECIMALS = 6 SOLANA_NATIVE_DECIMALS = 9 +# Default compute unit limit for the facilitator transfer (matches the web app). +FACILITATOR_COMPUTE_UNIT_LIMIT = 40_000 +# Default compute unit price in microlamports/CU (well under the 5,000,000 cap). +FACILITATOR_COMPUTE_UNIT_PRICE_MICRO_LAMPORTS = "1" # Mainnet wrapped-SOL (WSOL) SPL mint. SOLANA_WSOL_MINT = "So11111111111111111111111111111111111111112" @@ -222,6 +229,251 @@ async def execute_solana_x402_payment( return {**execution, "payment": payment_map} +async def build_payer_signed_delegated_tx( + *, + rpc_url: str, + fee_payer: str, + payee: str, + amount: str, + mint: str, + decimals: int, + secret_key: str | bytes, + source_token_account: str | None = None, + destination_token_account: str | None = None, + compute_unit_limit: int = FACILITATOR_COMPUTE_UNIT_LIMIT, + compute_unit_price_micro_lamports: str = FACILITATOR_COMPUTE_UNIT_PRICE_MICRO_LAMPORTS, + rpc_request: RpcRequest | None = None, +) -> str: + """Build the gasless (facilitator fee-paid) x402 "exact" Solana transfer and + partially sign it with the agent's keypair. + + The transaction is ``[SetComputeUnitLimit, SetComputeUnitPrice, + TransferChecked]`` with the facilitator (``fee_payer``) as account 0 (the fee + payer) and the agent as the transfer authority (a read-only second signer). + Only the agent signature is filled; the fee-payer signature slot is left zeroed + for the facilitator to co-sign and broadcast at settle time. Returns the base64 + wire transaction to attach as the x402 payment's ``metadata.delegatedTx``. + + The payee's destination token account must already exist — the exact scheme + forbids ATA creation in the payment transaction. Mirrors the TS SDK's + ``buildPayerSignedDelegatedTx``. + """ + keypair = _keypair_from_secret(secret_key) + payer = str(keypair.pubkey()) + normalized_amount = _normalized_amount(amount) + request = rpc_request or _aiohttp_rpc_request(rpc_url) + + source = source_token_account or await _find_token_account( + request, owner=payer, mint=mint, minimum_amount=normalized_amount + ) + destination = destination_token_account or await _find_token_account( + request, owner=payee, mint=mint + ) + latest = await request("getLatestBlockhash", [{"commitment": "confirmed"}]) + + message = _two_signer_facilitator_message( + fee_payer=fee_payer, + authority=payer, + source_token_account=source, + destination_token_account=destination, + mint=mint, + amount=normalized_amount, + decimals=decimals, + compute_unit_limit=compute_unit_limit, + compute_unit_price_micro_lamports=compute_unit_price_micro_lamports, + blockhash=latest["value"]["blockhash"], + ) + # Sign as the authority (signer index 1). The fee-payer slot (index 0) is left + # empty for the facilitator to fill at settle time. + authority_signature = bytes(keypair.sign_message(message)) + empty_fee_payer_signature = b"\x00" * 64 + wire = _short_vec(2) + empty_fee_payer_signature + authority_signature + message + return base64.b64encode(wire).decode("ascii") + + +def build_delegated_x402_envelope( + *, + network: str, + amount: str, + asset_mint: str, + pay_to: str, + fee_payer: str, + transaction: str, + max_timeout_seconds: int = 60, +) -> dict[str, Any]: + """Build the standard x402 v2 ``PaymentPayload`` envelope for a sponsored + (gasless SPL) Solana ``exact`` payment. + + The partially-signed wire transaction travels in ``payload.transaction``; the + facilitator fee payer is advertised in ``accepted.extra.feePayer``. ``asset`` + is the on-chain SPL **mint** (base58), not a symbol. This is the standard + envelope the backend reads from the ``PAYMENT-SIGNATURE`` header — there is no + proprietary ``metadata.delegatedTx``. + """ + return { + "x402Version": 2, + "accepted": { + "scheme": "exact", + "network": str(network), + "amount": str(amount), + "asset": str(asset_mint), + "payTo": str(pay_to), + "maxTimeoutSeconds": max_timeout_seconds, + "extra": {"feePayer": str(fee_payer)}, + }, + "payload": {"transaction": str(transaction)}, + } + + +def encode_delegated_x402_header(envelope: dict[str, Any]) -> str: + """Encode a sponsored-payment envelope as the ``PAYMENT-SIGNATURE`` header + value: standard base64 (with padding) of the UTF-8 JSON of the envelope. + """ + raw = json.dumps(envelope, separators=(",", ":"), ensure_ascii=False).encode("utf-8") + return base64.b64encode(raw).decode("ascii") + + +async def build_delegated_x402_payment_header( + *, + rpc_url: str, + fee_payer: str, + payment: dict[str, Any], + mint: str, + decimals: int, + secret_key: str | bytes, + source_token_account: str | None = None, + destination_token_account: str | None = None, + compute_unit_limit: int = FACILITATOR_COMPUTE_UNIT_LIMIT, + compute_unit_price_micro_lamports: str = FACILITATOR_COMPUTE_UNIT_PRICE_MICRO_LAMPORTS, + rpc_request: RpcRequest | None = None, +) -> str: + """Build the agent-signed facilitator transfer and wrap it into the standard + x402 v2 ``PAYMENT-SIGNATURE`` header value. + + Given a parsed 402 ``payment`` challenge (``network``/``asset``/``amount``/ + ``to``) plus the facilitator ``fee_payer``, the resolved SPL ``mint`` and the + payer's ``secret_key``/``rpc_url``, this returns the base64-encoded standard + envelope to submit in the :data:`~tinyplace.x402.X402_PAYMENT_HEADER` header, + with **no** ``payment`` field in the request body. Replaces the proprietary + ``build_delegated_x402_payment_map`` (which carried the tx under + ``metadata.delegatedTx``). + """ + wire = await build_payer_signed_delegated_tx( + rpc_url=rpc_url, + fee_payer=fee_payer, + payee=str(payment["to"]), + amount=str(payment["amount"]), + mint=mint, + decimals=decimals, + secret_key=secret_key, + source_token_account=source_token_account, + destination_token_account=destination_token_account, + compute_unit_limit=compute_unit_limit, + compute_unit_price_micro_lamports=compute_unit_price_micro_lamports, + rpc_request=rpc_request, + ) + envelope = build_delegated_x402_envelope( + network=str(payment["network"]), + amount=str(payment["amount"]), + # The envelope advertises the on-chain SPL mint, not the challenge symbol. + asset_mint=mint, + pay_to=str(payment["to"]), + fee_payer=str(fee_payer), + transaction=wire, + ) + return encode_delegated_x402_header(envelope) + + +def _two_signer_facilitator_message( + *, + fee_payer: str, + authority: str, + source_token_account: str, + destination_token_account: str, + mint: str, + amount: str, + decimals: int, + compute_unit_limit: int, + compute_unit_price_micro_lamports: str, + blockhash: str, +) -> bytes: + """Serialize a two-signer legacy message for the facilitator transfer. + + Account ordering follows Solana's rules: writable signers, then read-only + signers, then writable non-signers, then read-only non-signers. The fee payer + must be account 0; the transfer authority is a read-only signer at index 1. + Mirrors the TS SDK's ``twoSignerFacilitatorMessage``. + """ + # 0: feePayer (writable signer), 1: authority (read-only signer), + # 2: source, 3: destination (writable non-signers), + # 4: mint, 5: token program, 6: compute budget program (read-only non-signers). + account_keys = [ + fee_payer, + authority, + source_token_account, + destination_token_account, + mint, + SOLANA_TOKEN_PROGRAM_ID, + SOLANA_COMPUTE_BUDGET_PROGRAM_ID, + ] + header = bytes([2, 1, 3]) + + # SetComputeUnitLimit: u8 discriminant (2) + u32 LE limit. + compute_limit_data = bytes([2]) + int(compute_unit_limit).to_bytes(4, "little") + # SetComputeUnitPrice: u8 discriminant (3) + u64 LE microlamports. + compute_price_data = bytes([3]) + int(compute_unit_price_micro_lamports).to_bytes(8, "little") + # TransferChecked: u8 discriminant (12) + u64 LE amount + u8 decimals. + transfer_data = bytes([12]) + int(amount).to_bytes(8, "little") + bytes([decimals & 0xFF]) + + parts = [ + header, + _short_vec(len(account_keys)), + *[decode_base58(key) for key in account_keys], + decode_base58(blockhash), + # Three instructions. + _short_vec(3), + # ComputeBudget SetComputeUnitLimit (program index 6, no accounts). + bytes([6]), + _short_vec(0), + _short_vec(len(compute_limit_data)), + compute_limit_data, + # ComputeBudget SetComputeUnitPrice (program index 6, no accounts). + bytes([6]), + _short_vec(0), + _short_vec(len(compute_price_data)), + compute_price_data, + # Token TransferChecked (program index 5): source, mint, dest, authority. + bytes([5]), + _short_vec(4), + bytes([2, 4, 3, 1]), + _short_vec(len(transfer_data)), + transfer_data, + ] + return b"".join(parts) + + +def _short_vec(value: int) -> bytes: + """Encode an unsigned integer as a Solana compact-u16 (shortvec) length.""" + out = bytearray() + current = value + while True: + byte = current & 0x7F + current >>= 7 + if current > 0: + byte |= 0x80 + out.append(byte) + if current == 0: + break + return bytes(out) + + +def _normalized_amount(amount: str) -> str: + trimmed = str(amount).strip() + if not trimmed.isdigit() or int(trimmed) <= 0: + raise ValueError(f"Solana payment amount must be a positive integer: {amount}") + return trimmed + + async def _send_transaction(rpc_request: RpcRequest, tx: Transaction, commitment: str) -> str: signature = await rpc_request( "sendTransaction", diff --git a/sdk/python/src/tinyplace/x402.py b/sdk/python/src/tinyplace/x402.py index 0bdaf6d7..159ec721 100644 --- a/sdk/python/src/tinyplace/x402.py +++ b/sdk/python/src/tinyplace/x402.py @@ -98,6 +98,58 @@ def x402_authorization_to_payment_map(authorization: dict[str, Any]) -> dict[str return payment +# The canonical x402 v2 submission header. A migrated SDK (or any standard x402 +# client) base64-encodes the PaymentPayload envelope and submits it in this +# header. The legacy ``X-PAYMENT`` header is still accepted by the backend for +# backwards compatibility. +X402_PAYMENT_HEADER = "PAYMENT-SIGNATURE" + + +def build_x402_payment_envelope(authorization: dict[str, Any]) -> dict[str, Any]: + """Build the standard x402 v2 PaymentPayload envelope from an authorization. + + tiny.place's authorization signature travels as the scheme-specific + ``payload``; a standard client base64-encodes this and submits it in the + :data:`X402_PAYMENT_HEADER` (``PAYMENT-SIGNATURE``) header on the + header-based payment surfaces (e.g. a2a). + """ + payload_authorization: dict[str, str] = { + "from": str(authorization["from"]), + "to": str(authorization["to"]), + "value": str(authorization["amount"]), + "nonce": str(authorization["nonce"]), + } + if authorization.get("expiresAt"): + payload_authorization["validBefore"] = str(authorization["expiresAt"]) + return { + "x402Version": 2, + "accepted": { + "scheme": str(authorization["scheme"]), + "network": str(authorization["network"]), + "amount": str(authorization["amount"]), + "asset": str(authorization["asset"]), + "payTo": str(authorization["to"]), + "maxTimeoutSeconds": 60, + "extra": {k: str(v) for k, v in (authorization.get("metadata") or {}).items()}, + }, + "payload": { + "signature": str(authorization["signature"]), + "authorization": payload_authorization, + }, + "extensions": {}, + } + + +def encode_x402_payment_header(authorization: dict[str, Any]) -> str: + """Encode an authorization as the base64 :data:`X402_PAYMENT_HEADER` + (``PAYMENT-SIGNATURE``) header value — the standard x402 v2 submission + format. Mirrors the backend's ``x402.ParseInboundPayment``. + """ + envelope = build_x402_payment_envelope(authorization) + raw = json.dumps(envelope, separators=(",", ":")).encode("utf-8") + return base64.b64encode(raw).decode("ascii") + + def generate_nonce(prefix: str | None = None) -> str: value = secrets.token_hex(12) return f"{prefix}_{value}" if prefix else value diff --git a/sdk/python/tests/test_bounties.py b/sdk/python/tests/test_bounties.py index 3fb36849..3328b623 100644 --- a/sdk/python/tests/test_bounties.py +++ b/sdk/python/tests/test_bounties.py @@ -35,62 +35,95 @@ async def test_bounties_create_and_submit_sign_as_actor() -> None: assert session.requests[1]["headers"]["X-Agent-ID"] == "SubmitterId" -async def test_bounties_create_with_solana_payment_settles_then_creates(monkeypatch) -> None: +def _challenge_accepts(challenge: dict) -> dict: + """Wrap a flat challenge dict in a canonical x402 v2 ``accepts[0]`` body. + + The backend emits ONLY the standard envelope — the SDK parses ``accepts[0]`` + (``network``/``amount``/``asset``-mint/``payTo``/``extra.feePayer``). + """ + fee_payer = challenge.get("feePayer", "FacilitatorFeePayer111") + return { + "error": challenge.get("error"), + "accepts": [ + { + "scheme": "exact", + "network": challenge["network"], + "amount": challenge["amount"], + "asset": challenge["asset"], + "payTo": challenge["to"], + "extra": {"feePayer": fee_payer}, + } + ], + } + + +async def test_bounties_create_with_solana_payment_submits_header_then_creates(monkeypatch) -> None: signer = LocalSigner.from_seed(bytes([96]) * 32) challenge = { "amount": "5", "to": "EscrowWallet111", "network": SOLANA_MAINNET_NETWORK, "asset": "USDC", - "nonce": "n1", + "feePayer": "FacilitatorFeePayer111", } session = FakeSession( [ - FakeResponse(402, {"error": "payment required to create and fund this bounty", "payment": challenge}), # probe + FakeResponse(402, _challenge_accepts(challenge)), # probe FakeResponse(200, {"bountyId": "b1", "status": "open"}), # re-create (funded) ] ) client = _client(signer, session) captured: dict = {} - async def fake_exec(**kwargs): + async def fake_header(**kwargs): captured.update(kwargs) - return {"signature": "sig", "payment": {"signature": "s"}} + return "ENCODED-ENVELOPE" - monkeypatch.setattr("tinyplace.api.bounties.execute_solana_x402_payment", fake_exec) + monkeypatch.setattr("tinyplace.api.bounties.build_delegated_x402_payment_header", fake_header) result = await client.bounties.create_with_solana_payment( {"creator": "CreatorId", "title": "X", "amount": "5", "asset": "USDC"}, rpc_url="https://rpc.example", secret_key=bytes([96]) * 32, ) - # Paid the reward into the escrow wallet, from the creator. + # Paid the reward into the escrow wallet with the facilitator fee payer from + # accepts[].extra.feePayer. assert captured["payment"]["amount"] == "5" - assert captured["payment"]["to"] == "EscrowWallet111" and captured["payment"]["from"] == "CreatorId" - assert captured["payment"]["metadata"]["kind"] == "bounty-fund" - # Both POSTs hit the combined create endpoint; the re-create carried the payment. + assert captured["payment"]["to"] == "EscrowWallet111" + assert captured["fee_payer"] == "FacilitatorFeePayer111" + # Both POSTs hit the combined create endpoint; the re-create carried the + # PAYMENT-SIGNATURE header and NO body payment map. assert session.requests[0]["url"].endswith("/bounties") - create_body = json.loads(session.requests[1]["data"]) - assert create_body["payment"]["signature"] == "s" - assert result["bounty"]["status"] == "open" and result["payment"]["signature"] == "sig" + recreate = session.requests[1] + assert recreate["headers"]["PAYMENT-SIGNATURE"] == "ENCODED-ENVELOPE" + create_body = json.loads(recreate["data"]) + assert "payment" not in create_body + assert result["bounty"]["status"] == "open" + assert result["paymentHeader"] == "ENCODED-ENVELOPE" async def test_bounties_create_with_solana_payment_retries_through_confirmation_lag(monkeypatch) -> None: signer = LocalSigner.from_seed(bytes([97]) * 32) - challenge = {"amount": "5", "to": "Escrow1", "network": SOLANA_MAINNET_NETWORK, "asset": "USDC"} + challenge = { + "amount": "5", + "to": "Escrow1", + "network": SOLANA_MAINNET_NETWORK, + "asset": "USDC", + "feePayer": "FacilitatorFeePayer111", + } session = FakeSession( [ - FakeResponse(402, {"payment": challenge}), # probe + FakeResponse(402, _challenge_accepts(challenge)), # probe FakeResponse(402, {"error": "transaction not found"}), # re-create: not confirmed yet FakeResponse(200, {"bountyId": "b1", "status": "open"}), # re-create: confirmed ] ) client = _client(signer, session) - async def fake_exec(**kwargs): - return {"signature": "sig", "payment": {"signature": "s"}} + async def fake_header(**kwargs): + return "ENCODED-ENVELOPE" - monkeypatch.setattr("tinyplace.api.bounties.execute_solana_x402_payment", fake_exec) + monkeypatch.setattr("tinyplace.api.bounties.build_delegated_x402_payment_header", fake_header) result = await client.bounties.create_with_solana_payment( {"creator": "CreatorId", "title": "X", "amount": "5"}, @@ -99,9 +132,45 @@ async def fake_exec(**kwargs): interval_ms=0, # no real sleep ) assert len(session.requests) == 3 # probe + 2 create attempts + # Every re-create attempt carried the same PAYMENT-SIGNATURE header. + assert session.requests[1]["headers"]["PAYMENT-SIGNATURE"] == "ENCODED-ENVELOPE" + assert session.requests[2]["headers"]["PAYMENT-SIGNATURE"] == "ENCODED-ENVELOPE" assert result["bounty"]["status"] == "open" +async def test_bounties_create_with_solana_payment_requires_fee_payer(monkeypatch) -> None: + import pytest + + signer = LocalSigner.from_seed(bytes([95]) * 32) + # accepts[0] with no extra.feePayer — the sponsored path cannot proceed. + body = { + "accepts": [ + { + "scheme": "exact", + "network": SOLANA_MAINNET_NETWORK, + "amount": "5", + "asset": "USDC", + "payTo": "EscrowWallet111", + "extra": {}, + } + ] + } + session = FakeSession([FakeResponse(402, body)]) + client = _client(signer, session) + + async def fail_header(**_kwargs): # must never be reached + raise AssertionError("header builder should not run without a fee payer") + + monkeypatch.setattr("tinyplace.api.bounties.build_delegated_x402_payment_header", fail_header) + + with pytest.raises(ValueError, match="fee payer"): + await client.bounties.create_with_solana_payment( + {"creator": "CreatorId", "title": "X", "amount": "5", "asset": "USDC"}, + rpc_url="https://rpc.example", + secret_key=bytes([95]) * 32, + ) + + async def test_bounties_create_with_solana_payment_requires_creator() -> None: import pytest diff --git a/sdk/python/tests/test_registry.py b/sdk/python/tests/test_registry.py index 23ad7045..e564b490 100644 --- a/sdk/python/tests/test_registry.py +++ b/sdk/python/tests/test_registry.py @@ -123,20 +123,48 @@ async def test_delete_subname_uses_public_delete_with_ownership_signature() -> N assert request["headers"]["X-TinyPlace-Signature"].startswith("v1:") -async def test_register_with_solana_payment_settles_then_retries(monkeypatch) -> None: +def _challenge_accepts(challenge: dict) -> dict: + """Wrap a flat challenge dict in a canonical x402 v2 ``accepts[0]`` body. + + The backend no longer emits a legacy top-level ``payment`` field or + ``metadata.feePayer`` — the SDK parses ``accepts[0]`` (``network``/``amount``/ + ``asset``-mint/``payTo``/``extra.feePayer``). + """ + metadata = dict(challenge.get("metadata") or {}) + fee_payer = challenge.get("feePayer") or metadata.get("feePayer") + extra = {k: v for k, v in metadata.items() if k != "feePayer"} + if fee_payer: + extra["feePayer"] = fee_payer + return { + "error": challenge.get("error"), + "accepts": [ + { + "scheme": "exact", + "network": challenge["network"], + "amount": challenge["amount"], + "asset": challenge["asset"], + "payTo": challenge["to"], + "extra": extra, + } + ], + } + + +_FEE_PAYER = "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU" + + +async def test_register_with_solana_payment_submits_header_then_retries(monkeypatch) -> None: signer = LocalSigner.from_seed(bytes([22]) * 32) challenge = { "amount": "10000000", "to": "Recipient1111111111111111111111111111111111", "network": SOLANA_MAINNET_NETWORK, "asset": "USDC", - "nonce": "n1", - "expiresAt": "2026-06-13T00:00:00Z", - "metadata": {"feeQuoteId": "q1"}, + "feePayer": _FEE_PAYER, } session = FakeSession( [ - FakeResponse(402, {"error": "payment required", "payment": challenge}), + FakeResponse(402, _challenge_accepts(challenge)), FakeResponse(200, {"username": "@agent", "cryptoId": signer.agent_id}), ] ) @@ -148,14 +176,13 @@ async def test_register_with_solana_payment_settles_then_retries(monkeypatch) -> captured: dict = {} - async def fake_execute(**kwargs): + async def fake_header(**kwargs): captured.update(kwargs) - return { - "signature": "onchain-sig", - "payment": {"scheme": "exact", "amount": kwargs["payment"]["amount"], "signature": "sig"}, - } + return "ENCODED-ENVELOPE" - monkeypatch.setattr("tinyplace.api.registry.execute_solana_x402_payment", fake_execute) + monkeypatch.setattr( + "tinyplace.api.registry.build_delegated_x402_payment_header", fake_header + ) result = await client.register_domain_with_solana_payment( "agent", @@ -164,16 +191,20 @@ async def fake_execute(**kwargs): network=SOLANA_MAINNET_NETWORK, ) - # Paid the challenge's exact amount to its recipient, tagged for registration. + # The header builder paid the challenge's exact amount to its recipient with + # the facilitator fee payer from accepts[].extra.feePayer. assert captured["payment"]["amount"] == "10000000" assert captured["payment"]["to"] == challenge["to"] - assert captured["payment"]["metadata"]["purpose"] == "registration" - assert captured["payment"]["metadata"]["identity"] == "@agent" - # The retried registration carried the signed payment map. + assert captured["fee_payer"] == _FEE_PAYER + # The retried registration carried the PAYMENT-SIGNATURE header and NO body + # payment map. + retry = session.requests[1] + assert retry["headers"]["PAYMENT-SIGNATURE"] == "ENCODED-ENVELOPE" retry_body = json_body_at(session, 1) - assert retry_body["payment"]["signature"] == "sig" + assert "payment" not in retry_body assert retry_body["username"] == "@agent" - assert result["onChainTx"] == "onchain-sig" + assert result["paymentHeader"] == "ENCODED-ENVELOPE" + assert result["onChainTx"] is None async def test_register_with_solana_payment_decodes_header_challenge_and_defaults_mint( @@ -190,9 +221,13 @@ async def test_register_with_solana_payment_decodes_header_challenge_and_default "to": "Recipient1111111111111111111111111111111111", "network": SOLANA_MAINNET_NETWORK, "asset": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", - "nonce": "n2", + "feePayer": _FEE_PAYER, } - header = _b64.urlsafe_b64encode(_json.dumps({"payment": challenge}).encode()).decode().rstrip("=") + header = ( + _b64.urlsafe_b64encode(_json.dumps(_challenge_accepts(challenge)).encode()) + .decode() + .rstrip("=") + ) session = FakeSession( [ FakeResponse(402, "", headers={"X-Payment-Required": header}), @@ -207,21 +242,23 @@ async def test_register_with_solana_payment_decodes_header_challenge_and_default captured: dict = {} - async def fake_execute(**kwargs): + async def fake_header(**kwargs): captured.update(kwargs) - return {"signature": "sig", "payment": {"signature": "s"}} + return "ENCODED-ENVELOPE" - monkeypatch.setattr("tinyplace.api.registry.execute_solana_x402_payment", fake_execute) + monkeypatch.setattr( + "tinyplace.api.registry.build_delegated_x402_payment_header", fake_header + ) result = await client.register_domain_with_solana_payment( "agent", rpc_url="https://rpc.example", secret_key=bytes([23]) * 32 ) - # Header-only challenge was decoded, and the USDC mint defaulted even though - # the asset was the mint address rather than "USDC". + # Header-only challenge was decoded from accepts[0], and the USDC mint + # defaulted even though the asset was the mint address rather than "USDC". assert captured["payment"]["amount"] == "10000000" assert captured["mint"] == "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v" - assert result["onChainTx"] == "sig" + assert result["paymentHeader"] == "ENCODED-ENVELOPE" async def test_register_with_solana_payment_recovers_on_server_error(monkeypatch) -> None: @@ -231,10 +268,11 @@ async def test_register_with_solana_payment_recovers_on_server_error(monkeypatch "to": "Recipient1111111111111111111111111111111111", "network": SOLANA_MAINNET_NETWORK, "asset": "USDC", + "feePayer": _FEE_PAYER, } session = FakeSession( [ - FakeResponse(402, {"payment": challenge}), # challenge probe + FakeResponse(402, _challenge_accepts(challenge)), # challenge probe FakeResponse(500, {"error": "boom"}), # paid retry: 5xx after persisting FakeResponse(200, {"available": False, "identity": {"username": "@agent"}}), # get() ] @@ -245,10 +283,12 @@ async def test_register_with_solana_payment_recovers_on_server_error(monkeypatch session=session, # type: ignore[arg-type] ) - async def fake_execute(**kwargs): - return {"signature": "sig", "payment": {}} + async def fake_header(**kwargs): + return "ENCODED-ENVELOPE" - monkeypatch.setattr("tinyplace.api.registry.execute_solana_x402_payment", fake_execute) + monkeypatch.setattr( + "tinyplace.api.registry.build_delegated_x402_payment_header", fake_header + ) result = await client.register_domain_with_solana_payment( "agent", rpc_url="https://rpc.example", secret_key=bytes([24]) * 32 @@ -257,7 +297,7 @@ async def fake_execute(**kwargs): # The handle was created despite the 5xx, so recovery returns it instead of # failing (which would risk a second payment on retry). assert result["identity"]["username"] == "@agent" - assert result["onChainTx"] == "sig" + assert result["paymentHeader"] == "ENCODED-ENVELOPE" def json_body(session: FakeSession) -> dict: diff --git a/sdk/python/tests/test_x402_delegated.py b/sdk/python/tests/test_x402_delegated.py new file mode 100644 index 00000000..961130e0 --- /dev/null +++ b/sdk/python/tests/test_x402_delegated.py @@ -0,0 +1,160 @@ +from __future__ import annotations + +import base64 +import json + +import pytest +from nacl.signing import VerifyKey +from solders.pubkey import Pubkey +from solders.transaction import VersionedTransaction + +from tinyplace import ( + FACILITATOR_COMPUTE_UNIT_LIMIT, + LocalSigner, + SOLANA_MAINNET_NETWORK, + SOLANA_USDC_MINT, + build_delegated_x402_payment_header, + build_payer_signed_delegated_tx, +) + +# Distinct valid base58 pubkeys for the facilitator fee payer, the payee, and the +# two associated token accounts the RPC lookup returns. +_FEE_PAYER = "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU" +_PAYEE = "9n4nbM75f5Ui33ZbPYXn59EwSgE8CGsHtAeTH5YFeJ9E" +_SOURCE_ATA = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" +_DEST_ATA = "ComputeBudget111111111111111111111111111111" +_BLOCKHASH = "11111111111111111111111111111111" + + +def _rpc_request(payer: str): + async def rpc_request(method: str, params: list) -> dict: + if method == "getTokenAccountsByOwner": + owner = params[0] + pubkey = _SOURCE_ATA if owner == payer else _DEST_ATA + return { + "value": [ + { + "pubkey": pubkey, + "account": { + "data": {"parsed": {"info": {"tokenAmount": {"amount": "5000000"}}}} + }, + } + ] + } + if method == "getLatestBlockhash": + return {"value": {"blockhash": _BLOCKHASH}} + raise AssertionError(method) + + return rpc_request + + +async def test_build_payer_signed_delegated_tx_fee_payer_and_authority_wiring() -> None: + signer = LocalSigner.from_seed(bytes([40]) * 32) + payer = signer.agent_id + + wire = await build_payer_signed_delegated_tx( + rpc_url="https://solana.example.test", + fee_payer=_FEE_PAYER, + payee=_PAYEE, + amount="1000000", + mint=SOLANA_USDC_MINT, + decimals=6, + secret_key=bytes([40]) * 32, + rpc_request=_rpc_request(payer), + ) + + tx = VersionedTransaction.from_bytes(base64.b64decode(wire)) + message = tx.message + keys = list(message.account_keys) + + # Account 0 is the facilitator fee payer; account 1 is the agent authority. + assert keys[0] == Pubkey.from_string(_FEE_PAYER) + assert keys[1] == Pubkey.from_string(payer) + + signatures = tx.signatures + assert len(signatures) == 2 + # The fee-payer slot is left zeroed for the facilitator to co-sign at settle. + assert all(byte == 0 for byte in bytes(signatures[0])) + # The agent signs as the transfer authority (signer index 1). + assert any(byte != 0 for byte in bytes(signatures[1])) + VerifyKey(signer.public_key).verify(bytes(message), bytes(signatures[1])) + + # [SetComputeUnitLimit, SetComputeUnitPrice, TransferChecked]. + assert len(message.instructions) == 3 + limit_ix = message.instructions[0] + assert limit_ix.data[0] == 2 # SetComputeUnitLimit discriminant + assert int.from_bytes(limit_ix.data[1:5], "little") == FACILITATOR_COMPUTE_UNIT_LIMIT + assert message.instructions[1].data[0] == 3 # SetComputeUnitPrice + transfer_ix = message.instructions[2] + assert transfer_ix.data[0] == 12 # TransferChecked discriminant + assert int.from_bytes(transfer_ix.data[1:9], "little") == 1000000 + + +async def test_build_delegated_x402_payment_header_emits_standard_envelope() -> None: + signer = LocalSigner.from_seed(bytes([41]) * 32) + payer = signer.agent_id + + header = await build_delegated_x402_payment_header( + rpc_url="https://solana.example.test", + fee_payer=_FEE_PAYER, + payment={ + "network": SOLANA_MAINNET_NETWORK, + # The challenge may still name the asset by symbol; the envelope must + # echo the on-chain SPL mint that the tx was built against. + "asset": "USDC", + "amount": "1000000", + "to": _PAYEE, + }, + mint=SOLANA_USDC_MINT, + decimals=6, + secret_key=bytes([41]) * 32, + rpc_request=_rpc_request(payer), + ) + + # The PAYMENT-SIGNATURE header is standard padded base64 of the UTF-8 JSON of + # the canonical x402 v2 PaymentPayload envelope. + envelope = json.loads(base64.b64decode(header)) + assert envelope["x402Version"] == 2 + + accepted = envelope["accepted"] + assert accepted["scheme"] == "exact" + assert accepted["network"] == SOLANA_MAINNET_NETWORK + assert accepted["amount"] == "1000000" + # ``asset`` is the on-chain SPL mint (base58), not the "USDC" symbol. + assert accepted["asset"] == SOLANA_USDC_MINT + assert accepted["payTo"] == _PAYEE + assert accepted["maxTimeoutSeconds"] == 60 + assert accepted["extra"]["feePayer"] == _FEE_PAYER + + # No proprietary metadata.delegatedTx and no body payment map — the proof is + # only the standard payload.transaction. + assert "metadata.delegatedTx" not in header + assert "metadata.delegatedTx" not in json.dumps(envelope) + assert "payment" not in envelope + + # payload.transaction is the non-empty partially-signed two-signature legacy + # tx: facilitator fee-payer slot zeroed (account 0), agent authority filled. + wire = envelope["payload"]["transaction"] + assert wire + tx = VersionedTransaction.from_bytes(base64.b64decode(wire)) + keys = list(tx.message.account_keys) + assert keys[0] == Pubkey.from_string(_FEE_PAYER) + assert keys[1] == Pubkey.from_string(payer) + signatures = tx.signatures + assert len(signatures) == 2 + assert all(byte == 0 for byte in bytes(signatures[0])) + assert any(byte != 0 for byte in bytes(signatures[1])) + + +async def test_build_payer_signed_delegated_tx_rejects_bad_amount() -> None: + with pytest.raises(ValueError, match="positive integer"): + await build_payer_signed_delegated_tx( + rpc_url="x", + fee_payer=_FEE_PAYER, + payee=_PAYEE, + amount="0", + mint=SOLANA_USDC_MINT, + decimals=6, + secret_key=bytes([42]) * 32, + rpc_request=_rpc_request("x"), + ) diff --git a/sdk/python/tests/test_x402_standard.py b/sdk/python/tests/test_x402_standard.py new file mode 100644 index 00000000..831decd0 --- /dev/null +++ b/sdk/python/tests/test_x402_standard.py @@ -0,0 +1,102 @@ +from __future__ import annotations + +import base64 +import json + +from tinyplace import ( + TinyPlaceClient, + TinyPlaceError, + X402_PAYMENT_HEADER, + build_x402_payment_envelope, + encode_x402_payment_header, +) + +from .helpers import FakeResponse, FakeSession + + +def test_exposes_canonical_submission_header() -> None: + assert X402_PAYMENT_HEADER == "PAYMENT-SIGNATURE" + + +async def test_sends_sdk_identification_header() -> None: + session = FakeSession([FakeResponse(200, {"ok": True})]) + client = TinyPlaceClient(base_url="https://api.example.test", session=session) # type: ignore[arg-type] + + await client.http.get("/anything") + + headers = session.requests[0]["headers"] + assert headers["X-Tinyplace-SDK"].startswith("py/") + + +def test_parses_challenge_from_standard_accepts() -> None: + body = { + "error": "payment required", + "x402Version": 2, + "resource": {"url": "https://tiny.place"}, + "accepts": [ + { + "scheme": "exact", + "network": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + "amount": "1000000", + "asset": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "payTo": "treasury-address", + "maxTimeoutSeconds": 60, + "extra": { + "domain": "tiny.place", + "feePayer": "facilitator-address", + "from": "payer-address", + "nonce": "nonce-xyz", + "expiresAt": "2026-06-21T00:00:00Z", + }, + } + ], + "extensions": {}, + } + error = TinyPlaceError(402, body) + assert error.payment_required is not None + payment = error.payment_required.payment + assert payment["amount"] == "1000000" + assert payment["to"] == "treasury-address" # payTo -> to + # Binding fields promoted out of extra. + assert payment["from"] == "payer-address" + assert payment["nonce"] == "nonce-xyz" + assert payment["expiresAt"] == "2026-06-21T00:00:00Z" + # Remaining extra becomes metadata; binding keys are not duplicated in. + assert payment["metadata"]["domain"] == "tiny.place" + assert payment["metadata"]["feePayer"] == "facilitator-address" + assert "nonce" not in payment["metadata"] + assert "from" not in payment["metadata"] + + +def test_falls_back_to_legacy_payment_field() -> None: + error = TinyPlaceError( + 402, {"error": "payment required", "payment": {"amount": "500", "to": "treasury"}} + ) + assert error.payment_required is not None + assert error.payment_required.payment["amount"] == "500" + + +def test_encodes_standard_x_payment_envelope() -> None: + authorization = { + "scheme": "exact", + "network": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + "asset": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "amount": "1000000", + "from": "payer", + "to": "treasury", + "nonce": "pay_test", + "expiresAt": "2026-06-21T00:00:00Z", + "signature": "v1:ts:nonce:sig", + "metadata": {"domain": "tiny.place", "feePayer": "facilitator"}, + } + + envelope = build_x402_payment_envelope(authorization) + assert envelope["x402Version"] == 2 + assert envelope["accepted"]["payTo"] == "treasury" + assert envelope["payload"]["signature"] == "v1:ts:nonce:sig" + assert envelope["payload"]["authorization"]["value"] == "1000000" + assert envelope["payload"]["authorization"]["validBefore"] == "2026-06-21T00:00:00Z" + + header = encode_x402_payment_header(authorization) + decoded = json.loads(base64.b64decode(header)) + assert decoded == envelope diff --git a/sdk/rust/src/api/bounties.rs b/sdk/rust/src/api/bounties.rs index 014ac2ba..297bd276 100644 --- a/sdk/rust/src/api/bounties.rs +++ b/sdk/rust/src/api/bounties.rs @@ -4,8 +4,12 @@ //! free, run the autonomous council, and the admin-approved payout to the //! council-selected winner. -use crate::error::Result; +use crate::error::{Error, Result}; use crate::http::HttpClient; +use crate::solana::{ + build_delegated_payment_header_from_challenge, payment_challenge, + ChallengeDelegatedPaymentOptions, RpcRequest, +}; use crate::types::{ Bounty, BountyComment, BountyCommentCreateRequest, BountyCommentQueryParams, BountyCommentsResponse, BountyCreateRequest, BountyListResponse, BountyQueryParams, @@ -14,6 +18,28 @@ use crate::types::{ }; use crate::util::encode; +/// Options for funding a bounty through the delegated (gasless facilitator) +/// Solana settlement path. The fee payer and payment terms are read from the +/// 402 challenge; only the SPL transfer details and RPC transport are supplied. +pub struct SolanaBountyPaymentOptions { + /// The agent's Solana secret key (32-byte seed or 64-byte key); signs the + /// SPL `TransferChecked` as the transfer authority. + pub secret_key: Vec, + /// Token decimals (USDC/CASH = 6). Defaults to 6. + pub decimals: Option, + /// JSON-RPC transport for blockhash + token-account lookups. When omitted, a + /// direct reqwest transport against `rpc_url` is used. + pub rpc: Option, + /// Solana RPC URL used to build the default transport when `rpc` is unset. + pub rpc_url: Option, + /// Override the SPL mint (defaults to the challenge `asset`). + pub mint: Option, + /// Override the payer's source token account (defaults to the agent's ATA). + pub source_token_account: Option, + /// Override the payee's destination token account (defaults to its ATA). + pub destination_token_account: Option, +} + /// BountiesApi covers the bounty platform: create + fund in one flow, browse, /// submit, comment for free, run the autonomous council, and approve the winning /// payout. @@ -52,6 +78,58 @@ impl BountiesApi { .await } + /// Create and fund a bounty via the **standard x402 sponsored (gasless + /// facilitator)** Solana settlement path. Mirrors the TS/Python flow: the + /// first call (no `payment`) returns the 402 challenge, from which the + /// facilitator fee payer (`accepts[].extra.feePayer`, surfaced on the parsed + /// challenge as `metadata.feePayer`) and payment terms are read; this builds + /// the payer-signed `[ComputeUnitLimit, ComputeUnitPrice, TransferChecked]` + /// transaction (fee payer = facilitator, agent = transfer authority), wraps + /// it in the standard x402 `PaymentPayload` envelope, and re-posts the bounty + /// with the envelope in the `PAYMENT-SIGNATURE` header (no body `payment` + /// map) to settle into escrow. USDC-only. + pub async fn create_with_solana_payment( + &self, + request: &BountyCreateRequest, + options: SolanaBountyPaymentOptions, + ) -> Result { + // A signer is required for the directory-auth on the funded create call. + self.http + .signer() + .ok_or_else(|| Error::Signing("a signer is required for a Solana payment".into()))?; + let creator = request.creator.as_deref().unwrap_or(""); + + // First call without payment to receive the 402 challenge. + let challenge = match self.create(request).await { + Ok(bounty) => return Ok(bounty), + Err(error) => payment_challenge(error)?, + }; + + // Build the standard PAYMENT-SIGNATURE header from the challenge. + let (header_name, header_value) = build_delegated_payment_header_from_challenge( + &challenge, + ChallengeDelegatedPaymentOptions { + secret_key: options.secret_key, + decimals: options.decimals, + rpc: options.rpc, + rpc_url: options.rpc_url, + mint: options.mint, + source_token_account: options.source_token_account, + destination_token_account: options.destination_token_account, + }, + ) + .await?; + + // Re-post the bounty with the payment in the header and NO body + // `payment` map. + let mut funded = request.clone(); + funded.payment = None; + let headers: crate::auth::Headers = vec![(header_name, header_value)]; + self.http + .post_directory_auth_as_with_headers("/bounties", creator, Some(&funded), &headers) + .await + } + /// Cancel a bounty, signed as the creator. pub async fn cancel(&self, bounty_id: &str, creator: &str) -> Result { let body = serde_json::json!({}); diff --git a/sdk/rust/src/api/registry.rs b/sdk/rust/src/api/registry.rs index 373c6701..a7ca7494 100644 --- a/sdk/rust/src/api/registry.rs +++ b/sdk/rust/src/api/registry.rs @@ -1,8 +1,8 @@ //! Identity registry API. Mirrors `sdk/typescript/src/api/registry.ts`. //! -//! On-chain Solana registration helpers (`registerWithSolanaPayment` and -//! friends) are intentionally omitted from this Rust port; only the plain REST -//! methods are provided. +//! Includes the **delegated (gasless facilitator)** Solana registration flow +//! ([`RegistryApi::register_with_solana_payment`]); the direct on-chain +//! settlement helpers (`registerWithExistingSolanaPayment`) remain TS-only. use serde::Serialize; @@ -10,6 +10,10 @@ use crate::auth::sign_fresh_canonical_payload; use crate::crypto::{canonical_payload, crypto_id_to_public_key_base64}; use crate::error::Result; use crate::http::HttpClient; +use crate::solana::{ + build_delegated_payment_header_from_challenge, payment_challenge, + ChallengeDelegatedPaymentOptions, RpcRequest, +}; use crate::types::{ ActorType, AvailabilityResponse, Identity, IdentityClaimRequest, IdentityExport, IdentityTransferRequest, PaymentMethod, ProfileVisibility, ProfileVisibilityUpdate, @@ -48,6 +52,29 @@ pub struct RegisterRequest { pub signature: Option, } +/// Options for funding identity registration through the delegated (gasless +/// facilitator) Solana settlement path. The fee payer and payment terms are read +/// from the 402 challenge; only the SPL transfer details and RPC transport are +/// supplied. Identity registration is USDC-only. +pub struct SolanaRegistrationPaymentOptions { + /// The agent's Solana secret key (32-byte seed or 64-byte key); signs the + /// SPL `TransferChecked` as the transfer authority. + pub secret_key: Vec, + /// Token decimals (USDC = 6). Defaults to 6. + pub decimals: Option, + /// JSON-RPC transport for blockhash + token-account lookups. When omitted, a + /// direct reqwest transport against `rpc_url` is used. + pub rpc: Option, + /// Solana RPC URL used to build the default transport when `rpc` is unset. + pub rpc_url: Option, + /// Override the SPL mint (defaults to the challenge `asset`). + pub mint: Option, + /// Override the payer's source token account (defaults to the agent's ATA). + pub source_token_account: Option, + /// Override the payee's destination token account (defaults to its ATA). + pub destination_token_account: Option, +} + /// The identity registry: register, look up, and manage `@handle` names. #[derive(Clone)] pub struct RegistryApi { @@ -84,6 +111,72 @@ impl RegistryApi { .await } + /// Register a name funded via the **standard x402 sponsored (gasless + /// facilitator)** Solana settlement path. Mirrors the TS/Python flow: the + /// first call (no payment) returns the 402 challenge, from which the + /// facilitator fee payer (`accepts[].extra.feePayer`, surfaced on the parsed + /// challenge as `metadata.feePayer`) and payment terms are read; this builds + /// the payer-signed `[ComputeUnitLimit, ComputeUnitPrice, TransferChecked]` + /// transaction (fee payer = facilitator, agent = transfer authority), wraps + /// it in the standard x402 `PaymentPayload` envelope, and re-posts the signed + /// registration with the envelope in the `PAYMENT-SIGNATURE` header (no body + /// `payment` map) to settle the registration fee. USDC-only. + pub async fn register_with_solana_payment( + &self, + request: RegisterRequest, + options: SolanaRegistrationPaymentOptions, + ) -> Result { + // A signer is required to sign the registration body on the funded call. + let signer = self.http.signer().ok_or_else(|| { + crate::error::Error::Signing("a signer is required for a Solana payment".into()) + })?; + let mut request = request; + request.username = normalize_handle(&request.username); + if request.public_key.is_none() && !request.crypto_id.is_empty() { + request.public_key = Some(crypto_id_to_public_key_base64(&request.crypto_id)?); + } + // The registration payload is re-signed on each call, so the signature + // must not be carried across the challenge fetch. + request.signature = None; + + // First call without payment to receive the 402 challenge. + let challenge = match self.register(request.clone()).await { + Ok(identity) => return Ok(identity), + Err(error) => payment_challenge(error)?, + }; + + // Build the standard PAYMENT-SIGNATURE header from the challenge. + let (header_name, header_value) = build_delegated_payment_header_from_challenge( + &challenge, + ChallengeDelegatedPaymentOptions { + secret_key: options.secret_key, + decimals: options.decimals, + rpc: options.rpc, + rpc_url: options.rpc_url, + mint: options.mint, + source_token_account: options.source_token_account, + destination_token_account: options.destination_token_account, + }, + ) + .await?; + + // Re-post the signed registration with the payment in the header and NO + // body `payment` map. + let mut funded = request; + funded.payment = None; + let payload = registration_signature_payload(&funded); + funded.signature = Some(sign_fresh_canonical_payload(signer.as_ref(), &payload).await?); + + let headers: crate::auth::Headers = vec![(header_name, header_value)]; + self.http + .post_public_with_headers::( + "/registry/names", + Some(&funded), + &headers, + ) + .await + } + /// Look up a name's availability and (if taken) its identity. pub async fn get(&self, name: &str) -> Result { self.http diff --git a/sdk/rust/src/http.rs b/sdk/rust/src/http.rs index 626eb1d5..de7dfb6e 100644 --- a/sdk/rust/src/http.rs +++ b/sdk/rust/src/http.rs @@ -78,6 +78,11 @@ use crate::error::{Error, PaymentChallenge, PaymentRequiredChallenge, Result}; use crate::signer::Signer; use crate::websocket::{TinyPlaceWebSocket, WsAuth}; +/// The request header first-party SDKs send to identify themselves. +pub const SDK_CLIENT_HEADER: &str = "X-Tinyplace-SDK"; +/// Value of [`SDK_CLIENT_HEADER`] for this Rust SDK (`rust/`). +pub const SDK_CLIENT: &str = concat!("rust/", env!("CARGO_PKG_VERSION")); + /// A list of query parameters. Arrays are expressed as repeated keys. pub type Query = [(String, String)]; @@ -231,8 +236,12 @@ impl HttpClient { loop { // Re-sign on every attempt so retries carry a fresh timestamp/nonce // and are never rejected as a replay. - let mut headers: Headers = - vec![("Content-Type".to_string(), "application/json".to_string())]; + let mut headers: Headers = vec![ + ("Content-Type".to_string(), "application/json".to_string()), + // Identify this first-party SDK so the backend serves the legacy + // x402 challenge shape during the standardization migration. + (SDK_CLIENT_HEADER.to_string(), SDK_CLIENT.to_string()), + ]; headers.extend(extra_headers.iter().cloned()); self.apply_auth( &mut headers, @@ -594,6 +603,45 @@ impl HttpClient { .await } + /// POST without backend auth, threading `extra_headers` (e.g. the standard + /// x402 `PAYMENT-SIGNATURE` payment header) and parsing the response body. + pub async fn post_public_with_headers( + &self, + path: &str, + body: Option<&B>, + headers: &Headers, + ) -> Result { + let body = Self::body_string(body)?; + let response = self + .execute(Method::POST, path, &[], body, Auth::None, None, headers) + .await?; + self.parse(response).await + } + + /// POST with directory auth as `actor`, threading `extra_headers` (e.g. the + /// standard x402 `PAYMENT-SIGNATURE` payment header) and parsing the response. + pub async fn post_directory_auth_as_with_headers( + &self, + path: &str, + actor: &str, + body: Option<&B>, + headers: &Headers, + ) -> Result { + let body = Self::body_string(body)?; + let response = self + .execute( + Method::POST, + path, + &[], + body, + Auth::Directory, + Some(actor), + headers, + ) + .await?; + self.parse(response).await + } + pub async fn post_agent_auth( &self, path: &str, @@ -881,18 +929,75 @@ fn payment_required_from_body(body: &serde_json::Value) -> Option Option { + let error = value + .get("error") + .and_then(|v| v.as_str()) + .map(str::to_string); + + // Prefer the standard x402 v2 accepts[] array. tiny.place advertises the + // payer-binding fields (from/nonce/expiresAt) under accepts[].extra so the + // challenge can be rebuilt from accepts[] alone. + if let Some(payment) = payment_challenge_from_accepts(value) { + return Some(PaymentRequiredChallenge { error, payment }); + } + + // Legacy fallback: the non-standard top-level `payment` object. Removed in + // Phase 2 once the standard accepts[] path is the only one in the field. let payment = value.get("payment")?; if !payment.is_object() { return None; } let payment: PaymentChallenge = serde_json::from_value(payment.clone()).ok()?; - let error = value - .get("error") - .and_then(|v| v.as_str()) - .map(str::to_string); Some(PaymentRequiredChallenge { error, payment }) } +/// Map the first standard x402 v2 `accepts[]` entry onto a [`PaymentChallenge`]. +/// The binding fields (`from`/`nonce`/`expiresAt`) are promoted out of `extra`; +/// the rest of `extra` becomes the signed metadata, mirroring the legacy shape. +fn payment_challenge_from_accepts(value: &serde_json::Value) -> Option { + let entry = value.get("accepts")?.as_array()?.first()?; + if !entry.is_object() { + return None; + } + + let string_field = |key: &str| entry.get(key).and_then(|v| v.as_str()).map(str::to_string); + let extra = entry.get("extra").and_then(|v| v.as_object()); + let extra_field = |key: &str| { + extra + .and_then(|map| map.get(key)) + .and_then(|v| v.as_str()) + .map(str::to_string) + }; + + let binding_keys = ["from", "nonce", "expiresAt"]; + let metadata: HashMap = extra + .map(|map| { + map.iter() + .filter(|(key, _)| !binding_keys.contains(&key.as_str())) + .filter_map(|(key, raw)| raw.as_str().map(|v| (key.clone(), v.to_string()))) + .collect() + }) + .unwrap_or_default(); + + Some(PaymentChallenge { + scheme: string_field("scheme"), + network: string_field("network"), + asset: string_field("asset"), + amount: string_field("amount"), + // accepts[] names the recipient `payTo`; the challenge calls it `to`. + to: string_field("payTo"), + from: extra_field("from"), + nonce: extra_field("nonce"), + expires_at: extra_field("expiresAt"), + signature: None, + metadata: if metadata.is_empty() { + None + } else { + Some(metadata) + }, + }) +} + fn base64_url_decode(value: &str) -> Option> { base64::engine::general_purpose::URL_SAFE_NO_PAD .decode(value) diff --git a/sdk/rust/src/lib.rs b/sdk/rust/src/lib.rs index 350e88ff..86c2a778 100644 --- a/sdk/rust/src/lib.rs +++ b/sdk/rust/src/lib.rs @@ -30,6 +30,7 @@ pub mod error; pub mod http; pub mod signal; pub mod signer; +pub mod solana; pub mod util; pub mod validation; pub mod websocket; @@ -44,7 +45,9 @@ pub const SDK_VERSION: &str = "0.1.0"; pub use client::{TinyPlaceClient, TinyPlaceClientOptions}; pub use error::{Error, PaymentChallenge, PaymentRequiredChallenge, Result}; -pub use http::{HttpClient, HttpClientOptions, RetryOptions, DEFAULT_TIMEOUT}; +pub use http::{ + HttpClient, HttpClientOptions, RetryOptions, DEFAULT_TIMEOUT, SDK_CLIENT, SDK_CLIENT_HEADER, +}; pub use signer::{LocalSigner, Signer}; pub use websocket::{TinyPlaceWebSocket, WebSocketConnection, WsAuth}; @@ -53,7 +56,17 @@ pub use assets::{ SOLANA_NATIVE_ASSET, SOLANA_USDC_MINT, SOLANA_WSOL_MINT, }; pub use auth::AdminSigningOptions; +pub use solana::{ + associated_token_account, build_delegated_payment_header_from_challenge, + build_delegated_x402_envelope, build_delegated_x402_payment_header, + build_payer_signed_delegated_tx, default_rpc_request, encode_delegated_x402_payment_header, + find_token_account, ChallengeDelegatedPaymentOptions, DelegatedX402PaymentHeaderOptions, + PayerSignedDelegatedTxOptions, RpcRequest, FACILITATOR_COMPUTE_UNIT_LIMIT, + FACILITATOR_COMPUTE_UNIT_PRICE_MICRO_LAMPORTS, SOLANA_COMPUTE_BUDGET_PROGRAM_ID, + SOLANA_MAINNET_NETWORK, SOLANA_SYSTEM_PROGRAM_ID, SOLANA_TOKEN_PROGRAM_ID, +}; pub use x402::{ - build_x402_payment_authorization, build_x402_payment_map, sign_x402_authorization, - X402Authorization, X402AuthorizationFields, X402PaymentAuthorizationOptions, X402PaymentMap, + build_x402_payment_authorization, build_x402_payment_envelope, build_x402_payment_map, + encode_x402_payment_header, sign_x402_authorization, X402Authorization, + X402AuthorizationFields, X402PaymentAuthorizationOptions, X402PaymentMap, X402_PAYMENT_HEADER, }; diff --git a/sdk/rust/src/signer.rs b/sdk/rust/src/signer.rs index ef57d468..ad88deef 100644 --- a/sdk/rust/src/signer.rs +++ b/sdk/rust/src/signer.rs @@ -60,7 +60,9 @@ impl LocalSigner { signing_key, siws_token: None, }; - signer.siws_token = Some(signer.mint_siws()); + // Mint once at construction; the proof is cached and reused on every + // authenticated request until it is explicitly re-minted. + signer.siws_token = Some(signer.build_siws()); signer } @@ -70,9 +72,19 @@ impl LocalSigner { self } - /// Mint (or re-mint) the reusable SIWS ownership proof token by signing a - /// Sign-In With Solana message with this key. - pub fn mint_siws(&self) -> String { + /// Mint a fresh SIWS proof, cache it on the signer, and return it. The cached + /// token is what authenticated requests reuse, so call this to rotate the + /// proof (e.g. before it expires). Mirrors the Python SDK's `mint_siws`. + pub fn mint_siws(&mut self) -> String { + let token = self.build_siws(); + self.siws_token = Some(token.clone()); + token + } + + /// Build a fresh SIWS ownership proof token by signing a Sign-In With Solana + /// message with this key. Does not touch the cache; use [`mint_siws`] to + /// rotate the cached token. + fn build_siws(&self) -> String { let issued_at = Utc::now(); let expires_at = issued_at + Duration::days(7); let nonce: [u8; 16] = rand::random(); diff --git a/sdk/rust/src/solana.rs b/sdk/rust/src/solana.rs new file mode 100644 index 00000000..17d508b5 --- /dev/null +++ b/sdk/rust/src/solana.rs @@ -0,0 +1,812 @@ +//! Delegated (gasless facilitator) x402 Solana settlement. Mirrors +//! `sdk/typescript/src/solana.ts` (`buildPayerSignedDelegatedTx` / +//! `buildDelegatedX402PaymentMap`). +//! +//! The Rust SDK does not depend on the heavy `solana-sdk` crate; the legacy +//! transaction wire format (shortvec length prefixes, message header, account +//! keys, blockhash, compiled instructions) is hand-rolled here, reusing the +//! Ed25519 signer, `bs58`, and `sha2` already pulled in for auth. The backend +//! decodes these legacy transactions by hand too. +//! +//! The "delegated" transaction is the standard x402 *exact*-scheme Solana +//! payment: instructions `[SetComputeUnitLimit, SetComputeUnitPrice, +//! TransferChecked]`, account 0 (the fee payer) is the **facilitator** (CDP / +//! PayAI) and the **payer** signs only as the SPL `TransferChecked` authority +//! (a read-only second signer). The fee-payer signature slot is left zeroed for +//! the backend to co-sign and broadcast at settle time — the agent never pays +//! the network fee. Only USDC/CASH-style SPL transfers go through this path. + +use std::future::Future; +use std::pin::Pin; +use std::sync::Arc; + +use base64::Engine as _; +use ed25519_dalek::{Signer as _, SigningKey as DalekSigningKey}; +use serde_json::json; + +use crate::crypto::{decode_base58, to_base64}; +use crate::error::{Error, PaymentChallenge, Result}; +use crate::x402::X402_PAYMENT_HEADER; + +/// Canonical mainnet Solana network id (the `solana:` form). +pub const SOLANA_MAINNET_NETWORK: &str = "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp"; +/// The SPL Token program. +pub const SOLANA_TOKEN_PROGRAM_ID: &str = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"; +/// The System program (native SOL transfers). +pub const SOLANA_SYSTEM_PROGRAM_ID: &str = "11111111111111111111111111111111"; +/// The ComputeBudget program (sets the compute unit limit + price). +pub const SOLANA_COMPUTE_BUDGET_PROGRAM_ID: &str = "ComputeBudget111111111111111111111111111111"; +/// The Associated Token Account program (deterministic ATA derivation). +pub const SOLANA_ASSOCIATED_TOKEN_PROGRAM_ID: &str = "ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL"; + +/// Default compute unit limit for the facilitator transfer (matches the web app). +pub const FACILITATOR_COMPUTE_UNIT_LIMIT: u32 = 40_000; +/// Default compute unit price in microlamports/CU (well under the 5,000,000 cap). +pub const FACILITATOR_COMPUTE_UNIT_PRICE_MICRO_LAMPORTS: u64 = 1; + +/// An async JSON-RPC callback: `(method, params) -> result`. Lets callers route +/// blockhash + token-account lookups through their own transport (e.g. the +/// backend's `/solana/rpc` proxy). [`default_rpc_request`] provides a direct +/// reqwest-backed implementation against a Solana RPC URL. +pub type RpcRequest = Arc< + dyn Fn( + String, + serde_json::Value, + ) -> Pin> + Send>> + + Send + + Sync, +>; + +/// Options for [`build_payer_signed_delegated_tx`]. +pub struct PayerSignedDelegatedTxOptions { + /// The facilitator's fee-payer pubkey (the 402 challenge `metadata.feePayer`). + pub fee_payer: String, + /// The payee/recipient owner address (the challenge `to` / `payTo`). + pub payee: String, + /// Amount in the asset's base units (a positive integer string). + pub amount: String, + /// The SPL mint to transfer. + pub mint: String, + /// Token decimals (USDC/CASH = 6). + pub decimals: u8, + /// The agent's Solana secret key (32-byte seed or 64-byte key); signs as the + /// transfer authority. + pub secret_key: Vec, + /// Overrides the payer's source token account (defaults to the agent's ATA). + pub source_token_account: Option, + /// Overrides the payee's destination token account (defaults to its ATA). + pub destination_token_account: Option, + /// Override the compute unit limit (defaults to [`FACILITATOR_COMPUTE_UNIT_LIMIT`]). + pub compute_unit_limit: Option, + /// Override the compute unit price (defaults to + /// [`FACILITATOR_COMPUTE_UNIT_PRICE_MICRO_LAMPORTS`]). + pub compute_unit_price_micro_lamports: Option, + /// A recent blockhash. When `None` it is fetched via `rpc`. + pub recent_blockhash: Option, + /// JSON-RPC transport for blockhash + ATA lookups. Required unless + /// `recent_blockhash` and both token accounts are supplied. + pub rpc: Option, +} + +/// Builds the standard x402 "exact" Solana payment for an autonomous agent and +/// partially signs it with the agent's keypair — the SDK counterpart to the web +/// app's wallet-signed builder. The transaction is +/// `[SetComputeUnitLimit, SetComputeUnitPrice, TransferChecked]` with the +/// facilitator as fee payer (account 0) and the agent as the transfer authority +/// (a read-only second signer). Only the agent signature is filled; the +/// fee-payer signature slot is left zeroed for the facilitator to co-sign and +/// broadcast at settle time. Returns the base64 wire transaction to carry in the +/// standard x402 `PaymentPayload` envelope's `payload.transaction`. +/// +/// The payee's destination token account must already exist — the exact scheme +/// forbids ATA creation in the payment transaction. +pub async fn build_payer_signed_delegated_tx( + options: PayerSignedDelegatedTxOptions, +) -> Result { + let signing_key = signing_key_from_secret(&options.secret_key)?; + let payer = bs58::encode(signing_key.verifying_key().to_bytes()).into_string(); + let amount = normalized_amount(&options.amount)?; + + let source_token_account = match options.source_token_account.clone() { + Some(account) => account, + None => associated_token_account(&payer, &options.mint)?, + }; + let destination_token_account = match options.destination_token_account.clone() { + Some(account) => account, + None => associated_token_account(&options.payee, &options.mint)?, + }; + + let recent_blockhash = match options.recent_blockhash.clone() { + Some(blockhash) => blockhash, + None => { + let rpc = options.rpc.clone().ok_or_else(|| { + Error::InvalidArgument( + "a recent_blockhash or rpc transport is required".to_string(), + ) + })?; + fetch_latest_blockhash(&rpc).await? + } + }; + + let message = two_signer_facilitator_message(&FacilitatorMessage { + fee_payer: &options.fee_payer, + authority: &payer, + source_token_account: &source_token_account, + destination_token_account: &destination_token_account, + mint: &options.mint, + amount, + decimals: options.decimals, + compute_unit_limit: options + .compute_unit_limit + .unwrap_or(FACILITATOR_COMPUTE_UNIT_LIMIT), + compute_unit_price_micro_lamports: options + .compute_unit_price_micro_lamports + .unwrap_or(FACILITATOR_COMPUTE_UNIT_PRICE_MICRO_LAMPORTS), + recent_blockhash: &recent_blockhash, + })?; + + // Sign as the authority (signer index 1). The fee-payer slot (index 0) is + // left zeroed for the facilitator to fill at settle time. + let authority_signature = signing_key.sign(&message).to_bytes(); + let mut wire = Vec::with_capacity(2 + 64 + 64 + message.len()); + wire.extend_from_slice(&short_vec(2)); + wire.extend_from_slice(&[0u8; 64]); // empty fee-payer signature + wire.extend_from_slice(&authority_signature); + wire.extend_from_slice(&message); + Ok(to_base64(&wire)) +} + +/// Build the standard x402 `PaymentPayload` envelope (`x402Version` 2) for a +/// sponsored SPL transfer. The partially-signed transaction travels in +/// `payload.transaction`; the fee payer is advertised in `accepted.extra.feePayer`. +/// `asset` is the on-chain SPL mint (base58), not a symbol. +pub fn build_delegated_x402_envelope( + network: &str, + amount: &str, + asset_mint: &str, + pay_to: &str, + fee_payer: &str, + transaction: &str, +) -> serde_json::Value { + json!({ + "x402Version": 2, + "accepted": { + "scheme": "exact", + "network": network, + "amount": amount, + "asset": asset_mint, + "payTo": pay_to, + "maxTimeoutSeconds": 60, + "extra": { "feePayer": fee_payer }, + }, + "payload": { "transaction": transaction }, + }) +} + +/// Encode a standard x402 `PaymentPayload` envelope as the +/// [`X402_PAYMENT_HEADER`] (`PAYMENT-SIGNATURE`) header value: standard base64 +/// (with padding) of the UTF-8 JSON. +pub fn encode_delegated_x402_payment_header(envelope: &serde_json::Value) -> String { + let raw = serde_json::to_vec(envelope).expect("envelope serialization cannot fail"); + base64::engine::general_purpose::STANDARD.encode(raw) +} + +/// Options for [`build_delegated_x402_payment_header`]. +pub struct DelegatedX402PaymentHeaderOptions { + /// The facilitator's fee-payer pubkey (the 402 challenge `metadata.feePayer`). + pub fee_payer: String, + /// The SPL mint to transfer. + pub mint: String, + /// Token decimals (USDC/CASH = 6). + pub decimals: u8, + /// The agent's Solana secret key (32-byte seed or 64-byte key). + pub secret_key: Vec, + pub source_token_account: Option, + pub destination_token_account: Option, + pub compute_unit_limit: Option, + pub compute_unit_price_micro_lamports: Option, + pub recent_blockhash: Option, + pub rpc: Option, + /// The x402 network id (`solana:`). + pub network: String, + /// The SPL mint advertised as the envelope `asset` (defaults to `mint`). + pub asset: Option, + /// Amount in base units (string). + pub amount: String, + /// The recipient (`payTo`). + pub to: String, +} + +/// Convenience wrapper: builds the agent-signed facilitator transfer and folds +/// it into the standard x402 `PaymentPayload` envelope, returning the +/// `(header_name, header_value)` pair to attach to the paid endpoint POST. The +/// backend reads the standard `PAYMENT-SIGNATURE` header and routes the +/// transaction to the facilitator; no body `payment` map is sent. +pub async fn build_delegated_x402_payment_header( + options: DelegatedX402PaymentHeaderOptions, +) -> Result<(String, String)> { + let transaction = build_payer_signed_delegated_tx(PayerSignedDelegatedTxOptions { + fee_payer: options.fee_payer.clone(), + payee: options.to.clone(), + amount: options.amount.clone(), + mint: options.mint.clone(), + decimals: options.decimals, + secret_key: options.secret_key, + source_token_account: options.source_token_account, + destination_token_account: options.destination_token_account, + compute_unit_limit: options.compute_unit_limit, + compute_unit_price_micro_lamports: options.compute_unit_price_micro_lamports, + recent_blockhash: options.recent_blockhash, + rpc: options.rpc, + }) + .await?; + + let asset = options.asset.unwrap_or_else(|| options.mint.clone()); + let envelope = build_delegated_x402_envelope( + &options.network, + &options.amount, + &asset, + &options.to, + &options.fee_payer, + &transaction, + ); + Ok(( + X402_PAYMENT_HEADER.to_string(), + encode_delegated_x402_payment_header(&envelope), + )) +} + +/// A direct reqwest-backed [`RpcRequest`] against a Solana JSON-RPC URL. Mirrors +/// the TS/Python SDK's built-in transport. +pub fn default_rpc_request(rpc_url: impl Into) -> RpcRequest { + let rpc_url = rpc_url.into(); + let client = reqwest::Client::new(); + Arc::new(move |method: String, params: serde_json::Value| { + let rpc_url = rpc_url.clone(); + let client = client.clone(); + Box::pin(async move { + let body = json!({ + "jsonrpc": "2.0", + "id": method, + "method": method, + "params": params, + }); + let response = client.post(&rpc_url).json(&body).send().await?; + if !response.status().is_success() { + return Err(Error::Rpc(format!( + "Solana RPC {method} failed with HTTP {}", + response.status().as_u16() + ))); + } + let payload: serde_json::Value = response.json().await?; + if let Some(error) = payload.get("error") { + let message = error + .get("message") + .and_then(|m| m.as_str()) + .map(|s| s.to_string()) + .unwrap_or_else(|| error.to_string()); + return Err(Error::Rpc(format!("Solana RPC {method} failed: {message}"))); + } + payload + .get("result") + .cloned() + .ok_or_else(|| Error::Rpc(format!("Solana RPC {method} returned no result"))) + }) as Pin> + Send>> + }) +} + +/// Resolve `owner`'s token account for `mint` over the RPC transport, returning +/// the first account holding at least `minimum_amount` (when set). Mirrors the +/// TS/Python `findTokenAccount`. The delegated builder uses the deterministic +/// ATA instead, but this is exported for callers that need an existing account. +pub async fn find_token_account( + rpc: &RpcRequest, + owner: &str, + mint: &str, + minimum_amount: Option<&str>, +) -> Result { + let params = json!([ + owner, + { "mint": mint }, + { "encoding": "jsonParsed", "commitment": "confirmed" }, + ]); + let result = rpc("getTokenAccountsByOwner".to_string(), params).await?; + let minimum: Option = minimum_amount.and_then(|m| m.parse().ok()); + let accounts = result + .get("value") + .and_then(|v| v.as_array()) + .cloned() + .unwrap_or_default(); + for account in accounts { + let amount = account + .pointer("/account/data/parsed/info/tokenAmount/amount") + .and_then(|v| v.as_str()) + .unwrap_or("0") + .parse::() + .unwrap_or(0); + if minimum.is_none_or(|min| amount >= min) { + if let Some(pubkey) = account.get("pubkey").and_then(|v| v.as_str()) { + return Ok(pubkey.to_string()); + } + } + } + Err(Error::Rpc(format!("No token account found for {owner}"))) +} + +async fn fetch_latest_blockhash(rpc: &RpcRequest) -> Result { + let params = json!([{ "commitment": "confirmed" }]); + let result = rpc("getLatestBlockhash".to_string(), params).await?; + result + .pointer("/value/blockhash") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + .ok_or_else(|| Error::Rpc("getLatestBlockhash returned no blockhash".to_string())) +} + +/// Derive the associated token account (ATA) for `owner` + `mint` under the +/// SPL Associated Token program, by `find_program_address` over the seeds +/// `[owner, TOKEN_PROGRAM, mint]`. +pub fn associated_token_account(owner: &str, mint: &str) -> Result { + let owner_bytes = decode_pubkey(owner)?; + let token_program = decode_pubkey(SOLANA_TOKEN_PROGRAM_ID)?; + let mint_bytes = decode_pubkey(mint)?; + let program = decode_pubkey(SOLANA_ASSOCIATED_TOKEN_PROGRAM_ID)?; + let seeds = [ + owner_bytes.as_slice(), + token_program.as_slice(), + mint_bytes.as_slice(), + ]; + let (address, _bump) = find_program_address(&seeds, &program)?; + Ok(bs58::encode(address).into_string()) +} + +/// Find a valid program-derived address (PDA) for `seeds` under `program_id`, +/// returning `(address, bump)`. Hand-rolled to avoid the `solana-sdk` crate. +fn find_program_address(seeds: &[&[u8]], program_id: &[u8; 32]) -> Result<([u8; 32], u8)> { + use sha2::{Digest, Sha256}; + for bump in (0u8..=255).rev() { + let mut hasher = Sha256::new(); + for seed in seeds { + hasher.update(seed); + } + hasher.update([bump]); + hasher.update(program_id); + hasher.update(b"ProgramDerivedAddress"); + let hash: [u8; 32] = hasher.finalize().into(); + if !is_on_curve(&hash) { + return Ok((hash, bump)); + } + } + Err(Error::InvalidArgument( + "unable to find a program-derived address (no off-curve bump)".to_string(), + )) +} + +/// True when `bytes` is a valid Ed25519 curve point (i.e. NOT a valid PDA). A +/// PDA must be off-curve. Uses `curve25519-dalek` (already a dependency). +fn is_on_curve(bytes: &[u8; 32]) -> bool { + curve25519_dalek::edwards::CompressedEdwardsY(*bytes) + .decompress() + .is_some() +} + +struct FacilitatorMessage<'a> { + fee_payer: &'a str, + authority: &'a str, + source_token_account: &'a str, + destination_token_account: &'a str, + mint: &'a str, + amount: u64, + decimals: u8, + compute_unit_limit: u32, + compute_unit_price_micro_lamports: u64, + recent_blockhash: &'a str, +} + +/// Serialize a two-signer legacy message for the facilitator transfer. Account +/// ordering follows Solana's rules: writable signers, then read-only signers, +/// then writable non-signers, then read-only non-signers. The fee payer must be +/// account 0; the transfer authority is a read-only signer at index 1. +fn two_signer_facilitator_message(options: &FacilitatorMessage<'_>) -> Result> { + // 0: feePayer (writable signer), 1: authority (read-only signer), + // 2: source, 3: destination (writable non-signers), + // 4: mint, 5: token program, 6: compute budget program (read-only non-signers). + let account_keys = [ + options.fee_payer, + options.authority, + options.source_token_account, + options.destination_token_account, + options.mint, + SOLANA_TOKEN_PROGRAM_ID, + SOLANA_COMPUTE_BUDGET_PROGRAM_ID, + ]; + // Header: 2 required signatures, 0 readonly-signed accounts? No — the + // authority is a read-only SIGNED account, so readonly-signed = 1; the last + // three keys are readonly-unsigned = 3. + let header = [2u8, 1u8, 3u8]; + + // SetComputeUnitLimit: u8 discriminant (2) + u32 LE limit. + let mut compute_limit_data = Vec::with_capacity(5); + compute_limit_data.push(2u8); + compute_limit_data.extend_from_slice(&options.compute_unit_limit.to_le_bytes()); + // SetComputeUnitPrice: u8 discriminant (3) + u64 LE microlamports. + let mut compute_price_data = Vec::with_capacity(9); + compute_price_data.push(3u8); + compute_price_data.extend_from_slice(&options.compute_unit_price_micro_lamports.to_le_bytes()); + // TransferChecked: u8 discriminant (12) + u64 LE amount + u8 decimals. + let mut transfer_data = Vec::with_capacity(10); + transfer_data.push(12u8); + transfer_data.extend_from_slice(&options.amount.to_le_bytes()); + transfer_data.push(options.decimals); + + let blockhash = decode_blockhash(options.recent_blockhash)?; + + let mut message = Vec::new(); + message.extend_from_slice(&header); + message.extend_from_slice(&short_vec(account_keys.len() as u32)); + for key in account_keys { + message.extend_from_slice(&decode_pubkey(key)?); + } + message.extend_from_slice(&blockhash); + // Three instructions. + message.extend_from_slice(&short_vec(3)); + // ComputeBudget SetComputeUnitLimit (program index 6, no accounts). + message.push(6); + message.extend_from_slice(&short_vec(0)); + message.extend_from_slice(&short_vec(compute_limit_data.len() as u32)); + message.extend_from_slice(&compute_limit_data); + // ComputeBudget SetComputeUnitPrice (program index 6, no accounts). + message.push(6); + message.extend_from_slice(&short_vec(0)); + message.extend_from_slice(&short_vec(compute_price_data.len() as u32)); + message.extend_from_slice(&compute_price_data); + // Token TransferChecked (program index 5): source, mint, dest, authority. + message.push(5); + message.extend_from_slice(&short_vec(4)); + message.extend_from_slice(&[2u8, 4u8, 3u8, 1u8]); + message.extend_from_slice(&short_vec(transfer_data.len() as u32)); + message.extend_from_slice(&transfer_data); + Ok(message) +} + +/// Encode a length as a Solana shortvec / compact-u16 (little-endian base-128). +fn short_vec(value: u32) -> Vec { + let mut bytes = Vec::new(); + let mut current = value; + loop { + let mut byte = (current & 0x7f) as u8; + current >>= 7; + if current > 0 { + byte |= 0x80; + } + bytes.push(byte); + if current == 0 { + break; + } + } + bytes +} + +/// Decode a base58 pubkey into exactly 32 bytes. +fn decode_pubkey(value: &str) -> Result<[u8; 32]> { + let bytes = decode_base58(value) + .map_err(|err| Error::InvalidArgument(format!("invalid base58 pubkey {value}: {err}")))?; + bytes.as_slice().try_into().map_err(|_| { + Error::InvalidArgument(format!( + "pubkey {value} does not decode to 32 bytes (got {})", + bytes.len() + )) + }) +} + +/// Decode a base58 blockhash into exactly 32 bytes. +fn decode_blockhash(value: &str) -> Result<[u8; 32]> { + let bytes = decode_base58(value) + .map_err(|err| Error::InvalidArgument(format!("invalid base58 blockhash: {err}")))?; + bytes.as_slice().try_into().map_err(|_| { + Error::InvalidArgument(format!( + "blockhash does not decode to 32 bytes (got {})", + bytes.len() + )) + }) +} + +fn signing_key_from_secret(secret: &[u8]) -> Result { + if secret.len() != 32 && secret.len() != 64 { + return Err(Error::InvalidArgument(format!( + "Solana secret key must be 32 or 64 bytes, got {}", + secret.len() + ))); + } + let mut seed = [0u8; 32]; + seed.copy_from_slice(&secret[..32]); + let signing_key = DalekSigningKey::from_bytes(&seed); + if secret.len() == 64 && signing_key.verifying_key().to_bytes() != secret[32..] { + return Err(Error::InvalidArgument( + "Solana secret key public key does not match seed".to_string(), + )); + } + Ok(signing_key) +} + +fn normalized_amount(amount: &str) -> Result { + let trimmed = amount.trim(); + let value: u64 = trimmed.parse().map_err(|_| { + Error::InvalidArgument(format!( + "Solana payment amount must be an integer: {amount}" + )) + })?; + if value == 0 { + return Err(Error::InvalidArgument(format!( + "Solana payment amount must be a positive integer: {amount}" + ))); + } + Ok(value) +} + +/// Options bridging a parsed 402 [`PaymentChallenge`] to the standard x402 +/// `PAYMENT-SIGNATURE` header: only the SPL transfer secret + RPC transport are +/// needed, since the fee payer, amount, recipient, asset, and network come from +/// the challenge. +pub struct ChallengeDelegatedPaymentOptions { + /// The agent's Solana secret key (32-byte seed or 64-byte key). + pub secret_key: Vec, + /// Token decimals (USDC/CASH = 6). Defaults to 6. + pub decimals: Option, + /// JSON-RPC transport for blockhash + token-account lookups. When omitted, a + /// direct reqwest transport against `rpc_url` is used. + pub rpc: Option, + /// Solana RPC URL used to build the default transport when `rpc` is unset. + pub rpc_url: Option, + /// Override the SPL mint (defaults to the challenge `asset`). + pub mint: Option, + pub source_token_account: Option, + pub destination_token_account: Option, +} + +/// Build the standard x402 `PAYMENT-SIGNATURE` header `(name, value)` from a +/// parsed 402 [`PaymentChallenge`]. Reads the facilitator fee payer from +/// `metadata.feePayer` (equivalently `accepts[].extra.feePayer`), and the +/// recipient/amount/asset(mint)/network from the challenge; signs the SPL +/// `TransferChecked` as the transfer authority and wraps the partially-signed +/// transaction in the standard `PaymentPayload` envelope. No body `payment` map +/// is produced. Shared by the bounty + registry Solana-payment flows. +pub async fn build_delegated_payment_header_from_challenge( + challenge: &PaymentChallenge, + options: ChallengeDelegatedPaymentOptions, +) -> Result<(String, String)> { + let metadata = challenge.metadata.clone().unwrap_or_default(); + let fee_payer = metadata.get("feePayer").cloned().ok_or_else(|| { + Error::InvalidArgument( + "402 challenge is missing metadata.feePayer (required for delegated settlement)" + .to_string(), + ) + })?; + let amount = challenge + .amount + .clone() + .ok_or_else(|| Error::InvalidArgument("402 challenge is missing an amount".to_string()))?; + let to = challenge.to.clone().ok_or_else(|| { + Error::InvalidArgument("402 challenge is missing a recipient".to_string()) + })?; + let network = challenge + .network + .clone() + .unwrap_or_else(|| SOLANA_MAINNET_NETWORK.to_string()); + let mint = options + .mint + .clone() + .or_else(|| challenge.asset.clone()) + .unwrap_or_default(); + + let rpc = options + .rpc + .clone() + .or_else(|| options.rpc_url.clone().map(default_rpc_request)); + + build_delegated_x402_payment_header(DelegatedX402PaymentHeaderOptions { + fee_payer, + mint: mint.clone(), + decimals: options.decimals.unwrap_or(6), + secret_key: options.secret_key, + source_token_account: options.source_token_account, + destination_token_account: options.destination_token_account, + compute_unit_limit: None, + compute_unit_price_micro_lamports: None, + recent_blockhash: None, + rpc, + network, + // The envelope `asset` must be the on-chain SPL mint used to build the + // tx, not a symbol. + asset: Some(mint), + amount, + to, + }) + .await +} + +/// Extract the x402 [`PaymentChallenge`] from a `402` error, surfacing any other +/// error unchanged. Used to drive the challenge → delegated-payment → resubmit +/// flow. +pub fn payment_challenge(error: Error) -> Result { + if error.status() == Some(402) { + if let Some(required) = error.payment_required() { + return Ok(required.payment.clone()); + } + } + Err(error) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::crypto::from_base64; + use crate::signer::LocalSigner; + + /// A fixed 32-byte seed so the wire bytes are deterministic. + fn test_signer() -> LocalSigner { + LocalSigner::from_seed(&[7u8; 32]).expect("seed") + } + + /// Decode a shortvec from `bytes` at `offset`, returning `(value, new_offset)`. + fn read_short_vec(bytes: &[u8], mut offset: usize) -> (u32, usize) { + let mut value: u32 = 0; + let mut shift = 0; + loop { + let byte = bytes[offset]; + offset += 1; + value |= ((byte & 0x7f) as u32) << shift; + if byte & 0x80 == 0 { + break; + } + shift += 7; + } + (value, offset) + } + + #[test] + fn ata_derivation_matches_known_vector() { + // ATA for owner 9WzD…AWWM + USDC mint under the SPL Associated Token + // program. Cross-checked with an independent ed25519 find_program_address + // reference (bump 254) and consistent with @solana/spl-token. + let owner = "9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM"; + let mint = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"; + let ata = associated_token_account(owner, mint).expect("ata"); + assert_eq!(ata, "FGETo8T8wMcN2wCjav8VK6eh3dLk63evNDPxzLSJra8B"); + } + + #[tokio::test] + async fn delegated_payment_header_carries_standard_envelope_and_decodes() { + let signer = test_signer(); + let fee_payer = "GThUX1Atko4tqhN2NaiTazWSeFWMuiUvfFnyJyUghFMJ"; // arbitrary + let payee = "9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM"; + let mint = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"; + // A valid base58 32-byte blockhash (reuse a pubkey-shaped value), + // served by a stub RPC so the build stays offline + deterministic. + let blockhash = "11111111111111111111111111111111"; + let rpc: RpcRequest = Arc::new(move |method: String, _params| { + Box::pin(async move { + assert_eq!(method, "getLatestBlockhash"); + Ok(json!({ "value": { "blockhash": blockhash } })) + }) as Pin> + Send>> + }); + + // Build the challenge → standard PAYMENT-SIGNATURE header, mirroring the + // register/bounty flows. The fee payer is read from metadata.feePayer. + let mut metadata = std::collections::HashMap::new(); + metadata.insert("feePayer".to_string(), fee_payer.to_string()); + let challenge = PaymentChallenge { + network: Some(SOLANA_MAINNET_NETWORK.to_string()), + asset: Some(mint.to_string()), + amount: Some("1000000".to_string()), + to: Some(payee.to_string()), + metadata: Some(metadata), + ..Default::default() + }; + + let (header_name, header_value) = build_delegated_payment_header_from_challenge( + &challenge, + ChallengeDelegatedPaymentOptions { + secret_key: signer.seed().to_vec(), + decimals: Some(6), + rpc: Some(rpc), + rpc_url: None, + mint: None, + source_token_account: None, + destination_token_account: None, + }, + ) + .await + .expect("payment header"); + + // Submitted via the standard x402 PAYMENT-SIGNATURE header. + assert_eq!(header_name, X402_PAYMENT_HEADER); + assert_eq!(header_name, "PAYMENT-SIGNATURE"); + + // The header is standard padded base64 of the UTF-8 JSON envelope. + let envelope_bytes = base64::engine::general_purpose::STANDARD + .decode(&header_value) + .expect("standard padded base64"); + let envelope: serde_json::Value = + serde_json::from_slice(&envelope_bytes).expect("utf-8 json envelope"); + + // The standard PaymentPayload envelope (x402Version 2, exact scheme). + assert_eq!(envelope["x402Version"], 2); + assert_eq!(envelope["accepted"]["scheme"], "exact"); + assert_eq!(envelope["accepted"]["network"], SOLANA_MAINNET_NETWORK); + assert_eq!(envelope["accepted"]["amount"], "1000000"); + // `asset` is the on-chain SPL mint, not a symbol. + assert_eq!(envelope["accepted"]["asset"], mint); + assert_eq!(envelope["accepted"]["payTo"], payee); + assert_eq!(envelope["accepted"]["maxTimeoutSeconds"], 60); + assert_eq!(envelope["accepted"]["extra"]["feePayer"], fee_payer); + // No legacy metadata.delegatedTx transport anywhere in the envelope. + assert!(envelope.get("metadata").is_none()); + assert!(envelope["accepted"]["extra"].get("delegatedTx").is_none()); + + // The partially-signed tx travels in payload.transaction. + let wire_b64 = envelope["payload"]["transaction"] + .as_str() + .expect("payload.transaction string"); + assert!( + !wire_b64.is_empty(), + "payload.transaction must be non-empty" + ); + let wire = from_base64(wire_b64).expect("base64"); + + // Wire = shortvec(signatures=2) ++ feePayerSig[64](zero) ++ authoritySig[64] ++ message. + let (sig_count, mut offset) = read_short_vec(&wire, 0); + assert_eq!(sig_count, 2); + let fee_payer_sig = &wire[offset..offset + 64]; + assert!( + fee_payer_sig.iter().all(|b| *b == 0), + "fee-payer signature slot must be zeroed for the facilitator to co-sign" + ); + offset += 64; // fee-payer sig + offset += 64; // authority sig + let message = &wire[offset..]; + + // Message header: 2 required signatures, 1 readonly-signed, 3 readonly-unsigned. + assert_eq!(&message[0..3], &[2, 1, 3]); + let (account_count, mut m) = read_short_vec(message, 3); + assert_eq!(account_count, 7); + + // Account 0 (fee payer) is the facilitator. + let fee_payer_key = bs58::encode(&message[m..m + 32]).into_string(); + assert_eq!(fee_payer_key, fee_payer); + m += 32 * account_count as usize; // skip all account keys + m += 32; // skip blockhash + + // Three instructions in order: ComputeUnitLimit, ComputeUnitPrice, TransferChecked. + let (ix_count, mut ix) = read_short_vec(message, m); + assert_eq!(ix_count, 3); + + // Instruction 1: program index 6 (compute budget), data[0] == 2 (SetComputeUnitLimit). + let (prog0, after_prog0) = (message[ix], ix + 1); + assert_eq!(prog0, 6); + let (acct_len0, after_acct0) = read_short_vec(message, after_prog0); + assert_eq!(acct_len0, 0); + let (data_len0, data0_start) = read_short_vec(message, after_acct0); + assert_eq!(message[data0_start], 2); + ix = data0_start + data_len0 as usize; + + // Instruction 2: program index 6, data[0] == 3 (SetComputeUnitPrice). + let prog1 = message[ix]; + assert_eq!(prog1, 6); + let (acct_len1, after_acct1) = read_short_vec(message, ix + 1); + assert_eq!(acct_len1, 0); + let (data_len1, data1_start) = read_short_vec(message, after_acct1); + assert_eq!(message[data1_start], 3); + ix = data1_start + data_len1 as usize; + + // Instruction 3: program index 5 (token), data[0] == 12 (TransferChecked). + let prog2 = message[ix]; + assert_eq!(prog2, 5); + let (acct_len2, after_acct2) = read_short_vec(message, ix + 1); + assert_eq!(acct_len2, 4); + // Accounts: [source(2), mint(4), dest(3), authority(1)]. + assert_eq!(&message[after_acct2..after_acct2 + 4], &[2, 4, 3, 1]); + let (_data_len2, data2_start) = read_short_vec(message, after_acct2 + 4); + assert_eq!(message[data2_start], 12); + } +} diff --git a/sdk/rust/src/x402.rs b/sdk/rust/src/x402.rs index 4ae89c47..b86c2e0d 100644 --- a/sdk/rust/src/x402.rs +++ b/sdk/rust/src/x402.rs @@ -6,7 +6,9 @@ use std::collections::HashMap; +use base64::Engine as _; use rand::RngCore as _; +use serde_json::json; use crate::crypto::{to_base64, to_hex}; use crate::error::Result; @@ -208,6 +210,67 @@ pub fn x402_authorization_to_payment_map(authorization: &X402Authorization) -> X map } +/// The canonical x402 v2 submission header. A migrated SDK (or any standard +/// x402 client) base64-encodes the PaymentPayload envelope and submits it in +/// this header. The legacy `X-PAYMENT` header is still accepted by the backend +/// for backwards compatibility. +pub const X402_PAYMENT_HEADER: &str = "PAYMENT-SIGNATURE"; + +/// Build the standard x402 v2 PaymentPayload envelope from an authorization. +/// +/// tiny.place's authorization signature travels as the scheme-specific +/// `payload`; a standard client base64-encodes this envelope and submits it in +/// the [`X402_PAYMENT_HEADER`] (`PAYMENT-SIGNATURE`) header on the header-based +/// payment surfaces (e.g. a2a). +pub fn build_x402_payment_envelope(authorization: &X402Authorization) -> serde_json::Value { + let f = &authorization.fields; + let extra: serde_json::Map = f + .metadata + .as_ref() + .map(|m| { + m.iter() + .map(|(k, v)| (k.clone(), serde_json::Value::String(v.clone()))) + .collect() + }) + .unwrap_or_default(); + + let mut payload_authorization = serde_json::Map::new(); + payload_authorization.insert("from".to_string(), json!(f.from)); + payload_authorization.insert("to".to_string(), json!(f.to)); + payload_authorization.insert("value".to_string(), json!(f.amount)); + payload_authorization.insert("nonce".to_string(), json!(f.nonce)); + if !f.expires_at.is_empty() { + payload_authorization.insert("validBefore".to_string(), json!(f.expires_at)); + } + + json!({ + "x402Version": 2, + "accepted": { + "scheme": f.scheme, + "network": f.network, + "amount": f.amount, + "asset": f.asset, + "payTo": f.to, + "maxTimeoutSeconds": 60, + "extra": extra, + }, + "payload": { + "signature": authorization.signature, + "authorization": payload_authorization, + }, + "extensions": {}, + }) +} + +/// Encode an authorization as the base64 [`X402_PAYMENT_HEADER`] +/// (`PAYMENT-SIGNATURE`) header value — the standard x402 v2 submission format. +/// Mirrors the backend's `x402::ParseInboundPayment`. +pub fn encode_x402_payment_header(authorization: &X402Authorization) -> String { + let envelope = build_x402_payment_envelope(authorization); + let raw = serde_json::to_vec(&envelope).expect("envelope serialization cannot fail"); + base64::engine::general_purpose::STANDARD.encode(raw) +} + fn payment_references(options: &X402PaymentReferenceOptions) -> X402PaymentMap { let mut references = HashMap::new(); let mut insert = |key: &str, value: &Option| { diff --git a/sdk/rust/tests/core.rs b/sdk/rust/tests/core.rs index 57be22b6..2facf8ed 100644 --- a/sdk/rust/tests/core.rs +++ b/sdk/rust/tests/core.rs @@ -187,6 +187,54 @@ async fn local_signer_defaults_to_siws() { assert!(fresh.starts_with("siws:")); } +#[tokio::test] +async fn siws_token_is_well_formed_and_cached() { + use base64::Engine as _; + + let mut signer = LocalSigner::from_seed(&[42u8; 32]).unwrap(); + let token = signer.siws_signature().expect("SIWS minted by default"); + + // 1. Correct `siws:` prefix. + let encoded = token + .strip_prefix("siws:") + .expect("token carries the siws: prefix"); + + // 2. The envelope decodes from url-safe (unpadded) base64 to a JSON object + // carrying both `signedMessage` and `signature` (mirroring the Python SDK). + let json = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(encoded) + .expect("envelope is url-safe base64"); + let value: serde_json::Value = serde_json::from_slice(&json).unwrap(); + assert_eq!(value["signatureType"], "ed25519"); + let message_b64 = value["signedMessage"] + .as_str() + .expect("envelope has signedMessage"); + assert!( + value["signature"].as_str().is_some(), + "envelope has signature" + ); + + // 3. The SIWS message's domain line is `tiny.place`. + let message = base64::engine::general_purpose::STANDARD + .decode(message_b64) + .unwrap(); + let text = String::from_utf8(message).unwrap(); + assert_eq!( + text.lines().next().unwrap(), + "tiny.place wants you to sign in with your Solana account:" + ); + + // 4. The token is cached: repeated reads return the identical proof rather + // than re-minting per call. + assert_eq!(signer.siws_signature().as_deref(), Some(token.as_str())); + assert_eq!(signer.siws_signature(), signer.siws_signature()); + + // 5. Explicitly re-minting rotates the cached token (fresh nonce/timestamp). + let rotated = signer.mint_siws(); + assert_ne!(rotated, token); + assert_eq!(signer.siws_signature().as_deref(), Some(rotated.as_str())); +} + #[tokio::test] async fn siws_signer_passes_token_through() { let signer = SiwsSigner; diff --git a/sdk/rust/tests/x402_standard.rs b/sdk/rust/tests/x402_standard.rs new file mode 100644 index 00000000..5203491d --- /dev/null +++ b/sdk/rust/tests/x402_standard.rs @@ -0,0 +1,169 @@ +//! x402 v2 standard-conformance tests for the Rust SDK: the SDK identification +//! header, parsing the 402 challenge from the standard `accepts[]` array, and +//! encoding the standard `X-PAYMENT` PaymentPayload envelope. + +use std::collections::HashMap; + +use base64::Engine as _; +use serde_json::json; +use tinyplace::{ + build_x402_payment_envelope, encode_x402_payment_header, Error, HttpClient, HttpClientOptions, + RetryOptions, X402Authorization, X402AuthorizationFields, X402_PAYMENT_HEADER, +}; + +#[test] +fn exposes_canonical_submission_header() { + assert_eq!(X402_PAYMENT_HEADER, "PAYMENT-SIGNATURE"); +} +use wiremock::matchers::any; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +fn http_for(uri: String) -> HttpClient { + HttpClient::new(HttpClientOptions { + base_url: uri, + retry: RetryOptions { + retries: 0, + ..Default::default() + }, + ..Default::default() + }) +} + +#[tokio::test] +async fn sends_sdk_identification_header() { + let server = MockServer::start().await; + Mock::given(any()) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({}))) + .mount(&server) + .await; + let http = http_for(server.uri()); + + let _: serde_json::Value = http.get("/thing", &[]).await.unwrap(); + + let requests = server.received_requests().await.unwrap(); + let header = requests[0] + .headers + .get("x-tinyplace-sdk") + .expect("X-Tinyplace-SDK header present") + .to_str() + .unwrap(); + assert!(header.starts_with("rust/"), "got {header}"); +} + +#[tokio::test] +async fn parses_challenge_from_standard_accepts() { + let body = json!({ + "error": "payment required", + "x402Version": 2, + "resource": { "url": "https://tiny.place" }, + "accepts": [{ + "scheme": "exact", + "network": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + "amount": "1000000", + "asset": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "payTo": "treasury-address", + "maxTimeoutSeconds": 60, + "extra": { + "domain": "tiny.place", + "feePayer": "facilitator-address", + "from": "payer-address", + "nonce": "nonce-xyz", + "expiresAt": "2026-06-21T00:00:00Z" + } + }], + "extensions": {} + }); + let server = MockServer::start().await; + Mock::given(any()) + .respond_with(ResponseTemplate::new(402).set_body_json(body)) + .mount(&server) + .await; + let http = http_for(server.uri()); + + let err: Error = http + .get::("/thing", &[]) + .await + .unwrap_err(); + let challenge = err + .payment_required() + .expect("challenge parsed from accepts"); + let payment = &challenge.payment; + assert_eq!(payment.amount.as_deref(), Some("1000000")); + assert_eq!(payment.to.as_deref(), Some("treasury-address")); // payTo -> to + // Binding fields promoted out of extra. + assert_eq!(payment.from.as_deref(), Some("payer-address")); + assert_eq!(payment.nonce.as_deref(), Some("nonce-xyz")); + assert_eq!(payment.expires_at.as_deref(), Some("2026-06-21T00:00:00Z")); + // Remaining extra becomes metadata; binding keys are not duplicated in. + let metadata = payment.metadata.as_ref().expect("metadata present"); + assert_eq!( + metadata.get("domain").map(String::as_str), + Some("tiny.place") + ); + assert_eq!( + metadata.get("feePayer").map(String::as_str), + Some("facilitator-address") + ); + assert!(!metadata.contains_key("nonce")); + assert!(!metadata.contains_key("from")); +} + +#[tokio::test] +async fn falls_back_to_legacy_payment_field() { + let body = + json!({ "error": "payment required", "payment": { "amount": "500", "to": "treasury" } }); + let server = MockServer::start().await; + Mock::given(any()) + .respond_with(ResponseTemplate::new(402).set_body_json(body)) + .mount(&server) + .await; + let http = http_for(server.uri()); + + let err: Error = http + .get::("/thing", &[]) + .await + .unwrap_err(); + let challenge = err.payment_required().expect("legacy challenge parsed"); + assert_eq!(challenge.payment.amount.as_deref(), Some("500")); +} + +#[test] +fn encodes_standard_x_payment_envelope() { + let mut metadata = HashMap::new(); + metadata.insert("domain".to_string(), "tiny.place".to_string()); + metadata.insert("feePayer".to_string(), "facilitator".to_string()); + let authorization = X402Authorization { + fields: X402AuthorizationFields { + scheme: "exact".into(), + network: "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp".into(), + asset: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v".into(), + amount: "1000000".into(), + from: "payer".into(), + to: "treasury".into(), + nonce: "pay_test".into(), + expires_at: "2026-06-21T00:00:00Z".into(), + metadata: Some(metadata), + }, + signature: "v1:ts:nonce:sig".into(), + }; + + let envelope = build_x402_payment_envelope(&authorization); + assert_eq!(envelope["x402Version"], 2); + assert_eq!(envelope["accepted"]["payTo"], "treasury"); + assert_eq!(envelope["accepted"]["extra"]["feePayer"], "facilitator"); + assert_eq!(envelope["payload"]["signature"], "v1:ts:nonce:sig"); + assert_eq!(envelope["payload"]["authorization"]["value"], "1000000"); + assert_eq!( + envelope["payload"]["authorization"]["validBefore"], + "2026-06-21T00:00:00Z" + ); + + let header = encode_x402_payment_header(&authorization); + let decoded: serde_json::Value = serde_json::from_slice( + &base64::engine::general_purpose::STANDARD + .decode(header) + .unwrap(), + ) + .unwrap(); + assert_eq!(decoded, envelope); +} diff --git a/sdk/typescript/package.json b/sdk/typescript/package.json index 3529887a..7bb47b32 100644 --- a/sdk/typescript/package.json +++ b/sdk/typescript/package.json @@ -35,8 +35,11 @@ } }, "scripts": { + "gen:version": "node scripts/gen-version.mjs", + "prebuild": "node scripts/gen-version.mjs", "build": "tsc", "lint": "tsc --noEmit", + "pretest": "node scripts/gen-version.mjs", "test": "vitest run", "test:staging": "RUN_STAGING=1 vitest run tests/staging.test.ts" }, diff --git a/sdk/typescript/scripts/gen-version.mjs b/sdk/typescript/scripts/gen-version.mjs new file mode 100644 index 00000000..858ca78c --- /dev/null +++ b/sdk/typescript/scripts/gen-version.mjs @@ -0,0 +1,24 @@ +// Regenerates src/version.ts from package.json so the SDK version is never +// hand-maintained — package.json is the single source of truth. Runs as the +// `prebuild`/`pretest` hook; the generated file is committed so type-checking +// and tests work without a prior build. +import { readFileSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const here = dirname(fileURLToPath(import.meta.url)); +const pkg = JSON.parse(readFileSync(join(here, "..", "package.json"), "utf8")); + +const contents = `// AUTO-GENERATED from package.json by scripts/gen-version.mjs — do not edit. +// The version is derived from the package manifest (single source of truth); +// it is reported in the X-Tinyplace-SDK request header so the backend can +// recognize first-party clients. +export const SDK_VERSION = ${JSON.stringify(pkg.version)}; + +// HEADER_SDK_CLIENT is the request header first-party SDKs send to identify +// themselves; SDK_CLIENT is its value for this TypeScript SDK. +export const HEADER_SDK_CLIENT = "X-Tinyplace-SDK"; +export const SDK_CLIENT = \`ts/\${SDK_VERSION}\`; +`; + +writeFileSync(join(here, "..", "src", "version.ts"), contents); diff --git a/sdk/typescript/src/api/bounties.ts b/sdk/typescript/src/api/bounties.ts index aa355a83..04a8261e 100644 --- a/sdk/typescript/src/api/bounties.ts +++ b/sdk/typescript/src/api/bounties.ts @@ -9,6 +9,7 @@ import type { BountySubmissionCreateRequest, } from "../types/index.js"; import { listField } from "../safe.js"; +import { X402_PAYMENT_HEADER } from "../x402.js"; // BountiesApi covers the bounty platform: create + fund in one x402 flow (the // reward into escrow), browse, submit a URL, comment for free, run the @@ -31,11 +32,20 @@ export class BountiesApi { return this.http.get(`/bounties/${encodeURIComponent(bountyId)}`); } - create(request: BountyCreateRequest): Promise { + /** + * Create a bounty. When `paymentHeader` is supplied (the standard x402 v2 SVM + * `PAYMENT-SIGNATURE` envelope from {@link buildDelegatedX402PaymentHeader}), + * it settles the reward gaslessly through the facilitator — the partially + * signed SPL transfer rides in the header and the body carries NO `payment` + * field. + */ + create(request: BountyCreateRequest, paymentHeader?: string): Promise { return this.http.postDirectoryAuthAs( "/bounties", request.creator ?? "", request, + undefined, + paymentHeader ? { [X402_PAYMENT_HEADER]: paymentHeader } : undefined, ); } diff --git a/sdk/typescript/src/api/registry.ts b/sdk/typescript/src/api/registry.ts index a275c93e..5ce9400d 100644 --- a/sdk/typescript/src/api/registry.ts +++ b/sdk/typescript/src/api/registry.ts @@ -9,12 +9,15 @@ import { } from "../http.js"; import { buildX402PaymentMap, + X402_PAYMENT_HEADER, type X402PaymentMap, type X402PaymentMapOptions, } from "../x402.js"; import { + buildDelegatedX402PaymentHeader, executeSolanaX402Payment, SOLANA_MAINNET_NETWORK, + SOLANA_NATIVE_ASSET, SOLANA_USDC_MINT, type SolanaX402PaymentExecution, type SolanaX402PaymentExecutionOptions, @@ -61,8 +64,10 @@ export interface RegisterRequest { signature?: string; } -export interface SolanaRegistrationPaymentOptions - extends Omit { +export interface SolanaRegistrationPaymentOptions extends Omit< + SolanaX402PaymentExecutionOptions, + "payment" | "signer" +> { amount?: string; to?: string; asset?: string; @@ -76,21 +81,57 @@ export interface SolanaRegistrationPaymentOptions registrationRetryErrors?: Array; } +/** + * The standard x402 v2 SVM "exact" delegated payment for the gasless sponsored + * path: the agent-signed SPL transfer is submitted in the `PAYMENT-SIGNATURE` + * header (`paymentHeader`, base64 of the {@link X402SvmPaymentEnvelope}), not as + * a request-body payment map. The facilitator co-signs as fee payer and + * broadcasts, so there is no client-side on-chain signature to surface. + */ +export interface DelegatedRegistrationPayment { + delegated: true; + /** The base64 `PAYMENT-SIGNATURE` envelope submitted with the registration. */ + paymentHeader: string; +} + export interface SolanaRegistrationResult { identity: Identity; - payment: SolanaX402PaymentExecution; + /** + * The settled x402 payment. The gasless delegated path (the default for SPL + * assets such as USDC, when the challenge advertises a facilitator fee payer) + * yields a {@link DelegatedRegistrationPayment} carrying the standard + * `PAYMENT-SIGNATURE` envelope (no client-broadcast signature); the direct + * native-SOL path yields a {@link SolanaX402PaymentExecution} that also + * carries the on-chain `signature`. + */ + payment: SolanaX402PaymentExecution | DelegatedRegistrationPayment; + /** + * The on-chain transaction signature, present only on the direct native-SOL + * path (the facilitator broadcasts the delegated transfer, so the gasless + * path has no client-side signature to surface). + */ + onChainTx?: string; } export interface SolanaRegistrationFailure extends Error { - registrationPayment?: SolanaX402PaymentExecution | X402PaymentMap; + registrationPayment?: + | SolanaX402PaymentExecution + | DelegatedRegistrationPayment; onChainTx?: string; } -export interface SolanaRegistrationProofOptions - extends Partial> { + | "amount" + | "asset" + | "network" + | "nonce" + | "expiresAt" + | "expiresInMs" + | "metadata" + > +> { onChainTx: string; to?: string; registrationAttempts?: number; @@ -117,10 +158,18 @@ export class RegistryApi { private readonly signingKey?: SigningKey, ) {} - async register(request: RegisterRequest): Promise { + async register( + request: RegisterRequest, + paymentHeader?: string, + ): Promise { request = normalizeRegisterRequest(request); const headers: Record = {}; + // Standard x402 v2: the sponsored SPL transfer rides in the + // PAYMENT-SIGNATURE header; the request body carries NO `payment` field. + if (paymentHeader) { + headers[X402_PAYMENT_HEADER] = paymentHeader; + } if (this.signingKey && !request.signature) { // Present the signing key so the backend can authorize a delegated hot // session key: it verifies the signature against this key, then checks the @@ -161,44 +210,88 @@ export class RegistryApi { throw new Error("registration payment requires amount and recipient"); } - const payment = await executeSolanaX402Payment({ - ...options, - mint: options.mint ?? SOLANA_USDC_MINT, - signer: this.signingKey, - payment: { - scheme: "exact", - network: options.network ?? challenge?.network ?? SOLANA_MAINNET_NETWORK, - asset: options.asset ?? challenge?.asset ?? "USDC", - amount, - from: normalizedRequest.cryptoId, - to, - nonce: - options.nonce ?? - challenge?.nonce ?? - generateRegistrationNonce(normalizedRequest.username), - expiresAt: options.expiresAt ?? challenge?.expiresAt, - expiresInMs: options.expiresInMs, - metadata: { - ...challenge?.metadata, - identity: normalizedRequest.username, - purpose: "registration", - ...options.metadata, + const network = + options.network ?? challenge?.network ?? SOLANA_MAINNET_NETWORK; + const asset = options.asset ?? challenge?.asset ?? "USDC"; + const metadata: Record = { + ...challenge?.metadata, + identity: normalizedRequest.username, + purpose: "registration", + ...options.metadata, + }; + const isNative = asset.trim().toUpperCase() === SOLANA_NATIVE_ASSET; + const feePayer = metadata["feePayer"]; + + // The USDC registration fee settles gaslessly through the facilitator: a + // payer-signed delegated tx whose fee payer is the facilitator (from the + // 402 challenge's metadata.feePayer), so the wallet needs no SOL for gas. + // This mirrors the bounty-funding flow and the Python/Rust SDKs. Native SOL + // — which the facilitator cannot settle — falls back to the direct path, + // where the wallet pays its own gas and broadcasts the transfer itself. + let payment: SolanaX402PaymentExecution | DelegatedRegistrationPayment; + let paymentMap: X402PaymentMap | undefined; + let paymentHeader: string | undefined; + let onChainTx: string | undefined; + if (!isNative && feePayer) { + // Standard x402 v2 SVM "exact": the agent-signed SPL transfer is encoded + // into the PAYMENT-SIGNATURE header (payload.transaction); the register + // body carries NO `payment` field. The facilitator co-signs as fee payer + // and broadcasts, so the wallet needs no SOL for gas. + paymentHeader = await buildDelegatedX402PaymentHeader({ + secretKey: options.secretKey, + rpcUrl: options.rpcUrl, + feePayer, + mint: options.mint ?? SOLANA_USDC_MINT, + decimals: options.decimals ?? 6, + ...(options.sourceTokenAccount + ? { sourceTokenAccount: options.sourceTokenAccount } + : {}), + ...(options.destinationTokenAccount + ? { destinationTokenAccount: options.destinationTokenAccount } + : {}), + ...(options.fetch ? { fetch: options.fetch } : {}), + payment: { network, asset, amount, to, metadata }, + }); + payment = { delegated: true, paymentHeader }; + } else { + const execution = await executeSolanaX402Payment({ + ...options, + mint: options.mint ?? SOLANA_USDC_MINT, + signer: this.signingKey, + payment: { + scheme: "exact", + network, + asset, + amount, + from: normalizedRequest.cryptoId, + to, + nonce: + options.nonce ?? + challenge?.nonce ?? + generateRegistrationNonce(normalizedRequest.username), + expiresAt: options.expiresAt ?? challenge?.expiresAt, + expiresInMs: options.expiresInMs, + metadata, + publicKeyBase64: normalizedRequest.publicKey, }, - publicKeyBase64: normalizedRequest.publicKey, - }, - }); + }); + payment = execution; + paymentMap = execution.payment; + onChainTx = execution.signature; + } + let identity: Identity; try { - identity = await this.registerWithPaymentMap( + identity = await this.submitRegistration( normalizedRequest, - payment.payment, + { paymentMap, paymentHeader }, options, ); } catch (error) { throw attachRegistrationPayment(error, payment); } - return { identity, payment }; + return { identity, payment, ...(onChainTx ? { onChainTx } : {}) }; } async registerWithExistingSolanaPayment( @@ -206,7 +299,9 @@ export class RegistryApi { options: SolanaRegistrationProofOptions, ): Promise { if (!this.signingKey) { - throw new Error("registerWithExistingSolanaPayment requires a signing key"); + throw new Error( + "registerWithExistingSolanaPayment requires a signing key", + ); } const normalizedRequest = normalizeRegisterRequest(request); @@ -246,9 +341,9 @@ export class RegistryApi { }); let identity: Identity; try { - identity = await this.registerWithPaymentMap( + identity = await this.submitRegistration( normalizedRequest, - payment, + { paymentMap: payment }, options, ); } catch (error) { @@ -272,24 +367,34 @@ export class RegistryApi { throw new Error("registration did not return a payment challenge"); } - private async registerWithPaymentMap( + /** + * Submit the signed registration, settling its fee. The sponsored x402 v2 SVM + * path carries the SPL transfer in the `PAYMENT-SIGNATURE` header + * (`paymentHeader`) with NO body `payment`; the native-SOL / existing-tx path + * carries a body `payment` map. Exactly one of the two is set. + */ + private async submitRegistration( request: RegisterRequest, - payment: X402PaymentMap, + proof: { paymentMap?: X402PaymentMap; paymentHeader?: string }, options: { registrationAttempts?: number; registrationIntervalMs?: number; registrationRetryErrors?: Array; }, ): Promise { + const body: RegisterRequest = proof.paymentMap + ? { ...request, payment: proof.paymentMap } + : request; try { - return await this.registerRetryingPayment({ - ...request, - payment, - }, { - attempts: options.registrationAttempts, - intervalMs: options.registrationIntervalMs, - retryErrors: options.registrationRetryErrors, - }); + return await this.registerRetryingPayment( + body, + { + attempts: options.registrationAttempts, + intervalMs: options.registrationIntervalMs, + retryErrors: options.registrationRetryErrors, + }, + proof.paymentHeader, + ); } catch (error) { const recovered = await this.createdIdentityAfterRegistrationError( request.username, @@ -497,17 +602,17 @@ export class RegistryApi { intervalMs?: number; retryErrors?: Array; }, + paymentHeader?: string, ): Promise { const attempts = options.attempts ?? DEFAULT_REGISTRATION_ATTEMPTS; - const intervalMs = - options.intervalMs ?? DEFAULT_REGISTRATION_INTERVAL_MS; + const intervalMs = options.intervalMs ?? DEFAULT_REGISTRATION_INTERVAL_MS; const retryErrors = options.retryErrors ?? DEFAULT_REGISTRATION_RETRY_ERRORS; let lastError: unknown; for (let attempt = 0; attempt < attempts; attempt += 1) { try { - return await this.register(request); + return await this.register(request, paymentHeader); } catch (error) { lastError = error; if (!isRetryablePaymentError(error, retryErrors)) { @@ -576,19 +681,29 @@ function paymentErrorMessage(body: unknown): string { function attachRegistrationPayment( error: unknown, - payment: SolanaX402PaymentExecution | X402PaymentMap, + payment: SolanaX402PaymentExecution | DelegatedRegistrationPayment | X402PaymentMap, ): unknown { if (typeof error === "object" && error !== null) { const failure = error as SolanaRegistrationFailure; - failure.registrationPayment = payment; - failure.onChainTx = registrationPaymentTx(payment); + failure.registrationPayment = payment as + | SolanaX402PaymentExecution + | DelegatedRegistrationPayment; + const tx = registrationPaymentTx(payment); + if (tx) { + failure.onChainTx = tx; + } } return error; } function registrationPaymentTx( - payment: SolanaX402PaymentExecution | X402PaymentMap, + payment: SolanaX402PaymentExecution | DelegatedRegistrationPayment | X402PaymentMap, ): string | undefined { + // The sponsored delegated path has no client-side on-chain signature (the + // facilitator broadcasts), so there is no tx to surface. + if ("delegated" in payment) { + return undefined; + } if ( "payment" in payment && "signature" in payment && @@ -597,7 +712,9 @@ function registrationPaymentTx( return payment.signature; } const paymentMap = payment as X402PaymentMap; - return paymentMap["onChainTx"] ?? paymentMap["tx"] ?? paymentMap["transaction"]; + return ( + paymentMap["onChainTx"] ?? paymentMap["tx"] ?? paymentMap["transaction"] + ); } function normalizeRegisterRequest(request: RegisterRequest): RegisterRequest { diff --git a/sdk/typescript/src/cli/context.ts b/sdk/typescript/src/cli/context.ts index 7067e5d6..2de4982c 100644 --- a/sdk/typescript/src/cli/context.ts +++ b/sdk/typescript/src/cli/context.ts @@ -5,11 +5,17 @@ import { TinyPlaceClient } from "../client.js"; import { LocalSigner } from "../local-signer.js"; import { FileSessionStore } from "../node/index.js"; import { bytesToHex, hexToBytes } from "./args.js"; -import type { CliContext, TinyPlaceCliConfig, TinyPlaceCliOptions } from "./types.js"; +import type { + CliContext, + TinyPlaceCliConfig, + TinyPlaceCliOptions, +} from "./types.js"; const DEFAULT_ENDPOINT = "https://api.tiny.place"; -export async function makeContext(options: TinyPlaceCliOptions): Promise { +export async function makeContext( + options: TinyPlaceCliOptions, +): Promise { // "Managed mode" is the real `tinyplace` bin (no env override): the CLI owns the // identity key and persists it. When an embedder/test passes its own env, stay // explicit — never generate or write a key on their behalf. @@ -30,7 +36,26 @@ export async function makeContext(options: TinyPlaceCliOptions): Promise.json). The X25519 identity is derived from the @@ -88,16 +113,27 @@ function randomSeed(): Uint8Array { return seed; } -async function loadCliConfig(env: Record): Promise { +async function loadCliConfig( + env: Record, +): Promise { try { - const parsed = JSON.parse(await readFile(configPathFor(env), "utf8")) as unknown; + const parsed = JSON.parse( + await readFile(configPathFor(env), "utf8"), + ) as unknown; if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { return {}; } const config = parsed as Record; return { - ...(typeof config.endpoint === "string" ? { endpoint: config.endpoint } : {}), - ...(typeof config.secretKey === "string" ? { secretKey: config.secretKey } : {}), + ...(typeof config.endpoint === "string" + ? { endpoint: config.endpoint } + : {}), + ...(typeof config.secretKey === "string" + ? { secretKey: config.secretKey } + : {}), + ...(typeof config.siwsToken === "string" + ? { siwsToken: config.siwsToken } + : {}), }; } catch (error) { if ((error as { code?: string }).code === "ENOENT") { @@ -112,12 +148,22 @@ async function persistSecretKey( env: Record, config: TinyPlaceCliConfig, secretKey: string, +): Promise { + await persistConfig(env, { ...config, secretKey }); +} + +/** Best-effort write of the CLI config (key + SIWS proof) at mode 0600. */ +async function persistConfig( + env: Record, + config: TinyPlaceCliConfig, ): Promise { const configPath = configPathFor(env); try { await mkdir(dirname(configPath), { recursive: true }); - await writeFile(configPath, `${JSON.stringify({ ...config, secretKey }, null, 2)}\n`, { mode: 0o600 }); + await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, { + mode: 0o600, + }); } catch { - // Read-only home or similar — keep using the in-memory key for this run. + // Read-only home or similar — keep using the in-memory key/token this run. } } diff --git a/sdk/typescript/src/cli/flows.ts b/sdk/typescript/src/cli/flows.ts index d2837e34..79c03cf9 100644 --- a/sdk/typescript/src/cli/flows.ts +++ b/sdk/typescript/src/cli/flows.ts @@ -17,7 +17,7 @@ import { import type { CliContext, Flags, JsonObject } from "./types.js"; import { idOf, resolveAgentId, settle, summarize } from "./workflows.js"; import type { PaymentChallenge } from "../http.js"; -import { buildDelegatedX402PaymentMap } from "../solana.js"; +import { buildDelegatedX402PaymentHeader } from "../solana.js"; import type { BountyCreateRequest, Identity } from "../types/index.js"; import type { RegisterRequest } from "../api/registry.js"; @@ -122,7 +122,10 @@ export async function registerFlow( function registrationSuggestions(handle: string): Array { return [ - suggest(`Make ${handle} your primary identity`, `tinyplace raw set-primary ${handle}`), + suggest( + `Make ${handle} your primary identity`, + `tinyplace raw set-primary ${handle}`, + ), suggest("Confirm your identity", "tinyplace whoami"), ]; } @@ -173,17 +176,17 @@ async function performPaidRegistration( try { if (opts.existingTx) { - const result = await ctx.client.registry.registerWithExistingSolanaPayment( - request, - { + const result = + await ctx.client.registry.registerWithExistingSolanaPayment(request, { onChainTx: opts.existingTx, ...(opts.challenge?.amount ? { amount: opts.challenge.amount } : {}), ...(opts.challenge?.asset ? { asset: opts.challenge.asset } : {}), - ...(opts.challenge?.network ? { network: opts.challenge.network } : {}), + ...(opts.challenge?.network + ? { network: opts.challenge.network } + : {}), ...(opts.challenge?.to ? { to: opts.challenge.to } : {}), ...(opts.challenge?.nonce ? { nonce: opts.challenge.nonce } : {}), - }, - ); + }); return { identity: result.identity, onChainTx: result.onChainTx }; } @@ -197,18 +200,34 @@ async function performPaidRegistration( return shortfall; } - const result = await ctx.client.registry.registerWithSolanaPayment(request, { - rpcUrl: opts.rpcUrl, - secretKey: hexToBytes(secretHex), - ...(asset?.mint ? { mint: asset.mint } : {}), - ...(asset?.decimals !== undefined ? { decimals: asset.decimals } : {}), - ...(opts.challenge?.amount ? { amount: opts.challenge.amount } : {}), - ...(opts.challenge?.to ? { to: opts.challenge.to } : {}), - ...(opts.challenge?.network ? { network: opts.challenge.network } : {}), - ...(opts.challenge?.asset ? { asset: opts.challenge.asset } : {}), - ...(opts.challenge?.nonce ? { nonce: opts.challenge.nonce } : {}), - }); - return { identity: result.identity, onChainTx: result.payment.signature }; + const result = await ctx.client.registry.registerWithSolanaPayment( + request, + { + rpcUrl: opts.rpcUrl, + secretKey: hexToBytes(secretHex), + ...(ctx.fetch ? { fetch: ctx.fetch } : {}), + ...(asset?.mint ? { mint: asset.mint } : {}), + ...(asset?.decimals !== undefined ? { decimals: asset.decimals } : {}), + ...(opts.challenge?.amount ? { amount: opts.challenge.amount } : {}), + ...(opts.challenge?.to ? { to: opts.challenge.to } : {}), + ...(opts.challenge?.network ? { network: opts.challenge.network } : {}), + ...(opts.challenge?.asset ? { asset: opts.challenge.asset } : {}), + ...(opts.challenge?.nonce ? { nonce: opts.challenge.nonce } : {}), + // Forward the challenge metadata so the API can read the facilitator + // fee payer (metadata.feePayer) and settle USDC gaslessly via the + // delegated path rather than the wallet-pays-gas direct path. + ...(opts.challenge?.metadata + ? { metadata: opts.challenge.metadata } + : {}), + }, + ); + // The gasless delegated path (USDC, the default) has no client-broadcast + // signature — the facilitator broadcasts. Surface onChainTx only on the + // direct native-SOL path, where the wallet broadcasts the transfer itself. + return { + identity: result.identity, + ...(result.onChainTx ? { onChainTx: result.onChainTx } : {}), + }; } catch (error) { const onChainTx = (error as { onChainTx?: string }).onChainTx; if (onChainTx) { @@ -290,7 +309,9 @@ async function resolveSplAsset( const match = info.assets.find( (entry) => entry.symbol.toUpperCase() === upper || - (entry.address ? entry.address.toLowerCase() === value.toLowerCase() : false), + (entry.address + ? entry.address.toLowerCase() === value.toLowerCase() + : false), ); if (!match) { return undefined; @@ -369,8 +390,14 @@ export async function postBountyFlow( const bountyId = idOf(result); return bountyId ? [ - suggest("Watch submissions arrive", `tinyplace submissions ${bountyId}`), - suggest("Check the bounty's status", `tinyplace raw bounty ${bountyId}`), + suggest( + "Watch submissions arrive", + `tinyplace submissions ${bountyId}`, + ), + suggest( + "Check the bounty's status", + `tinyplace raw bounty ${bountyId}`, + ), ] : []; }, @@ -406,21 +433,20 @@ async function createAndFundBounty( ctx.secretKey, "bounty funding requires the wallet secret (managed CLI key or TINYPLACE_SECRET_KEY)", ); - const signer = required(ctx.signer, "bounty funding requires a wallet signer"); + required(ctx.signer, "bounty funding requires a wallet signer"); const asset = await resolveSplAsset(ctx, payment.asset); if (!asset?.mint || asset.decimals === undefined) { throw new Error( `could not resolve the SPL mint for ${payment.asset ?? "the reward asset"} (the facilitator cannot settle native SOL)`, ); } - const paymentMap = await buildDelegatedX402PaymentMap({ - signer, + const paymentHeader = await buildDelegatedX402PaymentHeader({ secretKey: hexToBytes(secretHex), rpcUrl: opts.rpcUrl, + ...(ctx.fetch ? { fetch: ctx.fetch } : {}), feePayer, mint: asset.mint, decimals: asset.decimals, - from: opts.creator, payment: { network: payment.network ?? "", asset: payment.asset ?? "", @@ -429,7 +455,9 @@ async function createAndFundBounty( ...(payment.metadata ? { metadata: payment.metadata } : {}), }, }); - return ctx.client.bounties.create({ ...request, payment: paymentMap }); + // Standard x402 v2: the partially-signed SPL transfer rides in the + // PAYMENT-SIGNATURE header; the request body carries NO `payment` field. + return ctx.client.bounties.create(request, paymentHeader); } } @@ -487,8 +515,12 @@ export async function submitFlow( ctx.client.bounties.submit(bountyId, { submitter, url, - ...(stringFlag(flags, "title") ? { title: stringFlag(flags, "title") } : {}), - ...(stringFlag(flags, "note") ? { note: stringFlag(flags, "note") } : {}), + ...(stringFlag(flags, "title") + ? { title: stringFlag(flags, "title") } + : {}), + ...(stringFlag(flags, "note") + ? { note: stringFlag(flags, "note") } + : {}), ...bodyFlag(flags), } as never), onSuccess: () => [ @@ -522,7 +554,10 @@ export async function joinGroupFlow( command, run: () => ctx.client.groups.join(groupId, agentId), onSuccess: () => [ - suggest(`See who else is in ${groupId}`, `tinyplace raw group-members ${groupId}`), + suggest( + `See who else is in ${groupId}`, + `tinyplace raw group-members ${groupId}`, + ), suggest("Resume your loop", "tinyplace status"), ], }); @@ -566,8 +601,14 @@ export async function createGroupFlow( const groupId = idOf(result); return groupId ? [ - suggest(`Create an invite link for ${groupId}`, `tinyplace raw group-invite ${groupId}`), - suggest(`View members of ${groupId}`, `tinyplace raw group-members ${groupId}`), + suggest( + `Create an invite link for ${groupId}`, + `tinyplace raw group-invite ${groupId}`, + ), + suggest( + `View members of ${groupId}`, + `tinyplace raw group-members ${groupId}`, + ), ] : []; }, @@ -583,7 +624,10 @@ export async function followFlow( ctx: CliContext, positionals: Array, ): Promise { - required(ctx.signer?.agentId, "follow requires a wallet (re-run; the key auto-generates)"); + required( + ctx.signer?.agentId, + "follow requires a wallet (re-run; the key auto-generates)", + ); const target = required(positionals[0], "follow <@handle|agentId>"); const agentId = await resolveAgentId(ctx, target); const command = `tinyplace follow ${target}`; @@ -604,7 +648,10 @@ export async function unfollowFlow( ctx: CliContext, positionals: Array, ): Promise { - required(ctx.signer?.agentId, "unfollow requires a wallet (re-run; the key auto-generates)"); + required( + ctx.signer?.agentId, + "unfollow requires a wallet (re-run; the key auto-generates)", + ); const target = required(positionals[0], "unfollow <@handle|agentId>"); const agentId = await resolveAgentId(ctx, target); const command = `tinyplace unfollow ${target}`; diff --git a/sdk/typescript/src/cli/types.ts b/sdk/typescript/src/cli/types.ts index 8c3cd4f5..32bdd93c 100644 --- a/sdk/typescript/src/cli/types.ts +++ b/sdk/typescript/src/cli/types.ts @@ -43,6 +43,12 @@ export interface TinyPlaceCliOptions { export interface TinyPlaceCliConfig { endpoint?: string; secretKey?: string; + /** + * A persisted `siws:` Sign-In With Solana proof. The CLI mints this once from + * the identity key and reuses it across invocations (until it expires) as the + * preferred auth credential, rather than re-signing a sign-in message per run. + */ + siwsToken?: string; } export interface CliContext { diff --git a/sdk/typescript/src/http.ts b/sdk/typescript/src/http.ts index 69549766..4724e17a 100644 --- a/sdk/typescript/src/http.ts +++ b/sdk/typescript/src/http.ts @@ -6,6 +6,7 @@ import { type AdminSigningOptions, } from "./auth.js"; import { classifyError, type TinyPlaceErrorCode } from "./errors.js"; +import { HEADER_SDK_CLIENT, SDK_CLIENT } from "./version.js"; export type BodySigner = (body: TBody) => Promise | TBody; @@ -338,6 +339,10 @@ export class HttpClient { ): Promise { const headers: Record = { "Content-Type": "application/json", + // Identify this first-party SDK so the backend can serve the legacy x402 + // challenge shape during the standardization migration; standard clients + // omit this header and receive a clean x402 v2 challenge. + [HEADER_SDK_CLIENT]: SDK_CLIENT, ...(options?.headers ?? {}), }; @@ -633,12 +638,14 @@ export class HttpClient { actor: string, body?: unknown, signBody?: BodySigner, + headers?: Record, ): Promise { return this.request("POST", path, { body, directoryAuth: true, directoryActor: actor, signBody, + ...(headers ? { headers } : {}), }); } @@ -870,19 +877,29 @@ function paymentRequiredFromBody( function asPaymentRequiredChallenge( value: unknown, ): PaymentRequiredChallenge | undefined { - if (typeof value !== "object" || value === null || !("payment" in value)) { + if (typeof value !== "object" || value === null) { return undefined; } + const error = (value as { error?: unknown }).error; + const errorField = typeof error === "string" ? { error } : {}; + + // Prefer the standard x402 v2 accepts[] array. tiny.place advertises the + // payer-binding fields (from/nonce/expiresAt) under accepts[].extra so the + // challenge can be rebuilt from accepts[] alone — no legacy `payment` needed. + const fromAccepts = challengeFromAccepts(value); + if (fromAccepts) { + return { ...errorField, payment: fromAccepts }; + } + // Legacy fallback: the non-standard top-level `payment` object. Removed in + // Phase 2 once the standard accepts[] path is the only one in the field. const payment = (value as { payment?: unknown }).payment; if (typeof payment !== "object" || payment === null) { return undefined; } - const challengePayment = payment as Record; - const error = (value as { error?: unknown }).error; return { - ...(typeof error === "string" ? { error } : {}), + ...errorField, payment: { ...stringField(challengePayment, "scheme"), ...stringField(challengePayment, "network"), @@ -898,6 +915,54 @@ function asPaymentRequiredChallenge( }; } +// challengeFromAccepts maps the first standard x402 v2 accepts[] entry onto the +// SDK's flat PaymentChallenge. The payer-binding fields (from/nonce/expiresAt) +// are promoted out of `extra` to the top level; the remainder of `extra` +// becomes the signed metadata, exactly mirroring the legacy `payment` shape. +function challengeFromAccepts( + value: unknown, +): PaymentChallenge | undefined { + const accepts = (value as { accepts?: unknown }).accepts; + if (!Array.isArray(accepts) || accepts.length === 0) { + return undefined; + } + const entry = accepts[0]; + if (typeof entry !== "object" || entry === null) { + return undefined; + } + const accept = entry as Record; + const extra = + typeof accept["extra"] === "object" && accept["extra"] !== null + ? (accept["extra"] as Record) + : {}; + + const bindingKeys = ["from", "nonce", "expiresAt"] as const; + const metadata: Record = {}; + for (const [key, raw] of Object.entries(extra)) { + if (typeof raw === "string" && !bindingKeys.includes(key as never)) { + metadata[key] = raw; + } + } + + const binding: Partial = {}; + for (const key of bindingKeys) { + if (typeof extra[key] === "string") { + binding[key] = extra[key] as string; + } + } + + return { + ...stringField(accept, "scheme"), + ...stringField(accept, "network"), + ...stringField(accept, "asset"), + ...stringField(accept, "amount"), + // accepts[] names the recipient `payTo`; the SDK challenge calls it `to`. + ...(typeof accept["payTo"] === "string" ? { to: accept["payTo"] } : {}), + ...binding, + ...(Object.keys(metadata).length > 0 ? { metadata } : {}), + }; +} + function stringField( source: Record, key: keyof PaymentChallenge, diff --git a/sdk/typescript/src/index.ts b/sdk/typescript/src/index.ts index 2a991ad1..86818ed7 100644 --- a/sdk/typescript/src/index.ts +++ b/sdk/typescript/src/index.ts @@ -66,19 +66,28 @@ export type { X402PaymentAuthorizationOptions, X402PaymentMapOptions, X402PaymentMap, + X402PaymentEnvelope, + X402SvmPaymentEnvelope, + X402SvmPaymentEnvelopeOptions, } from "./x402.js"; export { buildCanonicalMessage, buildX402PaymentAuthorization, buildX402PaymentMap, buildX402PaymentPayload, + buildX402PaymentEnvelope, + buildX402SvmPaymentEnvelope, + encodeX402PaymentHeader, + encodeX402SvmPaymentHeader, + X402_PAYMENT_HEADER, signX402Authorization, x402AuthorizationToPaymentMap, generateNonce, } from "./x402.js"; +export { SDK_VERSION, SDK_CLIENT, HEADER_SDK_CLIENT } from "./version.js"; export { - buildDelegatedX402PaymentMap, + buildDelegatedX402PaymentHeader, buildPayerSignedDelegatedTx, DEFAULT_CONFIRMATION_POLLS, executeSolanaPayment, @@ -100,7 +109,7 @@ export { SOLANA_WSOL_MINT, } from "./solana.js"; export type { - DelegatedX402PaymentMapOptions, + DelegatedX402PaymentHeaderOptions, PayerSignedDelegatedTxOptions, SolanaAssetInfo, SolanaPaymentExecution, @@ -123,6 +132,7 @@ export { } from "./crypto.js"; export type { + DelegatedRegistrationPayment, RegisterRequest, SolanaRegistrationFailure, SolanaRegistrationPaymentOptions, diff --git a/sdk/typescript/src/local-signer.ts b/sdk/typescript/src/local-signer.ts index b45ad308..002a85ab 100644 --- a/sdk/typescript/src/local-signer.ts +++ b/sdk/typescript/src/local-signer.ts @@ -1,3 +1,5 @@ +import { ed25519 } from "@noble/curves/ed25519.js"; + import { Signer } from "./signer.js"; import { generateKeyPair, @@ -24,6 +26,14 @@ export interface LocalSignerOptions { siwsNetwork?: string; /** Origin/URI recorded in the SIWS message. Defaults to https://tiny.place. */ siwsOrigin?: string; + /** + * A previously minted, still-valid `siws:` proof to adopt instead of minting a + * fresh one. Lets a long-lived caller (e.g. the CLI) persist the proof across + * invocations and reuse it until it expires, rather than re-signing a new + * sign-in message every run. Ignored when it does not belong to this key, is + * malformed, or has expired (the signer mints a fresh proof in that case). + */ + siwsToken?: string; } // A minted SIWS proof is reusable until it expires; mirror the website's 7-day @@ -39,6 +49,7 @@ export class LocalSigner extends Signer { private readonly siwsEnabled: boolean; private readonly siwsNetwork: string; private readonly siwsOrigin: string; + private readonly siwsPreMinted: string | undefined; private siwsToken: string | undefined; private constructor(keyPair: KeyPair, options?: LocalSignerOptions) { @@ -50,6 +61,7 @@ export class LocalSigner extends Signer { this.siwsEnabled = options?.siws ?? true; this.siwsNetwork = options?.siwsNetwork ?? "solana:mainnet"; this.siwsOrigin = options?.siwsOrigin ?? "https://tiny.place"; + this.siwsPreMinted = options?.siwsToken; } /** Construct and pre-mint the SIWS proof (when enabled) before returning. */ @@ -59,6 +71,12 @@ export class LocalSigner extends Signer { ): Promise { const signer = new LocalSigner(keyPair, options); if (signer.siwsEnabled) { + // Adopt a persisted, still-valid proof if one was supplied (and belongs to + // this key); otherwise mint a fresh one. Reusing the stored token avoids + // re-signing a sign-in message on every invocation. + if (signer.siwsPreMinted && signer.adoptSiws(signer.siwsPreMinted)) { + return signer; + } await signer.mintSiws(); } return signer; @@ -150,7 +168,10 @@ export class LocalSigner extends Signer { ); } - const signer = await LocalSigner.fromSeed(secretBytes.slice(0, 32), options); + const signer = await LocalSigner.fromSeed( + secretBytes.slice(0, 32), + options, + ); if (secretBytes.length === 64) { const expectedPublicKey = secretBytes.slice(32); if (!bytesEqual(signer.publicKey, expectedPublicKey)) { @@ -214,6 +235,93 @@ export class LocalSigner extends Signer { }; this.siwsToken = `siws:${utf8ToBase64Url(JSON.stringify(token))}`; } + + /** + * Returns the cached `siws:` proof when one is held — exposed so a caller + * (e.g. the CLI) can persist the freshly minted token and reuse it on the next + * run via {@link LocalSignerOptions.siwsToken}, instead of re-minting each run. + */ + persistableSiwsToken(): string | undefined { + return this.siwsToken; + } + + /** + * Adopt a persisted `siws:` proof when it belongs to this key, parses, verifies, + * and is still within its expiration window. Returns true on success (the token + * becomes the cached proof) and false otherwise, so the caller mints a fresh + * proof instead of trusting a stale or foreign token. + */ + private adoptSiws(token: string): boolean { + const parsed = parseSiwsToken(token); + if (!parsed) { + return false; + } + const { message, signature } = parsed; + const address = message.split("\n")[1] ?? ""; + if (address !== this.agentId) { + return false; + } + const expiration = siwsExpiration(message); + if (expiration === undefined || expiration <= Date.now()) { + return false; + } + const messageBytes = new TextEncoder().encode(message); + if (!ed25519.verify(signature, messageBytes, this.publicKey)) { + return false; + } + this.siwsToken = token; + return true; + } +} + +/** Decode a `siws:` token into its signed message text and raw signature bytes. */ +function parseSiwsToken( + token: string, +): { message: string; signature: Uint8Array } | undefined { + if (!token.startsWith("siws:")) { + return undefined; + } + try { + const json = new TextDecoder().decode( + base64urlToBytes(token.slice("siws:".length)), + ); + const parsed = JSON.parse(json) as { + signedMessage?: unknown; + signature?: unknown; + }; + if ( + typeof parsed.signedMessage !== "string" || + typeof parsed.signature !== "string" + ) { + return undefined; + } + return { + message: new TextDecoder().decode(base64ToBytes(parsed.signedMessage)), + signature: base64ToBytes(parsed.signature), + }; + } catch { + return undefined; + } +} + +/** Parse the `Expiration Time:` line of a SIWS message into epoch milliseconds. */ +function siwsExpiration(message: string): number | undefined { + for (const line of message.split("\n")) { + if (line.startsWith("Expiration Time: ")) { + const value = Date.parse(line.slice("Expiration Time: ".length).trim()); + return Number.isNaN(value) ? undefined : value; + } + } + return undefined; +} + +function base64ToBytes(value: string): Uint8Array { + const binary = atob(value); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i += 1) { + bytes[i] = binary.charCodeAt(i); + } + return bytes; } function generateSiwsNonce(): string { diff --git a/sdk/typescript/src/solana.ts b/sdk/typescript/src/solana.ts index d552f4ec..cdd803e1 100644 --- a/sdk/typescript/src/solana.ts +++ b/sdk/typescript/src/solana.ts @@ -1,12 +1,13 @@ import { ed25519 } from "@noble/curves/ed25519.js"; -import type { SigningKey } from "./auth.js"; import type { X402AuthorizationFields } from "./x402.js"; import { buildX402PaymentMap, + encodeX402SvmPaymentHeader, type X402PaymentMap, type X402PaymentMapOptions, } from "./x402.js"; +import type { SigningKey } from "./auth.js"; export const SOLANA_MAINNET_NETWORK = "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp"; @@ -421,8 +422,9 @@ export interface PayerSignedDelegatedTxOptions { * facilitator (CDP/PayAI) as fee payer (account 0) and the agent as the transfer * authority (a read-only second signer). Only the agent signature is filled; the * fee-payer signature slot is left empty (zeroed) for the facilitator to co-sign - * and broadcast at settle time. Returns the base64 wire transaction to attach as - * the x402 payment's `metadata.delegatedTx`. + * and broadcast at settle time. Returns the base64 wire transaction to carry in + * the standard x402 envelope's `payload.transaction` (submitted via the + * `PAYMENT-SIGNATURE` header). * * The payee's destination token account must already exist — the exact scheme * forbids ATA creation in the payment transaction. @@ -565,43 +567,51 @@ function twoSignerFacilitatorMessage(options: { ); } -export interface DelegatedX402PaymentMapOptions - extends Omit { - /** The agent identity signer (signs the x402 authorization fields). */ - signer: SigningKey; +export interface DelegatedX402PaymentHeaderOptions + extends Omit { /** The payment requirements parsed from the 402 challenge. */ payment: Pick< X402AuthorizationFields, "network" | "asset" | "amount" | "to" > & { metadata?: Record }; - /** The payer wallet address recorded on the authorization (defaults to the agent id). */ - from?: string; + /** + * The facilitator's fee-payer pubkey. Defaults to the challenge's + * `payment.metadata.feePayer` (equivalently `accepts[].extra.feePayer`). + */ + feePayer?: string; } /** - * Convenience wrapper: builds the agent-signed facilitator transfer and folds it - * into a complete x402 payment map (with the wire transaction under - * `metadata.delegatedTx`), ready to resubmit to the paid endpoint. The backend - * routes any payment carrying `metadata.delegatedTx` to the facilitator. + * Builds the agent-signed facilitator transfer and encodes it into the standard + * x402 v2 SVM "exact" `PAYMENT-SIGNATURE` header value (the partially-signed + * transaction in `payload.transaction`, the fee payer in + * `accepted.extra.feePayer`). Replaces the proprietary `metadata.delegatedTx` + * payment map — the sponsored register/bounty flows attach this header and send + * NO `payment` field in the request body. The `asset` echoed in the envelope is + * the on-chain SPL mint used to build the transaction. */ -export async function buildDelegatedX402PaymentMap( - options: DelegatedX402PaymentMapOptions, -): Promise { +export async function buildDelegatedX402PaymentHeader( + options: DelegatedX402PaymentHeaderOptions, +): Promise { + const feePayer = options.feePayer ?? options.payment.metadata?.["feePayer"]; + if (!feePayer) { + throw new Error( + "delegated payment requires a facilitator fee payer (challenge metadata.feePayer)", + ); + } const wire = await buildPayerSignedDelegatedTx({ ...options, + feePayer, amount: options.payment.amount, payee: options.payment.to, }); - return buildX402PaymentMap(options.signer, { + return encodeX402SvmPaymentHeader({ network: options.payment.network, - asset: options.payment.asset, amount: options.payment.amount, - to: options.payment.to, - from: options.from, - metadata: { - ...options.payment.metadata, - delegatedTx: wire, - }, + assetMint: options.mint, + payTo: options.payment.to, + feePayer, + transaction: wire, }); } diff --git a/sdk/typescript/src/version.ts b/sdk/typescript/src/version.ts new file mode 100644 index 00000000..f740cdbd --- /dev/null +++ b/sdk/typescript/src/version.ts @@ -0,0 +1,10 @@ +// AUTO-GENERATED from package.json by scripts/gen-version.mjs — do not edit. +// The version is derived from the package manifest (single source of truth); +// it is reported in the X-Tinyplace-SDK request header so the backend can +// recognize first-party clients. +export const SDK_VERSION = "1.0.1"; + +// HEADER_SDK_CLIENT is the request header first-party SDKs send to identify +// themselves; SDK_CLIENT is its value for this TypeScript SDK. +export const HEADER_SDK_CLIENT = "X-Tinyplace-SDK"; +export const SDK_CLIENT = `ts/${SDK_VERSION}`; diff --git a/sdk/typescript/src/x402.ts b/sdk/typescript/src/x402.ts index 10442516..b319e1f7 100644 --- a/sdk/typescript/src/x402.ts +++ b/sdk/typescript/src/x402.ts @@ -200,6 +200,161 @@ export function x402AuthorizationToPaymentMap( return payment; } +/** + * The canonical x402 v2 submission header. A migrated SDK (or any standard x402 + * client) base64-encodes the {@link X402PaymentEnvelope} and submits it in this + * header. The legacy `X-PAYMENT` header is still accepted by the backend for + * backwards compatibility. + */ +export const X402_PAYMENT_HEADER = "PAYMENT-SIGNATURE"; + +/** + * The standard x402 v2 PaymentPayload envelope. A migrated SDK (or any standard + * x402 client) base64-encodes this and submits it in the + * {@link X402_PAYMENT_HEADER} (`PAYMENT-SIGNATURE`) header on the header-based + * payment surfaces (e.g. a2a). tiny.place's authorization signature travels as + * the scheme-specific `payload`. + */ +export interface X402PaymentEnvelope { + x402Version: number; + accepted: { + scheme: X402Scheme; + network: string; + amount: string; + asset: string; + payTo: string; + maxTimeoutSeconds: number; + extra: Record; + }; + payload: { + signature: string; + authorization: Record; + }; + extensions: Record; +} + +/** Builds the standard x402 v2 PaymentPayload envelope from an authorization. */ +export function buildX402PaymentEnvelope( + authorization: X402Authorization, +): X402PaymentEnvelope { + return { + x402Version: 2, + accepted: { + scheme: authorization.scheme, + network: authorization.network, + amount: authorization.amount, + asset: authorization.asset, + payTo: authorization.to, + maxTimeoutSeconds: 60, + extra: { ...(authorization.metadata ?? {}) }, + }, + payload: { + signature: authorization.signature, + authorization: { + from: authorization.from, + to: authorization.to, + value: authorization.amount, + nonce: authorization.nonce, + ...(authorization.expiresAt + ? { validBefore: authorization.expiresAt } + : {}), + }, + }, + extensions: {}, + }; +} + +/** + * Encodes an authorization as the base64 {@link X402_PAYMENT_HEADER} + * (`PAYMENT-SIGNATURE`) header value — the standard x402 v2 submission format. + * Mirrors the backend's x402.ParseInboundPayment. + */ +export function encodeX402PaymentHeader( + authorization: X402Authorization, +): string { + const json = JSON.stringify(buildX402PaymentEnvelope(authorization)); + return toBase64(new TextEncoder().encode(json)); +} + +/** + * Inputs to a standard x402 v2 SVM (Solana) "exact" PaymentPayload envelope — + * everything is read off the parsed 402 challenge plus the partially-signed + * transaction. `assetMint` is the on-chain SPL mint (base58), NOT a symbol. + */ +export interface X402SvmPaymentEnvelopeOptions { + network: string; + amount: string; + /** The on-chain SPL mint (base58) — not a symbol like "USDC". */ + assetMint: string; + payTo: string; + /** The facilitator's fee-payer pubkey (from the challenge `metadata.feePayer`). */ + feePayer: string; + /** The base64 partially-signed Solana transaction. */ + transaction: string; + maxTimeoutSeconds?: number; +} + +/** + * The standard x402 v2 PaymentPayload envelope for the SVM (Solana) "exact" + * scheme. Unlike {@link X402PaymentEnvelope} (the EVM-style envelope whose + * `payload` is `{signature, authorization}`), the SVM scheme carries the whole + * partially-signed transaction in `payload.transaction`, and the facilitator's + * fee payer travels in `accepted.extra.feePayer`. base64-encoded into the + * {@link X402_PAYMENT_HEADER} (`PAYMENT-SIGNATURE`) header. + */ +export interface X402SvmPaymentEnvelope { + x402Version: number; + accepted: { + scheme: X402Scheme; + network: string; + amount: string; + asset: string; + payTo: string; + maxTimeoutSeconds: number; + extra: { feePayer: string }; + }; + payload: { + transaction: string; + }; +} + +/** + * Builds the standard x402 v2 SVM "exact" PaymentPayload envelope from a 402 + * challenge's fields and the partially-signed transaction. + */ +export function buildX402SvmPaymentEnvelope( + options: X402SvmPaymentEnvelopeOptions, +): X402SvmPaymentEnvelope { + return { + x402Version: 2, + accepted: { + scheme: "exact", + network: options.network, + amount: options.amount, + asset: options.assetMint, + payTo: options.payTo, + maxTimeoutSeconds: options.maxTimeoutSeconds ?? 60, + extra: { feePayer: options.feePayer }, + }, + payload: { + transaction: options.transaction, + }, + }; +} + +/** + * Encodes a standard x402 v2 SVM "exact" envelope as the base64 + * {@link X402_PAYMENT_HEADER} (`PAYMENT-SIGNATURE`) header value — standard + * base64 (with padding) of the UTF-8 JSON. This is the sponsored Solana + * payment's submission format; the request body carries no `payment` field. + */ +export function encodeX402SvmPaymentHeader( + options: X402SvmPaymentEnvelopeOptions, +): string { + const json = JSON.stringify(buildX402SvmPaymentEnvelope(options)); + return toBase64(new TextEncoder().encode(json)); +} + function paymentReferences( options: X402PaymentReferenceOptions, ): X402PaymentMap { diff --git a/sdk/typescript/tests/cli.test.ts b/sdk/typescript/tests/cli.test.ts index e7202735..d92deb50 100644 --- a/sdk/typescript/tests/cli.test.ts +++ b/sdk/typescript/tests/cli.test.ts @@ -463,7 +463,9 @@ describe("tinyplace CLI", () => { }>; // `profile-feed` is a raw command; bare `feed` is now a workflow, so it (like // `status`) is excluded from the raw-only listing. - expect(commands.find((command) => command.name === "profile-feed")).toBeTruthy(); + expect( + commands.find((command) => command.name === "profile-feed"), + ).toBeTruthy(); expect(commands.find((command) => command.name === "feed")).toBeFalsy(); expect(commands.find((command) => command.name === "status")).toBeFalsy(); }); @@ -526,11 +528,15 @@ describe("tinyplace CLI", () => { expect(first.code).toBe(0); const persisted = JSON.parse(await readFile(configPath, "utf8")); expect(persisted.secretKey).toMatch(/^[0-9a-f]{64}$/); + // The SIWS proof is minted once and persisted alongside the key. + expect(persisted.siwsToken).toMatch(/^siws:/); const second = await runTinyPlaceCli(["version"]); expect(second.code).toBe(0); const reused = JSON.parse(await readFile(configPath, "utf8")); expect(reused.secretKey).toBe(persisted.secretKey); + // The stored proof is adopted and reused verbatim, not re-minted each run. + expect(reused.siwsToken).toBe(persisted.siwsToken); } finally { if (savedConfig === undefined) delete process.env.TINYPLACE_CONFIG; else process.env.TINYPLACE_CONFIG = savedConfig; @@ -590,7 +596,10 @@ describe("tinyplace CLI", () => { // No secret key: init must mint the wallet itself by grinding. "1" is a // leadable base58 prefix, so the grind resolves near-instantly. const result = await runTinyPlaceCli(["init", "--vanity", "1"], { - env: { TINYPLACE_ENDPOINT: "https://example.test", TINYPLACE_CONFIG: configPath }, + env: { + TINYPLACE_ENDPOINT: "https://example.test", + TINYPLACE_CONFIG: configPath, + }, fetch: async (input: RequestInfo | URL, init?: RequestInit) => { requests.push(new Request(input, init)); return Response.json({ ok: true }); @@ -603,7 +612,9 @@ describe("tinyplace CLI", () => { expect(parsed.wallet.vanity.prefix).toBe("1"); expect(parsed.wallet.vanity.matched).toBe(true); // The ground key is persisted so later runs reuse the same wallet. - const saved = JSON.parse(await readFile(configPath, "utf8")) as { secretKey?: string }; + const saved = JSON.parse(await readFile(configPath, "utf8")) as { + secretKey?: string; + }; expect(saved.secretKey).toMatch(/^[0-9a-f]{64}$/); // The onboarding link is minted from the ground identity. expect(parsed.onboardUrl).toContain("/onboard#grant="); @@ -700,10 +711,12 @@ describe("tinyplace CLI", () => { const parsed = JSON.parse(result.stdout); expect(parsed.status).toBe("done"); expect(parsed.suggestions[0].run).toBe("tinyplace read"); - expect(requests.some((request) => request.url.includes("/directory/resolve"))).toBe( + expect( + requests.some((request) => request.url.includes("/directory/resolve")), + ).toBe(true); + expect(requests.some((request) => request.url.includes("/bundle"))).toBe( true, ); - expect(requests.some((request) => request.url.includes("/bundle"))).toBe(true); const send = requests.find((request) => request.method === "PUT"); expect(send?.url).toContain("/messages"); @@ -722,18 +735,20 @@ describe("tinyplace CLI", () => { fetch: async (input: RequestInfo | URL) => { const url = String(input instanceof Request ? input.url : input); if (url.includes("/inbox/counts")) return Response.json({ unread: 1 }); - if (url.includes("/inbox")) return Response.json({ items: [{ id: "i1" }] }); + if (url.includes("/inbox")) + return Response.json({ items: [{ id: "i1" }] }); if (url.includes("/graphql")) return Response.json({ data: { bounties: [{ bountyId: "b1" }] } }); - if (url.includes("/keys/")) return Response.json({ lowOneTimePreKeys: false }); + if (url.includes("/keys/")) + return Response.json({ lowOneTimePreKeys: false }); return Response.json({ messages: [{ id: "m1" }] }); }, }); expect(result.code).toBe(0); - const runs = (JSON.parse(result.stdout).suggestions as Array<{ run: string }>).map( - (suggestion) => suggestion.run, - ); + const runs = ( + JSON.parse(result.stdout).suggestions as Array<{ run: string }> + ).map((suggestion) => suggestion.run); expect(runs).toEqual( expect.arrayContaining([ "tinyplace raw inbox-read i1", @@ -747,7 +762,10 @@ describe("tinyplace CLI", () => { const env = { TINYPLACE_ENDPOINT: "https://example.test" }; const capture = (): { requests: Array; - fetch: (input: RequestInfo | URL, init?: RequestInit) => Promise; + fetch: ( + input: RequestInfo | URL, + init?: RequestInit, + ) => Promise; } => { const requests: Array = []; return { @@ -769,7 +787,8 @@ describe("tinyplace CLI", () => { if (query.includes("comments(")) data["comments"] = []; if (query.includes("postLikers(")) data["postLikers"] = { likers: [], count: 0 }; - if (query.includes("homeFeed(")) data["homeFeed"] = { items: [], count: 0 }; + if (query.includes("homeFeed(")) + data["homeFeed"] = { items: [], count: 0 }; return Response.json({ data }); }, }; @@ -800,13 +819,16 @@ describe("tinyplace CLI", () => { it("passes the bounties status filter as a GraphQL variable", async () => { const requests: Array = []; - const result = await runTinyPlaceCli(["raw", "bounties", "--status", "open"], { - env: { TINYPLACE_ENDPOINT: "https://example.test" }, - fetch: async (input: RequestInfo | URL, init?: RequestInit) => { - requests.push(new Request(input, init)); - return Response.json({ data: { bounties: [] } }); + const result = await runTinyPlaceCli( + ["raw", "bounties", "--status", "open"], + { + env: { TINYPLACE_ENDPOINT: "https://example.test" }, + fetch: async (input: RequestInfo | URL, init?: RequestInit) => { + requests.push(new Request(input, init)); + return Response.json({ data: { bounties: [] } }); + }, }, - }); + ); expect(result.code).toBe(0); const body = (await requests[0].clone().json()) as { variables: { status?: string }; diff --git a/sdk/typescript/tests/flows.test.ts b/sdk/typescript/tests/flows.test.ts index c2929d85..62429741 100644 --- a/sdk/typescript/tests/flows.test.ts +++ b/sdk/typescript/tests/flows.test.ts @@ -2,7 +2,10 @@ import { describe, expect, it } from "vitest"; import { HARNESS_CLI_COMMANDS, runTinyPlaceCli } from "../src/cli.js"; const SEED = "01".repeat(32); -const ENV = { TINYPLACE_ENDPOINT: "https://example.test", TINYPLACE_SECRET_KEY: SEED }; +const ENV = { + TINYPLACE_ENDPOINT: "https://example.test", + TINYPLACE_SECRET_KEY: SEED, +}; /** * Captures every outbound request and answers each with `{ ok: true }`. Reads @@ -40,7 +43,12 @@ function recordingFetch(): { likeCount: 0, viewerHasLiked: false, createdAt: "2026-01-01T00:00:00Z", - author: { handle: "@peer", cryptoId: "peerId", displayName: "Peer", verified: false }, + author: { + handle: "@peer", + cryptoId: "peerId", + displayName: "Peer", + verified: false, + }, }, }, ], @@ -110,7 +118,10 @@ function registrationFetch(opts?: { usdcBalance?: string }): { }); } if (path === "/solana/rpc") { - const body = (await request.clone().json()) as { id?: string; method: string }; + const body = (await request.clone().json()) as { + id?: string; + method: string; + }; rpcMethods.push(body.method); if (body.method === "getBalance") { return Response.json({ @@ -144,7 +155,127 @@ function registrationFetch(opts?: { usdcBalance?: string }): { result: { context: { slot: 1 }, value }, }); } - return Response.json({ jsonrpc: "2.0", id: body.id ?? null, result: null }); + return Response.json({ + jsonrpc: "2.0", + id: body.id ?? null, + result: null, + }); + } + return Response.json({ ok: true }); + }, + }; +} + +/** + * A fetch mimicking the GASLESS delegated registration surface: the 402 challenge + * advertises a facilitator `feePayer` (so settlement goes through the delegated + * path), `/solana` advertises the USDC mint, and the RPC serves the token-account + * + blockhash lookups the delegated-tx builder needs. The final create succeeds + * with `{ id }`. `rpcMethods` records each JSON-RPC method so a test can assert no + * `sendTransaction` (the facilitator broadcasts, not the client). + */ +function delegatedRegistrationFetch(): { + requests: Array; + rpcMethods: Array; + fetch: (input: RequestInfo | URL, init?: RequestInit) => Promise; +} { + const requests: Array = []; + const rpcMethods: Array = []; + let registryCalls = 0; + return { + requests, + rpcMethods, + fetch: async (input: RequestInfo | URL, init?: RequestInit) => { + const request = new Request(input, init); + requests.push(request); + const path = new URL(request.url).pathname; + if (path === "/registry/names") { + registryCalls += 1; + // First call (the probe) → 402; the retry carrying the payment map → 200. + if (registryCalls === 1) { + return Response.json( + { + error: "payment required", + payment: { + scheme: "exact", + network: "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + asset: "USDC", + amount: "1000000", + to: "F8zMkwbG3hp1k2t3eQWQh9bsh8qrK8CtqfZ2dBrrW3Ee", + nonce: "n1", + metadata: { feePayer: "11111111111111111111111111111111" }, + }, + }, + { status: 402 }, + ); + } + return Response.json({ id: "@me", username: "@me" }); + } + if (path === "/solana") { + return Response.json({ + network: "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + name: "Solana", + nativeAsset: "SOL", + explorerUrl: "https://solscan.io", + assets: [ + { symbol: "SOL", decimals: 9 }, + { + symbol: "USDC", + address: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + decimals: 6, + }, + ], + }); + } + if (path === "/solana/rpc") { + const body = (await request.clone().json()) as { + id?: string; + method: string; + }; + rpcMethods.push(body.method); + if (body.method === "getBalance") { + return Response.json({ + jsonrpc: "2.0", + id: body.id, + result: { context: { slot: 1 }, value: 0 }, + }); + } + if (body.method === "getTokenAccountsByOwner") { + return Response.json({ + jsonrpc: "2.0", + id: body.id, + result: { + value: [ + { + pubkey: "89t6Va3uXRRzmPzfrt2VTPpGatBDFoj9gNeRVyeANKdK", + account: { + data: { + parsed: { + info: { + tokenAmount: { amount: "1000000000", decimals: 6 }, + }, + }, + }, + }, + }, + ], + }, + }); + } + if (body.method === "getLatestBlockhash") { + return Response.json({ + jsonrpc: "2.0", + id: body.id, + result: { + value: { blockhash: "11111111111111111111111111111111" }, + }, + }); + } + return Response.json({ + jsonrpc: "2.0", + id: body.id ?? null, + result: null, + }); } return Response.json({ ok: true }); }, @@ -201,7 +332,15 @@ describe("agent flows CLI", () => { it("post-bounty previews and performs nothing without --execute", async () => { const { requests, fetch } = recordingFetch(); const result = await runTinyPlaceCli( - ["post-bounty", "--title", "Best logo", "--amount", "10", "--asset", "USDC"], + [ + "post-bounty", + "--title", + "Best logo", + "--amount", + "10", + "--asset", + "USDC", + ], { env: ENV, fetch }, ); @@ -218,7 +357,16 @@ describe("agent flows CLI", () => { it("post-bounty --execute creates the bounty when no funding is required", async () => { const { requests, fetch } = recordingFetch(); const result = await runTinyPlaceCli( - ["post-bounty", "--title", "Best logo", "--amount", "10", "--asset", "USDC", "--execute"], + [ + "post-bounty", + "--title", + "Best logo", + "--amount", + "10", + "--asset", + "USDC", + "--execute", + ], { env: ENV, fetch }, ); @@ -226,15 +374,25 @@ describe("agent flows CLI", () => { const body = JSON.parse(result.stdout); expect(body.status).toBe("done"); expect(body.suggestions[0].run).toContain("tinyplace submissions"); - expect(requests.map((request) => [request.method, new URL(request.url).pathname])).toEqual([ - ["POST", "/bounties"], - ]); + expect( + requests.map((request) => [ + request.method, + new URL(request.url).pathname, + ]), + ).toEqual([["POST", "/bounties"]]); }); it("submit posts a submission to the bounty", async () => { const { requests, fetch } = recordingFetch(); const result = await runTinyPlaceCli( - ["submit", "bnt_42", "--url", "https://example.test/work", "--note", "done"], + [ + "submit", + "bnt_42", + "--url", + "https://example.test/work", + "--note", + "done", + ], { env: ENV, fetch }, ); @@ -247,15 +405,23 @@ describe("agent flows CLI", () => { it("join hits the group join route", async () => { const { requests, fetch } = recordingFetch(); - const result = await runTinyPlaceCli(["join", "grp_7"], { env: ENV, fetch }); + const result = await runTinyPlaceCli(["join", "grp_7"], { + env: ENV, + fetch, + }); expect(result.code).toBe(0); - expect(new URL(requests[0].url).pathname).toBe("/directory/groups/grp_7/join"); + expect(new URL(requests[0].url).pathname).toBe( + "/directory/groups/grp_7/join", + ); }); it("follow with a raw id needs no resolution and posts to /follows", async () => { const { requests, fetch } = recordingFetch(); - const result = await runTinyPlaceCli(["follow", "agentXYZ"], { env: ENV, fetch }); + const result = await runTinyPlaceCli(["follow", "agentXYZ"], { + env: ENV, + fetch, + }); expect(result.code).toBe(0); expect(requests).toHaveLength(1); @@ -303,9 +469,13 @@ describe("agent flows CLI", () => { const body = JSON.parse(result.stdout); expect(body.count).toBe(1); expect(body.items).toHaveLength(1); - const runs = body.suggestions.map((suggestion: { run: string }) => suggestion.run); + const runs = body.suggestions.map( + (suggestion: { run: string }) => suggestion.run, + ); expect(runs).toContain("tinyplace raw feed-like @peer pst_1"); - expect(runs).toContain('tinyplace raw feed-comment @peer pst_1 --data \'{"body":"..."}\''); + expect(runs).toContain( + 'tinyplace raw feed-comment @peer pst_1 --data \'{"body":"..."}\'', + ); }); it("registers the feed workflow and renames the raw profile feed", () => { @@ -319,7 +489,10 @@ describe("agent flows CLI", () => { it("register previews the on-chain fee and settles nothing without --execute", async () => { const { requests, fetch } = registrationFetch(); - const result = await runTinyPlaceCli(["register", "@me"], { env: ENV, fetch }); + const result = await runTinyPlaceCli(["register", "@me"], { + env: ENV, + fetch, + }); expect(result.code).toBe(0); const body = JSON.parse(result.stdout); @@ -346,9 +519,61 @@ describe("agent flows CLI", () => { expect(result.code).toBe(0); expect(JSON.parse(result.stdout).status).toBe("done"); - expect(requests.some((request) => new URL(request.url).pathname.startsWith("/registry"))).toBe( - true, + expect( + requests.some((request) => + new URL(request.url).pathname.startsWith("/registry"), + ), + ).toBe(true); + }); + + it("register --execute settles USDC gaslessly via the delegated facilitator path", async () => { + const { requests, rpcMethods, fetch } = delegatedRegistrationFetch(); + const result = await runTinyPlaceCli(["register", "@me", "--execute"], { + env: ENV, + fetch, + }); + + expect(result.code).toBe(0); + expect(JSON.parse(result.stdout).status).toBe("done"); + + // The handle is claimed by re-POSTing /registry/names. The gasless delegated + // path submits the standard x402 v2 envelope via the PAYMENT-SIGNATURE + // header — the request body carries NO `payment` field. + const registryPosts = requests.filter( + (request) => + request.method === "POST" && + new URL(request.url).pathname === "/registry/names", ); + expect(registryPosts.length).toBeGreaterThanOrEqual(2); + const settledPost = registryPosts.at(-1)!; + const settled = (await settledPost.clone().json()) as { + payment?: Record; + }; + // No proprietary metadata.delegatedTx map and no body `payment` at all. + expect(settled.payment).toBeUndefined(); + + // The payment proof is the standard x402 v2 SVM envelope in PAYMENT-SIGNATURE. + const headerValue = settledPost.headers.get("PAYMENT-SIGNATURE"); + expect(headerValue).toBeTruthy(); + const envelope = JSON.parse( + Buffer.from(headerValue!, "base64").toString("utf8"), + ) as { + x402Version: number; + accepted: { scheme: string; extra: { feePayer: string } }; + payload: { transaction: string }; + }; + expect(envelope.x402Version).toBe(2); + expect(envelope.accepted.scheme).toBe("exact"); + expect(envelope.accepted.extra.feePayer).toBe( + "11111111111111111111111111111111", + ); + expect(envelope.payload.transaction.length).toBeGreaterThan(0); + expect(headerValue).not.toContain("delegatedTx"); + + // Gasless: the client builds + signs but never broadcasts the transfer + // (the facilitator co-signs as fee payer and submits it). + expect(rpcMethods).toContain("getLatestBlockhash"); + expect(rpcMethods).not.toContain("sendTransaction"); }); it("register --execute pre-flights the balance and never broadcasts when underfunded", async () => { diff --git a/sdk/typescript/tests/local-signer-siws.test.ts b/sdk/typescript/tests/local-signer-siws.test.ts index 34bbc023..3252fb08 100644 --- a/sdk/typescript/tests/local-signer-siws.test.ts +++ b/sdk/typescript/tests/local-signer-siws.test.ts @@ -30,7 +30,11 @@ describe("LocalSigner SIWS minting", () => { // The proof is signed by this key and names this wallet address. expect(address).toBe(signer.agentId); expect( - ed25519.verify(signature, new TextEncoder().encode(message), signer.publicKey), + ed25519.verify( + signature, + new TextEncoder().encode(message), + signer.publicKey, + ), ).toBe(true); expect(message).toContain( "tiny.place wants you to sign in with your Solana account:", @@ -52,6 +56,49 @@ describe("LocalSigner SIWS minting", () => { ); }); + it("caches the minted proof and exposes it for persistence", async () => { + const signer = await LocalSigner.fromSeed(seed); + const token = signer.persistableSiwsToken(); + // The persistable token is the same well-formed proof the auth path emits, + // and it is stable across reads (cached, not re-minted per call). + expect(token).toBe(signer.siwsSignature()); + expect(token?.startsWith("siws:")).toBe(true); + expect(signer.persistableSiwsToken()).toBe(token); + }); + + it("adopts a persisted, still-valid proof instead of re-minting it", async () => { + const minted = await LocalSigner.fromSeed(seed); + const persisted = minted.persistableSiwsToken(); + expect(persisted).toBeTruthy(); + + // A later run hands the stored token back: it is reused verbatim, not re-minted. + const reloaded = await LocalSigner.fromSeed(seed, { siwsToken: persisted }); + expect(reloaded.siwsSignature()).toBe(persisted); + }); + + it("ignores a foreign or malformed persisted proof and mints a fresh one", async () => { + // A proof minted by a DIFFERENT key must not be adopted (its address line + // names the other wallet), and garbage must not crash adoption. + const otherSeed = new Uint8Array(32).map((_, i) => (i * 7 + 1) & 0xff); + const foreign = ( + await LocalSigner.fromSeed(otherSeed) + ).persistableSiwsToken(); + + const withForeign = await LocalSigner.fromSeed(seed, { + siwsToken: foreign, + }); + expect(withForeign.siwsSignature()).not.toBe(foreign); + expect(withForeign.siwsSignature().startsWith("siws:")).toBe(true); + expect(decodeSiws(withForeign.siwsSignature()).address).toBe( + withForeign.agentId, + ); + + const withGarbage = await LocalSigner.fromSeed(seed, { + siwsToken: "siws:not-base64url!!", + }); + expect(withGarbage.siwsSignature().startsWith("siws:")).toBe(true); + }); + it("falls back to raw freshness-bound signatures when SIWS is disabled", async () => { const signer = await LocalSigner.fromSeed(seed, { siws: false }); expect(signer.siwsSignature()).toBe(""); diff --git a/sdk/typescript/tests/registry.test.ts b/sdk/typescript/tests/registry.test.ts index 8164dd14..0a78a88a 100644 --- a/sdk/typescript/tests/registry.test.ts +++ b/sdk/typescript/tests/registry.test.ts @@ -57,6 +57,38 @@ async function verifyFreshSignature( } describe("RegistryApi", () => { + it("sends the X-Tinyplace-SDK identification header on every request", async () => { + const signer = await LocalSigner.fromSeed(new Uint8Array(32).fill(23), { + siws: false, + }); + const requests: Array = []; + const client = new TinyPlaceClient({ + baseUrl: "https://example.test", + signer, + fetch: async (input, init) => { + requests.push(new Request(input, init)); + return Response.json({ + username: "@agent", + cryptoId: signer.agentId, + publicKey: signer.publicKeyBase64, + registeredAt: "2026-06-13T00:00:00Z", + expiresAt: "2027-06-13T00:00:00Z", + status: "active", + updatedAt: "2026-06-13T00:00:00Z", + }); + }, + }); + + await client.registry.register({ + username: "@agent", + cryptoId: signer.agentId, + publicKey: signer.publicKeyBase64, + }); + + expect(requests).toHaveLength(1); + expect(requests[0]!.headers.get("X-Tinyplace-SDK")).toMatch(/^ts\//u); + }); + it("signs registration over cryptoId, publicKey, username and null payment methods", async () => { const signer = await LocalSigner.fromSeed(new Uint8Array(32).fill(19), { siws: false }); const requests: Array = []; diff --git a/sdk/typescript/tests/solana-delegated.test.ts b/sdk/typescript/tests/solana-delegated.test.ts index 29fbaa38..e3fc2ae0 100644 --- a/sdk/typescript/tests/solana-delegated.test.ts +++ b/sdk/typescript/tests/solana-delegated.test.ts @@ -2,12 +2,14 @@ import { ed25519 } from "@noble/curves/ed25519.js"; import { describe, expect, it } from "vitest"; import { - buildDelegatedX402PaymentMap, + buildDelegatedX402PaymentHeader, buildPayerSignedDelegatedTx, LocalSigner, SOLANA_MAINNET_NETWORK, } from "../src/index.js"; +const USDC_MINT = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"; + // A fee payer of all-"1" base58 decodes to 32 zero bytes, so account index 0 of // the assembled message must be 32 zeros — a clean check that the facilitator is // the fee payer without needing a base58 decoder in the test. @@ -120,16 +122,15 @@ describe("buildPayerSignedDelegatedTx", () => { }); }); -describe("buildDelegatedX402PaymentMap", () => { - it("folds the agent-signed wire transaction into the payment map under metadata.delegatedTx", async () => { +describe("buildDelegatedX402PaymentHeader", () => { + it("encodes the agent-signed wire transaction into the standard x402 v2 SVM PAYMENT-SIGNATURE envelope", async () => { const { secretKey, signer } = await createSigner(); - const payment = await buildDelegatedX402PaymentMap({ + const header = await buildDelegatedX402PaymentHeader({ rpcUrl: "https://solana.example.test", - signer, secretKey, feePayer: ZERO_FEE_PAYER, - mint: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + mint: USDC_MINT, decimals: 6, payment: { network: SOLANA_MAINNET_NETWORK, @@ -141,16 +142,54 @@ describe("buildDelegatedX402PaymentMap", () => { fetch: mockFetch([]), }); - expect(payment).toMatchObject({ - network: SOLANA_MAINNET_NETWORK, - asset: "USDC", - amount: "1000000", - to: PAYEE, - "metadata.identity": "@agent", - "metadata.purpose": "registration", - }); - expect(typeof payment["metadata.delegatedTx"]).toBe("string"); - expect(payment["metadata.delegatedTx"]!.length).toBeGreaterThan(0); - expect(payment["signature"]).toBeTruthy(); + // The header value is standard base64 (with padding) of the envelope JSON. + const envelope = JSON.parse( + Buffer.from(header, "base64").toString("utf8"), + ) as { + x402Version: number; + accepted: { + scheme: string; + network: string; + amount: string; + asset: string; + payTo: string; + maxTimeoutSeconds: number; + extra: { feePayer: string }; + }; + payload: { transaction: string }; + // The proprietary delegatedTx map transport must be gone. + metadata?: Record; + }; + + expect(envelope.x402Version).toBe(2); + expect(envelope.accepted.scheme).toBe("exact"); + expect(envelope.accepted.network).toBe(SOLANA_MAINNET_NETWORK); + expect(envelope.accepted.amount).toBe("1000000"); + // `asset` is the on-chain SPL mint (base58), NOT a symbol like "USDC". + expect(envelope.accepted.asset).toBe(USDC_MINT); + expect(envelope.accepted.payTo).toBe(PAYEE); + expect(envelope.accepted.maxTimeoutSeconds).toBe(60); + expect(envelope.accepted.extra.feePayer).toBe(ZERO_FEE_PAYER); + + // No proprietary metadata.delegatedTx transport anywhere on the envelope. + expect(envelope.metadata).toBeUndefined(); + expect(JSON.stringify(envelope)).not.toContain("delegatedTx"); + + // payload.transaction is a non-empty base64 tx that decodes to a 2-signature + // legacy tx: the fee-payer slot is zeroed, the authority slot is filled. + expect(typeof envelope.payload.transaction).toBe("string"); + expect(envelope.payload.transaction.length).toBeGreaterThan(0); + + const tx = new Uint8Array( + Buffer.from(envelope.payload.transaction, "base64"), + ); + expect(tx[0]).toBe(2); // two signature slots + const feePayerSignature = tx.slice(1, 65); + const authoritySignature = tx.slice(65, 129); + const message = tx.slice(129); + expect(feePayerSignature.every((b) => b === 0)).toBe(true); + expect(ed25519.verify(authoritySignature, message, signer.publicKey)).toBe( + true, + ); }); }); diff --git a/sdk/typescript/tests/tinyplace-error.test.ts b/sdk/typescript/tests/tinyplace-error.test.ts index 3366af9f..49f7bfda 100644 --- a/sdk/typescript/tests/tinyplace-error.test.ts +++ b/sdk/typescript/tests/tinyplace-error.test.ts @@ -25,6 +25,57 @@ describe("TinyPlaceError self-classification", () => { expect(error.paymentRequired?.payment.amount).toBe("1000"); }); + it("parses the challenge from the standard x402 v2 accepts[] array", () => { + const error = new TinyPlaceError(402, { + error: "payment required", + x402Version: 2, + resource: { url: "https://tiny.place" }, + accepts: [ + { + scheme: "exact", + network: "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + amount: "1000000", + asset: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + payTo: "treasury-address", + maxTimeoutSeconds: 60, + extra: { + domain: "tiny.place", + feePayer: "facilitator-address", + from: "payer-address", + nonce: "nonce-xyz", + expiresAt: "2026-06-21T00:00:00Z", + }, + }, + ], + extensions: {}, + }); + expect(error.code).toBe("payment_required"); + const payment = error.paymentRequired?.payment; + expect(payment?.amount).toBe("1000000"); + expect(payment?.asset).toBe("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"); + // payTo is mapped to `to`. + expect(payment?.to).toBe("treasury-address"); + // Binding fields are promoted out of `extra` to the top level. + expect(payment?.from).toBe("payer-address"); + expect(payment?.nonce).toBe("nonce-xyz"); + expect(payment?.expiresAt).toBe("2026-06-21T00:00:00Z"); + // The remaining extra becomes the signed metadata; binding keys are not + // duplicated into it (they would corrupt the canonical signing message). + expect(payment?.metadata?.["domain"]).toBe("tiny.place"); + expect(payment?.metadata?.["feePayer"]).toBe("facilitator-address"); + expect(payment?.metadata?.["nonce"]).toBeUndefined(); + expect(payment?.metadata?.["from"]).toBeUndefined(); + }); + + it("falls back to the legacy payment field when accepts[] is absent", () => { + const error = new TinyPlaceError(402, { + error: "payment required", + payment: { amount: "500", asset: "USDC", to: "treasury" }, + }); + expect(error.paymentRequired?.payment.amount).toBe("500"); + expect(error.paymentRequired?.payment.to).toBe("treasury"); + }); + it("serializes via toJSON with the recovery fields", () => { const error = new TinyPlaceError(404, { error: "missing" }); const json = error.toJSON(); diff --git a/sdk/typescript/tests/x402.test.ts b/sdk/typescript/tests/x402.test.ts index 4778d05d..08c203bd 100644 --- a/sdk/typescript/tests/x402.test.ts +++ b/sdk/typescript/tests/x402.test.ts @@ -5,6 +5,8 @@ import { buildX402PaymentAuthorization, buildX402PaymentMap, buildX402PaymentPayload, + encodeX402PaymentHeader, + X402_PAYMENT_HEADER, signX402Authorization, x402AuthorizationToPaymentMap, type SigningKey, @@ -84,6 +86,43 @@ describe("x402 helpers", () => { }); }); + it("encodes a standard x402 v2 X-PAYMENT envelope", () => { + const authorization: X402Authorization = { + scheme: "exact", + network: "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + asset: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + amount: "1000000", + from: payerAddress, + to: "treasury", + nonce: "pay_test", + expiresAt: "2026-06-21T00:00:00Z", + signature: "v1:ts:nonce:sig", + metadata: { domain: "tiny.place", feePayer: "facilitator" }, + }; + + const header = encodeX402PaymentHeader(authorization); + // Decodes to a standard v2 PaymentPayload the backend parser accepts. + const decoded = JSON.parse( + new TextDecoder().decode( + Uint8Array.from(atob(header), (c) => c.charCodeAt(0)), + ), + ); + expect(decoded.x402Version).toBe(2); + expect(decoded.accepted.payTo).toBe("treasury"); + expect(decoded.accepted.amount).toBe("1000000"); + expect(decoded.accepted.extra.feePayer).toBe("facilitator"); + expect(decoded.payload.signature).toBe("v1:ts:nonce:sig"); + expect(decoded.payload.authorization.from).toBe(payerAddress); + expect(decoded.payload.authorization.value).toBe("1000000"); + expect(decoded.payload.authorization.validBefore).toBe( + "2026-06-21T00:00:00Z", + ); + }); + + it("exposes the canonical x402 v2 submission header", () => { + expect(X402_PAYMENT_HEADER).toBe("PAYMENT-SIGNATURE"); + }); + it("keeps metadata sorted in canonical signing messages", () => { expect( buildCanonicalMessage({