Skip to content

Security: Neurowealth/NeuroWealth-Smartcontract

Security

SECURITY.md

Security Model

This document describes the security architecture, trust model, and threat model for the NeuroWealth Vault contract.

Threat Model & Trust Boundaries (Issue #563)

The NeuroWealthVault smart contract operates in a multi-actor ecosystem involving privileged governance keys, automated off-chain execution services, end users, external DeFi protocols, and token issuers. System security depends on defining explicit trust boundaries and technical constraints around each actor.

Trust Boundary Architecture Diagram

graph TD
    subgraph OwnerZone["Owner Trust Zone (Cold / Multisig Key)"]
        Owner["Owner Key (Multisig / HSM)"]
    end

    subgraph AgentZone["AI Agent Trust Zone (Hot Key)"]
        Agent["AI Agent Hot Key (Automated Server)"]
    end

    subgraph UserZone["User Trust Zone (Public)"]
        User["Depositors / Users"]
    end

    subgraph CoreContract["NeuroWealth Vault Smart Contract"]
        VaultState["Vault State & Share Accounting"]
    end

    subgraph ExternalProtocols["External Protocols & Assets"]
        USDC["USDC Token Issuer (Circle / SEP-41)"]
        Blend["Blend Lending Pool"]
        DEX["DEX Swap / Liquidity Pool"]
    end

    Owner -->|Pause / Caps / Timelocked Upgrades / Pool Addresses| VaultState
    Agent -->|Rebalance / Harvest / Report Assets| VaultState
    User -->|Deposit / Withdraw / Strategy Choice| VaultState
    VaultState -->|Transfer / Balance Query| USDC
    VaultState -->|Supply / Redeem Liquidity| Blend
    VaultState -->|Add / Remove Liquidity / Swap| DEX
Loading

Detailed Trust Boundary Analysis (CAN vs. CANNOT)

1. Owner Key (Cold / Multisig)

The Owner key is the primary administrative authority of the contract. As mandated in docs/MAINNET_CHECKLIST.md (Section 1), the Owner key MUST be stored in an offline/multisig cold environment separate from the AI Agent key.

  • CAN:
    • Emergency pause and unpause the vault (pause, emergency_pause, unpause).
    • Configure deposit safety caps (set_caps, set_tvl_cap, set_user_deposit_cap, set_deposit_limits).
    • Set rebalance cooldowns and approval TTLs (set_rebalance_cooldown, set_approval_ttl).
    • Initiate and execute timelocked AI agent rotations (update_agent, confirm_agent_update, cancel_agent_update).
    • Schedule, execute, or cancel contract WASM upgrades behind a 24-hour timelock (schedule_upgrade, execute_upgrade, cancel_upgrade).
    • Configure whitelisted external protocol pool addresses (set_blend_pool, set_dex_pool).
    • Initiate and complete ownership transfers (transfer_ownership, accept_ownership).
    • Execute emergency yield harvests during agent rotation (emergency_harvest).
  • CANNOT:
    • Direct user USDC deposits to owner-controlled arbitrary wallets.
    • Withdraw or burn user shares without explicit user authorization.
    • Modify individual user share balances.
    • Bypass upgrade or agent rotation timelocks (must wait 17,280 ledgers ≈ 24 hours).
    • Perform instant WASM code replacements.

2. AI Agent Hot Key

The AI Agent key is utilized by automated backend trading engines to execute strategy rebalances and update yield metrics. Because it operates in a hot server environment, it faces higher risk of exposure.

  • CAN:
    • Trigger protocol allocation adjustments between whitelisted strategies (rebalance).
    • Execute routine yield compounding (harvest).
    • Report total asset valuations and strategy yield/losses (update_total_assets).
  • CANNOT:
    • Transfer vault funds to external addresses outside whitelisted protocol pools.
    • Change vault administrative configurations (caps, pool targets, timelocks, owners).
    • Bypass solvency verification during asset updates (cannot inflate total assets beyond actual balance + pool holdings).
    • Report losses exceeding the per-call decrease cap (capped at max_decrease_bps, default 10%).
    • Pause or unpause the contract.
    • Modify user strategy choices or user balances directly.

3. Depositors / Regular Users

Depositors are un-privileged external accounts interacting with the vault to earn yield.

  • CAN:
    • Deposit USDC into the vault to mint proportional shares (deposit, batch_deposit).
    • Redeem vault shares for underlying USDC at any time (withdraw, withdraw_all).
    • Select individual yield strategy preferences (set_user_strategy).
    • Call permissionless maintenance utilities (touch_user_ttl).
  • CANNOT:
    • Withdraw funds belonging to other depositors.
    • Trigger vault rebalances or alter asset accounting.
    • Modify vault caps or administrative settings.
    • Inflate share exchange rates (defeated by storage-based asset accounting & minimum deposit floors).

4. Blend Protocol Pool Contract

External lending pool integration.

  • CAN:
    • Receive supplied USDC from the vault.
    • Generate interest on deployed USDC positions.
    • Return liquidity upon vault redemption requests.
    • Report current pool utilization and liquidity availability.
  • CANNOT:
    • Mutate NeuroWealthVault contract storage or state.
    • Seize idle USDC held in the vault contract.
    • Force liquidations or alter share accounting inside the vault.

5. DEX Protocol Pool Contract

External Automated Market Maker (AMM) pool integration.

  • CAN:
    • Execute token swaps during strategy shifts.
    • Receive and return liquidity pool tokens.
  • CANNOT:
    • Access vault funds outside explicit allowance approvals.
    • Mutate vault contract state or storage.

6. USDC Token Issuer (Circle / Stellar Admin)

The asset issuer controlling the underlying USDC token contract on Stellar.

  • CAN:
    • Issue, mint, or burn global USDC tokens.
    • Freeze or clawback token balances on flagged Stellar accounts (Stellar Asset Control flags).
  • CANNOT:
    • Access or alter internal vault share accounting (DataKey::Shares, DataKey::TotalShares).
    • Bypass contract authorization rules.

Mapping External Call Sites to Trust Assumptions

Every external contract call in neurowealth-vault/contracts/vault/src/lib.rs relies on specific trust assumptions and implements technical mitigations:

External Call Site Target Contract Trust Assumption Security Mitigation
token_client.transfer (deposit, withdraw, batch_deposit, withdraw_all) USDC Token Contract Assumes token contract conforms strictly to Soroban Token Standard (SEP-41) and transfers requested amount accurately without unexpected side-effects. All storage state updates execute before external transfer calls (Checks-Effects-Interactions pattern enforced by Issue #568 and check-stale-state-audit.sh).
token_client.balance (withdraw, update_total_assets, rebalance) USDC Token Contract Assumes token balance queries return non-manipulated, accurate token counts held by vault. Balance queries are used as upper-bound solvency checks; direct token transfers into vault cannot inflate share price (storage-based accounting).
BlendPoolClient::submit_with_allowance & withdraw (supply_to_blend, withdraw_from_blend) Whitelisted Blend Pool Assumes Blend pool honors liquidity deposit/withdrawal calls and accurately computes interest. Blend pool address is restricted to DataKey::BlendPool whitelisted strictly by the Owner. Partial withdrawal logic prevents vault lockup during high pool utilization.
BlendPoolClient::get_balance (update_total_assets, get_protocol_balance) Whitelisted Blend Pool Assumes Blend pool accurately reports deployed balance. Deployed balance is queried solely for solvency validation; loss updates are bounded by owner co-signatures and per-call decrease caps (max 10%).
DexPoolClient::add_liquidity & remove_liquidity (supply_to_dex, withdraw_from_dex) Whitelisted DEX Pool Assumes AMM pool processes liquidity additions/withdrawals cleanly without excessive slippage. DEX pool target is restricted to DataKey::DexPool set by Owner. Strategy-switch fallback returns funds to idle if DEX pool liquidity is insufficient.
DexPoolClient::get_balance (update_total_assets, get_protocol_balance) Whitelisted DEX Pool Assumes DEX pool correctly reports vault LP position value. Solvency validation guardrail; updates are rate-limited and loss-capped.

Mainnet Checklist Alignment & Verification

This threat model has been formally reviewed against docs/MAINNET_CHECKLIST.md:

  • Section 1 Compliance (Key Management & Separation):
    • Confirms complete independence of Owner ($G_{owner}$) and Agent ($G_{agent}$) keypairs.
    • Strictly isolates the Hot Agent Key (capable only of rebalance/harvest/update_total_assets) from the Cold Owner Key (capable of administrative governance and code upgrades).
  • Section 7 Compliance (Upgrade & Governance Multisig Plan):
    • Enforces mandatory 24-hour timelock (UPGRADE_TIMELOCK_LEDGERS = 17_280) for all contract code upgrades (schedule_upgrade $\rightarrow$ execute_upgrade).
    • Provides a cancellation window (cancel_upgrade) allowing security monitoring to block malicious or mistaken upgrades during the 24-hour window.
    • Recommends Stellar Multisig or hardware cold storage for the Owner key on mainnet deployments.

Withdrawal Guarantees

Automated Liquidity Management

The vault automatically manages liquidity between idle USDC (held in the contract) and deployed assets (e.g., in Blend protocol):

  1. Idle Withdrawals: If the vault holds sufficient idle USDC, withdrawals are processed immediately.
  2. Protocol Withdrawals: If idle USDC is insufficient, the vault automatically attempts to withdraw the required amount from the active protocol (e.g., Blend).
  3. Partial Withdrawals: If the protocol has insufficient liquidity (e.g., high utilization), the user receives all available USDC and retains their remaining shares in the vault. This ensures users are not forced into unfavorable liquidations during protocol-wide liquidity crunches.

Withdrawal Priority

Users can withdraw their USDC at any time without:

  • Lock-up periods
  • Withdrawal fees
  • Approval requirements beyond their signature

Risk Analysis

1. External Protocol Risk (Blend & DEX)

The vault can route idle USDC into external protocols (get_current_protocol reports idle, blend, or dex). Each integration introduces systemic risk:

  • Liquidity Risk (Blend): If Blend utilization is 100%, the vault cannot pull funds immediately. Users will experience partial withdrawals until liquidity returns to the protocol.
  • Slippage & Liquidity Risk (DEX): When the active strategy is a DEX pool, withdrawals and strategy switches execute swaps. Thin pool liquidity can cause slippage or a failed switch; the low-liquidity strategy-switch path returns funds to idle rather than forcing an unfavorable swap.
  • Protocol Failure: A bug or exploit in Blend or the DEX could result in loss of deployed assets.

2. Asset Reporting Risk

The update_total_assets function used by the AI agent has built-in guardrails:

  • Solvency Check: The agent cannot inflate total assets beyond the combined balance of idle USDC and funds actually deployed to external protocols.
  • Decrease Bounding: Reporting a loss is capped (default 10% per call) to prevent sudden, massive devaluations from a single malicious or erroneous call.

3. Agent Rebalance Risk

The AI agent can move funds between protocols via rebalance(), but is constrained:

  • Rebalance Cooldown: Consecutive rebalances are rate-limited by a configurable cooldown (get_rebalance_cooldown / get_last_rebalance_ledger), which bounds how quickly a compromised or malfunctioning agent can churn funds across protocols.
  • No Direct Custody: Rebalancing only moves funds between the vault's own positions in whitelisted pools; the agent cannot redirect funds to an arbitrary address.

4. Upgrade Risks

The contract owner can upgrade the contract code. To protect against malicious or accidental instant code changes, upgrade risk is mitigated via a mandatory two-step timelock mechanism:

  • Two-Step Timelock: Upgrades must first be scheduled via schedule_upgrade(new_wasm_hash), initiating a timelock delay before execute_upgrade() can be called.
  • Cancellation Window: During the timelock window, the owner or security monitoring can invoke cancel_upgrade() to abort a compromised or erroneous upgrade proposal.
  • Owner Multi-Sig Governance: For mainnet deployment, owner authority must be held by a Stellar native multi-sig account (e.g. 2-of-3 threshold).
  • Guardian Key Evaluation & Formal Deferral (Issues #607, #922): A contract-level second-signature pattern (DataKey::Guardian gating execute_upgrade) was evaluated in docs/GUARDIAN_KEY_DESIGN.md and formally deferred in favor of native Stellar protocol multi-sig. Native multi-sig protects all owner actions at the ledger level rather than adding bespoke contract logic for upgrades alone.

5. State Rent & TTL Expiry

Soroban persistent entries (such as each user's Shares record) accrue state rent and expire if their TTL is not periodically extended:

  • Pure Read-Only Getters: get_balance and get_shares are side-effect free — they do not extend storage TTL. This keeps pure reads cheap and prevents read traffic from silently mutating ledger state.
  • Explicit Maintenance: Off-chain indexers or maintenance jobs should call the permissionless touch_user_ttl(user) to refresh a user's Shares TTL. State-changing calls (deposit, withdraw) already rewrite Shares and refresh its TTL during normal operation.
  • Risk: A long-dormant user who never transacts and whose entry is never touched could see their Shares entry expire and require restoration. Active users, and any indexer running touch_user_ttl, are unaffected.

6. Fee-on-Transfer / Deflationary Asset Risk (Issue #603)

Every accounting path in the vault (deposit, withdraw, rebalance) assumes the amount received by a token transfer equals the amount requested. Today this holds because the vault's configured asset is Stellar's canonical USDC (a Stellar Asset Contract): SAC transfers move the exact requested amount with no protocol-level fee.

This assumption would silently break in either of these scenarios:

  • The underlying USDC issuer ever introduced fee-on-transfer or rebasing/deflationary semantics on the Stellar Asset Contract.
  • The vault's configured usdc_token address were ever misconfigured to point at a wrapper or non-standard token that does not deliver the full requested amount on transfer.

Impact if it happened silently: get_total_assets() / get_shares() accounting would diverge from the vault's actual physical token balance — shares would be backed by fewer tokens than recorded, socializing the loss across all holders on the next redemption.

Rejection stance: this vault does not support fee-on-transfer or deflationary assets, and does not plan to add support. If the configured asset's transfer semantics ever changed to violate the "received == requested" assumption:

  1. Do not treat this as a vault bug to patch around. The correct response is to pause the vault (pause()) and initiate the owner-compromise-style incident response (see the runbook below), not to add fee-compensation logic to the accounting math — that logic is itself a well-known attack surface (see the ERC-4626 inflation-attack class of vulnerabilities).
  2. Any future integration with a different asset must independently verify (and this document must be updated to record) that the asset has fixed, fee-free transfer semantics before it is wired into usdc_token.

Detection: test_transfer_delta_sanity.rs pins this assumption in CI — it measures the actual token balance delta around every fund-moving call (deposit, withdraw, rebalance) against the requested amount and fails immediately if they ever diverge, so a regression (or a misconfigured test token in a future integration) is caught before it reaches a real deployment.

7. Supported Asset Boundaries (Issue #842)

Legacy deposits, withdrawals, and Blend/DEX protocol movements use only the vault's configured UsdcToken. batch_deposit rejects every other token with the typed UnsupportedAsset error before transfers or rate-limit writes. Asset-aware deposit_asset and withdraw_asset accept only symbols registered by the owner and resolve each symbol to its configured token address; unknown symbols return the same typed error. Owner-registered non-USDC assets remain idle and are not supplied to or redeemed from the USDC-only Blend/DEX integrations. Protocol clients independently enforce the configured token before querying token balances or calling a pool.

8. Yield Sniping Protection (Issue #975)

Threat: Yield reaches the share price only when the agent calls update_total_assets, which can raise TotalAssets by any amount up to the real balance in one step. With MinHoldingPeriod defaulting to 0, anyone watching the mempool or the agent's schedule could deposit just before the update and withdraw right after, capturing yield accrued over a period they were not invested.

Mitigation: The vault now initializes with a non-zero default MinHoldingPeriod of 120 ledgers (~10 minutes). This prevents same-block deposit/withdraw cycles that would allow yield sniping. The owner can adjust this period via set_min_holding_period if needed for operational reasons, but the default provides baseline protection.

Impact: Attackers attempting to snipe yield must now wait at least 120 ledgers between deposit and withdrawal, making the attack unprofitable for typical yield reporting intervals.

9. Decrease Cap Configuration (Issue #974)

Threat: update_total_assets(agent, new_total, allow_decrease, max_decrease_bps) capped a loss report at max(max_decrease_bps, 100) bps of the old total. The cap was a caller-supplied argument, so the caller could pass 10_000 and report a 100% loss in one call. The only safeguard was the owner co-sign requirement.

Mitigation: The decrease cap is now stored in owner-configured storage (DataKey::MaxDecreaseBps) with a sane default of 1000 bps (10%) and a hard ceiling of 10_000 bps (100%). The caller-supplied argument in update_total_assets is ignored. The owner can configure the cap via set_max_decrease_bps, but cannot exceed the hard ceiling.

Impact: Even with owner co-sign, a single transaction cannot report a loss exceeding the configured cap (default 10%). Repeated calls cannot chain past the limit without additional owner authorization for each call.

10. Zero-Assets-With-Shares Protection (Issue #973)

Threat: convert_to_shares_internal used a bootstrap 1:1 rate when total_shares == 0 || total_assets == 0. If shares were outstanding but assets were zero (e.g., after a full loss report, or dust shares remaining after rounding), a new depositor would get amount shares while existing holders, whose shares were worth nothing, would immediately own a pro-rata slice of the new deposit.

Mitigation: When total_shares > 0 && total_assets == 0, deposits are rejected with the typed error VaultError::DepositRejectedZeroAssetsWithShares. This prevents the dilution attack. The vault must be in a recovery mode (owner intervention) to accept new deposits in this state.

Impact: New depositors cannot be diluted by existing worthless shares. The vault requires explicit recovery action (e.g., owner-initiated loss write-down or share restructuring) before accepting new deposits after a total loss event.

Centralization-Risk Register

This register documents every owner-only and agent-only capability, the blast radius if the corresponding key is compromised, and the existing mitigation status.

Capability / Function Role Blast Radius (Worst-Case Impact) Mitigation Status
pause, emergency_pause Owner Vault stops accepting deposits/withdrawals, causing denial of service. accepted risk (required for emergency response)
unpause Owner Unpausing prematurely during an active exploit could lead to fund drain. accepted risk
set_tvl_cap, set_user_deposit_cap, set_caps, set_deposit_limits, set_limits Owner Setting caps to zero blocks new deposits; setting excessively high removes deposit guardrails. partial (limits only restrict future deposits, cannot steal existing funds)
set_rebalance_cooldown Owner Removing cooldown allows a compromised agent to churn funds rapidly between protocols. partial (requires both owner and agent compromise for maximum impact)
set_blend_pool, set_dex_pool Owner Redirecting pool addresses to a malicious contract could allow the agent to rebalance funds into an attacker-controlled drain. mitigated (requires two-step exploit: owner sets pool, agent rebalances)
update_agent, confirm_agent_update, cancel_agent_update Owner Replacing the agent with a malicious one allows unauthorized yield reporting and fund churning. mitigated (two-step timelock with cancellation window)
schedule_upgrade, execute_upgrade, cancel_upgrade Owner Upgrading to a malicious WASM contract could allow complete drain of all vault funds. mitigated (two-step timelock with cancellation window)
transfer_ownership, cancel_ownership_transfer Owner Transferring ownership to an attacker solidifies control over the contract. mitigated (two-step transfer requires acceptance by new owner)
emergency_harvest Owner Owner can move funds within authorized protocols; no direct withdrawal possible. accepted risk (fallback for agent-key rotation)
set_approval_ttl, set_blend_approval_ttl Owner Changing TTL affects how often approvals expire, leading to minor operational griefing. accepted risk (low impact)
update_total_assets Agent Agent can falsely report a massive loss, devaluing shares and allowing cheap buy-ins, or falsely report yield to over-mint shares. partial (capped maximum decrease bounds single-call impact)
rebalance Agent Agent can move all funds to the most risky whitelisted protocol, or repeatedly churn funds to incur fees. mitigated (rebalance cooldown limits churn rate; restricted to owner-whitelisted pools)
harvest Agent Agent can force protocol withdrawals to compound yield; no direct fund drain possible. accepted risk (funds stay within vault bounds)

Access Control Summary

Function Owner Agent User Anyone
update_agent yes - - -
confirm_agent_update yes - - -
cancel_agent_update yes - - -
update_standby_agent yes - - -
switch_to_standby_agent yes - - -
update_total_assets - yes - -
deposit - - yes -
withdraw - - yes -
withdraw_all - - yes -
rebalance - yes - -
harvest - yes - -
pause yes - - -
emergency_pause yes - - -
unpause yes - - -
reset_circuit_breaker yes - - -
set_caps yes - - -
set_tvl_cap yes - - -
set_user_deposit_cap yes - - -
set_deposit_limits yes - - -
set_limits yes - - -
set_rebalance_cooldown yes - - -
set_approval_ttl yes - - -
set_blend_approval_ttl yes - - -
schedule_upgrade yes - - -
execute_upgrade yes - - -
cancel_upgrade yes - - -
set_blend_pool yes - - -
set_dex_pool yes - - -
schedule_migration_target yes - - -
execute_migration_target yes - - -
cancel_migration_target yes - - -
set_migration_paused yes - - -
transfer_ownership yes - - -
cancel_ownership_transfer yes - - -
accept_ownership - - - pending owner
touch_user_ttl - - - anyone
set_user_strategy - - yes -
set_min_holding_period yes - - -
get_min_holding_period - - - anyone
set_rate_limit, set_rate_limit_config yes - - -
set_queue_config yes - - -
process_withdrawal_queue - - - anyone (authenticated)
submit_mev_report - yes - -
set_max_acceptable_mev_loss yes - - -
submit_apy_prediction - yes - -

Timelock and Pause Behavior for Newer Privileged Functions (Issue #997)

The table above names the caller for every privileged function, but not whether that call is delayed by a timelock or still reachable while the vault is paused. Both properties matter for an incident responder deciding whether a given function can be used to make the situation worse (or better) right now. The table below covers the functions added since the table above was last extended; update_agent and switch_to_standby_agent are included for contrast, since together they are the timelock gap this issue asks to be noted explicitly.

Function Caller Timelocked? Allowed while paused?
update_agent Owner Yes (AGENT_TIMELOCK_LEDGERS, step 1 of update_agent/confirm_agent_update) Yes (no pause check)
update_standby_agent Owner No Yes (no pause check)
switch_to_standby_agent Owner No — instant, by design (see function doc: "no timelock, no 24-hour wait") Yes (no pause check)
schedule_migration_target Owner Yes (MIGRATION_TARGET_TIMELOCK_LEDGERS, step 1 of 2) Yes (no pause check)
execute_migration_target Owner Yes (must wait for the scheduled delay to elapse) Yes (no pause check)
cancel_migration_target Owner No (cancels a pending timelock; not itself delayed) Yes (no pause check)
set_migration_paused Owner No Yes (no pause check)
reset_circuit_breaker Owner No Yes, deliberately — its entire purpose is to clear Paused
process_withdrawal_queue Anyone (authenticated; the agent parameter is accepted but never checked against the stored agent address — see function doc, "kept for ABI compatibility") No Yes (no pause check)
set_min_holding_period Owner No Yes (no pause check)
set_rate_limit, set_rate_limit_config Owner No Yes (no pause check)
set_queue_config Owner No Yes (no pause check)

The standby-agent timelock gap (tracked in #954, open). update_agent (primary agent rotation) is timelocked specifically so a compromised owner key cannot instantly hand agent authority to an attacker-controlled address — the delay gives the real owner a window to notice and react via cancel_agent_update. update_standby_agent and switch_to_standby_agent provide no equivalent delay: an owner (or an attacker who has compromised the owner key) can set an arbitrary address as standby and instantly promote it to primary agent in two calls, with no timelock and no cooldown between them, which defeats the timelock users rely on to react to a malicious agent change. Until #954 is resolved, treat standby-agent rotation as carrying the same trust assumptions as an instant, undelayed owner action, not the delayed-and-cancellable guarantee update_agent provides.

Emergency Harvest Fallback (Issue #506)

When the agent key is lost, compromised, or mid-rotation via the update_agent timelock, the normal harvest() function is unusable because it requires agent authorization. The owner can call emergency_harvest(min_out) to compound yield during this window:

  • Gating: Owner auth only (not agent auth)
  • Pause bypass: Works even when the vault is paused, so the owner can compound yield during an emergency pause without unpausing first
  • Same mechanics: Withdraws accrued yield from the active protocol and re-supplies it (same round-trip as harvest())
  • Distinct event: Emits EmergencyHarvestEvent (topic em_harv) so indexers can differentiate from agent-initiated HarvestEvent (topic harvest)
Function Owner Agent User Anyone
emergency_harvest yes - - -

Pause-Semantics Matrix

Issue #601 — Definitive table of which functions are blocked vs. allowed while the vault is paused. This table is the source of truth; the exhaustive test in neurowealth-vault/contracts/vault/src/tests/test_pause.rs encodes it as parameterised assertions so future functions cannot silently bypass pause checks.

Legend

Symbol Meaning
🔴 BLOCKED Function panics with VaultError::Paused (#35) when the vault is paused
🟢 ALLOWED Function executes normally while the vault is paused

State-Changing Functions

Function Caller Paused Behaviour Rationale
deposit User 🔴 BLOCKED No new deposits accepted during an emergency
batch_deposit User 🔴 BLOCKED Same reason as deposit
withdraw User 🔴 BLOCKED See note below on withdrawal semantics
withdraw_all User 🔴 BLOCKED See note below on withdrawal semantics
rebalance Agent 🔴 BLOCKED No fund movement while vault is paused
harvest Agent 🔴 BLOCKED No protocol interaction while paused
schedule_upgrade Owner 🔴 BLOCKED Upgrades must not be proposed during an emergency
execute_upgrade Owner 🔴 BLOCKED Execution of a pending upgrade requires unpaused vault
cancel_upgrade Owner 🟢 ALLOWED Cancelling a malicious upgrade must be possible even while paused
update_total_assets Agent + Owner 🟢 ALLOWED Yield/loss reporting should remain available for bookkeeping
emergency_harvest Owner 🟢 ALLOWED Owner fallback for compounding yield during an agent-key rotation; explicitly bypasses pause
pause Owner 🟢 ALLOWED Must be callable to transition into the paused state
unpause Owner 🟢 ALLOWED Must be callable to resume normal operations
emergency_pause Owner 🟢 ALLOWED Must be callable even when already paused (idempotent)
update_agent Owner 🟢 ALLOWED Agent rotation is a recovery action; blocking it during a pause would worsen an incident
confirm_agent_update Owner 🟢 ALLOWED Completing agent rotation must not be blocked
cancel_agent_update Owner 🟢 ALLOWED Cancelling a malicious agent update must always be possible
transfer_ownership Owner 🟢 ALLOWED Ownership rotation is a recovery action
accept_ownership Pending owner 🟢 ALLOWED Completing ownership transfer must not be blocked
cancel_ownership_transfer Owner 🟢 ALLOWED Must remain available during emergencies
set_tvl_cap Owner 🟢 ALLOWED Configuration changes should be possible during a pause
set_user_deposit_cap Owner 🟢 ALLOWED Configuration changes should be possible during a pause
set_caps Owner 🟢 ALLOWED Configuration changes should be possible during a pause
set_deposit_limits Owner 🟢 ALLOWED Configuration changes should be possible during a pause
set_limits (deprecated) Owner 🟢 ALLOWED Same as set_caps
set_blend_pool Owner 🟢 ALLOWED Pool reconfiguration is a recovery action
set_dex_pool Owner 🟢 ALLOWED Pool reconfiguration is a recovery action
set_rebalance_cooldown Owner 🟢 ALLOWED Configuration changes should be possible during a pause
set_approval_ttl Owner 🟢 ALLOWED Configuration changes should be possible during a pause
set_blend_approval_ttl Owner 🟢 ALLOWED Configuration changes should be possible during a pause
set_max_consecutive_failures Owner 🟢 ALLOWED Configuration changes should be possible during a pause
set_user_strategy User 🟢 ALLOWED Preference storage; no fund movement
touch_user_ttl Anyone 🟢 ALLOWED Permissionless TTL maintenance; no fund movement
initialize Deployer 🟢 ALLOWED Initialization runs before pause is even possible

Read-Only / View Functions

All view/getter functions are 🟢 ALLOWED while paused. They perform no state changes and emit no events. Blocking them during a pause would prevent operators from assessing vault state.

Function Paused Behaviour
is_paused 🟢 ALLOWED
get_balance 🟢 ALLOWED
get_total_deposits 🟢 ALLOWED
get_total_assets 🟢 ALLOWED
get_total_shares 🟢 ALLOWED
get_shares 🟢 ALLOWED
get_users_with_shares 🟢 ALLOWED
get_user_info 🟢 ALLOWED
get_owner 🟢 ALLOWED
get_agent 🟢 ALLOWED
get_version 🟢 ALLOWED
get_usdc_token 🟢 ALLOWED
get_current_protocol 🟢 ALLOWED
get_blend_pool 🟢 ALLOWED
get_dex_pool 🟢 ALLOWED
get_tvl_cap 🟢 ALLOWED
get_user_deposit_cap 🟢 ALLOWED
get_min_deposit 🟢 ALLOWED
get_max_deposit 🟢 ALLOWED
get_idle_balance 🟢 ALLOWED
get_deployed_assets 🟢 ALLOWED
get_asset_breakdown 🟢 ALLOWED
get_exchange_rate 🟢 ALLOWED
get_rebalance_cooldown 🟢 ALLOWED
get_last_rebalance_ledger 🟢 ALLOWED
get_approval_ttl 🟢 ALLOWED
get_blend_approval_ttl 🟢 ALLOWED
get_max_consecutive_failures 🟢 ALLOWED
get_consecutive_failures 🟢 ALLOWED
get_pending_upgrade 🟢 ALLOWED
get_pending_agent_update 🟢 ALLOWED
get_pending_owner 🟢 ALLOWED
get_pending_ownership 🟢 ALLOWED
get_user_strategy 🟢 ALLOWED
preview_deposit_to_shares 🟢 ALLOWED
preview_shares_to_assets 🟢 ALLOWED
preview_withdraw 🟢 ALLOWED
convert_to_shares 🟢 ALLOWED
convert_to_assets 🟢 ALLOWED
get_min_holding_period 🟢 ALLOWED
get_mev_stats 🟢 ALLOWED
get_apy_prediction 🟢 ALLOWED
get_vault_health_snapshot 🟢 ALLOWED
get_withdrawal_eligibility 🟢 ALLOWED

Note on Withdrawal Semantics

Both withdraw and withdraw_all are blocked while the vault is paused. This is a deliberate design choice:

  • The pause mechanism is an emergency stop intended to freeze all fund movement while a security incident is investigated.
  • Allowing withdrawals while paused could enable a race between an attacker (draining funds) and the security team (trying to stop the drain).
  • The owner-compromise runbook documents how to unpause safely once the incident is resolved, at which point normal withdrawals resume immediately.

If a future version of the protocol wishes to allow emergency withdrawals while paused, this would require a separate "withdrawal-allowed" flag that is independent of the pause flag, along with careful analysis of reentrancy and ordering risks.

Checklist for Adding New Functions

When adding a new contract function, the author must update this matrix and the corresponding test in test_pause.rs. The PR review checklist (.github/pull_request_template.md) includes a pause-semantics item for this purpose.

Security Best Practices Implemented

  1. Checks-Effects-Interactions Pattern: All state updates happen before external calls
  2. Auth on Withdrawals: require_auth() ensures users can only access their own funds
  3. Minimum Deposits: Prevents dust attacks
  4. Deposit Caps: Limits exposure per user
  5. TVL Caps: Limits total exposure
  6. Pausable: Emergency stop functionality

Owner-Compromise Response Runbook

If the owner keypair is suspected or confirmed to be compromised, follow this sequence immediately. Every step that requires owner auth is marked [owner].

For agent-key compromise procedures, see the Agent-Key Compromise Runbook.

Step 1 — Pause the vault (within minutes)

The single fastest action to protect user funds is an emergency pause. No new deposits or withdrawals can execute while the vault is paused.

stellar contract invoke \
  --id $VAULT_CONTRACT_ID \
  --source <OWNER_SECRET_KEY> \
  --network mainnet \
  -- pause

Requires: owner auth [owner]

Note: Unlike pause, the emergency_pause function also requires owner auth. If the owner key is already confirmed compromised and you cannot sign with it, see Step 2 to assess whether the attacker has already rotated the owner address.

Step 2 — Assess exposure

Before taking further action, determine what the attacker could have done or is still doing:

Check Command
Current paused state stellar contract invoke --id $VAULT_CONTRACT_ID --network mainnet -- get_paused
Current owner address stellar contract invoke --id $VAULT_CONTRACT_ID --network mainnet -- get_owner
Current agent address stellar contract invoke --id $VAULT_CONTRACT_ID --network mainnet -- get_agent
Pending agent update stellar contract invoke --id $VAULT_CONTRACT_ID --network mainnet -- get_pending_agent_update
Pending contract upgrade stellar contract invoke --id $VAULT_CONTRACT_ID --network mainnet -- get_pending_upgrade
Active protocol (idle/blend/dex) stellar contract invoke --id $VAULT_CONTRACT_ID --network mainnet -- get_current_protocol
TVL cap stellar contract invoke --id $VAULT_CONTRACT_ID --network mainnet -- get_tvl_cap

Owner-only actions an attacker with the key could have taken:

  • Initiated update_agent or schedule_upgrade to queue a malicious agent or WASM upgrade.
  • Called set_blend_pool or set_dex_pool to point the vault at a drain contract.
  • Called set_caps to raise or remove deposit limits.
  • Initiated transfer_ownership to a new address they control.

The attacker cannot directly withdraw user funds — withdrawals require the user's own auth signature, not the owner key.

Step 3 — Rotate the owner key

Generate a new owner keypair on an air-gapped machine. Then initiate the two-step ownership transfer from the current (compromised) key while you still control it:

# Step 3a — propose new owner [owner]
stellar contract invoke \
  --id $VAULT_CONTRACT_ID \
  --source <CURRENT_OWNER_SECRET_KEY> \
  --network mainnet \
  -- transfer_ownership \
  --new_owner <NEW_OWNER_ADDRESS>

# Step 3b — accept from the new keypair [pending owner]
stellar contract invoke \
  --id $VAULT_CONTRACT_ID \
  --source <NEW_OWNER_SECRET_KEY> \
  --network mainnet \
  -- accept_ownership

If the compromised key has already been used to initiate an attacker-controlled transfer_ownership, the pending owner is stored under DataKey::PendingOwner. You must call accept_ownership from the legitimate new owner before the attacker does. Check DataKey::PendingOwner on-chain immediately.

Step 4 — Revert any attacker configuration changes & pending timelocks

Once the new owner key is in place, audit and reset all owner-controlled state and cancel pending malicious timelocks:

# Cancel any pending malicious agent update or contract upgrade scheduled by attacker [owner]
stellar contract invoke --id $VAULT_CONTRACT_ID --source <NEW_OWNER_KEY> \
  --network mainnet -- cancel_agent_update

stellar contract invoke --id $VAULT_CONTRACT_ID --source <NEW_OWNER_KEY> \
  --network mainnet -- cancel_upgrade

# Initiate and confirm agent update to legitimate AI agent address via timelock [owner]
stellar contract invoke --id $VAULT_CONTRACT_ID --source <NEW_OWNER_KEY> \
  --network mainnet -- update_agent --new_agent <LEGITIMATE_AGENT_ADDRESS>

# (After timelock window expires)
stellar contract invoke --id $VAULT_CONTRACT_ID --source <NEW_OWNER_KEY> \
  --network mainnet -- confirm_agent_update

# Reset pool addresses to audited contracts [owner]
stellar contract invoke --id $VAULT_CONTRACT_ID --source <NEW_OWNER_KEY> \
  --network mainnet -- set_blend_pool --pool_address <AUDITED_BLEND_POOL>

stellar contract invoke --id $VAULT_CONTRACT_ID --source <NEW_OWNER_KEY> \
  --network mainnet -- set_dex_pool --pool_address <AUDITED_DEX_POOL>

# Restore caps to pre-incident values [owner]
stellar contract invoke --id $VAULT_CONTRACT_ID --source <NEW_OWNER_KEY> \
  --network mainnet -- set_caps \
  --user_deposit_cap <ORIGINAL_CAP> --tvl_cap <ORIGINAL_TVL_CAP>

Step 5 — Restore safe operation

Only unpause once Steps 1–4 are fully complete and verified.

stellar contract invoke \
  --id $VAULT_CONTRACT_ID \
  --source <NEW_OWNER_SECRET_KEY> \
  --network mainnet \
  -- unpause

Requires: owner auth [owner]

Step 5a — Emergency harvest during agent-key rotation

If the vault has funds deployed to a protocol (Blend or DEX) and the agent key is being rotated, use emergency_harvest to compound yield without waiting for the new agent key:

stellar contract invoke \
  --id $VAULT_CONTRACT_ID \
  --source <NEW_OWNER_SECRET_KEY> \
  --network mainnet \
  -- emergency_harvest \
  --min_out 0

Requires: owner auth [owner]

Note: emergency_harvest bypasses the paused-state check, so it can be called before or after unpause. It still respects the rebalance cooldown and requires an active protocol (panics with UnsupportedProtocol if CurrentProtocol == "none"). The emitted EmergencyHarvestEvent (topic em_harv) is distinct from the regular HarvestEvent (topic harvest).

Resume normal agent-initiated harvest() calls once the new agent key is confirmed.

Step 6 — Post-incident

  • Revoke and rotate all credentials that were co-located with the compromised key.
  • Publish a post-mortem within 72 hours.
  • Consider migrating to a multi-sig owner address before resuming normal operations.

Flash-Loan Attack Threat Model (Issue #659)

Attack Vector

A flash-loan attack against a yield vault exploits the fact that an attacker can deposit a very large amount, trigger a favourable state change (harvest, rebalance, or share-price update), and withdraw in the same transaction — or across a very small number of consecutive ledgers — to extract yield that belongs to long-term depositors.

Mitigation: Minimum Holding Period

The vault enforces a configurable minimum holding period tracked per user:

Storage key Type Description
DataKey::MinHoldingPeriod u32 (instance) Minimum ledgers a user must hold their deposit before withdrawal is allowed. 0 = disabled.
DataKey::LastDepositLedger(Address) u32 (persistent) Ledger sequence at which the user most recently deposited.

On deposit: LastDepositLedger(user) is set to env.ledger().sequence().

On withdraw: If MinHoldingPeriod > 0 and current_ledger - LastDepositLedger < MinHoldingPeriod, the transaction panics with VaultError::HoldingPeriodNotElapsed (#75) and emits a FlashLoanProtectionTriggeredEvent (topic fl_block).

Recommended Configuration

On mainnet, set the holding period to at least 1 ledger (≈ 5 seconds) during initial deployment, and tighten to 120–240 ledgers (10–20 minutes) after TVL exceeds a meaningful threshold. The owner can update the period at any time via set_min_holding_period.

Access Control

Function Role
set_min_holding_period(env, ledgers) Owner only
get_min_holding_period(env) -> u32 Anyone

Setting ledgers = 0 disables flash-loan protection entirely (acceptable on testnet; not recommended for mainnet once funds are deployed).

Error Codes

Code Variant Condition
#75 HoldingPeriodNotElapsed current_ledger - last_deposit < min_holding
#76 InvalidHoldingPeriod Reserved for future validation (e.g., period > hard cap)

MEV Protection Threat Model (Issue #658)

Attack Vector

Maximal Extractable Value (MEV) in the context of this vault arises during rebalances, where an agent-submitted transaction moves assets between protocols (Blend → DEX, etc.). An adversary who can observe pending rebalance transactions can front-run or sandwich-attack the swap, extracting value from the vault at the expense of depositors.

Mitigation: On-Chain MEV Incident Tracking

The vault does not currently block MEV extraction on-chain (MEV largely occurs at the sequencing layer, outside of contract logic). Instead, it implements on-chain observability so the agent can react and governance can act:

Storage key Type Description
DataKey::CumulativeMevLoss i128 (instance) Running total of estimated loss in stroops across all reported incidents.
DataKey::MevIncidentCount u32 (instance) Number of MEV incidents reported.
DataKey::MaxAcceptableMevLoss i128 (instance) Owner-configured threshold; agent should pause rebalances above this.

Reporting flow:

  1. After each rebalance, the off-chain agent compares min_out to the actual amount received. If the difference exceeds a configured threshold, it calls submit_mev_report(protocol, estimated_loss_stroops, min_out_used).
  2. The contract increments CumulativeMevLoss and MevIncidentCount and emits a MevExtractionSuspectedEvent (topic mev_susp) for indexers.
  3. Governance (owner) monitors cumulative loss via get_mev_stats(). If CumulativeMevLoss > MaxAcceptableMevLoss, the agent should halt rebalances and wait for owner review.

Access Control

Function Role
submit_mev_report(env, protocol, estimated_loss_stroops, min_out_used) Agent only
get_mev_stats(env) -> (i128, u32) Anyone
set_max_acceptable_mev_loss(env, max_loss_stroops) Owner only

Off-Chain Recommendations

  • Set min_out on every rebalance() call to bound slippage on-chain (current implementation uses min_out to revert if output is below the threshold).
  • Use a private mempool or Stellar's fee-bump transactions to reduce the window for front-running.
  • Rotate rebalance timing randomly to prevent predictable extraction patterns.
  • Monitor MevExtractionSuspectedEvent events via an indexer; alert if more than 3 incidents occur within 100 ledgers.

Centralization-Risk Register Update

Capability Role Blast Radius Mitigation
submit_mev_report Agent Falsely inflated CumulativeMevLoss could trigger premature rebalance halt. Accepted risk; only the agent key can submit reports, and the owner can reset caps via set_max_acceptable_mev_loss.
set_max_acceptable_mev_loss Owner Setting to 0 would block any rebalance after the first reported incident. Accepted risk; owner configuration change.

Audit & Mainnet Deployment Checklist

Before any mainnet deployment, you must refer to and complete the formal Mainnet Deployment Checklist.

Additionally, ensure:

  • All functions have documented panic conditions
  • All state changes emit events
  • Access control verified for each function
  • Upgrade mechanism tested on testnet
  • Pause/unpause tested
  • Withdrawal flow tested with edge cases
  • Maximum deposit limits enforced
  • TVL cap enforced
  • Integration with USDC token tested
  • Integration with Blend protocol tested (Phase 2)

On-chain rate-limit controls

Rate limiting is enforced inside NeuroWealthVault, before any token transfer or external protocol invocation. It is a defense against transaction spam, block space abuse, protocol churn, TTL-maintenance griefing, and repeated expensive share-conversion calls. It complements—not replaces—the pause switch, TVL and per-user deposit caps, user authentication, and the existing MinRebalanceInterval cooldown.

The owner uses set_rate_limit(category, max_calls, window_ledgers) to configure six bounded categories: per-user deposit, per-user withdraw, global rebalance, per-user touch_ttl, global preview, and per-user batch_dep. rebalance, harvest, and emergency_harvest share the same global bucket so harvest cannot be used to bypass the rebalance frequency policy. Every preview and conversion method shares one global bucket so changing the method name does not evade computational protection. A zero max_calls explicitly disables a category; an enabled category must have a non-zero ledger window.

batch_deposit has two independent protections: an owner-configured maximum entry count (set_max_batch_size, default 50; zero means unlimited) and the per-user deposit/batch frequency buckets. A batch is counted once in each frequency bucket, not once per entry. This bounds the transfer/event loop while preserving atomic all-or-nothing semantics.

State and monitoring

Policies and counters use the appended DataKey::RateLimitConfig, DataKey::RateLimitGlobalState, DataKey::RateLimitUserState, and DataKey::MaxBatchSize entries in instance storage. Counters are keyed by the user address for user-scoped categories and by category for global categories. They are fixed-window counters based only on env.ledger().sequence() and are reset by overwriting the bucket after the configured window. Instance storage is intentional: a persistent TTL expiry must not give a caller a free reset. The trade-off is a small per-user instance key for each category used; sybil addresses remain economically constrained by transaction fees and the vault's other caps.

RateLimitConfigUpdatedEvent (rate_cfg) and BatchSizeLimitUpdatedEvent (batch_lim) provide configuration audit trails. RateLimitExceededEvent (rate_hit) includes the category, optional user, current ledger, window start, maximum, and observed count. It is emitted immediately before the contract returns VaultError::RateLimitExceeded. Operators should consume both the event stream and failed transaction error codes, since an over-limit transaction itself is reverted by the host.

Operational guidance

  • Set non-zero, workload-specific values before mainnet operation and verify them with get_rate_limit and the state getters.
  • Keep MinRebalanceInterval non-zero as a second independent control on agent churn; lowering one policy does not silently remove the other.
  • Treat preview functions as state-writing contract calls when submitted on chain. RPC simulations do not commit state, so they are useful for display but are not a substitute for the transaction-level guard.
  • Alert on repeated rate_hit events, especially global preview/rebalance hits and a single address exhausting deposit, withdrawal, or TTL buckets.
  • Rate limits do not stop a user from creating many addresses; retain the TVL cap, user deposit cap, fee policy, and authentication checks as the sybil resistance layer.

WhatsApp Custodial Key Custody Threat Model (Issue #944)

The WhatsApp bot (whatsapp/) creates a custodial Stellar keypair per verified phone number and signs vault transactions on the user's behalf. Unlike the vault contract, these keys are held by the operator, so this section records what is protected, from whom, and what is explicitly not protected. Operational detail is in docs/WHATSAPP_BOT.md.

Assets

Asset Where it lives Sensitivity
User Stellar secret key whatsapp_wallets (encrypted_data, iv, tag), AES-256-GCM encrypted Critical: controls the user's vault position and any other funds in the account
Data-encryption key (DEK), one per wallet whatsapp_wallets.wrapped_dek wrapped by the KEK Critical, but useless without the KEK
Key-encryption key (KEK) ENCRYPTION_KEY (+ ENCRYPTION_KEY_RETIRED during rotation) in the process environment / secret store Critical: the single secret that unlocks every DEK
Phone-hash salt PHONE_HASH_SALT Medium: protects phone-number privacy
OTP challenges, sessions whatsapp_otps, whatsapp_sessions Medium: an exposed live OTP allows an account takeover for up to 5 minutes

Design

  • Envelope encryption. Each secret has its own random DEK; the DEK is wrapped with the KEK (AES-256-GCM, version label bound as AAD). Database contents alone (dump, backup, SQL injection, replica access) reveal no usable key material.
  • KEK rotation without re-exposing secrets. npm run rotate-keys re-wraps DEKs under a new ENCRYPTION_KEY_VERSION; Stellar secrets are never re-encrypted or logged. Old KEKs are kept only in ENCRYPTION_KEY_RETIRED until rotation completes. Runbook: docs/WHATSAPP_BOT.md.
  • Isolation seam for KMS/HSM. KEK access is confined to wrapDek / unwrapDek; a managed key service can replace the env-var KEK without touching storage or callers.
  • Fail-closed configuration. The service refuses to boot with a missing, short, placeholder or default secret (src/envGuard.ts), and CI forbids source-level fallbacks (see the policy below).
  • Plaintext handling. The decrypted secret exists only inside getDecryptedSecretKey for the duration of signing; it is never persisted, returned to chat, or logged.

Adversaries and residual risk

# Adversary / event Outcome today Mitigation / residual risk
1 Reads or steals the database (SQLi, leaked backup, over-broad replica access) Gets ciphertext, wrapped DEKs, public keys, hashed phones. Cannot decrypt without the KEK Protected, provided the KEK is stored separately from DB backups
2 Reads the bot's environment (host compromise, leaked CI/deploy secrets, debug endpoint, crash dump) Obtains the KEK and, with DB access, can unwrap every DEK Not mitigated by env-var custody. Limit who can read the secret store, keep the KEK out of logs/CI, and move to KMS/HSM (non-exportable KEK, per-call audit, rate alarms) before holding material funds
3 Full compromise of a running replica Can call the signing path for any wallet while it runs Not preventable at the application layer. Contain with network egress limits, short-lived credentials, per-user vault deposit caps (VAULT_USER_DEPOSIT_CAP), and incident response (docs/INCIDENT_RESPONSE.md)
4 Operator / insider with both DB and secret-store access Full custody of all users' keys Inherent to custodial design. Separate duties between DB and secret-store admins; KMS/HSM audit logs; users should be told the wallet is custodial
5 KEK exposure discovered later (leak, ex-employee) Past DB copies remain decryptable by the leaked KEK Rotate (runbook) to protect future copies. Wallets whose ciphertext was captured together with the KEK must be considered compromised: move funds to fresh wallets
6 Loss of the KEK Every custodial secret is permanently unrecoverable Escrow the KEK (and retired KEKs until rotation completes) in a secret manager with backup and access review; test restores
7 Tampering with key_version / ciphertext Fails GCM authentication (version is AAD of the DEK wrap) Detected; the wallet becomes unusable rather than signing with a wrong key
8 Phone-number takeover (SIM swap) or interception of the OTP Attacker verifies as the victim and gets that number's session OTP is currently echoed in the chat reply and therefore proves nothing about phone control. Deliver the OTP out of band (SMS / Twilio Verify) before real funds
9 OTP brute force across replicas Attempts are counted atomically in Postgres, so parallel guesses cannot exceed the limit; 3 attempts / 5 minutes Bounded
10 Replayed or forged webhooks Forged requests fail Twilio signature validation; replays are deduplicated by MessageSid and never re-run state-changing commands Bounded; see docs/WHATSAPP_BOT.md
11 Weak or placeholder secrets in a deployment Service refuses to start (envGuard); legacy fixed-salt derivation is only used to read old rows and is upgraded by rotate-keys Run rotate-keys once after deploying to retire the legacy format

Out of scope

Compromise of the Stellar network or the vault contract (covered by the vault threat model above), and endpoint security of users' own phones.

Rotation cadence and triggers

Rotate the KEK at least every 12 months and immediately on: any suspected exposure of the secret store or a replica, departure of someone with access to it, or a change of secret-store provider. Record each rotation (date, old/new version, operator) in the operations log.

Deployment Configuration Fail-Closed Policy

Production-sensitive defaults must not be embedded in application source. The WhatsApp encryption key, phone hashing salt, and vault contract identifiers must come from deployment environment variables. If any of these values are missing, the affected service should fail during startup rather than silently falling back to a shared development secret or stale testnet contract id.

CI enforces this posture with scripts/check-no-production-defaults.py, which is run from the secret-scan job. The check is intentionally narrow and focused on the env vars that would create direct custody, privacy, or routing risk if a default value drifted into production code.

Required fail-closed values:

Variable Used by Risk if defaulted
ENCRYPTION_KEY WhatsApp custodial key-encryption key (see custody threat model) Shared fallback key could decrypt every stored user signing secret
PHONE_HASH_SALT WhatsApp phone-number hashing Shared fallback salt weakens PII privacy and cross-environment isolation
DATABASE_URL WhatsApp wallet, OTP and session storage A default postgres:postgres@localhost credential could point production at an unintended or weakly protected database
TWILIO_AUTH_TOKEN WhatsApp webhook signature validation A default token would let anyone forge signed webhooks
VAULT_CONTRACT_ID WhatsApp vault routing Bot could submit or simulate against a stale contract
NEXT_PUBLIC_VAULT_CONTRACT_ID Frontend vault reads UI could display balances or submit flows against a stale contract

At runtime the WhatsApp service enforces the same policy: whatsapp/src/bootstrapEnv.ts (first import of every entry point) runs assertEnv() from whatsapp/src/envGuard.ts and aborts the boot with a list of every missing, too-short, placeholder or default value, and refuses TWILIO_SKIP_SIGNATURE_VALIDATION=true under NODE_ENV=production (Issue #945). check-no-production-defaults.py additionally fails CI if an entry point stops importing the guard.

There aren't any published security advisories