From 8b96576a7984bd1da95e6dbf431935a7634c0484 Mon Sep 17 00:00:00 2001 From: carrion256 Date: Mon, 18 May 2026 15:29:32 +0200 Subject: [PATCH 1/2] fix: harden share-token TTL runbook (#FIND-096 #FIND-098 #FIND-100 Nexus 1423b989-f1cf-4827-bc37-6d76d00018f8 Nexus 8e84564c-0b32-40b9-8bc8-227b2ca23395 Nexus 1122e421-af18-43d0-9062-bb44dbe948cc) Trace: e189e24fc06f01cd0a1b45988d5984aa0ee5bffa --- contract/vault/soroban/README.md | 7 ++ contract/vault/soroban/share-token/src/lib.rs | 6 + .../vault/soroban/share-token/src/tests.rs | 115 ++++++++++++++++++ 3 files changed, 128 insertions(+) diff --git a/contract/vault/soroban/README.md b/contract/vault/soroban/README.md index 5f2794673..3b685a1b7 100644 --- a/contract/vault/soroban/README.md +++ b/contract/vault/soroban/README.md @@ -218,3 +218,10 @@ Parity tests check behavioral equivalence across the shared kernel and chain exe - Soroban share-token transfers are user-authorized (`from.require_auth()`). - The vault can still transfer shares for internal flows (escrow/payout effects). + +## Share Token TTL and Archival Recovery + +- Share-token instance storage is refreshed by every public share-token entrypoint, including SEP-41 read-only methods (`total_supply`, `balance`, `allowance`, `decimals`, `name`, and `symbol`) and the custom `admin` / `vault` getters. +- The admin-only `extend_ttl(caller)` entrypoint is the explicit keeper path for proactive instance maintenance. Operators should schedule it well before the instance reaches the TTL threshold; if the instance is archived, restore the contract instance through the Stellar/Soroban archival restore flow first, then call `extend_ttl` as the configured admin. +- Per-holder balances are persistent entries owned by the upstream `stellar-tokens` implementation. Balance reads and balance-changing writes refresh the specific holder balance that is touched; the share token intentionally does not maintain an enumerable holder index or perform unbounded global balance refreshes from `extend_ttl`. +- Allowances are temporary entries bounded by their explicit `live_until_ledger`. They are not extended beyond that caller-selected expiry by the share-token keeper path; owners should renew approvals when continued delegated spending is desired. diff --git a/contract/vault/soroban/share-token/src/lib.rs b/contract/vault/soroban/share-token/src/lib.rs index 25701ee16..83a44a636 100644 --- a/contract/vault/soroban/share-token/src/lib.rs +++ b/contract/vault/soroban/share-token/src/lib.rs @@ -25,14 +25,17 @@ impl FungibleToken for SorobanShareTokenContract { type ContractType = Base; fn total_supply(e: &Env) -> i128 { + extend_instance_ttl(e); Base::total_supply(e) } fn balance(e: &Env, account: Address) -> i128 { + extend_instance_ttl(e); Base::balance(e, &account) } fn allowance(e: &Env, owner: Address, spender: Address) -> i128 { + extend_instance_ttl(e); Base::allowance(e, &owner, &spender) } @@ -52,14 +55,17 @@ impl FungibleToken for SorobanShareTokenContract { } fn decimals(e: &Env) -> u32 { + extend_instance_ttl(e); Base::decimals(e) } fn name(e: &Env) -> String { + extend_instance_ttl(e); Base::name(e) } fn symbol(e: &Env) -> String { + extend_instance_ttl(e); Base::symbol(e) } } diff --git a/contract/vault/soroban/share-token/src/tests.rs b/contract/vault/soroban/share-token/src/tests.rs index 60f5fe87e..0ac39114e 100644 --- a/contract/vault/soroban/share-token/src/tests.rs +++ b/contract/vault/soroban/share-token/src/tests.rs @@ -467,6 +467,121 @@ fn admin_cannot_change_metadata_after_deployment() { ); } +#[test] +fn read_only_entrypoints_cover_share_token_ttl_maintenance_surface() { + let (env, _admin, vault, token) = setup(); + let user = Address::generate(&env); + let spender = Address::generate(&env); + + env.as_contract(&vault, || { + VaultCaller::mint(env.clone(), token.clone(), user.clone(), 1000); + VaultCaller::approve( + env.clone(), + token.clone(), + user.clone(), + spender.clone(), + 250, + 300, + ); + }); + + let supply: i128 = env.invoke_contract( + &token, + &soroban_sdk::Symbol::new(&env, "total_supply"), + ().into_val(&env), + ); + let balance: i128 = env.invoke_contract( + &token, + &soroban_sdk::Symbol::new(&env, "balance"), + (&user,).into_val(&env), + ); + let allowance: i128 = env.invoke_contract( + &token, + &soroban_sdk::Symbol::new(&env, "allowance"), + (&user, &spender).into_val(&env), + ); + let name: String = env.invoke_contract( + &token, + &soroban_sdk::Symbol::new(&env, "name"), + ().into_val(&env), + ); + let symbol: String = env.invoke_contract( + &token, + &soroban_sdk::Symbol::new(&env, "symbol"), + ().into_val(&env), + ); + let decimals: u32 = env.invoke_contract( + &token, + &soroban_sdk::Symbol::new(&env, "decimals"), + ().into_val(&env), + ); + + assert_eq!(supply, 1000); + assert_eq!(balance, 1000); + assert_eq!(allowance, 250); + assert_eq!(name, String::from_str(&env, "Templar Share")); + assert_eq!(symbol, String::from_str(&env, "tvSHARE")); + assert_eq!(decimals, 7); +} + +#[test] +fn admin_extend_ttl_preserves_holder_balances_and_allowance_expiry_semantics() { + let (env, admin, vault, token) = setup(); + let user = Address::generate(&env); + let spender = Address::generate(&env); + + env.as_contract(&vault, || { + VaultCaller::mint(env.clone(), token.clone(), user.clone(), 1000); + VaultCaller::approve( + env.clone(), + token.clone(), + user.clone(), + spender.clone(), + 400, + 150, + ); + }); + + env.invoke_contract::<()>( + &token, + &soroban_sdk::Symbol::new(&env, "extend_ttl"), + (&admin,).into_val(&env), + ); + + let balance: i128 = env.invoke_contract( + &token, + &soroban_sdk::Symbol::new(&env, "balance"), + (&user,).into_val(&env), + ); + let allowance_before_expiry: i128 = env.invoke_contract( + &token, + &soroban_sdk::Symbol::new(&env, "allowance"), + (&user, &spender).into_val(&env), + ); + assert_eq!(balance, 1000); + assert_eq!(allowance_before_expiry, 400); + + env.ledger().set(LedgerInfo { + timestamp: 101, + protocol_version: 25, + sequence_number: 151, + max_entry_ttl: 1_000, + ..Default::default() + }); + + env.invoke_contract::<()>( + &token, + &soroban_sdk::Symbol::new(&env, "extend_ttl"), + (&admin,).into_val(&env), + ); + let allowance_after_expiry: i128 = env.invoke_contract( + &token, + &soroban_sdk::Symbol::new(&env, "allowance"), + (&user, &spender).into_val(&env), + ); + assert_eq!(allowance_after_expiry, 0); +} + #[test] fn total_supply_tracks_mint_and_burn() { let (env, _admin, vault, token) = setup(); From 09c9fc20a74c3184b2336a3b3d6b4d8a65e324ae Mon Sep 17 00:00:00 2001 From: carrion256 Date: Wed, 20 May 2026 12:47:15 +0200 Subject: [PATCH 2/2] test: assert share-token read TTL refresh (#FIND-094 Nexus 5d032eb3-1953-4ed7-9971-7e335029ad0a) --- .../vault/soroban/share-token/src/tests.rs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/contract/vault/soroban/share-token/src/tests.rs b/contract/vault/soroban/share-token/src/tests.rs index 0ac39114e..430e547c0 100644 --- a/contract/vault/soroban/share-token/src/tests.rs +++ b/contract/vault/soroban/share-token/src/tests.rs @@ -1,4 +1,5 @@ use super::*; +use soroban_sdk::testutils::storage::Instance; use soroban_sdk::testutils::Address as _; use soroban_sdk::testutils::{Events, Ledger, LedgerInfo}; use soroban_sdk::xdr::{ContractEventBody, ScVal}; @@ -522,6 +523,24 @@ fn read_only_entrypoints_cover_share_token_ttl_maintenance_surface() { assert_eq!(name, String::from_str(&env, "Templar Share")); assert_eq!(symbol, String::from_str(&env, "tvSHARE")); assert_eq!(decimals, 7); + + env.ledger().set(LedgerInfo { + timestamp: 100, + protocol_version: 25, + sequence_number: 2_592_100, + max_entry_ttl: 3_110_400, + ..Default::default() + }); + let ttl_before_read = env.as_contract(&token, || env.storage().instance().get_ttl()); + let refreshed_supply: i128 = env.invoke_contract( + &token, + &soroban_sdk::Symbol::new(&env, "total_supply"), + ().into_val(&env), + ); + let ttl_after_read = env.as_contract(&token, || env.storage().instance().get_ttl()); + + assert_eq!(refreshed_supply, 1000); + assert!(ttl_after_read > ttl_before_read); } #[test]