Skip to content

feat(contracts): reentrancy and cross-contract call guard framework (#811) - #879

Merged
0xDeon merged 1 commit into
Suncrest-Labs:devfrom
numdinkushi:feat/811-reentrancy-guard-framework
Jul 25, 2026
Merged

feat(contracts): reentrancy and cross-contract call guard framework (#811)#879
0xDeon merged 1 commit into
Suncrest-Labs:devfrom
numdinkushi:feat/811-reentrancy-guard-framework

Conversation

@numdinkushi

@numdinkushi numdinkushi commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Adds a shared reentrancy guard and callee allowlist in libs/common, backed by Soroban temporary storage so locks clear automatically on revert.
  • Applies guarded entrypoints across vault, treasury, and allocation strategy fund-moving paths, with internal unguarded variants for legitimate nesting (e.g. emergency queue processing during deposit).
  • Introduces hostile token, strategy, and yield-source mocks plus adversarial integration tests; documents deposit/withdraw resource costs in TESTING.md.

Test plan

  • make test in packages/contracts
  • make integration-test in packages/contracts
  • make clippy in packages/contracts
  • Reentrancy lock clears after guarded call reverts
  • Adversarial tests block reentrant token, strategy, and yield-source callbacks
  • Unregistered callee calls revert with CalleeNotAllowed

Closes #811

Summary by CodeRabbit

  • New Features

    • Added reentrancy protection and callee allowlisting across vault, treasury, and allocation strategy operations.
    • Added admin controls to register or remove approved external callees.
    • Simplified protocol initialization with a consolidated configuration.
    • Added clearer errors when reentrant calls or unapproved callees are detected.
  • Bug Fixes

    • Improved protection for deposits, withdrawals, harvesting, rebalancing, fee distribution, and emergency processing.
    • Strengthened validation for delay and rebalance threshold limits.
  • Tests & Documentation

    • Added adversarial integration coverage for reentrancy, authorization, paused vaults, invalid deposits, and withdrawal limits.
    • Documented reentrancy cost measurements and hostile test scenarios.

…uncrest-Labs#811)

Introduce shared temporary-storage reentrancy guards and callee allowlists in libs/common, apply them across vault, treasury, and allocation strategy fund-moving paths, and add hostile mocks with adversarial integration tests plus documented resource costs.
@numdinkushi
numdinkushi requested a review from 0xDeon as a code owner July 25, 2026 13:52
@netlify

netlify Bot commented Jul 25, 2026

Copy link
Copy Markdown

Deploy Preview for nesterhq canceled.

Name Link
🔨 Latest commit 5d79b45
🔍 Latest deploy log https://app.netlify.com/projects/nesterhq/deploys/6a64bfa7c749b00008f818eb

@drips-wave

drips-wave Bot commented Jul 25, 2026

Copy link
Copy Markdown

@numdinkushi Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits.

You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀

Learn more about application limits

@netlify

netlify Bot commented Jul 25, 2026

Copy link
Copy Markdown

Deploy Preview for nesterdapp ready!

Name Link
🔨 Latest commit 5d79b45
🔍 Latest deploy log https://app.netlify.com/projects/nesterdapp/deploys/6a64bfa91d9f7700084233f0
😎 Deploy Preview https://deploy-preview-879--nesterdapp.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@coderabbitai

coderabbitai Bot commented Jul 25, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

This PR adds shared reentrancy and callee-allowlist primitives, applies them to vault, treasury, and allocation-strategy call paths, consolidates Nester initialization configuration, and adds hostile mocks plus adversarial integration tests and resource-cost documentation.

Changes

Reentrancy and callee enforcement

Layer / File(s) Summary
Shared guard and allowlist primitives
packages/contracts/libs/common/*
Adds temporary-storage reentrancy locking, persistent callee allowlists, typed errors, public exports, and unit coverage.
Vault call controls and guarded entrypoints
packages/contracts/contracts/vault/src/lib.rs
Guards state-changing vault operations, routes external calls and token transfers through allowlisted helpers, bootstraps trusted callees, and adds admin registration controls.
Treasury and allocation strategy enforcement
packages/contracts/contracts/treasury/src/lib.rs, packages/contracts/contracts/allocation_strategy/src/lib.rs
Guards treasury operations, allowlists treasury tokens and registry calls, and protects allocation strategy weight updates.
Hostile contract test infrastructure
packages/contracts/libs/test_utils/src/hostile/*, packages/contracts/libs/test_utils/src/lib.rs
Adds reentrant token, strategy, yield-source, and vault harness utilities.
Adversarial integration coverage
packages/contracts/tests/integration/src/integration/*, packages/contracts/contracts/vault/src/test.rs, packages/contracts/TESTING.md
Adds reentrancy, allowlist, emergency-queue, and validation scenarios, updates strategy wiring, and documents guard resource measurements.

Initialization and validation updates

Layer / File(s) Summary
Protocol initialization contract
packages/contracts/contracts/nester/src/lib.rs, packages/contracts/contracts/nester/src/test.rs
Replaces multiple initialization addresses with a public ProtocolInitConfig and updates storage, events, and tests.
Validation and cleanup
packages/contracts/contracts/timelock/src/lib.rs, packages/contracts/contracts/allocation_strategy/src/lib.rs, packages/contracts/contracts/yield_registry/src/lib.rs
Uses inclusive range checks and is_empty helpers, simplifies index handling and risk clamping, and reorders registry role documentation.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant User
  participant Vault
  participant ReentrancyGuard
  participant Token
  participant Treasury
  User->>Vault: deposit or withdraw
  Vault->>ReentrancyGuard: enter guarded scope
  Vault->>Token: allowlisted transfer
  Vault->>Treasury: allowlisted fee call
  Vault->>ReentrancyGuard: exit guarded scope
Loading

Possibly related PRs

Suggested reviewers: 0xdeon

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Out of Scope Changes check ⚠️ Warning packages/contracts/contracts/timelock/src/lib.rs and packages/contracts/contracts/yield_registry/src/lib.rs include unrelated changes. Move the timelock and yield_registry edits into a separate PR if they are intentional.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The PR implements the shared guard, allowlist, hostile mocks, adversarial tests, and TESTING.md updates requested by #811.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: a shared reentrancy and cross-contract call guard framework across contracts.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/contracts/contracts/vault/src/lib.rs`:
- Around line 927-940: Protect infrastructure callees from admin removal: in
packages/contracts/contracts/vault/src/lib.rs lines 927-940, update
register_callee/unregister_callee to prevent overwriting or unregistering the
addresses stored in DataKey::Token, DataKey::VaultToken, and the fee
configuration’s treasury_address, preferably through an immutable core-callee
check. In packages/contracts/contracts/treasury/src/lib.rs lines 356-366, guard
unregister operations from removing tokens referenced by pending
distribute/withdraw calls, using the existing confirmation or timelock mechanism
if available.

In `@packages/contracts/libs/test_utils/src/assertions.rs`:
- Around line 17-20: Update assert_reentrancy_blocked to verify that result is
the expected ContractError::ReentrancyDetected error, rather than accepting any
Err value. Preserve the existing failure message while matching the Soroban
error structure and rejecting unrelated authorization, balance, or validation
failures.

In `@packages/contracts/libs/test_utils/src/hostile/token.rs`:
- Around line 23-31: Update the `mint` function’s balance calculation to use
checked i128 addition for `balance + amount`, and handle an overflow according
to the existing contract error/panic conventions rather than allowing unchecked
arithmetic.

In `@packages/contracts/TESTING.md`:
- Around line 123-134: Update the hostile mock reference in the “Adversarial
integration tests” section to point to the directory module
`libs/test_utils/src/hostile/` rather than `libs/test_utils/src/hostile.rs`,
preserving the existing test scenario descriptions.

In `@packages/contracts/tests/integration/src/integration/adversarial_tests.rs`:
- Around line 115-151: Replace the tautological assertion in
registered_strategy_rebalance_invokes_allowlisted_callee with a meaningful
rebalance outcome check. Assert the concrete expected delta count or values; if
this setup should produce a rebalance, at minimum require !deltas.is_empty().
- Around line 12-49: Update the #[should_panic] annotations on
reentrant_strategy_during_rebalance_is_blocked,
reentrant_token_during_deposit_is_blocked, and
reentrant_yield_source_during_harvest_is_blocked to require the expected panic
text "Error(Contract, `#22`)", preserving the existing test bodies.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 55145ace-9d44-4bd7-a4ed-86ca250ac25e

📥 Commits

Reviewing files that changed from the base of the PR and between 35da7b5 and 5d79b45.

⛔ Files ignored due to path filters (1)
  • packages/contracts/Cargo.lock is excluded by !**/*.lock, !**/Cargo.lock
📒 Files selected for processing (26)
  • packages/contracts/TESTING.md
  • packages/contracts/contracts/allocation_strategy/src/lib.rs
  • packages/contracts/contracts/nester/src/lib.rs
  • packages/contracts/contracts/nester/src/test.rs
  • packages/contracts/contracts/timelock/src/lib.rs
  • packages/contracts/contracts/treasury/src/lib.rs
  • packages/contracts/contracts/treasury/src/test.rs
  • packages/contracts/contracts/vault/src/lib.rs
  • packages/contracts/contracts/vault/src/test.rs
  • packages/contracts/contracts/yield_registry/src/lib.rs
  • packages/contracts/libs/common/Cargo.toml
  • packages/contracts/libs/common/src/errors.rs
  • packages/contracts/libs/common/src/lib.rs
  • packages/contracts/libs/common/src/reentrancy.rs
  • packages/contracts/libs/common/src/storage.rs
  • packages/contracts/libs/test_utils/Cargo.toml
  • packages/contracts/libs/test_utils/src/assertions.rs
  • packages/contracts/libs/test_utils/src/hostile/harness.rs
  • packages/contracts/libs/test_utils/src/hostile/mod.rs
  • packages/contracts/libs/test_utils/src/hostile/strategy.rs
  • packages/contracts/libs/test_utils/src/hostile/token.rs
  • packages/contracts/libs/test_utils/src/hostile/yield_source.rs
  • packages/contracts/libs/test_utils/src/lib.rs
  • packages/contracts/tests/integration/src/integration/adversarial_tests.rs
  • packages/contracts/tests/integration/src/integration/lifecycle_tests.rs
  • packages/contracts/tests/integration/src/integration/mod.rs

Comment on lines +927 to +940
pub fn register_callee(env: Env, caller: Address, callee: Address) {
require_initialized(&env);
caller.require_auth();
AccessControl::require_role(&env, &caller, Role::Admin);
CalleeAllowlist::register(&env, &callee);
}

pub fn unregister_callee(env: Env, caller: Address, callee: Address) {
require_initialized(&env);
caller.require_auth();
AccessControl::require_role(&env, &caller, Role::Admin);
CalleeAllowlist::unregister(&env, &callee);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Admin can unregister core infrastructure callees and brick fund-moving entrypoints.

CalleeAllowlist::unregister has no concept of a protected/core address, so unregister_callee can remove addresses that transfer_tokens/invoke_allowed/VaultTokenContractClient::call treat as load-bearing. A single mistaken or malicious admin call turns every guarded deposit/withdraw/distribute/withdraw call into a CalleeNotAllowed panic until re-registered — a self-inflicted but severe availability incident.

  • packages/contracts/contracts/vault/src/lib.rs#L927-L940: reject unregister_callee(callee) (and ideally register_callee overwrite) when callee equals the stored DataKey::Token, DataKey::VaultToken, or the fee config's treasury_address; or track these three as an immutable "core callee" set separate from the admin-editable extended allowlist.
  • packages/contracts/contracts/treasury/src/lib.rs#L356-L366: reject unregistering the token(s) actively referenced by pending distribute/withdraw calls, or otherwise document/guard against this operational footgun (e.g. require a second confirmation step or timelock for removing an in-use callee).
📍 Affects 2 files
  • packages/contracts/contracts/vault/src/lib.rs#L927-L940 (this comment)
  • packages/contracts/contracts/treasury/src/lib.rs#L356-L366
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/contracts/contracts/vault/src/lib.rs` around lines 927 - 940,
Protect infrastructure callees from admin removal: in
packages/contracts/contracts/vault/src/lib.rs lines 927-940, update
register_callee/unregister_callee to prevent overwriting or unregistering the
addresses stored in DataKey::Token, DataKey::VaultToken, and the fee
configuration’s treasury_address, preferably through an immutable core-callee
check. In packages/contracts/contracts/treasury/src/lib.rs lines 356-366, guard
unregister operations from removing tokens referenced by pending
distribute/withdraw calls, using the existing confirmation or timelock mechanism
if available.

Comment on lines +17 to +20

pub fn assert_reentrancy_blocked(result: &core::result::Result<(), soroban_sdk::Error>) {
assert!(result.is_err(), "expected reentrancy to be blocked");
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

assert_reentrancy_blocked doesn't verify the failure is actually a reentrancy rejection.

It only checks is_err(), so a hostile-mock test would pass even if the call failed for an unrelated reason (auth, balance, validation) rather than ContractError::ReentrancyDetected (#22). This undermines confidence that adversarial tests are actually proving the guard works.

♻️ Proposed fix: assert the specific error code
-pub fn assert_reentrancy_blocked(result: &core::result::Result<(), soroban_sdk::Error>) {
-    assert!(result.is_err(), "expected reentrancy to be blocked");
-}
+pub fn assert_reentrancy_blocked(result: &core::result::Result<(), soroban_sdk::Error>) {
+    match result {
+        Err(e) => assert_eq!(
+            e,
+            &soroban_sdk::Error::from_contract_error(22),
+            "expected ReentrancyDetected (`#22`), got a different error"
+        ),
+        Ok(_) => panic!("expected reentrancy to be blocked"),
+    }
+}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
pub fn assert_reentrancy_blocked(result: &core::result::Result<(), soroban_sdk::Error>) {
assert!(result.is_err(), "expected reentrancy to be blocked");
}
pub fn assert_reentrancy_blocked(result: &core::result::Result<(), soroban_sdk::Error>) {
match result {
Err(e) => assert_eq!(
e,
&soroban_sdk::Error::from_contract_error(22),
"expected ReentrancyDetected (`#22`), got a different error"
),
Ok(_) => panic!("expected reentrancy to be blocked"),
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/contracts/libs/test_utils/src/assertions.rs` around lines 17 - 20,
Update assert_reentrancy_blocked to verify that result is the expected
ContractError::ReentrancyDetected error, rather than accepting any Err value.
Preserve the existing failure message while matching the Soroban error structure
and rejecting unrelated authorization, balance, or validation failures.

Comment on lines +23 to +31
pub fn mint(env: Env, to: Address, amount: i128) {
let admin: Address = env.storage().instance().get(&symbol_short!("admin")).unwrap();
admin.require_auth();
let key = TokenKey::Balance(to.clone());
let balance: i128 = env.storage().persistent().get(&key).unwrap_or(0);
env.storage()
.persistent()
.set(&key, &(balance + amount));
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Unchecked i128 addition in mint.

Line 30 does balance + amount without an overflow guard (checked_add). Low practical risk since this is test-only scaffolding with bounded amounts, but flagging per path instructions covering packages/contracts/**.

🛡️ Optional hardening
-        let balance: i128 = env.storage().persistent().get(&key).unwrap_or(0);
-        env.storage()
-            .persistent()
-            .set(&key, &(balance + amount));
+        let balance: i128 = env.storage().persistent().get(&key).unwrap_or(0);
+        let new_balance = balance.checked_add(amount).expect("balance overflow");
+        env.storage().persistent().set(&key, &new_balance);

As per path instructions, "Flag unchecked arithmetic on i128 balances" for packages/contracts/**.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
pub fn mint(env: Env, to: Address, amount: i128) {
let admin: Address = env.storage().instance().get(&symbol_short!("admin")).unwrap();
admin.require_auth();
let key = TokenKey::Balance(to.clone());
let balance: i128 = env.storage().persistent().get(&key).unwrap_or(0);
env.storage()
.persistent()
.set(&key, &(balance + amount));
}
pub fn mint(env: Env, to: Address, amount: i128) {
let admin: Address = env.storage().instance().get(&symbol_short!("admin")).unwrap();
admin.require_auth();
let key = TokenKey::Balance(to.clone());
let balance: i128 = env.storage().persistent().get(&key).unwrap_or(0);
let new_balance = balance.checked_add(amount).expect("balance overflow");
env.storage().persistent().set(&key, &new_balance);
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/contracts/libs/test_utils/src/hostile/token.rs` around lines 23 -
31, Update the `mint` function’s balance calculation to use checked i128
addition for `balance + amount`, and handle an overflow according to the
existing contract error/panic conventions rather than allowing unchecked
arithmetic.

Source: Path instructions

Comment on lines +123 to 134
## Adversarial integration tests

`tests/integration/src/integration/adversarial_tests.rs` exercises hostile mocks from `libs/test_utils/src/hostile.rs`:

| Scenario | What it validates |
|----------|-------------------|
| `reentrant_token_during_deposit_is_blocked` | Token transfer hook cannot re-enter via `withdraw` |
| `reentrant_strategy_during_rebalance_is_blocked` | Strategy callback cannot re-enter during rebalance |
| `reentrant_yield_source_during_harvest_is_blocked` | External fee sink cannot re-enter during harvest |
| `unregistered_callee_is_rejected` | Callee allowlist blocks unknown cross-contract targets |
| `nested_emergency_queue_processing_does_not_double_guard` | Legitimate internal nesting via unguarded variants |

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Stale file path reference.

Line 125 references libs/test_utils/src/hostile.rs, but the hostile mocks live in the directory module libs/test_utils/src/hostile/ (mod.rs, harness.rs, strategy.rs, token.rs, yield_source.rs), not a single file.

📝 Suggested fix
-`tests/integration/src/integration/adversarial_tests.rs` exercises hostile mocks from `libs/test_utils/src/hostile.rs`:
+`tests/integration/src/integration/adversarial_tests.rs` exercises hostile mocks from `libs/test_utils/src/hostile/`:
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
## Adversarial integration tests
`tests/integration/src/integration/adversarial_tests.rs` exercises hostile mocks from `libs/test_utils/src/hostile.rs`:
| Scenario | What it validates |
|----------|-------------------|
| `reentrant_token_during_deposit_is_blocked` | Token transfer hook cannot re-enter via `withdraw` |
| `reentrant_strategy_during_rebalance_is_blocked` | Strategy callback cannot re-enter during rebalance |
| `reentrant_yield_source_during_harvest_is_blocked` | External fee sink cannot re-enter during harvest |
| `unregistered_callee_is_rejected` | Callee allowlist blocks unknown cross-contract targets |
| `nested_emergency_queue_processing_does_not_double_guard` | Legitimate internal nesting via unguarded variants |
## Adversarial integration tests
`tests/integration/src/integration/adversarial_tests.rs` exercises hostile mocks from `libs/test_utils/src/hostile/`:
| Scenario | What it validates |
|----------|-------------------|
| `reentrant_token_during_deposit_is_blocked` | Token transfer hook cannot re-enter via `withdraw` |
| `reentrant_strategy_during_rebalance_is_blocked` | Strategy callback cannot re-enter during rebalance |
| `reentrant_yield_source_during_harvest_is_blocked` | External fee sink cannot re-enter during harvest |
| `unregistered_callee_is_rejected` | Callee allowlist blocks unknown cross-contract targets |
| `nested_emergency_queue_processing_does_not_double_guard` | Legitimate internal nesting via unguarded variants |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/contracts/TESTING.md` around lines 123 - 134, Update the hostile
mock reference in the “Adversarial integration tests” section to point to the
directory module `libs/test_utils/src/hostile/` rather than
`libs/test_utils/src/hostile.rs`, preserving the existing test scenario
descriptions.

Comment on lines +12 to +49
#[test]
#[should_panic]
fn reentrant_strategy_during_rebalance_is_blocked() {
let h = NesterHarness::setup();
let user = h.create_user();
let attacker = h.create_user();
h.mint_deposit_tokens(&user, 20_000_000);
h.vault().deposit(&user, &10_000_000, &0);

let hostile = register_reentrant_strategy(&h.env, &h.vault_id, &attacker);
h.vault().register_callee(&h.admin, &hostile);
h.vault().set_allocation_strategy(&h.admin, &hostile);
h.vault()
.grant_role(&h.admin, &h.admin, &Role::Operator);
h.vault().record_source_allocation(&h.admin, &symbol_short!("aave"), &10_000_000_i128);
h.vault().rebalance(&h.admin);
}

#[test]
#[should_panic]
fn reentrant_token_during_deposit_is_blocked() {
let h = HostileVaultHarness::setup_with_reentrant_token();
let user = Address::generate(&h.env);
h.mint_deposit_tokens(&user, 20_000_000);
h.vault().deposit(&user, &10_000_000, &0);
}

#[test]
#[should_panic]
fn reentrant_yield_source_during_harvest_is_blocked() {
let h = HostileVaultHarness::setup_with_reentrant_yield_sink();
let user = Address::generate(&h.env);
h.mint_stellar_deposit_tokens(&user, 20_000_000);
h.vault().deposit(&user, &10_000_000, &0);
h.grant_manager();
h.vault().report_yield(&user, &5_000_000);
h.vault().harvest(&user);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find the discriminant assigned to ReentrancyDetected to fill in the expected message.
rg -n -A5 'ReentrancyDetected' packages/contracts/libs/common/src/errors.rs

Repository: Suncrest-Labs/nester

Length of output: 225


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- errors.rs ---'
sed -n '1,80p' packages/contracts/libs/common/src/errors.rs

echo
echo '--- adversarial_tests.rs (relevant range) ---'
sed -n '1,90p' packages/contracts/tests/integration/src/integration/adversarial_tests.rs

echo
echo '--- panic / error formatting references ---'
rg -n "Error\\(Contract, #|panic_with_error|ReentrancyDetected|CalleeNotAllowed" packages/contracts -g '!**/target/**'

Repository: Suncrest-Labs/nester

Length of output: 22618


Add explicit reentrancy error expectations to these panic tests.

packages/contracts/tests/integration/src/integration/adversarial_tests.rs:12-49 uses bare #[should_panic] for reentrant_strategy_during_rebalance_is_blocked, reentrant_token_during_deposit_is_blocked, and reentrant_yield_source_during_harvest_is_blocked. Pin them to #[should_panic(expected = "Error(Contract, #22)")] so the tests fail if a different panic starts triggering.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/contracts/tests/integration/src/integration/adversarial_tests.rs`
around lines 12 - 49, Update the #[should_panic] annotations on
reentrant_strategy_during_rebalance_is_blocked,
reentrant_token_during_deposit_is_blocked, and
reentrant_yield_source_during_harvest_is_blocked to require the expected panic
text "Error(Contract, `#22`)", preserving the existing test bodies.

Comment on lines 115 to +151
#[test]
fn test_last_admin_cannot_be_revoked() {
assert!(true, "placeholder: last admin protection");
} No newline at end of file
fn registered_strategy_rebalance_invokes_allowlisted_callee() {
let h = NesterHarness::setup();
let user = h.create_user();
h.mint_deposit_tokens(&user, 20_000_000);
h.vault().deposit(&user, &10_000_000, &0);

h.vault().register_callee(&h.admin, &h.strategy_id);
h.vault().set_allocation_strategy(&h.admin, &h.strategy_id);
h.vault()
.grant_role(&h.admin, &h.admin, &Role::Operator);
h.vault().record_source_allocation(&h.admin, &symbol_short!("aave"), &10_000_000_i128);

let aave = symbol_short!("aave");
let blend = symbol_short!("blend");
h.registry()
.register_source(&h.admin, &aave, &h.create_user(), &nester_common::ProtocolType::Lending);
h.registry()
.register_source(&h.admin, &blend, &h.create_user(), &nester_common::ProtocolType::Lending);
h.strategy()
.update_strategy_params(&h.admin, &500u32, &10_000u32, &100u32);
let weights = soroban_sdk::vec![
&h.env,
allocation_strategy_contract::AllocationWeight {
source_id: aave.clone(),
weight_bps: 6_000,
},
allocation_strategy_contract::AllocationWeight {
source_id: blend.clone(),
weight_bps: 4_000,
},
];
h.strategy().set_weights(&h.admin, &weights);

let deltas = h.vault().rebalance(&h.admin);
assert!(deltas.is_empty() || !deltas.is_empty());
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Tautological assertion at line 150 verifies nothing.

assert!(deltas.is_empty() || !deltas.is_empty()) is always true regardless of the returned value, so this test never actually checks the rebalance outcome. Assert on a concrete expectation (e.g., specific delta count/values, or at minimum !deltas.is_empty() if a rebalance is expected to produce deltas here).

🐛 Suggested fix
-    let deltas = h.vault().rebalance(&h.admin);
-    assert!(deltas.is_empty() || !deltas.is_empty());
+    let deltas = h.vault().rebalance(&h.admin);
+    assert!(!deltas.is_empty(), "expected rebalance to produce allocation deltas");
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
#[test]
fn test_last_admin_cannot_be_revoked() {
assert!(true, "placeholder: last admin protection");
}
\ No newline at end of file
fn registered_strategy_rebalance_invokes_allowlisted_callee() {
let h = NesterHarness::setup();
let user = h.create_user();
h.mint_deposit_tokens(&user, 20_000_000);
h.vault().deposit(&user, &10_000_000, &0);
h.vault().register_callee(&h.admin, &h.strategy_id);
h.vault().set_allocation_strategy(&h.admin, &h.strategy_id);
h.vault()
.grant_role(&h.admin, &h.admin, &Role::Operator);
h.vault().record_source_allocation(&h.admin, &symbol_short!("aave"), &10_000_000_i128);
let aave = symbol_short!("aave");
let blend = symbol_short!("blend");
h.registry()
.register_source(&h.admin, &aave, &h.create_user(), &nester_common::ProtocolType::Lending);
h.registry()
.register_source(&h.admin, &blend, &h.create_user(), &nester_common::ProtocolType::Lending);
h.strategy()
.update_strategy_params(&h.admin, &500u32, &10_000u32, &100u32);
let weights = soroban_sdk::vec![
&h.env,
allocation_strategy_contract::AllocationWeight {
source_id: aave.clone(),
weight_bps: 6_000,
},
allocation_strategy_contract::AllocationWeight {
source_id: blend.clone(),
weight_bps: 4_000,
},
];
h.strategy().set_weights(&h.admin, &weights);
let deltas = h.vault().rebalance(&h.admin);
assert!(deltas.is_empty() || !deltas.is_empty());
}
let deltas = h.vault().rebalance(&h.admin);
assert!(!deltas.is_empty(), "expected rebalance to produce allocation deltas");
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/contracts/tests/integration/src/integration/adversarial_tests.rs`
around lines 115 - 151, Replace the tautological assertion in
registered_strategy_rebalance_invokes_allowlisted_callee with a meaningful
rebalance outcome check. Assert the concrete expected delta count or values; if
this setup should produce a rebalance, at minimum require !deltas.is_empty().

@0xDeon 0xDeon left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Guard framework looks solid. Temporary storage for the lock is the right call and the clear-on-revert test proves it, the public-wrapper/private-impl split handles the emergency-queue nesting cleanly, and the hostile mocks are real attacks rather than stand-ins. Gas numbers in TESTING.md are appreciated.

Two things for a follow-up rather than blockers. The three reentrancy tests in adversarial_tests.rs use a bare should_panic, so they'd pass on any panic at all — including CalleeNotAllowed or a setup failure — and they don't assert that state is unchanged afterwards, which the issue does ask for. Worth tightening to the expected error code. Second, bootstrap_callee_allowlist registers the token, vault token and treasury but not the allocation strategy, so rebalance reverts until an admin calls register_callee by hand; folding that into set_allocation_strategy would remove a sharp edge. rent_escrow is also still unguarded.

Merging.

@0xDeon
0xDeon merged commit 500c24d into Suncrest-Labs:dev Jul 25, 2026
28 checks passed
cyb3ralee pushed a commit to cyb3ralee/nester that referenced this pull request Jul 30, 2026
…uncrest-Labs#811) (Suncrest-Labs#879)

Introduce shared temporary-storage reentrancy guards and callee allowlists in libs/common, apply them across vault, treasury, and allocation strategy fund-moving paths, and add hostile mocks with adversarial integration tests plus documented resource costs.
0xDeon added a commit that referenced this pull request Aug 10, 2026
* chore: add CodeRabbit AI review config (auto-review PRs targeting dev/main)

* fix(ci): unblock Rust and Go pipelines (#798)

Bump ethnum 1.5.0 -> 1.5.3 in packages/contracts so the contract crate
compiles on the current stable toolchain (rustc 1.97.1). ethnum 1.5.0 fails
with error[E0512] transmuting () into TryFromIntError, which no longer share
a size; the crate fixed it in 1.5.1.

Bump apps/api to Go 1.25.12 and golang.org/x/text v0.39.0 to clear the two
govulncheck findings not in the allowlist: GO-2026-5856 (crypto/tls, fixed
in go1.25.12) and GO-2026-5970 (x/text, fixed in v0.39.0).

* Branch to solve issue#788 (#797)

* test(contracts): add negative authorization coverage

* test: tighten negative authorization assertions

---------

Co-authored-by: Deon <110722148+0xDeon@users.noreply.github.com>

* feat(api): envelope encryption, key versioning & rotation for account cipher (#799)

* feat(crypto): version-tagged envelope cipher for account numbers

Replace the single-key AccountCipher with a multi-version AES-256-GCM
cipher. Encrypt seals with the active key and returns a CipherEnvelope
carrying the key version; Decrypt resolves the key by the ciphertext's
version and fails with ErrUnknownKeyVersion when it is not registered.
NewAccountCipher is retained (registers the key as v1) for backward
compatibility. Fingerprints use a stable pepper independent of the active
key so the uniqueness index survives rotation.

* test(crypto): cover active-key encrypt, cross-version decrypt, unknown version, fingerprint stability

* feat(config): add AccountCipherConfig accessor and versioned key set type

* feat(config): parse ACCOUNT_CIPHER_KEYS/ACTIVE_KEY with legacy single-key fallback

ACCOUNT_CIPHER_KEYS (comma-separated version:base64 pairs) plus
ACCOUNT_CIPHER_ACTIVE_KEY take precedence; when unset, the existing
BANK_ACCOUNT_ENCRYPTION_KEY is registered as v1 so single-key
deployments keep working. Validates active version membership and pair
format at startup.

* test(config): cover multi-key parsing, legacy fallback, and validation errors

* docs(config): document account cipher key set and rotation env vars in .env.example

* feat(db): add bank_accounts.key_version column defaulting existing rows to v1

New column records which key sealed each row so rotation never rewrites
history. Indexed so the rotation tool can cheaply find un-rotated rows.

* feat(db): down migration dropping key_version column and index

* feat(bankaccount): thread key version through Repository Create/GetByID

* feat(repo): persist key_version on bank account insert

* feat(repo): return key_version from GetByID and add rotation Store methods

GetByID now yields the stored key version so callers can decrypt with the
right key. CountPending/ScanPending/UpdateCipher implement rotation.Store
for the rotation tool; UpdateCipher leaves the fingerprint untouched so the
uniqueness index is unaffected.

* feat(rotation): idempotent, resumable batch key-rotation engine

Rotator scans rows not on the active key version, decrypts each with its
recorded version, re-seals with the active key, and commits per row. A
second run finds nothing (idempotent); an interrupted run resumes from the
remainder. Logs only counts and row IDs, never plaintext, keys, or
ciphertext.

* test(rotation): re-encrypt to active, idempotency, resume-after-interrupt, no data loss

* feat(service): seal new accounts with the active key envelope

* feat(service): decrypt saved accounts by their stored key version

ResolveForSettlement reconstructs the CipherEnvelope from the stored
ciphertext and key version; SetDefault/Remove absorb the extra GetByID
return value.

* test(service): update in-memory repo mock for key-versioned signatures

* test(service): new writes use active key; legacy row decrypts after key added

* feat(cmd): rotate_keys CLI to re-encrypt accounts onto the active key

Loads the same key config as the API, refuses to run when no cipher is
configured, and drives the rotation engine over the bank_accounts store
with -batch-size and -timeout flags.

* feat(api): wire the multi-key account cipher from AccountCipherConfig

* docs(security): key versioning model, env format, and 5-step rotation runbook

* fix(crypto): require explicit fingerprint pepper when no v1 key is configured

Defaulting the fingerprint pepper to the active key let it change on every
rotation (e.g. v2->v3) and silently break blind-index uniqueness. Fail closed
with ErrFingerprintKeyRequired instead; cover the no-v1 active-key rotation
case. (CodeRabbit)

* fix(config): fail closed on empty keyset, over-long versions, and v1-less sets

- ACCOUNT_CIPHER_KEYS that parses to zero entries now errors instead of
  silently disabling the cipher, and an active version absent from the set is
  always rejected.
- Reject key versions longer than 32 chars (bank_accounts.key_version is
  VARCHAR(32)) before they fail at the DB boundary.
- Require ACCOUNT_CIPHER_FINGERPRINT_KEY when the key set has no v1. (CodeRabbit)

* fix(db): guard key_version rollback and build its index concurrently

- 057 down aborts if any row is on a non-v1 key, since dropping key_version
  would make rotated ciphertext undecryptable.
- Move the index into 058 using CREATE INDEX CONCURRENTLY so a large
  bank_accounts table is not write-locked during deploy. (CodeRabbit)

* docs: clarify that a v1-less key set must set an explicit fingerprint pepper (CodeRabbit)

* test(service): consolidate key-versioning scenarios into a table-driven test (CodeRabbit)

* docs: clarify no active-key fallback; config-load failure vs constructor sentinel (CodeRabbit)

* feat(api): distributed rate limiting with strict route limits (#800)

* feat(api): add distributed rate limiting with strict route limits

Extend the existing in-memory rate limiter with a dual-mode backend: a
Redis fixed-window counter for cross-instance enforcement, falling back
to the in-memory token bucket when REDIS_ADDR is unset.

- Add Limiter interface + NewLimiter factory (Redis or in-memory)
- Global per-IP limiter now excludes /health*, /readyz, /metrics
- Strict per-IP limiter on POST /auth/challenge and /auth/verify
  (credential stuffing) and strict per-user limiter on
  POST /settlements (settlement spam)
- New RATELIMIT_AUTH_* and RATELIMIT_SETTLEMENT_* config knobs + .env
- Redis limiter fails open on outage so it never blocks live traffic
- Table-driven tests: under/over limit, 429 + Retry-After, window
  reset, per-IP and per-user isolation, memory fallback, and a
  Redis integration test guarded by REDIS_ADDR

* fix(api): address CodeRabbit review on rate limiting

- CORS: move cors middleware outermost so 429 responses from the global
  and auth-route limiters still carry Access-Control-Allow-Origin and
  stay readable to browser clients
- Redis: bound each limiter round-trip with a 75ms timeout so a slow
  (not just down) Redis fails fast into fail-open instead of adding
  multi-second latency to every request; log fail-open events
- Proxy-aware client IP: add RATELIMIT_TRUSTED_PROXY_COUNT (default 0).
  When set, derive the client IP from X-Forwarded-For counting hops from
  the right, so traffic behind a load balancer keys off the real client
  instead of collapsing onto the proxy address, without letting clients
  spoof past the trusted-proxy boundary
- Tests: proxy-aware keying + spoof resistance, and config default /
  override / negative-validation for the new knob

* fix(api): reject sub-millisecond rate-limit windows

The Redis limiter converts the window to whole milliseconds for PEXPIRE,
so a positive but sub-1ms window (e.g. 500us) truncates to 0, expiring
the counter immediately and silently disabling enforcement. Reject
global/auth/settlement windows below 1ms at config load, with a
regression test.

* feat: core backend + AI primitives — job queue, harvest engine, portfolio valuation, RAG grounding (#824 #845 #832 #852) (#876)

* feat(api): durable async job queue (#824)

PostgreSQL-backed job queue with FOR UPDATE SKIP LOCKED dequeue, lease-based
visibility timeout with crash recovery, exponential backoff + full jitter,
dead-letter queue, per-job-type concurrency limits, idempotent enqueue, and
graceful drain on shutdown. Queue-depth/DLQ/latency metrics and correlation-ID
propagation included. Worker pool wired into the API with config knobs.

* feat(api): yield harvest orchestration engine (#845)

Cadence + event-triggered engine that applies the economic gate (harvest iff
accrued yield > gas fee + margin), defers under network congestion, and submits
harvests as idempotent, window-deduplicated jobs on the #824 queue. Includes a
gas oracle abstraction, vault/user/service adapters, an idempotent job handler,
and an owner-scoped harvest-status API (pending yield, threshold, estimated next
harvest). Pure decision core and engine fully unit-tested.

* feat(api): real-time portfolio valuation service (#832)

Stroop-exact aggregation of positions, pending deposits, accrued yield, goal
allocations, and claimable rewards with a structured per-vault/per-goal
breakdown (principal vs yield, locked vs flexible, settled vs pending,
claimable). Multi-asset oracle pricing with confidence propagation, per-user
cache with event-driven invalidation on confirmed transactions, and WebSocket
push of refreshed valuations. Pure aggregator, cache, and service unit-tested.

* feat(intelligence): RAG grounding for Prometheus AI (#852)

Structured retrieval layer that routes queries to the right user-scoped data
sources (positions, goals, transactions, yield landscape) without embeddings,
assembling only the minimal context needed with citations. Grounding rules
force the model to answer solely from retrieved context, cite it, and refuse
when data is missing; post-generation numeric validation flags any figure not
present in the context to catch hallucinations. Strict per-user isolation: scope
is fixed by the JWT subject and cannot be widened by prompt injection. Wired into
streaming chat (and WebSocket chat via the shared path). Fully unit-tested.

* feat(api): savings goal archive-on-delete, amount/name validation, yield cache warming (#874)

* fix(savingsgoal): soft-archive goals on DELETE instead of hard-delete (#685)
Replace the permanent DELETE with an UPDATE that stamps archived_at and
sets status to 'archived'. Adds migration 059 to introduce the
archived_at column. Already-archived goals surface as ErrGoalNotFound
(404) so callers get a sensible response without a 500. Adds two unit
tests via sqlmock asserting the soft-delete and already-archived cases.

* feat(yield): warm DeFiLlama Stellar cache on service startup (#667)

* fix(savingsgoal): validate target_amount and goal name (#692 #681)
#692 — Add savingsgoal.ErrInvalidAmount (defined in the savingsgoal
domain, not imported from vault) and update validateSavingsGoalInput to
return it when target_amount is zero, negative, or below MinTargetAmount
(0.01). Handler writeError now maps ErrInvalidAmount to 400 Bad Request,
fixing the 500 that was returned when vault.ErrInvalidAmount was not
recognised.
#681 — Add validateGoalName capping name at MaxGoalNameLength (100
chars) consistent with the savings_goals.name column width. Called on
both Create and Update paths so over-long names return a 400 instead of
a DB error.

* test(savingsgoal): cover amount and name validation cases (#692 #681)

* feat(contracts): add reentrancy guard and callee allowlist framework (#811) (#879)

Introduce shared temporary-storage reentrancy guards and callee allowlists in libs/common, apply them across vault, treasury, and allocation strategy fund-moving paths, and add hostile mocks with adversarial integration tests plus documented resource costs.

* chore(security): fix IDOR vulnerabilities and harden JWT configuration (#872)

* chore(security): fix IDOR vulnerabilities and harden JWT configuration

Addresses highest-priority findings from security assessment (Issue #589):

Fixed:
- Added ownership validation for vault retrieval endpoints (GET /vaults/{id}, GET /vaults/{id}/allocations)
- Added ownership validation for transaction creation (POST /transactions)
- Added ownership validation for transaction retrieval (GET /transactions/{hash})
- Hardened Intelligence service by preventing production startup without JWT secret via Pydantic model_validator

Documentation:
- Added docs/security/threat-model.md (assets, trust boundaries, entry points, threat actors, controls)
- Added docs/security/pentest-report-v1.md (11 findings with evidence, impact, root cause, remediation, verification)

Authorization was implemented at the handler level rather than the service layer because the service methods are shared by numerous trusted internal system components (scheduler, rebalance, TVL, projections). Refactoring those interfaces would have expanded scope considerably and increased regression risk.

Closes #589

* style: fix ruff line length in test_config.py

* fix: return 404 for missing vault in transaction ownership check

* chore: address CodeRabbit review comments

- Remove dead var _ = decimal.Zero
- Fix function name extractClientIP -> clientIP in evidence
- Update Go test result from manual to ALL PASS (CI confirmed)
- Correct deposit flow wording (price_per_share is not user-supplied)
- Fix WebSocket nil-authenticator mitigation claim (no nil check exists)

* fix(api): resolve duplicate 059 migration prefix on dev

Two migrations shared the prefix 059. `059_create_jobs` landed first in
#876; `059_add_savings_goal_archived_at` landed eight minutes later in
#874 and collided with it.

The consequence was worse than a lint failure. golang-migrate refuses to
load a directory containing duplicate versions, so migrations could not
run at all past 058, and the migration-prefix guard in the API (Go)
workflow failed on every pull request touching the Go API — six open PRs
were red through no fault of their authors.

Renumber the later arrival to 060 and leave `059_create_jobs` in place,
since it merged first and is the version any environment already sitting
at 59 will have applied. Renumbering it instead would have desynced those
environments. `059_add_savings_goal_archived_at` has never been applied
anywhere, because the collision prevented golang-migrate from loading the
directory in the first place, so moving it is safe.

The only in-repo reference to either filename is in
job_repository_integration_test.go, which points at `059_create_jobs.up.sql`
and is unaffected.

Open PRs claiming 060 will need to rebase and renumber.

* feat(api): feature flags, server-side exports, replica routing, API versioning (#882)

Implements four platform capabilities:

Feature flags (#838) — internal/flags/
- Boolean kill-switch, deterministic percentage rollout, cohort and
  typed-value flags stored in Postgres (migration 060)
- Percentage membership is hash-based and stable: a user in at 10% stays
  in at 20%
- In-process cache with TTL backstop and pub/sub invalidation channel so
  changes propagate across instances within seconds
- Kill switches fail SAFE: evaluator returns the registered safe position
  when the flag service is unreachable, never fail-open
- Secret guard rejects secret-marked names from the flag store
- Every change goes through a required AuditRecorder

Server-side exports (#839) — internal/export/
- Transaction-history CSV generated from the ledger source of truth with a
  stable, documented column schema
- Reconciliation invariant: exported movements must sum to the ledger's
  net change per asset or the export errors instead of delivering a wrong
  document
- Exports above a documented row threshold route to the durable job queue
- HMAC-signed, time-limited, ownership-verified download tokens; another
  user cannot fetch someone's export

Read-replica routing (#841) — internal/db/router.go
- Explicit Read/Write paths declared at call sites, never inferred from SQL
- Read-your-writes: users are pinned to the primary for a bounded window
  after writing (Pinner interface; in-memory impl, Redis-ready)
- Unhealthy or lag-exceeding replicas are routed around automatically with
  primary fallback; transactions always use the primary
- Per-role pool stats exposed for metrics; Close drains all pools

API versioning (#842) — internal/server/versioning.go, docs/api-versioning.md
- Uniform URL-path versioning with versioned route groups
- Deprecated versions emit Deprecation, Sunset and successor Link headers
  on every response; retired versions return 410 Gone with guidance
- Unversioned requests route to a pinned default, not 'latest'
- Per-version usage counting so retirement is data-driven

All packages fully unit-tested (33 tests).

* feat(intelligence): yield optimization engine with constraint-based allocation strategies (#889)

Adds a deterministic, constraint-based yield optimizer to the intelligence
service (#848). Given candidate yield sources and hard constraints
(diversification cap, liquidity floor, lock-horizon fit, risk ceiling,
deposit caps, source status), app.services.yield_optimizer.optimize()
solves a concave-quadratic risk-adjusted-return objective with
scipy.optimize.minimize (SLSQP) and returns per-source weights (fraction
and basis points), expected yield, aggregate risk, and a diversification
index. Infeasible constraint sets are reported explicitly via
infeasibility_reasons and never silently relaxed.

The optimizer is a pure, synchronous, dependency-free function, kept
separate from app.services.yield_explanation, which has Claude narrate an
already-computed result in plain language and validates (via the existing
extract_numbers/normalize_number helpers from retrieval.py) that no number
absent from the result appears in the explanation, falling back to a
deterministic template otherwise.

Also wires a new POST /intelligence/yield-optimization endpoint, adds
scipy/numpy to requirements.txt, and fixes config.py's stale
anthropic_model default (claude-sonnet-4-6 -> claude-sonnet-5).

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>

* chore(security): create load and stress testing plan for vault API and real-time balance endpoints (#891)

Co-authored-by: felladaniel36-hash <felladaniel36@gmail.com>

* feat(api): scheduler leader election for safe multi-instance background jobs (#894)

Adds Postgres-advisory-lock leader election (internal/scheduler/leadership.go)
gating all five scheduler background job loops (rebalancer, recurring
deposits, APY deviation alerts, goal deadline reminders, protocol health
checks) so exactly one instance runs them at a time, with automatic failover
bounded by a 3s heartbeat interval and an execution-time leadership recheck
immediately before every money-moving/notification action to guard against
split-brain during failover.

Also fixes a real latent bug the recurring-deposit job had: its transaction
hash was derived from the schedule ID alone (constant across every
occurrence of a recurring schedule), so only a schedule's first-ever
occurrence could ever be recorded — every later occurrence hit
vault_transactions' unique transaction_hash constraint and retried forever.
The hash now folds in the occurrence timestamp, and the deposit-recording
step is routed through the existing durable job queue (internal/domain/
jobqueue) with a per-occurrence idempotency key, mirroring the harvest
engine's enqueue pattern, for at-least-once safety.

Wires the three job loops that existed but were never started in main.go
(the rebalance-decision Scheduler remains unwired pending real on-chain
RebalanceSubmitter/YieldFetcher adapters — a pre-existing gap, not a
regression), and exposes current leader/instance/since via a new
GET /api/v1/admin/scheduler/leadership endpoint.

Closes #846

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>

* feat: AI savings coaching, AI rebalance engine, PWA offline support, i18n framework (#896)

Closes #112, #110, #790, #789

- Savings goal AI coaching (#112): on-demand GET /api/v1/users/savings-goals/{id}/coaching
  endpoint plus a weekly GoalCoachingScheduler background job, both backed by the
  existing intelligence /intelligence/coaching endpoint. Progress tracking, on-track
  status, and required-deposit math already existed in the savingsgoal domain/service;
  this closes the remaining AI-coaching gap in the issue.

- AI rebalancing engine (#110): new risk_model.py (Sharpe-ratio inspired
  risk_adjusted_score, per-protocol risk factors) and rebalance_engine.py in
  apps/intelligence, combining live DeFiLlama APY data with a cached-baseline
  fallback and Claude-generated rationale. New POST /vaults/{id}/rebalance/suggest
  and /execute endpoints (execute builds an unsigned Stellar transaction via
  stellar-sdk), proxied through the Go API at the same paths.

- PWA installability + offline handling (#790): web app manifest, hand-rolled
  service worker (app-shell precache, network-first navigation with an /offline
  fallback, API requests never cached), and an offline guard on the offramp
  withdraw action. The online/offline hook, banner, and deposit/withdraw guards
  already existed; this fills the manifest/SW/offline-route/offramp gaps.

- i18n framework (#789): locale provider + en/fr message catalogs + a shared
  formatCurrency/formatNumber/formatDate helper (Intl-based), wired into
  dashboard/savings/offramp/settings screens, replacing several ad-hoc
  formatCurrency implementations. Locale persists to localStorage and is
  selectable from Settings > Preferences.

Verified: go build/vet/test + golangci-lint clean; pytest (84 tests) + ruff +
mypy --strict clean; vitest (83 tests) + tsc --noEmit + eslint clean.

Co-authored-by: Chidimj <Chidimj@users.noreply.github.com>

* feat(contracts): granular RBAC, autonomous circuit breaker, vault factory, referral program (#900)

Closes #820, #817, #816, #818

- access_control: granular Role enum (Guardian, Upgrader, Attester,
  FeeManager, RebalanceKeeper, Treasurer, VaultCreator), generalised
  two-step role transfer (transfer_role/accept_role/cancel_role_transfer),
  time-bounded grants (grant_role_until), bounded on-chain enumeration
  (get_role_members, role_expires_at). Guardian can pause/halt but never
  unpause/upgrade/withdraw.
- vault: autonomous staged circuit breaker (breaker.rs) with independently
  configurable trip conditions (share-price move, yield sanity, withdrawal
  velocity with anti-griefing margin, source failure), graded severity
  (Normal/Throttled/DepositsHalted/FullHalt), staged cooled-down recovery
  gated to Admin/Upgrader, and an emergency withdrawal path that works at
  every severity. Guardian-only pause/halt entrypoints added.
- vault_factory: new contract deploying vaults from a governed WASM hash
  via the Soroban deployer, atomic deploy+init, deterministic address
  prediction, O(1) is_nester_vault registry, bounded pagination, timelocked
  WASM-hash governance, deprecate_vault.
- referral: new standalone contract for a trustless referral program.
  Rewards accrue from the protocol's performance-fee slice (never the
  referred user's own yield), gated by minimum deposit/tenure, capped per
  referrer and by a global budget that halts accrual without clawback.
  Vault is the sole trusted caller, mirroring the existing
  treasury.receive_fees pattern.
- Narrower roles wired into treasury (Treasurer), yield_registry
  (Attester), and allocation_strategy/vault (RebalanceKeeper, FeeManager)
  alongside existing Admin/Operator checks.
- EVENTS.md, SECURITY.md, and the contracts README document the new role
  model, Guardian asymmetry, and breaker/factory/referral event surface.

All contracts build to wasm32-unknown-unknown; full workspace test suite
and clippy (-D warnings) pass clean.

* feat(intelligence): add sourced market context signals (#892)

* feat(intelligence): add sourced market context signals

* fix(intelligence): satisfy market context lint

* fix(intelligence): type extraction client boundary

* fix(intelligence): harden signal provenance and batching

* feat(security): add adaptive API abuse protection (#893)

* feat(security): add adaptive abuse protection

* fix(security): harden adaptive abuse state

* feat(intelligence): personalized savings recommendation engine grounded in user data (#897)

* feat(intelligence): personalized savings recommendation engine grounded in user data

Adds a savings recommendation engine (app/services/recommendation_engine.py)
that generates personalized, actionable recommendations from a user's real
goals, positions, and cash-flow behavior. Candidate actions (increase a
goal's contribution, move idle balance to higher yield, lock for a term
boost, consolidate goals toward the nearest deadline) and every number
attached to them are computed deterministically in Python -- Claude, called
via tool use, only selects 2-4 candidates, orders them, and writes a short
explanation, constrained to a `select_recommendations` tool schema that can
only reference candidates by id. A fabrication guard
(`_validate_selection`) checks every number in the model's prose against the
set of numbers the referenced candidate actually carries, rejects and
regenerates once on violation, then falls back to a fully templated
explanation built straight from the candidate's own fields -- so a fabricated
number can never reach the response.

Yield-related candidates always carry risk context (from the vault's real
risk score or a documented default disclosure); goal-success figures are
integrated with the #843 Monte Carlo simulation endpoint when reachable
(two calls -- current vs. proposed contribution -- diffed into a real
probability delta) and degrade to a documented heuristic otherwise, since
per-user (Redis-backed with an in-memory fallback, mirroring
conversation_store.py's pattern) and filtered out permanently; acted-on
action types get a deterministic priority boost. Recommendations are cached
per-user for 6 hours and invalidated automatically when goal/vault figures
change materially, rather than recomputed per page load. Fixes the stale
`claude-sonnet-4-6` model id.

Closes #847

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(intelligence): correct #843 simulation contract, mypy/ruff cleanup for #847

- projection_client.py: point ProjectionProvider at the real #843 endpoint
  contract (POST /api/v1/tools/simulation, goal_success.probability) now
  that it's known, instead of the placeholder GET route guessed before
  #843's shape was finalized. Computes the success-probability delta from
  two simulation calls (current vs required contribution) rather than
  guessing the Go service's internal sensitivity-grid step sizes.
- recommendation_engine.py: thread GoalContext into enrich_with_projections
  so it can build the simulation request; type the Anthropic tool-use call
  properly (ToolParam/ToolChoiceToolParam/MessageParam) instead of bare
  dicts, fixing mypy strict errors -- this is the first tool-use call in
  the intelligence service, so no prior typed precedent existed.
- ruff: import sort, drop pointless f-string prefixes, wrap one long line.

mypy --strict and ruff both clean; full suite still 105/105 passing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* chore: retrigger CI (no functional change)

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>

* feat(intelligence): backend plumbing for periodic financial insight digests (#898)

Adds the Go-side data plumbing for #859's periodic digest: a digest_cadence
notification preference (off/weekly/monthly, opt-out respected), a
user_digests cache/audit table for one-generation-per-period, a
digest-ledger source endpoint exposing deterministic period deposit/yield/
streak facts for the intelligence service to narrate via the relay, and a
leader-elected daily scheduler job that generates and delivers digests
through the existing notification dispatcher.

This PR covers the Go backend groundwork only. The intelligence-service
narrative generation (grounded LLM prompt, zero-save honesty handling,
attention items, Redis caching), frontend insights card, and test coverage
described in #859's acceptance criteria are not yet implemented — tracked
as follow-up. #865, #864, and #856 are referenced per this repo's issue
numbering but have no implementation in this branch.

Note: this environment has no Go toolchain available, so these changes are
reviewed manually but not compiled or test-run locally.

* feat(vault): fair-ordering emergency queue, tiered fees, slippage-safe rebalance, penalty escrow (#901)

Implements four vault contract features plus their backend indexing:

- #814: fair-ordering emergency withdrawal queue (queue.rs) so paused-vault
  exits are served in request order instead of first-caller-wins.
- #813: duration/size-tiered fee schedule (performance, exit, management)
  replacing the flat-rate config, with a continuous tenure curve superseding
  the old binary min-lock gate.
- #810: slippage-safe multi-hop rebalance split into plan/execute steps
  (rebalance.rs) with per-leg minimum-out enforcement.
- #805: early-exit penalty escrow with depositor/treasury split distribution
  instead of penalties vanishing into thin air.

Backend: migrations for the four new event-sourced tables, Stellar event
indexer wiring for all seven new on-chain events (including the previously
unhandled rebalance-completed event), and read-only history endpoints under
/api/v1/vaults/{id}/.

Co-authored-by: dslegacy <dslegacy@users.noreply.github.com>

* feat(contracts): on-chain savings goal registry with milestone attestation (#903)

* feat(contracts): on-chain savings goal registry with milestone attestation

Adds a savings_goal Soroban contract recording goal ownership, target,
deadline, and progress trustlessly, with an idempotent 25/50/75/100%
milestone bitmask and bounded multi-contributor accounting. The registry
never custodies funds — only the vault does. Vaults are validated against
the deployed vault_factory at goal creation.

Backend: onchain_goal_id/onchain_status columns and model fields, repo
read/write wiring, and a bitmask<->milestone translation helper aligned
with the contract's semantics so the existing notifier can treat an
on-chain attestation as equivalent to a notified milestone.

* fix: renumber duplicate/colliding migrations to match landed dev sequence

* test(api): add unit tests for YieldHarvest model (#962)

Co-authored-by: opascal221-design <opascal221-design@users.noreply.github.com>

* feat(dapp): savings goal and vault progress visualization with locked-position support

Add rich progress visualization for savings goals and vaults:
- Segmented progress bar distinguishing locked vs flexible portions
- Principal vs earned yield composition breakdown
- Maturity timeline for locked positions with boost badges and unlock dates
- Probabilistic projection band (confidence interval + success probability)
- Constructive at-risk messaging when goal is off track
- Multi-asset vault composition donut (reuses existing recharts pattern)
- Celebration/completion state, encouraging empty state
- Respects reduced-motion preferences via existing useReducedMotion hook
- All states: empty, in-progress-with-locks, at-risk, completed
- Backward compatible: falls back to simple progress bar when rich data absent

Closes #869

* test(api): add unit tests for YieldHarvest model (#962)

Co-authored-by: opascal221-design <opascal221-design@users.noreply.github.com>

* fix: remove unused variable and import

- Remove unused startKYC variable from rotate_keys/main.go (go vet error).
- Remove unused time import from backfill_kyc_encryption/main.go (go vet error).

* fix(apysnapshot): add unique constraint and idempotent upsert (#963)

Adds DB-level uniqueness on (protocol_slug, captured_at) via migration
069, and updates Upsert to use an explicit ON CONFLICT target so duplicate
oracle reports for the same protocol+timestamp are silently ignored.

* feat: session hardening, Prometheus tool-use, nudge engine, unified list search (#967)

* fix(ci): unblock Rust and Go pipelines

Bump ethnum 1.5.0 -> 1.5.3 in packages/contracts so the contract crate
compiles on the current stable toolchain (rustc 1.97.1). ethnum 1.5.0 fails
with error[E0512] transmuting () into TryFromIntError, which no longer share
a size; the crate fixed it in 1.5.1.

Bump apps/api to Go 1.25.12 and golang.org/x/text v0.39.0 to clear the two
govulncheck findings not in the allowlist: GO-2026-5856 (crypto/tls, fixed
in go1.25.12) and GO-2026-5970 (x/text, fixed in v0.39.0).

* chore: remove internal audit and decision report files from repo root

* feat(intelligence): add prompt-injection and output-safety guardrails for chat/analyze (#875)

* feat(intelligence): add prompt-injection and output-safety guardrails

Claude calls in the chat and analyze paths had no defense against prompt
injection or system-prompt extraction, and recommendation output wasn't
schema-enforced. Add input screening (regex-based, logs request_id + a
non-reversible fingerprint, never raw content), a hardened system prompt
with an explicit trust boundary and tagged untrusted-content wrapping,
deterministic history/message bounding, and output post-processing that
strips leaked system-prompt text and enforces a non-model-controlled
disclaimer on /analyze and related endpoints.

* fix(intelligence): close remaining guardrail gaps from review

- Validate inbound X-Request-Id against a bounded safe charset before
  trusting it in state/headers/logs, falling back to a fresh UUID
  otherwise (prevents log/header injection via a client-supplied header).
- Fix the chat streaming leak-redaction buffer to retain a sanitized
  lookback tail on flush instead of resetting to empty, so a
  system-prompt marker split across two deltas is still caught.
- Wrap the remaining unwrapped context data interpolated into the
  recommend/vault and analyze prompts (positions, vault/user context
  lines) in the same trust-boundary tags used elsewhere.
- Sanitize the few model-derived output fields that were missed:
  confidence_reason/data_freshness in Recommendation, insight card
  action.label/href, and deposit schedule note.

* feat(api/pkg): add keyset cursor and list query grammar parsing

* feat(api/vault): add full-text search and advanced list filtering

* feat(api/settlement): implement memo search and filter updates

* feat(api/savingsgoal): implement search, list filters and repository updates

* feat(api/activity): introduce activity domain, repository, handlers and filter

* feat(dapp/history): update history page to support list filtering and search

* feat(db): add migration for session family rotation and tracking

* feat(api): implement session domain models, repositories, and config

* feat(api): add auth services for token rotation, revocation, and anomaly detection

* feat(api): add session-aware auth middleware, HTTP handlers, and wire main API

* feat(ws): disconnect active WebSocket connections on session revocation

* feat(frontend): implement automatic token refresh and auth provider state

* feat(frontend): add active sessions UI management in settings

* ci: add concurrency groups with cancel-in-progress across workflows

* test(api/savingsgoal): implement ListPaginated mock in template handler tests

* db(migrations): add schemas for user timezone, activity events, nudge log, and preferences

Add DB migration files:
- 057: User timezone column on users table
- 058: Activity events table for tracking user logins and interactions
- 059: Nudge dispatch log table for dispatch history and outcome tracking
- 060: Nudges enabled preference flag

* feat(domain): define smart nudge catalog, user signals, anti-fatigue rules, and intelligence DTOs

Introduce core domain primitives for smart savings nudges:
- Nudge catalog, trigger condition evaluation, priority ranking, and anti-fatigue limits
- User activity, engagement heuristics, responsive timing window, and user segmentation
- Intelligence request/response DTOs for AI copy generation
- User model update for timezone preferences

* feat(repo): add data access for user timezones, activity events, nudge history, and goals

Implement Postgres repository methods for:
- User profile updates supporting timezone
- Recording and querying user activity events
- Logging nudge dispatches, checking anti-fatigue thresholds, and tracking conversion outcomes
- Fetching active savings goals for nudge evaluation

* feat(intelligence): add AI nudge copy generation endpoint with numeric grounding guardrails

Add FastAPI endpoint and AI services for dynamic push copy generation:
- Generate personalized nudge copy via Anthropic Claude model integration
- Validate numeric grounding in guardrails to prevent hallucinated currency figures
- Register /intelligence/nudges route in main FastAPI application

* feat(service): implement nudge engine orchestration, copy generation, and outcome tracking

Add core service logic for smart savings nudges:
- Composite copy generator (static templates fallback + LLM generated copy)
- Prometheus client method for generating nudge copy
- Nudge notifier adapter and milestone-to-nudge milestone mapper
- Nudge outcome service for recording deposits, goal completions, and return visits
- Core NudgeEngineService evaluating rules, user signals, ranking, and anti-fatigue limits
- Register EventSavingsNudge in notifications package

* feat(auth,savings): integrate timezone capture, activity tracking, and nudge outcome hooks

Hook user actions into nudge signals and outcome tracking:
- Return userID from Auth.VerifyAndIssue to record user timezone, login activity event, and return visit outcome
- Attach OutcomeRecorder to SavingsGoalService to track goal completion outcomes

* feat(scheduler,cmd): replace legacy reminder job with periodic nudge engine and wire main app

Wire up the smart savings nudge engine:
- Replace legacy goal deadline reminder job with background NudgeEngineJob
- Initialize repositories, services, and nudge notification dispatcher in main.go
- Trigger nudge evaluation and outcome tracking on completed transaction deposits

* refactor(api): extract audit entry model to domain layer to prevent import cycle

* feat(api): add jti claim to access tokens for unique token minting

* db(migrations): add 057_create_tool_invocations for tool audit logging

* feat(api): add tool audit domain, repository, service, handlers, and proxy routes

* feat(intelligence): implement Prometheus tool execution loop, tool registry, cost governor, and audit client

* feat(dapp): add interactive tool confirmation flow to Prometheus chatbot UI

* style: format code and sort imports across Python intelligence service and Go test files

* fix(api): add timezone field to UpdateProfileInput in UserService

Extend UpdateProfileInput struct with Timezone field to enable clean profile updates from auth handler during wallet verification.

* test(api): add unit test coverage for nudge rules, signals, and outcome recording

Add unit tests covering:
- Anti-fatigue cooldown limits and cap checks
- Static copy template formatting and facts mapping
- Priority scoring and ranking for candidate nudges
- Responsive window signal calculations
- Nudge outcome recorder (deposit, goal completion, return visit tracking)

* fix(intelligence): enforce strict numeric grounding on percentage values and add tests

Update validate_numeric_grounding guardrail:
- Treat percentage values (e.g., '8%') as fact-grounded regardless of digit count to prevent APY mismatches
- Add unit test suite for numeric grounding validation across dollar amounts, Naira figures, percentages, and prose integers

* feat(api): migrate refresh tokens to httpOnly secure cookies

* feat(frontend): adapt API client and auth store for httpOnly refresh cookies

* fix(intelligence): harden input screening against nested boundary tags

* fix(cmd,usersignal): fix vault lookup method in txPoller and remove unused import

Fix vault lookup in main.go transaction poller callback from GetByID to GetVault, and remove unused time import from usersignal interfaces.

* style(intelligence): format Python nudge models, router, and services

Clean up import order and apply ruff/black formatting across Python intelligence service nudge endpoints and functions.

* style: format code and sort imports across Python intelligence service and Go test files

* refactor(api/migrations): renumber search & activity migrations to 061-064

* refactor(intelligence): add strict type hints and defensive checks to tool handlers

* test(api): update auth_service_test for 3-tuple return from VerifyAndIssue

Update unit test assertions in auth_service_test.go to match the updated VerifyAndIssue signature returning (token, userID, err).

* style(intelligence): format Pydantic schema in nudge models

Format blank lines around Pydantic classes in nudge.py according to PEP 8 standards.

* refactor(intelligence): add strict type hints and defensive checks to tool handlers

* fix(intelligence): remove duplicate Any import

* feat(intelligence): secure nudge copy router with JWT auth and strong typing

Update nudge copy endpoint contract:
- Switch route authentication dependency from API key to JWT verification (verify_jwt)
- Update generate_nudge_copy service function to return strongly typed NudgeCopyResponse Pydantic models

* fix(intelligence): remove unused type ignores and add missing kwargs type

* fix(intelligence): source rebalance rationale model from settings

---------

Co-authored-by: 0xDeon <oluwadamilare_daniel@outlook.com>
Co-authored-by: G-ELM <alfygodwin@gmail.com>

* Feat/issues 943 944 945 946 (#969)

* test(api): add unit tests for protocoltvl model

- Add coverage for TVL delta computation
- Add tests for negative and zero TVL edge cases
- Test 24h change percentage calculations

* test(api): add unit tests for tvl model

- Add coverage for aggregation across protocols
- Test zero and negative TVL edge cases
- Test precision handling for USDC formatting

* feat(api): add vault capacity limits and soft-cap warnings

- Add SoftCapacity and CapacityWarningPct fields to Vault model
- Implement GetCapacityStatus() for API exposure
- Implement CanAcceptDeposit() to gate deposits at capacity
- Add ErrCapacityExceeded error type
- Add comprehensive tests for capacity status and gating

* feat(api): add harvest dry-run/simulation mode

- Add SimulateHarvest() method to harvest engine
- Returns expected gas cost and net yield without execution
- Integrates with existing gas estimation in gas.go
- Useful for user-facing harvest preview features

---------

Co-authored-by: meloball9993 <starmeloball9993@gmail.com>

* test(api): unit tests for apysnapshot model validation (#975)

apps/api/internal/domain/apysnapshot/model.go had no test coverage.
Adds model_test.go covering:

- Validate(): required protocol slug, non-negative APY/TVL, non-zero
  capture timestamp
- ByCapturedAt: chronological ordering of a snapshot slice
- DuplicateTimestamps: detecting repeated captured_at values within a
  protocol's snapshot history, which the (protocol_slug, captured_at)
  unique constraint should otherwise prevent from reaching storage
- error message assertions for ErrProtocolNotFound and
  ErrDuplicateSnapshot

Validate, ByCapturedAt, and DuplicateTimestamps are small additions to
model.go needed to give the requested validation/ordering/duplicate
tests something concrete to exercise at the domain layer, independent
of the Postgres repository.

Co-authored-by: AdaBliss <295242925+AdaBliss@users.noreply.github.com>

* fix: correct computePctChange for negative TVL values and fix precision test expectation (#977)

- computePctChange now treats negative current as zero and returns 0 for
  negative prior (avoid division-by-zero with negative denominator)
- Fix TestPrecisionHandling expected value: StringFixed(2) rounds half-up,
  so 1234.567890 -> 1234.57, not 1234.56

* feat(dapp): market sentiment component historical trend view (#978)

components/ai/marketSentiment.tsx showed current sentiment only. Adds a
small 7/30 day sparkline so users see the trend, not just a
point-in-time read.

- app/services/sentiment_history.py: records each successfully computed
  sentiment (signal + confidence) with a timestamp, backed by Redis
  when available (same pattern as coingecko.py's cache) with an
  in-memory fallback, retaining 30 days of points
- wire recording into prometheus.get_market_sentiment on its success
  path
- new endpoint GET /api/v1/market/sentiment/history?days=7|30 in
  analyze.py, clamped to [1, 30]
- dapp: intelligence.getMarketSentimentHistory(days) client method and
  a SentimentSparkline component in marketSentiment.tsx rendering an
  inline SVG confidence trend line with a 7d/30d toggle, colored by the
  most recent point's signal, with a graceful "not enough history yet"
  state when fewer than 2 points are available

Co-authored-by: AdaBliss <295242925+AdaBliss@users.noreply.github.com>

* feat(api): per-vault configurable harvest frequency (#974)

The harvest engine previously evaluated every vault on a single global
tick interval, so a small vault and a large one paid the same harvest
cadence regardless of their gas-cost tradeoffs. Vaults can now be
configured for daily or weekly harvesting.

- add harvest_frequency and last_harvested_at columns to vaults
  (migration 080), defaulting new vaults to daily
- add vault.ParseHarvestFrequency and a Repository.UpdateHarvestFrequency
  method
- gate the harvest engine's tick, TriggerVault and status/simulation paths
  on a new DueForHarvest check alongside the existing economic gate, and
  record last_harvested_at whenever a harvest is applied
- add a PATCH /api/v1/vaults/{id}/harvest-frequency endpoint, restricted
  to the vault owner

Complements the harvest engine from #845.

Co-authored-by: AdaBliss <295242925+AdaBliss@users.noreply.github.com>

* feat(contracts): add timelock-governed upgrade framework for Soroban contracts (#959)

* feat(contracts): implement secure timelock-governed upgrade framework

* fix(ci): build contract packages explicitly for WASM target

* fix(ci): strip CR/LF from extracted package name to fix WASM build loop

* fix(ci): use cargo metadata to enumerate workspace-member contracts for WASM build

* fix(api): fix TVL negative edge cases and precision truncation

- protocoltvl: add computePctChange() — clamps negative current to 0,
  returns 0 for negative/zero prior (undefined %). Add model_test.go.
- tvl: add FormatUSD() (truncate-2) and FormatUSDC() (truncate-6) to
  prevent rounding-up of displayed balances. Use them in tvl service.
  Add model_test.go covering TestPrecisionHandling.

* fix(api): move computePctChange to model_test.go to avoid redeclaration

---------

Co-authored-by: Hamfit <opefawazademolar@gmail.com>

* feat(api): Monte Carlo savings forecasting engine (#890)

* feat(api): Monte Carlo savings forecasting engine

Upgrade savings projections from a single deterministic point estimate to
a Monte Carlo forecast: thousands of randomized paths over the horizon
varying yield (grounded in a vault's real historical APY volatility) and
contribution behavior (grounded in the user's own active savings
schedule, with a documented new-user prior), reporting a P10/P50/P90
band and a goal-success probability plus a deposit/deadline sensitivity
grid whose "more deposit never lowers success probability" guarantee is
an exact structural property (common random numbers), not statistical.

- internal/domain/projection/simulation.go: pure Monte Carlo engine
  (RunMonteCarloSimulation, SensitivityGrid, DeriveSeed, MeanStdDev) and
  supporting types, carried over from a prior session and left
  unmodified except for adding SimulationOutput.ContributionSource.
- internal/service/projection_simulation.go: SimulateVaultProjection
  resolves real APY history/schedule data, derives a stable seed, and
  caches results in a small in-process TTL cache (5 min window).
- internal/handler/projection_handler.go: new authenticated
  POST /api/v1/tools/simulation endpoint.
- cmd/api/main.go: wires the savings goal/schedule repos into
  ProjectionService.
- internal/domain/projection/README.md: documents every distributional
  assumption (yield model, contribution/skip model + new-user prior,
  path count rationale, RNG seeding/caching scheme).
- calculator_test.go: percentile stability across runs with the same
  seed, zero-volatility collapse to the deterministic projection,
  goal-success probability against a hand-computed case, and
  sensitivity-grid deposit monotonicity.
- Frontend: lib/api/projection.ts gains typed simulation types/client;
  savings-calculator.tsx renders the P10/P90 band + P50 line and a
  goal-success probability tile alongside the existing deterministic
  chart.

Closes #843

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(api): bound Monte Carlo simulation horizon to fix CodeQL memory-exhaustion alerts

CodeQL flagged two high-severity findings on this PR: make([][]float64,
months) and make([]PercentileTimelinePoint, months) in
RunMonteCarloSimulation size their allocation directly off the caller-
supplied PeriodMonths, with no upper bound. A caller (or a bug upstream)
supplying an extreme period_months value would drive an unbounded
allocation before any other check fires -- a memory-exhaustion DoS vector.

Adds MaxPeriodMonths (50 years) and:
  - SimulationInput.Validate rejects PeriodMonths/DeadlineMonths beyond it
    with a new ErrPeriodTooLong, so a caller gets a clear error instead of
    a silently truncated result.
  - RunMonteCarloSimulation also clamps to MaxPeriodMonths directly at the
    allocation site, as defense in depth for any caller that reaches it
    without going through Validate first.

Adds regression tests for both: TestSimulationInput_Validate_RejectsExcessivePeriod
and TestRunMonteCarloSimulation_ClampsExcessivePeriodMonths (the latter
passing quickly without an OOM is itself the assertion for a
2-billion-month input).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(api): use min() in make() calls to satisfy CodeQL flow analysis

* fix(api): guard against excessive PeriodMonths with early return instead of clamping

CodeQL's taint tracking for 'Slice memory allocation with excessive size
value' could not verify the defensive clamp (months = MaxPeriodMonths)
as a sufficient bounds check. Replacing with a guard clause
(months <= 0 || months > MaxPeriodMonths -> early return) makes the
invariant explicit: the make([]T, months) calls are only reachable when
months is already within [1, MaxPeriodMonths].

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>

* feat: add reconciliation engine foundation (#887)

Co-authored-by: kitWarse <278602811+kitWarse@users.noreply.github.com>

* feat: add time-series rollup store (#885)

Co-authored-by: kitWarse <278602811+kitWarse@users.noreply.github.com>

* fix: address review feedback - useMemo dependency, unused import and vars

* feat(api): yield APY snapshot anomaly flagging before ingestion

apysnapshot/model.go stores APY snapshots straight from the DeFiLlama
poller with no sanity check, so a bad upstream reading (oracle glitch,
scraping error, a genuinely manipulated pool) flows straight into vault
APY history and user-facing yield figures. This adds a guard that flags
implausible jumps before a snapshot is persisted, complementing the
oracle aggregation/failover work in #830.

- add apysnapshot.DetectAnomalousJump: compares a new snapshot's APY to
  the protocol's most recent prior reading and flags moves of more than
  AnomalyJumpMultiplier (3x) in either direction, skipping near-zero
  baselines (< 0.5%) where large percentage swings are normal noise
- add Flagged/FlagReason fields to APYSnapshot, persisted via migration
  081 (apy_snapshots.flagged, flag_reason)
- wire the guard into APYService.poll via a new flagIfAnomalous step
  that looks up the most recent snapshot within a 48h lookback window
  and flags (does not reject) the incoming snapshot, so a genuine
  market dislocation doesn't starve history/oracle failover of data

* feat: Claude rate-limit handling, retrieval tests, defillama staleness guard, AI opt-out

- #928: apps/intelligence/app/services/prometheus.py's stream_chat caught
  every Claude error identically (generic "trouble connecting" message).
  Added a specific anthropic.APIStatusError handler that distinguishes
  429 (rate-limited) / 529 (overloaded) with a clearer "receiving a lot
  of requests, try again shortly" message, while other API status errors
  and non-API exceptions keep the existing generic fallback. Neither
  chat.py nor ws_chat.py ever surfaced a raw 500 for this — stream_chat
  already caught everything — but the message didn't call out the
  specific, actionable rate-limit/overload case.
- #930: added tests/test_retrieval_relevance.py. retrieval.py's
  "relevance filtering" is intent-based section gating (route_query ->
  which of GOALS/TRANSACTIONS/YIELD_LANDSCAPE/POSITIONS get fetched),
  not numeric relevance scoring — tests confirm sections NOT matched by
  the query's intents are never even fetched (not just absent from
  output), plus the existing empty-result fallback behavior.
  tests/test_retrieval.py (from #852) already covered routing and basic
  empty-fallback; this fills the specific "sections excluded, not just
  empty" gap #930 asks for.
- #931: apps/intelligence/app/services/defillama.py already had a TTL
  cache; added the staleness guard the issue is actually about — every
  successful fetch also writes a long-TTL (24h) "last known good" copy,
  and a live-fetch failure after the short-TTL entry has expired now
  serves that stale copy instead of an empty list, so a DefiLlama
  outage degrades to slightly-stale yield data instead of no data.
- #935: apps/api/internal/service/goal_coaching_scheduler.go's weekly
  AI goal-coaching job iterated every active goal and called the
  intelligence service unconditionally — no opt-out check at all,
  unlike nudge_engine_service.go's existing NudgesEnabled gate for
  generic nudges. Added the same nudge.PreferenceChecker gate before
  any intelligence-service call, and added an explicit
  ai_insights_enabled field to CoachingRequest (both Go and the
  intelligence service's Pydantic model) so the intelligence service
  also refuses to generate content when told a user opted out —
  enforcement independent of the caller, not just "trust the API
  already checked." Defaults to enabled so on-demand (user-initiated)
  coaching requests, which opt-out doesn't apply to, are unaffected.

Verification notes:
- Python (apps/intelligence): full suite run locally in a fresh uv venv
  — 212 passed, including all new/changed tests.
- Go (apps/api): no Go toolchain was available in the environment this
  was authored in, so main.go / goal_coaching_scheduler.go / model.go
  and their test changes could not be compiled or run locally —
  reviewed by hand for signature/interface consistency (nudgeHistoryRepo
  already implements nudge.PreferenceChecker; the *T-vs-T receiver on
  the new recordingGoalCoachingClient test double satisfies the
  GoalCoachingClient interface). Please confirm via CI or a local
  `go build ./... && go test ./...` before merging.

Closes #928
Closes #930
Closes #931
Closes #935

* feat(api): typed GetOrCompute cache layer with single-flight and stale-while-revalidate (#827)

Adds a generic Redis-backed cache (internal/cache) sitting in front of any
compute function: in-process single-flight collapses concurrent same-key
misses to one compute regardless of Redis, a best-effort cross-process Redis
lock reduces duplicate work across instances, TTLs are jittered to avoid
synchronized expiry, and soft/hard TTLs enable serve-stale-while-revalidate
(a stale value is returned immediately while a background refresh runs).
Namespace-scoped Invalidate targets a single key; a nil Redis client
degrades the cache to in-process-only behavior rather than failing.

closes #827

* feat(api): horizontally-scalable WebSocket layer with Redis pub/sub fan-out (#828)

Extends the WebSocket hub so events reach connected clients regardless of
which API instance holds their socket or produced the event: each instance
publishes broadcast events to Redis pub/sub and re-injects events received
from other instances into its own local delivery path, skipping its own
echoed publishes via an origin-instance tag. Per-topic Redis subscriptions
are reference-counted against local subscriber counts so an instance only
subscribes to channels its own clients actually need, and are released on
the last local unsubscribe or on graceful shutdown.

Adds Redis-backed presence tracking with a heartbeat-refreshed TTL so a
crashed instance's presence entries self-expire rather than lingering.
Adds per-IP connection limits (429 on exceeding the configured cap) and
keeps the existing slow-client backpressure (disconnect on a full send
buffer) intact. All of this degrades to the pre-existing single-instance
in-process-only behavior when no Redis client is configured (nil-safe
throughout, matching the codebase's existing dual-mode convention for
middleware.NewLimiter).

Tests include a real two-Redis-sharing two-Hub cross-instance delivery test,
an own-event-not-double-delivered test, a reconnect-moves-subscriptions
test, a cross-instance presence test, a slow-client disconnect test, and a
per-IP limit rejection test — all passing against a real Redis instance.

closes #828

* feat(api): multi-channel notification service with categories, preferences, dedup and delivery tracking (#829)

Adds a suppressibility Category (safety/transactional/promotional) per
EventType: safety notifications always bypass preference and rate-limit
checks (a breaker trip must never be silently opted out of), promotional
fully honors opt-out, transactional sits in between. Preferences can now be
resolved per-category via an optional CategoryPreferenceStore seam (a
Postgres-backed GetForCategory/SetCategoryOverride is added to
NotificationRepository, storing overrides in a new category_overrides JSONB
column added by migration 069) while stores that only implement the
existing flat PreferenceStore keep working unchanged.

Adds dedup (in-memory and Redis-backed, SET-NX-EX) and per-user-per-category
rate limiting (reusing middleware.NewLimiter's existing dual-mode Redis/
in-process pattern) — both suppress a Send while still persisting the
notification with a recorded SuppressedReason, so a suppressed message is
auditable rather than silently dropped. A suppressed or delivered
notification's outcome is tracked per channel (Delivered/Error/IsFallback)
via a new optional DeliveryOutcomeRecorder seam, with a Push/Email failure
falling back to WebSocket delivery (deduped against a WebSocket delivery
already in that event's normal channel matrix). Failed Email/Push
deliveries enqueue a durable retry job through the existing job queue
(jobqueue.Client); the job handler redelivers via the specific channel that
failed. Dispatcher.Stats() exposes per-category attempted/delivered/failed/
suppressed counts for a metrics endpoint.

Also fixes the stale "TODO: Fix interface implementation" in main.go that
had left NewWebSocketChannel commented out — WebSocketHub's PushToUser
signature already matched Hub's once #828's hub.go changes landed, so
in-app websocket delivery through the notification dispatcher is now
actually wired, not just persisted-and-discarded.

Deliberately deferred (disclosed rather than silently skipped): HTTP
handler/frontend surface for editing category preferences (the existing
flat-preference handler/settings page is unchanged); migrating
goal_milestone_notifier's own notified_milestones dedup onto the new
generic Deduplicator (that table is a permanent, non-windowed,
correctness-sensitive dedup — migrating it is exactly the kind of
unreviewed, regression-risk change this PR intentionally avoids); real SMTP/
push provider integrations (the existing MailSender/PushSender seams and
their Noop/Recording implementations are unchanged).

Also fixes CI: the api job's Redis service only exported REDIS_URL, but
every Redis-backed test (existing internal/cache tests included) skips via
REDIS_ADDR per the established convention, so these tests have been
silently skipping in CI. Sets REDIS_ADDR alongside REDIS_URL.

closes #829

* fix(ci): export REDIS_ADDR alongside REDIS_URL so Redis-backed tests actually run

The api job's redis service was only exposed to tests via REDIS_URL, but
every Redis-backed test in this codebase (internal/cache, internal/
middleware's rate limiter, and this PR's internal/ws and internal/
notifications tests) skips via t.Skip when REDIS_ADDR specifically is
unset. That means these tests have been silently skipping in CI even
though a real Redis service was running right next to them the whole time.

* feat(api): oracle aggregation layer with multi-source consensus and failover (#830)

Adds Aggregate: queries every healthy registered source for a data type in
parallel (each bounded by a per-source timeout so one slow source cannot
stall the result), then reconciles responses via median-with-deviation-band
outlier rejection — a source more than MaxDeviationBPS from the pre-filter
median is discarded before the final median is recomputed from survivors,
so a single bad or manipulated print cannot move the consensus. Returns
Unavailable only when zero sources respond; a lone responding source below
MinAgreeingSources still produces a value (preserving the existing
priority-failover availability guarantee) but with reduced Confidence
rather than a blind pass-through, so a caller that needs full consensus can
gate on Confidence instead of merely on "a value came back".

Adds HealthTracker: per-source consecutive-failure count, last error, and
an exponential backoff window (5s base, doubling per consecutive failure,
capped at 5m) during which a source is skipped rather than queried on every
request; a success clears the failure history immediately.

Wires this into RateService.fetchXLM (internal/oracle/service.go),
replacing the previous "try providers in priority order, first success
wins" loop with real two-source (Horizon, DeFiLlama) consensus — the
existing XLM sanity-bounds check is kept as a second, independent defense
against every source being corrupted in the same direction, which
deviation-band rejection alone can't catch. ExchangeRate gains Confidence
and SourcesUsed fields (empty/zero for rates that don't go through the
aggregator, e.g. the fixed USDC/USD peg) and a MeetsConfidenceThreshold
helper for downstream consumers to gate on. All existing service_test.go
cases pass unchanged, including the priority-style single-surviving-source
assertions (SourceName() reports a lone source's own name, matching the
prior Source field behavior exactly, and only joins names when more than
one source genuinely agreed).

Deliberately deferred (disclosed rather than silently skipped): a second
independent source for TVL and for the DeFiLlama-sourced portions of the
APY pipeline (apy_service.go / apy_refresh.go) — those currently have only
one real external provider each in this codebase, and standing up a second
genuine external data provider integration is out of scope here; migrating
risk_service.go and the on-chain attestation signer (a separate contracts
repo) onto Confidence gating. One added cost worth flagging: because
Aggregate queries every healthy source in parallel rather than stopping at
the first success, DeFiLlama is now called on every XLM/USD refresh even
when Horizon succeeds, not only as a fallback.

Tests cover: agreeing sources produce the correct median consensus; a
wildly-off outlier is rejected without moving the consensus; all-but-one
source down yields a value with reduced (not zero, not full) confidence
rather than Unavailable; every source down is Unavailable; a slow source
times out without stalling the result; a source is skipped once unhealthy
and re-probed after its backoff window elapses; backoff grows with
consecutive failures; a success clears failure history; confidence decay
for staleness reaches exactly half at maxAge.

closes #830

* feat(intelligence): add per-user AI tone/style preference (#927)

* feat(intelligence): add explainability trace for AI-suggested actions (#925)

* feat(api): add soft-delete with recovery window for savings goals (#924)

* fix(api): prevent duplicate deadline reminders across timezones (#923)

* feat: contribution limits, admin goal templates, and calculator export

- api: add optional min/max per-contribution limits on savings goals,
  validated at deposit time in DepositSplit (savingsgoal/model.go)
- api: let admins publish/edit/remove curated savings goal templates via
  domain/admin, growing the catalog beyond the pre-built #778 defaults
  without a redeploy
- dapp: add CSV/PDF export to the savings calculator, reusing the existing
  lib/export utilities

Closes #918
Closes #919
Closes #922

* fix(api): restore soft-delete code erased by merge 9c30de1 (#1000)

* fix(api): restore soft-delete code erased by merge 9c30de1 (#994)

Merge 9c30de1 (via PR #985) resolved conflicts in the savings g…
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(contracts): reentrancy and cross-contract call guard framework in libs/common

2 participants