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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 10 additions & 5 deletions rust/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 26 additions & 0 deletions rust/crates/core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
32 changes: 29 additions & 3 deletions rust/crates/core/src/lib.rs
Original file line number Diff line number Diff line change
@@ -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<T> = std::result::Result<T, Error>;
Original file line number Diff line number Diff line change
@@ -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::{
Expand All @@ -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";

Expand Down Expand Up @@ -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")
}
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
Expand All @@ -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(&params.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(&params.program_id))
.open_args(OpenArgs {
Expand Down Expand Up @@ -423,6 +457,57 @@ 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<Distribution>,
token_program: &Pubkey,
program_id: &Pubkey,
fee_payer: &Pubkey,
recent_blockhash: Hash,
) -> Result<PaymentChannelOpenTransaction> {
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(&params).channel;
let ix = build_open_instruction(&params);
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::*;
Expand Down
121 changes: 121 additions & 0 deletions rust/crates/core/src/units.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
//! 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<String> {
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());
}
}
5 changes: 5 additions & 0 deletions rust/crates/kit/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Loading
Loading