diff --git a/stellar/Cargo.toml b/stellar/Cargo.toml index 35434b3..7cd2fc5 100644 --- a/stellar/Cargo.toml +++ b/stellar/Cargo.toml @@ -5,6 +5,7 @@ members = [ "stealth-sender", "stealth-splitter", "wraith-names", + "contracts/stellar/stealth-batch-sender", "bench", "wraith-asset-policy", ] diff --git a/stellar/stealth-batch-sender/Cargo.toml b/stellar/stealth-batch-sender/Cargo.toml new file mode 100644 index 0000000..a96e078 --- /dev/null +++ b/stellar/stealth-batch-sender/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "stealth-batch-sender" +version = "0.1.0" +edition = "2021" +description = "Atomic batch stealth sends for the Wraith Protocol on Stellar" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +soroban-sdk = { version = "21.0.0", features = ["alloc"] } + +[dev-dependencies] +soroban-sdk = { version = "21.0.0", features = ["testutils", "alloc"] } + +[profile.release] +opt-level = "z" +overflow-checks = true +debug = 0 +strip = "symbols" +debug-assertions = false +panic = "abort" +codegen-units = 1 +lto = true \ No newline at end of file diff --git a/stellar/stealth-batch-sender/src/lib.rs b/stellar/stealth-batch-sender/src/lib.rs new file mode 100644 index 0000000..e603ac3 --- /dev/null +++ b/stellar/stealth-batch-sender/src/lib.rs @@ -0,0 +1,100 @@ +#![no_std] + +use soroban_sdk::{ + contract, contractimpl, contracttype, symbol_short, + token::Client as TokenClient, + Address, Env, Vec, +}; + +/// Maximum transfers per batch — justified against Soroban's ~100M instruction +/// budget. Each transfer costs ~500K instructions (token transfer + event emit). +/// 100 transfers = ~50M instructions, leaving headroom for overhead. +pub const MAX_BATCH_SIZE: u32 = 100; + +/// A single stealth transfer within a batch. +/// Mirrors the EVM WraithSender batchSendETH/batchSendERC20 structure. +#[contracttype] +#[derive(Clone)] +pub struct Transfer { + /// Pre-computed stealth address (recipient) + pub stealth_address: Address, + /// Ephemeral public key for the recipient to scan with + pub ephemeral_pub_key: soroban_sdk::Bytes, + /// Token amount (in the asset's base unit) + pub amount: i128, +} + +#[contract] +pub struct StealthBatchSender; + +#[contractimpl] +impl StealthBatchSender { + /// Atomically send `asset` tokens from `from` to N pre-computed stealth + /// addresses in a single transaction. + /// + /// # All-or-nothing semantics + /// Soroban's transaction model guarantees atomicity: if any individual + /// transfer panics (e.g. insufficient balance mid-batch), the entire + /// transaction is rolled back. No partial sends are possible. + /// + /// # Resource budget + /// Capped at MAX_BATCH_SIZE (100) transfers. This keeps instruction usage + /// well under Soroban's per-transaction limit while still being ~100x more + /// efficient than N individual stealth-sender::send calls (one auth, one + /// ledger round-trip vs N). + pub fn batch_send( + env: Env, + from: Address, + transfers: Vec, + asset: Address, + ) { + // Auth: sender must sign once for the entire batch + from.require_auth(); + + // Validate batch size + let count = transfers.len(); + if count == 0 { + panic!("batch must contain at least one transfer"); + } + if count > MAX_BATCH_SIZE { + panic!("batch exceeds MAX_BATCH_SIZE"); + } + + let token = TokenClient::new(&env, &asset); + + for transfer in transfers.iter() { + // Validate individual transfer + if transfer.amount <= 0 { + panic!("transfer amount must be positive"); + } + if transfer.ephemeral_pub_key.is_empty() { + panic!("ephemeral_pub_key must not be empty"); + } + + // Execute transfer — any failure here aborts the whole tx (atomicity) + token.transfer(&from, &transfer.stealth_address, &transfer.amount); + + // Per-transfer announcement (mirrors stealth-sender pattern) + env.events().publish( + (symbol_short!("ANNOUNCE"),), + ( + transfer.stealth_address.clone(), + transfer.ephemeral_pub_key.clone(), + transfer.amount, + asset.clone(), + ), + ); + } + + // Batch-level summary event + env.events().publish( + (symbol_short!("BATCH"),), + (from, count, asset), + ); + } + + /// Query the maximum allowed batch size. + pub fn max_batch_size(_env: Env) -> u32 { + MAX_BATCH_SIZE + } +} \ No newline at end of file diff --git a/stellar/stealth-batch-sender/src/test.rs b/stellar/stealth-batch-sender/src/test.rs new file mode 100644 index 0000000..a685086 --- /dev/null +++ b/stellar/stealth-batch-sender/src/test.rs @@ -0,0 +1,162 @@ +#![cfg(test)] + +use super::*; +use soroban_sdk::{ + testutils::{Address as _, AuthorizedFunction, AuthorizedInvocation}, + token::{Client as TokenClient, StellarAssetClient}, + vec, Address, Bytes, Env, IntoVal, +}; + +fn create_token<'a>( + env: &Env, + admin: &Address, +) -> (TokenClient<'a>, StellarAssetClient<'a>) { + let contract_id = env.register_stellar_asset_contract_v2(admin.clone()); + ( + TokenClient::new(env, &contract_id.address()), + StellarAssetClient::new(env, &contract_id.address()), + ) +} + +fn dummy_pub_key(env: &Env) -> Bytes { + Bytes::from_slice(env, &[0x02u8; 33]) // compressed secp256k1 pubkey +} + +#[test] +fn test_batch_send_success() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let sender = Address::generate(&env); + let (token, token_admin) = create_token(&env, &admin); + + // Mint 1000 tokens to sender + token_admin.mint(&sender, &1000); + + let contract_id = env.register_contract(None, StealthBatchSender); + let client = StealthBatchSenderClient::new(&env, &contract_id); + + let stealth1 = Address::generate(&env); + let stealth2 = Address::generate(&env); + let stealth3 = Address::generate(&env); + + let transfers = vec![ + &env, + Transfer { stealth_address: stealth1.clone(), ephemeral_pub_key: dummy_pub_key(&env), amount: 100 }, + Transfer { stealth_address: stealth2.clone(), ephemeral_pub_key: dummy_pub_key(&env), amount: 200 }, + Transfer { stealth_address: stealth3.clone(), ephemeral_pub_key: dummy_pub_key(&env), amount: 300 }, + ]; + + client.batch_send(&sender, &transfers, &token.address); + + // Verify balances + assert_eq!(token.balance(&sender), 400); + assert_eq!(token.balance(&stealth1), 100); + assert_eq!(token.balance(&stealth2), 200); + assert_eq!(token.balance(&stealth3), 300); +} + +#[test] +#[should_panic(expected = "batch exceeds MAX_BATCH_SIZE")] +fn test_batch_size_cap() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let sender = Address::generate(&env); + let (token, token_admin) = create_token(&env, &admin); + token_admin.mint(&sender, &100_000); + + let contract_id = env.register_contract(None, StealthBatchSender); + let client = StealthBatchSenderClient::new(&env, &contract_id); + + // Build 101 transfers (over cap) + let mut transfers = Vec::new(&env); + for _ in 0..=MAX_BATCH_SIZE { + transfers.push_back(Transfer { + stealth_address: Address::generate(&env), + ephemeral_pub_key: dummy_pub_key(&env), + amount: 1, + }); + } + + client.batch_send(&sender, &transfers, &token.address); +} + +#[test] +#[should_panic] +fn test_atomicity_on_mid_batch_failure() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let sender = Address::generate(&env); + let (token, token_admin) = create_token(&env, &admin); + + // Mint only enough for 2 transfers, but send 3 + token_admin.mint(&sender, &150); + + let contract_id = env.register_contract(None, StealthBatchSender); + let client = StealthBatchSenderClient::new(&env, &contract_id); + + let transfers = vec![ + &env, + Transfer { stealth_address: Address::generate(&env), ephemeral_pub_key: dummy_pub_key(&env), amount: 100 }, + Transfer { stealth_address: Address::generate(&env), ephemeral_pub_key: dummy_pub_key(&env), amount: 100 }, // fails here + Transfer { stealth_address: Address::generate(&env), ephemeral_pub_key: dummy_pub_key(&env), amount: 100 }, + ]; + + // Must panic — and because Soroban tx is atomic, first transfer is also rolled back + client.batch_send(&sender, &transfers, &token.address); +} + +#[test] +#[should_panic(expected = "batch must contain at least one transfer")] +fn test_empty_batch_rejected() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let sender = Address::generate(&env); + let (token, _) = create_token(&env, &admin); + + let contract_id = env.register_contract(None, StealthBatchSender); + let client = StealthBatchSenderClient::new(&env, &contract_id); + + client.batch_send(&sender, &Vec::new(&env), &token.address); +} + +#[test] +#[should_panic(expected = "transfer amount must be positive")] +fn test_zero_amount_rejected() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let sender = Address::generate(&env); + let (token, token_admin) = create_token(&env, &admin); + token_admin.mint(&sender, &100); + + let contract_id = env.register_contract(None, StealthBatchSender); + let client = StealthBatchSenderClient::new(&env, &contract_id); + + let transfers = vec![ + &env, + Transfer { + stealth_address: Address::generate(&env), + ephemeral_pub_key: dummy_pub_key(&env), + amount: 0, + }, + ]; + + client.batch_send(&sender, &transfers, &token.address); +} + +#[test] +fn test_max_batch_size_query() { + let env = Env::default(); + let contract_id = env.register_contract(None, StealthBatchSender); + let client = StealthBatchSenderClient::new(&env, &contract_id); + assert_eq!(client.max_batch_size(), 100u32); +} \ No newline at end of file