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
12 changes: 8 additions & 4 deletions remittance_split/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand Down
19 changes: 16 additions & 3 deletions remittance_split/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(())
Expand Down Expand Up @@ -440,7 +440,20 @@ impl RemittanceSplit {
bills_percent: u32,
insurance_percent: u32,
) -> Result<bool, RemittanceSplitError> {
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)?;

Expand Down
40 changes: 39 additions & 1 deletion remittance_split/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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();
Expand Down
Loading