From c5f9c1a0333ec31c7d69f898e054a4490e32bca5 Mon Sep 17 00:00:00 2001 From: Ludo Galabru Date: Thu, 18 Jun 2026 16:41:30 -0400 Subject: [PATCH 1/6] feat(rust): add support for x402:upto --- rust/README.md | 15 +- rust/crates/core/Cargo.toml | 26 + rust/crates/core/src/lib.rs | 32 +- .../program => core/src}/payment_channels.rs | 104 ++- rust/crates/core/src/units.rs | 118 +++ rust/crates/kit/Cargo.toml | 5 + rust/crates/kit/examples/axum_upto.rs | 73 ++ rust/crates/kit/src/gate.rs | 285 +++++- rust/crates/kit/src/lib.rs | 3 +- rust/crates/mpp/Cargo.toml | 3 +- rust/crates/mpp/src/client/mod.rs | 13 +- rust/crates/mpp/src/client/multi_delegate.rs | 121 --- .../crates/mpp/src/client/payment_channels.rs | 844 ------------------ rust/crates/mpp/src/client/session.rs | 784 +++++++++++++++- rust/crates/mpp/src/error.rs | 9 + rust/crates/mpp/src/program/mod.rs | 8 +- .../crates/mpp/src/program/multi_delegator.rs | 465 ---------- rust/crates/mpp/src/program/subscriptions.rs | 209 +++-- rust/crates/mpp/src/protocol/intents/mod.rs | 85 +- .../mpp/src/protocol/intents/session.rs | 4 +- rust/crates/x402/Cargo.toml | 3 + rust/crates/x402/src/client/mod.rs | 1 + rust/crates/x402/src/client/upto/mod.rs | 5 + rust/crates/x402/src/client/upto/payment.rs | 237 +++++ rust/crates/x402/src/error.rs | 9 + rust/crates/x402/src/lib.rs | 1 + rust/crates/x402/src/protocol/schemes/mod.rs | 1 + .../x402/src/protocol/schemes/upto/mod.rs | 7 + .../x402/src/protocol/schemes/upto/types.rs | 292 ++++++ .../x402/src/protocol/schemes/upto/verify.rs | 194 ++++ rust/crates/x402/src/server/exact.rs | 48 +- rust/crates/x402/src/server/mod.rs | 2 + rust/crates/x402/src/server/upto.rs | 436 +++++++++ rust/specs/schemes/upto/scheme_upto_svm.md | 265 ++++++ .../pay-kit/src/__tests__/x402.test.ts | 101 +++ .../pay-kit/src/adapters/x402-upto.ts | 184 ++++ .../packages/pay-kit/src/adapters/x402.ts | 131 +++ 37 files changed, 3489 insertions(+), 1634 deletions(-) rename rust/crates/{mpp/src/program => core/src}/payment_channels.rs (81%) create mode 100644 rust/crates/core/src/units.rs create mode 100644 rust/crates/kit/examples/axum_upto.rs delete mode 100644 rust/crates/mpp/src/client/multi_delegate.rs delete mode 100644 rust/crates/mpp/src/client/payment_channels.rs delete mode 100644 rust/crates/mpp/src/program/multi_delegator.rs create mode 100644 rust/crates/x402/src/client/upto/mod.rs create mode 100644 rust/crates/x402/src/client/upto/payment.rs create mode 100644 rust/crates/x402/src/protocol/schemes/upto/mod.rs create mode 100644 rust/crates/x402/src/protocol/schemes/upto/types.rs create mode 100644 rust/crates/x402/src/protocol/schemes/upto/verify.rs create mode 100644 rust/crates/x402/src/server/upto.rs create mode 100644 rust/specs/schemes/upto/scheme_upto_svm.md create mode 100644 typescript/packages/pay-kit/src/__tests__/x402.test.ts create mode 100644 typescript/packages/pay-kit/src/adapters/x402-upto.ts create mode 100644 typescript/packages/pay-kit/src/adapters/x402.ts diff --git a/rust/README.md b/rust/README.md index 68a9f6e9c..d5d24accb 100644 --- a/rust/README.md +++ b/rust/README.md @@ -176,17 +176,22 @@ payer, broadcasts, polls to `confirmed`, and emits `Payment-Receipt`. ## x402 -[x402](https://x402.org) revives HTTP `402 Payment Required` with a -single-recipient `exact` scheme. The Rust implementation -(`solana-pay-kit::x402`, the `solana-x402` crate) ships the `exact` server and -client plus SIWX. +[x402](https://x402.org) revives HTTP `402 Payment Required`. The Rust +implementation (`solana-pay-kit::x402`, the `solana-x402` crate) ships the +single-recipient `exact` scheme and the usage-based `upto` scheme — both server +and client — plus SIWX. | Intent | Status | |--------------------|--------| | `exact` | ✅ | -| `upto` | — | +| `upto` | ✅ | | `batch-settlement` | — | +`upto` charges for actual usage up to a ceiling: it settles on a payment channel +after the handler runs, so it needs a `fee_payer_signer` (the operator signs the +settlement voucher) and is gated with `paid_upto_get` / `paid_upto_post` rather +than `paid_get` / `paid_post`. + ## Client Unlike the Ruby, Python, PHP, and Lua SDKs (server-only), Rust also ships the diff --git a/rust/crates/core/Cargo.toml b/rust/crates/core/Cargo.toml index 4353395dd..3839dd3b0 100644 --- a/rust/crates/core/Cargo.toml +++ b/rust/crates/core/Cargo.toml @@ -7,3 +7,29 @@ license = "MIT" repository = "https://github.com/solana-foundation/pay-kit" [dependencies] +# Signing — solana-keychain with sdk-v3 (matches mpp/x402 pins) +solana-keychain = { git = "https://github.com/solana-foundation/solana-keychain", rev = "abf75944", default-features = false, features = ["memory", "sdk-v3"] } + +# Solana — atomic crates only +solana-hash = { version = "3.1", default-features = false } +solana-instruction = { version = "3.1", default-features = false } +solana-message = { version = "3.0", default-features = false } +solana-pubkey = { version = "3.0", default-features = false } +solana-transaction = { version = "3.0", default-features = false } +solana-address = { version = "2", features = ["borsh", "curve25519"] } + +# Payment-channels program client (generated) +payment_channels_client = { package = "payment-channels-client", path = "../programs/payment-channels" } + +# Randomness (channel salt) +getrandom = "0.3" + +# Serialization / hashing +borsh = { version = "1.0", features = ["derive"] } +blake3 = "1" +bs58 = "0.5" +base64 = "0.22" +bincode = "1" + +# Errors +thiserror = "2" diff --git a/rust/crates/core/src/lib.rs b/rust/crates/core/src/lib.rs index cb73e13f1..33f272a70 100644 --- a/rust/crates/core/src/lib.rs +++ b/rust/crates/core/src/lib.rs @@ -1,5 +1,31 @@ //! Shared Solana primitives for the pay-kit crates. //! -//! Holds network, currency, and transaction helpers extracted from -//! `solana-mpp` and `solana-x402`. Populated incrementally; intentionally -//! empty at workspace inception. +//! Holds transaction helpers extracted from `solana-mpp` and `solana-x402` so +//! both protocol crates (and the unified `solana-pay-kit` gate) can build on the +//! same on-chain plumbing without depending on each other. +//! +//! Currently exposes [`payment_channels`]: PDA derivation, voucher bytes, +//! distribution hashing, and instruction/transaction builders for the on-chain +//! payment-channels program. `solana-mpp` re-exports this module at +//! `mpp::program::payment_channels`, and `solana-x402` uses it to back the +//! `upto` scheme. + +pub mod payment_channels; +pub mod units; + +pub use units::{parse_units, MAX_DECIMALS}; + +/// Errors produced by the shared pay-kit core helpers. +#[derive(Debug, thiserror::Error)] +pub enum Error { + /// A Borsh (de)serialization step failed. + #[error("serialization error: {0}")] + Serialization(String), + + /// Catch-all for anything else (invalid pubkey, signing failure, …). + #[error("{0}")] + Other(String), +} + +/// Convenience result alias over [`Error`]. +pub type Result = std::result::Result; diff --git a/rust/crates/mpp/src/program/payment_channels.rs b/rust/crates/core/src/payment_channels.rs similarity index 81% rename from rust/crates/mpp/src/program/payment_channels.rs rename to rust/crates/core/src/payment_channels.rs index f73c0341a..89b8f47bb 100644 --- a/rust/crates/mpp/src/program/payment_channels.rs +++ b/rust/crates/core/src/payment_channels.rs @@ -1,18 +1,28 @@ //! Typed helpers for the payment-channels program. //! //! The generated Codama client is kept as a path dependency and re-exported -//! through this module. Everything here is hand-written adapter code: PDA +//! through this module. Everything else here is hand-written adapter code: PDA //! derivation, associated token derivation, distribution hashing, voucher bytes, -//! and convenience instruction builders. +//! and convenience instruction/transaction builders. +//! +//! This module lives in `solana-pay-core` so both `solana-mpp` (which re-exports +//! it at `mpp::program::payment_channels`) and `solana-x402` (which uses it to +//! back the `upto` scheme) share one implementation without depending on each +//! other. use std::str::FromStr; +use base64::Engine; use solana_address::Address; +use solana_hash::Hash; use solana_instruction::AccountMeta; use solana_instruction::Instruction; +use solana_keychain::SolanaSigner; +use solana_message::Message; use solana_pubkey::Pubkey; +use solana_transaction::Transaction; -use crate::error::{Error, Result}; +use crate::{Error, Result}; pub use payment_channels_client as generated; use payment_channels_client::generated::instructions::{ @@ -27,6 +37,12 @@ use payment_channels_client::generated::types::{ /// Canonical payment-channels program ID deployed to Surfnet. pub const PAYMENT_CHANNELS_PROGRAM_ID: &str = "GuoKrzaBiZnW5DvJ3yZVE7xHqbcBvaX9SH6P6Cn9gNvc"; +/// Associated Token Account program ID. +pub const ASSOCIATED_TOKEN_PROGRAM: &str = "ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL"; + +/// Default payment-channel close grace period, in seconds. +pub const DEFAULT_GRACE_PERIOD_SECONDS: u32 = 900; + /// Channel PDA seed prefix. pub const CHANNEL_SEED: &[u8] = b"channel"; @@ -76,10 +92,32 @@ pub struct ChannelAddresses { pub event_authority: Pubkey, } +/// Output of [`build_open_payment_channel_tx`]: the derived channel PDA and the +/// base64-encoded (payer-signed, fee-payer-unsigned) open transaction. +#[derive(Debug, Clone)] +pub struct PaymentChannelOpenTransaction { + pub channel_id: Pubkey, + pub transaction: String, +} + pub fn default_program_id() -> Pubkey { Pubkey::from_str(PAYMENT_CHANNELS_PROGRAM_ID).expect("valid payment-channels program id") } +/// Generate a random `u64` channel salt. +/// +/// Used to derive a unique channel PDA per open. Uses the OS CSPRNG so salts +/// don't collide across processes or restarts. +pub fn random_salt() -> u64 { + let mut bytes = [0u8; 8]; + getrandom::fill(&mut bytes).expect("getrandom CSPRNG failure"); + u64::from_le_bytes(bytes) +} + +pub fn associated_token_program_id() -> Pubkey { + Pubkey::from_str(ASSOCIATED_TOKEN_PROGRAM).expect("valid associated token program id") +} + pub fn instructions_sysvar_id() -> Pubkey { Pubkey::from_str(INSTRUCTIONS_SYSVAR_ID).expect("valid instructions sysvar id") } @@ -138,8 +176,7 @@ pub fn find_associated_token_address( mint: &Pubkey, token_program: &Pubkey, ) -> (Pubkey, u8) { - let ata_program = Pubkey::from_str(crate::protocol::solana::programs::ASSOCIATED_TOKEN_PROGRAM) - .expect("valid associated token program id"); + let ata_program = associated_token_program_id(); Pubkey::find_program_address( &[owner.as_ref(), token_program.as_ref(), mint.as_ref()], &ata_program, @@ -190,7 +227,7 @@ pub fn voucher_message_bytes( expires_at, }; borsh::to_vec(&voucher) - .map_err(|e| Error::Other(format!("voucher Borsh serialization failed: {e}"))) + .map_err(|e| Error::Serialization(format!("voucher Borsh serialization failed: {e}"))) } pub fn build_open_instruction(params: &OpenChannelParams) -> Instruction { @@ -214,10 +251,7 @@ pub fn build_open_instruction(params: &OpenChannelParams) -> Instruction { .channel_token_account(to_address(&addresses.channel_token_account)) .token_program(to_address(¶ms.token_program)) .rent(to_address(&rent_sysvar_id())) - .associated_token_program(to_address( - &Pubkey::from_str(crate::protocol::solana::programs::ASSOCIATED_TOKEN_PROGRAM) - .expect("valid associated token program id"), - )) + .associated_token_program(to_address(&associated_token_program_id())) .event_authority(to_address(&addresses.event_authority)) .self_program(to_address(¶ms.program_id)) .open_args(OpenArgs { @@ -423,6 +457,56 @@ pub fn build_distribute_instruction( ix } +/// Build a payer-signed (fee-payer-unsigned) channel `open` transaction. +/// +/// The `payer` (the `signer`) signs to authorize the deposit; `fee_payer` is the +/// account that pays the network fee and must co-sign before broadcast (e.g. the +/// operator). Returns the derived channel PDA and the base64-encoded transaction. +#[allow(clippy::too_many_arguments)] +pub async fn build_open_payment_channel_tx( + signer: &dyn SolanaSigner, + payee: &Pubkey, + mint: &Pubkey, + authorized_signer: &Pubkey, + salt: u64, + deposit: u64, + grace_period: u32, + recipients: Vec, + token_program: &Pubkey, + program_id: &Pubkey, + fee_payer: &Pubkey, + recent_blockhash: Hash, +) -> Result { + let params = OpenChannelParams { + payer: signer.pubkey(), + payee: *payee, + mint: *mint, + authorized_signer: *authorized_signer, + salt, + deposit, + grace_period, + recipients, + token_program: *token_program, + program_id: *program_id, + }; + let channel_id = derive_channel_addresses(¶ms).channel; + let ix = build_open_instruction(¶ms); + let message = Message::new_with_blockhash(&[ix], Some(fee_payer), &recent_blockhash); + let mut tx = Transaction::new_unsigned(message); + + signer + .sign_transaction(&mut tx) + .await + .map_err(|e| Error::Other(format!("payment-channel open signing failed: {e}")))?; + + let bytes = bincode::serialize(&tx) + .map_err(|e| Error::Serialization(format!("payment-channel open tx serialization failed: {e}")))?; + Ok(PaymentChannelOpenTransaction { + channel_id, + transaction: base64::engine::general_purpose::STANDARD.encode(bytes), + }) +} + #[cfg(test)] mod tests { use super::*; diff --git a/rust/crates/core/src/units.rs b/rust/crates/core/src/units.rs new file mode 100644 index 000000000..45a98a51a --- /dev/null +++ b/rust/crates/core/src/units.rs @@ -0,0 +1,118 @@ +//! Canonical human-decimal → base-units conversion, shared by `solana-mpp` +//! and `solana-x402`. +//! +//! This is the audited implementation (previously duplicated in both protocol +//! crates): it caps `decimals`, uses checked arithmetic, and validates input +//! shape strictly. + +use crate::{Error, Result}; + +/// Upper bound on the `decimals` argument to [`parse_units`]. +/// +/// Solana's SPL convention is 0–9. 18 gives ERC-20-style headroom while staying +/// well below the cliff at 39 where `10u128.pow(decimals)` overflows. The cap +/// gives a single rejection site so any callsite that hasn't validated +/// `decimals` upstream gets a clear error rather than a panic or wrap. +pub const MAX_DECIMALS: u8 = 18; + +/// Convert a human-readable amount to base units. +/// +/// Matches the TypeScript SDK's `parseUnits(amount, decimals)` — e.g. +/// `parse_units("1.5", 6)` → `"1500000"`. +/// +/// Rejects `decimals > MAX_DECIMALS`, empty amounts, more than one `.`, empty +/// integer/fractional parts (`".5"`, `"1."`), non-ASCII-digit characters, and +/// excess fractional digits. The integer branch uses checked arithmetic so a +/// hostile or buggy caller cannot trigger a panic (debug) or silent overflow +/// (release). +pub fn parse_units(amount: &str, decimals: u8) -> Result { + if decimals > MAX_DECIMALS { + return Err(Error::Other(format!( + "Decimals {decimals} exceeds maximum {MAX_DECIMALS}" + ))); + } + if amount.is_empty() { + return Err(Error::Other("Empty amount".into())); + } + if amount.matches('.').count() > 1 { + return Err(Error::Other(format!( + "Invalid amount `{amount}`: more than one decimal point" + ))); + } + let decimals = decimals as u32; + + if let Some((integer, fraction)) = amount.split_once('.') { + if integer.is_empty() || fraction.is_empty() { + return Err(Error::Other(format!( + "Invalid amount `{amount}`: integer and fractional parts must both be non-empty" + ))); + } + if !integer.bytes().all(|b| b.is_ascii_digit()) + || !fraction.bytes().all(|b| b.is_ascii_digit()) + { + return Err(Error::Other(format!( + "Invalid amount `{amount}`: only ASCII digits and a single optional decimal point are allowed" + ))); + } + let frac_len = fraction.len() as u32; + if frac_len > decimals { + return Err(Error::Other(format!( + "Too many decimal places: {frac_len} > {decimals}" + ))); + } + let padding = decimals - frac_len; + let combined = format!("{integer}{fraction}{}", "0".repeat(padding as usize)); + let trimmed = combined.trim_start_matches('0'); + if trimmed.is_empty() { + Ok("0".to_string()) + } else { + Ok(trimmed.to_string()) + } + } else { + let value: u128 = amount + .parse() + .map_err(|_| Error::Other(format!("Invalid amount: {amount}")))?; + let factor = 10u128.checked_pow(decimals).ok_or_else(|| { + Error::Other(format!("10^{decimals} overflows u128 in parse_units")) + })?; + let product = value.checked_mul(factor).ok_or_else(|| { + Error::Other(format!( + "{value} * 10^{decimals} overflows u128 in parse_units" + )) + })?; + Ok(product.to_string()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn integer_and_decimal() { + assert_eq!(parse_units("1", 6).unwrap(), "1000000"); + assert_eq!(parse_units("0", 6).unwrap(), "0"); + assert_eq!(parse_units("1.5", 6).unwrap(), "1500000"); + assert_eq!(parse_units("0.000001", 6).unwrap(), "1"); + assert_eq!(parse_units("0.0", 6).unwrap(), "0"); + } + + #[test] + fn rejects_malformed() { + assert!(parse_units("", 6).is_err()); + assert!(parse_units(".5", 1).is_err()); + assert!(parse_units("1.", 0).is_err()); + assert!(parse_units("1.2.3", 6).is_err()); + assert!(parse_units("-1", 6).is_err()); + assert!(parse_units("1a.2", 6).is_err()); + assert!(parse_units("1.0000001", 6).is_err()); + } + + #[test] + fn decimals_cap_and_overflow() { + assert!(parse_units("1", MAX_DECIMALS + 1).is_err()); + assert_eq!(parse_units("1", MAX_DECIMALS).unwrap(), "1000000000000000000"); + let huge = format!("1{}", "0".repeat(21)); + assert!(parse_units(&huge, MAX_DECIMALS).is_err()); + } +} diff --git a/rust/crates/kit/Cargo.toml b/rust/crates/kit/Cargo.toml index 4ec6b8d61..cb78c0141 100644 --- a/rust/crates/kit/Cargo.toml +++ b/rust/crates/kit/Cargo.toml @@ -30,7 +30,12 @@ tokio = { version = "1", features = ["full"] } axum = "0.8" tower = { version = "0.5", features = ["util"] } serde_json = "1" +ed25519-dalek = "2" [[example]] name = "axum_quickstart" required-features = ["axum"] + +[[example]] +name = "axum_upto" +required-features = ["axum"] diff --git a/rust/crates/kit/examples/axum_upto.rs b/rust/crates/kit/examples/axum_upto.rs new file mode 100644 index 000000000..426651e38 --- /dev/null +++ b/rust/crates/kit/examples/axum_upto.rs @@ -0,0 +1,73 @@ +//! Usage-based (`upto`) gate: charge per actual usage, up to a ceiling. +//! +//! The route advertises a **maximum** price. The client opens a payment channel +//! depositing that ceiling; the handler meters real usage and reports it via the +//! [`Charge`] extractor; the gate settles the actual amount and refunds the +//! remainder. This is the shape you want for LLM-token billing or per-byte +//! metering, where the final cost is unknown until after the work runs. +//! +//! `upto` settlement is operator-signed, so it requires a `fee_payer_signer`. +//! Provide the operator key as a JSON byte array in `MPP_OPERATOR_KEY`: +//! +//! ```bash +//! export MPP_OPERATOR_KEY='[12,34,...]' # 64-byte keypair +//! cargo run -p solana-pay-kit --example axum_upto --features axum +//! ``` + +use std::sync::Arc; + +use axum::Router; +use solana_pay_kit::mpp::solana_keychain::memory::MemorySigner; +use solana_pay_kit::{paid_upto_post, Charge, PayKit, PayKitConfig, Payment}; + +/// Price per generated token, in USDC base units (6 decimals): 100 = $0.0001. +const PRICE_PER_TOKEN_BASE_UNITS: u64 = 100; + +/// Summarize the request body and bill for the tokens "generated". +/// +/// `Payment` carries the authorized ceiling; `Charge` reports actual usage. The +/// body is the text to summarize. Extractors that read the body come last. +async fn summarize(payment: Payment, charge: Charge, body: String) -> String { + // Pretend we ran a model: one token per 4 input bytes, min one token. + let tokens = (body.len() / 4).max(1) as u64; + let owed = tokens.saturating_mul(PRICE_PER_TOKEN_BASE_UNITS); + + // Report actual usage; the gate settles this (clamped to the ceiling) and + // refunds the unused deposit. + charge.charge(owed); + + format!( + "summarized {} bytes as {tokens} tokens; billed {} of up to {} base units (ceiling {})", + body.len(), + owed.min(charge.max_base_units()), + charge.max_base_units(), + payment.amount, + ) +} + +#[tokio::main] +async fn main() { + let operator_json = std::env::var("MPP_OPERATOR_KEY") + .expect("set MPP_OPERATOR_KEY to a 64-byte keypair JSON array"); + let operator = MemorySigner::from_private_key_string(&operator_json) + .expect("valid operator keypair in MPP_OPERATOR_KEY"); + + let pay = PayKit::new(PayKitConfig { + recipient: "CXhrFZJLKqjzmP3sjYLcF4dTeXWKCy9e2SXXZ2Yo6MPY".to_string(), + network: "devnet".to_string(), + rpc_url: Some("https://402.surfnet.dev:8899".to_string()), + // upto settlement vouchers are operator-signed, so a signer is required. + fee_payer_signer: Some(Arc::new(operator)), + ..Default::default() + }) + .expect("valid config"); + + // Maximum $1.00; the handler bills only for actual tokens generated. + let app = Router::new().route("/summarize", paid_upto_post(summarize, "1.00", &pay)); + + let listener = tokio::net::TcpListener::bind("127.0.0.1:4568") + .await + .unwrap(); + println!("listening on http://127.0.0.1:4568/summarize"); + axum::serve(listener, app).await.unwrap(); +} diff --git a/rust/crates/kit/src/gate.rs b/rust/crates/kit/src/gate.rs index d6494adad..c65ebf22b 100644 --- a/rust/crates/kit/src/gate.rs +++ b/rust/crates/kit/src/gate.rs @@ -25,7 +25,7 @@ //! let app: Router = Router::new().route("/report", paid_get(report, "0.10", &pay)); //! ``` -use std::sync::Arc; +use std::sync::{Arc, Mutex}; use axum::extract::{FromRequestParts, Request, State}; use axum::handler::Handler; @@ -38,7 +38,9 @@ use axum::routing::{get, post, MethodRouter}; use solana_mpp::server::{Config as MppConfig, Mpp}; use solana_mpp::solana_keychain::SolanaSigner; use solana_mpp::{format_receipt, format_www_authenticate, Receipt, ReceiptKind}; -use solana_x402::server::{Config as X402Config, ExactOptions, VerifiedExactPayment, X402}; +use solana_x402::server::{ + Config as X402Config, ExactOptions, UptoConfig, VerifiedExactPayment, X402Upto, X402, +}; use solana_x402::{PAYMENT_RESPONSE_HEADER, PAYMENT_SIGNATURE_HEADER, X402_V1_PAYMENT_HEADER}; const PAYMENT_RECEIPT_HEADER: &str = "Payment-Receipt"; @@ -117,8 +119,15 @@ impl Default for PayKitConfig { pub struct PayKit { mpp: Arc, x402: Arc, + /// Usage-based x402 `upto` handler. `Some` only when `fee_payer_signer` is + /// set — the operator must sign settlement vouchers, so `upto` routes + /// require a signer. + x402_upto: Option>, } +/// Default `upto` completion window (seconds) advertised in `maxTimeoutSeconds`. +const UPTO_MAX_TIMEOUT_SECONDS: u64 = 300; + impl PayKit { /// Build both protocol handlers from one config. pub fn new(config: PayKitConfig) -> Result { @@ -155,9 +164,34 @@ impl PayKit { }) .map_err(|e| PayKitError::X402(e.to_string()))?; + // The `upto` scheme needs an operator signer to settle vouchers, so it + // is only available when the gate sponsors fees with a signer. + let x402_upto = config + .fee_payer_signer + .as_ref() + .map(|signer| { + X402Upto::new(UptoConfig { + recipient: config.recipient.clone(), + currency: config.currency.clone(), + decimals: config.decimals, + cluster: config.network.clone(), + rpc_url: config.rpc_url.clone(), + resource: String::new(), + description: None, + max_timeout_seconds: UPTO_MAX_TIMEOUT_SECONDS, + token_program: None, + program_id: None, + operator_signer: signer.clone(), + }) + .map(Arc::new) + .map_err(|e| PayKitError::X402(e.to_string())) + }) + .transpose()?; + Ok(Self { mpp: Arc::new(mpp), x402: Arc::new(x402), + x402_upto, }) } @@ -170,6 +204,11 @@ impl PayKit { pub fn x402(&self) -> &Arc { &self.x402 } + + /// The underlying x402 `upto` handler, when a fee-payer signer is configured. + pub fn x402_upto(&self) -> Option<&Arc> { + self.x402_upto.as_ref() + } } /// The protocol a [`Payment`] was made with. @@ -219,6 +258,48 @@ impl FromRequestParts for Payment { } } +/// Usage meter for [`paid_upto_get`] / [`paid_upto_post`] routes. +/// +/// The route handler reports the actual amount consumed (in base units) by +/// calling [`Charge::charge`]. The gate settles that amount — never more than +/// the authorized ceiling — after the handler returns, refunding the remainder. +/// If the handler never calls `charge`, the settled amount is `0`. +#[derive(Clone)] +pub struct Charge { + cell: Arc>>, + max_base_units: u64, +} + +impl Charge { + /// Record the actual amount consumed, in token base units. Values above the + /// authorized maximum are clamped to it. + pub fn charge(&self, base_units: u64) { + let clamped = base_units.min(self.max_base_units); + if let Ok(mut slot) = self.cell.lock() { + *slot = Some(clamped); + } + } + + /// The authorized maximum for this request, in base units. + pub fn max_base_units(&self) -> u64 { + self.max_base_units + } +} + +impl FromRequestParts for Charge { + type Rejection = Response; + + async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result { + parts.extensions.get::().cloned().ok_or_else(|| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + "Charge extractor used on a route not gated by paid_upto_get/paid_upto_post", + ) + .into_response() + }) + } +} + /// The price a [`paid_get`] / [`paid_post`] route charges: a fixed amount or a /// per-request closure. /// @@ -546,6 +627,156 @@ where )) } +/// Build the 402 response advertising the x402 `upto` challenge. +fn upto_challenge_response(upto: &X402Upto, amount: &str) -> Response { + let mut resp = (StatusCode::PAYMENT_REQUIRED, "Payment Required").into_response(); + match upto.payment_required_header(amount) { + Ok((name, value)) => match ( + HeaderName::from_bytes(name.as_bytes()), + HeaderValue::from_str(&value), + ) { + (Ok(n), Ok(v)) => { + resp.headers_mut().insert(n, v); + } + _ => tracing::warn!(amount = %amount, "invalid x402 upto PAYMENT-REQUIRED header"), + }, + Err(e) => tracing::warn!(amount = %amount, error = %e, "failed to build upto challenge"), + } + resp.headers_mut() + .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store")); + resp +} + +/// Usage-based gate: verify the authorization and broadcast the channel open, +/// run the handler, then settle the actual metered amount and refund the rest. +async fn upto_gate_middleware( + State(state): State, + mut req: Request, + next: Next, +) -> Response { + let amount = { + let ctx = PriceCtx { + method: req.method(), + uri: req.uri(), + headers: req.headers(), + }; + state.price.resolve(&ctx) + }; + + let Some(upto) = state.pay.x402_upto.clone() else { + tracing::error!("paid_upto route used but no fee_payer_signer configured"); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + "upto routes require a fee_payer_signer", + ) + .into_response(); + }; + + let x402_header = req + .headers() + .get(PAYMENT_SIGNATURE_HEADER) + .and_then(|v| v.to_str().ok()) + .map(str::to_string); + + let Some(header_value) = x402_header else { + return upto_challenge_response(&upto, &amount); + }; + + // Verify the authorization and broadcast + confirm the channel open. + let open = match upto.verify_open(&header_value, &amount).await { + Ok(open) => open, + Err(e) => { + tracing::warn!(amount = %amount, error = %e, "upto open verification failed"); + return upto_challenge_response(&upto, &amount); + } + }; + + // Run the handler with a usage meter; default to a zero charge. + let cell = Arc::new(Mutex::new(None)); + let charge = Charge { + cell: cell.clone(), + max_base_units: open.max_amount, + }; + req.extensions_mut().insert(Payment { + amount: amount.clone(), + protocol: Protocol::X402, + reference: open.channel_id.to_string(), + }); + req.extensions_mut().insert(charge); + let mut resp = next.run(req).await; + + let actual = cell.lock().ok().and_then(|slot| *slot).unwrap_or(0); + + // Settle the actual amount and refund the remainder. + match upto.settle_actual(&open, actual).await { + Ok(settlement) => match upto.settlement_header(&settlement) { + Ok((name, value)) => { + if let (Ok(n), Ok(v)) = ( + HeaderName::from_bytes(name.as_bytes()), + HeaderValue::from_str(&value), + ) { + resp.headers_mut().insert(n, v); + } + resp + } + Err(e) => { + tracing::error!(error = %e, "failed to encode upto settlement header"); + resp + } + }, + Err(e) => { + tracing::error!(actual, error = %e, "upto settlement failed after handler ran"); + ( + StatusCode::BAD_GATEWAY, + "payment settlement failed; the channel can be reclaimed after its grace period", + ) + .into_response() + } + } +} + +/// Gate a `GET` handler behind x402 `upto` (usage-based) payment at the given +/// **maximum** price. The handler reports actual usage via the [`Charge`] +/// extractor; the gate settles that amount and refunds the rest. +/// +/// Requires a `fee_payer_signer` on [`PayKitConfig`] (the operator signs +/// settlement vouchers). +pub fn paid_upto_get(handler: H, max_price: impl Into, pay: &PayKit) -> MethodRouter +where + H: Handler, + T: 'static, + S: Clone + Send + Sync + 'static, +{ + get(handler).layer(from_fn_with_state( + GateState { + pay: pay.clone(), + price: max_price.into(), + }, + upto_gate_middleware, + )) +} + +/// Gate a `POST` handler behind x402 `upto` (usage-based) payment at the given +/// **maximum** price. See [`paid_upto_get`]. +pub fn paid_upto_post( + handler: H, + max_price: impl Into, + pay: &PayKit, +) -> MethodRouter +where + H: Handler, + T: 'static, + S: Clone + Send + Sync + 'static, +{ + post(handler).layer(from_fn_with_state( + GateState { + pay: pay.clone(), + price: max_price.into(), + }, + upto_gate_middleware, + )) +} + #[cfg(test)] mod tests { use super::*; @@ -570,6 +801,56 @@ mod tests { "ok" } + fn test_signer() -> Arc { + let sk = ed25519_dalek::SigningKey::from_bytes(&[7u8; 32]); + let mut kp = [0u8; 64]; + kp[..32].copy_from_slice(sk.as_bytes()); + kp[32..].copy_from_slice(sk.verifying_key().as_bytes()); + Arc::new(solana_mpp::solana_keychain::MemorySigner::from_bytes(&kp).expect("valid keypair")) + } + + /// PayKit with an operator signer (enables `upto`). A bogus RPC URL makes + /// the best-effort blockhash fetch fail fast so the challenge builds offline. + fn upto_paykit() -> PayKit { + PayKit::new(PayKitConfig { + recipient: TEST_RECIPIENT.to_string(), + challenge_binding_secret: Some(TEST_SECRET.to_string()), + network: "devnet".to_string(), + rpc_url: Some("http://127.0.0.1:1".to_string()), + fee_payer_signer: Some(test_signer()), + ..Default::default() + }) + .expect("valid paykit config") + } + + #[tokio::test(flavor = "multi_thread")] + async fn paid_upto_without_signer_returns_500() { + // No fee_payer_signer → no upto handler → misconfiguration surfaces as 500. + let pay = test_paykit(); + let app: Router = Router::new().route("/u", paid_upto_get(report, "1.00", &pay)); + let resp = app + .oneshot(Request::builder().uri("/u").body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR); + } + + #[tokio::test(flavor = "multi_thread")] + async fn paid_upto_unpaid_returns_402_with_upto_challenge() { + let pay = upto_paykit(); + let app: Router = Router::new().route("/u", paid_upto_get(report, "1.00", &pay)); + let resp = app + .oneshot(Request::builder().uri("/u").body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::PAYMENT_REQUIRED); + assert!(resp.headers().contains_key("payment-required")); + assert_eq!( + resp.headers().get(header::CACHE_CONTROL).unwrap(), + "no-store" + ); + } + fn ctx<'a>(method: &'a Method, uri: &'a Uri, headers: &'a HeaderMap) -> PriceCtx<'a> { PriceCtx { method, diff --git a/rust/crates/kit/src/lib.rs b/rust/crates/kit/src/lib.rs index 1d18f68bf..badb35aaf 100644 --- a/rust/crates/kit/src/lib.rs +++ b/rust/crates/kit/src/lib.rs @@ -19,5 +19,6 @@ mod gate; #[cfg(feature = "axum")] pub use gate::{ - paid_get, paid_post, PayKit, PayKitConfig, PayKitError, Payment, Price, PriceCtx, Protocol, + paid_get, paid_post, paid_upto_get, paid_upto_post, Charge, PayKit, PayKitConfig, PayKitError, + Payment, Price, PriceCtx, Protocol, }; diff --git a/rust/crates/mpp/Cargo.toml b/rust/crates/mpp/Cargo.toml index 44a6bb8d7..f4531192f 100644 --- a/rust/crates/mpp/Cargo.toml +++ b/rust/crates/mpp/Cargo.toml @@ -49,7 +49,8 @@ bs58 = "0.5" base64 = "0.22" bincode = "1" borsh = { version = "1.0", features = ["derive"] } -payment_channels_client = { package = "payment-channels-client", path = "../programs/payment-channels" } +solana-pay-core = { path = "../core" } +subscriptions_client = { package = "subscriptions-client", path = "../programs/subscriptions" } solana-address = { version = "2", features = ["borsh", "curve25519"] } # Crypto (HMAC challenge IDs + distribution hash + voucher verification) diff --git a/rust/crates/mpp/src/client/mod.rs b/rust/crates/mpp/src/client/mod.rs index 323095699..5718eab9d 100644 --- a/rust/crates/mpp/src/client/mod.rs +++ b/rust/crates/mpp/src/client/mod.rs @@ -3,8 +3,6 @@ pub mod authenticate; mod charge; pub mod http_stream; -pub mod multi_delegate; -pub mod payment_channels; pub mod session; pub mod session_consumer; pub mod subscription; @@ -15,7 +13,16 @@ pub use authenticate::{ }; pub use charge::*; pub use http_stream::*; -pub use payment_channels::*; +// The payment-channel session opener now lives in `session`; re-export the +// same symbols here to preserve the historical `mpp::client::*` paths. +pub use session::{ + build_open_payment_channel_transaction, create_payment_channel_session_opener, + create_server_opened_payment_channel_session_opener, derive_payment_channel_open, + BuildOpenPaymentChannelTransactionParams, DerivePaymentChannelOpenParams, PaymentChannelOpen, + PaymentChannelOpenOptions, PaymentChannelSessionOpen, PaymentChannelSessionOpenOptions, + ServerOpenedPaymentChannelSessionOpenOptions, DEFAULT_GRACE_PERIOD_SECONDS, + PENDING_SERVER_SIGNATURE, +}; pub use session_consumer::*; pub use subscription::{ build_subscription_activation_transaction, diff --git a/rust/crates/mpp/src/client/multi_delegate.rs b/rust/crates/mpp/src/client/multi_delegate.rs deleted file mode 100644 index 6fd1155cf..000000000 --- a/rust/crates/mpp/src/client/multi_delegate.rs +++ /dev/null @@ -1,121 +0,0 @@ -//! Client-side multi-delegator transaction builders. -//! -//! Produces base64-encoded, fully-signed Solana transactions that the server -//! can submit on the client's behalf during a pull-mode session open. - -use base64::Engine; -use solana_hash::Hash; -use solana_instruction::Instruction; -use solana_keychain::SolanaSigner; -use solana_message::Message; -use solana_pubkey::Pubkey; -use solana_signature::Signature; -use solana_transaction::Transaction; - -use crate::error::{Error, Result}; -use crate::program::multi_delegator::{ - build_create_fixed_delegation_ix, build_init_multi_delegate_ix, find_fixed_delegation_pda, - find_multi_delegate_pda, -}; - -/// Build + sign the `initMultiDelegateTx`. -/// -/// Two instructions in one transaction: -/// 1. `InitMultiDelegate` — creates the `MultiDelegate` PDA and approves it as -/// `u64::MAX` SPL Token delegate on `user_ata`. -/// 2. `CreateFixedDelegation` — creates a `FixedDelegation` PDA capping the -/// operator's authority to `amount` tokens for `nonce` + `expiry_ts`. -/// -/// The signer is the user/client; they pay fees. -/// Returns the serialized, signed transaction as a standard base64 string. -#[allow(clippy::too_many_arguments)] -pub async fn build_init_multi_delegate_tx( - signer: &dyn SolanaSigner, - mint: &Pubkey, - user_ata: &Pubkey, - operator: &Pubkey, - program_id: &Pubkey, - token_program: &Pubkey, - nonce: u64, - amount: u64, - expiry_ts: i64, - recent_blockhash: Hash, -) -> Result { - let user = signer.pubkey(); - let (multi_delegate_pda, _) = find_multi_delegate_pda(&user, mint, program_id); - let (delegation_pda, _) = - find_fixed_delegation_pda(&multi_delegate_pda, &user, operator, nonce, program_id); - - let init_ix = build_init_multi_delegate_ix(program_id, &user, mint, user_ata, token_program); - let create_ix = build_create_fixed_delegation_ix( - program_id, - &user, - &multi_delegate_pda, - &delegation_pda, - operator, - nonce, - amount, - expiry_ts, - ); - - sign_and_encode(signer, &[init_ix, create_ix], recent_blockhash).await -} - -/// Build + sign the `updateDelegationTx`. -/// -/// One `CreateFixedDelegation` instruction, used to raise an existing cap or -/// create a new delegation at a fresh `nonce`. -/// -/// Returns the serialized, signed transaction as a standard base64 string. -#[allow(clippy::too_many_arguments)] -pub async fn build_update_delegation_tx( - signer: &dyn SolanaSigner, - mint: &Pubkey, - operator: &Pubkey, - program_id: &Pubkey, - nonce: u64, - amount: u64, - expiry_ts: i64, - recent_blockhash: Hash, -) -> Result { - let user = signer.pubkey(); - let (multi_delegate_pda, _) = find_multi_delegate_pda(&user, mint, program_id); - let (delegation_pda, _) = - find_fixed_delegation_pda(&multi_delegate_pda, &user, operator, nonce, program_id); - - let ix = build_create_fixed_delegation_ix( - program_id, - &user, - &multi_delegate_pda, - &delegation_pda, - operator, - nonce, - amount, - expiry_ts, - ); - - sign_and_encode(signer, &[ix], recent_blockhash).await -} - -// ── shared helper ───────────────────────────────────────────────────────────── - -async fn sign_and_encode( - signer: &dyn SolanaSigner, - instructions: &[Instruction], - recent_blockhash: Hash, -) -> Result { - let fee_payer = signer.pubkey(); - let message = Message::new_with_blockhash(instructions, Some(&fee_payer), &recent_blockhash); - let mut tx = Transaction::new_unsigned(message); - - let sig_bytes = signer - .sign_message(&tx.message_data()) - .await - .map_err(|e| Error::Other(format!("signing failed: {e}")))?; - let sig = Signature::from(<[u8; 64]>::from(sig_bytes)); - tx.signatures[0] = sig; - - let bytes = bincode::serialize(&tx) - .map_err(|e| Error::Other(format!("tx serialization failed: {e}")))?; - Ok(base64::engine::general_purpose::STANDARD.encode(bytes)) -} diff --git a/rust/crates/mpp/src/client/payment_channels.rs b/rust/crates/mpp/src/client/payment_channels.rs deleted file mode 100644 index f3eec9176..000000000 --- a/rust/crates/mpp/src/client/payment_channels.rs +++ /dev/null @@ -1,844 +0,0 @@ -//! Client-side helpers for payment-channel open transactions. - -use std::str::FromStr; - -use base64::Engine; -use solana_hash::Hash; -use solana_keychain::SolanaSigner; -use solana_message::Message; -use solana_pubkey::Pubkey; -use solana_transaction::Transaction; - -use super::session::ActiveSession; -use crate::error::{Error, Result}; -use crate::program::payment_channels::{ - build_open_instruction, default_program_id, derive_channel_addresses, Distribution, - OpenChannelParams, -}; -use crate::protocol::intents::session::{ - OpenPayload, SessionAction, SessionMode, SessionPullVoucherStrategy, SessionRequest, - DEFAULT_SESSION_EXPIRES_AT, -}; -use crate::protocol::solana::{default_token_program_for_currency, resolve_stablecoin_mint}; - -/// Default payment-channel close grace period used by the TypeScript client. -pub const DEFAULT_GRACE_PERIOD_SECONDS: u32 = 900; - -/// Placeholder signature used while the operator still needs to submit the -/// server-broadcast open transaction. -pub const PENDING_SERVER_SIGNATURE: &str = - "1111111111111111111111111111111111111111111111111111111111111111"; - -#[derive(Debug, Clone)] -pub struct PaymentChannelOpen { - pub channel_id: Pubkey, - pub payer: Pubkey, - pub payee: Pubkey, - pub mint: Pubkey, - pub authorized_signer: Pubkey, - pub salt: u64, - pub deposit: u64, - pub grace_period: u32, - pub recipients: Vec, - pub token_program: Pubkey, - pub program_id: Pubkey, -} - -impl PaymentChannelOpen { - pub fn open_channel_params(&self) -> OpenChannelParams { - OpenChannelParams { - payer: self.payer, - payee: self.payee, - mint: self.mint, - authorized_signer: self.authorized_signer, - salt: self.salt, - deposit: self.deposit, - grace_period: self.grace_period, - recipients: self.recipients.clone(), - token_program: self.token_program, - program_id: self.program_id, - } - } - - pub fn open_payload(&self, mode: SessionMode, signature: impl Into) -> OpenPayload { - OpenPayload::payment_channel_with_mode( - mode, - pubkey_string(&self.channel_id), - self.deposit.to_string(), - pubkey_string(&self.payer), - pubkey_string(&self.payee), - pubkey_string(&self.mint), - self.salt, - self.grace_period, - pubkey_string(&self.authorized_signer), - signature.into(), - ) - } -} - -#[derive(Debug, Clone)] -pub struct PaymentChannelOpenTransaction { - pub channel_id: Pubkey, - pub transaction: String, -} - -#[derive(Debug, Clone, Default)] -pub struct PaymentChannelOpenOptions { - pub deposit: Option, - pub grace_period: Option, - pub program_id: Option, - pub recipients: Option>, - pub salt: Option, - pub token_program: Option, -} - -#[derive(Debug, Clone)] -pub struct DerivePaymentChannelOpenParams<'a> { - pub request: &'a SessionRequest, - pub payer: Pubkey, - pub authorized_signer: Pubkey, - pub options: PaymentChannelOpenOptions, -} - -pub struct PaymentChannelSessionOpen { - pub open: PaymentChannelOpen, - pub session: ActiveSession, - pub action: SessionAction, -} - -#[derive(Default)] -pub struct PaymentChannelSessionOpenOptions { - pub open: PaymentChannelOpenOptions, - pub signature: Option, - pub cumulative: Option, - pub expires_at: Option, -} - -#[derive(Default)] -pub struct ServerOpenedPaymentChannelSessionOpenOptions { - pub open: PaymentChannelOpenOptions, - pub payer: Option, - pub signature: Option, - pub cumulative: Option, - pub expires_at: Option, -} - -pub fn derive_payment_channel_open( - params: DerivePaymentChannelOpenParams<'_>, -) -> Result { - let request = params.request; - let network = request.network.as_deref(); - let mint = parse_pubkey( - resolve_stablecoin_mint(&request.currency, network) - .ok_or_else(|| Error::Other("session payment channels require an SPL token".into()))?, - "mint", - )?; - let payee = parse_pubkey(&request.recipient, "recipient")?; - let deposit = match params.options.deposit { - Some(deposit) => deposit, - None => parse_u64_string(&request.cap, "session cap")?, - }; - let grace_period = params - .options - .grace_period - .unwrap_or(DEFAULT_GRACE_PERIOD_SECONDS); - let program_id = match params.options.program_id { - Some(program_id) => program_id, - None => request - .program_id - .as_deref() - .map(|value| parse_pubkey(value, "programId")) - .transpose()? - .unwrap_or_else(default_program_id), - }; - let token_program = match params.options.token_program { - Some(token_program) => token_program, - None => parse_pubkey( - default_token_program_for_currency(&request.currency, network), - "token program", - )?, - }; - let recipients = match params.options.recipients { - Some(recipients) => recipients, - None => parse_splits(request)?, - }; - let salt = params.options.salt.unwrap_or_else(unique_salt); - let open_params = OpenChannelParams { - payer: params.payer, - payee, - mint, - authorized_signer: params.authorized_signer, - salt, - deposit, - grace_period, - recipients, - token_program, - program_id, - }; - let channel_id = derive_channel_addresses(&open_params).channel; - - Ok(PaymentChannelOpen { - channel_id, - payer: open_params.payer, - payee: open_params.payee, - mint: open_params.mint, - authorized_signer: open_params.authorized_signer, - salt: open_params.salt, - deposit: open_params.deposit, - grace_period: open_params.grace_period, - recipients: open_params.recipients, - token_program: open_params.token_program, - program_id: open_params.program_id, - }) -} - -pub struct BuildOpenPaymentChannelTransactionParams<'a> { - pub request: &'a SessionRequest, - pub signer: &'a dyn SolanaSigner, - pub authorized_signer: Pubkey, - pub fee_payer: Option, - pub recent_blockhash: Hash, - pub options: PaymentChannelOpenOptions, -} - -pub async fn build_open_payment_channel_transaction( - params: BuildOpenPaymentChannelTransactionParams<'_>, -) -> Result { - let fee_payer = params - .fee_payer - .map(Ok) - .unwrap_or_else(|| parse_pubkey(¶ms.request.operator, "operator"))?; - let open = derive_payment_channel_open(DerivePaymentChannelOpenParams { - request: params.request, - payer: params.signer.pubkey(), - authorized_signer: params.authorized_signer, - options: params.options, - })?; - - build_open_payment_channel_tx( - params.signer, - &open.payee, - &open.mint, - &open.authorized_signer, - open.salt, - open.deposit, - open.grace_period, - open.recipients.clone(), - &open.token_program, - &open.program_id, - &fee_payer, - params.recent_blockhash, - ) - .await -} - -pub async fn create_payment_channel_session_opener( - request: &SessionRequest, - payer_signer: &dyn SolanaSigner, - session_signer: Box, - recent_blockhash: Hash, - options: PaymentChannelSessionOpenOptions, -) -> Result { - ensure_client_voucher_pull(request)?; - let authorized_signer = session_signer.pubkey(); - let fee_payer = parse_pubkey(&request.operator, "operator")?; - let open = derive_payment_channel_open(DerivePaymentChannelOpenParams { - request, - payer: payer_signer.pubkey(), - authorized_signer, - options: options.open.clone(), - })?; - let tx = build_open_payment_channel_tx( - payer_signer, - &open.payee, - &open.mint, - &open.authorized_signer, - open.salt, - open.deposit, - open.grace_period, - open.recipients.clone(), - &open.token_program, - &open.program_id, - &fee_payer, - recent_blockhash, - ) - .await?; - let mut session = ActiveSession::new(open.channel_id, session_signer); - configure_session(&mut session, options.cumulative, options.expires_at); - let signature = options - .signature - .unwrap_or_else(|| PENDING_SERVER_SIGNATURE.to_string()); - let action = SessionAction::Open( - open.open_payload(SessionMode::Pull, signature) - .with_transaction(tx.transaction), - ); - - Ok(PaymentChannelSessionOpen { - open, - session, - action, - }) -} - -pub fn create_server_opened_payment_channel_session_opener( - request: &SessionRequest, - session_signer: Box, - options: ServerOpenedPaymentChannelSessionOpenOptions, -) -> Result { - ensure_client_voucher_pull(request)?; - let payer = options - .payer - .map(Ok) - .unwrap_or_else(|| parse_pubkey(&request.operator, "operator"))?; - let authorized_signer = session_signer.pubkey(); - let open = derive_payment_channel_open(DerivePaymentChannelOpenParams { - request, - payer, - authorized_signer, - options: options.open, - })?; - let mut session = ActiveSession::new(open.channel_id, session_signer); - configure_session(&mut session, options.cumulative, options.expires_at); - let signature = options - .signature - .unwrap_or_else(|| PENDING_SERVER_SIGNATURE.to_string()); - let action = SessionAction::Open(open.open_payload(SessionMode::Pull, signature)); - - Ok(PaymentChannelSessionOpen { - open, - session, - action, - }) -} - -#[allow(clippy::too_many_arguments)] -pub async fn build_open_payment_channel_tx( - signer: &dyn SolanaSigner, - payee: &Pubkey, - mint: &Pubkey, - authorized_signer: &Pubkey, - salt: u64, - deposit: u64, - grace_period: u32, - recipients: Vec, - token_program: &Pubkey, - program_id: &Pubkey, - fee_payer: &Pubkey, - recent_blockhash: Hash, -) -> Result { - let params = OpenChannelParams { - payer: signer.pubkey(), - payee: *payee, - mint: *mint, - authorized_signer: *authorized_signer, - salt, - deposit, - grace_period, - recipients, - token_program: *token_program, - program_id: *program_id, - }; - let channel_id = derive_channel_addresses(¶ms).channel; - let ix = build_open_instruction(¶ms); - let message = Message::new_with_blockhash(&[ix], Some(fee_payer), &recent_blockhash); - let mut tx = Transaction::new_unsigned(message); - - signer - .sign_transaction(&mut tx) - .await - .map_err(|e| Error::Other(format!("payment-channel open signing failed: {e}")))?; - - let bytes = bincode::serialize(&tx) - .map_err(|e| Error::Other(format!("payment-channel open tx serialization failed: {e}")))?; - Ok(PaymentChannelOpenTransaction { - channel_id, - transaction: base64::engine::general_purpose::STANDARD.encode(bytes), - }) -} - -fn ensure_client_voucher_pull(request: &SessionRequest) -> Result<()> { - if !request.modes.contains(&SessionMode::Pull) { - return Err(Error::Other( - "session challenge does not advertise pull mode".to_string(), - )); - } - if request.pull_voucher_strategy.as_ref() != Some(&SessionPullVoucherStrategy::ClientVoucher) { - return Err(Error::Other( - "session challenge does not advertise pull + clientVoucher".to_string(), - )); - } - Ok(()) -} - -fn configure_session( - session: &mut ActiveSession, - cumulative: Option, - expires_at: Option, -) { - session.cumulative = cumulative.unwrap_or(0); - session.set_expires_at(expires_at.unwrap_or(DEFAULT_SESSION_EXPIRES_AT)); -} - -fn parse_splits(request: &SessionRequest) -> Result> { - request - .splits - .iter() - .map(|split| { - Ok(Distribution { - recipient: parse_pubkey(&split.recipient, "split recipient")?, - bps: split.bps, - }) - }) - .collect() -} - -fn parse_u64_string(value: &str, label: &str) -> Result { - value - .parse::() - .map_err(|e| Error::Other(format!("invalid {label}: {e}"))) -} - -fn parse_pubkey(value: &str, label: &str) -> Result { - Pubkey::from_str(value).map_err(|e| Error::Other(format!("invalid {label}: {e}"))) -} - -fn pubkey_string(pubkey: &Pubkey) -> String { - bs58::encode(pubkey.as_ref()).into_string() -} - -fn unique_salt() -> u64 { - let bytes = Pubkey::new_unique().to_bytes(); - u64::from_le_bytes(bytes[..8].try_into().expect("slice is exactly 8 bytes")) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::protocol::intents::session::SessionSplit; - use crate::protocol::solana::{mints, programs}; - use solana_keychain::MemorySigner; - use solana_signature::Signature; - - fn make_signer(seed: u8) -> Box { - let sk = ed25519_dalek::SigningKey::from_bytes(&[seed; 32]); - let mut kp = [0u8; 64]; - kp[..32].copy_from_slice(sk.as_bytes()); - kp[32..].copy_from_slice(sk.verifying_key().as_bytes()); - Box::new(MemorySigner::from_bytes(&kp).expect("valid keypair")) - } - - fn test_request(operator: Pubkey, recipient: Pubkey) -> SessionRequest { - SessionRequest { - cap: "1000".to_string(), - currency: "USDC".to_string(), - decimals: Some(6), - network: Some("localnet".to_string()), - operator: pubkey_string(&operator), - recipient: pubkey_string(&recipient), - splits: vec![], - program_id: None, - description: None, - external_id: None, - min_voucher_delta: None, - modes: vec![SessionMode::Pull], - pull_voucher_strategy: Some(SessionPullVoucherStrategy::ClientVoucher), - recent_blockhash: None, - } - } - - fn decode_transaction(encoded: &str) -> Transaction { - let bytes = base64::engine::general_purpose::STANDARD - .decode(encoded) - .expect("base64 transaction"); - bincode::deserialize(&bytes).expect("bincode transaction") - } - - #[test] - fn derive_payment_channel_open_uses_challenge_defaults_and_splits() { - let operator = Pubkey::new_unique(); - let recipient = Pubkey::new_unique(); - let split_recipient = Pubkey::new_unique(); - let mut request = test_request(operator, recipient); - request.splits.push(SessionSplit { - recipient: pubkey_string(&split_recipient), - bps: 10, - }); - - let payer = Pubkey::new_unique(); - let authorized_signer = Pubkey::new_unique(); - let open = derive_payment_channel_open(DerivePaymentChannelOpenParams { - request: &request, - payer, - authorized_signer, - options: PaymentChannelOpenOptions { - salt: Some(42), - ..PaymentChannelOpenOptions::default() - }, - }) - .unwrap(); - - assert_eq!(open.payer, payer); - assert_eq!(open.payee, recipient); - assert_eq!(open.authorized_signer, authorized_signer); - assert_eq!(open.deposit, 1000); - assert_eq!(open.grace_period, DEFAULT_GRACE_PERIOD_SECONDS); - assert_eq!(open.salt, 42); - assert_eq!(open.recipients.len(), 1); - assert_eq!(open.recipients[0].recipient, split_recipient); - assert_eq!(open.recipients[0].bps, 10); - assert_eq!( - open.mint, - Pubkey::from_str(mints::USDC_MAINNET).expect("valid USDC mint") - ); - assert_eq!( - open.token_program, - Pubkey::from_str(programs::TOKEN_PROGRAM).expect("valid token program") - ); - assert_eq!( - open.channel_id, - derive_channel_addresses(&open.open_channel_params()).channel - ); - } - - #[test] - fn derive_payment_channel_open_honors_explicit_options() { - let operator = Pubkey::new_unique(); - let recipient = Pubkey::new_unique(); - let split_recipient = Pubkey::new_unique(); - let program_id = Pubkey::new_unique(); - let token_program = Pubkey::from_str(programs::TOKEN_2022_PROGRAM).unwrap(); - let mut request = test_request(operator, recipient); - request.cap = "not-a-number".to_string(); - request.splits.push(SessionSplit { - recipient: "not-a-pubkey".to_string(), - bps: 999, - }); - - let open = derive_payment_channel_open(DerivePaymentChannelOpenParams { - request: &request, - payer: Pubkey::new_unique(), - authorized_signer: Pubkey::new_unique(), - options: PaymentChannelOpenOptions { - deposit: Some(55), - grace_period: Some(12), - program_id: Some(program_id), - recipients: Some(vec![Distribution { - recipient: split_recipient, - bps: 25, - }]), - salt: Some(7), - token_program: Some(token_program), - }, - }) - .unwrap(); - - assert_eq!(open.deposit, 55); - assert_eq!(open.grace_period, 12); - assert_eq!(open.program_id, program_id); - assert_eq!(open.token_program, token_program); - assert_eq!(open.recipients.len(), 1); - assert_eq!(open.recipients[0].recipient, split_recipient); - assert_eq!(open.recipients[0].bps, 25); - } - - #[test] - fn derive_payment_channel_open_rejects_invalid_challenge_values() { - let operator = Pubkey::new_unique(); - let recipient = Pubkey::new_unique(); - let payer = Pubkey::new_unique(); - let authorized_signer = Pubkey::new_unique(); - - let mut request = test_request(operator, recipient); - request.currency = "SOL".to_string(); - let err = derive_payment_channel_open(DerivePaymentChannelOpenParams { - request: &request, - payer, - authorized_signer, - options: PaymentChannelOpenOptions::default(), - }) - .unwrap_err(); - assert!(err.to_string().contains("SPL token")); - - let mut request = test_request(operator, recipient); - request.cap = "not-a-number".to_string(); - let err = derive_payment_channel_open(DerivePaymentChannelOpenParams { - request: &request, - payer, - authorized_signer, - options: PaymentChannelOpenOptions::default(), - }) - .unwrap_err(); - assert!(err.to_string().contains("session cap")); - - let mut request = test_request(operator, recipient); - request.recipient = "not-a-pubkey".to_string(); - let err = derive_payment_channel_open(DerivePaymentChannelOpenParams { - request: &request, - payer, - authorized_signer, - options: PaymentChannelOpenOptions::default(), - }) - .unwrap_err(); - assert!(err.to_string().contains("recipient")); - - let mut request = test_request(operator, recipient); - request.program_id = Some("not-a-program".to_string()); - let err = derive_payment_channel_open(DerivePaymentChannelOpenParams { - request: &request, - payer, - authorized_signer, - options: PaymentChannelOpenOptions::default(), - }) - .unwrap_err(); - assert!(err.to_string().contains("programId")); - - let mut request = test_request(operator, recipient); - request.splits.push(SessionSplit { - recipient: "not-a-pubkey".to_string(), - bps: 10, - }); - let err = derive_payment_channel_open(DerivePaymentChannelOpenParams { - request: &request, - payer, - authorized_signer, - options: PaymentChannelOpenOptions::default(), - }) - .unwrap_err(); - assert!(err.to_string().contains("split recipient")); - } - - #[tokio::test] - async fn build_open_payment_channel_transaction_partially_signs_for_operator_broadcast() { - let operator = Pubkey::new_unique(); - let recipient = Pubkey::new_unique(); - let request = test_request(operator, recipient); - let payer_signer = make_signer(7); - let authorized_signer = make_signer(8).pubkey(); - - let built = - build_open_payment_channel_transaction(BuildOpenPaymentChannelTransactionParams { - request: &request, - signer: payer_signer.as_ref(), - authorized_signer, - fee_payer: None, - recent_blockhash: Hash::new_unique(), - options: PaymentChannelOpenOptions { - salt: Some(99), - ..PaymentChannelOpenOptions::default() - }, - }) - .await - .unwrap(); - let tx = decode_transaction(&built.transaction); - let expected_open = derive_payment_channel_open(DerivePaymentChannelOpenParams { - request: &request, - payer: payer_signer.pubkey(), - authorized_signer, - options: PaymentChannelOpenOptions { - salt: Some(99), - ..PaymentChannelOpenOptions::default() - }, - }) - .unwrap(); - - assert_eq!(built.channel_id, expected_open.channel_id); - assert_eq!(tx.message.account_keys[0], operator); - assert_eq!(tx.message.instructions.len(), 1); - - let payer_index = tx - .message - .account_keys - .iter() - .position(|key| key == &payer_signer.pubkey()) - .expect("payer signer account"); - assert_eq!(tx.signatures[0], Signature::default()); - assert_ne!(tx.signatures[payer_index], Signature::default()); - } - - #[tokio::test] - async fn build_open_payment_channel_transaction_uses_explicit_fee_payer() { - let operator = Pubkey::new_unique(); - let explicit_fee_payer = Pubkey::new_unique(); - let recipient = Pubkey::new_unique(); - let request = test_request(operator, recipient); - let payer_signer = make_signer(15); - - let built = - build_open_payment_channel_transaction(BuildOpenPaymentChannelTransactionParams { - request: &request, - signer: payer_signer.as_ref(), - authorized_signer: make_signer(16).pubkey(), - fee_payer: Some(explicit_fee_payer), - recent_blockhash: Hash::new_unique(), - options: PaymentChannelOpenOptions { - salt: Some(123), - ..PaymentChannelOpenOptions::default() - }, - }) - .await - .unwrap(); - let tx = decode_transaction(&built.transaction); - - assert_eq!(tx.message.account_keys[0], explicit_fee_payer); - } - - #[tokio::test] - async fn create_payment_channel_session_opener_builds_pull_client_voucher_action() { - let operator = Pubkey::new_unique(); - let recipient = Pubkey::new_unique(); - let request = test_request(operator, recipient); - let payer_signer = make_signer(9); - let session_signer = make_signer(10); - let authorized_signer = session_signer.pubkey(); - - let opened = create_payment_channel_session_opener( - &request, - payer_signer.as_ref(), - session_signer, - Hash::new_unique(), - PaymentChannelSessionOpenOptions { - open: PaymentChannelOpenOptions { - salt: Some(11), - ..PaymentChannelOpenOptions::default() - }, - ..PaymentChannelSessionOpenOptions::default() - }, - ) - .await - .unwrap(); - - assert_eq!(opened.session.channel_id, opened.open.channel_id); - match opened.action { - SessionAction::Open(payload) => { - assert_eq!(payload.mode, SessionMode::Pull); - assert_eq!( - payload.channel_id.as_deref(), - Some(pubkey_string(&opened.open.channel_id).as_str()) - ); - assert_eq!( - payload.payer.as_deref(), - Some(pubkey_string(&payer_signer.pubkey()).as_str()) - ); - assert_eq!(payload.authorized_signer, pubkey_string(&authorized_signer)); - assert_eq!(payload.signature, PENDING_SERVER_SIGNATURE); - assert!(payload.transaction.is_some()); - assert!(payload.token_account.is_none()); - assert!(payload.approved_amount.is_none()); - assert!(payload.init_multi_delegate_tx.is_none()); - assert!(payload.update_delegation_tx.is_none()); - } - _ => panic!("expected open action"), - } - } - - #[tokio::test] - async fn create_payment_channel_session_opener_applies_session_options() { - let operator = Pubkey::new_unique(); - let recipient = Pubkey::new_unique(); - let request = test_request(operator, recipient); - let payer_signer = make_signer(17); - - let opened = create_payment_channel_session_opener( - &request, - payer_signer.as_ref(), - make_signer(18), - Hash::new_unique(), - PaymentChannelSessionOpenOptions { - open: PaymentChannelOpenOptions { - salt: Some(19), - ..PaymentChannelOpenOptions::default() - }, - signature: Some("operator-will-fill".to_string()), - cumulative: Some(20), - expires_at: Some(1234), - }, - ) - .await - .unwrap(); - - match &opened.action { - SessionAction::Open(payload) => { - assert_eq!(payload.signature, "operator-will-fill"); - } - _ => panic!("expected open action"), - } - let voucher = opened.session.prepare_increment(5).await.unwrap(); - assert_eq!(voucher.data.cumulative, "25"); - assert_eq!(voucher.data.expires_at, 1234); - } - - #[test] - fn create_server_opened_session_opener_uses_operator_payer_without_transaction() { - let operator = Pubkey::new_unique(); - let recipient = Pubkey::new_unique(); - let request = test_request(operator, recipient); - let session_signer = make_signer(12); - let authorized_signer = session_signer.pubkey(); - - let opened = create_server_opened_payment_channel_session_opener( - &request, - session_signer, - ServerOpenedPaymentChannelSessionOpenOptions { - open: PaymentChannelOpenOptions { - salt: Some(13), - ..PaymentChannelOpenOptions::default() - }, - ..ServerOpenedPaymentChannelSessionOpenOptions::default() - }, - ) - .unwrap(); - - assert_eq!(opened.open.payer, operator); - match opened.action { - SessionAction::Open(payload) => { - assert_eq!(payload.mode, SessionMode::Pull); - assert_eq!(payload.payer.as_deref(), Some(request.operator.as_str())); - assert_eq!(payload.authorized_signer, pubkey_string(&authorized_signer)); - assert_eq!(payload.signature, PENDING_SERVER_SIGNATURE); - assert!(payload.transaction.is_none()); - assert!(payload.token_account.is_none()); - assert!(payload.approved_amount.is_none()); - } - _ => panic!("expected open action"), - } - } - - #[test] - fn session_opener_rejects_non_pull_challenge() { - let operator = Pubkey::new_unique(); - let recipient = Pubkey::new_unique(); - let mut request = test_request(operator, recipient); - request.modes = vec![SessionMode::Push]; - request.pull_voucher_strategy = None; - - let err = match create_server_opened_payment_channel_session_opener( - &request, - make_signer(20), - ServerOpenedPaymentChannelSessionOpenOptions::default(), - ) { - Ok(_) => panic!("expected non-pull challenge to be rejected"), - Err(err) => err, - }; - assert!(err.to_string().contains("pull mode")); - } - - #[test] - fn session_opener_rejects_operated_voucher_pull_challenge() { - let operator = Pubkey::new_unique(); - let recipient = Pubkey::new_unique(); - let mut request = test_request(operator, recipient); - request.pull_voucher_strategy = Some(SessionPullVoucherStrategy::OperatedVoucher); - - let err = match create_server_opened_payment_channel_session_opener( - &request, - make_signer(14), - ServerOpenedPaymentChannelSessionOpenOptions::default(), - ) { - Ok(_) => panic!("expected operated-voucher challenge to be rejected"), - Err(err) => err, - }; - assert!(err - .to_string() - .contains("does not advertise pull + clientVoucher")); - } -} diff --git a/rust/crates/mpp/src/client/session.rs b/rust/crates/mpp/src/client/session.rs index a0f254667..9ed6a0462 100644 --- a/rust/crates/mpp/src/client/session.rs +++ b/rust/crates/mpp/src/client/session.rs @@ -20,14 +20,27 @@ //! // Attach voucher to Authorization header via SessionAction::Voucher //! ``` +use std::str::FromStr; + +use solana_hash::Hash; use solana_keychain::SolanaSigner; use solana_pubkey::Pubkey; use crate::error::{Error, Result}; +use crate::program::payment_channels::{ + build_open_payment_channel_tx, default_program_id, derive_channel_addresses, random_salt, + Distribution, OpenChannelParams, PaymentChannelOpenTransaction, +}; use crate::protocol::intents::session::{ - ClosePayload, OpenPayload, SessionAction, SessionMode, SignedVoucher, TopUpPayload, - VoucherData, VoucherPayload, DEFAULT_SESSION_EXPIRES_AT, + ClosePayload, OpenPayload, SessionAction, SessionMode, SessionPullVoucherStrategy, + SessionRequest, SignedVoucher, TopUpPayload, VoucherData, VoucherPayload, + DEFAULT_SESSION_EXPIRES_AT, }; +use crate::protocol::solana::{default_token_program_for_currency, resolve_stablecoin_mint}; + +/// Default payment-channel close grace period (seconds). Re-exported from +/// `solana-pay-core` to preserve this crate's public path. +pub use crate::program::payment_channels::DEFAULT_GRACE_PERIOD_SECONDS; /// Default voucher expiry: 2100-01-01T00:00:00Z. /// @@ -294,6 +307,339 @@ impl ActiveSession { } } +/// Placeholder signature used while the operator still needs to submit the +/// server-broadcast open transaction. +pub const PENDING_SERVER_SIGNATURE: &str = + "1111111111111111111111111111111111111111111111111111111111111111"; + +#[derive(Debug, Clone)] +pub struct PaymentChannelOpen { + pub channel_id: Pubkey, + pub payer: Pubkey, + pub payee: Pubkey, + pub mint: Pubkey, + pub authorized_signer: Pubkey, + pub salt: u64, + pub deposit: u64, + pub grace_period: u32, + pub recipients: Vec, + pub token_program: Pubkey, + pub program_id: Pubkey, +} + +impl PaymentChannelOpen { + pub fn open_channel_params(&self) -> OpenChannelParams { + OpenChannelParams { + payer: self.payer, + payee: self.payee, + mint: self.mint, + authorized_signer: self.authorized_signer, + salt: self.salt, + deposit: self.deposit, + grace_period: self.grace_period, + recipients: self.recipients.clone(), + token_program: self.token_program, + program_id: self.program_id, + } + } + + pub fn open_payload(&self, mode: SessionMode, signature: impl Into) -> OpenPayload { + OpenPayload::payment_channel_with_mode( + mode, + pubkey_string(&self.channel_id), + self.deposit.to_string(), + pubkey_string(&self.payer), + pubkey_string(&self.payee), + pubkey_string(&self.mint), + self.salt, + self.grace_period, + pubkey_string(&self.authorized_signer), + signature.into(), + ) + } +} + +#[derive(Debug, Clone, Default)] +pub struct PaymentChannelOpenOptions { + pub deposit: Option, + pub grace_period: Option, + pub program_id: Option, + pub recipients: Option>, + pub salt: Option, + pub token_program: Option, +} + +#[derive(Debug, Clone)] +pub struct DerivePaymentChannelOpenParams<'a> { + pub request: &'a SessionRequest, + pub payer: Pubkey, + pub authorized_signer: Pubkey, + pub options: PaymentChannelOpenOptions, +} + +pub struct PaymentChannelSessionOpen { + pub open: PaymentChannelOpen, + pub session: ActiveSession, + pub action: SessionAction, +} + +#[derive(Default)] +pub struct PaymentChannelSessionOpenOptions { + pub open: PaymentChannelOpenOptions, + pub signature: Option, + pub cumulative: Option, + pub expires_at: Option, +} + +#[derive(Default)] +pub struct ServerOpenedPaymentChannelSessionOpenOptions { + pub open: PaymentChannelOpenOptions, + pub payer: Option, + pub signature: Option, + pub cumulative: Option, + pub expires_at: Option, +} + +pub fn derive_payment_channel_open( + params: DerivePaymentChannelOpenParams<'_>, +) -> Result { + let request = params.request; + let network = request.network.as_deref(); + let mint = parse_pubkey( + resolve_stablecoin_mint(&request.currency, network) + .ok_or_else(|| Error::Other("session payment channels require an SPL token".into()))?, + "mint", + )?; + let payee = parse_pubkey(&request.recipient, "recipient")?; + let deposit = match params.options.deposit { + Some(deposit) => deposit, + None => parse_u64_string(&request.cap, "session cap")?, + }; + let grace_period = params + .options + .grace_period + .unwrap_or(DEFAULT_GRACE_PERIOD_SECONDS); + let program_id = match params.options.program_id { + Some(program_id) => program_id, + None => request + .program_id + .as_deref() + .map(|value| parse_pubkey(value, "programId")) + .transpose()? + .unwrap_or_else(default_program_id), + }; + let token_program = match params.options.token_program { + Some(token_program) => token_program, + None => parse_pubkey( + default_token_program_for_currency(&request.currency, network), + "token program", + )?, + }; + let recipients = match params.options.recipients { + Some(recipients) => recipients, + None => parse_splits(request)?, + }; + let salt = params.options.salt.unwrap_or_else(random_salt); + let open_params = OpenChannelParams { + payer: params.payer, + payee, + mint, + authorized_signer: params.authorized_signer, + salt, + deposit, + grace_period, + recipients, + token_program, + program_id, + }; + let channel_id = derive_channel_addresses(&open_params).channel; + + Ok(PaymentChannelOpen { + channel_id, + payer: open_params.payer, + payee: open_params.payee, + mint: open_params.mint, + authorized_signer: open_params.authorized_signer, + salt: open_params.salt, + deposit: open_params.deposit, + grace_period: open_params.grace_period, + recipients: open_params.recipients, + token_program: open_params.token_program, + program_id: open_params.program_id, + }) +} + +pub struct BuildOpenPaymentChannelTransactionParams<'a> { + pub request: &'a SessionRequest, + pub signer: &'a dyn SolanaSigner, + pub authorized_signer: Pubkey, + pub fee_payer: Option, + pub recent_blockhash: Hash, + pub options: PaymentChannelOpenOptions, +} + +pub async fn build_open_payment_channel_transaction( + params: BuildOpenPaymentChannelTransactionParams<'_>, +) -> Result { + let fee_payer = params + .fee_payer + .map(Ok) + .unwrap_or_else(|| parse_pubkey(¶ms.request.operator, "operator"))?; + let open = derive_payment_channel_open(DerivePaymentChannelOpenParams { + request: params.request, + payer: params.signer.pubkey(), + authorized_signer: params.authorized_signer, + options: params.options, + })?; + + build_open_payment_channel_tx( + params.signer, + &open.payee, + &open.mint, + &open.authorized_signer, + open.salt, + open.deposit, + open.grace_period, + open.recipients.clone(), + &open.token_program, + &open.program_id, + &fee_payer, + params.recent_blockhash, + ) + .await + .map_err(Into::into) +} + +pub async fn create_payment_channel_session_opener( + request: &SessionRequest, + payer_signer: &dyn SolanaSigner, + session_signer: Box, + recent_blockhash: Hash, + options: PaymentChannelSessionOpenOptions, +) -> Result { + ensure_client_voucher_pull(request)?; + let authorized_signer = session_signer.pubkey(); + let fee_payer = parse_pubkey(&request.operator, "operator")?; + let open = derive_payment_channel_open(DerivePaymentChannelOpenParams { + request, + payer: payer_signer.pubkey(), + authorized_signer, + options: options.open.clone(), + })?; + let tx = build_open_payment_channel_tx( + payer_signer, + &open.payee, + &open.mint, + &open.authorized_signer, + open.salt, + open.deposit, + open.grace_period, + open.recipients.clone(), + &open.token_program, + &open.program_id, + &fee_payer, + recent_blockhash, + ) + .await?; + let mut session = ActiveSession::new(open.channel_id, session_signer); + configure_session(&mut session, options.cumulative, options.expires_at); + let signature = options + .signature + .unwrap_or_else(|| PENDING_SERVER_SIGNATURE.to_string()); + let action = SessionAction::Open( + open.open_payload(SessionMode::Pull, signature) + .with_transaction(tx.transaction), + ); + + Ok(PaymentChannelSessionOpen { + open, + session, + action, + }) +} + +pub fn create_server_opened_payment_channel_session_opener( + request: &SessionRequest, + session_signer: Box, + options: ServerOpenedPaymentChannelSessionOpenOptions, +) -> Result { + ensure_client_voucher_pull(request)?; + let payer = options + .payer + .map(Ok) + .unwrap_or_else(|| parse_pubkey(&request.operator, "operator"))?; + let authorized_signer = session_signer.pubkey(); + let open = derive_payment_channel_open(DerivePaymentChannelOpenParams { + request, + payer, + authorized_signer, + options: options.open, + })?; + let mut session = ActiveSession::new(open.channel_id, session_signer); + configure_session(&mut session, options.cumulative, options.expires_at); + let signature = options + .signature + .unwrap_or_else(|| PENDING_SERVER_SIGNATURE.to_string()); + let action = SessionAction::Open(open.open_payload(SessionMode::Pull, signature)); + + Ok(PaymentChannelSessionOpen { + open, + session, + action, + }) +} + +fn ensure_client_voucher_pull(request: &SessionRequest) -> Result<()> { + if !request.modes.contains(&SessionMode::Pull) { + return Err(Error::Other( + "session challenge does not advertise pull mode".to_string(), + )); + } + if request.pull_voucher_strategy.as_ref() != Some(&SessionPullVoucherStrategy::ClientVoucher) { + return Err(Error::Other( + "session challenge does not advertise pull + clientVoucher".to_string(), + )); + } + Ok(()) +} + +fn configure_session( + session: &mut ActiveSession, + cumulative: Option, + expires_at: Option, +) { + session.cumulative = cumulative.unwrap_or(0); + session.set_expires_at(expires_at.unwrap_or(DEFAULT_SESSION_EXPIRES_AT)); +} + +fn parse_splits(request: &SessionRequest) -> Result> { + request + .splits + .iter() + .map(|split| { + Ok(Distribution { + recipient: parse_pubkey(&split.recipient, "split recipient")?, + bps: split.bps, + }) + }) + .collect() +} + +fn parse_u64_string(value: &str, label: &str) -> Result { + value + .parse::() + .map_err(|e| Error::Other(format!("invalid {label}: {e}"))) +} + +fn parse_pubkey(value: &str, label: &str) -> Result { + Pubkey::from_str(value).map_err(|e| Error::Other(format!("invalid {label}: {e}"))) +} + +fn pubkey_string(pubkey: &Pubkey) -> String { + bs58::encode(pubkey.as_ref()).into_string() +} + + #[cfg(test)] mod tests { use super::*; @@ -576,3 +922,437 @@ mod tests { } } } + +#[cfg(test)] +mod open_tests { + use super::*; + use crate::protocol::intents::session::SessionSplit; + use crate::protocol::solana::{mints, programs}; + use base64::Engine; + use solana_keychain::MemorySigner; + use solana_signature::Signature; + use solana_transaction::Transaction; + + fn make_signer(seed: u8) -> Box { + let sk = ed25519_dalek::SigningKey::from_bytes(&[seed; 32]); + let mut kp = [0u8; 64]; + kp[..32].copy_from_slice(sk.as_bytes()); + kp[32..].copy_from_slice(sk.verifying_key().as_bytes()); + Box::new(MemorySigner::from_bytes(&kp).expect("valid keypair")) + } + + fn test_request(operator: Pubkey, recipient: Pubkey) -> SessionRequest { + SessionRequest { + cap: "1000".to_string(), + currency: "USDC".to_string(), + decimals: Some(6), + network: Some("localnet".to_string()), + operator: pubkey_string(&operator), + recipient: pubkey_string(&recipient), + splits: vec![], + program_id: None, + description: None, + external_id: None, + min_voucher_delta: None, + modes: vec![SessionMode::Pull], + pull_voucher_strategy: Some(SessionPullVoucherStrategy::ClientVoucher), + recent_blockhash: None, + } + } + + fn decode_transaction(encoded: &str) -> Transaction { + let bytes = base64::engine::general_purpose::STANDARD + .decode(encoded) + .expect("base64 transaction"); + bincode::deserialize(&bytes).expect("bincode transaction") + } + + #[test] + fn derive_payment_channel_open_uses_challenge_defaults_and_splits() { + let operator = Pubkey::new_unique(); + let recipient = Pubkey::new_unique(); + let split_recipient = Pubkey::new_unique(); + let mut request = test_request(operator, recipient); + request.splits.push(SessionSplit { + recipient: pubkey_string(&split_recipient), + bps: 10, + }); + + let payer = Pubkey::new_unique(); + let authorized_signer = Pubkey::new_unique(); + let open = derive_payment_channel_open(DerivePaymentChannelOpenParams { + request: &request, + payer, + authorized_signer, + options: PaymentChannelOpenOptions { + salt: Some(42), + ..PaymentChannelOpenOptions::default() + }, + }) + .unwrap(); + + assert_eq!(open.payer, payer); + assert_eq!(open.payee, recipient); + assert_eq!(open.authorized_signer, authorized_signer); + assert_eq!(open.deposit, 1000); + assert_eq!(open.grace_period, DEFAULT_GRACE_PERIOD_SECONDS); + assert_eq!(open.salt, 42); + assert_eq!(open.recipients.len(), 1); + assert_eq!(open.recipients[0].recipient, split_recipient); + assert_eq!(open.recipients[0].bps, 10); + assert_eq!( + open.mint, + Pubkey::from_str(mints::USDC_MAINNET).expect("valid USDC mint") + ); + assert_eq!( + open.token_program, + Pubkey::from_str(programs::TOKEN_PROGRAM).expect("valid token program") + ); + assert_eq!( + open.channel_id, + derive_channel_addresses(&open.open_channel_params()).channel + ); + } + + #[test] + fn derive_payment_channel_open_honors_explicit_options() { + let operator = Pubkey::new_unique(); + let recipient = Pubkey::new_unique(); + let split_recipient = Pubkey::new_unique(); + let program_id = Pubkey::new_unique(); + let token_program = Pubkey::from_str(programs::TOKEN_2022_PROGRAM).unwrap(); + let mut request = test_request(operator, recipient); + request.cap = "not-a-number".to_string(); + request.splits.push(SessionSplit { + recipient: "not-a-pubkey".to_string(), + bps: 999, + }); + + let open = derive_payment_channel_open(DerivePaymentChannelOpenParams { + request: &request, + payer: Pubkey::new_unique(), + authorized_signer: Pubkey::new_unique(), + options: PaymentChannelOpenOptions { + deposit: Some(55), + grace_period: Some(12), + program_id: Some(program_id), + recipients: Some(vec![Distribution { + recipient: split_recipient, + bps: 25, + }]), + salt: Some(7), + token_program: Some(token_program), + }, + }) + .unwrap(); + + assert_eq!(open.deposit, 55); + assert_eq!(open.grace_period, 12); + assert_eq!(open.program_id, program_id); + assert_eq!(open.token_program, token_program); + assert_eq!(open.recipients.len(), 1); + assert_eq!(open.recipients[0].recipient, split_recipient); + assert_eq!(open.recipients[0].bps, 25); + } + + #[test] + fn derive_payment_channel_open_rejects_invalid_challenge_values() { + let operator = Pubkey::new_unique(); + let recipient = Pubkey::new_unique(); + let payer = Pubkey::new_unique(); + let authorized_signer = Pubkey::new_unique(); + + let mut request = test_request(operator, recipient); + request.currency = "SOL".to_string(); + let err = derive_payment_channel_open(DerivePaymentChannelOpenParams { + request: &request, + payer, + authorized_signer, + options: PaymentChannelOpenOptions::default(), + }) + .unwrap_err(); + assert!(err.to_string().contains("SPL token")); + + let mut request = test_request(operator, recipient); + request.cap = "not-a-number".to_string(); + let err = derive_payment_channel_open(DerivePaymentChannelOpenParams { + request: &request, + payer, + authorized_signer, + options: PaymentChannelOpenOptions::default(), + }) + .unwrap_err(); + assert!(err.to_string().contains("session cap")); + + let mut request = test_request(operator, recipient); + request.recipient = "not-a-pubkey".to_string(); + let err = derive_payment_channel_open(DerivePaymentChannelOpenParams { + request: &request, + payer, + authorized_signer, + options: PaymentChannelOpenOptions::default(), + }) + .unwrap_err(); + assert!(err.to_string().contains("recipient")); + + let mut request = test_request(operator, recipient); + request.program_id = Some("not-a-program".to_string()); + let err = derive_payment_channel_open(DerivePaymentChannelOpenParams { + request: &request, + payer, + authorized_signer, + options: PaymentChannelOpenOptions::default(), + }) + .unwrap_err(); + assert!(err.to_string().contains("programId")); + + let mut request = test_request(operator, recipient); + request.splits.push(SessionSplit { + recipient: "not-a-pubkey".to_string(), + bps: 10, + }); + let err = derive_payment_channel_open(DerivePaymentChannelOpenParams { + request: &request, + payer, + authorized_signer, + options: PaymentChannelOpenOptions::default(), + }) + .unwrap_err(); + assert!(err.to_string().contains("split recipient")); + } + + #[tokio::test] + async fn build_open_payment_channel_transaction_partially_signs_for_operator_broadcast() { + let operator = Pubkey::new_unique(); + let recipient = Pubkey::new_unique(); + let request = test_request(operator, recipient); + let payer_signer = make_signer(7); + let authorized_signer = make_signer(8).pubkey(); + + let built = + build_open_payment_channel_transaction(BuildOpenPaymentChannelTransactionParams { + request: &request, + signer: payer_signer.as_ref(), + authorized_signer, + fee_payer: None, + recent_blockhash: Hash::new_unique(), + options: PaymentChannelOpenOptions { + salt: Some(99), + ..PaymentChannelOpenOptions::default() + }, + }) + .await + .unwrap(); + let tx = decode_transaction(&built.transaction); + let expected_open = derive_payment_channel_open(DerivePaymentChannelOpenParams { + request: &request, + payer: payer_signer.pubkey(), + authorized_signer, + options: PaymentChannelOpenOptions { + salt: Some(99), + ..PaymentChannelOpenOptions::default() + }, + }) + .unwrap(); + + assert_eq!(built.channel_id, expected_open.channel_id); + assert_eq!(tx.message.account_keys[0], operator); + assert_eq!(tx.message.instructions.len(), 1); + + let payer_index = tx + .message + .account_keys + .iter() + .position(|key| key == &payer_signer.pubkey()) + .expect("payer signer account"); + assert_eq!(tx.signatures[0], Signature::default()); + assert_ne!(tx.signatures[payer_index], Signature::default()); + } + + #[tokio::test] + async fn build_open_payment_channel_transaction_uses_explicit_fee_payer() { + let operator = Pubkey::new_unique(); + let explicit_fee_payer = Pubkey::new_unique(); + let recipient = Pubkey::new_unique(); + let request = test_request(operator, recipient); + let payer_signer = make_signer(15); + + let built = + build_open_payment_channel_transaction(BuildOpenPaymentChannelTransactionParams { + request: &request, + signer: payer_signer.as_ref(), + authorized_signer: make_signer(16).pubkey(), + fee_payer: Some(explicit_fee_payer), + recent_blockhash: Hash::new_unique(), + options: PaymentChannelOpenOptions { + salt: Some(123), + ..PaymentChannelOpenOptions::default() + }, + }) + .await + .unwrap(); + let tx = decode_transaction(&built.transaction); + + assert_eq!(tx.message.account_keys[0], explicit_fee_payer); + } + + #[tokio::test] + async fn create_payment_channel_session_opener_builds_pull_client_voucher_action() { + let operator = Pubkey::new_unique(); + let recipient = Pubkey::new_unique(); + let request = test_request(operator, recipient); + let payer_signer = make_signer(9); + let session_signer = make_signer(10); + let authorized_signer = session_signer.pubkey(); + + let opened = create_payment_channel_session_opener( + &request, + payer_signer.as_ref(), + session_signer, + Hash::new_unique(), + PaymentChannelSessionOpenOptions { + open: PaymentChannelOpenOptions { + salt: Some(11), + ..PaymentChannelOpenOptions::default() + }, + ..PaymentChannelSessionOpenOptions::default() + }, + ) + .await + .unwrap(); + + assert_eq!(opened.session.channel_id, opened.open.channel_id); + match opened.action { + SessionAction::Open(payload) => { + assert_eq!(payload.mode, SessionMode::Pull); + assert_eq!( + payload.channel_id.as_deref(), + Some(pubkey_string(&opened.open.channel_id).as_str()) + ); + assert_eq!( + payload.payer.as_deref(), + Some(pubkey_string(&payer_signer.pubkey()).as_str()) + ); + assert_eq!(payload.authorized_signer, pubkey_string(&authorized_signer)); + assert_eq!(payload.signature, PENDING_SERVER_SIGNATURE); + assert!(payload.transaction.is_some()); + assert!(payload.token_account.is_none()); + assert!(payload.approved_amount.is_none()); + assert!(payload.init_multi_delegate_tx.is_none()); + assert!(payload.update_delegation_tx.is_none()); + } + _ => panic!("expected open action"), + } + } + + #[tokio::test] + async fn create_payment_channel_session_opener_applies_session_options() { + let operator = Pubkey::new_unique(); + let recipient = Pubkey::new_unique(); + let request = test_request(operator, recipient); + let payer_signer = make_signer(17); + + let opened = create_payment_channel_session_opener( + &request, + payer_signer.as_ref(), + make_signer(18), + Hash::new_unique(), + PaymentChannelSessionOpenOptions { + open: PaymentChannelOpenOptions { + salt: Some(19), + ..PaymentChannelOpenOptions::default() + }, + signature: Some("operator-will-fill".to_string()), + cumulative: Some(20), + expires_at: Some(1234), + }, + ) + .await + .unwrap(); + + match &opened.action { + SessionAction::Open(payload) => { + assert_eq!(payload.signature, "operator-will-fill"); + } + _ => panic!("expected open action"), + } + let voucher = opened.session.prepare_increment(5).await.unwrap(); + assert_eq!(voucher.data.cumulative, "25"); + assert_eq!(voucher.data.expires_at, 1234); + } + + #[test] + fn create_server_opened_session_opener_uses_operator_payer_without_transaction() { + let operator = Pubkey::new_unique(); + let recipient = Pubkey::new_unique(); + let request = test_request(operator, recipient); + let session_signer = make_signer(12); + let authorized_signer = session_signer.pubkey(); + + let opened = create_server_opened_payment_channel_session_opener( + &request, + session_signer, + ServerOpenedPaymentChannelSessionOpenOptions { + open: PaymentChannelOpenOptions { + salt: Some(13), + ..PaymentChannelOpenOptions::default() + }, + ..ServerOpenedPaymentChannelSessionOpenOptions::default() + }, + ) + .unwrap(); + + assert_eq!(opened.open.payer, operator); + match opened.action { + SessionAction::Open(payload) => { + assert_eq!(payload.mode, SessionMode::Pull); + assert_eq!(payload.payer.as_deref(), Some(request.operator.as_str())); + assert_eq!(payload.authorized_signer, pubkey_string(&authorized_signer)); + assert_eq!(payload.signature, PENDING_SERVER_SIGNATURE); + assert!(payload.transaction.is_none()); + assert!(payload.token_account.is_none()); + assert!(payload.approved_amount.is_none()); + } + _ => panic!("expected open action"), + } + } + + #[test] + fn session_opener_rejects_non_pull_challenge() { + let operator = Pubkey::new_unique(); + let recipient = Pubkey::new_unique(); + let mut request = test_request(operator, recipient); + request.modes = vec![SessionMode::Push]; + request.pull_voucher_strategy = None; + + let err = match create_server_opened_payment_channel_session_opener( + &request, + make_signer(20), + ServerOpenedPaymentChannelSessionOpenOptions::default(), + ) { + Ok(_) => panic!("expected non-pull challenge to be rejected"), + Err(err) => err, + }; + assert!(err.to_string().contains("pull mode")); + } + + #[test] + fn session_opener_rejects_operated_voucher_pull_challenge() { + let operator = Pubkey::new_unique(); + let recipient = Pubkey::new_unique(); + let mut request = test_request(operator, recipient); + request.pull_voucher_strategy = Some(SessionPullVoucherStrategy::OperatedVoucher); + + let err = match create_server_opened_payment_channel_session_opener( + &request, + make_signer(14), + ServerOpenedPaymentChannelSessionOpenOptions::default(), + ) { + Ok(_) => panic!("expected operated-voucher challenge to be rejected"), + Err(err) => err, + }; + assert!(err + .to_string() + .contains("does not advertise pull + clientVoucher")); + } +} diff --git a/rust/crates/mpp/src/error.rs b/rust/crates/mpp/src/error.rs index c1e061f42..284c839a8 100644 --- a/rust/crates/mpp/src/error.rs +++ b/rust/crates/mpp/src/error.rs @@ -62,5 +62,14 @@ pub enum Error { Other(String), } +impl From for Error { + fn from(err: solana_pay_core::Error) -> Self { + match err { + solana_pay_core::Error::Serialization(msg) => Error::Other(msg), + solana_pay_core::Error::Other(msg) => Error::Other(msg), + } + } +} + /// Result type alias. pub type Result = std::result::Result; diff --git a/rust/crates/mpp/src/program/mod.rs b/rust/crates/mpp/src/program/mod.rs index 19a9f13d9..553bc7029 100644 --- a/rust/crates/mpp/src/program/mod.rs +++ b/rust/crates/mpp/src/program/mod.rs @@ -1,3 +1,7 @@ -pub mod multi_delegator; -pub mod payment_channels; pub mod subscriptions; + +/// Payment-channels helpers live in `solana-pay-core` so they can be shared with +/// `solana-x402` (the `upto` scheme) without either protocol crate depending on +/// the other. Re-exported here to preserve the `crate::program::payment_channels` +/// path used across this crate. +pub use solana_pay_core::payment_channels; diff --git a/rust/crates/mpp/src/program/multi_delegator.rs b/rust/crates/mpp/src/program/multi_delegator.rs deleted file mode 100644 index 7137e3822..000000000 --- a/rust/crates/mpp/src/program/multi_delegator.rs +++ /dev/null @@ -1,465 +0,0 @@ -//! Multi-delegator program state assessment. -//! -//! Multi-delegator accounts are **long-lived** — they persist across many -//! sessions. A client may already have a `MultiDelegate` PDA and a -//! `FixedDelegation` with sufficient cap from a previous session, in which -//! case no on-chain action is needed. -//! -//! When a client opens a pull-mode session they pre-sign **both** potential -//! setup transactions (`initMultiDelegateTx` and `updateDelegationTx`) and -//! attach them to the `open` payload. The server fetches the current on-chain -//! state, calls [`assess_multi_delegate_setup`] to decide which action (if any) -//! to take, and submits the corresponding transaction. -//! -//! # Decision matrix -//! -//! | MultiDelegate PDA | FixedDelegation cap | Action | -//! |-------------------|--------------------------|---------------------------| -//! | does not exist | — | [`SubmitInit`] | -//! | exists | None or < required | [`SubmitUpdate`] | -//! | exists | ≥ required | [`AlreadySufficient`] | -//! -//! Missing required payloads surface as [`MissingPayload`] errors so the -//! operator can return a descriptive 402 to the client. -//! -//! [`SubmitInit`]: MultiDelegateSetupAction::SubmitInit -//! [`SubmitUpdate`]: MultiDelegateSetupAction::SubmitUpdate -//! [`AlreadySufficient`]: MultiDelegateSetupAction::AlreadySufficient -//! [`MissingPayload`]: MultiDelegateSetupAction::MissingPayload - -/// Current on-chain state for a (client, operator) multi-delegator pair. -/// -/// Fetched by the server from the Solana RPC before deciding what action -/// to take. Both fields are independent: a `MultiDelegate` PDA can exist -/// without a `FixedDelegation` for this specific operator. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct MultiDelegateOnChainState { - /// Whether the `MultiDelegate` PDA exists for `(owner, mint)`. - pub multi_delegate_exists: bool, - - /// Current cap of the `FixedDelegation` PDA for this `(operator, owner)` pair. - /// - /// `None` means the account does not exist (no delegation set up yet). - /// `Some(cap)` is the maximum amount the operator is currently authorised - /// to transfer — this may be from a previous session. - pub existing_delegation_cap: Option, -} - -/// Reason a required transaction payload is absent from the client's `open` action. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum MissingPayloadReason { - /// The `MultiDelegate` PDA does not exist but the client did not include - /// `initMultiDelegateTx`. - NoInitTx, - /// The delegation cap is insufficient but the client did not include - /// `updateDelegationTx`. - NoUpdateTx, -} - -impl std::fmt::Display for MissingPayloadReason { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::NoInitTx => write!( - f, - "MultiDelegate PDA does not exist — provide initMultiDelegateTx" - ), - Self::NoUpdateTx => write!( - f, - "delegation cap insufficient — provide updateDelegationTx" - ), - } - } -} - -/// The on-chain setup action the operator must perform before accepting a -/// pull-mode session. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum MultiDelegateSetupAction { - /// Existing delegation already covers the session cap — no on-chain action - /// needed. The most common outcome for returning clients. - AlreadySufficient, - - /// Submit `initMultiDelegateTx`: creates the `MultiDelegate` PDA and an - /// initial `FixedDelegation`. Required when the client has never delegated - /// to this operator before. - SubmitInit, - - /// Submit `updateDelegationTx`: creates or raises the `FixedDelegation` - /// cap. Required when the `MultiDelegate` PDA exists but the current cap - /// is below the session amount. - SubmitUpdate, - - /// A required transaction payload was not provided by the client. - MissingPayload(MissingPayloadReason), -} - -impl std::fmt::Display for MultiDelegateSetupAction { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::AlreadySufficient => write!(f, "already sufficient — no tx needed"), - Self::SubmitInit => write!(f, "submit initMultiDelegateTx"), - Self::SubmitUpdate => write!(f, "submit updateDelegationTx"), - Self::MissingPayload(r) => write!(f, "missing payload: {r}"), - } - } -} - -/// Determine the on-chain setup action required for a pull-mode session open. -/// -/// This is a **pure function** — all IO (RPC fetches, tx submission) is the -/// caller's responsibility. -/// -/// # Arguments -/// -/// - `state` — on-chain state fetched by the server -/// - `required_cap` — the delegation amount the new session needs -/// - `has_init_tx` — whether the client provided `initMultiDelegateTx` -/// - `has_update_tx` — whether the client provided `updateDelegationTx` -/// -/// # Decision logic -/// -/// ```text -/// if !multi_delegate_exists: -/// if has_init_tx → SubmitInit -/// else → MissingPayload(NoInitTx) -/// elif existing_cap < required_cap: // includes None (no delegation) -/// if has_update_tx → SubmitUpdate -/// else → MissingPayload(NoUpdateTx) -/// else: -/// AlreadySufficient -/// ``` -pub fn assess_multi_delegate_setup( - state: &MultiDelegateOnChainState, - required_cap: u64, - has_init_tx: bool, - has_update_tx: bool, -) -> MultiDelegateSetupAction { - if !state.multi_delegate_exists { - if has_init_tx { - MultiDelegateSetupAction::SubmitInit - } else { - MultiDelegateSetupAction::MissingPayload(MissingPayloadReason::NoInitTx) - } - } else { - let cap_sufficient = state - .existing_delegation_cap - .is_some_and(|cap| cap >= required_cap); - - if cap_sufficient { - MultiDelegateSetupAction::AlreadySufficient - } else if has_update_tx { - MultiDelegateSetupAction::SubmitUpdate - } else { - MultiDelegateSetupAction::MissingPayload(MissingPayloadReason::NoUpdateTx) - } - } -} - -// ── Solana instruction builders ─────────────────────────────────────────────── -// -// Pure functions: derive PDAs locally and encode instruction data in the exact -// layout expected by the on-chain multi-delegator program. No I/O performed. - -use solana_instruction::{AccountMeta, Instruction}; -use solana_pubkey::Pubkey; - -/// Canonical mainnet multi-delegator program address. -pub const MULTI_DELEGATOR_PROGRAM_ID: &str = "EPEUTog1kptYkthDJF6MuB1aM4aDAwHYwoF32Rzv5rqg"; - -/// PDA seed prefix for `MultiDelegate` accounts. -pub const MULTI_DELEGATE_SEED: &[u8] = b"MultiDelegate"; - -/// PDA seed prefix for delegation accounts (fixed, recurring, etc.). -pub const DELEGATION_SEED: &[u8] = b"delegation"; - -/// Derive the `MultiDelegate` PDA for `(user, mint)`. -/// -/// Seeds: `[b"MultiDelegate", user, mint]` → `program_id` -pub fn find_multi_delegate_pda(user: &Pubkey, mint: &Pubkey, program_id: &Pubkey) -> (Pubkey, u8) { - Pubkey::find_program_address( - &[MULTI_DELEGATE_SEED, user.as_ref(), mint.as_ref()], - program_id, - ) -} - -/// Derive the `FixedDelegation` PDA for `(multi_delegate, delegator, delegatee, nonce)`. -/// -/// Seeds: `[b"delegation", multi_delegate, delegator, delegatee, nonce_le_bytes]` → `program_id` -pub fn find_fixed_delegation_pda( - multi_delegate: &Pubkey, - delegator: &Pubkey, - delegatee: &Pubkey, - nonce: u64, - program_id: &Pubkey, -) -> (Pubkey, u8) { - Pubkey::find_program_address( - &[ - DELEGATION_SEED, - multi_delegate.as_ref(), - delegator.as_ref(), - delegatee.as_ref(), - &nonce.to_le_bytes(), - ], - program_id, - ) -} - -/// Build an `InitMultiDelegate` instruction (disc = 0x00). -/// -/// Creates the `MultiDelegate` PDA for `(user, mint)` and approves it as -/// the SPL Token delegate on `user_ata` with a `u64::MAX` allowance. -/// -/// Account order (matches the on-chain program): -/// 0. `user` — signer, writable -/// 1. `multi_delegate` — writable (PDA derived here) -/// 2. `token_mint` — read-only -/// 3. `user_ata` — writable -/// 4. `system_program` — read-only -/// 5. `token_program` — read-only -pub fn build_init_multi_delegate_ix( - program_id: &Pubkey, - user: &Pubkey, - mint: &Pubkey, - user_ata: &Pubkey, - token_program: &Pubkey, -) -> Instruction { - let (multi_delegate_pda, _) = find_multi_delegate_pda(user, mint, program_id); - // System program: 11111111111111111111111111111111 = all-zero bytes = Pubkey::default() - let system_program = Pubkey::default(); - Instruction { - program_id: *program_id, - accounts: vec![ - AccountMeta::new(*user, true), - AccountMeta::new(multi_delegate_pda, false), - AccountMeta::new_readonly(*mint, false), - AccountMeta::new(*user_ata, false), - AccountMeta::new_readonly(system_program, false), - AccountMeta::new_readonly(*token_program, false), - ], - data: vec![0x00], - } -} - -/// Build a `CreateFixedDelegation` instruction (disc = 0x01). -/// -/// Creates a `FixedDelegation` PDA capping the delegatee to `amount` tokens, -/// expiring at `expiry_ts` (Unix seconds; `0` = never expires). -/// -/// Instruction data layout: `[0x01] ++ nonce_le ++ amount_le ++ expiry_ts_le` -/// -/// Account order: -/// 0. `delegator` — signer, writable -/// 1. `multi_delegate` — read-only -/// 2. `delegation_pda` — writable (created by this instruction) -/// 3. `delegatee` — read-only -/// 4. `system_program` — read-only -/// -/// (No optional 6th payer — `delegator` pays rent.) -#[allow(clippy::too_many_arguments)] -pub fn build_create_fixed_delegation_ix( - program_id: &Pubkey, - delegator: &Pubkey, - multi_delegate_pda: &Pubkey, - delegation_pda: &Pubkey, - delegatee: &Pubkey, - nonce: u64, - amount: u64, - expiry_ts: i64, -) -> Instruction { - let system_program = Pubkey::default(); - let mut data = Vec::with_capacity(25); - data.push(0x01); - data.extend_from_slice(&nonce.to_le_bytes()); - data.extend_from_slice(&amount.to_le_bytes()); - data.extend_from_slice(&expiry_ts.to_le_bytes()); - Instruction { - program_id: *program_id, - accounts: vec![ - AccountMeta::new(*delegator, true), - AccountMeta::new_readonly(*multi_delegate_pda, false), - AccountMeta::new(*delegation_pda, false), - AccountMeta::new_readonly(*delegatee, false), - AccountMeta::new_readonly(system_program, false), - ], - data, - } -} - -#[cfg(test)] -mod tests { - use super::*; - - // ── Helper ──────────────────────────────────────────────────────────────── - - fn state(exists: bool, cap: Option) -> MultiDelegateOnChainState { - MultiDelegateOnChainState { - multi_delegate_exists: exists, - existing_delegation_cap: cap, - } - } - - const REQ: u64 = 1_000_000; - - // ── No MultiDelegate PDA ────────────────────────────────────────────────── - - #[test] - fn no_multi_delegate_with_init_tx_returns_submit_init() { - let action = assess_multi_delegate_setup(&state(false, None), REQ, true, false); - assert_eq!(action, MultiDelegateSetupAction::SubmitInit); - } - - #[test] - fn no_multi_delegate_without_init_tx_returns_missing_init() { - let action = assess_multi_delegate_setup(&state(false, None), REQ, false, false); - assert_eq!( - action, - MultiDelegateSetupAction::MissingPayload(MissingPayloadReason::NoInitTx) - ); - } - - #[test] - fn no_multi_delegate_with_both_txs_uses_init_not_update() { - // When MultiDelegate doesn't exist, init takes priority even if update tx is also present. - let action = assess_multi_delegate_setup(&state(false, None), REQ, true, true); - assert_eq!(action, MultiDelegateSetupAction::SubmitInit); - } - - #[test] - fn no_multi_delegate_with_update_tx_only_returns_missing_init() { - // update_tx alone is insufficient when MultiDelegate doesn't exist yet. - let action = assess_multi_delegate_setup(&state(false, None), REQ, false, true); - assert_eq!( - action, - MultiDelegateSetupAction::MissingPayload(MissingPayloadReason::NoInitTx) - ); - } - - // ── MultiDelegate exists, no FixedDelegation ────────────────────────────── - - #[test] - fn multi_delegate_exists_no_delegation_with_update_tx_returns_submit_update() { - let action = assess_multi_delegate_setup(&state(true, None), REQ, false, true); - assert_eq!(action, MultiDelegateSetupAction::SubmitUpdate); - } - - #[test] - fn multi_delegate_exists_no_delegation_without_update_tx_returns_missing_update() { - let action = assess_multi_delegate_setup(&state(true, None), REQ, false, false); - assert_eq!( - action, - MultiDelegateSetupAction::MissingPayload(MissingPayloadReason::NoUpdateTx) - ); - } - - #[test] - fn multi_delegate_exists_no_delegation_with_both_txs_uses_update() { - // MultiDelegate exists — only need to create the FixedDelegation. - let action = assess_multi_delegate_setup(&state(true, None), REQ, true, true); - assert_eq!(action, MultiDelegateSetupAction::SubmitUpdate); - } - - // ── MultiDelegate + FixedDelegation: cap boundary checks ───────────────── - - #[test] - fn exact_cap_returns_already_sufficient() { - let action = assess_multi_delegate_setup(&state(true, Some(REQ)), REQ, false, false); - assert_eq!(action, MultiDelegateSetupAction::AlreadySufficient); - } - - #[test] - fn cap_above_required_returns_already_sufficient() { - let action = assess_multi_delegate_setup(&state(true, Some(REQ + 1)), REQ, false, false); - assert_eq!(action, MultiDelegateSetupAction::AlreadySufficient); - } - - #[test] - fn large_existing_cap_always_sufficient() { - let action = assess_multi_delegate_setup(&state(true, Some(u64::MAX)), REQ, false, false); - assert_eq!(action, MultiDelegateSetupAction::AlreadySufficient); - } - - #[test] - fn cap_one_below_required_with_update_tx_returns_submit_update() { - let action = assess_multi_delegate_setup(&state(true, Some(REQ - 1)), REQ, false, true); - assert_eq!(action, MultiDelegateSetupAction::SubmitUpdate); - } - - #[test] - fn cap_one_below_required_without_update_tx_returns_missing_update() { - let action = assess_multi_delegate_setup(&state(true, Some(REQ - 1)), REQ, false, false); - assert_eq!( - action, - MultiDelegateSetupAction::MissingPayload(MissingPayloadReason::NoUpdateTx) - ); - } - - #[test] - fn zero_cap_with_update_tx_returns_submit_update() { - let action = assess_multi_delegate_setup(&state(true, Some(0)), REQ, false, true); - assert_eq!(action, MultiDelegateSetupAction::SubmitUpdate); - } - - // ── AlreadySufficient ignores available txs ─────────────────────────────── - - #[test] - fn sufficient_cap_ignores_update_tx_if_provided() { - // Even if the client sent an update tx, don't submit it — cap is fine. - let action = assess_multi_delegate_setup(&state(true, Some(5 * REQ)), REQ, false, true); - assert_eq!(action, MultiDelegateSetupAction::AlreadySufficient); - } - - #[test] - fn sufficient_cap_ignores_both_txs_if_provided() { - let action = assess_multi_delegate_setup(&state(true, Some(5 * REQ)), REQ, true, true); - assert_eq!(action, MultiDelegateSetupAction::AlreadySufficient); - } - - // ── Zero required_cap edge case ─────────────────────────────────────────── - - #[test] - fn zero_required_cap_with_any_existing_cap_is_sufficient() { - let action = assess_multi_delegate_setup(&state(true, Some(1)), 0, false, false); - assert_eq!(action, MultiDelegateSetupAction::AlreadySufficient); - } - - #[test] - fn zero_required_cap_with_zero_existing_cap_is_sufficient() { - // 0 >= 0 - let action = assess_multi_delegate_setup(&state(true, Some(0)), 0, false, false); - assert_eq!(action, MultiDelegateSetupAction::AlreadySufficient); - } - - // ── Display ─────────────────────────────────────────────────────────────── - - #[test] - fn display_already_sufficient_mentions_no_tx() { - let s = MultiDelegateSetupAction::AlreadySufficient.to_string(); - assert!(s.contains("sufficient"), "got: {s}"); - } - - #[test] - fn display_submit_init_mentions_init_tx() { - let s = MultiDelegateSetupAction::SubmitInit.to_string(); - assert!(s.contains("initMultiDelegateTx"), "got: {s}"); - } - - #[test] - fn display_submit_update_mentions_update_tx() { - let s = MultiDelegateSetupAction::SubmitUpdate.to_string(); - assert!(s.contains("updateDelegationTx"), "got: {s}"); - } - - #[test] - fn display_missing_init_mentions_init_tx() { - let s = - MultiDelegateSetupAction::MissingPayload(MissingPayloadReason::NoInitTx).to_string(); - assert!(s.contains("initMultiDelegateTx"), "got: {s}"); - } - - #[test] - fn display_missing_update_mentions_update_tx() { - let s = - MultiDelegateSetupAction::MissingPayload(MissingPayloadReason::NoUpdateTx).to_string(); - assert!(s.contains("updateDelegationTx"), "got: {s}"); - } -} diff --git a/rust/crates/mpp/src/program/subscriptions.rs b/rust/crates/mpp/src/program/subscriptions.rs index 215d1d4aa..0db75d826 100644 --- a/rust/crates/mpp/src/program/subscriptions.rs +++ b/rust/crates/mpp/src/program/subscriptions.rs @@ -17,11 +17,29 @@ use std::str::FromStr; +use solana_address::Address; use solana_instruction::{AccountMeta, Instruction}; use solana_pubkey::Pubkey; +use subscriptions_client::generated::instructions::{ + CreatePlan as GenCreatePlan, CreatePlanInstructionArgs, + InitSubscriptionAuthority as GenInitSubscriptionAuthority, Subscribe as GenSubscribe, + SubscribeInstructionArgs, TransferSubscription as GenTransferSubscription, + TransferSubscriptionInstructionArgs, +}; +use subscriptions_client::generated::types::{ + PlanData as GenPlanData, PlanTerms as GenPlanTerms, SubscribeData as GenSubscribeData, + TransferData as GenTransferData, +}; + use crate::error::{Error, Result}; +/// Convert a `Pubkey` to the `solana_address::Address` used by the generated +/// subscriptions client. +fn to_addr(pubkey: &Pubkey) -> Address { + Address::from(pubkey.to_bytes()) +} + /// Canonical mainnet program ID for the subscriptions program. pub const SUBSCRIPTIONS_PROGRAM_ID: &str = "De1egAFMkMWZSN5rYXRj9CAdheBamobVNubTsi9avR44"; @@ -336,17 +354,29 @@ pub fn build_create_plan_ix( data: &CreatePlanData, ) -> Instruction { let system_program = Pubkey::from_str(SYSTEM_PROGRAM_ID).expect("valid system program id"); - Instruction { - program_id, - accounts: vec![ - AccountMeta::new(accounts.merchant, true), - AccountMeta::new(accounts.plan_pda, false), - AccountMeta::new_readonly(accounts.token_mint, false), - AccountMeta::new_readonly(system_program, false), - AccountMeta::new_readonly(accounts.token_program, false), - ], - data: data.to_bytes(), + let plan_data = GenPlanData { + plan_id: data.plan_id, + mint: to_addr(&data.mint), + terms: GenPlanTerms { + amount: data.terms.amount, + period_hours: data.terms.period_hours, + created_at: data.terms.created_at, + }, + end_ts: data.end_ts, + destinations: data.destinations.map(|p| to_addr(&p)), + pullers: data.pullers.map(|p| to_addr(&p)), + metadata_uri: data.metadata_uri, + }; + let mut ix = GenCreatePlan { + merchant: to_addr(&accounts.merchant), + plan_pda: to_addr(&accounts.plan_pda), + token_mint: to_addr(&accounts.token_mint), + system_program: to_addr(&system_program), + token_program: to_addr(&accounts.token_program), } + .instruction(CreatePlanInstructionArgs { plan_data }); + ix.program_id = to_addr(&program_id); + ix } /// Account inputs for [`build_subscribe_ix`]. `payer` is optional — when @@ -370,24 +400,35 @@ pub fn build_subscribe_ix( data: &SubscribeData, ) -> Instruction { let system_program = Pubkey::from_str(SYSTEM_PROGRAM_ID).expect("valid system program id"); - let mut metas = vec![ - AccountMeta::new(accounts.subscriber, true), - AccountMeta::new_readonly(accounts.merchant, false), - AccountMeta::new_readonly(accounts.plan_pda, false), - AccountMeta::new(accounts.subscription_pda, false), - AccountMeta::new_readonly(accounts.subscription_authority_pda, false), - AccountMeta::new_readonly(system_program, false), - AccountMeta::new_readonly(accounts.event_authority, false), - AccountMeta::new_readonly(program_id, false), - ]; - if let Some(payer) = accounts.payer { - metas.push(AccountMeta::new(payer, true)); - } - Instruction { - program_id, - accounts: metas, - data: data.to_bytes(), - } + let subscribe_data = GenSubscribeData { + plan_id: data.plan_id, + plan_bump: data.plan_bump, + expected_mint: to_addr(&data.expected_mint), + expected_amount: data.expected_amount, + expected_period_hours: data.expected_period_hours, + expected_created_at: data.expected_created_at, + expected_subscription_authority_init_id: data.expected_subscription_authority_init_id, + }; + let gen = GenSubscribe { + subscriber: to_addr(&accounts.subscriber), + merchant: to_addr(&accounts.merchant), + plan_pda: to_addr(&accounts.plan_pda), + subscription_pda: to_addr(&accounts.subscription_pda), + subscription_authority_pda: to_addr(&accounts.subscription_authority_pda), + system_program: to_addr(&system_program), + event_authority: to_addr(&accounts.event_authority), + self_program: to_addr(&program_id), + }; + // The optional payer rides as a trailing (writable, signer) account, exactly + // as the program's `resolve_optional_payer` expects. + let remaining: Vec = accounts + .payer + .map(|payer| vec![AccountMeta::new(to_addr(&payer), true)]) + .unwrap_or_default(); + let mut ix = + gen.instruction_with_remaining_accounts(SubscribeInstructionArgs { subscribe_data }, &remaining); + ix.program_id = to_addr(&program_id); + ix } /// Account inputs for [`build_transfer_subscription_ix`]. @@ -412,22 +453,26 @@ pub fn build_transfer_subscription_ix( accounts: TransferSubscriptionAccounts, data: &TransferData, ) -> Instruction { - Instruction { - program_id, - accounts: vec![ - AccountMeta::new(accounts.subscription_pda, false), - AccountMeta::new_readonly(accounts.plan_pda, false), - AccountMeta::new_readonly(accounts.subscription_authority, false), - AccountMeta::new(accounts.delegator_ata, false), - AccountMeta::new(accounts.receiver_ata, false), - AccountMeta::new_readonly(accounts.caller, true), - AccountMeta::new_readonly(accounts.token_mint, false), - AccountMeta::new_readonly(accounts.token_program, false), - AccountMeta::new_readonly(accounts.event_authority, false), - AccountMeta::new_readonly(program_id, false), - ], - data: data.to_bytes(INSTRUCTION_TRANSFER_SUBSCRIPTION), + let transfer_data = GenTransferData { + amount: data.amount, + delegator: to_addr(&data.delegator), + mint: to_addr(&data.mint), + }; + let mut ix = GenTransferSubscription { + subscription_pda: to_addr(&accounts.subscription_pda), + plan_pda: to_addr(&accounts.plan_pda), + subscription_authority: to_addr(&accounts.subscription_authority), + delegator_ata: to_addr(&accounts.delegator_ata), + receiver_ata: to_addr(&accounts.receiver_ata), + caller: to_addr(&accounts.caller), + token_mint: to_addr(&accounts.token_mint), + token_program: to_addr(&accounts.token_program), + event_authority: to_addr(&accounts.event_authority), + self_program: to_addr(&program_id), } + .instruction(TransferSubscriptionInstructionArgs { transfer_data }); + ix.program_id = to_addr(&program_id); + ix } /// Account inputs for [`build_cancel_subscription_ix`]. @@ -445,6 +490,12 @@ pub fn build_cancel_subscription_ix( program_id: Pubkey, accounts: CancelSubscriptionAccounts, ) -> Instruction { + // NOTE: not delegated to the generated client. Both agree `subscriber` is a + // signer, but the vendored IDL marks it read-only while the program treats + // it as writable (rent is refunded to the subscriber on cancel). Adopting + // the generated builder would make `subscriber` read-only and risk breaking + // the refund. Until the IDL is fixed and regenerated, keep the hand-written + // account metas — see `generated_cancel_subscription_idl_still_diverges_from_program`. Instruction { program_id, accounts: vec![ @@ -475,18 +526,17 @@ pub fn build_initialize_subscription_authority_ix( accounts: InitializeSubscriptionAuthorityAccounts, ) -> Instruction { let system_program = Pubkey::from_str(SYSTEM_PROGRAM_ID).expect("valid system program id"); - Instruction { - program_id, - accounts: vec![ - AccountMeta::new(accounts.owner, true), - AccountMeta::new(accounts.subscription_authority, false), - AccountMeta::new_readonly(accounts.token_mint, false), - AccountMeta::new(accounts.user_ata, false), - AccountMeta::new_readonly(system_program, false), - AccountMeta::new_readonly(accounts.token_program, false), - ], - data: vec![INSTRUCTION_INITIALIZE_SUBSCRIPTION_AUTHORITY], + let mut ix = GenInitSubscriptionAuthority { + owner: to_addr(&accounts.owner), + subscription_authority: to_addr(&accounts.subscription_authority), + token_mint: to_addr(&accounts.token_mint), + user_ata: to_addr(&accounts.user_ata), + system_program: to_addr(&system_program), + token_program: to_addr(&accounts.token_program), } + .instruction(); + ix.program_id = to_addr(&program_id); + ix } #[cfg(test)] @@ -808,6 +858,57 @@ mod tests { assert_eq!(ix.data, vec![INSTRUCTION_CANCEL_SUBSCRIPTION]); } + #[test] + fn build_initialize_subscription_authority_ix_account_shape() { + let program = default_program_id(); + let owner = Pubkey::new_unique(); + let token_program = + Pubkey::from_str(crate::protocol::solana::programs::TOKEN_PROGRAM).unwrap(); + let ix = build_initialize_subscription_authority_ix( + program, + InitializeSubscriptionAuthorityAccounts { + owner, + subscription_authority: Pubkey::new_unique(), + token_mint: Pubkey::new_unique(), + user_ata: Pubkey::new_unique(), + token_program, + }, + ); + assert_eq!(ix.program_id, program); + assert_eq!(ix.accounts.len(), 6); + assert!(ix.accounts[0].is_signer && ix.accounts[0].is_writable); // owner + assert!(ix.accounts[1].is_writable && !ix.accounts[1].is_signer); // authority PDA + assert!(!ix.accounts[2].is_writable); // mint + assert!(ix.accounts[3].is_writable && !ix.accounts[3].is_signer); // user ATA + assert_eq!(ix.data, vec![INSTRUCTION_INITIALIZE_SUBSCRIPTION_AUTHORITY]); + } + + /// Locks the known IDL discrepancy: `build_cancel_subscription_ix` is the one + /// builder NOT delegated to the generated client because the vendored IDL + /// marks the subscriber read-only, while the program treats it as a writable + /// signer (rent refund). Both agree it is a signer. If the writable + /// assertion fails the IDL was fixed — regenerate and switch + /// `build_cancel_subscription_ix` to the generated builder. + #[test] + fn generated_cancel_subscription_idl_still_diverges_from_program() { + use subscriptions_client::generated::instructions::CancelSubscription as Gen; + let gen = Gen { + subscriber: to_addr(&Pubkey::new_unique()), + plan_pda: to_addr(&Pubkey::new_unique()), + subscription_pda: to_addr(&Pubkey::new_unique()), + event_authority: to_addr(&Pubkey::new_unique()), + self_program: to_addr(&default_program_id()), + } + .instruction(); + // Both sides agree the subscriber signs. + assert!(gen.accounts[0].is_signer); + // The divergence: the IDL marks it read-only; we require writable. + assert!( + !gen.accounts[0].is_writable, + "IDL cancel subscriber is now writable — migrate cancel to the generated builder" + ); + } + #[test] fn find_event_authority_pda_is_deterministic() { let program = default_program_id(); diff --git a/rust/crates/mpp/src/protocol/intents/mod.rs b/rust/crates/mpp/src/protocol/intents/mod.rs index bcbd87b46..13e7b551d 100644 --- a/rust/crates/mpp/src/protocol/intents/mod.rs +++ b/rust/crates/mpp/src/protocol/intents/mod.rs @@ -22,92 +22,21 @@ pub use subscription::{ SubscriptionReceiptExtensions, SubscriptionRequest, }; -/// Audit #39: upper bound on the `decimals` argument to `parse_units`. +/// Upper bound on the `decimals` argument to [`parse_units`]. /// -/// Solana's SPL convention is 0–9 (the protocol spec says so). 18 gives -/// ERC-20-style headroom while staying well below the cliff at 39 where -/// `10u128.pow(decimals)` actually overflows. The point of the cap is to -/// give us a single rejection site so any callsite that hasn't validated -/// `decimals` upstream gets a clear error rather than a panic or wrap. -pub const MAX_DECIMALS: u8 = 18; +/// Re-exported from `solana-pay-core`, the canonical shared implementation. +pub use solana_pay_core::MAX_DECIMALS; /// Convert a human-readable amount to base units. /// /// Matches the TypeScript SDK's `parseUnits(amount, decimals)`. /// e.g., `parse_units("1.5", 6)` → `"1500000"`. /// -/// Audit #39: rejects `decimals > MAX_DECIMALS` and uses checked -/// arithmetic in the integer branch so a hostile or buggy caller cannot -/// trigger a panic (debug) or silent overflow (release). -/// -/// Audits #44 and #45: validate input shape and content. -/// - Reject empty amount and amounts with more than one `.` (e.g. -/// `"1.2.3"`) — `split_once('.')` only splits on the first dot, which -/// would otherwise let `"1.2.3"` parse as `"1" + "23"` and silently -/// produce the wrong value. -/// - Reject inputs that aren't strict ASCII digit strings on either side -/// of the dot — `"1a.2"`, `".5"`, `"5."`, `"."` all become errors. +/// Delegates to [`solana_pay_core::parse_units`] — the audited shared +/// implementation (decimals cap, checked arithmetic, strict input validation) — +/// mapping its error into this crate's error type. pub fn parse_units(amount: &str, decimals: u8) -> Result { - if decimals > MAX_DECIMALS { - return Err(crate::error::Error::Other(format!( - "Decimals {decimals} exceeds maximum {MAX_DECIMALS}" - ))); - } - if amount.is_empty() { - return Err(crate::error::Error::Other("Empty amount".into())); - } - if amount.matches('.').count() > 1 { - return Err(crate::error::Error::Other(format!( - "Invalid amount `{amount}`: more than one decimal point" - ))); - } - let decimals = decimals as u32; - - if let Some((integer, fraction)) = amount.split_once('.') { - // Audit #44/#45: require non-empty digit strings on both sides - // of the dot. `".5"`, `"5."`, `"."`, `"1a.2"` all rejected. - if integer.is_empty() || fraction.is_empty() { - return Err(crate::error::Error::Other(format!( - "Invalid amount `{amount}`: integer and fractional parts must both be non-empty" - ))); - } - if !integer.bytes().all(|b| b.is_ascii_digit()) - || !fraction.bytes().all(|b| b.is_ascii_digit()) - { - return Err(crate::error::Error::Other(format!( - "Invalid amount `{amount}`: only ASCII digits and a single optional decimal point are allowed" - ))); - } - let frac_len = fraction.len() as u32; - if frac_len > decimals { - return Err(crate::error::Error::Other(format!( - "Too many decimal places: {frac_len} > {decimals}" - ))); - } - let padding = decimals - frac_len; - let combined = format!("{integer}{fraction}{}", "0".repeat(padding as usize)); - // Strip leading zeros but keep at least one digit. - let trimmed = combined.trim_start_matches('0'); - if trimmed.is_empty() { - Ok("0".to_string()) - } else { - Ok(trimmed.to_string()) - } - } else { - // No decimal point — multiply by 10^decimals. - let value: u128 = amount - .parse() - .map_err(|_| crate::error::Error::Other(format!("Invalid amount: {amount}")))?; - let factor = 10u128.checked_pow(decimals).ok_or_else(|| { - crate::error::Error::Other(format!("10^{decimals} overflows u128 in parse_units")) - })?; - let product = value.checked_mul(factor).ok_or_else(|| { - crate::error::Error::Other(format!( - "{value} * 10^{decimals} overflows u128 in parse_units" - )) - })?; - Ok(product.to_string()) - } + solana_pay_core::parse_units(amount, decimals).map_err(Into::into) } /// Deserialize a request from a base64url JSON string. diff --git a/rust/crates/mpp/src/protocol/intents/session.rs b/rust/crates/mpp/src/protocol/intents/session.rs index 9994e2321..f5b40860c 100644 --- a/rust/crates/mpp/src/protocol/intents/session.rs +++ b/rust/crates/mpp/src/protocol/intents/session.rs @@ -696,11 +696,11 @@ impl VoucherData { .cumulative .parse() .map_err(|_| crate::error::Error::Other("invalid voucher cumulative".to_string()))?; - crate::program::payment_channels::voucher_message_bytes( + Ok(crate::program::payment_channels::voucher_message_bytes( &channel_id, cumulative, self.expires_at, - ) + )?) } } diff --git a/rust/crates/x402/Cargo.toml b/rust/crates/x402/Cargo.toml index 4e56dc203..78620516b 100644 --- a/rust/crates/x402/Cargo.toml +++ b/rust/crates/x402/Cargo.toml @@ -11,6 +11,9 @@ server = ["dep:tokio"] client = ["dep:reqwest"] [dependencies] +# Shared channel primitives (PDA/voucher/instruction builders for the upto scheme) +solana-pay-core = { path = "../core" } + # Signing — solana-keychain with sdk-v3 solana-keychain = { git = "https://github.com/solana-foundation/solana-keychain", rev = "abf75944", default-features = false, features = ["memory", "sdk-v3"] } diff --git a/rust/crates/x402/src/client/mod.rs b/rust/crates/x402/src/client/mod.rs index e6fbbc7af..9a6c4a74b 100644 --- a/rust/crates/x402/src/client/mod.rs +++ b/rust/crates/x402/src/client/mod.rs @@ -1 +1,2 @@ pub mod exact; +pub mod upto; diff --git a/rust/crates/x402/src/client/upto/mod.rs b/rust/crates/x402/src/client/upto/mod.rs new file mode 100644 index 000000000..e7971614e --- /dev/null +++ b/rust/crates/x402/src/client/upto/mod.rs @@ -0,0 +1,5 @@ +//! Client-side payment building for the x402 `upto` scheme. + +mod payment; + +pub use payment::*; diff --git a/rust/crates/x402/src/client/upto/payment.rs b/rust/crates/x402/src/client/upto/payment.rs new file mode 100644 index 000000000..5561b3e5b --- /dev/null +++ b/rust/crates/x402/src/client/upto/payment.rs @@ -0,0 +1,237 @@ +//! Client-side payment building for the x402 `upto` scheme (payment-channel). +//! +//! The client opens a channel whose `deposit` is the authorized maximum, with +//! `authorized_signer = operator` so the operator can settle the actual amount +//! with a single voucher. The client signs only the `open` transaction; the +//! operator broadcasts it and settles after metering. + +use std::str::FromStr; + +use solana_hash::Hash; +use solana_keychain::SolanaSigner; +use solana_pubkey::Pubkey; + +use solana_pay_core::payment_channels as pc; + +use crate::error::Error; +use crate::protocol::schemes::upto::{ + UptoPayload, UptoRequiredEnvelope, UptoRequirements, UptoSignatureEnvelope, + PROFILE_PAYMENT_CHANNEL, UPTO_SCHEME, +}; +use crate::{PAYMENT_REQUIRED_HEADER, X402_VERSION_V2}; + +/// Build an `upto` payload for a `payment-channel` requirement. +/// +/// `expires_at` is the voucher/authorization deadline (Unix seconds); `nonce` +/// uniquely identifies this authorization. The requirement MUST carry +/// `extra.recentBlockhash` (the operator provides it in the 402 challenge). +pub async fn build_upto_payload( + payer_signer: &dyn SolanaSigner, + requirements: &UptoRequirements, + expires_at: i64, + nonce: impl Into, +) -> Result { + if !requirements + .extra + .profiles + .iter() + .any(|p| p == PROFILE_PAYMENT_CHANNEL) + { + return Err(Error::Other( + "requirement does not advertise the payment-channel profile".to_string(), + )); + } + + let max = requirements.max_amount()?; + let payee = Pubkey::from_str(&requirements.pay_to) + .map_err(|e| Error::Other(format!("invalid payTo: {e}")))?; + let mint = Pubkey::from_str(&requirements.asset) + .map_err(|e| Error::Other(format!("invalid asset mint: {e}")))?; + let operator = Pubkey::from_str(&requirements.extra.facilitator) + .map_err(|e| Error::Other(format!("invalid facilitator: {e}")))?; + let program_id = match &requirements.extra.program_id { + Some(value) => { + Pubkey::from_str(value).map_err(|e| Error::Other(format!("invalid programId: {e}")))? + } + None => pc::default_program_id(), + }; + let token_program = match &requirements.extra.token_program { + Some(value) => Pubkey::from_str(value) + .map_err(|e| Error::Other(format!("invalid tokenProgram: {e}")))?, + None => Pubkey::from_str( + "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", + ) + .expect("valid token program"), + }; + let recent_blockhash = requirements + .extra + .recent_blockhash + .as_deref() + .ok_or_else(|| Error::Other("requirement missing extra.recentBlockhash".to_string()))?; + let blockhash = Hash::from_str(recent_blockhash) + .map_err(|e| Error::Other(format!("invalid recentBlockhash: {e}")))?; + + let salt = pc::random_salt(); + // operator is both the voucher signer (authorized_signer) and the fee payer. + let open = pc::build_open_payment_channel_tx( + payer_signer, + &payee, + &mint, + &operator, + salt, + max, + pc::DEFAULT_GRACE_PERIOD_SECONDS, + vec![], + &token_program, + &program_id, + &operator, + blockhash, + ) + .await?; + + Ok(UptoPayload { + profile: PROFILE_PAYMENT_CHANNEL.to_string(), + from: pc::pubkey_string(&payer_signer.pubkey()), + max_amount: max.to_string(), + expires_at, + valid_after: requirements.extra.valid_after.unwrap_or(0), + nonce: nonce.into(), + channel_id: pc::pubkey_string(&open.channel_id), + deposit: max.to_string(), + authorized_signer: pc::pubkey_string(&operator), + open_transaction: Some(open.transaction), + signature: None, + }) +} + +/// Wrap a payload in a `PAYMENT-SIGNATURE` envelope and base64-encode it. +pub fn encode_upto_header( + requirements: &UptoRequirements, + payload: UptoPayload, +) -> Result { + let envelope = UptoSignatureEnvelope { + x402_version: X402_VERSION_V2, + scheme: UPTO_SCHEME.to_string(), + network: Some(requirements.network.clone()), + accepted: Some(requirements.to_accepted_value()?), + payload, + }; + let json = serde_json::to_string(&envelope) + .map_err(|e| Error::Other(format!("upto envelope serialization failed: {e}")))?; + Ok(base64::Engine::encode( + &base64::engine::general_purpose::STANDARD, + json.as_bytes(), + )) +} + +/// Build the full `PAYMENT-SIGNATURE` header value for an `upto` payment. +pub async fn build_upto_header( + payer_signer: &dyn SolanaSigner, + requirements: &UptoRequirements, + expires_at: i64, + nonce: impl Into, +) -> Result { + let payload = build_upto_payload(payer_signer, requirements, expires_at, nonce).await?; + encode_upto_header(requirements, payload) +} + +/// Parse a 402 `upto` challenge from a `PAYMENT-REQUIRED` header value or body. +pub fn parse_upto_challenge( + headers: &[(String, String)], + body: Option<&str>, +) -> Option { + let from_header = headers + .iter() + .find(|(name, _)| name.eq_ignore_ascii_case(PAYMENT_REQUIRED_HEADER)) + .and_then(|(_, value)| { + base64::Engine::decode(&base64::engine::general_purpose::STANDARD, value).ok() + }) + .and_then(|bytes| serde_json::from_slice::(&bytes).ok()); + + let envelope = from_header + .or_else(|| body.and_then(|b| serde_json::from_str::(b).ok()))?; + + envelope + .accepts + .into_iter() + .find(|req| req.scheme == UPTO_SCHEME) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::protocol::schemes::upto::UptoExtra; + + const OPERATOR: &str = "9xQeWvG816bUx9EPjHmaT23yvVM2ZWbrrpZb9PusVFin"; + + fn requirements() -> UptoRequirements { + UptoRequirements { + scheme: UPTO_SCHEME.to_string(), + network: "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1".to_string(), + amount: "1000000".to_string(), + asset: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v".to_string(), + pay_to: "CXhrFZJLKqjzmP3sjYLcF4dTeXWKCy9e2SXXZ2Yo6MPY".to_string(), + max_timeout_seconds: 300, + extra: UptoExtra { + profiles: vec![PROFILE_PAYMENT_CHANNEL.to_string()], + decimals: Some(6), + token_program: None, + facilitator: OPERATOR.to_string(), + program_id: None, + recent_blockhash: Some(Hash::default().to_string()), + valid_after: None, + }, + } + } + + fn sample_payload() -> UptoPayload { + UptoPayload { + profile: PROFILE_PAYMENT_CHANNEL.to_string(), + from: "CXhrFZJLKqjzmP3sjYLcF4dTeXWKCy9e2SXXZ2Yo6MPY".to_string(), + max_amount: "1000000".to_string(), + expires_at: 4_102_444_800, + valid_after: 0, + nonce: "n-1".to_string(), + channel_id: "CXhrFZJLKqjzmP3sjYLcF4dTeXWKCy9e2SXXZ2Yo6MPY".to_string(), + deposit: "1000000".to_string(), + authorized_signer: OPERATOR.to_string(), + open_transaction: Some("tx".to_string()), + signature: None, + } + } + + #[test] + fn encode_header_produces_upto_envelope() { + let header = encode_upto_header(&requirements(), sample_payload()).unwrap(); + let bytes = + base64::Engine::decode(&base64::engine::general_purpose::STANDARD, &header).unwrap(); + let envelope: UptoSignatureEnvelope = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(envelope.scheme, UPTO_SCHEME); + assert_eq!(envelope.payload.max_amount, "1000000"); + assert_eq!(envelope.x402_version, X402_VERSION_V2); + assert!(envelope.accepted.is_some()); + } + + #[test] + fn parse_challenge_reads_payment_required_header() { + let envelope = UptoRequiredEnvelope { + x402_version: X402_VERSION_V2, + resource: None, + accepts: vec![requirements()], + error: None, + }; + let json = serde_json::to_string(&envelope).unwrap(); + let value = + base64::Engine::encode(&base64::engine::general_purpose::STANDARD, json.as_bytes()); + let headers = vec![(PAYMENT_REQUIRED_HEADER.to_string(), value)]; + + let parsed = parse_upto_challenge(&headers, None).unwrap(); + assert_eq!(parsed.amount, "1000000"); + assert_eq!(parsed.extra.facilitator, OPERATOR); + } + + #[test] + fn parse_challenge_returns_none_without_upto_offer() { + assert!(parse_upto_challenge(&[], None).is_none()); + } +} diff --git a/rust/crates/x402/src/error.rs b/rust/crates/x402/src/error.rs index 33630cb1f..7d7902c8e 100644 --- a/rust/crates/x402/src/error.rs +++ b/rust/crates/x402/src/error.rs @@ -58,3 +58,12 @@ pub enum Error { #[error("{0}")] Other(String), } + +impl From for Error { + fn from(err: solana_pay_core::Error) -> Self { + match err { + solana_pay_core::Error::Serialization(msg) => Error::Other(msg), + solana_pay_core::Error::Other(msg) => Error::Other(msg), + } + } +} diff --git a/rust/crates/x402/src/lib.rs b/rust/crates/x402/src/lib.rs index 1001456c3..0d0af6897 100644 --- a/rust/crates/x402/src/lib.rs +++ b/rust/crates/x402/src/lib.rs @@ -25,6 +25,7 @@ pub mod siwx; pub use constants::*; pub use error::Error; pub use protocol::schemes::exact; +pub use protocol::schemes::upto; pub use siwx::*; // Re-export crates callers need to use with the payment builder. diff --git a/rust/crates/x402/src/protocol/schemes/mod.rs b/rust/crates/x402/src/protocol/schemes/mod.rs index e6fbbc7af..9a6c4a74b 100644 --- a/rust/crates/x402/src/protocol/schemes/mod.rs +++ b/rust/crates/x402/src/protocol/schemes/mod.rs @@ -1 +1,2 @@ pub mod exact; +pub mod upto; diff --git a/rust/crates/x402/src/protocol/schemes/upto/mod.rs b/rust/crates/x402/src/protocol/schemes/upto/mod.rs new file mode 100644 index 000000000..97e202a67 --- /dev/null +++ b/rust/crates/x402/src/protocol/schemes/upto/mod.rs @@ -0,0 +1,7 @@ +//! Solana implementation of the x402 `upto` scheme (channels-first). + +mod types; +mod verify; + +pub use types::*; +pub use verify::*; diff --git a/rust/crates/x402/src/protocol/schemes/upto/types.rs b/rust/crates/x402/src/protocol/schemes/upto/types.rs new file mode 100644 index 000000000..23d55f704 --- /dev/null +++ b/rust/crates/x402/src/protocol/schemes/upto/types.rs @@ -0,0 +1,292 @@ +//! Wire types for the x402 `upto` scheme on Solana. +//! +//! `upto` authorizes a **maximum** amount; the server settles for the **actual** +//! usage (`actual ≤ max`) determined after the resource is consumed. The v1 SVM +//! backend is the `payment-channel` profile: the client opens a channel whose +//! `deposit` is the ceiling, and the operator settles the metered amount with a +//! single voucher, refunding the remainder. See +//! `specs/schemes/upto/scheme_upto_svm.md`. + +use serde::{Deserialize, Serialize}; + +use crate::error::Error; +use crate::protocol::schemes::exact::ResourceInfo; + +/// `upto` scheme identifier. +pub const UPTO_SCHEME: &str = "upto"; + +/// Payment-channel settlement profile (normative v1). +pub const PROFILE_PAYMENT_CHANNEL: &str = "payment-channel"; + +fn upto_scheme() -> String { + UPTO_SCHEME.to_string() +} + +/// The `extra` object on an `upto` requirement. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UptoExtra { + /// Settlement profiles the server supports, in preference order. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub profiles: Vec, + + /// Token decimals. + #[serde(skip_serializing_if = "Option::is_none")] + pub decimals: Option, + + /// Token program address (legacy SPL or Token-2022). + #[serde(skip_serializing_if = "Option::is_none")] + pub token_program: Option, + + /// Base58 operator/facilitator key authorized to settle. + pub facilitator: String, + + /// Channel program id; defaults to the canonical deployment when absent. + #[serde(skip_serializing_if = "Option::is_none")] + pub program_id: Option, + + /// Server-prefetched recent blockhash for building the open transaction. + #[serde(skip_serializing_if = "Option::is_none")] + pub recent_blockhash: Option, + + /// Earliest activation time (Unix seconds). + #[serde(skip_serializing_if = "Option::is_none")] + pub valid_after: Option, +} + +/// An `upto` payment requirement (the `accepted` object in a 402 challenge). +/// +/// `amount` is **phase-dependent**: the authorized maximum during verification, +/// the actual charge during settlement. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UptoRequirements { + #[serde(default = "upto_scheme")] + pub scheme: String, + + /// CAIP-2 network identifier. + pub network: String, + + /// Maximum authorized amount (base units) at verification. + pub amount: String, + + /// SPL mint address (or a known symbol like `"USDC"`). + pub asset: String, + + /// Base58 recipient. + pub pay_to: String, + + /// Completion window in seconds. + pub max_timeout_seconds: u64, + + /// Scheme-specific data. + pub extra: UptoExtra, +} + +impl UptoRequirements { + /// Parse the authorized maximum as base units. + pub fn max_amount(&self) -> Result { + self.amount + .parse() + .map_err(|_| Error::Other(format!("invalid upto amount: {}", self.amount))) + } + + /// Canonical accepted-object JSON for this requirement. + pub fn to_accepted_value(&self) -> Result { + serde_json::to_value(self) + .map_err(|e| Error::Other(format!("upto requirement serialization failed: {e}"))) + } +} + +/// The `PAYMENT-REQUIRED` envelope for an `upto` challenge. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UptoRequiredEnvelope { + pub x402_version: u64, + + #[serde(skip_serializing_if = "Option::is_none")] + pub resource: Option, + + #[serde(default)] + pub accepts: Vec, + + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +/// The client authorization carried in `PAYMENT-SIGNATURE.payload`. +/// +/// For the `payment-channel` profile the channel `open` is the authorization: +/// the client's signature commits the deposit ceiling, payee, and mint. The +/// operator settles the actual amount with a voucher it signs. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UptoPayload { + /// Settlement profile (`payment-channel` in v1). + pub profile: String, + + /// Payer wallet (base58). + pub from: String, + + /// Signed ceiling (base units). MUST equal verification-phase `amount`. + pub max_amount: String, + + /// Deadline (Unix seconds); signed into the on-chain voucher. + pub expires_at: i64, + + /// Activation time (Unix seconds). + pub valid_after: i64, + + /// Unique per-authorization identifier. + pub nonce: String, + + /// Channel PDA (base58). + pub channel_id: String, + + /// On-chain escrow ceiling (base units); MUST equal `max_amount`. + pub deposit: String, + + /// Voucher signer — the operator/facilitator key (base58). + pub authorized_signer: String, + + /// Base64 client-signed `open` transaction for the operator to broadcast + /// (pull). Present unless the client pre-broadcast the open (push). + #[serde(skip_serializing_if = "Option::is_none")] + pub open_transaction: Option, + + /// Base58 signature of an already-broadcast `open` transaction (push). + #[serde(skip_serializing_if = "Option::is_none")] + pub signature: Option, +} + +impl UptoPayload { + /// Parse the signed ceiling as base units. + pub fn max_amount(&self) -> Result { + self.max_amount + .parse() + .map_err(|_| Error::Other(format!("invalid upto maxAmount: {}", self.max_amount))) + } + + /// Parse the deposit as base units. + pub fn deposit(&self) -> Result { + self.deposit + .parse() + .map_err(|_| Error::Other(format!("invalid upto deposit: {}", self.deposit))) + } +} + +/// The `PAYMENT-SIGNATURE` envelope for an `upto` payment. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UptoSignatureEnvelope { + pub x402_version: u64, + + pub scheme: String, + + #[serde(skip_serializing_if = "Option::is_none")] + pub network: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub accepted: Option, + + pub payload: UptoPayload, +} + +/// The `PAYMENT-RESPONSE` settlement result for an `upto` payment. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UptoSettlementResponse { + pub success: bool, + + #[serde(skip_serializing_if = "Option::is_none")] + pub error_reason: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub payer: Option, + + /// Settle (or settle-and-finalize) signature; empty when the charge is `0`. + pub transaction: String, + + pub network: String, + + /// Actual base units charged (may be `0`). + pub amount: String, +} + +#[cfg(test)] +mod tests { + use super::*; + + fn requirements() -> UptoRequirements { + UptoRequirements { + scheme: UPTO_SCHEME.to_string(), + network: "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1".to_string(), + amount: "1000000".to_string(), + asset: "USDC".to_string(), + pay_to: "CXhrFZJLKqjzmP3sjYLcF4dTeXWKCy9e2SXXZ2Yo6MPY".to_string(), + max_timeout_seconds: 300, + extra: UptoExtra { + profiles: vec![PROFILE_PAYMENT_CHANNEL.to_string()], + decimals: Some(6), + token_program: None, + facilitator: "9xQeWvG816bUx9EPjHmaT23yvVM2ZWbrrpZb9PusVFin".to_string(), + program_id: None, + recent_blockhash: None, + valid_after: None, + }, + } + } + + #[test] + fn requirements_round_trip_canonical_shape() { + let req = requirements(); + let json = serde_json::to_value(&req).unwrap(); + assert_eq!(json["scheme"], "upto"); + assert_eq!(json["payTo"], req.pay_to); + assert_eq!(json["amount"], "1000000"); + assert_eq!(json["maxTimeoutSeconds"], 300); + assert_eq!(json["extra"]["profiles"][0], "payment-channel"); + assert_eq!(json["extra"]["facilitator"], req.extra.facilitator); + + let back: UptoRequirements = serde_json::from_value(json).unwrap(); + assert_eq!(back.max_amount().unwrap(), 1_000_000); + assert_eq!(back.scheme, "upto"); + } + + #[test] + fn payload_omits_optional_fields_and_parses_amounts() { + let payload = UptoPayload { + profile: PROFILE_PAYMENT_CHANNEL.to_string(), + from: "Payer1111111111111111111111111111111111111".to_string(), + max_amount: "1000000".to_string(), + expires_at: 4_102_444_800, + valid_after: 0, + nonce: "n-1".to_string(), + channel_id: "Chan1111111111111111111111111111111111111".to_string(), + deposit: "1000000".to_string(), + authorized_signer: "Op11111111111111111111111111111111111111111".to_string(), + open_transaction: Some("base64tx".to_string()), + signature: None, + }; + let json = serde_json::to_string(&payload).unwrap(); + assert!(json.contains("\"openTransaction\":\"base64tx\"")); + assert!(!json.contains("\"signature\"")); + assert_eq!(payload.max_amount().unwrap(), 1_000_000); + assert_eq!(payload.deposit().unwrap(), 1_000_000); + } + + #[test] + fn settlement_response_omits_none() { + let resp = UptoSettlementResponse { + success: true, + error_reason: None, + payer: Some("Payer".to_string()), + transaction: "sig".to_string(), + network: "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1".to_string(), + amount: "500000".to_string(), + }; + let json = serde_json::to_string(&resp).unwrap(); + assert!(!json.contains("errorReason")); + assert!(json.contains("\"amount\":\"500000\"")); + } +} diff --git a/rust/crates/x402/src/protocol/schemes/upto/verify.rs b/rust/crates/x402/src/protocol/schemes/upto/verify.rs new file mode 100644 index 000000000..1bdf684cc --- /dev/null +++ b/rust/crates/x402/src/protocol/schemes/upto/verify.rs @@ -0,0 +1,194 @@ +//! Pure (no-RPC) verification for the x402 `upto` scheme. +//! +//! These checks run at the envelope level: amounts, time window, profile, and +//! operator binding. The on-chain binding (channel deposit/payee/mint/status) +//! is confirmed by the server after it broadcasts and confirms the `open` +//! transaction — see `server::upto`. + +use crate::error::Error; + +use super::types::{UptoPayload, UptoRequirements, PROFILE_PAYMENT_CHANNEL}; + +/// Scheme-specific error string for an over-ceiling settlement. +pub const ERR_SETTLEMENT_EXCEEDS_AMOUNT: &str = "invalid_upto_svm_payload_settlement_exceeds_amount"; + +/// Verify an `upto` payload against the route's pinned requirements. +/// +/// `operator` is the server's facilitator key (base58); `now` is the current +/// Unix time in seconds. Returns `Ok(())` when the authorization is valid for +/// the ceiling — the actual charge is validated later by +/// [`assert_settlement_within_ceiling`]. +pub fn verify_upto_payload( + payload: &UptoPayload, + requirements: &UptoRequirements, + operator: &str, + now: i64, +) -> Result<(), Error> { + if payload.profile != PROFILE_PAYMENT_CHANNEL { + return Err(Error::InvalidPayloadType(payload.profile.clone())); + } + if !requirements + .extra + .profiles + .iter() + .any(|p| p == &payload.profile) + { + return Err(Error::Other(format!( + "profile {} not advertised by the server", + payload.profile + ))); + } + + let max = requirements.max_amount()?; + let signed_max = payload.max_amount()?; + if signed_max != max { + return Err(Error::AmountMismatch { + expected: max.to_string(), + actual: signed_max.to_string(), + }); + } + + let deposit = payload.deposit()?; + if deposit < max { + return Err(Error::Other(format!( + "channel deposit {deposit} is below the authorized maximum {max}" + ))); + } + + if now < payload.valid_after { + return Err(Error::Other(format!( + "authorization not yet active (validAfter {} > now {now})", + payload.valid_after + ))); + } + if now > payload.expires_at { + return Err(Error::Other(format!( + "authorization expired (expiresAt {} < now {now})", + payload.expires_at + ))); + } + + if requirements.extra.facilitator != operator { + return Err(Error::Other( + "requirement facilitator does not match this operator".to_string(), + )); + } + if payload.authorized_signer != operator { + return Err(Error::Other( + "voucher authorized_signer must be the operator for the payment-channel profile" + .to_string(), + )); + } + + Ok(()) +} + +/// Enforce `actual ≤ max` at settlement. +pub fn assert_settlement_within_ceiling(actual: u64, max: u64) -> Result<(), Error> { + if actual > max { + return Err(Error::Other(ERR_SETTLEMENT_EXCEEDS_AMOUNT.to_string())); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::protocol::schemes::upto::types::{UptoExtra, UptoRequirements}; + + const OPERATOR: &str = "9xQeWvG816bUx9EPjHmaT23yvVM2ZWbrrpZb9PusVFin"; + + fn requirements() -> UptoRequirements { + UptoRequirements { + scheme: "upto".to_string(), + network: "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1".to_string(), + amount: "1000000".to_string(), + asset: "USDC".to_string(), + pay_to: "CXhrFZJLKqjzmP3sjYLcF4dTeXWKCy9e2SXXZ2Yo6MPY".to_string(), + max_timeout_seconds: 300, + extra: UptoExtra { + profiles: vec![PROFILE_PAYMENT_CHANNEL.to_string()], + decimals: Some(6), + token_program: None, + facilitator: OPERATOR.to_string(), + program_id: None, + recent_blockhash: None, + valid_after: None, + }, + } + } + + fn payload() -> UptoPayload { + UptoPayload { + profile: PROFILE_PAYMENT_CHANNEL.to_string(), + from: "Payer1111111111111111111111111111111111111".to_string(), + max_amount: "1000000".to_string(), + expires_at: 4_102_444_800, + valid_after: 0, + nonce: "n-1".to_string(), + channel_id: "Chan1111111111111111111111111111111111111".to_string(), + deposit: "1000000".to_string(), + authorized_signer: OPERATOR.to_string(), + open_transaction: Some("tx".to_string()), + signature: None, + } + } + + #[test] + fn accepts_valid_payload() { + assert!(verify_upto_payload(&payload(), &requirements(), OPERATOR, 1000).is_ok()); + } + + #[test] + fn rejects_wrong_profile() { + let mut p = payload(); + p.profile = "permit".to_string(); + assert!(matches!( + verify_upto_payload(&p, &requirements(), OPERATOR, 1000), + Err(Error::InvalidPayloadType(_)) + )); + } + + #[test] + fn rejects_max_mismatch() { + let mut p = payload(); + p.max_amount = "999999".to_string(); + assert!(matches!( + verify_upto_payload(&p, &requirements(), OPERATOR, 1000), + Err(Error::AmountMismatch { .. }) + )); + } + + #[test] + fn rejects_deposit_below_ceiling() { + let mut p = payload(); + p.deposit = "500000".to_string(); + assert!(verify_upto_payload(&p, &requirements(), OPERATOR, 1000).is_err()); + } + + #[test] + fn rejects_expired_and_not_yet_active() { + let mut p = payload(); + p.expires_at = 500; + assert!(verify_upto_payload(&p, &requirements(), OPERATOR, 1000).is_err()); + + let mut p = payload(); + p.valid_after = 2000; + assert!(verify_upto_payload(&p, &requirements(), OPERATOR, 1000).is_err()); + } + + #[test] + fn rejects_non_operator_signer() { + let mut p = payload(); + p.authorized_signer = "Payer1111111111111111111111111111111111111".to_string(); + assert!(verify_upto_payload(&p, &requirements(), OPERATOR, 1000).is_err()); + } + + #[test] + fn settlement_ceiling_enforced() { + assert!(assert_settlement_within_ceiling(999_999, 1_000_000).is_ok()); + assert!(assert_settlement_within_ceiling(1_000_000, 1_000_000).is_ok()); + let err = assert_settlement_within_ceiling(1_000_001, 1_000_000).unwrap_err(); + assert!(err.to_string().contains(ERR_SETTLEMENT_EXCEEDS_AMOUNT)); + } +} diff --git a/rust/crates/x402/src/server/exact.rs b/rust/crates/x402/src/server/exact.rs index 7775ea846..0621e8f9f 100644 --- a/rust/crates/x402/src/server/exact.rs +++ b/rust/crates/x402/src/server/exact.rs @@ -699,47 +699,13 @@ fn managed_signers_for_requirements( .unwrap_or_else(|| Ok(Vec::new())) } -fn parse_units(amount: &str, decimals: u8) -> Result { - if amount.is_empty() { - return Err(Error::Other("amount is required".into())); - } - if amount.starts_with('-') { - return Err(Error::Other("amount must be non-negative".into())); - } - - let mut parts = amount.split('.'); - let whole = parts.next().unwrap_or_default(); - let fractional = parts.next(); - if parts.next().is_some() { - return Err(Error::Other(format!("Invalid amount: {amount}"))); - } - - if !whole.chars().all(|c| c.is_ascii_digit()) { - return Err(Error::Other(format!("Invalid amount: {amount}"))); - } - - let fractional = fractional.unwrap_or_default(); - if !fractional.chars().all(|c| c.is_ascii_digit()) { - return Err(Error::Other(format!("Invalid amount: {amount}"))); - } - if fractional.len() > decimals as usize { - return Err(Error::Other(format!( - "Too many decimal places for amount: {amount}" - ))); - } - - let mut units = whole.to_string(); - units.push_str(fractional); - while units.len() < whole.len() + decimals as usize { - units.push('0'); - } - - let normalized = units.trim_start_matches('0'); - Ok(if normalized.is_empty() { - "0".to_string() - } else { - normalized.to_string() - }) +/// Convert a human-readable amount to base units. +/// +/// Delegates to [`solana_pay_core::parse_units`] — the audited shared +/// implementation used by both protocol crates — mapping its error into this +/// crate's error type. +pub(crate) fn parse_units(amount: &str, decimals: u8) -> Result { + solana_pay_core::parse_units(amount, decimals).map_err(Into::into) } #[cfg(test)] diff --git a/rust/crates/x402/src/server/mod.rs b/rust/crates/x402/src/server/mod.rs index ced79d997..db34fbc6d 100644 --- a/rust/crates/x402/src/server/mod.rs +++ b/rust/crates/x402/src/server/mod.rs @@ -1,6 +1,8 @@ pub mod exact; +pub mod upto; pub use exact::{ check_network_blockhash, Config, ExactOptions, VerifiedExactPayment, LOCALNET_NETWORK, SURFPOOL_BLOCKHASH_PREFIX, X402, }; +pub use upto::{UptoConfig, VerifiedUptoOpen, X402Upto}; diff --git a/rust/crates/x402/src/server/upto.rs b/rust/crates/x402/src/server/upto.rs new file mode 100644 index 000000000..44ea9db4c --- /dev/null +++ b/rust/crates/x402/src/server/upto.rs @@ -0,0 +1,436 @@ +//! Server-side handler for the x402 `upto` scheme (payment-channel profile). +//! +//! Flow (single HTTP round-trip, handler-determined amount): +//! +//! 1. [`X402Upto::upto`] advertises a 402 with the authorized maximum and the +//! operator's facilitator key. +//! 2. [`X402Upto::verify_open`] validates the client authorization, broadcasts +//! the channel `open` (co-signing as fee payer), confirms it, and reads the +//! channel state back to bind deposit/payee/mint/signer on-chain. +//! 3. The route handler runs and determines the actual metered amount. +//! 4. [`X402Upto::settle_actual`] signs a single operator voucher for the actual +//! amount and submits `settle_and_finalize` + `distribute`, refunding +//! `deposit − actual` to the payer. + +use std::str::FromStr; +use std::sync::Arc; +use std::time::{SystemTime, UNIX_EPOCH}; + +use solana_keychain::SolanaSigner; +use solana_message::Message; +use solana_pubkey::Pubkey; +use solana_rpc_client::rpc_client::RpcClient; +use solana_signature::Signature; +use solana_transaction::versioned::VersionedTransaction; +use solana_transaction::Transaction; + +use solana_pay_core::payment_channels as pc; +use solana_pay_core::payment_channels::generated::accounts::Channel; + +use crate::error::Error; +use crate::protocol::schemes::exact::{ + caip2_network_for_cluster, default_rpc_url, default_token_program_for_currency, + resolve_stablecoin_mint, ResourceInfo, +}; +use crate::protocol::schemes::upto::{ + assert_settlement_within_ceiling, verify_upto_payload, UptoExtra, UptoRequiredEnvelope, + UptoRequirements, UptoSettlementResponse, UptoSignatureEnvelope, PROFILE_PAYMENT_CHANNEL, + UPTO_SCHEME, +}; +use crate::{PAYMENT_REQUIRED_HEADER, PAYMENT_RESPONSE_HEADER, X402_VERSION_V2}; + +/// `ChannelStatus::Open` discriminant in the generated client. +const CHANNEL_STATUS_OPEN: u8 = 0; + +/// Server configuration for the Solana x402 `upto` scheme. +#[derive(Clone)] +pub struct UptoConfig { + /// Base58 recipient (payTo) of the metered charge. + pub recipient: String, + /// Currency symbol (`"USDC"`) or mint address. + pub currency: String, + /// Token decimals. + pub decimals: u8, + /// Solana cluster: `mainnet-beta`, `devnet`, or `localnet`. + pub cluster: String, + /// RPC URL override (defaults per cluster). + pub rpc_url: Option, + /// Resource identifier for the 402 challenge. + pub resource: String, + /// Human-readable description. + pub description: Option, + /// Completion window in seconds. + pub max_timeout_seconds: u64, + /// Token program override. + pub token_program: Option, + /// Channel program id override (defaults to the canonical deployment). + pub program_id: Option, + /// Operator signer — co-signs the open as fee payer and signs settlement + /// vouchers + transactions. Its pubkey is the advertised facilitator. + pub operator_signer: Arc, +} + +/// A confirmed, on-chain-verified channel open, carried from +/// [`X402Upto::verify_open`] to [`X402Upto::settle_actual`]. +#[derive(Debug, Clone)] +pub struct VerifiedUptoOpen { + pub channel_id: Pubkey, + pub payer: Pubkey, + pub mint: Pubkey, + pub token_program: Pubkey, + pub program_id: Pubkey, + pub deposit: u64, + pub max_amount: u64, + pub expires_at: i64, + pub network: String, +} + +/// Server-side payment handler for the Solana x402 `upto` scheme. +#[derive(Clone)] +pub struct X402Upto { + rpc: Arc, + config: UptoConfig, + operator: Pubkey, +} + +fn now_unix() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0) +} + +impl X402Upto { + pub fn new(config: UptoConfig) -> Result { + if config.recipient.is_empty() { + return Err(Error::Other("recipient is required".into())); + } + Pubkey::from_str(&config.recipient) + .map_err(|e| Error::Other(format!("Invalid recipient pubkey: {e}")))?; + + let operator = config.operator_signer.pubkey(); + let rpc_url = config + .rpc_url + .clone() + .unwrap_or_else(|| default_rpc_url(&config.cluster).to_string()); + + Ok(Self { + rpc: Arc::new(RpcClient::new(rpc_url)), + config, + operator, + }) + } + + /// The operator/facilitator public key (base58). + pub fn operator(&self) -> String { + pc::pubkey_string(&self.operator) + } + + fn program_id(&self) -> Result { + match &self.config.program_id { + Some(value) => Pubkey::from_str(value) + .map_err(|e| Error::Other(format!("invalid programId: {e}"))), + None => Ok(pc::default_program_id()), + } + } + + fn mint(&self) -> Result { + let mint = resolve_stablecoin_mint(&self.config.currency, Some(&self.config.cluster)) + .ok_or_else(|| Error::Other("upto requires an SPL token (not native SOL)".into()))?; + Pubkey::from_str(mint).map_err(|e| Error::Other(format!("invalid mint: {e}"))) + } + + fn token_program(&self) -> Result { + let tp = self.config.token_program.clone().unwrap_or_else(|| { + default_token_program_for_currency(&self.config.currency, Some(&self.config.cluster)) + .to_string() + }); + Pubkey::from_str(&tp).map_err(|e| Error::Other(format!("invalid token program: {e}"))) + } + + /// Build the `upto` payment requirement for the given authorized maximum. + /// + /// `max_amount` is a human-decimal amount (e.g. `"0.10"`), converted to base + /// units using the configured decimals — same convention as the `exact` + /// scheme, so the gate passes one dollar string everywhere. + pub fn upto_requirements(&self, max_amount: &str) -> Result { + let mint = self.mint()?; + let token_program = self.token_program()?; + let base_units = crate::server::exact::parse_units(max_amount, self.config.decimals)?; + let recent_blockhash = self + .rpc + .get_latest_blockhash() + .ok() + .map(|hash| hash.to_string()); + + Ok(UptoRequirements { + scheme: UPTO_SCHEME.to_string(), + network: caip2_network_for_cluster(&self.config.cluster).to_string(), + amount: base_units, + asset: pc::pubkey_string(&mint), + pay_to: self.config.recipient.clone(), + max_timeout_seconds: self.config.max_timeout_seconds, + extra: UptoExtra { + profiles: vec![PROFILE_PAYMENT_CHANNEL.to_string()], + decimals: Some(self.config.decimals), + token_program: Some(pc::pubkey_string(&token_program)), + facilitator: self.operator(), + program_id: Some(pc::pubkey_string(&self.program_id()?)), + recent_blockhash, + valid_after: None, + }, + }) + } + + /// Build the full `PAYMENT-REQUIRED` envelope for an `upto` challenge. + pub fn upto(&self, max_amount: &str) -> Result { + let requirement = self.upto_requirements(max_amount)?; + let resource = (!self.config.resource.is_empty()).then(|| ResourceInfo { + url: self.config.resource.clone(), + description: self.config.description.clone(), + mime_type: None, + }); + Ok(UptoRequiredEnvelope { + x402_version: X402_VERSION_V2, + resource, + accepts: vec![requirement], + error: None, + }) + } + + /// `(header-name, base64-value)` for the `upto` 402 challenge. + pub fn payment_required_header(&self, max_amount: &str) -> Result<(String, String), Error> { + let envelope = self.upto(max_amount)?; + let json = serde_json::to_string(&envelope) + .map_err(|e| Error::InvalidPaymentRequired(e.to_string()))?; + Ok(( + PAYMENT_REQUIRED_HEADER.to_string(), + base64::Engine::encode(&base64::engine::general_purpose::STANDARD, json.as_bytes()), + )) + } + + /// `(header-name, base64-value)` for the `PAYMENT-RESPONSE` settlement + /// result, ready to set on the route's response. + pub fn settlement_header( + &self, + settlement: &UptoSettlementResponse, + ) -> Result<(String, String), Error> { + let json = serde_json::to_string(settlement) + .map_err(|e| Error::Other(format!("settlement serialization failed: {e}")))?; + Ok(( + PAYMENT_RESPONSE_HEADER.to_string(), + base64::Engine::encode(&base64::engine::general_purpose::STANDARD, json.as_bytes()), + )) + } + + /// Decode a `PAYMENT-SIGNATURE` header into an `upto` envelope. + pub fn parse_payment_signature(&self, header: &str) -> Result { + let decoded = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, header) + .map_err(|e| Error::InvalidPaymentRequired(e.to_string()))?; + let envelope: UptoSignatureEnvelope = serde_json::from_slice(&decoded) + .map_err(|e| Error::InvalidPaymentRequired(e.to_string()))?; + if envelope.scheme != UPTO_SCHEME { + return Err(Error::InvalidPayloadType(envelope.scheme)); + } + Ok(envelope) + } + + /// Verify an `upto` authorization for a route capped at `max_amount`, + /// broadcast and confirm the channel `open`, and bind its on-chain state. + pub async fn verify_open( + &self, + header: &str, + max_amount: &str, + ) -> Result { + let envelope = self.parse_payment_signature(header)?; + let requirements = self.upto_requirements(max_amount)?; + let payload = &envelope.payload; + + verify_upto_payload(payload, &requirements, &self.operator(), now_unix())?; + + let program_id = self.program_id()?; + let expected_mint = self.mint()?; + let expected_payee = Pubkey::from_str(&self.config.recipient) + .map_err(|e| Error::Other(format!("invalid recipient: {e}")))?; + let channel_id = Pubkey::from_str(&payload.channel_id) + .map_err(|e| Error::Other(format!("invalid channelId: {e}")))?; + let payer = Pubkey::from_str(&payload.from) + .map_err(|e| Error::Other(format!("invalid payer: {e}")))?; + let max = payload.max_amount()?; + + // Broadcast the client-signed open (pull). Push (already broadcast) is + // not yet supported; require the transaction. + let open_tx_b64 = payload.open_transaction.as_deref().ok_or_else(|| { + Error::Other("payment-channel profile requires openTransaction (pull)".to_string()) + })?; + let mut tx = decode_transaction(open_tx_b64)?; + self.cosign_fee_payer(&mut tx).await?; + self.rpc + .send_and_confirm_transaction(&tx) + .map_err(|e| Error::Rpc(format!("open broadcast failed: {e}")))?; + + // Read the confirmed channel state and bind it. + let channel = self.fetch_channel(&channel_id)?; + if channel.status != CHANNEL_STATUS_OPEN { + return Err(Error::Other("channel is not open after broadcast".to_string())); + } + if pc::from_address(&channel.mint) != expected_mint { + return Err(Error::MintMismatch { + expected: pc::pubkey_string(&expected_mint), + actual: pc::pubkey_string(&pc::from_address(&channel.mint)), + }); + } + if pc::from_address(&channel.payee) != expected_payee { + return Err(Error::RecipientMismatch { + expected: pc::pubkey_string(&expected_payee), + actual: pc::pubkey_string(&pc::from_address(&channel.payee)), + }); + } + if pc::from_address(&channel.authorized_signer) != self.operator { + return Err(Error::Other( + "channel authorized_signer is not the operator".to_string(), + )); + } + if channel.deposit < max { + return Err(Error::Other(format!( + "on-chain deposit {} below authorized maximum {max}", + channel.deposit + ))); + } + + Ok(VerifiedUptoOpen { + channel_id, + payer, + mint: expected_mint, + token_program: self.token_program()?, + program_id, + deposit: channel.deposit, + max_amount: max, + expires_at: payload.expires_at, + network: requirements.network, + }) + } + + /// Settle the actual metered amount (`actual ≤ max`) against a verified + /// open: operator-signed voucher, `settle_and_finalize` + `distribute`, + /// refunding the remainder. `actual == 0` still finalizes (full refund). + pub async fn settle_actual( + &self, + open: &VerifiedUptoOpen, + actual: u64, + ) -> Result { + assert_settlement_within_ceiling(actual, open.max_amount)?; + + let mut instructions = if actual == 0 { + pc::build_settle_and_finalize_instructions( + &self.operator, + &open.channel_id, + &self.operator, + None, + 0, + open.expires_at, + &open.program_id, + )? + } else { + let voucher_bytes = + pc::voucher_message_bytes(&open.channel_id, actual, open.expires_at)?; + let sig_bytes: [u8; 64] = self + .config + .operator_signer + .sign_message(&voucher_bytes) + .await + .map_err(|e| Error::Other(format!("voucher signing failed: {e}")))? + .into(); + pc::build_settle_and_finalize_instructions( + &self.operator, + &open.channel_id, + &self.operator, + Some(&sig_bytes), + actual, + open.expires_at, + &open.program_id, + )? + }; + + instructions.push(pc::build_distribute_instruction( + &open.channel_id, + &open.payer, + &Pubkey::from_str(&self.config.recipient) + .map_err(|e| Error::Other(format!("invalid recipient: {e}")))?, + &pc::treasury_owner(), + &open.mint, + &[], + &open.token_program, + &open.program_id, + )); + + let blockhash = self + .rpc + .get_latest_blockhash() + .map_err(|e| Error::Rpc(format!("blockhash fetch failed: {e}")))?; + let message = Message::new_with_blockhash(&instructions, Some(&self.operator), &blockhash); + let mut tx = Transaction::new_unsigned(message); + self.config + .operator_signer + .sign_transaction(&mut tx) + .await + .map_err(|e| Error::Other(format!("settle signing failed: {e}")))?; + + let signature = self + .rpc + .send_and_confirm_transaction(&tx) + .map_err(|e| Error::Rpc(format!("settle broadcast failed: {e}")))?; + + Ok(UptoSettlementResponse { + success: true, + error_reason: None, + payer: Some(pc::pubkey_string(&open.payer)), + transaction: signature.to_string(), + network: open.network.clone(), + amount: actual.to_string(), + }) + } + + /// Co-sign the fee-payer (operator) slot of a partially-signed transaction. + async fn cosign_fee_payer(&self, tx: &mut VersionedTransaction) -> Result<(), Error> { + let account_keys = tx.message.static_account_keys(); + let idx = account_keys + .iter() + .position(|k| k == &self.operator) + .ok_or_else(|| Error::Other("operator (fee payer) not in open transaction".into()))?; + if idx >= tx.signatures.len() { + return Err(Error::Other( + "operator is not a required signer in the open transaction".into(), + )); + } + let msg_data = tx.message.serialize(); + let sig_bytes: [u8; 64] = self + .config + .operator_signer + .sign_message(&msg_data) + .await + .map_err(|e| Error::Other(format!("fee payer signing failed: {e}")))? + .into(); + tx.signatures[idx] = Signature::from(sig_bytes); + Ok(()) + } + + fn fetch_channel(&self, channel_id: &Pubkey) -> Result { + let data = self + .rpc + .get_account_data(channel_id) + .map_err(|e| Error::Rpc(format!("channel account fetch failed: {e}")))?; + Channel::from_bytes(&data) + .map_err(|e| Error::Other(format!("channel decode failed: {e}"))) + } +} + +/// Decode a base64 (standard) bincode transaction, accepting legacy and v0. +fn decode_transaction(b64: &str) -> Result { + let bytes = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, b64) + .map_err(|e| Error::Other(format!("invalid base64 transaction: {e}")))?; + bincode::deserialize::(&bytes) + .map(VersionedTransaction::from) + .or_else(|_| bincode::deserialize::(&bytes)) + .map_err(|e| Error::Other(format!("invalid transaction: {e}"))) +} diff --git a/rust/specs/schemes/upto/scheme_upto_svm.md b/rust/specs/schemes/upto/scheme_upto_svm.md new file mode 100644 index 000000000..b7349cd36 --- /dev/null +++ b/rust/specs/schemes/upto/scheme_upto_svm.md @@ -0,0 +1,265 @@ +# SVM `upto` Scheme: Usage-Based Payment Authorization on Solana + +> Status: **draft**. Companion to the network-agnostic +> [`scheme_upto.md`](https://github.com/x402-foundation/x402/blob/main/specs/schemes/upto/scheme_upto.md) +> and the EVM profile +> [`scheme_upto_evm.md`](https://github.com/x402-foundation/x402/blob/main/specs/schemes/upto/scheme_upto_evm.md). +> This document specifies how the `upto` scheme is realized on Solana Virtual +> Machine (SVM) networks. + +## 1. Purpose + +`upto` lets a client authorize a **maximum** amount while the server settles for +**actual** usage (`actual ≤ max`), with the final charge determined after the +resource is consumed. Same target use cases as the generic spec: LLM token +billing, per-byte metering, dynamic compute pricing. + +The hard part on Solana: a normal signed transfer commits to an *exact* amount +and exact instruction data, so the server cannot lower the amount after the +client signs without invalidating the signature. `upto` therefore requires an +authorization that commits the client to a **ceiling** and lets the +**facilitator** choose the actual amount at settlement. SVM offers two +mechanisms that decouple "client authorizes a max" from "facilitator executes +the actual," expressed here as two **profiles**. + +## 2. Mapping the five core requirements to SVM + +| Requirement (generic spec) | EVM mechanism | SVM mechanism (this spec) | +|---|---|---| +| Single-use authorization | Permit2 nonce | `payment-channel`: `finalize` makes the channel terminal (`ChannelStatus::Finalized`). `permit`: a one-shot nonce PDA consumed at settle. | +| Time-bound validity (`validAfter`, `deadline`) | Permit2 `deadline` + witness `validAfter` | Authorization `validAfter` + `expiresAt`; `expiresAt` is also signed into the on-chain voucher / permit message. | +| Recipient binding | Permit2 witness `to` | `payment-channel`: `channel.payee` + `distribution_hash` fixed at open. `permit`: `payTo` signed into the permit message and enforced by the program. | +| Maximum amount enforcement | `permitted.amount` ceiling | `payment-channel`: on-chain `deposit` ceiling, `cumulative_amount ≤ deposit`. `permit`: `maxAmount` signed into the permit message, `actual ≤ maxAmount` enforced by the program. | +| Phase-dependent amount semantics | `amount` = max at verify, actual at settle | Identical. `amount` in `PaymentRequirements` is the max during verification and the actual charge during settlement. | + +The facilitator MUST always verify against the client-signed ceiling, never +against the settlement-time `amount`. + +## 3. Profiles + +A server advertises one or more profiles in `extra.profiles`. The client picks +one and signals it back in the payload's `profile` field. + +### 3.1 `payment-channel` (normative, v1) + +Backed by the on-chain payment-channels program (a `pay-kit`-controlled program; +program id `GuoKrzaBiZnW5DvJ3yZVE7xHqbcBvaX9SH6P6Cn9gNvc` on the current +deployment). The escrow **deposit is the ceiling**; a single signed voucher +settles the actual amount; `finalize` closes the channel and refunds the unused +remainder to the payer in the same transaction. + +Strengths: every requirement is enforced on-chain by a program we control. The +facilitator cannot overcharge (capped by `deposit`), cannot redirect funds +(`channel.payee` / `distribution_hash` are fixed at open), and cannot replay +(channel is terminal after `finalize`). + +Cost: the client locks `max` in escrow for the lifetime of the request, and the +flow needs two on-chain transactions (open, then settle-and-finalize). Channel +rent is reclaimed at finalize. + +### 3.2 `permit` (optional, v2 — requires a new program) + +Backed by an Ed25519-signed one-shot delegated transfer — the closest analog to +EVM's `permitWitnessTransferFrom`. The client `approve`s a program-owned +delegate on its token account (reusable across requests, like a one-time Permit2 +approval), then signs an off-chain **permit message** committing to a ceiling and +a witness. At settlement the facilitator submits one transaction; a small +on-chain program verifies the Ed25519 signature (via the Ed25519 precompile + +instructions sysvar, the same pattern the payment-channels program already uses +for vouchers), checks the witness, enforces `actual ≤ maxAmount`, transfers the +actual amount to `payTo`, and consumes a nonce PDA for replay protection. + +Strengths: no escrow lockup; a single settlement transaction; reusable approval. +Matches EVM ergonomics. + +Cost: requires a new, audited on-chain program. Until that program ships and is +audited, `payment-channel` is the only normative profile. + +> Multi-delegator is explicitly **not** an `upto` backend. Its `FixedDelegation` +> is a standing, multi-pull cap that binds the *delegatee* (operator), not the +> final `payTo`, and offers no on-chain single-use guarantee. It is the right +> primitive for `session`/streaming, not for one-shot `upto`. Using it here +> would downgrade the recipient-binding and single-use requirements to +> "trust the facilitator," defeating the scheme. + +## 4. Wire format + +`upto` reuses the x402 v2 transport: a `402` response carries `PAYMENT-REQUIRED`; +the paid retry carries `PAYMENT-SIGNATURE`; the response carries +`PAYMENT-RESPONSE`. Only the `scheme` value and the payload shape change relative +to `exact`. + +### 4.1 `PaymentRequirements` (in `PAYMENT-REQUIRED.accepts[]`) + +| Field | Type | Required | Notes | +|---|---|---|---| +| `scheme` | string | ✓ | `"upto"` | +| `network` | string | ✓ | CAIP-2, e.g. `solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp` (mainnet), `solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1` (devnet) | +| `amount` | string | ✓ | **Phase-dependent**: max authorized at verification; actual charge at settlement. Base units. | +| `asset` | string | ✓ | SPL mint address (or `"SOL"` — discouraged for `upto`; stablecoins recommended) | +| `payTo` | string | ✓ | Base58 recipient | +| `maxTimeoutSeconds` | number | ✓ | Completion window; also the basis for the authorization `expiresAt` | +| `extra` | object | ✓ | See below | + +`extra`: + +| Field | Type | Required | Notes | +|---|---|---|---| +| `profiles` | string[] | ✓ | Subset of `["payment-channel","permit"]`, in server preference order | +| `decimals` | number | ✓ | Token decimals | +| `tokenProgram` | string | ✓ | `Tokenkeg…` or `TokenzQ…` (Token-2022) | +| `facilitator` | string | ✓ | Base58 operator/facilitator key authorized to settle | +| `programId` | string | – | Channel/permit program id; defaults to the canonical deployment | +| `recentBlockhash` | string | – | Pre-fetched blockhash so the client can build setup transactions without an extra RPC round-trip | +| `validAfter` | number | – | Earliest activation time (Unix seconds); default = now | +| `splits` | `{recipient,bps}[]` | – | `payment-channel` only; distributed at finalize | + +### 4.2 `UptoPayload` (in `PAYMENT-SIGNATURE.payload`) + +Common fields: + +| Field | Type | Notes | +|---|---|---| +| `profile` | string | `"payment-channel"` or `"permit"` | +| `from` | string | Payer wallet (base58) | +| `maxAmount` | string | The signed ceiling (base units). MUST equal verification-phase `amount`. | +| `expiresAt` | number | Deadline (Unix seconds); signed into the on-chain message | +| `validAfter` | number | Activation time (Unix seconds) | +| `nonce` | string | Unique per authorization | + +`payment-channel` profile adds: + +| Field | Type | Notes | +|---|---|---| +| `channelId` | string | Channel PDA (base58) | +| `deposit` | string | On-chain escrow = the ceiling; MUST equal `maxAmount` | +| `authorizedSigner` | string | The **operator/facilitator** key (base58). The operator — not the client — signs the single settlement voucher (see §5 Phase 2). | +| `openTransaction` | string | Base64 signed `open` transaction for the facilitator to broadcast if the channel is not yet open (pull-style). Omitted if the client already broadcast `open` (push-style) and `signature` is set. | +| `signature` | string | Base58 signature of the broadcast `open` transaction (push) | + +> The voucher is **not** carried in the payload. Because the actual amount is +> only known after the resource is consumed, and the client's protection is the +> on-chain `deposit` ceiling plus the fixed `payee`, the operator (set as the +> channel's `authorizedSigner` at open) signs the single voucher for the metered +> amount at settlement. This keeps `upto` a single HTTP round-trip with a +> handler-determined amount, matching the EVM trust model where the server fills +> in `actual ≤ ceiling`. + +`permit` profile adds: + +| Field | Type | Notes | +|---|---|---| +| `tokenAccount` | string | Payer ATA being delegated (base58) | +| `approveTransaction` | string | Base64 signed `approve` transaction for the facilitator to broadcast if no sufficient delegation exists | +| `permitSignature` | string | Base58 Ed25519 signature by `from` over the permit message | + +Permit message (Borsh, signed by `from`): + +``` +nonce_pda: [u8;32] // PDA(["upto", from, mint, nonce]) +mint: [u8;32] +pay_to: [u8;32] +facilitator:[u8;32] +max_amount: u64 (le) +valid_after: i64 (le) +expires_at: i64 (le) +``` + +### 4.3 `SettlementResponse` (in `PAYMENT-RESPONSE`) + +| Field | Type | Required | Notes | +|---|---|---|---| +| `success` | boolean | ✓ | | +| `errorReason` | string | – | Omitted on success | +| `payer` | string | – | `from` | +| `transaction` | string | ✓ | Settle (or settle-and-finalize) signature; empty string if the actual charge is `0` | +| `network` | string | ✓ | CAIP-2 | +| `amount` | string | ✓ | Actual base units charged (may be `0`) | + +## 5. Phases + +### Phase 1 — Setup (gas/approval) + +- `payment-channel`: the client builds an `open` transaction depositing + `maxAmount`. Push: the client broadcasts it and sends `signature`. Pull: the + client sends `openTransaction` and the facilitator broadcasts it (the + facilitator may co-sign as fee payer, matching `exact`'s fee-sponsorship). +- `permit`: the client ensures a program-owned delegate is approved on + `tokenAccount` for at least `maxAmount`. If absent, the facilitator returns + `412 Precondition Failed` with `errorReason = APPROVAL_REQUIRED` and the + required `approve` transaction, mirroring EVM's `PERMIT2_ALLOWANCE_REQUIRED`. + +### Phase 2 — Authorization signature + +- `payment-channel` (normative v1): the client's signature on the `open` + transaction **is** the authorization — it commits the `deposit` ceiling, the + `payee`, and the `mint`, with `authorizedSigner` set to the **operator**. The + client does not sign a voucher. After metering, the operator signs the single + voucher for `cumulativeAmount = actual` (Ed25519 over + `channel_id ‖ cumulative_amount_le ‖ expires_at_le`) and settles it. The + client is protected by the on-chain ceiling (the operator cannot exceed + `deposit`) and the fixed `payee` (the operator cannot redirect) — the same + trust model as EVM `upto`, in a single round-trip. +- `permit`: sign the permit message with `from`. + +### Phase 3 — Verification (before serving the resource) + +The facilitator MUST, in order: + +1. Confirm `payload.maxAmount` equals verification-phase `requirements.amount`. +2. Confirm `network`, `asset` (mint), `tokenProgram`, and `payTo` match the requirements. +3. Confirm `facilitator` in `extra` is this server's key. +4. `payment-channel`: confirm the channel exists (or the `openTransaction` is valid and broadcastable), `channel.deposit ≥ maxAmount`, `channel.payee` and `distribution_hash` match `payTo`/`splits`, `channel.status == Open`, and `channel.mint == asset`. `permit`: recover `permitSignature`, confirm signer is `from`; confirm the delegate allowance and the payer token balance both cover `maxAmount`. +5. Validate `validAfter ≤ now ≤ expiresAt`. +6. Simulate the settlement instruction(s). + +On failure the server returns `402` (or `412` for the approval/open +precondition) without serving the resource. + +### Phase 4 — Settlement (after serving the resource) + +At settlement `paymentRequirements.amount` carries the **actual** metered amount. +The facilitator MUST: + +1. Re-verify the authorization against the **signed ceiling** + (`maxAmount` / `deposit`), NOT against `paymentRequirements.amount`. +2. Assert `paymentRequirements.amount ≤ maxAmount`. On violation, fail with + `invalid_upto_svm_payload_settlement_exceeds_amount`. +3. Execute: + - `payment-channel`: `settle_and_finalize` with the single voucher for the + actual cumulative amount, then `distribute` to `payTo`/`splits`. Finalize + refunds `deposit − actual` to the payer and closes the channel. + - `permit`: submit the verify+settle transaction; the program checks the + Ed25519 signature and witness, enforces `actual ≤ maxAmount`, transfers + `actual` to `payTo`, and consumes the nonce PDA. +4. A `0`-amount settlement requires no transfer. `payment-channel` still + finalizes (full refund, channel closed); `permit` still consumes the nonce. + `transaction` MAY be empty when no token movement occurred. + +## 6. Error codes + +Standard x402 codes apply. Scheme-specific: + +- `invalid_upto_svm_payload_settlement_exceeds_amount` — actual > signed ceiling. +- `APPROVAL_REQUIRED` (`permit`, with `412`) — delegate approval missing/insufficient. +- `CHANNEL_REQUIRED` (`payment-channel`, with `412`) — no open channel and no broadcastable `openTransaction`. + +## 7. Security properties + +- **No overcharge.** `payment-channel`: capped by on-chain `deposit`. `permit`: + capped by the signed `maxAmount` enforced inside the program. +- **No redirection.** `payment-channel`: `channel.payee`/`distribution_hash` + fixed at open. `permit`: `payTo` is in the signed message and checked on-chain. +- **No replay.** `payment-channel`: terminal `ChannelStatus::Finalized` plus + monotonic `SettlementWatermarks.settled`. `permit`: one-shot nonce PDA. +- **Time-bounded.** `validAfter`/`expiresAt` checked off-chain at verify and + signed into the on-chain message so a stale authorization cannot settle. +- **Trust model.** As in the generic spec, the client trusts the server to meter + honestly within the ceiling. Everything above the ceiling, the destination, + and replay are enforced cryptographically/on-chain. + +## 8. Out of scope + +Multi-settlement streaming and recurring auto-pay are served by the `session` +and `subscription` intents, not `upto`. `upto` settles **at most once** per +authorization. diff --git a/typescript/packages/pay-kit/src/__tests__/x402.test.ts b/typescript/packages/pay-kit/src/__tests__/x402.test.ts new file mode 100644 index 000000000..7539ecf48 --- /dev/null +++ b/typescript/packages/pay-kit/src/__tests__/x402.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from 'vitest'; + +import { createX402ExactAdapter } from '../adapters/x402.js'; +import { Charge, X402Upto } from '../adapters/x402-upto.js'; +import { configure, type PayKitConfig } from '../config.js'; +import { Gate } from '../gate.js'; +import { usd } from '../price.js'; +import { gateDefaults } from '../pricing.js'; + +async function testConfig(): Promise { + return await configure({ mpp: { challengeBindingSecret: 'x402-test-secret' }, network: 'solana_localnet' }); +} + +function gateFor(config: PayKitConfig, amount = usd('0.10')): Gate { + return Gate.create({ amount, name: 'test' }, gateDefaults(config)); +} + +describe('x402 exact adapter', () => { + it('advertises a canonical x402 accepts entry', async () => { + const config = await testConfig(); + const adapter = createX402ExactAdapter(config); + const entry = await adapter.acceptsEntry(gateFor(config), new Request('http://localhost/r')); + + expect(entry.protocol).toBe('x402'); + expect(entry.scheme).toBe('exact'); + expect(entry.amount).toBe('100000'); // 0.10 USDC, 6 decimals + expect(entry.payTo).toBe(config.operator.recipient); + expect(typeof entry.network).toBe('string'); + expect(typeof entry.asset).toBe('string'); + expect((entry.extra as { feePayer?: string }).feePayer).toBe(config.operator.signer.pubkey); + }); + + it('detects the x402 payment header', async () => { + const config = await testConfig(); + const adapter = createX402ExactAdapter(config); + expect(adapter.detect(new Request('http://localhost/r'))).toBe(false); + expect(adapter.detect(new Request('http://localhost/r', { headers: { 'x-payment': 'abc' } }))).toBe(true); + expect(adapter.detect(new Request('http://localhost/r', { headers: { 'payment-signature': 'abc' } }))).toBe( + true, + ); + }); + + it('emits the PAYMENT-REQUIRED challenge header', async () => { + const config = await testConfig(); + const adapter = createX402ExactAdapter(config); + const headers = await adapter.challengeHeaders(gateFor(config), new Request('http://localhost/r')); + expect(typeof headers['payment-required']).toBe('string'); + expect(headers['payment-required'].length).toBeGreaterThan(0); + }); +}); + +describe('x402 upto engine', () => { + it('advertises an upto accepts entry with the facilitator binding', async () => { + const config = await testConfig(); + const upto = new X402Upto(config); + const [entry] = upto.accepts(usd('1.00')); + + expect(entry.scheme).toBe('upto'); + expect(entry.amount).toBe('1000000'); // 1.00 USDC ceiling + expect(entry.payTo).toBe(config.operator.recipient); + expect((entry.extra as { facilitatorAddress?: string }).facilitatorAddress).toBe(config.operator.signer.pubkey); + expect((entry.extra as { feePayer?: string }).feePayer).toBe(config.operator.signer.pubkey); + }); + + it('detects the payment header and emits a challenge', async () => { + const config = await testConfig(); + const upto = new X402Upto(config); + expect(upto.detect(new Request('http://localhost/u'))).toBe(false); + expect(upto.detect(new Request('http://localhost/u', { headers: { 'x-payment': 'abc' } }))).toBe(true); + const headers = upto.challengeHeaders(usd('1.00'), new Request('http://localhost/u')); + expect(typeof headers['payment-required']).toBe('string'); + }); +}); + +describe('Charge meter', () => { + it('defaults to a zero settlement', () => { + expect(new Charge(1_000_000n).settledBaseUnits()).toBe(0n); + }); + + it('records the reported amount', () => { + const charge = new Charge(1_000_000n); + charge.charge(400_000n); + expect(charge.settledBaseUnits()).toBe(400_000n); + }); + + it('clamps above the ceiling and floors negatives', () => { + const overCharge = new Charge(1_000_000n); + overCharge.charge(2_000_000n); + expect(overCharge.settledBaseUnits()).toBe(1_000_000n); + + const negative = new Charge(1_000_000n); + negative.charge(-5); + expect(negative.settledBaseUnits()).toBe(0n); + }); + + it('accepts a plain number', () => { + const charge = new Charge(1_000_000n); + charge.charge(250_000); + expect(charge.settledBaseUnits()).toBe(250_000n); + }); +}); diff --git a/typescript/packages/pay-kit/src/adapters/x402-upto.ts b/typescript/packages/pay-kit/src/adapters/x402-upto.ts new file mode 100644 index 000000000..89a899d8d --- /dev/null +++ b/typescript/packages/pay-kit/src/adapters/x402-upto.ts @@ -0,0 +1,184 @@ +import { x402Facilitator } from '@x402/core/facilitator'; +import { + decodePaymentSignatureHeader, + encodePaymentRequiredHeader, + encodePaymentResponseHeader, +} from '@x402/core/http'; +import type { Network, PaymentPayload, PaymentRequired, PaymentRequirements } from '@x402/core/types'; +import { resolveStablecoinMint } from '@x402/svm'; +import { UptoSvmScheme as UptoSvmFacilitator } from '@x402/svm/upto/facilitator'; + +import type { PayKitConfig } from '../config.js'; +import { ConfigurationError, InvalidProofError } from '../errors.js'; +import type { Price } from '../price.js'; +import { caip2 } from '../protocol.js'; + +/** Settlement-response header mirrored by the x402 SDK family. */ +const PAYMENT_RESPONSE_HEADER = 'x-payment-response'; +/** 402 challenge header read by x402 clients (alongside the JSON body). */ +const PAYMENT_REQUIRED_HEADER = 'payment-required'; +const X402_VERSION = 2; +const MAX_TIMEOUT_SECONDS = 300; + +/** + * Usage meter handed to a usage-gated handler. The handler reports the actual + * amount consumed (token base units) via {@link Charge.charge}; the gate settles + * that amount — never above the authorized ceiling — after the handler returns, + * refunding the remainder. If the handler never calls `charge`, the settled + * amount is `0`. Mirrors the Rust `Charge` extractor on `paid_upto_*` routes. + */ +export class Charge { + #amount: bigint | undefined; + + /** The authorized maximum for this request, in base units. */ + readonly maxBaseUnits: bigint; + + constructor(maxBaseUnits: bigint) { + this.maxBaseUnits = maxBaseUnits; + } + + /** Record the actual amount consumed (base units). Values above the ceiling are clamped; negatives floor to 0. */ + charge(baseUnits: bigint | number): void { + const value = typeof baseUnits === 'bigint' ? baseUnits : BigInt(Math.trunc(baseUnits)); + this.#amount = value < 0n ? 0n : value > this.maxBaseUnits ? this.maxBaseUnits : value; + } + + /** The amount to settle (base units): the clamped charge, or `0` if never set. */ + settledBaseUnits(): bigint { + return this.#amount ?? 0n; + } +} + +/** A verified `upto` authorization carried from {@link X402Upto.verifyOpen} to {@link X402Upto.settle}. */ +export type UptoVerified = { + readonly maxBaseUnits: bigint; + readonly payer: string; + readonly payload: PaymentPayload; + readonly requirements: PaymentRequirements; +}; + +/** Result of settling a `upto` authorization. */ +export type UptoSettlement = { + readonly amount: string; + readonly settlementHeaders: Readonly>; + readonly transaction: string; +}; + +/** + * Usage-based (`upto`) x402 engine: the metered counterpart to the `exact` + * adapter. The client opens a payment channel depositing the authorized ceiling; + * the in-process `@x402/svm` upto facilitator (signed + fee-paid by the operator) + * verifies and broadcasts the open, then settles the metered amount with a single + * voucher, refunding the remainder. + * + * `upto` does not fit the protocol-uniform {@link import('../adapter.js').ProtocolAdapter} + * contract (which settles before the handler runs), so it is exposed as a + * dedicated engine the framework wrappers drive — exactly as Rust ships + * `paid_upto_*` separately from the unified gate. + */ +export class X402Upto { + readonly #facilitator: x402Facilitator; + readonly #network: Network; + readonly #operator: string; + readonly #recipient: string; + readonly #stablecoins: readonly string[]; + + constructor(config: PayKitConfig) { + this.#network = caip2(config.network) as Network; + this.#operator = config.operator.signer.pubkey; + this.#recipient = config.operator.recipient; + this.#stablecoins = config.stablecoins; + this.#facilitator = new x402Facilitator().register( + this.#network, + new UptoSvmFacilitator(config.operator.signer.signer, { rpcUrl: config.rpcUrl }), + ); + } + + /** Whether `request` carries an x402 payment credential. */ + detect(request: Request): boolean { + return this.#paymentHeader(request) !== undefined; + } + + /** The 402 challenge headers for a route capped at `maxPrice`. */ + challengeHeaders(maxPrice: Price, request: Request): Readonly> { + const paymentRequired: PaymentRequired = { + accepts: [this.#requirements(maxPrice)], + resource: { url: new URL(request.url).pathname }, + x402Version: X402_VERSION, + }; + return { [PAYMENT_REQUIRED_HEADER]: encodePaymentRequiredHeader(paymentRequired) }; + } + + /** The `accepts[]` entries for the 402 JSON body. */ + accepts(maxPrice: Price): readonly PaymentRequirements[] { + return [this.#requirements(maxPrice)]; + } + + /** + * Verify the authorization and broadcast the channel open (escrowing the + * ceiling before the resource is served). + * + * @throws {InvalidProofError} when the authorization fails verification. + */ + async verifyOpen(request: Request, maxPrice: Price): Promise { + const header = this.#paymentHeader(request); + if (!header) throw new InvalidProofError('missing_x402_payment_header'); + + let payload: PaymentPayload; + try { + payload = decodePaymentSignatureHeader(header); + } catch (error) { + throw new InvalidProofError('invalid_x402_payment_header', errorMessage(error)); + } + + const requirements = this.#requirements(maxPrice); + const verification = await this.#facilitator.verify(payload, requirements); + if (!verification.isValid) { + throw new InvalidProofError(verification.invalidReason ?? 'invalid_proof', verification.invalidMessage); + } + return { maxBaseUnits: BigInt(requirements.amount), payer: verification.payer ?? '', payload, requirements }; + } + + /** + * Settle the metered amount (`actualBaseUnits`, clamped to the ceiling) against + * a verified open: operator voucher, settle-and-finalize, refund the remainder. + * + * @throws {InvalidProofError} when settlement fails. + */ + async settle(verified: UptoVerified, actualBaseUnits: bigint): Promise { + const actual = actualBaseUnits > verified.maxBaseUnits ? verified.maxBaseUnits : actualBaseUnits; + const settleRequirements: PaymentRequirements = { ...verified.requirements, amount: actual.toString() }; + const settlement = await this.#facilitator.settle(verified.payload, settleRequirements); + if (!settlement.success) { + throw new InvalidProofError(settlement.errorReason ?? 'settlement_failed', settlement.errorMessage); + } + return { + amount: settlement.amount ?? actual.toString(), + settlementHeaders: { [PAYMENT_RESPONSE_HEADER]: encodePaymentResponseHeader(settlement) }, + transaction: settlement.transaction, + }; + } + + #requirements(maxPrice: Price): PaymentRequirements { + const coin = maxPrice.primaryCoin() ?? this.#stablecoins[0] ?? 'USDC'; + const mint = resolveStablecoinMint(coin, this.#network); + if (!mint) throw new ConfigurationError(`No ${coin} mint known for ${this.#network}.`); + return { + amount: maxPrice.baseUnits().toString(), + asset: mint, + extra: { facilitatorAddress: this.#operator, feePayer: this.#operator }, + maxTimeoutSeconds: MAX_TIMEOUT_SECONDS, + network: this.#network, + payTo: this.#recipient, + scheme: 'upto', + }; + } + + #paymentHeader(request: Request): string | undefined { + return request.headers.get('x-payment') ?? request.headers.get('payment-signature') ?? undefined; + } +} + +function errorMessage(error: unknown): string | undefined { + return error instanceof Error ? error.message : undefined; +} diff --git a/typescript/packages/pay-kit/src/adapters/x402.ts b/typescript/packages/pay-kit/src/adapters/x402.ts new file mode 100644 index 000000000..f9d2f4c03 --- /dev/null +++ b/typescript/packages/pay-kit/src/adapters/x402.ts @@ -0,0 +1,131 @@ +import { x402Facilitator } from '@x402/core/facilitator'; +import { + decodePaymentSignatureHeader, + encodePaymentRequiredHeader, + encodePaymentResponseHeader, +} from '@x402/core/http'; +import type { Network, PaymentPayload, PaymentRequired, PaymentRequirements } from '@x402/core/types'; +import { resolveStablecoinMint, toFacilitatorSvmSigner } from '@x402/svm'; +import { ExactSvmScheme as ExactSvmFacilitator } from '@x402/svm/exact/facilitator'; + +import type { ProtocolAdapter } from '../adapter.js'; +import type { AcceptsEntry } from '../challenge.js'; +import type { PayKitConfig } from '../config.js'; +import { ConfigurationError, InvalidProofError } from '../errors.js'; +import type { Gate } from '../gate.js'; +import type { Payment } from '../payment.js'; +import { caip2 } from '../protocol.js'; + +/** x402 v2 protocol version advertised in the challenge envelope. */ +const X402_VERSION = 2; +/** Default completion window advertised in `maxTimeoutSeconds`. */ +const MAX_TIMEOUT_SECONDS = 300; +/** Settlement-response header mirrored by the x402 SDK family. */ +const PAYMENT_RESPONSE_HEADER = 'x-payment-response'; +/** 402 challenge header read by x402 clients (alongside the JSON body). */ +const PAYMENT_REQUIRED_HEADER = 'payment-required'; + +/** + * The x402 `exact` protocol adapter: wraps `@x402/svm`'s exact scheme behind + * the PayKit {@link ProtocolAdapter} contract, settling SPL transfers through + * an in-process `@x402/core` facilitator that the configured operator signs + * and fee-pays. The 402 challenge is delivered both as the `PAYMENT-REQUIRED` + * header and in the JSON body's `accepts[]`, and the paid retry is read from + * `X-PAYMENT` (or `PAYMENT-SIGNATURE`), matching the x402 HTTP convention. + */ +export function createX402ExactAdapter(config: PayKitConfig): ProtocolAdapter { + const network = caip2(config.network) as Network; + const operator = config.operator.signer.pubkey; + + // In-process facilitator: the operator both fee-pays and signs settlement. + const facilitator = new x402Facilitator().register( + network, + new ExactSvmFacilitator( + toFacilitatorSvmSigner(config.operator.signer.signer, { defaultRpcUrl: config.rpcUrl }), + ), + ); + + function mintFor(gate: Gate): string { + const coin = gate.amount.primaryCoin() ?? config.stablecoins[0] ?? 'USDC'; + const mint = resolveStablecoinMint(coin, network); + if (!mint) throw new ConfigurationError(`No ${coin} mint known for ${config.network}.`); + return mint; + } + + /** The route's pinned requirements — the credential is bound to this exact amount. */ + function requirementsFor(gate: Gate): PaymentRequirements { + return { + amount: gate.total().baseUnits().toString(), + asset: mintFor(gate), + extra: { feePayer: operator }, + maxTimeoutSeconds: MAX_TIMEOUT_SECONDS, + network, + payTo: gate.payTo, + scheme: 'exact', + }; + } + + function paymentHeader(request: Request): string | undefined { + return request.headers.get('x-payment') ?? request.headers.get('payment-signature') ?? undefined; + } + + return { + acceptsEntry(gate: Gate): Promise { + const requirements = requirementsFor(gate); + return Promise.resolve({ ...requirements, protocol: 'x402' }); + }, + + challengeHeaders(gate: Gate, request: Request): Promise>> { + const paymentRequired: PaymentRequired = { + accepts: [requirementsFor(gate)], + resource: { url: new URL(request.url).pathname }, + x402Version: X402_VERSION, + }; + return Promise.resolve({ [PAYMENT_REQUIRED_HEADER]: encodePaymentRequiredHeader(paymentRequired) }); + }, + + detect(request: Request): boolean { + return paymentHeader(request) !== undefined; + }, + + protocol: 'x402', + scheme: 'exact', + + async verifyAndSettle(gate: Gate, request: Request): Promise { + const header = paymentHeader(request); + if (!header) throw new InvalidProofError('missing_x402_payment_header'); + + let payload: PaymentPayload; + try { + payload = decodePaymentSignatureHeader(header); + } catch (error) { + throw new InvalidProofError('invalid_x402_payment_header', errorMessage(error)); + } + + const requirements = requirementsFor(gate); + const verification = await facilitator.verify(payload, requirements); + if (!verification.isValid) { + throw new InvalidProofError(verification.invalidReason ?? 'invalid_proof', verification.invalidMessage); + } + + const settlement = await facilitator.settle(payload, requirements); + if (!settlement.success) { + throw new InvalidProofError(settlement.errorReason ?? 'settlement_failed', settlement.errorMessage); + } + + return { + gateName: gate.name, + payer: settlement.payer ?? verification.payer, + protocol: 'x402', + raw: header, + scheme: 'exact', + settlementHeaders: { [PAYMENT_RESPONSE_HEADER]: encodePaymentResponseHeader(settlement) }, + transaction: settlement.transaction, + }; + }, + }; +} + +function errorMessage(error: unknown): string | undefined { + return error instanceof Error ? error.message : undefined; +} From a826bf0dfc582d81657496d81c24e54809ac8976 Mon Sep 17 00:00:00 2001 From: Ludo Galabru Date: Thu, 18 Jun 2026 16:55:34 -0400 Subject: [PATCH 2/6] style(rust): rustfmt the upto/core/subscriptions changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Rust CI 'format check' step (cargo fmt --check) failed on the upto scheme + core/subscriptions changes. No logic changes — formatting only. Co-Authored-By: Claude Opus 4.8 (1M context) --- rust/crates/core/src/payment_channels.rs | 5 +++-- rust/crates/core/src/units.rs | 11 +++++++---- rust/crates/kit/src/gate.rs | 6 +++++- rust/crates/mpp/src/client/session.rs | 1 - rust/crates/mpp/src/program/subscriptions.rs | 6 ++++-- rust/crates/x402/src/client/upto/payment.rs | 6 ++---- rust/crates/x402/src/protocol/schemes/upto/verify.rs | 3 ++- rust/crates/x402/src/server/upto.rs | 12 +++++++----- 8 files changed, 30 insertions(+), 20 deletions(-) diff --git a/rust/crates/core/src/payment_channels.rs b/rust/crates/core/src/payment_channels.rs index 89b8f47bb..b1bc174bb 100644 --- a/rust/crates/core/src/payment_channels.rs +++ b/rust/crates/core/src/payment_channels.rs @@ -499,8 +499,9 @@ pub async fn build_open_payment_channel_tx( .await .map_err(|e| Error::Other(format!("payment-channel open signing failed: {e}")))?; - let bytes = bincode::serialize(&tx) - .map_err(|e| Error::Serialization(format!("payment-channel open tx serialization failed: {e}")))?; + let bytes = bincode::serialize(&tx).map_err(|e| { + Error::Serialization(format!("payment-channel open tx serialization failed: {e}")) + })?; Ok(PaymentChannelOpenTransaction { channel_id, transaction: base64::engine::general_purpose::STANDARD.encode(bytes), diff --git a/rust/crates/core/src/units.rs b/rust/crates/core/src/units.rs index 45a98a51a..5bbda07df 100644 --- a/rust/crates/core/src/units.rs +++ b/rust/crates/core/src/units.rs @@ -72,9 +72,9 @@ pub fn parse_units(amount: &str, decimals: u8) -> Result { let value: u128 = amount .parse() .map_err(|_| Error::Other(format!("Invalid amount: {amount}")))?; - let factor = 10u128.checked_pow(decimals).ok_or_else(|| { - Error::Other(format!("10^{decimals} overflows u128 in parse_units")) - })?; + let factor = 10u128 + .checked_pow(decimals) + .ok_or_else(|| Error::Other(format!("10^{decimals} overflows u128 in parse_units")))?; let product = value.checked_mul(factor).ok_or_else(|| { Error::Other(format!( "{value} * 10^{decimals} overflows u128 in parse_units" @@ -111,7 +111,10 @@ mod tests { #[test] fn decimals_cap_and_overflow() { assert!(parse_units("1", MAX_DECIMALS + 1).is_err()); - assert_eq!(parse_units("1", MAX_DECIMALS).unwrap(), "1000000000000000000"); + assert_eq!( + parse_units("1", MAX_DECIMALS).unwrap(), + "1000000000000000000" + ); let huge = format!("1{}", "0".repeat(21)); assert!(parse_units(&huge, MAX_DECIMALS).is_err()); } diff --git a/rust/crates/kit/src/gate.rs b/rust/crates/kit/src/gate.rs index c65ebf22b..75b72b81b 100644 --- a/rust/crates/kit/src/gate.rs +++ b/rust/crates/kit/src/gate.rs @@ -741,7 +741,11 @@ async fn upto_gate_middleware( /// /// Requires a `fee_payer_signer` on [`PayKitConfig`] (the operator signs /// settlement vouchers). -pub fn paid_upto_get(handler: H, max_price: impl Into, pay: &PayKit) -> MethodRouter +pub fn paid_upto_get( + handler: H, + max_price: impl Into, + pay: &PayKit, +) -> MethodRouter where H: Handler, T: 'static, diff --git a/rust/crates/mpp/src/client/session.rs b/rust/crates/mpp/src/client/session.rs index 9ed6a0462..bbb190544 100644 --- a/rust/crates/mpp/src/client/session.rs +++ b/rust/crates/mpp/src/client/session.rs @@ -639,7 +639,6 @@ fn pubkey_string(pubkey: &Pubkey) -> String { bs58::encode(pubkey.as_ref()).into_string() } - #[cfg(test)] mod tests { use super::*; diff --git a/rust/crates/mpp/src/program/subscriptions.rs b/rust/crates/mpp/src/program/subscriptions.rs index 0db75d826..9f2c88bd2 100644 --- a/rust/crates/mpp/src/program/subscriptions.rs +++ b/rust/crates/mpp/src/program/subscriptions.rs @@ -425,8 +425,10 @@ pub fn build_subscribe_ix( .payer .map(|payer| vec![AccountMeta::new(to_addr(&payer), true)]) .unwrap_or_default(); - let mut ix = - gen.instruction_with_remaining_accounts(SubscribeInstructionArgs { subscribe_data }, &remaining); + let mut ix = gen.instruction_with_remaining_accounts( + SubscribeInstructionArgs { subscribe_data }, + &remaining, + ); ix.program_id = to_addr(&program_id); ix } diff --git a/rust/crates/x402/src/client/upto/payment.rs b/rust/crates/x402/src/client/upto/payment.rs index 5561b3e5b..e4a0d70bd 100644 --- a/rust/crates/x402/src/client/upto/payment.rs +++ b/rust/crates/x402/src/client/upto/payment.rs @@ -58,10 +58,8 @@ pub async fn build_upto_payload( let token_program = match &requirements.extra.token_program { Some(value) => Pubkey::from_str(value) .map_err(|e| Error::Other(format!("invalid tokenProgram: {e}")))?, - None => Pubkey::from_str( - "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", - ) - .expect("valid token program"), + None => Pubkey::from_str("TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA") + .expect("valid token program"), }; let recent_blockhash = requirements .extra diff --git a/rust/crates/x402/src/protocol/schemes/upto/verify.rs b/rust/crates/x402/src/protocol/schemes/upto/verify.rs index 1bdf684cc..b36876646 100644 --- a/rust/crates/x402/src/protocol/schemes/upto/verify.rs +++ b/rust/crates/x402/src/protocol/schemes/upto/verify.rs @@ -10,7 +10,8 @@ use crate::error::Error; use super::types::{UptoPayload, UptoRequirements, PROFILE_PAYMENT_CHANNEL}; /// Scheme-specific error string for an over-ceiling settlement. -pub const ERR_SETTLEMENT_EXCEEDS_AMOUNT: &str = "invalid_upto_svm_payload_settlement_exceeds_amount"; +pub const ERR_SETTLEMENT_EXCEEDS_AMOUNT: &str = + "invalid_upto_svm_payload_settlement_exceeds_amount"; /// Verify an `upto` payload against the route's pinned requirements. /// diff --git a/rust/crates/x402/src/server/upto.rs b/rust/crates/x402/src/server/upto.rs index 44ea9db4c..aaa07e7e3 100644 --- a/rust/crates/x402/src/server/upto.rs +++ b/rust/crates/x402/src/server/upto.rs @@ -128,8 +128,9 @@ impl X402Upto { fn program_id(&self) -> Result { match &self.config.program_id { - Some(value) => Pubkey::from_str(value) - .map_err(|e| Error::Other(format!("invalid programId: {e}"))), + Some(value) => { + Pubkey::from_str(value).map_err(|e| Error::Other(format!("invalid programId: {e}"))) + } None => Ok(pc::default_program_id()), } } @@ -272,7 +273,9 @@ impl X402Upto { // Read the confirmed channel state and bind it. let channel = self.fetch_channel(&channel_id)?; if channel.status != CHANNEL_STATUS_OPEN { - return Err(Error::Other("channel is not open after broadcast".to_string())); + return Err(Error::Other( + "channel is not open after broadcast".to_string(), + )); } if pc::from_address(&channel.mint) != expected_mint { return Err(Error::MintMismatch { @@ -420,8 +423,7 @@ impl X402Upto { .rpc .get_account_data(channel_id) .map_err(|e| Error::Rpc(format!("channel account fetch failed: {e}")))?; - Channel::from_bytes(&data) - .map_err(|e| Error::Other(format!("channel decode failed: {e}"))) + Channel::from_bytes(&data).map_err(|e| Error::Other(format!("channel decode failed: {e}"))) } } From c48e7fa4820df3b48d53f6a835ab34d8202d2d1e Mon Sep 17 00:00:00 2001 From: Ludo Galabru Date: Thu, 18 Jun 2026 17:05:28 -0400 Subject: [PATCH 3/6] fix(upto): address Greptile review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TS (P1): emit `extra.facilitator` not `facilitatorAddress` in the upto adapter so it matches the spec (§4.1) and the Rust `UptoExtra` wire field — fixes cross-SDK deserialization (Rust client would reject the challenge). - rust verify.rs (P2): drop the redundant `requirements.extra.facilitator != operator` check (always built server-side; can never fire). The authorized_signer == operator check is the real binding. - rust gate.rs (P2): recover from mutex poisoning in `Charge` so a handler that panics after `charge()` still settles the consumed amount instead of a silent zero refund. - rust server/upto.rs (P2): make `upto_requirements` pure (no RPC); fetch the recent blockhash only in `upto()` when building the challenge, so `verify_open` no longer fetches a divergent blockhash. Co-Authored-By: Claude Opus 4.8 (1M context) --- rust/crates/kit/src/gate.rs | 10 +++++---- .../x402/src/protocol/schemes/upto/verify.rs | 9 ++++---- rust/crates/x402/src/server/upto.rs | 21 ++++++++++++------- .../pay-kit/src/__tests__/x402.test.ts | 2 +- .../pay-kit/src/adapters/x402-upto.ts | 2 +- 5 files changed, 26 insertions(+), 18 deletions(-) diff --git a/rust/crates/kit/src/gate.rs b/rust/crates/kit/src/gate.rs index 75b72b81b..1dd2c794b 100644 --- a/rust/crates/kit/src/gate.rs +++ b/rust/crates/kit/src/gate.rs @@ -275,9 +275,9 @@ impl Charge { /// authorized maximum are clamped to it. pub fn charge(&self, base_units: u64) { let clamped = base_units.min(self.max_base_units); - if let Ok(mut slot) = self.cell.lock() { - *slot = Some(clamped); - } + // Recover from a poisoned lock (a panicked handler) rather than dropping + // the charge — otherwise a panic after `charge()` would settle for zero. + *self.cell.lock().unwrap_or_else(|e| e.into_inner()) = Some(clamped); } /// The authorized maximum for this request, in base units. @@ -705,7 +705,9 @@ async fn upto_gate_middleware( req.extensions_mut().insert(charge); let mut resp = next.run(req).await; - let actual = cell.lock().ok().and_then(|slot| *slot).unwrap_or(0); + // Recover from poisoning so a handler that panicked *after* recording a + // charge still settles the amount it consumed (not a silent zero refund). + let actual = (*cell.lock().unwrap_or_else(|e| e.into_inner())).unwrap_or(0); // Settle the actual amount and refund the remainder. match upto.settle_actual(&open, actual).await { diff --git a/rust/crates/x402/src/protocol/schemes/upto/verify.rs b/rust/crates/x402/src/protocol/schemes/upto/verify.rs index b36876646..464e051f7 100644 --- a/rust/crates/x402/src/protocol/schemes/upto/verify.rs +++ b/rust/crates/x402/src/protocol/schemes/upto/verify.rs @@ -69,11 +69,10 @@ pub fn verify_upto_payload( ))); } - if requirements.extra.facilitator != operator { - return Err(Error::Other( - "requirement facilitator does not match this operator".to_string(), - )); - } + // The meaningful binding: the client must have authorized *this* operator as + // the channel's voucher signer. (We don't re-check `requirements.extra + // .facilitator` — it is always built server-side as `self.operator()`, so the + // comparison can never fail; the authorized_signer check is what matters.) if payload.authorized_signer != operator { return Err(Error::Other( "voucher authorized_signer must be the operator for the payment-channel profile" diff --git a/rust/crates/x402/src/server/upto.rs b/rust/crates/x402/src/server/upto.rs index aaa07e7e3..de813cc8b 100644 --- a/rust/crates/x402/src/server/upto.rs +++ b/rust/crates/x402/src/server/upto.rs @@ -154,15 +154,14 @@ impl X402Upto { /// `max_amount` is a human-decimal amount (e.g. `"0.10"`), converted to base /// units using the configured decimals — same convention as the `exact` /// scheme, so the gate passes one dollar string everywhere. + /// + /// Pure (no RPC): `extra.recent_blockhash` is left `None` and filled in by + /// [`upto`] when building the 402 challenge. The verify path reuses this + /// without fetching (or diverging on) a blockhash. pub fn upto_requirements(&self, max_amount: &str) -> Result { let mint = self.mint()?; let token_program = self.token_program()?; let base_units = crate::server::exact::parse_units(max_amount, self.config.decimals)?; - let recent_blockhash = self - .rpc - .get_latest_blockhash() - .ok() - .map(|hash| hash.to_string()); Ok(UptoRequirements { scheme: UPTO_SCHEME.to_string(), @@ -177,15 +176,23 @@ impl X402Upto { token_program: Some(pc::pubkey_string(&token_program)), facilitator: self.operator(), program_id: Some(pc::pubkey_string(&self.program_id()?)), - recent_blockhash, + recent_blockhash: None, valid_after: None, }, }) } /// Build the full `PAYMENT-REQUIRED` envelope for an `upto` challenge. + /// + /// This is where the (best-effort) recent blockhash is fetched and attached, + /// so the client can build the channel `open` without an extra RPC. pub fn upto(&self, max_amount: &str) -> Result { - let requirement = self.upto_requirements(max_amount)?; + let mut requirement = self.upto_requirements(max_amount)?; + requirement.extra.recent_blockhash = self + .rpc + .get_latest_blockhash() + .ok() + .map(|hash| hash.to_string()); let resource = (!self.config.resource.is_empty()).then(|| ResourceInfo { url: self.config.resource.clone(), description: self.config.description.clone(), diff --git a/typescript/packages/pay-kit/src/__tests__/x402.test.ts b/typescript/packages/pay-kit/src/__tests__/x402.test.ts index 7539ecf48..a646aa840 100644 --- a/typescript/packages/pay-kit/src/__tests__/x402.test.ts +++ b/typescript/packages/pay-kit/src/__tests__/x402.test.ts @@ -58,7 +58,7 @@ describe('x402 upto engine', () => { expect(entry.scheme).toBe('upto'); expect(entry.amount).toBe('1000000'); // 1.00 USDC ceiling expect(entry.payTo).toBe(config.operator.recipient); - expect((entry.extra as { facilitatorAddress?: string }).facilitatorAddress).toBe(config.operator.signer.pubkey); + expect((entry.extra as { facilitator?: string }).facilitator).toBe(config.operator.signer.pubkey); expect((entry.extra as { feePayer?: string }).feePayer).toBe(config.operator.signer.pubkey); }); diff --git a/typescript/packages/pay-kit/src/adapters/x402-upto.ts b/typescript/packages/pay-kit/src/adapters/x402-upto.ts index 89a899d8d..fa11ae0b2 100644 --- a/typescript/packages/pay-kit/src/adapters/x402-upto.ts +++ b/typescript/packages/pay-kit/src/adapters/x402-upto.ts @@ -166,7 +166,7 @@ export class X402Upto { return { amount: maxPrice.baseUnits().toString(), asset: mint, - extra: { facilitatorAddress: this.#operator, feePayer: this.#operator }, + extra: { facilitator: this.#operator, feePayer: this.#operator }, maxTimeoutSeconds: MAX_TIMEOUT_SECONDS, network: this.#network, payTo: this.#recipient, From 9d170222e47375d2e58cae5fc99db14716e5838e Mon Sep 17 00:00:00 2001 From: Ludo Galabru Date: Thu, 18 Jun 2026 17:34:32 -0400 Subject: [PATCH 4/6] =?UTF-8?q?chore:=20scope=20PR=20to=20Rust=20=E2=80=94?= =?UTF-8?q?=20untrack=20the=20TypeScript=20x402=20adapters?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pay-kit TS x402/upto adapters belong to a separate, in-progress TS integration (they import the unpublished @x402 packages). Remove them from this Rust-only PR; the files stay on disk for that follow-up work. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../pay-kit/src/__tests__/x402.test.ts | 101 ---------- .../pay-kit/src/adapters/x402-upto.ts | 184 ------------------ .../packages/pay-kit/src/adapters/x402.ts | 131 ------------- 3 files changed, 416 deletions(-) delete mode 100644 typescript/packages/pay-kit/src/__tests__/x402.test.ts delete mode 100644 typescript/packages/pay-kit/src/adapters/x402-upto.ts delete mode 100644 typescript/packages/pay-kit/src/adapters/x402.ts diff --git a/typescript/packages/pay-kit/src/__tests__/x402.test.ts b/typescript/packages/pay-kit/src/__tests__/x402.test.ts deleted file mode 100644 index a646aa840..000000000 --- a/typescript/packages/pay-kit/src/__tests__/x402.test.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { createX402ExactAdapter } from '../adapters/x402.js'; -import { Charge, X402Upto } from '../adapters/x402-upto.js'; -import { configure, type PayKitConfig } from '../config.js'; -import { Gate } from '../gate.js'; -import { usd } from '../price.js'; -import { gateDefaults } from '../pricing.js'; - -async function testConfig(): Promise { - return await configure({ mpp: { challengeBindingSecret: 'x402-test-secret' }, network: 'solana_localnet' }); -} - -function gateFor(config: PayKitConfig, amount = usd('0.10')): Gate { - return Gate.create({ amount, name: 'test' }, gateDefaults(config)); -} - -describe('x402 exact adapter', () => { - it('advertises a canonical x402 accepts entry', async () => { - const config = await testConfig(); - const adapter = createX402ExactAdapter(config); - const entry = await adapter.acceptsEntry(gateFor(config), new Request('http://localhost/r')); - - expect(entry.protocol).toBe('x402'); - expect(entry.scheme).toBe('exact'); - expect(entry.amount).toBe('100000'); // 0.10 USDC, 6 decimals - expect(entry.payTo).toBe(config.operator.recipient); - expect(typeof entry.network).toBe('string'); - expect(typeof entry.asset).toBe('string'); - expect((entry.extra as { feePayer?: string }).feePayer).toBe(config.operator.signer.pubkey); - }); - - it('detects the x402 payment header', async () => { - const config = await testConfig(); - const adapter = createX402ExactAdapter(config); - expect(adapter.detect(new Request('http://localhost/r'))).toBe(false); - expect(adapter.detect(new Request('http://localhost/r', { headers: { 'x-payment': 'abc' } }))).toBe(true); - expect(adapter.detect(new Request('http://localhost/r', { headers: { 'payment-signature': 'abc' } }))).toBe( - true, - ); - }); - - it('emits the PAYMENT-REQUIRED challenge header', async () => { - const config = await testConfig(); - const adapter = createX402ExactAdapter(config); - const headers = await adapter.challengeHeaders(gateFor(config), new Request('http://localhost/r')); - expect(typeof headers['payment-required']).toBe('string'); - expect(headers['payment-required'].length).toBeGreaterThan(0); - }); -}); - -describe('x402 upto engine', () => { - it('advertises an upto accepts entry with the facilitator binding', async () => { - const config = await testConfig(); - const upto = new X402Upto(config); - const [entry] = upto.accepts(usd('1.00')); - - expect(entry.scheme).toBe('upto'); - expect(entry.amount).toBe('1000000'); // 1.00 USDC ceiling - expect(entry.payTo).toBe(config.operator.recipient); - expect((entry.extra as { facilitator?: string }).facilitator).toBe(config.operator.signer.pubkey); - expect((entry.extra as { feePayer?: string }).feePayer).toBe(config.operator.signer.pubkey); - }); - - it('detects the payment header and emits a challenge', async () => { - const config = await testConfig(); - const upto = new X402Upto(config); - expect(upto.detect(new Request('http://localhost/u'))).toBe(false); - expect(upto.detect(new Request('http://localhost/u', { headers: { 'x-payment': 'abc' } }))).toBe(true); - const headers = upto.challengeHeaders(usd('1.00'), new Request('http://localhost/u')); - expect(typeof headers['payment-required']).toBe('string'); - }); -}); - -describe('Charge meter', () => { - it('defaults to a zero settlement', () => { - expect(new Charge(1_000_000n).settledBaseUnits()).toBe(0n); - }); - - it('records the reported amount', () => { - const charge = new Charge(1_000_000n); - charge.charge(400_000n); - expect(charge.settledBaseUnits()).toBe(400_000n); - }); - - it('clamps above the ceiling and floors negatives', () => { - const overCharge = new Charge(1_000_000n); - overCharge.charge(2_000_000n); - expect(overCharge.settledBaseUnits()).toBe(1_000_000n); - - const negative = new Charge(1_000_000n); - negative.charge(-5); - expect(negative.settledBaseUnits()).toBe(0n); - }); - - it('accepts a plain number', () => { - const charge = new Charge(1_000_000n); - charge.charge(250_000); - expect(charge.settledBaseUnits()).toBe(250_000n); - }); -}); diff --git a/typescript/packages/pay-kit/src/adapters/x402-upto.ts b/typescript/packages/pay-kit/src/adapters/x402-upto.ts deleted file mode 100644 index fa11ae0b2..000000000 --- a/typescript/packages/pay-kit/src/adapters/x402-upto.ts +++ /dev/null @@ -1,184 +0,0 @@ -import { x402Facilitator } from '@x402/core/facilitator'; -import { - decodePaymentSignatureHeader, - encodePaymentRequiredHeader, - encodePaymentResponseHeader, -} from '@x402/core/http'; -import type { Network, PaymentPayload, PaymentRequired, PaymentRequirements } from '@x402/core/types'; -import { resolveStablecoinMint } from '@x402/svm'; -import { UptoSvmScheme as UptoSvmFacilitator } from '@x402/svm/upto/facilitator'; - -import type { PayKitConfig } from '../config.js'; -import { ConfigurationError, InvalidProofError } from '../errors.js'; -import type { Price } from '../price.js'; -import { caip2 } from '../protocol.js'; - -/** Settlement-response header mirrored by the x402 SDK family. */ -const PAYMENT_RESPONSE_HEADER = 'x-payment-response'; -/** 402 challenge header read by x402 clients (alongside the JSON body). */ -const PAYMENT_REQUIRED_HEADER = 'payment-required'; -const X402_VERSION = 2; -const MAX_TIMEOUT_SECONDS = 300; - -/** - * Usage meter handed to a usage-gated handler. The handler reports the actual - * amount consumed (token base units) via {@link Charge.charge}; the gate settles - * that amount — never above the authorized ceiling — after the handler returns, - * refunding the remainder. If the handler never calls `charge`, the settled - * amount is `0`. Mirrors the Rust `Charge` extractor on `paid_upto_*` routes. - */ -export class Charge { - #amount: bigint | undefined; - - /** The authorized maximum for this request, in base units. */ - readonly maxBaseUnits: bigint; - - constructor(maxBaseUnits: bigint) { - this.maxBaseUnits = maxBaseUnits; - } - - /** Record the actual amount consumed (base units). Values above the ceiling are clamped; negatives floor to 0. */ - charge(baseUnits: bigint | number): void { - const value = typeof baseUnits === 'bigint' ? baseUnits : BigInt(Math.trunc(baseUnits)); - this.#amount = value < 0n ? 0n : value > this.maxBaseUnits ? this.maxBaseUnits : value; - } - - /** The amount to settle (base units): the clamped charge, or `0` if never set. */ - settledBaseUnits(): bigint { - return this.#amount ?? 0n; - } -} - -/** A verified `upto` authorization carried from {@link X402Upto.verifyOpen} to {@link X402Upto.settle}. */ -export type UptoVerified = { - readonly maxBaseUnits: bigint; - readonly payer: string; - readonly payload: PaymentPayload; - readonly requirements: PaymentRequirements; -}; - -/** Result of settling a `upto` authorization. */ -export type UptoSettlement = { - readonly amount: string; - readonly settlementHeaders: Readonly>; - readonly transaction: string; -}; - -/** - * Usage-based (`upto`) x402 engine: the metered counterpart to the `exact` - * adapter. The client opens a payment channel depositing the authorized ceiling; - * the in-process `@x402/svm` upto facilitator (signed + fee-paid by the operator) - * verifies and broadcasts the open, then settles the metered amount with a single - * voucher, refunding the remainder. - * - * `upto` does not fit the protocol-uniform {@link import('../adapter.js').ProtocolAdapter} - * contract (which settles before the handler runs), so it is exposed as a - * dedicated engine the framework wrappers drive — exactly as Rust ships - * `paid_upto_*` separately from the unified gate. - */ -export class X402Upto { - readonly #facilitator: x402Facilitator; - readonly #network: Network; - readonly #operator: string; - readonly #recipient: string; - readonly #stablecoins: readonly string[]; - - constructor(config: PayKitConfig) { - this.#network = caip2(config.network) as Network; - this.#operator = config.operator.signer.pubkey; - this.#recipient = config.operator.recipient; - this.#stablecoins = config.stablecoins; - this.#facilitator = new x402Facilitator().register( - this.#network, - new UptoSvmFacilitator(config.operator.signer.signer, { rpcUrl: config.rpcUrl }), - ); - } - - /** Whether `request` carries an x402 payment credential. */ - detect(request: Request): boolean { - return this.#paymentHeader(request) !== undefined; - } - - /** The 402 challenge headers for a route capped at `maxPrice`. */ - challengeHeaders(maxPrice: Price, request: Request): Readonly> { - const paymentRequired: PaymentRequired = { - accepts: [this.#requirements(maxPrice)], - resource: { url: new URL(request.url).pathname }, - x402Version: X402_VERSION, - }; - return { [PAYMENT_REQUIRED_HEADER]: encodePaymentRequiredHeader(paymentRequired) }; - } - - /** The `accepts[]` entries for the 402 JSON body. */ - accepts(maxPrice: Price): readonly PaymentRequirements[] { - return [this.#requirements(maxPrice)]; - } - - /** - * Verify the authorization and broadcast the channel open (escrowing the - * ceiling before the resource is served). - * - * @throws {InvalidProofError} when the authorization fails verification. - */ - async verifyOpen(request: Request, maxPrice: Price): Promise { - const header = this.#paymentHeader(request); - if (!header) throw new InvalidProofError('missing_x402_payment_header'); - - let payload: PaymentPayload; - try { - payload = decodePaymentSignatureHeader(header); - } catch (error) { - throw new InvalidProofError('invalid_x402_payment_header', errorMessage(error)); - } - - const requirements = this.#requirements(maxPrice); - const verification = await this.#facilitator.verify(payload, requirements); - if (!verification.isValid) { - throw new InvalidProofError(verification.invalidReason ?? 'invalid_proof', verification.invalidMessage); - } - return { maxBaseUnits: BigInt(requirements.amount), payer: verification.payer ?? '', payload, requirements }; - } - - /** - * Settle the metered amount (`actualBaseUnits`, clamped to the ceiling) against - * a verified open: operator voucher, settle-and-finalize, refund the remainder. - * - * @throws {InvalidProofError} when settlement fails. - */ - async settle(verified: UptoVerified, actualBaseUnits: bigint): Promise { - const actual = actualBaseUnits > verified.maxBaseUnits ? verified.maxBaseUnits : actualBaseUnits; - const settleRequirements: PaymentRequirements = { ...verified.requirements, amount: actual.toString() }; - const settlement = await this.#facilitator.settle(verified.payload, settleRequirements); - if (!settlement.success) { - throw new InvalidProofError(settlement.errorReason ?? 'settlement_failed', settlement.errorMessage); - } - return { - amount: settlement.amount ?? actual.toString(), - settlementHeaders: { [PAYMENT_RESPONSE_HEADER]: encodePaymentResponseHeader(settlement) }, - transaction: settlement.transaction, - }; - } - - #requirements(maxPrice: Price): PaymentRequirements { - const coin = maxPrice.primaryCoin() ?? this.#stablecoins[0] ?? 'USDC'; - const mint = resolveStablecoinMint(coin, this.#network); - if (!mint) throw new ConfigurationError(`No ${coin} mint known for ${this.#network}.`); - return { - amount: maxPrice.baseUnits().toString(), - asset: mint, - extra: { facilitator: this.#operator, feePayer: this.#operator }, - maxTimeoutSeconds: MAX_TIMEOUT_SECONDS, - network: this.#network, - payTo: this.#recipient, - scheme: 'upto', - }; - } - - #paymentHeader(request: Request): string | undefined { - return request.headers.get('x-payment') ?? request.headers.get('payment-signature') ?? undefined; - } -} - -function errorMessage(error: unknown): string | undefined { - return error instanceof Error ? error.message : undefined; -} diff --git a/typescript/packages/pay-kit/src/adapters/x402.ts b/typescript/packages/pay-kit/src/adapters/x402.ts deleted file mode 100644 index f9d2f4c03..000000000 --- a/typescript/packages/pay-kit/src/adapters/x402.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { x402Facilitator } from '@x402/core/facilitator'; -import { - decodePaymentSignatureHeader, - encodePaymentRequiredHeader, - encodePaymentResponseHeader, -} from '@x402/core/http'; -import type { Network, PaymentPayload, PaymentRequired, PaymentRequirements } from '@x402/core/types'; -import { resolveStablecoinMint, toFacilitatorSvmSigner } from '@x402/svm'; -import { ExactSvmScheme as ExactSvmFacilitator } from '@x402/svm/exact/facilitator'; - -import type { ProtocolAdapter } from '../adapter.js'; -import type { AcceptsEntry } from '../challenge.js'; -import type { PayKitConfig } from '../config.js'; -import { ConfigurationError, InvalidProofError } from '../errors.js'; -import type { Gate } from '../gate.js'; -import type { Payment } from '../payment.js'; -import { caip2 } from '../protocol.js'; - -/** x402 v2 protocol version advertised in the challenge envelope. */ -const X402_VERSION = 2; -/** Default completion window advertised in `maxTimeoutSeconds`. */ -const MAX_TIMEOUT_SECONDS = 300; -/** Settlement-response header mirrored by the x402 SDK family. */ -const PAYMENT_RESPONSE_HEADER = 'x-payment-response'; -/** 402 challenge header read by x402 clients (alongside the JSON body). */ -const PAYMENT_REQUIRED_HEADER = 'payment-required'; - -/** - * The x402 `exact` protocol adapter: wraps `@x402/svm`'s exact scheme behind - * the PayKit {@link ProtocolAdapter} contract, settling SPL transfers through - * an in-process `@x402/core` facilitator that the configured operator signs - * and fee-pays. The 402 challenge is delivered both as the `PAYMENT-REQUIRED` - * header and in the JSON body's `accepts[]`, and the paid retry is read from - * `X-PAYMENT` (or `PAYMENT-SIGNATURE`), matching the x402 HTTP convention. - */ -export function createX402ExactAdapter(config: PayKitConfig): ProtocolAdapter { - const network = caip2(config.network) as Network; - const operator = config.operator.signer.pubkey; - - // In-process facilitator: the operator both fee-pays and signs settlement. - const facilitator = new x402Facilitator().register( - network, - new ExactSvmFacilitator( - toFacilitatorSvmSigner(config.operator.signer.signer, { defaultRpcUrl: config.rpcUrl }), - ), - ); - - function mintFor(gate: Gate): string { - const coin = gate.amount.primaryCoin() ?? config.stablecoins[0] ?? 'USDC'; - const mint = resolveStablecoinMint(coin, network); - if (!mint) throw new ConfigurationError(`No ${coin} mint known for ${config.network}.`); - return mint; - } - - /** The route's pinned requirements — the credential is bound to this exact amount. */ - function requirementsFor(gate: Gate): PaymentRequirements { - return { - amount: gate.total().baseUnits().toString(), - asset: mintFor(gate), - extra: { feePayer: operator }, - maxTimeoutSeconds: MAX_TIMEOUT_SECONDS, - network, - payTo: gate.payTo, - scheme: 'exact', - }; - } - - function paymentHeader(request: Request): string | undefined { - return request.headers.get('x-payment') ?? request.headers.get('payment-signature') ?? undefined; - } - - return { - acceptsEntry(gate: Gate): Promise { - const requirements = requirementsFor(gate); - return Promise.resolve({ ...requirements, protocol: 'x402' }); - }, - - challengeHeaders(gate: Gate, request: Request): Promise>> { - const paymentRequired: PaymentRequired = { - accepts: [requirementsFor(gate)], - resource: { url: new URL(request.url).pathname }, - x402Version: X402_VERSION, - }; - return Promise.resolve({ [PAYMENT_REQUIRED_HEADER]: encodePaymentRequiredHeader(paymentRequired) }); - }, - - detect(request: Request): boolean { - return paymentHeader(request) !== undefined; - }, - - protocol: 'x402', - scheme: 'exact', - - async verifyAndSettle(gate: Gate, request: Request): Promise { - const header = paymentHeader(request); - if (!header) throw new InvalidProofError('missing_x402_payment_header'); - - let payload: PaymentPayload; - try { - payload = decodePaymentSignatureHeader(header); - } catch (error) { - throw new InvalidProofError('invalid_x402_payment_header', errorMessage(error)); - } - - const requirements = requirementsFor(gate); - const verification = await facilitator.verify(payload, requirements); - if (!verification.isValid) { - throw new InvalidProofError(verification.invalidReason ?? 'invalid_proof', verification.invalidMessage); - } - - const settlement = await facilitator.settle(payload, requirements); - if (!settlement.success) { - throw new InvalidProofError(settlement.errorReason ?? 'settlement_failed', settlement.errorMessage); - } - - return { - gateName: gate.name, - payer: settlement.payer ?? verification.payer, - protocol: 'x402', - raw: header, - scheme: 'exact', - settlementHeaders: { [PAYMENT_RESPONSE_HEADER]: encodePaymentResponseHeader(settlement) }, - transaction: settlement.transaction, - }; - }, - }; -} - -function errorMessage(error: unknown): string | undefined { - return error instanceof Error ? error.message : undefined; -} From b6b58ee7ccb90413168ff151905666fb6c9126a7 Mon Sep 17 00:00:00 2001 From: Ludo Galabru Date: Thu, 18 Jun 2026 18:02:04 -0400 Subject: [PATCH 5/6] fix(upto): address Greptile round 2 (P0 + P1s) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - server/upto.rs (P0, security): validate the client open transaction before the operator co-signs as fee payer — exactly one instruction, on the payment-channels program, with the open discriminator and accounts bound to the expected payer/payee/mint/operator/channel. Closes the SOL-drain vector where a malicious client could get the operator to sign an arbitrary (e.g. SystemProgram transfer) instruction. Added unit tests. - server/upto.rs (P1, security): in-flight channel dedup via an RAII guard, so the same authorization replayed concurrently can't be served twice for one deposit. - server/upto.rs (P1): bind on-chain channel.payer == payload.from in verify_open, so settlement's distribute can't fail on-chain after the resource was served. - server/upto.rs (P1): upto() now fails (retryable) if the blockhash RPC errors instead of issuing a 402 with no blockhash the in-SDK client can't act on. - kit/gate.rs: surface that as a retryable 503 (not a header-less 402); test updated accordingly. Co-Authored-By: Claude Opus 4.8 (1M context) --- rust/crates/kit/src/gate.rs | 49 +++-- rust/crates/x402/src/server/upto.rs | 294 +++++++++++++++++++++++++++- 2 files changed, 319 insertions(+), 24 deletions(-) diff --git a/rust/crates/kit/src/gate.rs b/rust/crates/kit/src/gate.rs index 1dd2c794b..f6524412c 100644 --- a/rust/crates/kit/src/gate.rs +++ b/rust/crates/kit/src/gate.rs @@ -628,19 +628,31 @@ where } /// Build the 402 response advertising the x402 `upto` challenge. +/// +/// If the challenge can't be built — e.g. the operator's RPC is down so no +/// recent blockhash is available — return a retryable `503` instead of a `402` +/// carrying no challenge the client could act on. fn upto_challenge_response(upto: &X402Upto, amount: &str) -> Response { + let (name, value) = match upto.payment_required_header(amount) { + Ok(header) => header, + Err(e) => { + tracing::warn!(amount = %amount, error = %e, "failed to build upto challenge"); + return ( + StatusCode::SERVICE_UNAVAILABLE, + "payment challenge temporarily unavailable", + ) + .into_response(); + } + }; let mut resp = (StatusCode::PAYMENT_REQUIRED, "Payment Required").into_response(); - match upto.payment_required_header(amount) { - Ok((name, value)) => match ( - HeaderName::from_bytes(name.as_bytes()), - HeaderValue::from_str(&value), - ) { - (Ok(n), Ok(v)) => { - resp.headers_mut().insert(n, v); - } - _ => tracing::warn!(amount = %amount, "invalid x402 upto PAYMENT-REQUIRED header"), - }, - Err(e) => tracing::warn!(amount = %amount, error = %e, "failed to build upto challenge"), + match ( + HeaderName::from_bytes(name.as_bytes()), + HeaderValue::from_str(&value), + ) { + (Ok(n), Ok(v)) => { + resp.headers_mut().insert(n, v); + } + _ => tracing::warn!(amount = %amount, "invalid x402 upto PAYMENT-REQUIRED header"), } resp.headers_mut() .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store")); @@ -816,7 +828,8 @@ mod tests { } /// PayKit with an operator signer (enables `upto`). A bogus RPC URL makes - /// the best-effort blockhash fetch fail fast so the challenge builds offline. + /// the recent-blockhash fetch fail fast — so the challenge can't be built + /// offline and the gate surfaces a retryable 503. fn upto_paykit() -> PayKit { PayKit::new(PayKitConfig { recipient: TEST_RECIPIENT.to_string(), @@ -842,19 +855,17 @@ mod tests { } #[tokio::test(flavor = "multi_thread")] - async fn paid_upto_unpaid_returns_402_with_upto_challenge() { + async fn paid_upto_unpaid_returns_503_when_challenge_rpc_unavailable() { + // With the operator RPC unreachable, no recent blockhash can be embedded + // in the challenge, so the gate returns a retryable 503 rather than a + // 402 carrying a challenge the in-SDK client could not act on. let pay = upto_paykit(); let app: Router = Router::new().route("/u", paid_upto_get(report, "1.00", &pay)); let resp = app .oneshot(Request::builder().uri("/u").body(Body::empty()).unwrap()) .await .unwrap(); - assert_eq!(resp.status(), StatusCode::PAYMENT_REQUIRED); - assert!(resp.headers().contains_key("payment-required")); - assert_eq!( - resp.headers().get(header::CACHE_CONTROL).unwrap(), - "no-store" - ); + assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE); } fn ctx<'a>(method: &'a Method, uri: &'a Uri, headers: &'a HeaderMap) -> PriceCtx<'a> { diff --git a/rust/crates/x402/src/server/upto.rs b/rust/crates/x402/src/server/upto.rs index de813cc8b..115487fa8 100644 --- a/rust/crates/x402/src/server/upto.rs +++ b/rust/crates/x402/src/server/upto.rs @@ -12,8 +12,9 @@ //! amount and submits `settle_and_finalize` + `distribute`, refunding //! `deposit − actual` to the payer. +use std::collections::HashSet; use std::str::FromStr; -use std::sync::Arc; +use std::sync::{Arc, Mutex}; use std::time::{SystemTime, UNIX_EPOCH}; use solana_keychain::SolanaSigner; @@ -42,6 +43,10 @@ use crate::{PAYMENT_REQUIRED_HEADER, PAYMENT_RESPONSE_HEADER, X402_VERSION_V2}; /// `ChannelStatus::Open` discriminant in the generated client. const CHANNEL_STATUS_OPEN: u8 = 0; +/// `Open` instruction discriminator in the generated payment-channels client +/// (`payment_channels_client::generated::instructions::OPEN_DISCRIMINATOR`). +const OPEN_INSTRUCTION_DISCRIMINATOR: u8 = 1; + /// Server configuration for the Solana x402 `upto` scheme. #[derive(Clone)] pub struct UptoConfig { @@ -72,7 +77,10 @@ pub struct UptoConfig { /// A confirmed, on-chain-verified channel open, carried from /// [`X402Upto::verify_open`] to [`X402Upto::settle_actual`]. -#[derive(Debug, Clone)] +/// +/// Not `Clone`: it holds the in-flight guard for its channel, released when this +/// value is dropped (after settlement, or on any error/panic path). +#[derive(Debug)] pub struct VerifiedUptoOpen { pub channel_id: Pubkey, pub payer: Pubkey, @@ -83,6 +91,26 @@ pub struct VerifiedUptoOpen { pub max_amount: u64, pub expires_at: i64, pub network: String, + /// Releases this channel from the in-flight set on drop. + _in_flight: InFlightGuard, +} + +/// RAII guard removing a channel id from [`X402Upto`]'s in-flight set on drop, +/// so a channel being processed can't be served concurrently (replay), and the +/// slot is always freed — including on early-return errors or a handler panic. +#[derive(Debug)] +struct InFlightGuard { + set: Arc>>, + channel_id: Pubkey, +} + +impl Drop for InFlightGuard { + fn drop(&mut self) { + self.set + .lock() + .unwrap_or_else(|e| e.into_inner()) + .remove(&self.channel_id); + } } /// Server-side payment handler for the Solana x402 `upto` scheme. @@ -91,6 +119,9 @@ pub struct X402Upto { rpc: Arc, config: UptoConfig, operator: Pubkey, + /// Channel ids currently being processed (verify_open → settle_actual), to + /// reject concurrent replays of the same authorization. + in_flight: Arc>>, } fn now_unix() -> i64 { @@ -118,6 +149,7 @@ impl X402Upto { rpc: Arc::new(RpcClient::new(rpc_url)), config, operator, + in_flight: Arc::new(Mutex::new(HashSet::new())), }) } @@ -188,11 +220,15 @@ impl X402Upto { /// so the client can build the channel `open` without an extra RPC. pub fn upto(&self, max_amount: &str) -> Result { let mut requirement = self.upto_requirements(max_amount)?; - requirement.extra.recent_blockhash = self + // Fail loudly (retryable) rather than issuing a 402 with no blockhash: + // the in-SDK client hard-requires `extra.recentBlockhash` to build the + // channel open, so a silent `None` would surface as a non-retryable + // payment failure on a transient RPC hiccup. + let blockhash = self .rpc .get_latest_blockhash() - .ok() - .map(|hash| hash.to_string()); + .map_err(|e| Error::Rpc(format!("failed to fetch recent blockhash: {e}")))?; + requirement.extra.recent_blockhash = Some(blockhash.to_string()); let resource = (!self.config.resource.is_empty()).then(|| ResourceInfo { url: self.config.resource.clone(), description: self.config.description.clone(), @@ -266,12 +302,33 @@ impl X402Upto { .map_err(|e| Error::Other(format!("invalid payer: {e}")))?; let max = payload.max_amount()?; + // In-flight dedup: reject a concurrent request replaying the same + // channel before its first settlement finalizes. The guard releases the + // slot on drop — including every early-return below and a handler panic. + let in_flight = { + let mut set = self.in_flight.lock().unwrap_or_else(|e| e.into_inner()); + if !set.insert(channel_id) { + return Err(Error::Other( + "channel is already being processed (concurrent request)".to_string(), + )); + } + InFlightGuard { + set: self.in_flight.clone(), + channel_id, + } + }; + // Broadcast the client-signed open (pull). Push (already broadcast) is // not yet supported; require the transaction. let open_tx_b64 = payload.open_transaction.as_deref().ok_or_else(|| { Error::Other("payment-channel profile requires openTransaction (pull)".to_string()) })?; let mut tx = decode_transaction(open_tx_b64)?; + // SECURITY: the operator co-signs as fee payer, so it must only ever + // sign the expected channel-open instruction — never an arbitrary + // operator-authorized instruction (e.g. a SystemProgram transfer that + // drains the operator). Validate before co-signing/broadcasting. + self.validate_open_transaction(&tx, &payer, &expected_payee, &expected_mint, &channel_id)?; self.cosign_fee_payer(&mut tx).await?; self.rpc .send_and_confirm_transaction(&tx) @@ -307,6 +364,16 @@ impl X402Upto { channel.deposit ))); } + // Bind the on-chain payer: settlement's `distribute` refunds to this + // account, and the program enforces it equals `channel.payer`. A + // mismatch would fail settlement on-chain after the resource was served. + if pc::from_address(&channel.payer) != payer { + return Err(Error::Other(format!( + "channel payer {} does not match payload.from {}", + pc::pubkey_string(&pc::from_address(&channel.payer)), + pc::pubkey_string(&payer) + ))); + } Ok(VerifiedUptoOpen { channel_id, @@ -318,6 +385,7 @@ impl X402Upto { max_amount: max, expires_at: payload.expires_at, network: requirements.network, + _in_flight: in_flight, }) } @@ -401,6 +469,33 @@ impl X402Upto { }) } + /// Verify the client transaction is exactly the expected payment-channels + /// `open` instruction before the operator co-signs it as fee payer. + /// + /// Without this, a malicious client could include any operator-authorized + /// instruction (e.g. a SystemProgram transfer draining the operator) and the + /// operator would blindly sign it. We require a single instruction, on the + /// payment-channels program, with the `open` discriminator, whose accounts + /// bind the expected payer / payee / mint / operator / channel. + fn validate_open_transaction( + &self, + tx: &VersionedTransaction, + payer: &Pubkey, + payee: &Pubkey, + mint: &Pubkey, + channel_id: &Pubkey, + ) -> Result<(), Error> { + validate_open_instruction( + tx, + &self.program_id()?, + &self.operator, + payer, + payee, + mint, + channel_id, + ) + } + /// Co-sign the fee-payer (operator) slot of a partially-signed transaction. async fn cosign_fee_payer(&self, tx: &mut VersionedTransaction) -> Result<(), Error> { let account_keys = tx.message.static_account_keys(); @@ -443,3 +538,192 @@ fn decode_transaction(b64: &str) -> Result { .or_else(|_| bincode::deserialize::(&bytes)) .map_err(|e| Error::Other(format!("invalid transaction: {e}"))) } + +/// Assert `tx` is exactly the expected payment-channels `open` instruction so the +/// operator can safely co-sign it as fee payer (see [`X402Upto::validate_open_transaction`]). +fn validate_open_instruction( + tx: &VersionedTransaction, + program_id: &Pubkey, + operator: &Pubkey, + payer: &Pubkey, + payee: &Pubkey, + mint: &Pubkey, + channel_id: &Pubkey, +) -> Result<(), Error> { + let keys = tx.message.static_account_keys(); + let instructions = tx.message.instructions(); + if instructions.len() != 1 { + return Err(Error::Other(format!( + "open transaction must contain exactly one instruction, found {}", + instructions.len() + ))); + } + let ix = &instructions[0]; + let prog = keys + .get(ix.program_id_index as usize) + .ok_or_else(|| Error::Other("open instruction program id out of range".to_string()))?; + if prog != program_id { + return Err(Error::Other( + "open transaction targets an unexpected program".to_string(), + )); + } + if ix.data.first() != Some(&OPEN_INSTRUCTION_DISCRIMINATOR) { + return Err(Error::Other( + "open transaction is not a channel-open instruction".to_string(), + )); + } + // Account order from `build_open_instruction`: + // [payer, payee, mint, authorized_signer, channel, ...]. + let account_at = |pos: usize| -> Option { + ix.accounts + .get(pos) + .and_then(|&i| keys.get(i as usize)) + .copied() + }; + let expect = |pos: usize, want: &Pubkey, label: &str| -> Result<(), Error> { + match account_at(pos) { + Some(got) if got == *want => Ok(()), + other => Err(Error::Other(format!( + "open transaction {label} mismatch: expected {}, got {}", + pc::pubkey_string(want), + other + .map(|p| pc::pubkey_string(&p)) + .unwrap_or_else(|| "".to_string()) + ))), + } + }; + expect(0, payer, "payer")?; + expect(1, payee, "payee")?; + expect(2, mint, "mint")?; + expect(3, operator, "authorized_signer")?; + expect(4, channel_id, "channel")?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use solana_pay_core::payment_channels::{ + build_open_instruction, derive_channel_addresses, OpenChannelParams, + }; + + fn token_program() -> Pubkey { + Pubkey::from_str("TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA").unwrap() + } + + fn open_params( + payer: Pubkey, + payee: Pubkey, + mint: Pubkey, + operator: Pubkey, + ) -> OpenChannelParams { + OpenChannelParams { + payer, + payee, + mint, + authorized_signer: operator, + salt: 7, + deposit: 1_000_000, + grace_period: 900, + recipients: vec![], + token_program: token_program(), + program_id: pc::default_program_id(), + } + } + + fn unsigned_tx(instructions: &[solana_instruction::Instruction]) -> VersionedTransaction { + let msg = Message::new(instructions, Some(&Pubkey::new_unique())); + VersionedTransaction::from(Transaction::new_unsigned(msg)) + } + + #[test] + fn accepts_a_well_formed_open() { + let (payer, payee, mint, operator) = ( + Pubkey::new_unique(), + Pubkey::new_unique(), + Pubkey::new_unique(), + Pubkey::new_unique(), + ); + let params = open_params(payer, payee, mint, operator); + let channel = derive_channel_addresses(¶ms).channel; + let tx = unsigned_tx(&[build_open_instruction(¶ms)]); + + assert!(validate_open_instruction( + &tx, + &pc::default_program_id(), + &operator, + &payer, + &payee, + &mint, + &channel, + ) + .is_ok()); + } + + #[test] + fn rejects_a_foreign_program_instruction() { + // The SOL-drain vector: a SystemProgram transfer from the operator. + let operator = Pubkey::new_unique(); + let system = Pubkey::from_str("11111111111111111111111111111111").unwrap(); + let evil = solana_instruction::Instruction { + program_id: system, + accounts: vec![], + data: vec![2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], // transfer-ish + }; + let tx = unsigned_tx(&[evil]); + assert!(validate_open_instruction( + &tx, + &pc::default_program_id(), + &operator, + &Pubkey::new_unique(), + &Pubkey::new_unique(), + &Pubkey::new_unique(), + &Pubkey::new_unique(), + ) + .is_err()); + } + + #[test] + fn rejects_extra_instructions_and_account_mismatch() { + let (payer, payee, mint, operator) = ( + Pubkey::new_unique(), + Pubkey::new_unique(), + Pubkey::new_unique(), + Pubkey::new_unique(), + ); + let params = open_params(payer, payee, mint, operator); + let channel = derive_channel_addresses(¶ms).channel; + let open = build_open_instruction(¶ms); + + // A second instruction smuggled in alongside the open. + let extra = solana_instruction::Instruction { + program_id: Pubkey::from_str("11111111111111111111111111111111").unwrap(), + accounts: vec![], + data: vec![], + }; + let two = unsigned_tx(&[open.clone(), extra]); + assert!(validate_open_instruction( + &two, + &pc::default_program_id(), + &operator, + &payer, + &payee, + &mint, + &channel, + ) + .is_err()); + + // Right shape, wrong expected payee. + let one = unsigned_tx(&[open]); + assert!(validate_open_instruction( + &one, + &pc::default_program_id(), + &operator, + &payer, + &Pubkey::new_unique(), + &mint, + &channel, + ) + .is_err()); + } +} From 154fbe9788c2755adae35f4410c4a740a666b065 Mon Sep 17 00:00:00 2001 From: EfeDurmaz16 Date: Fri, 19 Jun 2026 16:25:17 +0300 Subject: [PATCH 6/6] fix(x402): reject unsupported upto split channels --- rust/crates/x402/src/server/upto.rs | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/rust/crates/x402/src/server/upto.rs b/rust/crates/x402/src/server/upto.rs index 115487fa8..d2deaebfe 100644 --- a/rust/crates/x402/src/server/upto.rs +++ b/rust/crates/x402/src/server/upto.rs @@ -353,6 +353,9 @@ impl X402Upto { actual: pc::pubkey_string(&pc::from_address(&channel.payee)), }); } + // This first slice does not advertise split recipients, so bind the + // confirmed channel to the empty-recipient distribution before serving. + validate_empty_recipient_distribution_hash(&channel.distribution_hash)?; if pc::from_address(&channel.authorized_signer) != self.operator { return Err(Error::Other( "channel authorized_signer is not the operator".to_string(), @@ -539,6 +542,16 @@ fn decode_transaction(b64: &str) -> Result { .map_err(|e| Error::Other(format!("invalid transaction: {e}"))) } +fn validate_empty_recipient_distribution_hash(distribution_hash: &[u8; 32]) -> Result<(), Error> { + let expected = pc::distribution_hash(&[]); + if distribution_hash != &expected { + return Err(Error::Other( + "x402 upto currently supports only empty-recipient payment channels".to_string(), + )); + } + Ok(()) +} + /// Assert `tx` is exactly the expected payment-channels `open` instruction so the /// operator can safely co-sign it as fee payer (see [`X402Upto::validate_open_transaction`]). fn validate_open_instruction( @@ -726,4 +739,16 @@ mod tests { ) .is_err()); } + + #[test] + fn rejects_non_empty_recipient_distribution_hash() { + let empty = pc::distribution_hash(&[]); + assert!(validate_empty_recipient_distribution_hash(&empty).is_ok()); + + let non_empty = pc::distribution_hash(&[pc::Distribution { + recipient: Pubkey::new_unique(), + bps: 10_000, + }]); + assert!(validate_empty_recipient_distribution_hash(&non_empty).is_err()); + } }