diff --git a/Stellar-contracts-v1/README.md b/Stellar-contracts-v1/README.md index 781aca8..ec70a06 100644 --- a/Stellar-contracts-v1/README.md +++ b/Stellar-contracts-v1/README.md @@ -90,6 +90,42 @@ remain available. Only the authorized pauser can change the pause state; for a volume-limit halt, governance must use the auditable override flow described below before activity can resume. +### Storage TTL / rent model + +`wpi-token` deliberately splits storage by lifecycle: + +| Data | Storage class | TTL strategy | +|---|---|---| +| `Balance(owner)`, `Allowance(owner, spender)`, processed deposit IDs, processed redemption IDs | Persistent | Each write extends that entry's TTL independently | +| `Admin`, `ProposedAdmin`, `Minter`, `Pauser`, `VolumeLimitAdmin`, `Paused`, `CircuitBreaker`, `TotalSupply`, volume-window bookkeeping | Instance | The contract refreshes instance TTL on writes; operators must still bump it during long idle periods | + +This matters because Soroban `instance()` storage has a single shared TTL for +the whole contract instance. If it is allowed to age out, every value stored in +that namespace becomes unavailable together. User-owned balances and allowances +therefore live in `persistent()` storage instead, so one stale account can no +longer drag every holder over the same expiry cliff. + +For quiet periods, run an automated keeper or cron job that calls the admin-only +`bump_instance_ttl` entrypoint before the shared instance TTL gets close to +expiry. Example: + +```bash +stellar contract invoke \ + --id "$WPI_CONTRACT_ID" \ + --source "$ADMIN_IDENTITY" \ + --network testnet \ + -- \ + bump_instance_ttl +``` + +Recommended operations policy: + +- Trigger `bump_instance_ttl` on a schedule comfortably inside the rent window + even if no bridge traffic is flowing. +- Monitor the contract instance TTL off-chain and alert well before expiry. +- Keep rent funding and the admin signer used for this keeper path under the + same operational controls as upgrade governance. + ### Configure the wPi bridge volume circuit breaker `wpi-token` fails closed: mint and burn calls return diff --git a/Stellar-contracts-v1/target/.rustc_info.json b/Stellar-contracts-v1/target/.rustc_info.json index 4dc939b..8f15bd9 100644 --- a/Stellar-contracts-v1/target/.rustc_info.json +++ b/Stellar-contracts-v1/target/.rustc_info.json @@ -1 +1 @@ -{"rustc_fingerprint":6167044578903928928,"outputs":{"17747080675513052775":{"success":true,"status":"","code":0,"stdout":"rustc 1.88.0 (6b00bc388 2025-06-23)\nbinary: rustc\ncommit-hash: 6b00bc3880198600130e1cf62b8f8a93494488cc\ncommit-date: 2025-06-23\nhost: x86_64-pc-windows-msvc\nrelease: 1.88.0\nLLVM version: 20.1.5\n","stderr":""},"7971740275564407648":{"success":true,"status":"","code":0,"stdout":"___.exe\nlib___.rlib\n___.dll\n___.dll\n___.lib\n___.dll\nC:\\Users\\pc\\.rustup\\toolchains\\1.88.0-x86_64-pc-windows-msvc\npacked\n___\ndebug_assertions\npanic=\"unwind\"\nproc_macro\ntarget_abi=\"\"\ntarget_arch=\"x86_64\"\ntarget_endian=\"little\"\ntarget_env=\"msvc\"\ntarget_family=\"windows\"\ntarget_feature=\"cmpxchg16b\"\ntarget_feature=\"fxsr\"\ntarget_feature=\"sse\"\ntarget_feature=\"sse2\"\ntarget_feature=\"sse3\"\ntarget_has_atomic=\"128\"\ntarget_has_atomic=\"16\"\ntarget_has_atomic=\"32\"\ntarget_has_atomic=\"64\"\ntarget_has_atomic=\"8\"\ntarget_has_atomic=\"ptr\"\ntarget_os=\"windows\"\ntarget_pointer_width=\"64\"\ntarget_vendor=\"pc\"\nwindows\n","stderr":""},"6027984484328994041":{"success":true,"status":"","code":0,"stdout":"___.exe\nlib___.rlib\n___.dll\n___.dll\nlib___.a\n___.dll\nC:\\Users\\pc\\.rustup\\toolchains\\1.88.0-x86_64-pc-windows-msvc\noff\n___\ndebug_assertions\npanic=\"unwind\"\nproc_macro\ntarget_abi=\"\"\ntarget_arch=\"x86_64\"\ntarget_endian=\"little\"\ntarget_env=\"gnu\"\ntarget_family=\"windows\"\ntarget_feature=\"cmpxchg16b\"\ntarget_feature=\"fxsr\"\ntarget_feature=\"sse\"\ntarget_feature=\"sse2\"\ntarget_feature=\"sse3\"\ntarget_has_atomic=\"128\"\ntarget_has_atomic=\"16\"\ntarget_has_atomic=\"32\"\ntarget_has_atomic=\"64\"\ntarget_has_atomic=\"8\"\ntarget_has_atomic=\"ptr\"\ntarget_os=\"windows\"\ntarget_pointer_width=\"64\"\ntarget_vendor=\"pc\"\nwindows\n","stderr":""}},"successes":{}} \ No newline at end of file +{"rustc_fingerprint":9382291209947408725,"outputs":{"7971740275564407648":{"success":true,"status":"","code":0,"stdout":"___.exe\nlib___.rlib\n___.dll\n___.dll\n___.lib\n___.dll\nC:\\Users\\Godsm\\.rustup\\toolchains\\1.88.0-x86_64-pc-windows-msvc\npacked\n___\ndebug_assertions\npanic=\"unwind\"\nproc_macro\ntarget_abi=\"\"\ntarget_arch=\"x86_64\"\ntarget_endian=\"little\"\ntarget_env=\"msvc\"\ntarget_family=\"windows\"\ntarget_feature=\"cmpxchg16b\"\ntarget_feature=\"fxsr\"\ntarget_feature=\"sse\"\ntarget_feature=\"sse2\"\ntarget_feature=\"sse3\"\ntarget_has_atomic=\"128\"\ntarget_has_atomic=\"16\"\ntarget_has_atomic=\"32\"\ntarget_has_atomic=\"64\"\ntarget_has_atomic=\"8\"\ntarget_has_atomic=\"ptr\"\ntarget_os=\"windows\"\ntarget_pointer_width=\"64\"\ntarget_vendor=\"pc\"\nwindows\n","stderr":""},"17747080675513052775":{"success":true,"status":"","code":0,"stdout":"rustc 1.88.0 (6b00bc388 2025-06-23)\nbinary: rustc\ncommit-hash: 6b00bc3880198600130e1cf62b8f8a93494488cc\ncommit-date: 2025-06-23\nhost: x86_64-pc-windows-msvc\nrelease: 1.88.0\nLLVM version: 20.1.5\n","stderr":""}},"successes":{}} \ No newline at end of file diff --git a/Stellar-contracts-v1/wpi-token/src/lib.rs b/Stellar-contracts-v1/wpi-token/src/lib.rs index 1abc4a5..11b98b1 100644 --- a/Stellar-contracts-v1/wpi-token/src/lib.rs +++ b/Stellar-contracts-v1/wpi-token/src/lib.rs @@ -9,11 +9,16 @@ use soroban_sdk::{ contract, contracterror, contractevent, contractimpl, contracttype, symbol_short, Address, - BytesN, Env, Symbol, + BytesN, Env, IntoVal, Symbol, Val, }; const NAME: &str = "Wrapped Pi"; const SYMBOL: &str = "wPI"; +const LEDGERS_PER_DAY: u32 = 17_280; +const PERSISTENT_ENTRY_TTL_THRESHOLD: u32 = 30 * LEDGERS_PER_DAY; +const PERSISTENT_ENTRY_TTL_EXTEND_TO: u32 = 180 * LEDGERS_PER_DAY; +const INSTANCE_TTL_THRESHOLD: u32 = 7 * LEDGERS_PER_DAY; +const INSTANCE_TTL_EXTEND_TO: u32 = 30 * LEDGERS_PER_DAY; /// Pi Network is an SCP fork of Stellar and exposes the same Horizon REST /// API. Native Pi amounts use 7 decimal places (1 Pi = 10_000_000 stroops), /// identical to Stellar's native asset convention. @@ -148,14 +153,31 @@ fn is_redemption_processed(env: &Env, redemption_id: &BytesN<32>) -> bool { } fn mark_redemption_processed(env: &Env, redemption_id: &BytesN<32>) { - env.storage() - .persistent() - .set(&DataKey::ProcessedRedemption(redemption_id.clone()), &true); + let key = DataKey::ProcessedRedemption(redemption_id.clone()); + env.storage().persistent().set(&key, &true); + bump_persistent_ttl(env, &key); } #[contract] pub struct WpiToken; +fn bump_instance_storage_ttl(env: &Env) { + env.storage() + .instance() + .extend_ttl(INSTANCE_TTL_THRESHOLD, INSTANCE_TTL_EXTEND_TO); +} + +fn bump_persistent_ttl(env: &Env, key: &K) +where + K: IntoVal, +{ + env.storage().persistent().extend_ttl( + key, + PERSISTENT_ENTRY_TTL_THRESHOLD, + PERSISTENT_ENTRY_TTL_EXTEND_TO, + ); +} + fn read_admin(env: &Env) -> Address { env.storage() .instance() @@ -196,6 +218,7 @@ fn write_volume_limit_admin(env: &Env, admin: &Address) { env.storage() .instance() .set(&DataKey::VolumeLimitAdmin, admin); + bump_instance_storage_ttl(env); } fn read_minter(env: &Env) -> Address { @@ -218,6 +241,7 @@ fn require_minter(env: &Env) -> Address { fn write_minter(env: &Env, minter: &Address) { env.storage().instance().set(&DataKey::Minter, minter); + bump_instance_storage_ttl(env); } fn read_pauser(env: &Env) -> Address { @@ -240,10 +264,12 @@ fn require_pauser(env: &Env) -> Address { fn write_pauser(env: &Env, pauser: &Address) { env.storage().instance().set(&DataKey::Pauser, pauser); + bump_instance_storage_ttl(env); } fn write_admin(env: &Env, admin: &Address) { env.storage().instance().set(&DataKey::Admin, admin); + bump_instance_storage_ttl(env); } fn read_proposed_admin(env: &Env) -> Option
{ @@ -256,10 +282,12 @@ fn write_proposed_admin(env: &Env, proposed: &Address) { env.storage() .instance() .set(&DataKey::ProposedAdmin, proposed); + bump_instance_storage_ttl(env); } fn remove_proposed_admin(env: &Env) { env.storage().instance().remove(&DataKey::ProposedAdmin); + bump_instance_storage_ttl(env); } fn is_paused(env: &Env) -> bool { @@ -271,6 +299,7 @@ fn is_paused(env: &Env) -> bool { fn set_paused(env: &Env, paused: bool) { env.storage().instance().set(&DataKey::Paused, &paused); + bump_instance_storage_ttl(env); } fn is_circuit_breaker_active(env: &Env) -> bool { @@ -284,26 +313,35 @@ fn set_circuit_breaker(env: &Env, active: bool) { env.storage() .instance() .set(&DataKey::CircuitBreaker, &active); + bump_instance_storage_ttl(env); } fn read_balance(env: &Env, address: &Address) -> i128 { + let key = DataKey::Balance(address.clone()); env.storage() - .instance() - .get::(&DataKey::Balance(address.clone())) + .persistent() + .get::(&key) + .or_else(|| env.storage().instance().get::(&key)) .unwrap_or(0) } fn write_balance(env: &Env, address: &Address, amount: i128) { + let key = DataKey::Balance(address.clone()); + env.storage().persistent().set(&key, &amount); + bump_persistent_ttl(env, &key); + env.storage().instance().remove(&key); +} + +fn read_allowance_data(env: &Env, owner: &Address, spender: &Address) -> Option { + let key = DataKey::Allowance(owner.clone(), spender.clone()); env.storage() - .instance() - .set(&DataKey::Balance(address.clone()), &amount); + .persistent() + .get::(&key) + .or_else(|| env.storage().instance().get::(&key)) } fn read_allowance(env: &Env, owner: &Address, spender: &Address) -> i128 { - let allowance = env - .storage() - .instance() - .get::(&DataKey::Allowance(owner.clone(), spender.clone())); + let allowance = read_allowance_data(env, owner, spender); match allowance { Some(data) if data.expiration_ledger >= env.ledger().sequence() => data.amount, _ => 0, @@ -317,13 +355,16 @@ fn write_allowance( amount: i128, expiration_ledger: u32, ) { - env.storage().instance().set( - &DataKey::Allowance(owner.clone(), spender.clone()), + let key = DataKey::Allowance(owner.clone(), spender.clone()); + env.storage().persistent().set( + &key, &AllowanceData { amount, expiration_ledger, }, ); + bump_persistent_ttl(env, &key); + env.storage().instance().remove(&key); } fn read_total_supply(env: &Env) -> i128 { @@ -335,6 +376,7 @@ fn read_total_supply(env: &Env) -> i128 { fn write_total_supply(env: &Env, amount: i128) { env.storage().instance().set(&DataKey::TotalSupply, &amount); + bump_instance_storage_ttl(env); } fn is_deposit_processed(env: &Env, deposit_id: &BytesN<32>) -> bool { @@ -345,9 +387,9 @@ fn is_deposit_processed(env: &Env, deposit_id: &BytesN<32>) -> bool { } fn mark_deposit_processed(env: &Env, deposit_id: &BytesN<32>) { - env.storage() - .persistent() - .set(&DataKey::ProcessedDeposit(deposit_id.clone()), &true); + let key = DataKey::ProcessedDeposit(deposit_id.clone()); + env.storage().persistent().set(&key, &true); + bump_persistent_ttl(env, &key); } fn next_redemption_nonce(env: &Env) -> Result { @@ -360,6 +402,7 @@ fn next_redemption_nonce(env: &Env) -> Result { env.storage() .instance() .set(&DataKey::RedemptionNonce, &next); + bump_instance_storage_ttl(env); Ok(next) } @@ -384,6 +427,7 @@ fn advance_volume_generation(env: &Env) -> Result { env.storage() .instance() .set(&DataKey::VolumeGeneration, &next); + bump_instance_storage_ttl(env); Ok(next) } @@ -418,6 +462,7 @@ fn write_volume_bucket(env: &Env, bucket: &VolumeBucket) { env.storage() .instance() .set(&DataKey::VolumeBucket(slot), bucket); + bump_instance_storage_ttl(env); } fn read_volume_window(env: &Env, config: &VolumeLimitConfig) -> Result { @@ -611,6 +656,7 @@ impl WpiToken { env.storage() .instance() .set(&DataKey::VolumeLimitConfig, &config); + bump_instance_storage_ttl(&env); VolumeLimitsConfigured { mint_limit, burn_limit, @@ -687,8 +733,16 @@ impl WpiToken { transfer_internal(&env, &from, &to, amount)?; let expiration_ledger = env .storage() - .instance() + .persistent() .get::(&DataKey::Allowance(from.clone(), spender.clone())) + .or_else(|| { + env.storage() + .instance() + .get::(&DataKey::Allowance( + from.clone(), + spender.clone(), + )) + }) .map(|data| data.expiration_ledger) .unwrap_or(0); write_allowance(&env, &from, &spender, allowance - amount, expiration_ledger); @@ -723,8 +777,16 @@ impl WpiToken { } let expiration_ledger = env .storage() - .instance() + .persistent() .get::(&DataKey::Allowance(from.clone(), spender.clone())) + .or_else(|| { + env.storage() + .instance() + .get::(&DataKey::Allowance( + from.clone(), + spender.clone(), + )) + }) .map(|data| data.expiration_ledger) .unwrap_or(0); write_allowance(&env, &from, &spender, allowance - amount, expiration_ledger); @@ -865,6 +927,14 @@ impl WpiToken { Ok(()) } + /// Admin-only keeper hook for periods where the contract is idle and + /// instance storage would not otherwise be refreshed by normal writes. + pub fn bump_instance_ttl(env: Env) -> Result<(), Error> { + require_admin(&env); + bump_instance_storage_ttl(&env); + Ok(()) + } + pub fn upgrade(env: Env, new_wasm_hash: BytesN<32>) -> Result<(), Error> { require_admin(&env); env.deployer().update_current_contract_wasm(new_wasm_hash); diff --git a/Stellar-contracts-v1/wpi-token/src/test.rs b/Stellar-contracts-v1/wpi-token/src/test.rs index 1e392dc..098e88a 100644 --- a/Stellar-contracts-v1/wpi-token/src/test.rs +++ b/Stellar-contracts-v1/wpi-token/src/test.rs @@ -1,6 +1,9 @@ use super::*; use proptest::prelude::*; -use soroban_sdk::testutils::{Address as _, Events as _, Ledger as _, MockAuth, MockAuthInvoke}; +use soroban_sdk::testutils::{ + storage::{Instance as _, Persistent as _}, + Address as _, Events as _, Ledger as _, MockAuth, MockAuthInvoke, +}; use soroban_sdk::IntoVal; fn deposit_id(env: &Env, tag: u8) -> BytesN<32> { @@ -164,6 +167,92 @@ fn rolling_window_does_not_expire_volume_early_at_bucket_boundary() { assert!(client.circuit_breaker_active()); } +#[test] +fn user_state_is_persistent_and_gets_its_own_ttl() { + let env = Env::default(); + let (_admin, client, user) = setup(&env, 1_000, 1_000, 86_400); + let spender = Address::generate(&env); + + client.mint_from_deposit(&user, &25, &deposit_id(&env, 1)); + client.approve(&user, &spender, &10, &(env.ledger().sequence() + 500)); + + env.as_contract(&client.address, || { + assert_eq!( + env.storage() + .persistent() + .get_ttl(&DataKey::Balance(user.clone())), + PERSISTENT_ENTRY_TTL_EXTEND_TO + ); + assert_eq!( + env.storage() + .persistent() + .get_ttl(&DataKey::Allowance(user.clone(), spender.clone())), + PERSISTENT_ENTRY_TTL_EXTEND_TO + ); + }); +} + +#[test] +fn admin_can_refresh_instance_ttl_for_idle_periods() { + let env = Env::default(); + let (_admin, client, _user) = setup(&env, 1_000, 1_000, 86_400); + + env.ledger() + .set_sequence_number(env.ledger().sequence() + INSTANCE_TTL_EXTEND_TO - 25); + + env.as_contract(&client.address, || { + assert_eq!(env.storage().instance().get_ttl(), 25); + }); + + client.bump_instance_ttl(); + + env.as_contract(&client.address, || { + assert_eq!(env.storage().instance().get_ttl(), INSTANCE_TTL_EXTEND_TO); + }); +} + +#[test] +fn an_older_balance_entry_expiring_does_not_wipe_newer_accounts() { + let env = Env::default(); + let (_admin, client, user_a) = setup(&env, 1_000, 1_000, 86_400); + let user_b = Address::generate(&env); + + client.mint_from_deposit(&user_a, &7, &deposit_id(&env, 1)); + + env.ledger() + .set_sequence_number(env.ledger().sequence() + PERSISTENT_ENTRY_TTL_EXTEND_TO - 10); + client.mint_from_deposit(&user_b, &11, &deposit_id(&env, 2)); + + env.as_contract(&client.address, || { + assert_eq!( + env.storage() + .persistent() + .get_ttl(&DataKey::Balance(user_a.clone())), + 10 + ); + assert_eq!( + env.storage() + .persistent() + .get_ttl(&DataKey::Balance(user_b.clone())), + PERSISTENT_ENTRY_TTL_EXTEND_TO + ); + }); + + env.ledger() + .set_sequence_number(env.ledger().sequence() + 11); + + assert_eq!(client.balance(&user_b), 11); + + env.as_contract(&client.address, || { + assert!( + env.storage() + .persistent() + .get_ttl(&DataKey::Balance(user_b.clone())) + > PERSISTENT_ENTRY_TTL_THRESHOLD + ); + }); +} + #[test] fn only_override_can_lift_a_tripped_circuit_breaker() { let env = Env::default();