Skip to content
Open
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
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,23 @@ To report a security vulnerability, see `security/bounty.md` or email `security@

Mainnet addresses are published after audit completion. Testnet addresses are in `deployments/testnet.json`.

## Testnet deployment, reconciliation & demo

`scripts/` (issue #145) holds the tooling that deploys this stack to
Soroban testnet, reconciles on-chain state against expected outcomes, and
runs an end-to-end deposit → harvest → withdraw demo through the real SDK:

```bash
cd scripts && pnpm install
./deploy-testnet.sh # populates deployments/testnet.json
DEMO_SECRET_KEY=S... pnpm demo # deposit -> harvest -> withdraw walkthrough
RECONCILE_READER_PUBLIC_KEY=G... pnpm reconcile # balance/shares/harvest-split cross-checks
RECONCILE_READER_PUBLIC_KEY=G... pnpm report # TVL-vs-cap and harvest-cadence health
```

See `scripts/README.md` for the full reproducible walkthrough, required
environment variables, and what each check covers.

## SDK

```typescript
Expand Down
14 changes: 13 additions & 1 deletion contracts/harvester/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,16 @@
#![no_std]
use soroban_sdk::{contract, contractimpl, contracttype, token, Address, Env};
use soroban_sdk::{contract, contractimpl, contracttype, symbol_short, token, Address, Env, Symbol};

const BOUNTY_BPS: i128 = 10;
const BPS_DENOMINATOR: i128 = 10_000;

/// Event topic (issue #145) — data is `(harvested, bounty, remainder, caller)`.
/// A zero-yield harvest (see the early-return below) still emits this event
/// with `harvested == 0` so reconciliation can distinguish "harvest ran and
/// found nothing" from "harvest never ran" without re-deriving it from
/// `last_harvest`/`next_harvest_ledger` alone.
const TOPIC_HARVEST: Symbol = symbol_short!("harvest");

#[contracttype]
pub enum DataKey {
Strategy,
Expand Down Expand Up @@ -83,6 +90,8 @@ impl Harvester {
.instance()
.set(&DataKey::LastHarvestLedger, &current);
env.storage().instance().extend_ttl(17_280, 17_280);
env.events()
.publish((TOPIC_HARVEST, caller), (0i128, 0i128, 0i128));
return 0;
}

Expand Down Expand Up @@ -112,6 +121,9 @@ impl Harvester {
.set(&DataKey::LastHarvestLedger, &current);
env.storage().instance().extend_ttl(17_280, 17_280);

env.events()
.publish((TOPIC_HARVEST, caller), (harvested, bounty, remainder));

harvested
}

Expand Down
36 changes: 28 additions & 8 deletions contracts/vault_flex/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ const STRATEGY_KEY: Symbol = symbol_short!("strategy");
const TOTAL_SHARES_KEY: Symbol = symbol_short!("t_shares");
const TOTAL_BALANCE_KEY: Symbol = symbol_short!("t_bal");

// Event topics (issue #145) — "early_exit" exceeds the 9-char symbol_short!
// limit, so its topic is built with Symbol::new at the call site instead.
const TOPIC_DEPOSIT: Symbol = symbol_short!("deposit");
const TOPIC_WITHDRAW: Symbol = symbol_short!("withdraw");

#[derive(Clone)]
#[contracttype]
pub enum DataKey {
Expand Down Expand Up @@ -61,6 +66,9 @@ impl VaultFlex {

// Audit M-01 Safeguard: Log checkpoint rule delay sequence via shared infrastructure
record_deposit_checkpoint(&env, &user, &asset);

env.events()
.publish((TOPIC_DEPOSIT, user, asset), amount);
}

/// Withdraw `amount` from the caller's Flex position for `asset`. Only
Expand All @@ -82,6 +90,26 @@ impl VaultFlex {
/// Flex now tracks its own principal balance exactly like the locked
/// tiers instead.
pub fn withdraw(env: Env, user: Address, asset: Address, amount: i128) -> i128 {
let payout = Self::do_withdraw(&env, &user, &asset, amount);
env.events()
.publish((TOPIC_WITHDRAW, user, asset), payout);
payout
}

/// Flex has no lock period, so early exit is equivalent to a plain
/// withdrawal: no fee, no maturity check. Kept as a distinct entry
/// point so VaultRouter's early_exit forwarding works uniformly across
/// all four tiers, and so it emits its own "early_exit" event distinct
/// from "withdraw" (issue #145 — reconciliation/observability needs to
/// tell the two apart even though the payout math is identical here).
pub fn early_exit(env: Env, user: Address, asset: Address, amount: i128) -> i128 {
let payout = Self::do_withdraw(&env, &user, &asset, amount);
env.events()
.publish((Symbol::new(&env, "early_exit"), user, asset), payout);
payout
}

fn do_withdraw(env: &Env, user: &Address, asset: &Address, amount: i128) -> i128 {
let admin: Address = env.storage().instance().get(&ADMIN_KEY).expect("Uninitialized");
admin.require_auth();

Expand Down Expand Up @@ -117,14 +145,6 @@ impl VaultFlex {
amount
}

/// Flex has no lock period, so early exit is equivalent to a plain
/// withdrawal: no fee, no maturity check. Kept as a distinct entry
/// point so VaultRouter's early_exit forwarding works uniformly across
/// all four tiers.
pub fn early_exit(env: Env, user: Address, asset: Address, amount: i128) -> i128 {
Self::withdraw(env, user, asset, amount)
}

/// Flex has no lock; always returns 0. Present for ABI parity with the
/// locked tiers so all four vaults share an identical signature set.
pub fn lock_until(_env: Env, _user: Address, _asset: Address) -> u32 {
Expand Down
33 changes: 29 additions & 4 deletions contracts/vault_l12/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
#![no_std]
use soroban_sdk::{contract, contracterror, contractimpl, contracttype, panic_with_error, Address, Env};
use soroban_sdk::{
contract, contracterror, contractimpl, contracttype, panic_with_error, symbol_short, Address,
Env, Symbol,
};

// Event topics (issue #145) — "early_exit" exceeds the 9-char symbol_short!
// limit, so its topic is built with Symbol::new at the call site instead.
const TOPIC_DEPOSIT: Symbol = symbol_short!("deposit");
const TOPIC_WITHDRAW: Symbol = symbol_short!("withdraw");

#[contracterror]
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
Expand Down Expand Up @@ -97,24 +105,34 @@ impl VaultL12 {
env.storage().persistent().set(&DataKey::LockUntil(user.clone(), asset.clone()), &lock_until);
let checkpoint = env.ledger().sequence() + 1;
env.storage().persistent().set(&DataKey::Checkpoint(user.clone(), asset.clone()), &checkpoint);

env.events()
.publish((TOPIC_DEPOSIT, user, asset), amount);
}

/// Withdraw `amount` from a matured position for `asset`.
pub fn withdraw(env: Env, user: Address, asset: Address, amount: i128) -> i128 {
let payout = Self::do_withdraw(&env, &user, &asset, amount);
env.events()
.publish((TOPIC_WITHDRAW, user, asset), payout);
payout
}

fn do_withdraw(env: &Env, user: &Address, asset: &Address, amount: i128) -> i128 {
let admin: Address = env.storage().instance().get(&DataKey::Admin).expect("not initialized");
admin.require_auth();

let lock_until: u32 = env.storage().persistent().get(&DataKey::LockUntil(user.clone(), asset.clone())).unwrap_or(0);
if env.ledger().sequence() < lock_until {
panic_with_error!(&env, VaultError::LockNotExpired);
panic_with_error!(env, VaultError::LockNotExpired);
}

let balance: i128 = env.storage().persistent().get(&DataKey::Balance(user.clone(), asset.clone())).unwrap_or(0);
let user_shares: i128 = env.storage().persistent().get(&DataKey::Shares(user.clone(), asset.clone())).unwrap_or(0);
let total_shares: i128 = env.storage().instance().get(&DataKey::TotalShares).unwrap_or(0);

if amount > balance {
panic_with_error!(&env, VaultError::AmountExceedsBalance);
panic_with_error!(env, VaultError::AmountExceedsBalance);
}

if amount >= balance {
Expand All @@ -140,6 +158,13 @@ impl VaultL12 {

/// Early exit `amount` before maturity for `asset`.
pub fn early_exit(env: Env, user: Address, asset: Address, amount: i128) -> i128 {
let net_amount = Self::do_early_exit(&env, &user, &asset, amount);
env.events()
.publish((Symbol::new(&env, "early_exit"), user, asset), net_amount);
net_amount
}

fn do_early_exit(env: &Env, user: &Address, asset: &Address, amount: i128) -> i128 {
let admin: Address = env.storage().instance().get(&DataKey::Admin).expect("not initialized");
admin.require_auth();

Expand All @@ -148,7 +173,7 @@ impl VaultL12 {
let total_shares: i128 = env.storage().instance().get(&DataKey::TotalShares).unwrap_or(0);

if amount > balance {
panic_with_error!(&env, VaultError::AmountExceedsBalance);
panic_with_error!(env, VaultError::AmountExceedsBalance);
}

// Exit fee: 2.50% on withdrawn amount only
Expand Down
33 changes: 29 additions & 4 deletions contracts/vault_l3/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
#![no_std]
use soroban_sdk::{contract, contracterror, contractimpl, contracttype, panic_with_error, Address, Env};
use soroban_sdk::{
contract, contracterror, contractimpl, contracttype, panic_with_error, symbol_short, Address,
Env, Symbol,
};

// Event topics (issue #145) — "early_exit" exceeds the 9-char symbol_short!
// limit, so its topic is built with Symbol::new at the call site instead.
const TOPIC_DEPOSIT: Symbol = symbol_short!("deposit");
const TOPIC_WITHDRAW: Symbol = symbol_short!("withdraw");

#[contracterror]
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
Expand Down Expand Up @@ -129,6 +137,9 @@ impl VaultL3 {

let checkpoint = env.ledger().sequence() + 1;
env.storage().persistent().set(&DataKey::Checkpoint(user.clone(), asset.clone()), &checkpoint);

env.events()
.publish((TOPIC_DEPOSIT, user, asset), amount);
}

/// Withdraw `amount` from a matured position for `asset`.
Expand All @@ -138,6 +149,13 @@ impl VaultL3 {
/// reduces Balance/Shares, leaves LockUntil/Checkpoint untouched.
/// - If `amount > balance`: rejected with `AmountExceedsBalance`.
pub fn withdraw(env: Env, user: Address, asset: Address, amount: i128) -> i128 {
let payout = Self::do_withdraw(&env, &user, &asset, amount);
env.events()
.publish((TOPIC_WITHDRAW, user, asset), payout);
payout
}

fn do_withdraw(env: &Env, user: &Address, asset: &Address, amount: i128) -> i128 {
let admin: Address = env.storage().instance().get(&DataKey::Admin).expect("not initialized");
admin.require_auth();

Expand All @@ -150,7 +168,7 @@ impl VaultL3 {
if !emergency {
let lock_until: u32 = env.storage().persistent().get(&DataKey::LockUntil(user.clone(), asset.clone())).unwrap_or(0);
if env.ledger().sequence() < lock_until {
panic_with_error!(&env, VaultError::LockNotExpired);
panic_with_error!(env, VaultError::LockNotExpired);
}
}

Expand All @@ -159,7 +177,7 @@ impl VaultL3 {
let total_shares: i128 = env.storage().instance().get(&DataKey::TotalShares).unwrap_or(0);

if amount > balance {
panic_with_error!(&env, VaultError::AmountExceedsBalance);
panic_with_error!(env, VaultError::AmountExceedsBalance);
}

if amount >= balance {
Expand Down Expand Up @@ -192,6 +210,13 @@ impl VaultL3 {
/// - If `amount < balance`: partial early exit, remainder stays.
/// - If `amount > balance`: rejected with `AmountExceedsBalance`.
pub fn early_exit(env: Env, user: Address, asset: Address, amount: i128) -> i128 {
let net_amount = Self::do_early_exit(&env, &user, &asset, amount);
env.events()
.publish((Symbol::new(&env, "early_exit"), user, asset), net_amount);
net_amount
}

fn do_early_exit(env: &Env, user: &Address, asset: &Address, amount: i128) -> i128 {
let admin: Address = env.storage().instance().get(&DataKey::Admin).expect("not initialized");
admin.require_auth();

Expand All @@ -200,7 +225,7 @@ impl VaultL3 {
let total_shares: i128 = env.storage().instance().get(&DataKey::TotalShares).unwrap_or(0);

if amount > balance {
panic_with_error!(&env, VaultError::AmountExceedsBalance);
panic_with_error!(env, VaultError::AmountExceedsBalance);
}

let emergency: bool = env
Expand Down
33 changes: 29 additions & 4 deletions contracts/vault_l6/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
#![no_std]
use soroban_sdk::{contract, contracterror, contractimpl, contracttype, panic_with_error, Address, Env};
use soroban_sdk::{
contract, contracterror, contractimpl, contracttype, panic_with_error, symbol_short, Address,
Env, Symbol,
};

// Event topics (issue #145) — "early_exit" exceeds the 9-char symbol_short!
// limit, so its topic is built with Symbol::new at the call site instead.
const TOPIC_DEPOSIT: Symbol = symbol_short!("deposit");
const TOPIC_WITHDRAW: Symbol = symbol_short!("withdraw");

#[contracterror]
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
Expand Down Expand Up @@ -97,24 +105,34 @@ impl VaultL6 {
env.storage().persistent().set(&DataKey::LockUntil(user.clone(), asset.clone()), &lock_until);
let checkpoint = env.ledger().sequence() + 1;
env.storage().persistent().set(&DataKey::Checkpoint(user.clone(), asset.clone()), &checkpoint);

env.events()
.publish((TOPIC_DEPOSIT, user, asset), amount);
}

/// Withdraw `amount` from a matured position for `asset`.
pub fn withdraw(env: Env, user: Address, asset: Address, amount: i128) -> i128 {
let payout = Self::do_withdraw(&env, &user, &asset, amount);
env.events()
.publish((TOPIC_WITHDRAW, user, asset), payout);
payout
}

fn do_withdraw(env: &Env, user: &Address, asset: &Address, amount: i128) -> i128 {
let admin: Address = env.storage().instance().get(&DataKey::Admin).expect("not initialized");
admin.require_auth();

let lock_until: u32 = env.storage().persistent().get(&DataKey::LockUntil(user.clone(), asset.clone())).unwrap_or(0);
if env.ledger().sequence() < lock_until {
panic_with_error!(&env, VaultError::LockNotExpired);
panic_with_error!(env, VaultError::LockNotExpired);
}

let balance: i128 = env.storage().persistent().get(&DataKey::Balance(user.clone(), asset.clone())).unwrap_or(0);
let user_shares: i128 = env.storage().persistent().get(&DataKey::Shares(user.clone(), asset.clone())).unwrap_or(0);
let total_shares: i128 = env.storage().instance().get(&DataKey::TotalShares).unwrap_or(0);

if amount > balance {
panic_with_error!(&env, VaultError::AmountExceedsBalance);
panic_with_error!(env, VaultError::AmountExceedsBalance);
}

if amount >= balance {
Expand All @@ -140,6 +158,13 @@ impl VaultL6 {

/// Early exit `amount` before maturity for `asset`.
pub fn early_exit(env: Env, user: Address, asset: Address, amount: i128) -> i128 {
let net_amount = Self::do_early_exit(&env, &user, &asset, amount);
env.events()
.publish((Symbol::new(&env, "early_exit"), user, asset), net_amount);
net_amount
}

fn do_early_exit(env: &Env, user: &Address, asset: &Address, amount: i128) -> i128 {
let admin: Address = env.storage().instance().get(&DataKey::Admin).expect("not initialized");
admin.require_auth();

Expand All @@ -148,7 +173,7 @@ impl VaultL6 {
let total_shares: i128 = env.storage().instance().get(&DataKey::TotalShares).unwrap_or(0);

if amount > balance {
panic_with_error!(&env, VaultError::AmountExceedsBalance);
panic_with_error!(env, VaultError::AmountExceedsBalance);
}

// Exit fee: 1.25% on withdrawn amount only
Expand Down
27 changes: 27 additions & 0 deletions scripts/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Soroban RPC endpoint used by every script in this directory.
SOROBAN_RPC_URL=https://soroban-testnet.stellar.org

# Deposit-asset SAC (Stellar Asset Contract) id used for deposit/withdraw/
# reconciliation calls. On testnet this is whatever USDC-equivalent token
# scripts/deploy-testnet.sh wired up (see deployments/testnet.json's
# "assetContractId" note in scripts/README.md) — it is intentionally not
# hardcoded here since it can change on every testnet reset.
USDC_TOKEN_CONTRACT_ID=

# --- scripts/reconcile.ts, scripts/operational-report.ts ---
# Any existing testnet account's public key. Used only as a syntactically
# valid transaction source for read-only simulateTransaction calls — no
# signing, no funds needed, no state changes.
RECONCILE_READER_PUBLIC_KEY=

# --- scripts/deploy-testnet.sh ---
# Identity name registered with `stellar keys generate <name>` and funded
# via `stellar keys fund <name> --network testnet`. Becomes the admin/
# strategist/deployer for every contract this script initializes.
DEPLOYER_IDENTITY=yieldladder-deployer

# --- scripts/e2e-demo.ts ---
# Secret key of a funded testnet account holding the deposit asset. Signs
# transactions directly (Freighter can't be automated headlessly) via the
# same Signer interface a WalletAdapter uses — see scripts/README.md.
DEMO_SECRET_KEY=
Loading
Loading