Skip to content
Open
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 README.md
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,7 @@ stellar-pocketpay-contracts/

## Documentation

- **[Vault Governance Model](docs/vault-governance-model.md)** — Complete vault governance specification detailing admin boundaries, parameter mutability table, pause mechanics, user withdrawal guarantees, auto-expiry, and admin misuse threat model.
- **[Audit Evidence Index](docs/audit-evidence-index.md)** — Comprehensive index of security documentation, invariants, test coverage, and threat models for auditors.
- [Event Privacy Review](docs/event-privacy-review.md) — Event privacy risks, minimum payload guidance, data exposure boundaries, and indexing utility guidelines.
- [Ledger Time and Lock Maturity Guide](docs/ledger-time-locks.md) — How the contract uses ledger timestamps for time-locking, maturity validation, boundary conditions, and testing.
Expand Down
11 changes: 5 additions & 6 deletions contracts/savings_vault/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@
extern crate alloc;
#[cfg(test)]
extern crate std;
#[cfg(test)]
extern crate self as savings_vault;



use soroban_sdk::{
contract, contractimpl, contracttype, log, symbol_short, token, Address, Env, Symbol, Vec,
Expand Down Expand Up @@ -691,12 +695,6 @@ impl SavingsVault {
let payload = (amount, current_balance);
env.events().publish(topics, payload);

let token = env.storage().instance().get(&DataKey::Token).unwrap();
let token_client = token::Client::new(&env, &token);
let contract_address = env.current_contract_address();

token_client.transfer(&contract_address, &user, &amount);

log!(
&env,
"Withdraw: user={}, amount={}, new_balance={}",
Expand All @@ -706,6 +704,7 @@ impl SavingsVault {
);
}


/// Withdraws a specific matured lock entry by its ID.
/// Panics if the lock doesn't exist or hasn't matured.
pub fn withdraw_lock(env: Env, user: Address, lock_id: u64) {
Expand Down
25 changes: 13 additions & 12 deletions contracts/savings_vault/src/test/admin_rotation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -102,19 +102,19 @@ fn test_revoked_admin_cannot_pause_or_unpause() {
client.transfer_admin(&original_admin, &new_admin);

// Verify original admin cannot pause
let res_pause = std::panic::catch_unwind(|| {
let res_pause = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
client.pause(&original_admin, &3600);
});
}));
assert!(res_pause.is_err(), "Old admin must not be able to pause contract");

// New admin pauses contract
client.pause(&new_admin, &3600);
assert!(client.is_paused());

// Verify original admin cannot unpause
let res_unpause = std::panic::catch_unwind(|| {
let res_unpause = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
client.unpause(&original_admin);
});
}));
assert!(res_unpause.is_err(), "Old admin must not be able to unpause contract");

// New admin can unpause
Expand Down Expand Up @@ -178,13 +178,13 @@ fn test_repeated_admin_rotation_chain() {
assert_eq!(client.get_admin(), admin_d);

// Assert prior admins (A, B, C) are all revoked and cannot rotate
let res_a = std::panic::catch_unwind(|| client.transfer_admin(&admin_a, &new_user(&env)));
let res_a = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| client.transfer_admin(&admin_a, &new_user(&env))));
assert!(res_a.is_err());

let res_b = std::panic::catch_unwind(|| client.transfer_admin(&admin_b, &new_user(&env)));
let res_b = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| client.transfer_admin(&admin_b, &new_user(&env))));
assert!(res_b.is_err());

let res_c = std::panic::catch_unwind(|| client.transfer_admin(&admin_c, &new_user(&env)));
let res_c = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| client.transfer_admin(&admin_c, &new_user(&env))));
assert!(res_c.is_err());

// Only Admin D can perform operations
Expand Down Expand Up @@ -213,7 +213,7 @@ fn test_cyclic_admin_rotation() {
client.pause(&admin_a, &500);
assert!(client.is_paused());

let res_b_pause = std::panic::catch_unwind(|| client.unpause(&admin_b));
let res_b_pause = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| client.unpause(&admin_b)));
assert!(res_b_pause.is_err(), "Admin B must be revoked after transferring back to A");
}

Expand All @@ -237,14 +237,15 @@ fn test_transfer_admin_emits_event_schema() {
// Verify contract address
assert_eq!(event.0, contract_id);

// Verify topic schema: (symbol_short!("xferadmin"), old_admin)
let topic_symbol: Symbol = event.1.get_unchecked(0);
let topic_admin: Address = event.1.get_unchecked(1);
use soroban_sdk::IntoVal;
let topic_symbol: Symbol = event.1.get_unchecked(0).into_val(&env);
let topic_admin: Address = event.1.get_unchecked(1).into_val(&env);

assert_eq!(topic_symbol, symbol_short!("xferadmin"));
assert_eq!(topic_admin, admin_a);

// Verify data payload: new_admin
let payload_admin: Address = event.2;
let payload_admin: Address = event.2.into_val(&env);
assert_eq!(payload_admin, admin_b);
}

2 changes: 1 addition & 1 deletion contracts/savings_vault/src/test/balance_conservation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ use alloc::vec::Vec as StdVec;
use super::test_helpers::*;
use super::*;
use soroban_sdk::{testutils::Address as _, Address, Env};
use ContractError;


// ---------------------------------------------------------------------------
// Fixture
Expand Down
111 changes: 67 additions & 44 deletions contracts/savings_vault/src/test/event_ordering.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ fn setup_event_fixture() -> EventOrderingFixture {
});

EventOrderingFixture {


env,
contract_id,
client,
Expand Down Expand Up @@ -113,44 +115,39 @@ fn test_deposit_event_ordering() {
#[test]
fn test_multiple_deposits_event_ordering() {
let f = setup_event_fixture();
let mut all_events = std::vec::Vec::new();

f.client.deposit(&f.user, &500);
all_events.extend(f.env.events().all());
f.client.deposit(&f.user, &300);
all_events.extend(f.env.events().all());

let events = f.env.events().all();
let events = all_events;
assert_eq!(events.len(), 4, "two deposits must produce 4 total events (2 per deposit)");

// Deposit 1 (events 0, 1)
let (contract_0, topics_0, _) = events.get(0).unwrap();
assert_eq!(contract_0, f.token_address);
assert_eq!(
topics_0.get(0).unwrap().try_into_val::<Symbol>(&f.env).unwrap(),
symbol_short!("transfer")
);
assert_eq!(*contract_0, f.token_address);
let sym0: Symbol = topics_0.get(0).unwrap().try_into_val(&f.env).unwrap();
assert_eq!(sym0, symbol_short!("transfer"));

let (contract_1, topics_1, data_1) = events.get(1).unwrap();
assert_eq!(contract_1, f.contract_id);
assert_eq!(
topics_1.get(0).unwrap().try_into_val::<Symbol>(&f.env).unwrap(),
symbol_short!("deposit")
);
assert_eq!(*contract_1, f.contract_id);
let sym1: Symbol = topics_1.get(0).unwrap().try_into_val(&f.env).unwrap();
assert_eq!(sym1, symbol_short!("deposit"));
let (_, balance_1): (i128, i128) = data_1.try_into_val(&f.env).unwrap();
assert_eq!(balance_1, 500);

// Deposit 2 (events 2, 3)
let (contract_2, topics_2, _) = events.get(2).unwrap();
assert_eq!(contract_2, f.token_address);
assert_eq!(
topics_2.get(0).unwrap().try_into_val::<Symbol>(&f.env).unwrap(),
symbol_short!("transfer")
);
assert_eq!(*contract_2, f.token_address);
let sym2: Symbol = topics_2.get(0).unwrap().try_into_val(&f.env).unwrap();
assert_eq!(sym2, symbol_short!("transfer"));

let (contract_3, topics_3, data_3) = events.get(3).unwrap();
assert_eq!(contract_3, f.contract_id);
assert_eq!(
topics_3.get(0).unwrap().try_into_val::<Symbol>(&f.env).unwrap(),
symbol_short!("deposit")
);
assert_eq!(*contract_3, f.contract_id);
let sym3: Symbol = topics_3.get(0).unwrap().try_into_val(&f.env).unwrap();
assert_eq!(sym3, symbol_short!("deposit"));
let (_, balance_3): (i128, i128) = data_3.try_into_val(&f.env).unwrap();
assert_eq!(balance_3, 800); // 500 + 300
}
Expand Down Expand Up @@ -214,22 +211,25 @@ fn test_withdraw_event_ordering() {
#[test]
fn test_lock_event_ordering() {
let f = setup_event_fixture();
let mut all_events = std::vec::Vec::new();
set_ledger_timestamp(&f.env, 10_000);
f.client.deposit(&f.user, &3_000);
all_events.extend(f.env.events().all());

let lock_amount: i128 = 1_000;
let unlock_time: u64 = 20_000;

let lock_id = f.client.lock_funds(&f.user, &lock_amount, &unlock_time);
all_events.extend(f.env.events().all());
assert_eq!(lock_id, 1);

let events = f.env.events().all();
let events = all_events;
// After deposit (2 events), lock_funds produces 1 event
assert_eq!(events.len(), 3, "deposit (2) + lock (1) = 3 total events");

// Event 2 (last event): Vault Lock Event
let (contract_2, topics_2, data_2) = events.get(2).unwrap();
assert_eq!(contract_2, f.contract_id, "lock event must originate from vault contract");
assert_eq!(*contract_2, f.contract_id, "lock event must originate from vault contract");
let topic0_2: Symbol = topics_2.get(0).unwrap().try_into_val(&f.env).unwrap();
assert_eq!(topic0_2, symbol_short!("lock"));
let user_2: Address = topics_2.get(1).unwrap().try_into_val(&f.env).unwrap();
Expand All @@ -253,23 +253,27 @@ fn test_lock_event_ordering() {
#[test]
fn test_withdraw_lock_event_ordering() {
let f = setup_event_fixture();
let mut all_events = std::vec::Vec::new();
set_ledger_timestamp(&f.env, 1_000);
f.client.deposit(&f.user, &2_000);
all_events.extend(f.env.events().all());
let lock_id = f.client.lock_funds(&f.user, &1_500, &5_000);
all_events.extend(f.env.events().all());

// Fast-forward timestamp past lock maturity
set_ledger_timestamp(&f.env, 6_000);

// Call withdraw_lock
f.client.withdraw_lock(&f.user, &lock_id);
all_events.extend(f.env.events().all());

let events = f.env.events().all();
let events = all_events;
// Events: Deposit (2) + Lock (1) + WithdrawLock (2) = 5 total events
assert_eq!(events.len(), 5, "total event stream count must be 5");

// Event 3: SAC Transfer for Matured Lock Release
let (contract_3, topics_3, data_3) = events.get(3).unwrap();
assert_eq!(contract_3, f.token_address, "event 3 must originate from SAC token contract");
assert_eq!(*contract_3, f.token_address, "event 3 must originate from SAC token contract");
let topic0_3: Symbol = topics_3.get(0).unwrap().try_into_val(&f.env).unwrap();
assert_eq!(topic0_3, symbol_short!("transfer"));
let from_3: Address = topics_3.get(1).unwrap().try_into_val(&f.env).unwrap();
Expand All @@ -281,7 +285,7 @@ fn test_withdraw_lock_event_ordering() {

// Event 4: Vault WithdrawLock Event
let (contract_4, topics_4, data_4) = events.get(4).unwrap();
assert_eq!(contract_4, f.contract_id, "event 4 must originate from vault contract");
assert_eq!(*contract_4, f.contract_id, "event 4 must originate from vault contract");
let topic0_4: Symbol = topics_4.get(0).unwrap().try_into_val(&f.env).unwrap();
assert_eq!(topic0_4, Symbol::new(&f.env, "withdraw_lock"));
let user_4: Address = topics_4.get(1).unwrap().try_into_val(&f.env).unwrap();
Expand All @@ -304,28 +308,37 @@ fn test_withdraw_lock_event_ordering() {
#[test]
fn test_full_vault_lifecycle_event_ordering() {
let f = setup_event_fixture();
let _ = f.env.events().all();
set_ledger_timestamp(&f.env, 1_000);

let mut all_events = std::vec::Vec::new();

// Step 1: Deposit 5,000 tokens
f.client.deposit(&f.user, &5_000);
all_events.extend(f.env.events().all());

// Step 2: Lock 2,000 tokens until t = 10,000
let lock_id = f.client.lock_funds(&f.user, &2_000, &10_000);
all_events.extend(f.env.events().all());

// Step 3: Advance time and withdraw locked funds
set_ledger_timestamp(&f.env, 12_000);
f.client.withdraw_lock(&f.user, &lock_id);
all_events.extend(f.env.events().all());

// Step 4: Withdraw remaining available balance (3,000 tokens)
f.client.withdraw(&f.user, &3_000);
all_events.extend(f.env.events().all());

let events = f.env.events().all();
let events = all_events;
assert_eq!(
events.len(),
7,
"full lifecycle must produce exactly 7 events in chronological order"
);



// Expected sequence summary:
// Event 0: SAC Transfer (User -> Contract, 5000)
// Event 1: Vault Deposit (User, amount=5000, new_balance=5000)
Expand All @@ -337,22 +350,26 @@ fn test_full_vault_lifecycle_event_ordering() {

// Event 0
let (c0, t0, d0) = events.get(0).unwrap();
assert_eq!(c0, f.token_address);
assert_eq!(t0.get(0).unwrap().try_into_val::<Symbol>(&f.env).unwrap(), symbol_short!("transfer"));
assert_eq!(d0.try_into_val::<i128>(&f.env).unwrap(), 5_000);
assert_eq!(*c0, f.token_address);
let s0: Symbol = t0.get(0).unwrap().try_into_val(&f.env).unwrap();
let val0: i128 = d0.try_into_val(&f.env).unwrap();
assert_eq!(s0, symbol_short!("transfer"));
assert_eq!(val0, 5_000);

// Event 1
let (c1, t1, d1) = events.get(1).unwrap();
assert_eq!(c1, f.contract_id);
assert_eq!(t1.get(0).unwrap().try_into_val::<Symbol>(&f.env).unwrap(), symbol_short!("deposit"));
assert_eq!(*c1, f.contract_id);
let s1: Symbol = t1.get(0).unwrap().try_into_val(&f.env).unwrap();
assert_eq!(s1, symbol_short!("deposit"));
let (amt1, bal1): (i128, i128) = d1.try_into_val(&f.env).unwrap();
assert_eq!(amt1, 5_000);
assert_eq!(bal1, 5_000);

// Event 2
let (c2, t2, d2) = events.get(2).unwrap();
assert_eq!(c2, f.contract_id);
assert_eq!(t2.get(0).unwrap().try_into_val::<Symbol>(&f.env).unwrap(), symbol_short!("lock"));
assert_eq!(*c2, f.contract_id);
let s2: Symbol = t2.get(0).unwrap().try_into_val(&f.env).unwrap();
assert_eq!(s2, symbol_short!("lock"));
let (amt2, time2, avail2, locked2): (i128, u64, i128, i128) = d2.try_into_val(&f.env).unwrap();
assert_eq!(amt2, 2_000);
assert_eq!(time2, 10_000);
Expand All @@ -361,28 +378,34 @@ fn test_full_vault_lifecycle_event_ordering() {

// Event 3
let (c3, t3, d3) = events.get(3).unwrap();
assert_eq!(c3, f.token_address);
assert_eq!(t3.get(0).unwrap().try_into_val::<Symbol>(&f.env).unwrap(), symbol_short!("transfer"));
assert_eq!(d3.try_into_val::<i128>(&f.env).unwrap(), 2_000);
assert_eq!(*c3, f.token_address);
let s3: Symbol = t3.get(0).unwrap().try_into_val(&f.env).unwrap();
let val3: i128 = d3.try_into_val(&f.env).unwrap();
assert_eq!(s3, symbol_short!("transfer"));
assert_eq!(val3, 2_000);

// Event 4
let (c4, t4, d4) = events.get(4).unwrap();
assert_eq!(c4, f.contract_id);
assert_eq!(t4.get(0).unwrap().try_into_val::<Symbol>(&f.env).unwrap(), Symbol::new(&f.env, "withdraw_lock"));
assert_eq!(*c4, f.contract_id);
let s4: Symbol = t4.get(0).unwrap().try_into_val(&f.env).unwrap();
assert_eq!(s4, Symbol::new(&f.env, "withdraw_lock"));
let (lid4, amt4): (u64, i128) = d4.try_into_val(&f.env).unwrap();
assert_eq!(lid4, lock_id);
assert_eq!(amt4, 2_000);

// Event 5
let (c5, t5, d5) = events.get(5).unwrap();
assert_eq!(c5, f.token_address);
assert_eq!(t5.get(0).unwrap().try_into_val::<Symbol>(&f.env).unwrap(), symbol_short!("transfer"));
assert_eq!(d5.try_into_val::<i128>(&f.env).unwrap(), 3_000);
assert_eq!(*c5, f.token_address);
let s5: Symbol = t5.get(0).unwrap().try_into_val(&f.env).unwrap();
let val5: i128 = d5.try_into_val(&f.env).unwrap();
assert_eq!(s5, symbol_short!("transfer"));
assert_eq!(val5, 3_000);

// Event 6
let (c6, t6, d6) = events.get(6).unwrap();
assert_eq!(c6, f.contract_id);
assert_eq!(t6.get(0).unwrap().try_into_val::<Symbol>(&f.env).unwrap(), symbol_short!("withdraw"));
assert_eq!(*c6, f.contract_id);
let s6: Symbol = t6.get(0).unwrap().try_into_val(&f.env).unwrap();
assert_eq!(s6, symbol_short!("withdraw"));
let (amt6, bal6): (i128, i128) = d6.try_into_val(&f.env).unwrap();
assert_eq!(amt6, 3_000);
assert_eq!(bal6, 0);
Expand Down
Loading