Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions sdk/python/src/tinyplace/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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",
Expand All @@ -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",
Expand Down
74 changes: 44 additions & 30 deletions sdk/python/src/tinyplace/api/bounties.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Comment on lines +50 to +53

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Sanitize delegated bounty requests before probing and retrying.

If request already contains "payment", the probe is not paymentless and the funded create can carry both body payment and PAYMENT-SIGNATURE. Clone and strip "payment" for the delegated path.

Proposed fix
     async def create(self, request: JsonDict, *, payment_header: str | None = None) -> Json:
+        body = dict(request)
+        if payment_header:
+            body.pop("payment", None)
         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, headers=headers
+            "/bounties", str(request.get("creator") or ""), body, headers=headers
         )
@@
-        challenge = await self._create_challenge(request)
+        paymentless_request = dict(request)
+        paymentless_request.pop("payment", None)
+        challenge = await self._create_challenge(paymentless_request)
@@
-        bounty = await self._create_retrying(request, payment_header, attempts, interval_ms)
+        bounty = await self._create_retrying(
+            paymentless_request, payment_header, attempts, interval_ms
+        )

Also applies to: 82-115

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@sdk/python/src/tinyplace/api/bounties.py` around lines 50 - 53, The create
method needs to sanitize the request before delegating to the HTTP call when
payment information is present. Before passing the request to
post_directory_auth_as, check if the request contains a "payment" field. If it
does, create a clone of the request dictionary and remove the "payment" key from
the clone, then use this sanitized clone for the delegated HTTP call instead of
the original request. This prevents the delegated path from carrying both body
payment and the PAYMENT-SIGNATURE header. Apply this same sanitization pattern
to all other similar methods in the range 82-115 that use delegation.

)

async def create_with_solana_payment(
Expand All @@ -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")
Expand All @@ -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.
Expand All @@ -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
Expand Down
79 changes: 44 additions & 35 deletions sdk/python/src/tinyplace/api/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -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")
Expand All @@ -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(
Expand All @@ -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
Expand Down
Loading
Loading