Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,39 @@ Versioning follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Fixed

- `VaultRouter` <-> tier-vault ABI mismatch: `VaultFlex`, `VaultL3`, `VaultL6`,
and `VaultL12` now all expose the same `deposit(user, asset, amount)` /
`withdraw(user, asset, amount) -> i128` / `early_exit(user, asset, amount)
-> i128` / `balance(user, asset)` / `shares(user, asset)` /
`lock_until(user, asset)` signatures that `VaultRouter` already called
with, and `relock(user, asset)` (both on the router and on the locked
tier vaults) so it isn't ambiguous once locks are asset-scoped.
- Every tier vault's storage (`Balance`, `Shares`, `LockUntil`, `Checkpoint`,
and VaultL3's `EmergencyUnlock`) is now keyed by `(user, asset)` instead
of `user` alone, so two different assets held by the same user in the
same tier no longer share balance, lock, or checkpoint state.
`shared::checkpoint` is asset-scoped for the same reason.
- Removed a duplicate token transfer inside each tier vault's `deposit()`:
`VaultRouter` already moves the tokens from the user to the vault before
invoking it, so the vault's own additional `user -> strategy` transfer
(which also hardcoded the tier's original single USDC address regardless
of which `asset` was actually deposited) double-charged the user and used
the wrong token for any non-USDC asset. Tier vaults are now bookkeeping-
only; VaultRouter owns all token movement.
- `VaultFlex` now supports partial withdrawal with the same semantics as
the locked tiers, and gained `early_exit`/`lock_until` for ABI parity.
Its previous pro-rata yield payout depended on an externally supplied
`strategy_balance` argument that the router can no longer provide per
the unified signature; `VaultFlex` now tracks its own principal balance
like the locked tiers (see the doc comment on `VaultFlex::withdraw`).

### Added

- Integration tests in `vault_router/tests/` that exercise `VaultRouter`
against the real `VaultFlex`/`VaultL3`/`VaultL6`/`VaultL12` contracts
(not the unit-test `MockVault`) across two allowlisted assets.
- Cargo workspace with contract stubs for all eight vault roles.
- Next.js dashboard scaffold.
- TypeScript SDK skeleton.
Expand Down
7 changes: 7 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

22 changes: 13 additions & 9 deletions contracts/shared/src/checkpoint.rs
Original file line number Diff line number Diff line change
@@ -1,17 +1,21 @@
use soroban_sdk::{Env, Address, symbol_short, Symbol};
use soroban_sdk::{Env, Address};

const LEDGER_KEY: Symbol = symbol_short!("chkpnt");

pub fn record_deposit_checkpoint(env: &Env, user: &Address) {
/// Record a deposit checkpoint for a specific (user, asset) position.
///
/// Asset-scoped so that a user holding positions in two different assets in
/// the same tier vault doesn't share a single eligibility window between
/// them (issue #138).
pub fn record_deposit_checkpoint(env: &Env, user: &Address, asset: &Address) {
// Audit M-01 Fix: yield accrual window activates on the NEXT ledger sequence
let next_eligible_ledger = env.ledger().sequence().checked_add(1).expect("Ledger max seq overflow");
env.storage().persistent().set(&user, &next_eligible_ledger);
env.storage().persistent().set(&(user.clone(), asset.clone()), &next_eligible_ledger);
}

pub fn is_eligible_for_yield(env: &Env, user: &Address) -> bool {
if !env.storage().persistent().has(user) {
pub fn is_eligible_for_yield(env: &Env, user: &Address, asset: &Address) -> bool {
let key = (user.clone(), asset.clone());
if !env.storage().persistent().has(&key) {
return false;
}
let activation_ledger: u32 = env.storage().persistent().get(user).unwrap();
let activation_ledger: u32 = env.storage().persistent().get(&key).unwrap();
env.ledger().sequence() >= activation_ledger
}
}
30 changes: 25 additions & 5 deletions contracts/shared/src/tests/checkpoint_tests.rs
Original file line number Diff line number Diff line change
@@ -1,19 +1,39 @@
#![cfg(test)]
use soroban_sdk::{Env, Address};
use soroban_sdk::{Env, Address, testutils::Address as _};
use crate::checkpoint::{record_deposit_checkpoint, is_eligible_for_yield};

#[test]
fn test_same_ledger_frontrunning_protection() {
let env = Env::default();
let user = Address::generate(&env);
let asset = Address::generate(&env);

env.ledger().set_sequence(10050);
record_deposit_checkpoint(&env, &user);
record_deposit_checkpoint(&env, &user, &asset);

// Audit M-01 verification checkpoint: Depositing inside active sequence block MUST yield ineligible for execution rewards
assert_eq!(is_eligible_for_yield(&env, &user), false);
assert_eq!(is_eligible_for_yield(&env, &user, &asset), false);

// Advance sequence frame explicitly to activate yield window eligibility
env.ledger().set_sequence(10051);
assert_eq!(is_eligible_for_yield(&env, &user), true);
}
assert_eq!(is_eligible_for_yield(&env, &user, &asset), true);
}

#[test]
fn test_checkpoint_is_scoped_per_asset() {
let env = Env::default();
let user = Address::generate(&env);
let asset_a = Address::generate(&env);
let asset_b = Address::generate(&env);

env.ledger().set_sequence(500);
record_deposit_checkpoint(&env, &user, &asset_a);

// A checkpoint recorded for asset_a must not make asset_b eligible.
assert_eq!(is_eligible_for_yield(&env, &user, &asset_a), false);
assert_eq!(is_eligible_for_yield(&env, &user, &asset_b), false);

env.ledger().set_sequence(501);
assert_eq!(is_eligible_for_yield(&env, &user, &asset_a), true);
assert_eq!(is_eligible_for_yield(&env, &user, &asset_b), false);
}
Loading
Loading