Skip to content

Commit 4b10687

Browse files
committed
feat: warn when minting a rotating Solana MPP recipient
Stripe-multichain per-PaymentIntent Solana deposit addresses rotate and have no pre-existing USDC ATA. @solana/mpp >=0.6.0 does not create the primary recipient's ATA in the charge transaction, so those settles fail simulation with InstructionError [2, InvalidAccountData]. Emit a one-time logging.warning from the mint path when a rotating Solana recipient is minted, pointing at the static-recipient path (static_recipients={'solana': ...} + a pre-funded ATA). Reframe the README stripe-multichain guidance from a cost optimization to the requirement it is. Parity with node-commerce 2.6.1.
1 parent 7e33031 commit 4b10687

3 files changed

Lines changed: 55 additions & 1 deletion

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ pip install 'agentscore-commerce[fastapi,x402,coinbase]'
3030
| `agentscore_commerce.payment` | `networks`, `USDC`, `rails` registries; `payment_directive`, `build_payment_directive`, `www_authenticate_header`, `payment_required_header`, `alias_amount_fields` (opt-in v1↔v2 amount-field shim that adds both `amount` and `maxAmountRequired` to an entry. The 402 builders do NOT apply it by default, strict x402 v2 settlement matches the agent's echoed requirement by exact comparison, so an extra field the server's rebuilt requirement lacks breaks settle; use only when you know a client is hardcoded to read `maxAmountRequired`), `settlement_override_header`, `dispatch_settlement_by_network`, `extract_payment_signer` (accepts positional `x402_payment_header` AND/OR `authorization_header=` kwarg; recovers signer from x402 EIP-3009 `payload.authorization.from` OR MPP `Authorization: Payment <base64>` `did:pkh:eip155:<chain>:<addr>` / `did:pkh:solana:<genesis>:<addr>` source DID), `detect_rail_from_headers` (returns `"x402"` / `"mpp"` / `None` from inbound headers), `register_x402_schemes_v1_v2`; drop-in x402 helpers: `validate_x402_network_config` (boot-time guard), `verify_x402_request` (parse + validate inbound X-Payment), `process_x402_settle` (verify-then-settle with one call), `classify_x402_settle_result` (maps the tagged settle result to a recommended HTTP status / code / next_steps so merchants get a controlled envelope without coupling to facilitator-specific error text), `classify_orchestration_error` (same `ClassifiedX402Error` shape but for uncaught exceptions thrown elsewhere in the orchestration; returns `None` for unknown errors so merchants rethrow instead of swallowing); `zero_amount_carve_out` (skip CDP / pympp upstream verify+settle for $0 settles where the upstream rejects value=0 payloads; parses the credential, lifts signer + network, returns a `ZeroSettleResult` shaped identically to the success path so callers branch on rail, not on result shape); `usd_to_atomic` (Decimal-based USD → atomic int, ROUND_HALF_UP, for Tempo / Solana / Base USDC amount construction). |
3131
| `agentscore_commerce.discovery` | `is_discovery_probe_request`, `build_discovery_probe_response` (with optional `x402_sample` for x402-aware crawlers like `awal x402 details`), `sample_x402_accept_for_network` (USDC sample-accept builder for known CAIP-2 networks), `build_well_known_mpp`, `build_llms_txt` + `llms_txt_identity_section` + `llms_txt_payment_section` (compact + verbose modes), `build_skill_md` (Claude-Skill-compatible `/skill.md` agent-discovery manifest; strictly agent-facing data only, no internal posture), `build_redemption_skill_md` (delivery-neutral redemption-code template, printed mailers, emailed codes, API trial credits all covered; `endpoint_path`/`delivery_intro`/`body_shape`/`body_rules`/`extra_recovery_rows` overrides for non-goods shapes), `build_merchant_index_json` (canonical `/` discovery body), `standard_endpoint_descriptions(kind=)` (canonical method+path → description map for goods vs api merchants; optional `include_order_status_route` for goods), `build_success_next_steps` (universal Passport-active success block), `build_agentscore_onboarding_steps` (canonical skill.md onboarding for goods or API merchants), `agentscore_openapi_snippets`, `build_bazaar_discovery_payload`, `NoindexNonDiscoveryMiddleware` (ASGI middleware emitting `X-Robots-Tag: noindex` on every path except the agent-discovery surfaces; pure helpers `is_discovery_path` + `DEFAULT_DISCOVERY_PATHS` for non-ASGI frameworks). Plus the UCP/JWKS publish surface: `build_signed_ucp_response`, `build_signed_jwks_response`, `well_known_preflight_response`, `default_a2a_services`, `bootstrap_ucp_signing_key`, framework-neutral `SignedDiscoveryResponse` + per-framework wrappers `signed_response_{fastapi,flask,django,aiohttp,sanic}`. |
3232
| `agentscore_commerce.challenge` | `build_402_body`, `build_accepted_methods`, `build_identity_metadata` (auto-attached by `Checkout` when an inbound `X-Wallet-Address` header is present), `build_how_to_pay`, `build_agent_instructions` (auto-emits per-rail `compatible_clients`: smoke-verified CLIs the agent should use; vendor override supported; pure helper `compatible_clients_by_rails(rails)` returns the same map for vendors building custom 402s), `build_pricing_block` (cents to dollar-string with optional shipping/tax), `first_encounter_agent_memory` (cross-merchant hint, returns the canonical block or `None` based on a per-merchant first-seen flag), `Receipt` + `ReceiptNextSteps` + `ProductInfo` + `ShippingAddress` (canonical 200-receipt dataclasses, universal across goods + API merchants); `respond_402`, a drop-in 402 emit that preserves pympp's `WWW-Authenticate` and layers x402's `PAYMENT-REQUIRED`. `build_validation_error`: structured 4xx body builder (`{error: {code, message}, required_fields?, example_body?, next_steps?, ...extra}`) so vendors compose body shapes by name instead of inlining at every validation site. |
33-
| `agentscore_commerce.stripe_multichain` | `create_multichain_payment_intent` (returns `MultichainPaymentIntentResult(payment_intent_id, deposit_addresses)`; read `result.deposit_addresses[network]` directly), `create_pay_to_address_from_stripe_pi(authorization_header=, amount_cents=, stripe=, pi_cache=, networks=, static_recipients=, metadata=, order_id=, preferred_network=)`, per-order payTo resolver: on the settle leg, reuses the buyer's signed-against payTo from the MPP credential (after `pi_cache.has_address` check OR a `static_recipients` match, the static address is always-accepted because the merchant owns it); on the discovery leg, mints a fresh PI for the rails NOT covered by `static_recipients`, caches the merged map, registers static addresses with `pi_cache.cache_address` so verify-leg lookups pass. `mint_multichain_recipients(...same kwargs)`, structured variant returning `MintMultichainRecipientsResult(recipients, payment_intent_id, reused_from_credential)` for the full per-rail map (typical multi-rail merchant hook). Use `static_recipients={"solana": "<wallet>"}` for low-margin endpoints where Solana per-call ATA rent (~$0.50 against MPP spec §13.6) dominates revenue, the SDK skips Stripe minting on that network, reuses the static recipient forever, and (with a one-time external pre-funding of the recipient's USDC ATA) lets every settle pay only the ~$0.001 per-tx fee. `SolanaMppRailSpec.ata_creation_required` defaults to `True` (data-only; solana method registration through `create_mppx_server` is a follow-up, merchants building the solana method directly via `pympp` should pass the flag themselves to the charge factory). `simulate_crypto_deposit`; `create_pi_cache` (TTL'd PI / deposit-address cache, Redis-backed when `redis_url` set, in-memory otherwise), `simulate_deposit_if_test_mode` (gates on `sk_test_` and looks up the PI for you), `STRIPE_TEST_TX_HASH_SUCCESS` / `STRIPE_TEST_TX_HASH_FAILED` constants. Peer dep on `stripe`. |
33+
| `agentscore_commerce.stripe_multichain` | `create_multichain_payment_intent` (returns `MultichainPaymentIntentResult(payment_intent_id, deposit_addresses)`; read `result.deposit_addresses[network]` directly), `create_pay_to_address_from_stripe_pi(authorization_header=, amount_cents=, stripe=, pi_cache=, networks=, static_recipients=, metadata=, order_id=, preferred_network=)`, per-order payTo resolver: on the settle leg, reuses the buyer's signed-against payTo from the MPP credential (after `pi_cache.has_address` check OR a `static_recipients` match, the static address is always-accepted because the merchant owns it); on the discovery leg, mints a fresh PI for the rails NOT covered by `static_recipients`, caches the merged map, registers static addresses with `pi_cache.cache_address` so verify-leg lookups pass. `mint_multichain_recipients(...same kwargs)`, structured variant returning `MintMultichainRecipientsResult(recipients, payment_intent_id, reused_from_credential)` for the full per-rail map (typical multi-rail merchant hook). Solana MPP requires a static recipient: pass `static_recipients={"solana": "<wallet>"}` and pre-fund its USDC ATA once from any external wallet. Rotating (per-order, Stripe-minted) Solana recipients do not settle, since the settlement transaction does not create the recipient's token account; the SDK logs a warning when one is minted. The static recipient is reused forever, each settle pays only the ~$0.001 per-tx fee, and this also sidesteps the per-call ATA rent (~$0.50 against MPP spec §13.6). `SolanaMppRailSpec.ata_creation_required` defaults to `True` (data-only; solana method registration through `create_mppx_server` is a follow-up, merchants building the solana method directly via `pympp` should pass the flag themselves to the charge factory). `simulate_crypto_deposit`; `create_pi_cache` (TTL'd PI / deposit-address cache, Redis-backed when `redis_url` set, in-memory otherwise), `simulate_deposit_if_test_mode` (gates on `sk_test_` and looks up the PI for you), `STRIPE_TEST_TX_HASH_SUCCESS` / `STRIPE_TEST_TX_HASH_FAILED` constants. Peer dep on `stripe`. |
3434
| `agentscore_commerce.api` | Everything from `agentscore-py` re-exported in one place: `AgentScore` + `AgentScoreError`, `AGENTSCORE_TEST_ADDRESSES` + `is_agentscore_test_address`. **Don't add `agentscore-py` as a separate dep**: the two can drift versions and cause subtle type mismatches. |
3535
| `agentscore_commerce.middleware.{fastapi,flask,django,aiohttp,sanic,asgi}` | Framework-specific rate-limit middleware. FastAPI: `rate_limit_fastapi(...)` (FastAPI dependency) plus the ASGI `RateLimitMiddleware` re-export. Flask: `rate_limit_flask(app, ...)` installer. Django: class-based async `RateLimitMiddleware` configured via `settings.AGENTSCORE_RATE_LIMIT`. aiohttp: `rate_limit_aiohttp(...)` middleware factory. Sanic: `rate_limit_sanic(app, ...)` installer. `asgi.RateLimitMiddleware` works with any starlette-compatible app. Shared options: `window_seconds` (default 60), `max_requests` (default 60), `key_resolver` (default first hop of `x-forwarded-for`), `redis_url` (lazy-imports `redis.asyncio` when set, in-memory `dict` fallback otherwise), `key_prefix`. `redis` is an optional peer dep (install via the `redis` extra). |
3636

agentscore_commerce/stripe_multichain/pay_to_address.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from __future__ import annotations
1818

1919
import asyncio
20+
import logging
2021
from dataclasses import dataclass
2122
from typing import TYPE_CHECKING, Any
2223

@@ -28,6 +29,15 @@
2829
if TYPE_CHECKING:
2930
from agentscore_commerce.stripe_multichain.pi_cache import PiCache
3031

32+
_logger = logging.getLogger(__name__)
33+
34+
# Stripe-minted Solana deposit addresses rotate per PaymentIntent and have no
35+
# pre-existing USDC ATA. @solana/mpp >=0.6.0 will not create the primary
36+
# recipient's ATA in the charge transaction, so the settle fails simulation
37+
# (InstructionError [2, InvalidAccountData]). Warn once when a rotating Solana
38+
# recipient is about to be minted so the merchant isn't blindsided at settle.
39+
_warned_rotating_solana_mint = False
40+
3141

3242
async def _maybe_await(value: Any) -> Any:
3343
if asyncio.iscoroutine(value) or asyncio.isfuture(value):
@@ -241,6 +251,17 @@ async def _mint_and_cache(
241251
Registers everything in the cache and returns (preferred_address, merged_map).
242252
"""
243253
stripe_networks = [n for n in networks if n not in static_recipients]
254+
global _warned_rotating_solana_mint
255+
if "solana" in stripe_networks and not _warned_rotating_solana_mint:
256+
_warned_rotating_solana_mint = True
257+
_logger.warning(
258+
"[stripe-multichain] Minting a per-PaymentIntent (rotating) Solana recipient. "
259+
"Solana MPP settle fails on @solana/mpp >=0.6.0 for rotating recipients: the "
260+
"client does not create the primary recipient ATA, so a fresh deposit address "
261+
"has no token account to receive into. Use a static Solana recipient with a "
262+
"pre-funded ATA (static_recipients={'solana': '<wallet>'}), or drop 'solana' "
263+
"from networks."
264+
)
244265
idempotency_key = f"pi-{order_id}-{amount_cents}" if order_id else None
245266
result = create_multichain_payment_intent(
246267
stripe=stripe,

tests/test_pay_to_address.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from __future__ import annotations
44

55
import importlib.util
6+
import logging
67
from dataclasses import dataclass, field
78
from typing import Any
89
from unittest.mock import patch
@@ -109,6 +110,38 @@ def __init__(self) -> None:
109110
return _Stripe()
110111

111112

113+
@pytest.mark.asyncio
114+
async def test_warns_on_rotating_solana_mint_and_static_takes_safe_path(
115+
caplog: pytest.LogCaptureFixture,
116+
) -> None:
117+
import agentscore_commerce.stripe_multichain.pay_to_address as pta
118+
119+
pta._warned_rotating_solana_mint = False
120+
121+
# Rotating: default networks include solana, no static_recipients -> warn.
122+
cache = FakePiCache()
123+
with caplog.at_level(logging.WARNING):
124+
await mint_multichain_recipients(
125+
authorization_header=None,
126+
amount_cents=250,
127+
stripe=_fake_stripe({"tempo": "0xT", "base": "0xB", "solana": "SOL1"}),
128+
pi_cache=cache, # type: ignore[arg-type]
129+
)
130+
assert any("solana" in r.getMessage().lower() and "static_recipients" in r.getMessage() for r in caplog.records)
131+
132+
# Static: solana is filtered out of the Stripe mint, served from static_recipients.
133+
cache2 = FakePiCache()
134+
res = await mint_multichain_recipients(
135+
authorization_header=None,
136+
amount_cents=250,
137+
stripe=_fake_stripe({"tempo": "0xT2", "base": "0xB2"}),
138+
pi_cache=cache2, # type: ignore[arg-type]
139+
networks=["tempo", "base", "solana"],
140+
static_recipients={"solana": "STATICSOL"},
141+
)
142+
assert res.recipients["solana"] == "STATICSOL"
143+
144+
112145
@pytest.mark.asyncio
113146
async def test_reuses_credential_recipient_when_cached() -> None:
114147
cache = FakePiCache(has_address_result=True)

0 commit comments

Comments
 (0)