Skip to content
Draft
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
10 changes: 6 additions & 4 deletions contract/vault/soroban/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -194,15 +194,17 @@ Useful commands:
## State Size and Operational Limits

- Soroban enforces per-entry and per-transaction resource limits. Current network values are documented by Stellar: https://developers.stellar.org/docs/networks/resource-limits-fees
- Vault runtime state is persisted as a single `StateBlob`, so serialized `VaultState` size is the practical storage-pressure point.
- The main long-lived growth vector is pending withdrawals, which are bounded by `MAX_PENDING = 1024`.
- In-flight operation plans (`Allocating.plan`, `Refreshing.plan`) are expected to remain small under allocator policy, so the 1024 pending-withdrawal cap is the dominant operational bound in practice.
- Vault runtime state is persisted as a compact versioned `StateBlob` header plus domain-paged withdrawal queue entries. Each `wqpage` stores up to 128 pending withdrawals, so the queue can use the kernel `MAX_PENDING = 1024` cap without coupling the whole queue to one 64 KiB storage entry.
- Restrictions and policy blobs use the generic blob-paging transport. Small payloads are stored inline; larger payloads are split into bounded 32 KiB pages.
- One contract invocation is still bounded by Soroban transaction resource limits. Very large sanctions-list style updates should use a batched governance/update flow instead of one giant replacement payload.
- In-flight operation plans (`Allocating.plan`, `Refreshing.plan`) are expected to remain small under allocator policy; if that assumption changes, the paged blob transport protects storage entry size but not per-transaction CPU/write-byte budgets.
- Persistent storage blobs carry a compact `TVS` version header. Decoders reject pre-header bytes and unsupported versions; schema upgrades should add explicit per-version decode/migration dispatch before any layout change.

## Practical Risk Model

- TVL growth by itself does not significantly increase serialized state size.
- Risk comes from queue backlog plus unusually large in-flight plans.
- If state exceeds Soroban storage write limits, the transaction fails atomically (no partial state commit).
- If state would exceed Soroban storage write limits, storage save paths return a typed runtime storage error before the host storage write.

## Parity Tests

Expand Down
4 changes: 2 additions & 2 deletions contract/vault/soroban/STRIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -224,8 +224,8 @@ emit admin events via the existing `emit_admin_event()` pattern. |
| **Information Disclosure** | **Info.1.R.1** — Accepted: no confidentiality assumptions on-chain. This is expected behavior for public blockchain contracts. **Info.1.R.2** — Avoid introducing unnecessary detailed event payloads that could leak operational patterns. |
| | **Info.2.R.1** — Accepted risk: governance and fee configuration are operational parameters that need to be publicly verifiable. **Info.2.R.2** — Protect governance/curator keys through operational security (multisig, HSM), not obscurity. |
| | **Info.3.R.1** — Accepted risk: queue transparency is a feature for user trust. **Info.3.R.2** — Monitor for unusual withdrawal patterns that might indicate front-running. **Info.3.R.3** — Consider: withdrawal cooldown (`DEFAULT_COOLDOWN_NS`) already provides some protection against same-block front-running. |
| **Denial of Service** | **DoS.1.R.1** — Accepted operational model: queued withdrawals require keeper/operator progression. Queue bounded by `MAX_PENDING = 1024`; guarded transitions prevent partial corruption. **DoS.1.R.2** — Operate redundant keepers and queue-staleness alerts. |
| | **DoS.2.R.1** — Ensure governance key/contract is resilient (multisig/DAO, signer redundancy) to avoid migration-state liveness failures. **DoS.2.R.2** — Test upgrade/migrate flow thoroughly on testnet before mainnet deployment. **DoS.2.R.3** — ✅ **Implemented**: `cancel_migration()` governance method added. Governance can cancel a pending migration, reverting the contract to operational state if `migrate()` has not been called. **DoS.2.R.4** — Document the upgrade procedure and key custody requirements. |
| **Denial of Service** | **DoS.1.R.1** — Accepted operational model: queued withdrawals require keeper/operator progression. Soroban runtime caps pending withdrawals at `SOROBAN_MAX_PENDING_WITHDRAWALS = 512` so the monolithic `StateBlob` stays below the 64 KiB contract-data-entry limit with schema-growth margin. **DoS.1.R.2** — Operate redundant keepers and queue-staleness alerts. |
| | **DoS.2.R.1** — Ensure governance key/contract is resilient (multisig/DAO, signer redundancy) to avoid migration-state liveness failures. **DoS.2.R.2** — Test upgrade/migrate flow thoroughly on testnet before mainnet deployment. **DoS.2.R.3** — ✅ **Implemented**: `cancel_migration()` governance method clears the migration gate and returns the contract to operational state, but it is not a WASM rollback primitive after `update_current_contract_wasm`; bad-code recovery still requires a follow-up governed upgrade/migration when chain execution permits it. **DoS.2.R.4** — Document the upgrade procedure and key custody requirements. |
| | **DoS.3.R.1** — Adapter failures are localized to affected market operations; maintain diversified/vetted adapters. **DoS.3.R.2** — ✅ Implemented: adapter allowlist + queue-index routing enables rapid disabling of bad adapters. |
| | **DoS.4.R.1** — Accepted with monitoring based on current sizing math and workload expectations. **DoS.4.R.2** — Keep telemetry on queue depth/resource usage and revisit if workload or network limits change. |
| | **DoS.5.R.1** — `extend_ttl()` is permissionless — anyone can call it. TTL threshold is ~30 days (518,400 ledgers), extension is to ~6 months (3,110,400 ledgers). **DoS.5.R.2** — Operate a keeper bot that calls `extend_ttl` periodically. **DoS.5.R.3** — `save_state` and `save_address` automatically extend TTL on writes, providing additional safety margin. |
Expand Down
2 changes: 1 addition & 1 deletion contract/vault/soroban/src/contract/curator_vault.rs
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,7 @@ where
fees: self.config.fees,
min_withdrawal_assets: MIN_WITHDRAWAL_ASSETS,
withdrawal_cooldown_ns: DEFAULT_COOLDOWN_NS,
max_pending_withdrawals: MAX_PENDING as u32,
max_pending_withdrawals: SOROBAN_MAX_PENDING_WITHDRAWALS,
paused: self.paused,
virtual_shares: self.config.virtual_shares,
virtual_assets: self.config.virtual_assets,
Expand Down
3 changes: 2 additions & 1 deletion contract/vault/soroban/src/contract/entrypoints.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ use super::helpers::{
load_virtual_offsets, migrate_legacy_paused, migration_in_progress, require_contract_address,
require_governance, require_signed, sdk_string_to_alloc, set_config_address,
set_migration_in_progress, store_fees_spec, store_virtual_offsets,
with_contract_vault_contract_error,
validate_and_rewrite_storage, with_contract_vault_contract_error,
};
use super::*;
use templar_soroban_shared_types::{
Expand Down Expand Up @@ -989,6 +989,7 @@ impl SorobanVaultContract {
}

migrate_legacy_paused(&env);
runtime_to_contract(validate_and_rewrite_storage(&env))?;
extend_storage_ttl(&env);
set_migration_in_progress(&env, false);
emit_admin_event(&env, symbol_short!("migrate"));
Expand Down
58 changes: 56 additions & 2 deletions contract/vault/soroban/src/contract/helpers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -324,8 +324,8 @@ pub(crate) fn apply_fee_change(

runtime_to_contract(store_fees_spec(env, &fees))?;
let storage = SorobanStorage::new(env);
storage.save_address(&performance_kernel, &performance_recipient);
storage.save_address(&management_kernel, &management_recipient);
runtime_to_contract(storage.save_address(&performance_kernel, &performance_recipient))?;
runtime_to_contract(storage.save_address(&management_kernel, &management_recipient))?;
Ok(())
}

Expand All @@ -335,6 +335,29 @@ pub(crate) fn extend_storage_ttl(env: &Env) {
.extend_ttl(DEFAULT_TTL_THRESHOLD, DEFAULT_TTL_EXTEND_TO);
let storage = SorobanStorage::new(env);
storage.extend_ttl(DEFAULT_TTL_THRESHOLD, DEFAULT_TTL_EXTEND_TO);
if let Ok(fees) = load_fees_spec(env) {
storage.extend_address_ttl(
&fees.performance.recipient,
DEFAULT_TTL_THRESHOLD,
DEFAULT_TTL_EXTEND_TO,
);
storage.extend_address_ttl(
&fees.management.recipient,
DEFAULT_TTL_THRESHOLD,
DEFAULT_TTL_EXTEND_TO,
);
}
for key in [
VaultDataKey::Curator,
VaultDataKey::Governance,
VaultDataKey::AssetToken,
VaultDataKey::ShareToken,
] {
if let Ok(address) = get_config_address(env, &key) {
let kernel = kernel_address_from_sdk(env, &address);
storage.extend_address_ttl(&kernel, DEFAULT_TTL_THRESHOLD, DEFAULT_TTL_EXTEND_TO);
}
}
}

pub(crate) fn get_config_address(
Expand Down Expand Up @@ -367,6 +390,15 @@ pub(crate) fn sdk_string_to_alloc(
}

pub(crate) fn migrate_legacy_paused(env: &Env) {
if env
.storage()
.instance()
.get::<_, bool>(&LEGACY_PAUSED_MIGRATED_KEY)
.unwrap_or(false)
{
return;
}

let storage = SorobanStorage::new(env);

if let Some(paused) = env
Expand All @@ -376,12 +408,34 @@ pub(crate) fn migrate_legacy_paused(env: &Env) {
{
storage.set_paused(paused);
env.storage().instance().remove(&VaultDataKey::Paused);
env.storage()
.instance()
.set(&LEGACY_PAUSED_MIGRATED_KEY, &true);
return;
}

if let Some(paused) = storage.take_legacy_paused() {
storage.set_paused(paused);
}
env.storage()
.instance()
.set(&LEGACY_PAUSED_MIGRATED_KEY, &true);
}

pub(crate) fn validate_and_rewrite_storage(env: &Env) -> Result<(), RuntimeError> {
let mut storage = SorobanStorage::new(env);
if let Some(state) = storage.load_state()? {
storage.save_state(&state)?;
}
if let Some(policy) = storage.load_policy_state()? {
storage.save_policy_state(&policy)?;
}
if let Some(restrictions) = Storage::load_restrictions(&storage)? {
Storage::save_restrictions(&mut storage, &Some(restrictions))?;
}
let fees = load_fees_spec(env)?;
store_fees_spec(env, &fees)?;
Ok(())
}

#[inline(never)]
Expand Down
5 changes: 4 additions & 1 deletion contract/vault/soroban/src/contract/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,12 +50,15 @@ use templar_vault_kernel::{
apply_action, convert_to_assets, convert_to_assets_ceil, convert_to_shares,
convert_to_shares_ceil, plan_idle_payout, withdrawal_settled, Address, FeeAccrualAnchor,
FeeSlot, FeesSpec, KernelAction, OpState, PayoutOutcome, Restrictions, TargetId, TimestampNs,
VaultConfig, VaultState, Wad, MAX_MANAGEMENT_FEE_WAD, MAX_PENDING, MAX_PERFORMANCE_FEE_WAD,
VaultConfig, VaultState, Wad, MAX_MANAGEMENT_FEE_WAD, MAX_PERFORMANCE_FEE_WAD,
MIN_WITHDRAWAL_ASSETS,
};

use crate::storage::SOROBAN_MAX_PENDING_WITHDRAWALS;

pub(crate) const KERNEL_ADDRESS_DOMAIN: &[u8] = b"templar:soroban:address";
const MIGRATION_FLAG_KEY: soroban_sdk::Symbol = symbol_short!("migrate");
pub(crate) const LEGACY_PAUSED_MIGRATED_KEY: soroban_sdk::Symbol = symbol_short!("pmigdn");

pub(crate) fn decode_command(payload: &Bytes) -> Result<VaultCommand, ContractError> {
VaultCommand::decode(&payload.to_alloc_vec()).map_err(|_| ContractError::InvalidInput)
Expand Down
4 changes: 2 additions & 2 deletions contract/vault/soroban/src/effects/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -584,7 +584,7 @@ impl AddressMap {
/// Create a new address map.
#[inline]
#[must_use]
pub fn new(_env: &Env) -> Self {
pub fn new() -> Self {
Self {
addresses: AddressBook::new(),
}
Expand Down Expand Up @@ -642,7 +642,7 @@ where
env,
share_token,
asset_token,
address_map: AddressMap::new(env),
address_map: AddressMap::new(),
}
}

Expand Down
7 changes: 3 additions & 4 deletions contract/vault/soroban/src/fungible_vault.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,13 @@ use soroban_sdk::{token, Address as SdkAddress, Env};
use templar_vault_kernel::state::queue::DEFAULT_COOLDOWN_NS;
use templar_vault_kernel::{
compute_fee_shares_from_assets, compute_management_fee_shares, total_assets_for_fee_accrual,
FeeAccrualAnchor, Number, TimestampNs, VaultConfig, VaultState, MAX_PENDING,
MIN_WITHDRAWAL_ASSETS,
FeeAccrualAnchor, Number, TimestampNs, VaultConfig, VaultState, MIN_WITHDRAWAL_ASSETS,
};

use crate::contract::{load_fees_spec, load_virtual_offsets, VaultDataKey};
use crate::convert::{ledger_timestamp_ns, runtime_to_contract};
use crate::error::ContractError;
use crate::storage::{SorobanStorage, Storage};
use crate::storage::{SorobanStorage, Storage, SOROBAN_MAX_PENDING_WITHDRAWALS};

fn preview_state_with_fee_accrual(
env: &Env,
Expand Down Expand Up @@ -88,7 +87,7 @@ pub(crate) fn load_state_and_config(env: &Env) -> Result<(VaultState, VaultConfi
fees: runtime_to_contract(load_fees_spec(env))?,
min_withdrawal_assets: MIN_WITHDRAWAL_ASSETS,
withdrawal_cooldown_ns: DEFAULT_COOLDOWN_NS,
max_pending_withdrawals: MAX_PENDING as u32,
max_pending_withdrawals: SOROBAN_MAX_PENDING_WITHDRAWALS,
paused: storage.is_paused(),
virtual_shares,
virtual_assets,
Expand Down
Loading