diff --git a/remittance_split/README.md b/remittance_split/README.md index 6383f3bd..9351ecc4 100644 --- a/remittance_split/README.md +++ b/remittance_split/README.md @@ -8,10 +8,14 @@ across spending, savings, bills, and insurance categories. `distribute_usdc` is the only function that moves funds. It enforces the following invariants in strict order before any token interaction occurs: -1. **Auth first** — `from.require_auth()` is the very first operation; no state is read before - the caller proves authority. -2. **Pause guard** — the contract must not be globally paused. -3. **Owner-only** — `from` must equal the address stored as `config.owner` at initialization. +1. **Domain-Separated Auth** — `initialize_split` uses a structured `InitializationPayload` + containing the network ID, contract address, owner, and nonce. This payload must be + explicitly signed, preventing the authorization from being replayed on different contract + instances or Stellar networks. +2. **Auth first** — For other operations, `caller.require_auth()` is the very first operation; + no state is read before the caller proves authority. +3. **Pause guard** — the contract must not be globally paused. +4. **Owner-only** — `from` must equal the address stored as `config.owner` at initialization. Any other address is rejected with `Unauthorized`, even if it can self-authorize. 4. **Trusted token** — `usdc_contract` must match the address pinned in `config.usdc_contract` at initialization time. Passing a different address returns `UntrustedTokenContract`, diff --git a/remittance_split/src/lib.rs b/remittance_split/src/lib.rs index 0f46fe3c..04d082c9 100644 --- a/remittance_split/src/lib.rs +++ b/remittance_split/src/lib.rs @@ -5,7 +5,7 @@ mod test; use soroban_sdk::{ contract, contracterror, contractimpl, contracttype, symbol_short, token::TokenClient, vec, - Address, Env, Map, Symbol, Vec, + Address, BytesN, Env, IntoVal, Map, Symbol, Vec, }; // Event topics @@ -359,7 +359,7 @@ impl RemittanceSplit { // Emit admin transfer event for audit trail env.events().publish( (symbol_short!("split"), symbol_short!("adm_xfr")), - (current_upgrade_admin, new_admin.clone()), + (current_upgrade_admin.clone(), new_admin.clone()), ); Ok(()) @@ -440,7 +440,20 @@ impl RemittanceSplit { bills_percent: u32, insurance_percent: u32, ) -> Result { - owner.require_auth(); + let payload = SplitAuthPayload { + domain_id: symbol_short!("init"), + network_id: env.ledger().network_id(), + contract_addr: env.current_contract_address(), + owner_addr: owner.clone(), + nonce_val: nonce, + usdc_contract: usdc_contract.clone(), + spending_percent, + savings_percent, + bills_percent, + insurance_percent, + }; + owner.require_auth_for_args(vec![&env, payload.into_val(&env)]); + Self::require_not_paused(&env)?; Self::require_nonce(&env, &owner, nonce)?; diff --git a/remittance_split/src/test.rs b/remittance_split/src/test.rs index f3db8645..f5a69751 100644 --- a/remittance_split/src/test.rs +++ b/remittance_split/src/test.rs @@ -5,7 +5,7 @@ use soroban_sdk::{ testutils::storage::Instance as StorageInstance, testutils::{Address as AddressTrait, Events, Ledger, LedgerInfo}, token::{StellarAssetClient, TokenClient}, - Address, Env, Symbol, TryFromVal, + Address, Env, Symbol, TryFromVal, TryIntoVal, }; // --------------------------------------------------------------------------- @@ -67,6 +67,44 @@ fn setup_initialized_split<'a>( // initialize_split // --------------------------------------------------------------------------- +#[test] +fn test_initialize_split_domain_separated_auth() { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register_contract(None, RemittanceSplit); + let client = RemittanceSplitClient::new(&env, &contract_id); + let owner = Address::generate(&env); + let token_admin = Address::generate(&env); + let token_id = setup_token(&env, &token_admin, &owner, 0); + + client.initialize_split(&owner, &0, &token_id, &50, &30, &15, &5); + + // Verify that the authorization includes the full domain-separated payload + let auths = env.auths(); + assert_eq!(auths.len(), 1); + + // The auths captured by mock_all_auths record what was authorized. + // In our case, the contract calls owner.require_auth_for_args(payload). + let (address, auth_invocation) = auths.get(0).unwrap(); + assert_eq!(address, owner); + + // The top-level invocation from mock_all_auths for require_auth_for_args + // will have the authorized arguments. + let payload_val = auth_invocation.args.get(0).unwrap(); + let payload: SplitAuthPayload = payload_val.try_into_val(&env).unwrap(); + + assert_eq!(payload.domain_id, symbol_short!("init")); + assert_eq!(payload.network_id, env.ledger().network_id()); + assert_eq!(payload.contract_addr, contract_id); + assert_eq!(payload.owner_addr, owner); + assert_eq!(payload.nonce_val, 0); + assert_eq!(payload.usdc_contract, token_id); + assert_eq!(payload.spending_percent, 50); + assert_eq!(payload.savings_percent, 30); + assert_eq!(payload.bills_percent, 15); + assert_eq!(payload.insurance_percent, 5); +} + #[test] fn test_initialize_split_succeeds() { let env = Env::default();