From 123cbf328e784b3b3d9aaa2f9d2de6eae42dcc38 Mon Sep 17 00:00:00 2001 From: John Mitsch Date: Tue, 11 Aug 2026 14:47:02 -0400 Subject: [PATCH] fix(rpc): support asynchronous faucet transfers --- crates/core/README.md | 12 ++--- crates/core/src/rpc/payment/drawdown.rs | 64 +++++++++++++++++++++---- crates/node/src/lib.rs | 9 +++- crates/python/src/lib.rs | 8 +++- crates/ruby/src/lib.rs | 6 ++- npm/README.md | 12 ++--- python/README.md | 12 ++--- ruby/README.md | 12 ++--- 8 files changed, 96 insertions(+), 39 deletions(-) diff --git a/crates/core/README.md b/crates/core/README.md index 450a767..b00d037 100644 --- a/crates/core/README.md +++ b/crates/core/README.md @@ -1954,15 +1954,14 @@ Fund the payment wallet out of band — the testnet faucet below, or by sending `payment_address()` directly. Credits are provisioned against the account gateway-side. EVM payment networks use SIWE. Solana payment networks use SIWS with an Ed25519 -signature encoded as Base58. Solana wallets must be funded out of band; the faucet -is available for Base Sepolia only. +signature encoded as Base58. | Method | Cost | Returns | |---|---|---| | `payment_address()` | free, offline | the wallet address derived from the key | | `gateway_authenticate()` | free | `GatewaySession { token, exp_unix, account_id }` | | `gateway_credits(session)` | free | `CreditBalance { account_id, credits }` | -| `gateway_drip(session)` | free (testnet) | `DripReceipt { account_id, transaction_hash }` | +| `gateway_drip(session)` | free (testnet) | `DripReceipt` with `transfer_id` or `transaction_hash` | | `gateway_drawdown_call(method, params, network, session)` | 1 credit | the JSON-RPC `result` | ```rust @@ -1977,9 +1976,10 @@ retry that call. #### Testnet faucet -`gateway_drip` requests testnet tokens for the payment **wallet** on Base Sepolia. The -gateway allows one drip per account, and it returns the on-chain funding transaction hash -— not a credit balance. +`gateway_drip` requests testnet tokens for the payment **wallet**. Circle Gateway-backed +networks return `transfer_id` because settlement is asynchronous. Direct-transfer +networks such as Arc Testnet return `transaction_hash`. The response is not a credit +balance; call `gateway_credits` separately. ### MPP payment channel (deposit once, then vouchers) diff --git a/crates/core/src/rpc/payment/drawdown.rs b/crates/core/src/rpc/payment/drawdown.rs index 42742c4..a29110f 100644 --- a/crates/core/src/rpc/payment/drawdown.rs +++ b/crates/core/src/rpc/payment/drawdown.rs @@ -233,19 +233,22 @@ pub async fn credits( }) } -/// The faucet drip result: the on-chain funding transaction. The gateway's -/// `/drip` returns the settlement tx, not a credit balance — call [`credits`] -/// afterwards to read the updated balance. +/// The faucet drip result. Circle Gateway-backed networks return a transfer ID +/// because settlement is asynchronous. Direct-transfer networks return the +/// transaction hash instead. #[derive(Debug, Clone, PartialEq, Eq)] pub struct DripReceipt { pub account_id: String, - /// The faucet funding transaction hash. - pub transaction_hash: String, + pub wallet_address: Option, + pub network: Option, + pub transfer_id: Option, + pub amount_usdc: Option, + pub transaction_hash: Option, } /// Requests testnet tokens from the faucet (POST `/drip`, Bearer JWT). The -/// gateway allows this once per account on Base Sepolia and returns the funding -/// transaction (NOT a balance). Solana wallets must be funded out of band. +/// gateway allows this once per wallet and network per month. The response is +/// not a balance; call [`credits`] afterwards to read the credit balance. pub async fn drip( client: &reqwest::Client, payment: &ResolvedPayment, @@ -268,13 +271,24 @@ pub async fn drip( struct DripBody { #[serde(rename = "accountId")] account_id: String, + #[serde(rename = "walletAddress")] + wallet_address: Option, + network: Option, + #[serde(rename = "transferId")] + transfer_id: Option, + #[serde(rename = "amountUsdc")] + amount_usdc: Option, #[serde(rename = "transactionHash")] - transaction_hash: String, + transaction_hash: Option, } let parsed: DripBody = serde_json::from_str(&body).map_err(|source| SdkError::Decode { source, body })?; Ok(DripReceipt { account_id: parsed.account_id, + wallet_address: parsed.wallet_address, + network: parsed.network, + transfer_id: parsed.transfer_id, + amount_usdc: parsed.amount_usdc, transaction_hash: parsed.transaction_hash, }) } @@ -850,7 +864,7 @@ mod tests { } #[tokio::test] - async fn drip_returns_the_funding_transaction() { + async fn drip_returns_a_direct_transfer_transaction() { let server = MockServer::start().await; Mock::given(method("POST")) .and(path("/drip")) @@ -870,10 +884,40 @@ mod tests { }; let client = reqwest::Client::new(); let receipt = drip(&client, &payment, &session).await.unwrap(); - assert_eq!(receipt.transaction_hash, "0xfeed"); + assert_eq!(receipt.transaction_hash.as_deref(), Some("0xfeed")); + assert_eq!(receipt.transfer_id, None); assert_eq!(receipt.account_id, "eip155:84532:0xabc"); } + #[tokio::test] + async fn drip_returns_a_circle_transfer_id() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/drip")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "accountId": "eip155:84532:0xabc", + "walletAddress": "0xabc", + "network": "eip155:84532", + "transferId": "transfer-1", + "amountUsdc": "10" + }))) + .mount(&server) + .await; + + let payment = evm_payment(&server.uri()); + let session = GatewaySession { + token: "jwt-abc".into(), + exp_unix: now_unix() as i64 + 3600, + account_id: "a".into(), + }; + let receipt = drip(&reqwest::Client::new(), &payment, &session) + .await + .unwrap(); + assert_eq!(receipt.transfer_id.as_deref(), Some("transfer-1")); + assert_eq!(receipt.amount_usdc.as_deref(), Some("10")); + assert_eq!(receipt.transaction_hash, None); + } + // Menu with credit, per-request, and nanopayment tiers. fn gateway_menu() -> Value { let mut nanopayment = x402_credit_offer("100") diff --git a/crates/node/src/lib.rs b/crates/node/src/lib.rs index ea1772a..fc42263 100644 --- a/crates/node/src/lib.rs +++ b/crates/node/src/lib.rs @@ -1655,8 +1655,9 @@ impl RpcApiClient { } /// Requests testnet tokens from the x402 faucet. Resolves to the funding - /// transaction `{ accountId, transactionHash }` — NOT a balance; call - /// `gatewayCredits` afterwards for that. Allowed once per account. + /// transfer metadata containing either `transferId` or `transactionHash`. + /// The former is asynchronous Circle settlement; the latter is a direct + /// on-chain transfer. This is not a balance; call `gatewayCredits` for that. #[napi] pub async fn gateway_drip(&self, session: serde_json::Value) -> Result { let session = parse_gateway_session(&session)?; @@ -1667,6 +1668,10 @@ impl RpcApiClient { .map_err(errors::map_sdk_err)?; Ok(serde_json::json!({ "accountId": receipt.account_id, + "walletAddress": receipt.wallet_address, + "network": receipt.network, + "transferId": receipt.transfer_id, + "amountUsdc": receipt.amount_usdc, "transactionHash": receipt.transaction_hash, })) } diff --git a/crates/python/src/lib.rs b/crates/python/src/lib.rs index 39db131..c00ea9e 100644 --- a/crates/python/src/lib.rs +++ b/crates/python/src/lib.rs @@ -2712,8 +2712,8 @@ impl RpcApiClient { } /// Requests testnet tokens from the x402 faucet. Returns the funding - /// transaction as a dict `{account_id, transaction_hash}` — NOT a balance; - /// call `gateway_credits` afterwards for that. Allowed once per account. + /// transfer metadata as a dict. Circle-backed responses contain + /// `transfer_id`; direct transfers contain `transaction_hash`. #[gen_stub(override_return_type( type_repr = "typing.Coroutine[typing.Any, typing.Any, typing.Any]" ))] @@ -2731,6 +2731,10 @@ impl RpcApiClient { .map_err(errors::map_sdk_err)?; json_to_py(&serde_json::json!({ "account_id": receipt.account_id, + "wallet_address": receipt.wallet_address, + "network": receipt.network, + "transfer_id": receipt.transfer_id, + "amount_usdc": receipt.amount_usdc, "transaction_hash": receipt.transaction_hash, })) }) diff --git a/crates/ruby/src/lib.rs b/crates/ruby/src/lib.rs index 99ec934..a014997 100644 --- a/crates/ruby/src/lib.rs +++ b/crates/ruby/src/lib.rs @@ -2051,7 +2051,7 @@ impl RpcApiClient { })) } - // gateway_drip(session:) — request testnet funds; returns the tx hash. + // gateway_drip(session:) — request testnet funds; returns transfer metadata. fn gateway_drip(&self, opts: RHash) -> Result { validate_keys(&opts, &["session"])?; let session = require_gateway_session(&opts)?; @@ -2061,6 +2061,10 @@ impl RpcApiClient { .map_err(map_err)?; to_ruby(serde_json::json!({ "account_id": receipt.account_id, + "wallet_address": receipt.wallet_address, + "network": receipt.network, + "transfer_id": receipt.transfer_id, + "amount_usdc": receipt.amount_usdc, "transaction_hash": receipt.transaction_hash, })) } diff --git a/npm/README.md b/npm/README.md index 411c8ec..956ea62 100644 --- a/npm/README.md +++ b/npm/README.md @@ -1838,15 +1838,14 @@ Fund the payment wallet out of band — the testnet faucet below, or by sending `paymentAddress()` directly. Credits are provisioned against the account gateway-side. EVM payment networks use SIWE. Solana payment networks use SIWS with an Ed25519 -signature encoded as Base58. Solana wallets must be funded out of band; the faucet -is available for Base Sepolia only. +signature encoded as Base58. | Method | Cost | Returns | |---|---|---| | `paymentAddress()` | free, offline | the wallet address derived from the key | | `gatewayAuthenticate()` | free | `GatewaySession { token, expUnix, accountId }` | | `gatewayCredits(session)` | free | `CreditBalance { accountId, credits }` | -| `gatewayDrip(session)` | free (testnet) | `DripReceipt { accountId, transactionHash }` | +| `gatewayDrip(session)` | free (testnet) | `DripReceipt` with `transferId` or `transactionHash` | | `gatewayDrawdownCall(method, session, network, params?)` | 1 credit | the JSON-RPC `result` | ```typescript @@ -1861,9 +1860,10 @@ that call. #### Testnet faucet -`gatewayDrip` requests testnet tokens for the payment **wallet** on Base Sepolia. The -gateway allows one drip per account, and it returns the on-chain funding transaction hash -— not a credit balance. +`gatewayDrip` requests testnet tokens for the payment **wallet**. Circle Gateway-backed +networks return `transferId` because settlement is asynchronous. Direct-transfer +networks such as Arc Testnet return `transactionHash`. The response is not a credit +balance; call `gatewayCredits` separately. ### MPP payment channel (deposit once, then vouchers) diff --git a/python/README.md b/python/README.md index d61880f..d0af238 100644 --- a/python/README.md +++ b/python/README.md @@ -1831,15 +1831,14 @@ Fund the payment wallet out of band — the testnet faucet below, or by sending `payment_address()` directly. Credits are provisioned against the account gateway-side. EVM payment networks use SIWE. Solana payment networks use SIWS with an Ed25519 -signature encoded as Base58. Solana wallets must be funded out of band; the faucet -is available for Base Sepolia only. +signature encoded as Base58. | Method | Cost | Returns | |---|---|---| | `payment_address()` | free, offline | the wallet address derived from the key | | `gateway_authenticate()` | free | a dict `{token, exp_unix, account_id}` | | `gateway_credits(session)` | free | a dict `{account_id, credits}` | -| `gateway_drip(session)` | free (testnet) | a dict `{account_id, transaction_hash}` | +| `gateway_drip(session)` | free (testnet) | a dict with `transfer_id` or `transaction_hash` | | `gateway_drawdown_call(method, session, network, params=None)` | 1 credit | the JSON-RPC `result` | ```python @@ -1854,9 +1853,10 @@ that call. #### Testnet faucet -`gateway_drip` requests testnet tokens for the payment **wallet** on Base Sepolia. The -gateway allows one drip per account, and it returns the on-chain funding transaction hash -— not a credit balance. +`gateway_drip` requests testnet tokens for the payment **wallet**. Circle Gateway-backed +networks return `transfer_id` because settlement is asynchronous. Direct-transfer +networks such as Arc Testnet return `transaction_hash`. The response is not a credit +balance; call `gateway_credits` separately. ### MPP payment channel (deposit once, then vouchers) diff --git a/ruby/README.md b/ruby/README.md index 33bffa5..fa5ec98 100644 --- a/ruby/README.md +++ b/ruby/README.md @@ -1839,15 +1839,14 @@ Fund the payment wallet out of band — the testnet faucet below, or by sending `payment_address` directly. Credits are provisioned against the account gateway-side. EVM payment networks use SIWE. Solana payment networks use SIWS with an Ed25519 -signature encoded as Base58. Solana wallets must be funded out of band; the faucet -is available for Base Sepolia only. +signature encoded as Base58. | Method | Cost | Returns | |---|---|---| | `payment_address` | free, offline | the wallet address derived from the key | | `gateway_authenticate` | free | a Hash `{token:, exp_unix:, account_id:}` | | `gateway_credits(session:)` | free | a Hash `{account_id:, credits:}` | -| `gateway_drip(session:)` | free (testnet) | a Hash `{account_id:, transaction_hash:}` | +| `gateway_drip(session:)` | free (testnet) | a Hash with `transfer_id:` or `transaction_hash:` | | `gateway_drawdown_call(method:, session:, network:, params:)` | 1 credit | the JSON-RPC `result` | ```ruby @@ -1864,9 +1863,10 @@ that call. #### Testnet faucet -`gateway_drip` requests testnet tokens for the payment **wallet** on Base Sepolia. The -gateway allows one drip per account, and it returns the on-chain funding transaction hash -— not a credit balance. +`gateway_drip` requests testnet tokens for the payment **wallet**. Circle Gateway-backed +networks return `transfer_id` because settlement is asynchronous. Direct-transfer +networks such as Arc Testnet return `transaction_hash`. The response is not a credit +balance; call `gateway_credits` separately. ### MPP payment channel (deposit once, then vouchers)