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
4 changes: 2 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ name = "qn"
path = "src/lib.rs"

[dependencies]
quicknode-sdk = { version = "0.8.1", features = [
quicknode-sdk = { version = "0.8.2", features = [
"payments", # x402/EVM
"payments-svm", # + x402/Solana
"payments-tempo", # + MPP/Tempo
Expand Down
7 changes: 7 additions & 0 deletions IMPLEMENTATION_PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,10 @@
**Goal**: Validate both repositories with formatting, tests, clippy, and builds.
**Success criteria**: Required checks pass with no new warnings.
**Status**: Complete

## Stage 5: Faucet compatibility and Arc Testnet
**Goal**: Support both asynchronous Circle transfer responses and direct Arc Testnet
transaction responses from `qn rpc x402 drip`.
**Success criteria**: Arc network and USDC resolution work, both response shapes
render correctly, and the CLI passes all verification checks against the local SDK.
**Status**: In Progress
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -427,8 +427,8 @@ successful response). EVM and Solana payment wallets are supported:
```sh
qn wallet generate --vm evm --name payer # dedicated wallet

# Testnet only: fund the wallet from the faucet (Base Sepolia, once per
# account). Prints the funding tx; mainnet wallets are funded normally.
# Testnet only: fund the wallet from the faucet (Base Sepolia or Arc Testnet).
# Circle-backed networks return an asynchronous transfer ID; Arc returns a tx hash.
qn rpc x402 drip --payment-wallet payer --payment-network base-sepolia

# Buy prepaid credits with the funded wallet (moves real funds; gated — pass
Expand Down Expand Up @@ -514,7 +514,7 @@ channels. Exhausting the deposit points you at `qn rpc mpp top-up`; after

The flag stack is the same across all four paths:

- `--payment-network` takes a Quicknode network name (`base-sepolia`,
- `--payment-network` takes a Quicknode network name (`base-sepolia`, `arc-testnet`,
`solana-devnet`, `tempo-testnet`, ...) or a raw CAIP-2 id (`eip155:84532`,
`solana:EtWTRA...`); anything with a `:` passes through verbatim.
- `--payment-asset` takes a token address (EVM), a mint (Solana), or a symbol
Expand Down
4 changes: 2 additions & 2 deletions src/commands/agent/context.md
Original file line number Diff line number Diff line change
Expand Up @@ -189,8 +189,8 @@ Top-level nouns (plurals like `endpoints`/`streams` and `ls` are accepted aliase
**x402 drawdown** uses a cached gateway session. `qn rpc x402 buy-credits`
signs the credit purchase and requires the full payment flags. `balance` and
`drip` authenticate the wallet but do not sign a payment, so they need only
the wallet and payment network. `drip` works on Base Sepolia only. Fund
Solana wallets out of band. `buy-credits` is gated and needs `--yes` in a
the wallet and payment network. `drip` works on Base Sepolia and Arc Testnet.
Fund other wallets out of band. `buy-credits` is gated and needs `--yes` in a
script. `qn rpc call --x402-drawdown` needs the query network and wallet; it
uses one credit after a successful response and does not need an asset or
spend ceiling. The pay network defaults to the query network. Credits can pay
Expand Down
9 changes: 9 additions & 0 deletions src/commands/rpc/pay_asset.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@ const PAY_ASSETS: &[(&str, &str, &str)] = &[
"usdc",
"0x20c0000000000000000000000000000000000000",
),
(
"eip155:5042002",
"usdc",
"0x3600000000000000000000000000000000000000",
),
(
"eip155:8453",
"usdc",
Expand Down Expand Up @@ -119,6 +124,10 @@ mod tests {
resolve("usdc", "eip155:42431").unwrap(),
"0x20c0000000000000000000000000000000000000"
);
assert_eq!(
resolve("usdc", "eip155:5042002").unwrap(),
"0x3600000000000000000000000000000000000000"
);
}

#[test]
Expand Down
2 changes: 2 additions & 0 deletions src/commands/rpc/pay_network.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ const PAY_NETWORKS: &[(&str, &str)] = &[
("abstract-testnet", "eip155:11124"),
("arbitrum-mainnet", "eip155:42161"),
("arbitrum-sepolia", "eip155:421614"),
("arc-testnet", "eip155:5042002"),
("ault-mainnet", "eip155:904"),
("ault-testnet", "eip155:10904"),
("avalanche-mainnet", "eip155:43114"),
Expand Down Expand Up @@ -143,6 +144,7 @@ mod tests {
assert_eq!(resolve("base-sepolia").unwrap(), "eip155:84532");
assert_eq!(resolve("xdai").unwrap(), "eip155:100");
assert_eq!(resolve("tempo-testnet").unwrap(), "eip155:42431");
assert_eq!(resolve("arc-testnet").unwrap(), "eip155:5042002");
assert_eq!(
resolve("solana-devnet").unwrap(),
"solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1"
Expand Down
29 changes: 22 additions & 7 deletions src/commands/rpc/x402.rs
Original file line number Diff line number Diff line change
Expand Up @@ -332,14 +332,25 @@ async fn run_balance(args: SessionArgs, global: GlobalArgs) -> Result<(), CliErr
}

async fn run_drip(args: SessionArgs, global: GlobalArgs) -> Result<(), CliError> {
reject_non_base_drip(&args, &global)?;
reject_unsupported_drip(&args, &global)?;
let ctx = session_setup(&args, global.clone())?;
let session = ensure_gateway_session(&ctx, &global).await?;
let receipt = ctx.sdk.rpc.gateway_drip(&session).await?;

let settlement = receipt
.transfer_id
.as_deref()
.map(|id| format!("transfer: {id}"))
.or_else(|| {
receipt
.transaction_hash
.as_deref()
.map(|hash| format!("tx: {hash}"))
})
.unwrap_or_else(|| "settlement pending".to_string());
ctx.out.note(&format!(
"✓ Faucet funded {} (tx: {})",
receipt.account_id, receipt.transaction_hash
"✓ Faucet request accepted for {} ({settlement})",
receipt.account_id
));
let wallet = args.payment_wallet.as_deref().unwrap_or("<NAME>");
let pay_net = args.payment_network.as_deref().unwrap_or("<NET>");
Expand Down Expand Up @@ -406,17 +417,21 @@ async fn run_drip(args: SessionArgs, global: GlobalArgs) -> Result<(), CliError>
if matches!(ctx.global.format, Some(f) if f.is_structured()) {
let v = 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,
});
return super::emit_result(&ctx, &v);
}
if !ctx.out.stdout_is_tty {
println!("{}", receipt.transaction_hash);
println!("{settlement}");
}
Ok(())
}

fn reject_non_base_drip(args: &SessionArgs, global: &GlobalArgs) -> Result<(), CliError> {
fn reject_unsupported_drip(args: &SessionArgs, global: &GlobalArgs) -> Result<(), CliError> {
let section = load_payment_section(global)?;
let Some(network) = args
.payment_network
Expand All @@ -426,9 +441,9 @@ fn reject_non_base_drip(args: &SessionArgs, global: &GlobalArgs) -> Result<(), C
return Ok(());
};
let resolved = super::pay_network::resolve(&network)?;
if resolved != "eip155:84532" {
if !matches!(resolved.as_str(), "eip155:84532" | "eip155:5042002") {
return Err(CliError::Arg(
"x402 drip is available only on Base Sepolia. Fund Solana wallets out of band."
"x402 drip is available on Base Sepolia and Arc Testnet. Fund other wallets out of band."
.to_string(),
));
}
Expand Down
37 changes: 36 additions & 1 deletion tests/rpc_payment.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1261,7 +1261,9 @@ async fn x402_drip_reports_funding_tx() {
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"accountId": "eip155:84532:0xabc",
"walletAddress": "0xabc",
"transactionHash": "0xfeed"
"network": "eip155:84532",
"transferId": "transfer-1",
"amountUsdc": "10"
})))
.mount(&server)
.await;
Expand All @@ -1275,6 +1277,39 @@ async fn x402_drip_reports_funding_tx() {
assert_eq!(out.exit_code, 0, "stderr={}", out.stderr);
}

#[tokio::test]
async fn x402_drip_accepts_arc_transaction_hash() {
let server = MockServer::start().await;
mount_auth(&server).await;
Mock::given(method("POST"))
.and(path("/drip"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"accountId": "eip155:5042002:0xabc",
"walletAddress": "0xabc",
"network": "eip155:5042002",
"transactionHash": "0xfeed"
})))
.mount(&server)
.await;

let dir = tempfile::tempdir().unwrap();
let cfg = dir.path().join("config.toml").to_str().unwrap().to_string();
let (_guard, key_path) = key_file();
let args = [
"--config-file",
cfg.as_str(),
"rpc",
"x402",
"drip",
"--payment-key-file",
key_path.as_str(),
"--payment-network",
"arc-testnet",
];
let out = run_qn(&server.uri(), &args).await;
assert_eq!(out.exit_code, 0, "stderr={}", out.stderr);
}

#[tokio::test]
async fn x402_drip_rejects_solana_without_network_request() {
let server = MockServer::start().await;
Expand Down
Loading