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
1 change: 1 addition & 0 deletions stellar/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ members = [
"stealth-sender",
"stealth-splitter",
"wraith-names",
"contracts/stellar/stealth-batch-sender",
"bench",
"wraith-asset-policy",
]
Expand Down
24 changes: 24 additions & 0 deletions stellar/stealth-batch-sender/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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
100 changes: 100 additions & 0 deletions stellar/stealth-batch-sender/src/lib.rs
Original file line number Diff line number Diff line change
@@ -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<Transfer>,
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
}
}
162 changes: 162 additions & 0 deletions stellar/stealth-batch-sender/src/test.rs
Original file line number Diff line number Diff line change
@@ -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);
}