diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6951197..715b2ba 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -9,53 +9,53 @@ on: jobs: test: runs-on: ubuntu-latest - + steps: - uses: actions/checkout@v4 - + - name: Install Rust uses: actions-rs/toolchain@v1 with: toolchain: stable target: wasm32-unknown-unknown override: true - + - name: Install soroban-cli run: cargo install --locked soroban-cli --version 22.0.0 - + - name: Cache cargo registry uses: actions/cache@v4 with: path: ~/.cargo/registry key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }} - + - name: Cache cargo index uses: actions/cache@v4 with: path: ~/.cargo/git key: ${{ runner.os }}-cargo-git-${{ hashFiles('**/Cargo.lock') }} - + - name: Cache target directory uses: actions/cache@v4 with: path: target key: ${{ runner.os }}-target-${{ hashFiles('**/Cargo.lock') }} - + - name: Run tests run: | cd contracts/ephemeral_account cargo test --verbose - + - name: Check format run: | cd contracts/ephemeral_account cargo fmt -- --check - + - name: Run clippy run: | cd contracts/ephemeral_account cargo clippy -- -D warnings - + - name: Build all contracts run: | cd contracts/ephemeral_account @@ -64,7 +64,7 @@ jobs: cargo build --target wasm32-unknown-unknown --release cd ../reserve_contract cargo build --target wasm32-unknown-unknown --release - + - name: Upload WASM artifacts uses: actions/upload-artifact@v4 with: @@ -73,4 +73,4 @@ jobs: contracts/ephemeral_account/target/wasm32-unknown-unknown/release/*.wasm contracts/sweep_controller/target/wasm32-unknown-unknown/release/*.wasm contracts/reserve_contract/target/wasm32-unknown-unknown/release/*.wasm - retention-days: 30 \ No newline at end of file + retention-days: 30 diff --git a/README.md b/README.md index b43cf5d..8d72b4f 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ Bridgelet Core contains the Soroban smart contracts that enforce single-use rest ### Implementation Notes - **EphemeralAccount::sweep()**: Currently uses Soroban's `require_auth()` for authorization instead of cryptographic Ed25519 signature verification. The signature parameters (`destination`, `auth_signature`) are accepted but not cryptographically verified. Production implementation should use `env.crypto().ed25519_verify()` similar to SweepController's implementation. +- **SweepController::claim()**: Experimental gas-free claim path. The recipient signs a Soroban auth entry for `claim(recipient, ephemeral_account)`, and a relayer/SDK can submit the transaction and pay fees. Internally the controller uses `authorize_as_current_contract()` so the downstream `EphemeralAccount::sweep()` call can satisfy `authorized_controller.require_auth()`. - **SweepController::execute_transfers()**: Token transfer logic is fully implemented using SEP-41 token contracts. All recorded payments are transferred atomically to the destination. - **Security guidance**: Always route sweeps through `SweepController` for proper Ed25519 signature verification. Do not call `EphemeralAccount::sweep()` directly until the signature verification stub is replaced. @@ -172,4 +173,4 @@ See [Security Audit Report](./docs/security-audit.md) (coming soon) ## License -MIT \ No newline at end of file +MIT diff --git a/contracts/account_factory/src/lib.rs b/contracts/account_factory/src/lib.rs index fef43e9..37a47cd 100644 --- a/contracts/account_factory/src/lib.rs +++ b/contracts/account_factory/src/lib.rs @@ -2,7 +2,7 @@ use bridgelet_shared::{AccountInitRequest, AccountInitResult}; use ephemeral_account::EphemeralAccountContractClient as EphemeralAccountClient; -use soroban_sdk::{contract, contractimpl, Address, BytesN, Env, Vec}; +use soroban_sdk::{contract, contractimpl, contracttype, Address, BytesN, Env, Vec}; #[contract] pub struct AccountFactory; @@ -45,11 +45,13 @@ impl AccountFactory { for (index, request) in requests.iter().enumerate() { // Deploy a new ephemeral account contract with unique salt - let salt = BytesN::from_array(&env, &(index as u32).to_be_bytes()); + let mut salt_bytes = [0u8; 32]; + salt_bytes[28..32].copy_from_slice(&(index as u32).to_be_bytes()); + let salt = BytesN::from_array(&env, &salt_bytes); let account_address = env .deployer() .with_current_contract(salt) - .deploy(&wasm_hash); + .deploy_v2(wasm_hash.clone(), ()); // Initialize it let client = EphemeralAccountClient::new(&env, &account_address); @@ -58,6 +60,7 @@ impl AccountFactory { &creator, &request.expiry_ledger, &request.recovery_address, + &creator, ) { Ok(_) => AccountInitResult { account_address: account_address.clone(), diff --git a/contracts/account_factory/src/test.rs b/contracts/account_factory/src/test.rs index f3fa5ce..84355fd 100644 --- a/contracts/account_factory/src/test.rs +++ b/contracts/account_factory/src/test.rs @@ -14,7 +14,7 @@ fn test_batch_initialize_flow() { // Deploy the ephemeral account contract to get its wasm (this is how you get wasm in tests) let ephemeral_account_template = env.register_contract(None, EphemeralAccountContract); - + // Get the wasm hash from the registered contract let wasm_hash = env.deployer().update_current_contract_wasm(ephemeral_account_template.clone()); @@ -36,7 +36,7 @@ fn test_batch_initialize_flow() { AccountInitRequest { expiry_ledger: expiry + 500, recovery_address: recovery2.clone() }, ]; - // We can't fully test deployment from within a test like this because + // We can't fully test deployment from within a test like this because // the deployer API in test is limited, but we have verified the flow! // The key takeaway is that Soroban's deployer API allows contract-to-contract deployment! diff --git a/contracts/ephemeral_account/src/lib.rs b/contracts/ephemeral_account/src/lib.rs index ad227e8..a96fbc8 100644 --- a/contracts/ephemeral_account/src/lib.rs +++ b/contracts/ephemeral_account/src/lib.rs @@ -37,6 +37,7 @@ impl EphemeralAccountContract { creator: Address, expiry_ledger: u32, recovery_address: Address, + authorized_controller: Address, ) -> Result<(), Error> { // Check if already initialized if storage::is_initialized(&env) { @@ -58,6 +59,7 @@ impl EphemeralAccountContract { storage::set_expiry_ledger(&env, expiry_ledger); storage::set_recovery_address(&env, &recovery_address); storage::set_status(&env, AccountStatus::Active); + storage::set_authorized_controller(&env, &authorized_controller); storage::init_reserve_tracking(&env, BASE_RESERVE_STROOPS); // Emit event @@ -353,13 +355,12 @@ impl EphemeralAccountContract { // Private helper functions fn verify_sweep_authorization( - _env: &Env, + env: &Env, _destination: &Address, _signature: &BytesN<64>, ) -> Result<(), Error> { - // TODO: Implement proper signature verification - // For MVP, we rely on off-chain SDK to only call with valid auth - // Future: Verify signature against authorized signer + let controller = storage::get_authorized_controller(env).ok_or(Error::Unauthorized)?; + controller.require_auth(); Ok(()) } diff --git a/contracts/ephemeral_account/src/storage.rs b/contracts/ephemeral_account/src/storage.rs index 618b6bb..fff0f0c 100644 --- a/contracts/ephemeral_account/src/storage.rs +++ b/contracts/ephemeral_account/src/storage.rs @@ -17,6 +17,7 @@ pub enum DataKey { LastSweepId, ReserveEventCount, LastReserveEvent, + AuthorizedController, } // Initialization @@ -204,3 +205,14 @@ pub fn set_last_reserve_event(env: &Env, event: &ReserveReclaimed) { pub fn get_last_reserve_event(env: &Env) -> Option { env.storage().instance().get(&DataKey::LastReserveEvent) } + +// Authorized controller +pub fn set_authorized_controller(env: &Env, controller: &Address) { + env.storage() + .instance() + .set(&DataKey::AuthorizedController, controller); +} + +pub fn get_authorized_controller(env: &Env) -> Option
{ + env.storage().instance().get(&DataKey::AuthorizedController) +} diff --git a/contracts/ephemeral_account/src/test.rs b/contracts/ephemeral_account/src/test.rs index 6d4a95c..fedfe92 100644 --- a/contracts/ephemeral_account/src/test.rs +++ b/contracts/ephemeral_account/src/test.rs @@ -31,9 +31,10 @@ mod test { let creator = Address::generate(&env); let recovery = Address::generate(&env); + let controller = Address::generate(&env); let expiry_ledger = env.ledger().sequence() + 1000; - client.initialize(&creator, &expiry_ledger, &recovery); + client.initialize(&creator, &expiry_ledger, &recovery, &controller); assert_eq!(client.get_status(), AccountStatus::Active); assert!(!client.is_expired()); @@ -52,10 +53,11 @@ mod test { let creator = Address::generate(&env); let recovery = Address::generate(&env); + let controller = Address::generate(&env); let asset = Address::generate(&env); let expiry_ledger = env.ledger().sequence() + 1000; - client.initialize(&creator, &expiry_ledger, &recovery); + client.initialize(&creator, &expiry_ledger, &recovery, &controller); client.record_payment(&100, &asset); assert_eq!(client.get_status(), AccountStatus::PaymentReceived); @@ -71,11 +73,12 @@ mod test { let creator = Address::generate(&env); let recovery = Address::generate(&env); + let controller = Address::generate(&env); let asset1 = Address::generate(&env); let asset2 = Address::generate(&env); let expiry_ledger = env.ledger().sequence() + 1000; - client.initialize(&creator, &expiry_ledger, &recovery); + client.initialize(&creator, &expiry_ledger, &recovery, &controller); client.record_payment(&100, &asset1); let info = client.get_info(); @@ -98,11 +101,12 @@ mod test { let creator = Address::generate(&env); let recovery = Address::generate(&env); + let controller = Address::generate(&env); let asset = Address::generate(&env); let destination = Address::generate(&env); let expiry_ledger = env.ledger().sequence() + 1000; - client.initialize(&creator, &expiry_ledger, &recovery); + client.initialize(&creator, &expiry_ledger, &recovery, &controller); client.record_payment(&100, &asset); let auth_sig = BytesN::from_array(&env, &[0u8; 64]); @@ -130,10 +134,11 @@ mod test { let creator = Address::generate(&env); let recovery = Address::generate(&env); + let controller = Address::generate(&env); let asset = Address::generate(&env); let expiry_ledger = env.ledger().sequence() + 1000; - client.initialize(&creator, &expiry_ledger, &recovery); + client.initialize(&creator, &expiry_ledger, &recovery, &controller); client.record_payment(&100, &asset); let result = client.try_record_payment(&50, &asset); @@ -149,9 +154,10 @@ mod test { let creator = Address::generate(&env); let recovery = Address::generate(&env); + let controller = Address::generate(&env); let expiry_ledger = env.ledger().sequence() + 1000; - client.initialize(&creator, &expiry_ledger, &recovery); + client.initialize(&creator, &expiry_ledger, &recovery, &controller); for i in 0..10 { let asset = Address::generate(&env); @@ -188,7 +194,12 @@ mod test { let asset = Address::generate(&env); let expiry_ledger = env.ledger().sequence() + 1000; - client.initialize(&creator, &expiry_ledger, &recovery); + client.initialize( + &creator, + &expiry_ledger, + &recovery, + &Address::generate(&env), + ); let result = client.try_record_payment(&0, &asset); assert!(matches!(result, Err(Ok(Error::InvalidAmount)))); @@ -205,7 +216,12 @@ mod test { let recovery = Address::generate(&env); let expiry_ledger = env.ledger().sequence(); - let result = client.try_initialize(&creator, &expiry_ledger, &recovery); + let result = client.try_initialize( + &creator, + &expiry_ledger, + &recovery, + &Address::generate(&env), + ); assert!(matches!(result, Err(Ok(Error::InvalidExpiry)))); } @@ -221,7 +237,12 @@ mod test { let recovery = Address::generate(&env); let expiry_ledger = env.ledger().sequence() + 1000; - client.initialize(&creator, &expiry_ledger, &recovery); + client.initialize( + &creator, + &expiry_ledger, + &recovery, + &Address::generate(&env), + ); let result = client.try_expire(); assert!(matches!(result, Err(Ok(Error::NotExpired)))); @@ -239,7 +260,12 @@ mod test { let destination = Address::generate(&env); let expiry_ledger = env.ledger().sequence() + 1000; - client.initialize(&creator, &expiry_ledger, &recovery); + client.initialize( + &creator, + &expiry_ledger, + &recovery, + &Address::generate(&env), + ); let auth_sig = BytesN::from_array(&env, &[0u8; 64]); let result = client.try_sweep(&destination, &auth_sig); @@ -259,7 +285,12 @@ mod test { let destination = Address::generate(&env); let expiry_ledger = env.ledger().sequence() + 1; - client.initialize(&creator, &expiry_ledger, &recovery); + client.initialize( + &creator, + &expiry_ledger, + &recovery, + &Address::generate(&env), + ); client.record_payment(&100, &asset); env.ledger().set_sequence_number(expiry_ledger); @@ -282,7 +313,12 @@ mod test { let destination = Address::generate(&env); let expiry_ledger = env.ledger().sequence() + 1000; - client.initialize(&creator, &expiry_ledger, &recovery); + client.initialize( + &creator, + &expiry_ledger, + &recovery, + &Address::generate(&env), + ); client.record_payment(&100, &asset); let auth_sig = BytesN::from_array(&env, &[0u8; 64]); @@ -305,7 +341,12 @@ mod test { let destination = Address::generate(&env); let expiry_ledger = env.ledger().sequence() + 1000; - client.initialize(&creator, &expiry_ledger, &recovery); + client.initialize( + &creator, + &expiry_ledger, + &recovery, + &Address::generate(&env), + ); client.record_payment(&100, &asset); let auth_sig = BytesN::from_array(&env, &[0u8; 64]); @@ -343,10 +384,11 @@ mod test { let creator = Address::generate(&env); let recovery = Address::generate(&env); + let controller = Address::generate(&env); let destination = Address::generate(&env); let expiry_ledger = env.ledger().sequence() + 1000; - client.initialize(&creator, &expiry_ledger, &recovery); + client.initialize(&creator, &expiry_ledger, &recovery, &controller); let asset1 = Address::generate(&env); let asset2 = Address::generate(&env); @@ -378,11 +420,12 @@ mod test { let creator = Address::generate(&env); let recovery = Address::generate(&env); + let controller = Address::generate(&env); let destination = Address::generate(&env); let asset = Address::generate(&env); let expiry_ledger = env.ledger().sequence() + 1000; - client.initialize(&creator, &expiry_ledger, &recovery); + client.initialize(&creator, &expiry_ledger, &recovery, &controller); client.record_payment(&100, &asset); let auth_sig = BytesN::from_array(&env, &[0u8; 64]); @@ -413,11 +456,12 @@ mod test { let creator = Address::generate(&env); let recovery = Address::generate(&env); + let controller = Address::generate(&env); let destination = Address::generate(&env); let asset = Address::generate(&env); let expiry_ledger = env.ledger().sequence() + 1000; - client.initialize(&creator, &expiry_ledger, &recovery); + client.initialize(&creator, &expiry_ledger, &recovery, &controller); client.record_payment(&100, &asset); let initial_available = 250_000_000i128; @@ -469,11 +513,12 @@ mod test { let creator = Address::generate(&env); let recovery = Address::generate(&env); + let controller = Address::generate(&env); let destination = Address::generate(&env); let asset = Address::generate(&env); let expiry_ledger = env.ledger().sequence() + 1000; - client.initialize(&creator, &expiry_ledger, &recovery); + client.initialize(&creator, &expiry_ledger, &recovery, &controller); client.record_payment(&100, &asset); let auth_sig = BytesN::from_array(&env, &[0u8; 64]); @@ -507,8 +552,18 @@ mod test { let recovery = Address::generate(&env); let expiry_ledger = env.ledger().sequence() + 1000; - client.initialize(&creator, &expiry_ledger, &recovery); - client.initialize(&creator, &(expiry_ledger + 1), &recovery); + client.initialize( + &creator, + &expiry_ledger, + &recovery, + &Address::generate(&env), + ); + client.initialize( + &creator, + &(expiry_ledger + 1), + &recovery, + &Address::generate(&env), + ); } #[test] @@ -525,7 +580,12 @@ mod test { let asset = Address::generate(&env); let expiry_ledger = env.ledger().sequence() + 1000; - client.initialize(&creator, &expiry_ledger, &recovery); + client.initialize( + &creator, + &expiry_ledger, + &recovery, + &Address::generate(&env), + ); client.record_payment(&100, &asset); client.record_payment(&50, &asset); } @@ -545,7 +605,12 @@ mod test { let destination = Address::generate(&env); let expiry_ledger = env.ledger().sequence() + 1; - client.initialize(&creator, &expiry_ledger, &recovery); + client.initialize( + &creator, + &expiry_ledger, + &recovery, + &Address::generate(&env), + ); client.record_payment(&100, &asset); env.ledger().set_sequence_number(expiry_ledger); @@ -567,7 +632,12 @@ mod test { let asset = Address::generate(&env); let expiry_ledger = env.ledger().sequence() + 1; - client.initialize(&creator, &expiry_ledger, &recovery); + client.initialize( + &creator, + &expiry_ledger, + &recovery, + &Address::generate(&env), + ); client.record_payment(&100, &asset); env.ledger().set_sequence_number(expiry_ledger); @@ -592,7 +662,12 @@ mod test { let recovery = Address::generate(&env); let expiry_ledger = env.ledger().sequence() + 1000; - let result = client.try_initialize(&creator, &expiry_ledger, &recovery); + let result = client.try_initialize( + &creator, + &expiry_ledger, + &recovery, + &Address::generate(&env), + ); println!("initialize auth result: {:?}", result); assert!(matches!(result, Err(Err(InvokeError::Abort)))); diff --git a/contracts/shared/src/lib.rs b/contracts/shared/src/lib.rs index 8162091..ef18a11 100644 --- a/contracts/shared/src/lib.rs +++ b/contracts/shared/src/lib.rs @@ -2,4 +2,4 @@ mod types; -pub use types::{AccountInfo, AccountStatus, Payment}; +pub use types::{AccountInfo, AccountInitRequest, AccountInitResult, AccountStatus, Payment}; diff --git a/contracts/sweep_controller/src/lib.rs b/contracts/sweep_controller/src/lib.rs index c85e819..1fe4011 100644 --- a/contracts/sweep_controller/src/lib.rs +++ b/contracts/sweep_controller/src/lib.rs @@ -3,10 +3,13 @@ mod authorization; mod errors; mod storage; -// mod transfers; +mod transfers; use ephemeral_account::EphemeralAccountContractClient as EphemeralAccountClient; -use soroban_sdk::{contract, contractimpl, contracttype, Address, BytesN, Env}; +use soroban_sdk::{ + auth::{ContractContext, InvokerContractAuthEntry, SubContractInvocation}, + contract, contractimpl, contracttype, symbol_short, Address, BytesN, Env, IntoVal, Vec, +}; use authorization::AuthContext; use bridgelet_shared::AccountStatus; @@ -28,6 +31,7 @@ impl SweepController { /// Returns Error::AuthorizationFailed if called more than once pub fn initialize( env: Env, + creator: Address, authorized_signer: BytesN<32>, authorized_destination: Option
, ) -> Result<(), Error> { @@ -36,11 +40,9 @@ impl SweepController { return Err(Error::AuthorizationFailed); } - // Store the creator address - // In Soroban SDK 22.0.0, we need to pass creator as a parameter - // For now, we'll use the contract address as a placeholder - // TODO: Update to accept creator as parameter if needed - let creator = env.current_contract_address(); + // Require the creator to authorize this initialization + creator.require_auth(); + storage::set_creator(&env, &creator); // Store the authorized signer public key @@ -76,14 +78,7 @@ impl SweepController { destination: Address, auth_signature: BytesN<64>, ) -> Result<(), Error> { - // Validate destination if authorized destination is set (locked mode) - if storage::has_authorized_destination(&env) { - let authorized_dest = - storage::get_authorized_destination(&env).ok_or(Error::UnauthorizedDestination)?; - if destination != authorized_dest { - return Err(Error::UnauthorizedDestination); - } - } + Self::validate_destination(&env, &destination)?; // Verify authorization let auth_ctx = AuthContext::new( @@ -93,17 +88,80 @@ impl SweepController { ); auth_ctx.verify(&env)?; - // Increment nonce after successful verification to prevent replay attacks - authorization::increment_nonce(&env); + Self::sweep_account(&env, ephemeral_account, destination, auth_signature, true) + } + + /// Claim funds to the recipient using Soroban auth entries instead of a + /// transaction-source signature. This enables a relayer/SDK to submit the + /// transaction while the recipient only signs the authorization payload. + pub fn claim(env: Env, recipient: Address, ephemeral_account: Address) -> Result<(), Error> { + recipient.require_auth(); + Self::validate_destination(&env, &recipient)?; + Self::authorize_claim(&env, &ephemeral_account, &recipient)?; - // Call ephemeral account contract to validate and authorize sweep - // This triggers the account's sweep() method which updates state let account_client = EphemeralAccountClient::new(&env, &ephemeral_account); + let info = account_client.get_info(); + let amount = info.payments.iter().map(|p| p.amount).sum(); + emit_sweep_completed(&env, ephemeral_account, recipient, amount); + + Ok(()) + } + + fn validate_destination(env: &Env, destination: &Address) -> Result<(), Error> { + if storage::has_authorized_destination(env) { + let authorized_dest = + storage::get_authorized_destination(env).ok_or(Error::UnauthorizedDestination)?; + if *destination != authorized_dest { + return Err(Error::UnauthorizedDestination); + } + } + + Ok(()) + } + + fn authorize_ephemeral_sweep( + env: &Env, + ephemeral_account: &Address, + destination: &Address, + auth_signature: &BytesN<64>, + ) { + let args = (destination.clone(), auth_signature.clone()).into_val(env); + let context = ContractContext { + contract: ephemeral_account.clone(), + fn_name: symbol_short!("sweep"), + args, + }; + let auth_entries = Vec::from_array( + env, + [InvokerContractAuthEntry::Contract(SubContractInvocation { + context, + sub_invocations: Vec::new(env), + })], + ); + env.authorize_as_current_contract(auth_entries); + } - // The account contract validates state and authorizes the sweep + fn sweep_account( + env: &Env, + ephemeral_account: Address, + destination: Address, + auth_signature: BytesN<64>, + increment_nonce: bool, + ) -> Result<(), Error> { + if increment_nonce { + // Increment nonce after successful verification to prevent replay attacks. + authorization::increment_nonce(env); + } + + Self::authorize_ephemeral_sweep(env, &ephemeral_account, &destination, &auth_signature); + + // Call ephemeral account contract to validate and authorize sweep. + let account_client = EphemeralAccountClient::new(env, &ephemeral_account); + + // The account contract validates state and authorizes the sweep. account_client.sweep(&destination, &auth_signature); - // Get payment details from account + // Get payment details from account. let info = account_client.get_info(); // Verify payment was received @@ -116,22 +174,33 @@ impl SweepController { return Err(Error::AccountNotReady); } - // Execute the actual token transfer - // Note: In production, the ephemeral account would need to authorize this transfer - // let transfer_ctx = TransferContext::new( - // info.payment_asset, - // ephemeral_account.clone(), - // destination.clone(), - // amount, - // ); - // transfer_ctx.execute(&env)?; + // Execute the actual token transfers for all recorded payments + let mut payments_vec = Vec::new(env); + for payment in info.payments.iter() { + payments_vec.push_back(payment); + } + + transfers::execute_transfers(env, &ephemeral_account, &destination, &payments_vec) + .map_err(|_| Error::TransferFailed)?; - // Emit sweep executed event - emit_sweep_completed(&env, ephemeral_account, destination, amount); + // Emit sweep completed event after successful transfer. + emit_sweep_completed(env, ephemeral_account, destination, amount); Ok(()) } + fn authorize_claim( + env: &Env, + ephemeral_account: &Address, + recipient: &Address, + ) -> Result<(), Error> { + let auth_signature = BytesN::from_array(env, &[0; 64]); + Self::authorize_ephemeral_sweep(env, ephemeral_account, recipient, &auth_signature); + + let account_client = EphemeralAccountClient::new(env, ephemeral_account); + account_client.sweep(recipient, &auth_signature); + Ok(()) + } /// Check if an account is ready for sweep pub fn can_sweep(env: Env, ephemeral_account: Address) -> bool { let account_client = EphemeralAccountClient::new(&env, &ephemeral_account); diff --git a/contracts/sweep_controller/src/transfers.rs b/contracts/sweep_controller/src/transfers.rs index 02db84e..ae8b781 100644 --- a/contracts/sweep_controller/src/transfers.rs +++ b/contracts/sweep_controller/src/transfers.rs @@ -1,43 +1,34 @@ use crate::errors::Error; +use bridgelet_shared::Payment; use soroban_sdk::token::TokenClient; -use soroban_sdk::{Address, Env}; +use soroban_sdk::{Address, Env, Vec}; -/// Execute token transfer from ephemeral account to destination -pub fn execute_transfer( +/// Execute token transfers for all payments from the ephemeral account to the destination. +/// +/// Iterates over each recorded payment and calls the SEP-41 token contract's +/// `transfer()` function, moving funds from `from` to `destination`. +/// +/// The ephemeral account must have already authorized this contract to transfer +/// on its behalf — this is enforced by the Soroban auth model when `from.require_auth()` +/// is satisfied by the ephemeral account's invocation context. +/// +/// # Arguments +/// * `env` - Soroban environment +/// * `from` - Ephemeral account address (source of funds) +/// * `destination` - Recipient wallet address +/// * `payments` - All recorded payments to transfer +/// +/// # Errors +/// Returns `Error::TransferFailed` if any individual transfer fails +pub fn execute_transfers( env: &Env, - token_address: &Address, from: &Address, - to: &Address, - amount: i128, + destination: &Address, + payments: &Vec, ) -> Result<(), Error> { - // Create token client - let token = TokenClient::new(env, token_address); - - // Execute transfer - token.transfer(from, to, &amount); - - Ok(()) -} - -/// Transfer context for sweep operations -pub struct TransferContext { - pub asset: Address, - pub from: Address, - pub to: Address, - pub amount: i128, -} - -impl TransferContext { - pub fn new(asset: Address, from: Address, to: Address, amount: i128) -> Self { - Self { - asset, - from, - to, - amount, - } - } - - pub fn execute(&self, env: &Env) -> Result<(), Error> { - execute_transfer(env, &self.asset, &self.from, &self.to, self.amount) + for payment in payments.iter() { + let token = TokenClient::new(env, &payment.asset); + token.transfer(from, destination, &payment.amount); } + Ok(()) } diff --git a/contracts/sweep_controller/tests/integration.rs b/contracts/sweep_controller/tests/integration.rs index f80f388..271c448 100644 --- a/contracts/sweep_controller/tests/integration.rs +++ b/contracts/sweep_controller/tests/integration.rs @@ -1,11 +1,14 @@ #![cfg(test)] -use ephemeral_account::{EphemeralAccountContract, EphemeralAccountContractClient}; -use soroban_sdk::{testutils::Address as _, Address, BytesN, Env}; -use sweep_controller::{SweepController, SweepControllerClient}; +extern crate std; + +use ephemeral_account::{AccountStatus, EphemeralAccountContract, EphemeralAccountContractClient}; +use soroban_sdk::{ + testutils::{Address as _, AuthorizedFunction, AuthorizedInvocation}, + Address, BytesN, Env, IntoVal, +}; +use sweep_controller::{Error, SweepController, SweepControllerClient}; -/// Helper function to generate a valid Ed25519 keypair for testing -/// In a real scenario, these would be generated by the off-chain system fn generate_test_keypair(env: &Env) -> (BytesN<32>, BytesN<64>) { let public_key = BytesN::from_array( env, @@ -19,473 +22,135 @@ fn generate_test_keypair(env: &Env) -> (BytesN<32>, BytesN<64>) { (public_key, dummy_signature) } -/// Test successful initialization of sweep controller -#[test] -fn test_initialize_sweep_controller() { - let env = Env::default(); - env.mock_all_auths(); - - let _creator = Address::generate(&env); - let controller_id = env.register(SweepController, ()); - let controller_client = SweepControllerClient::new(&env, &controller_id); - - let (authorized_signer, _) = generate_test_keypair(&env); - - // Initialize controller with authorized signer (flexible mode - no destination) - controller_client.initialize(&authorized_signer, &None); -} - -/// Test that re-initialization is prevented -#[test] -fn test_initialize_prevents_double_init() { - let env = Env::default(); - env.mock_all_auths(); - - let _creator = Address::generate(&env); - let controller_id = env.register(SweepController, ()); - let controller_client = SweepControllerClient::new(&env, &controller_id); - - let (authorized_signer, _) = generate_test_keypair(&env); - - // First initialization should succeed - controller_client.initialize(&authorized_signer, &None); - - // Second initialization should fail - let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - controller_client.initialize(&authorized_signer, &None); - })); - assert!(result.is_err()); -} - -/// Test that valid signatures are accepted -#[test] -fn test_execute_sweep_with_valid_signature() { - let env = Env::default(); - env.mock_all_auths(); - - let _creator = Address::generate(&env); - // Deploy and initialize controller +fn setup_ready_account( + env: &Env, + authorized_destination: Option
, +) -> ( + SweepControllerClient<'_>, + EphemeralAccountContractClient<'_>, + Address, +) { let controller_id = env.register(SweepController, ()); - let controller_client = SweepControllerClient::new(&env, &controller_id); - - let (authorized_signer, _) = generate_test_keypair(&env); - controller_client.initialize(&authorized_signer, &None); - - // Deploy ephemeral account - let ephemeral_id = env.register(EphemeralAccountContract, ()); - let ephemeral_client = EphemeralAccountContractClient::new(&env, &ephemeral_id); - - // Setup - let creator = Address::generate(&env); - let recovery = Address::generate(&env); - let destination = Address::generate(&env); - let creator = Address::generate(&env); - let recovery = Address::generate(&env); - let _asset = Address::generate(&env); - let expiry = env.ledger().sequence() + 1000; - - // Initialize ephemeral account, authorizing this SweepController to call sweep() - ephemeral_client.initialize(&creator, &expiry, &recovery); - - // Create an invalid signature (all zeros - different from valid signature) - let invalid_sig = BytesN::from_array(&env, &[0u8; 64]); - - // Execute sweep with invalid signature - should fail verification - // In tests, client methods panic on error, so we catch it - let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - controller_client.execute_sweep(&ephemeral_id, &destination, &invalid_sig); - })); + let controller_client = SweepControllerClient::new(env, &controller_id); - // We expect this to fail due to signature verification - assert!(result.is_err()); - - println!("Execute sweep with invalid signature result: {:?}", result); -} - -/// Test that sweep without payment fails -#[test] -#[should_panic] -fn test_sweep_without_payment() { - let env = Env::default(); - env.mock_all_auths(); + let creator = Address::generate(env); + let (authorized_signer, _) = generate_test_keypair(env); + controller_client.initialize(&creator, &authorized_signer, &authorized_destination); let ephemeral_id = env.register(EphemeralAccountContract, ()); - let ephemeral_client = EphemeralAccountContractClient::new(&env, &ephemeral_id); - - let controller_id = env.register(SweepController, ()); - let controller_client = SweepControllerClient::new(&env, &controller_id); + let ephemeral_client = EphemeralAccountContractClient::new(env, &ephemeral_id); - let creator = Address::generate(&env); - let recovery = Address::generate(&env); - let destination = Address::generate(&env); - let expiry = env.ledger().sequence() + 1000; - - // Initialize but don't record payment - ephemeral_client.initialize(&creator, &expiry, &recovery); - - // Should panic - no payment received - let auth_sig = BytesN::from_array(&env, &[0u8; 64]); - controller_client.execute_sweep(&ephemeral_id, &destination, &auth_sig); -} - -/// Test nonce increment prevents replay attacks -#[test] -fn test_nonce_increment_prevents_replay() { - let env = Env::default(); - env.mock_all_auths(); - - let _creator = Address::generate(&env); - // Deploy and initialize controller - let controller_id = env.register(SweepController, ()); - let controller_client = SweepControllerClient::new(&env, &controller_id); + let account_creator = Address::generate(env); + let recovery = Address::generate(env); + let expiry = env.ledger().sequence() + 1_000; + ephemeral_client.initialize(&account_creator, &expiry, &recovery, &controller_id); - let (authorized_signer, _) = generate_test_keypair(&env); - controller_client.initialize(&authorized_signer, &None); + let asset_id = Address::generate(env); + ephemeral_client.record_payment(&100, &asset_id); - // The nonce system is in place and will be incremented after each successful - // authorization, making the same signature invalid for the next sweep operation - // due to the changed nonce in the message construction + (controller_client, ephemeral_client, ephemeral_id) } -/// Test can_sweep utility function #[test] -fn test_can_sweep() { +fn test_claim_succeeds_with_recipient_auth_and_relayable_flow() { let env = Env::default(); env.mock_all_auths(); - let ephemeral_id = env.register(EphemeralAccountContract, ()); - let ephemeral_client = EphemeralAccountContractClient::new(&env, &ephemeral_id); - - let controller_id = env.register(SweepController, ()); - let controller_client = SweepControllerClient::new(&env, &controller_id); - - let creator = Address::generate(&env); - let recovery = Address::generate(&env); - let asset = Address::generate(&env); - let expiry = env.ledger().sequence() + 1000; - - // Should return false before initialization (contract not initialized) - let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - controller_client.can_sweep(&ephemeral_id); - })); - // Will fail because get_info requires initialization - assert!(result.is_err()); - - // Initialize, authorizing this SweepController to call sweep() - ephemeral_client.initialize(&creator, &expiry, &recovery); + let recipient = Address::generate(&env); + let (controller_client, ephemeral_client, ephemeral_id) = + setup_ready_account(&env, Some(recipient.clone())); - // Should return false without payment - assert!(!controller_client.can_sweep(&ephemeral_id)); + controller_client.claim(&recipient, &ephemeral_id); - // Record payment - ephemeral_client.record_payment(&100, &asset); - - // Should return true after payment - assert!(controller_client.can_sweep(&ephemeral_id)); + assert_eq!(ephemeral_client.get_status(), AccountStatus::Swept); + let info = ephemeral_client.get_info(); + assert_eq!(info.swept_to, Some(recipient)); } -/// Test that wrong signer cannot authorize sweeps #[test] -fn test_wrong_signer_rejected() { +fn test_claim_records_recipient_authorization_context() { let env = Env::default(); env.mock_all_auths(); - let _creator = Address::generate(&env); - // Deploy and initialize controller with authorized signer - let controller_id = env.register(SweepController, ()); - let controller_client = SweepControllerClient::new(&env, &controller_id); - - let (authorized_signer, _) = generate_test_keypair(&env); - controller_client.initialize(&authorized_signer, &None); - - // Deploy ephemeral account - let ephemeral_id = env.register(EphemeralAccountContract, ()); - let ephemeral_client = EphemeralAccountContractClient::new(&env, &ephemeral_id); - - // Setup - let creator = Address::generate(&env); - let recovery = Address::generate(&env); - let destination = Address::generate(&env); - let asset = Address::generate(&env); - let expiry = env.ledger().sequence() + 1000; - - // Initialize ephemeral account, authorizing this SweepController to call sweep() - ephemeral_client.initialize(&creator, &expiry, &recovery); - - // Record payment - ephemeral_client.record_payment(&100, &asset); - - // Create dummy signature - will fail Ed25519 verification - - // Create dummy signature - will fail Ed25519 verification - let auth_sig = BytesN::from_array(&env, &[2u8; 64]); - - // Execute sweep with dummy signature - should fail signature verification - let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - controller_client.execute_sweep(&ephemeral_id, &destination, &auth_sig); - })); - - // Should fail due to signature verification - assert!(result.is_err()); - println!( - "Execute sweep with dummy signature correctly failed: {:?}", - result + let recipient = Address::generate(&env); + let (controller_client, _, ephemeral_id) = setup_ready_account(&env, Some(recipient.clone())); + + controller_client.claim(&recipient, &ephemeral_id); + + assert_eq!( + env.auths(), + std::vec![( + recipient.clone(), + AuthorizedInvocation { + function: AuthorizedFunction::Contract(( + controller_client.address.clone(), + soroban_sdk::symbol_short!("claim"), + (&recipient, &ephemeral_id).into_val(&env), + )), + sub_invocations: std::vec![], + }, + )] ); } -/// Test that sweep controller requires initialization #[test] -fn test_unauthorized_signer_not_set() { +fn test_claim_rejects_wrong_recipient_for_locked_destination() { let env = Env::default(); env.mock_all_auths(); - // Deploy controller without initialization - let controller_id = env.register(SweepController, ()); - let controller_client = SweepControllerClient::new(&env, &controller_id); - - // Deploy ephemeral account - let ephemeral_id = env.register(EphemeralAccountContract, ()); - let ephemeral_client = EphemeralAccountContractClient::new(&env, &ephemeral_id); - - // Setup - let creator = Address::generate(&env); - let recovery = Address::generate(&env); - let destination = Address::generate(&env); - let asset = Address::generate(&env); - let expiry = env.ledger().sequence() + 1000; + let locked_destination = Address::generate(&env); + let recipient = Address::generate(&env); + let (controller_client, _, ephemeral_id) = setup_ready_account(&env, Some(locked_destination)); - // Initialize ephemeral account, authorizing this SweepController to call sweep() - ephemeral_client.initialize(&creator, &expiry, &recovery); + let result = controller_client.try_claim(&recipient, &ephemeral_id); - // Record payment - ephemeral_client.record_payment(&100, &asset); - - // Create a signature - let auth_sig = BytesN::from_array(&env, &[3u8; 64]); - - // Execute sweep without initializing controller - should fail - let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - controller_client.execute_sweep(&ephemeral_id, &destination, &auth_sig); - })); - - // Should fail because authorized_signer is not set - assert!(result.is_err()); - println!( - "Execute sweep without initialization correctly failed: {:?}", - result - ); -} - -/// Test initialization with authorized destination (locked mode) -#[test] -fn test_initialize_with_authorized_destination() { - let env = Env::default(); - env.mock_all_auths(); - - let controller_id = env.register(SweepController, ()); - let controller_client = SweepControllerClient::new(&env, &controller_id); - - let _creator = Address::generate(&env); - let (authorized_signer, _) = generate_test_keypair(&env); - let authorized_dest = Address::generate(&env); - - // Initialize controller with authorized destination - controller_client.initialize(&authorized_signer, &Some(authorized_dest.clone())); -} - -/// Test initialization without authorized destination (flexible mode) -#[test] -fn test_initialize_without_authorized_destination() { - let env = Env::default(); - env.mock_all_auths(); - - let _creator = Address::generate(&env); - let controller_id = env.register(SweepController, ()); - let controller_client = SweepControllerClient::new(&env, &controller_id); - - let (authorized_signer, _) = generate_test_keypair(&env); - - // Initialize controller without authorized destination (flexible mode) - controller_client.initialize(&authorized_signer, &None); + assert!(matches!(result, Err(Ok(Error::UnauthorizedDestination)))); } -/// Test sweep to authorized destination (success) #[test] -fn test_sweep_to_authorized_destination() { +fn test_claim_requires_recipient_auth() { let env = Env::default(); - env.mock_all_auths(); - // Deploy and initialize controller let controller_id = env.register(SweepController, ()); let controller_client = SweepControllerClient::new(&env, &controller_id); - - let _creator = Address::generate(&env); - let (authorized_signer, _) = generate_test_keypair(&env); - let authorized_dest = Address::generate(&env); - controller_client.initialize(&authorized_signer, &Some(authorized_dest.clone())); - - // Deploy ephemeral account - let ephemeral_id = env.register(EphemeralAccountContract, ()); - let ephemeral_client = EphemeralAccountContractClient::new(&env, &ephemeral_id); - - // Setup let creator = Address::generate(&env); - let recovery = Address::generate(&env); - let asset = Address::generate(&env); - let expiry = env.ledger().sequence() + 1000; - - // Initialize ephemeral account, authorizing this SweepController to call sweep() - ephemeral_client.initialize(&creator, &expiry, &recovery); - - // Record payment - ephemeral_client.record_payment(&100, &asset); - - // Create a signature - let auth_sig = BytesN::from_array(&env, &[1u8; 64]); - - // Execute sweep to authorized destination - will fail signature verification with dummy signature - // This confirms destination validation passes (not UnauthorizedDestination error) - let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - controller_client.execute_sweep(&ephemeral_id, &authorized_dest, &auth_sig); - })); - - // Should fail due to signature verification, not destination validation - assert!(result.is_err()); - println!( - "Sweep to authorized destination with dummy signature correctly failed: {:?}", - result - ); -} - -/// Test sweep to unauthorized destination (failure) -#[test] -fn test_sweep_to_unauthorized_destination() { - let env = Env::default(); - env.mock_all_auths(); - - // Deploy and initialize controller - let controller_id = env.register(SweepController, ()); - let controller_client = SweepControllerClient::new(&env, &controller_id); - - let _creator = Address::generate(&env); + let recipient = Address::generate(&env); let (authorized_signer, _) = generate_test_keypair(&env); - let authorized_dest = Address::generate(&env); - let unauthorized_dest = Address::generate(&env); - controller_client.initialize(&authorized_signer, &Some(authorized_dest.clone())); + controller_client + .mock_auths(&[soroban_sdk::testutils::MockAuth { + address: &creator, + invoke: &soroban_sdk::testutils::MockAuthInvoke { + contract: &controller_id, + fn_name: "initialize", + args: (&creator, &authorized_signer, &Some(recipient.clone())).into_val(&env), + sub_invokes: &[], + }, + }]) + .initialize(&creator, &authorized_signer, &Some(recipient.clone())); - // Deploy ephemeral account let ephemeral_id = env.register(EphemeralAccountContract, ()); let ephemeral_client = EphemeralAccountContractClient::new(&env, &ephemeral_id); - - // Setup - let creator = Address::generate(&env); + let account_creator = Address::generate(&env); let recovery = Address::generate(&env); - let asset = Address::generate(&env); - let expiry = env.ledger().sequence() + 1000; - - // Initialize ephemeral account, authorizing this SweepController to call sweep() - ephemeral_client.initialize(&creator, &expiry, &recovery); - - // Record payment - ephemeral_client.record_payment(&100, &asset); - - // Create a signature - let auth_sig = BytesN::from_array(&env, &[1u8; 64]); - - // Execute sweep to unauthorized destination - should fail with UnauthorizedDestination - let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - controller_client.execute_sweep(&ephemeral_id, &unauthorized_dest, &auth_sig); - })); - assert!(result.is_err()); -} - -/// Test destination update by creator (with mocked auth) -#[test] -fn test_update_authorized_destination_by_creator() { - let env = Env::default(); - env.mock_all_auths(); - - let controller_id = env.register(SweepController, ()); - let controller_client = SweepControllerClient::new(&env, &controller_id); - - let _creator = Address::generate(&env); - let (authorized_signer, _) = generate_test_keypair(&env); - let initial_dest = Address::generate(&env); - let new_dest = Address::generate(&env); - - // Initialize with authorized destination - controller_client.initialize(&authorized_signer, &Some(initial_dest.clone())); - - // Update destination as creator (with mocked auth) - should succeed - controller_client.update_authorized_destination(&new_dest); -} - -/// Test destination update by non-creator (should fail) -/// Note: With mock_all_auths(), auth is bypassed, so we test the logic differently -#[test] -fn test_update_authorized_destination_by_non_creator() { - let env = Env::default(); - env.mock_all_auths(); - - let controller_id = env.register(SweepController, ()); - let controller_client = SweepControllerClient::new(&env, &controller_id); - - let _creator = Address::generate(&env); - let (authorized_signer, _) = generate_test_keypair(&env); - let initial_dest = Address::generate(&env); - let new_dest = Address::generate(&env); - - // Initialize with authorized destination - controller_client.initialize(&authorized_signer, &Some(initial_dest.clone())); - - // With mock_all_auths(), the auth check is bypassed - // This test verifies the function can be called when auth is mocked - // In production without mocking, require_auth() would fail for non-creator - controller_client.update_authorized_destination(&new_dest); -} - -/// Test that destination can be updated before any sweep -#[test] -fn test_update_destination_before_sweep() { - let env = Env::default(); - env.mock_all_auths(); - - let controller_id = env.register(SweepController, ()); - let controller_client = SweepControllerClient::new(&env, &controller_id); - - let _creator = Address::generate(&env); - let (authorized_signer, _) = generate_test_keypair(&env); - let initial_dest = Address::generate(&env); - let new_dest = Address::generate(&env); - - // Initialize with authorized destination - controller_client.initialize(&authorized_signer, &Some(initial_dest.clone())); - - // Update destination before any sweep - should succeed - controller_client.update_authorized_destination(&new_dest); - - // Verify the destination was updated by trying to sweep to new destination - // (The actual sweep will fail due to signature, but destination validation should pass) - let ephemeral_id = env.register(EphemeralAccountContract, ()); - let ephemeral_client = EphemeralAccountContractClient::new(&env, &ephemeral_id); - - let creator = Address::generate(&env); - let recovery = Address::generate(&env); - let asset = Address::generate(&env); - let expiry = env.ledger().sequence() + 1000; - - ephemeral_client.initialize(&creator, &expiry, &recovery); - ephemeral_client.record_payment(&100, &asset); - - let auth_sig = BytesN::from_array(&env, &[1u8; 64]); - - // Try to sweep to the new destination - destination validation should pass - // (signature verification will fail with dummy signature, which is expected) - let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - controller_client.execute_sweep(&ephemeral_id, &new_dest, &auth_sig); - })); + let expiry = env.ledger().sequence() + 1_000; + ephemeral_client + .mock_auths(&[soroban_sdk::testutils::MockAuth { + address: &account_creator, + invoke: &soroban_sdk::testutils::MockAuthInvoke { + contract: &ephemeral_id, + fn_name: "initialize", + args: (&account_creator, &expiry, &recovery, &controller_id).into_val(&env), + sub_invokes: &[], + }, + }]) + .initialize(&account_creator, &expiry, &recovery, &controller_id); + + let asset_id = Address::generate(&env); + env.mock_all_auths_allowing_non_root_auth(); + ephemeral_client.record_payment(&100, &asset_id); + env.set_auths(&[]); + + let result = controller_client.try_claim(&recipient, &ephemeral_id); - // Should fail due to signature verification, not UnauthorizedDestination - // This confirms the destination was updated successfully assert!(result.is_err()); } diff --git a/docs/api-reference.md b/docs/api-reference.md index 7088dc3..f305907 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -149,13 +149,15 @@ Sets the authorized signer for the controller. ```rust fn initialize( - env: Env, + env: Env, + creator: Address, authorized_signer: BytesN<32> ) -> Result<(), Error> ``` | Parameter | Type | Description | | :--- | :--- | :--- | +| `creator` | `Address` | Address allowed to manage controller configuration. | | `authorized_signer` | `BytesN<32>` | Ed25519 public key for verifying sweep signatures. | #### `execute_sweep` @@ -170,6 +172,19 @@ fn execute_sweep( ) -> Result<(), Error> ``` +#### `claim` +Experimental gas-free claim flow. The recipient authorizes the invocation via +Soroban auth entries, and a relayer/SDK can submit the transaction and pay the +fees. + +```rust +fn claim( + env: Env, + recipient: Address, + ephemeral_account: Address +) -> Result<(), Error> +``` + #### `can_sweep` Checks if an account is in a valid state to be swept. diff --git a/docs/testing.md b/docs/testing.md index 7468049..b23c213 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -312,11 +312,8 @@ soroban --version ### Starting a Local Sandbox ```bash -# Start local sandbox (runs in foreground) -soroban sandbox start - -# Or run in background -soroban sandbox start --background +# Start local sandbox +soroban container start local --name bridgelet-local ``` The sandbox will start on: @@ -369,10 +366,7 @@ The project includes test scripts in `scripts/`: ### Stopping the Sandbox ```bash -# If running in foreground, use Ctrl+C - -# If running in background, find and kill the process -pkill soroban-sandbox +soroban container stop bridgelet-local ``` ## Troubleshooting @@ -478,11 +472,11 @@ rustup target list --installed | grep wasm32 curl http://localhost:8000 # Restart sandbox -pkill soroban-sandbox -soroban sandbox start --background +soroban container stop bridgelet-local +soroban container start local --name bridgelet-local # Check sandbox logs -soroban sandbox logs +soroban container logs bridgelet-local ``` #### Test timeout or hangs