From 71c491df76c14be2926f310426cc6726548bcaca Mon Sep 17 00:00:00 2001 From: Omololu Aniyikaye Date: Mon, 24 Aug 2026 13:15:36 +0100 Subject: [PATCH] feat: Payments, Reconciliation, observability, and testnet demo tooling --- README.md | 17 +++ contracts/harvester/src/lib.rs | 14 +- contracts/vault_flex/src/lib.rs | 36 +++-- contracts/vault_l12/src/lib.rs | 33 ++++- contracts/vault_l3/src/lib.rs | 33 ++++- contracts/vault_l6/src/lib.rs | 33 ++++- scripts/.env.example | 27 ++++ scripts/README.md | 143 ++++++++++++++++++++ scripts/deploy-testnet.sh | 194 +++++++++++++++++++++++++++ scripts/package.json | 23 ++++ scripts/src/e2e-demo.ts | 120 +++++++++++++++++ scripts/src/env.ts | 84 ++++++++++++ scripts/src/operational-report.ts | 114 ++++++++++++++++ scripts/src/reconcile.core.ts | 212 ++++++++++++++++++++++++++++++ scripts/src/reconcile.test.ts | 177 +++++++++++++++++++++++++ scripts/src/reconcile.ts | 167 +++++++++++++++++++++++ scripts/src/rpc.ts | 109 +++++++++++++++ scripts/tsconfig.json | 14 ++ sdks/typescript/src/index.ts | 11 ++ 19 files changed, 1540 insertions(+), 21 deletions(-) create mode 100644 scripts/.env.example create mode 100644 scripts/README.md create mode 100755 scripts/deploy-testnet.sh create mode 100644 scripts/package.json create mode 100644 scripts/src/e2e-demo.ts create mode 100644 scripts/src/env.ts create mode 100644 scripts/src/operational-report.ts create mode 100644 scripts/src/reconcile.core.ts create mode 100644 scripts/src/reconcile.test.ts create mode 100644 scripts/src/reconcile.ts create mode 100644 scripts/src/rpc.ts create mode 100644 scripts/tsconfig.json diff --git a/README.md b/README.md index dd5b6b5..7f18c49 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/contracts/harvester/src/lib.rs b/contracts/harvester/src/lib.rs index 30809f1..d8e0370 100644 --- a/contracts/harvester/src/lib.rs +++ b/contracts/harvester/src/lib.rs @@ -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, @@ -83,6 +90,8 @@ impl Harvester { .instance() .set(&DataKey::LastHarvestLedger, ¤t); env.storage().instance().extend_ttl(17_280, 17_280); + env.events() + .publish((TOPIC_HARVEST, caller), (0i128, 0i128, 0i128)); return 0; } @@ -112,6 +121,9 @@ impl Harvester { .set(&DataKey::LastHarvestLedger, ¤t); env.storage().instance().extend_ttl(17_280, 17_280); + env.events() + .publish((TOPIC_HARVEST, caller), (harvested, bounty, remainder)); + harvested } diff --git a/contracts/vault_flex/src/lib.rs b/contracts/vault_flex/src/lib.rs index 83b7f5a..7ce31ca 100644 --- a/contracts/vault_flex/src/lib.rs +++ b/contracts/vault_flex/src/lib.rs @@ -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 { @@ -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 @@ -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(); @@ -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 { diff --git a/contracts/vault_l12/src/lib.rs b/contracts/vault_l12/src/lib.rs index 3a8811b..50aa7de 100644 --- a/contracts/vault_l12/src/lib.rs +++ b/contracts/vault_l12/src/lib.rs @@ -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)] @@ -97,16 +105,26 @@ 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); @@ -114,7 +132,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); } if amount >= balance { @@ -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(); @@ -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 diff --git a/contracts/vault_l3/src/lib.rs b/contracts/vault_l3/src/lib.rs index fdbf5e1..0102d79 100644 --- a/contracts/vault_l3/src/lib.rs +++ b/contracts/vault_l3/src/lib.rs @@ -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)] @@ -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`. @@ -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(); @@ -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); } } @@ -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 { @@ -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(); @@ -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 diff --git a/contracts/vault_l6/src/lib.rs b/contracts/vault_l6/src/lib.rs index e9c3f40..e19ab52 100644 --- a/contracts/vault_l6/src/lib.rs +++ b/contracts/vault_l6/src/lib.rs @@ -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)] @@ -97,16 +105,26 @@ 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); @@ -114,7 +132,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); } if amount >= balance { @@ -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(); @@ -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 diff --git a/scripts/.env.example b/scripts/.env.example new file mode 100644 index 0000000..d7d1766 --- /dev/null +++ b/scripts/.env.example @@ -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 ` and funded +# via `stellar keys fund --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= diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 0000000..e0b9123 --- /dev/null +++ b/scripts/README.md @@ -0,0 +1,143 @@ +# Testnet deployment, reconciliation & demo tooling + +Tooling for issue #145 (Payments 8/8): deploying the payment stack to +Soroban testnet, reconciling on-chain reality against expected outcomes, +reporting operational health, and running an end-to-end demo through the +real TypeScript SDK. + +## Setup + +```bash +cd scripts +pnpm install # or npm install +cp .env.example .env # then fill in the values below +``` + +| Variable | Used by | Notes | +| --- | --- | --- | +| `SOROBAN_RPC_URL` | all | defaults to the public testnet RPC | +| `USDC_TOKEN_CONTRACT_ID` | deploy, reconcile, report, demo | the deposit-asset SAC for this deployment | +| `RECONCILE_READER_PUBLIC_KEY` | reconcile, report | any existing testnet account; read-only, no funds needed | +| `DEPLOYER_IDENTITY` | deploy | a `stellar keys` identity, funded on testnet | +| `DEMO_SECRET_KEY` | demo | a funded testnet account holding the deposit asset | + +## 1. Deploy to testnet + +```bash +stellar keys generate "$DEPLOYER_IDENTITY" +stellar keys fund "$DEPLOYER_IDENTITY" --network testnet +./scripts/deploy-testnet.sh +``` + +Builds every contract, deploys VaultRouter, all four tier vaults, +StrategyVault, Harvester, Governance, and GuardianMultisig, initializes them +in dependency order, and writes real contract ids into +`deployments/testnet.json`, replacing the `CC_PENDING_TESTNET` placeholders. + +**Testnet resets:** Stellar testnet is periodically wiped. When that +happens every previously-deployed contract id in `deployments/testnet.json` +stops resolving. Recovery is just re-running `./scripts/deploy-testnet.sh` +— it always deploys and initializes fresh rather than trying to detect and +reuse old ids, so there's no special "reset mode" to remember. + +The script deploys a single-owner GuardianMultisig (`threshold=1`) for +testnet convenience. Replace that with the real multisig owner set before +any mainnet deployment. + +## 2. Run the end-to-end demo + +```bash +DEMO_SECRET_KEY=S... USDC_TOKEN_CONTRACT_ID=C... pnpm demo +``` + +Drives the real `@yieldladder/sdk` (issues #139/#141) against the +deployment from step 1: + +1. Deposits into Flex and L3 (2+ tiers), printing each `PaymentStatus` + transition as the SDK polls for confirmation. +2. Prints the `stellar contract invoke ... harvest` command to trigger a + harvest (the SDK's public surface is deposit/withdraw-focused; + Harvester's `harvest()` is permissionless and unrelated to a specific + depositor, so it's invoked directly rather than added to the SDK for + this one demo step). +3. Re-reads the Flex position so you can compare `accruedYield` before vs. + after the harvest. +4. Withdraws Flex and early-exits L3. +5. Prints final positions (both principals should be back to `0`) and the + exact `pnpm reconcile` command to confirm zero discrepancy. + +**On Freighter:** this script signs with a `Keypair` instead of a browser +extension, because Freighter can't be driven headlessly from Node. It +implements the SDK's `Signer` interface exactly (`signTransaction(xdr, +opts)`) — the same shape a WalletAdapter from issue #143 satisfies — so the +calls this script makes (`sdk.deposit(...)`, `sdk.withdraw(...)`, +`sdk.earlyExit(...)`) are identical to what the app makes through a +connected Freighter wallet. To demo it through Freighter instead, run the +app (`cd app && pnpm dev`), connect a testnet-funded Freighter wallet, and +repeat the same deposit → harvest → withdraw sequence through the UI. + +## 3. Reconcile + +```bash +RECONCILE_READER_PUBLIC_KEY=G... USDC_TOKEN_CONTRACT_ID=C... pnpm reconcile +``` + +Cross-checks: + +- **Per-asset balances**: sums deposit/withdraw/early_exit events (issue + #145's event-emission additions) per `(tier, asset)` and flags any that + goes negative — an impossible state that a tier-wide-only check would + miss if a shortfall in one asset were offset by a surplus in another. +- **Aggregate balance**: the same computed sums, totalled across assets per + tier, against that tier vault's on-chain `total_balance()` — the only + total the contracts track (there's no per-asset total on-chain, hence the + event-derived check above). +- **Harvest bounty split**: for every `harvest` event in the lookback + window, `bounty + remainder == harvested` (mirroring the invariant + unit-tested in isolation at `contracts/harvester/src/lib.rs:160-179`, + now checked against real chain state). A zero-yield harvest + (`harvested == 0`, `contracts/harvester/src/lib.rs:80-87`) is expected to + carry a zero bounty and is not flagged. +- **SDK vs. chain**: for every user seen in the event window (or passed via + `--users G...,G...`), compares `VaultRouter.position()` (what the SDK + reports) against the tier vault's own `balance()` getter (read directly). + +Exits non-zero if any mismatch is found — safe to wire into CI or a cron +job. The mismatch-detection logic itself (`src/reconcile.core.ts`) is pure +and covered by `pnpm test`, including an intentionally-injected mismatch, +so the detector's correctness doesn't depend on having a live deployment. + +**Emergency-unlock / paused windows:** neither is special-cased, by +design. A paused protocol has no new deposit events during the pause, so +the event-derived balance simply doesn't move — comparing reality against +reality (rather than against an "expected activity" model) means an idle +window during a pause produces zero mismatches on its own. Early-exit +events already carry the post-fee `net_amount` as their data (whether or +not emergency unlock waived the fee), so reconciliation never re-derives +fee logic itself. See the "paused window" and "zero-yield harvest" cases in +`src/reconcile.test.ts`. + +## 4. Operational health report + +```bash +RECONCILE_READER_PUBLIC_KEY=G... pnpm report +``` + +Answers "is the payment system healthy right now?" without reading +contract storage by hand: current TVL vs. cap per tier, and last-harvest +ledger vs. the expected cooldown-elapsed ledger. Run alongside `pnpm +reconcile` for the mismatch-detection half of that picture. + +## Known limitations + +- **Multi-asset event lookback**: `pnpm reconcile`'s event queries default + to roughly the last day of ledgers and rely on `getEvents`, which public + Soroban RPC providers only retain for a rolling window (commonly ~7 + days). A full-history reconciliation needs either a wider `--from-ledger` + (bounded by the provider's retention) or a persistent indexer ingesting + events as they're emitted — out of scope here; this tooling reconciles + the recent window, which is what a scheduled health check needs. +- **GuardianMultisig id**: `deployments/testnet.json`'s existing schema + (defined before this issue) doesn't have a slot for it. `deploy-testnet.sh` + prints it at the end of a run — record it yourself if you need to + reference it later (e.g. to call `set_emergency_unlock`). diff --git a/scripts/deploy-testnet.sh b/scripts/deploy-testnet.sh new file mode 100755 index 0000000..9508bf6 --- /dev/null +++ b/scripts/deploy-testnet.sh @@ -0,0 +1,194 @@ +#!/usr/bin/env bash +# +# Deploys the full payment stack (GuardianMultisig, StrategyVault, Harvester, +# all four tier vaults, VaultRouter, Governance) to Soroban testnet and +# populates deployments/testnet.json with the real contract ids, replacing +# the CC_PENDING_TESTNET placeholders (issue #145). +# +# Stellar testnet is periodically reset, wiping every previously-deployed +# contract. This script always deploys+initializes fresh rather than trying +# to detect and reuse "already deployed" ids from a prior deployments.json — +# after a reset those ids point at nothing, so a full rerun is the only +# state that's ever safe to assume. Re-running this script is exactly how +# you recover from a testnet reset. +# +# Usage: +# DEPLOYER_IDENTITY=yieldladder-deployer USDC_TOKEN_CONTRACT_ID=C... \ +# ./scripts/deploy-testnet.sh +# +# Prerequisites: +# - `stellar` CLI (https://developers.stellar.org/docs/tools/developer-tools/cli/install-cli) +# - `jq` +# - An identity registered and funded on testnet: +# stellar keys generate "$DEPLOYER_IDENTITY" +# stellar keys fund "$DEPLOYER_IDENTITY" --network testnet +# - USDC_TOKEN_CONTRACT_ID pointing at the deposit-asset SAC this +# deployment should use (see scripts/.env.example) — not deployed by +# this script, since which test token to use is an environment choice, +# not a protocol concern. + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +DEPLOYMENTS_FILE="$REPO_ROOT/deployments/testnet.json" +NETWORK="testnet" +IDENTITY="${DEPLOYER_IDENTITY:-yieldladder-deployer}" +COOLDOWN_LEDGERS="${HARVEST_COOLDOWN_LEDGERS:-120}" # ~10 minutes at 5s/ledger — short for demo iteration +DEFAULT_MAX_TVL="${DEFAULT_MAX_TVL:-10000000000000}" # 1,000,000 USDC (7 decimals), matches contract defaults + +for bin in stellar jq; do + if ! command -v "$bin" >/dev/null 2>&1; then + echo "error: '$bin' is required but not found on PATH." >&2 + exit 1 + fi +done + +if [ -z "${USDC_TOKEN_CONTRACT_ID:-}" ]; then + echo "error: USDC_TOKEN_CONTRACT_ID is not set. See scripts/.env.example." >&2 + exit 1 +fi +USDC="$USDC_TOKEN_CONTRACT_ID" + +DEPLOYER_ADDRESS="$(stellar keys address "$IDENTITY")" +echo "Deployer identity: $IDENTITY ($DEPLOYER_ADDRESS)" +echo "Deposit asset: $USDC" +echo + +echo "==> Building contracts" +stellar contract build + +deploy() { + local wasm_name="$1" + local wasm_path="$REPO_ROOT/target/wasm32-unknown-unknown/release/${wasm_name}.wasm" + stellar contract deploy \ + --wasm "$wasm_path" \ + --source "$IDENTITY" \ + --network "$NETWORK" \ + 2>/dev/null +} + +echo "==> Deploying contracts" +GUARDIAN_ID="$(deploy guardian_multisig)" +echo " GuardianMultisig: $GUARDIAN_ID" +STRATEGY_ID="$(deploy strategy_vault)" +echo " StrategyVault: $STRATEGY_ID" +HARVESTER_ID="$(deploy harvester)" +echo " Harvester: $HARVESTER_ID" +FLEX_ID="$(deploy vault_flex)" +echo " VaultFlex: $FLEX_ID" +L3_ID="$(deploy vault_l3)" +echo " VaultL3: $L3_ID" +L6_ID="$(deploy vault_l6)" +echo " VaultL6: $L6_ID" +L12_ID="$(deploy vault_l12)" +echo " VaultL12: $L12_ID" +ROUTER_ID="$(deploy vault_router)" +echo " VaultRouter: $ROUTER_ID" +GOVERNANCE_ID="$(deploy governance)" +echo " Governance: $GOVERNANCE_ID" +echo + +invoke() { + local contract_id="$1" + shift + stellar contract invoke \ + --id "$contract_id" \ + --source "$IDENTITY" \ + --network "$NETWORK" \ + -- "$@" +} + +echo "==> Initializing contracts (dependency order)" + +echo " GuardianMultisig.initialize (single-owner, threshold=1 — replace with the real guardian set before mainnet)" +invoke "$GUARDIAN_ID" initialize \ + --owners "[\"$DEPLOYER_ADDRESS\"]" \ + --threshold 1 + +echo " StrategyVault.initialize" +invoke "$STRATEGY_ID" initialize \ + --admin "$DEPLOYER_ADDRESS" \ + --usdc_token "$USDC" + +echo " Harvester.initialize (cooldown=${COOLDOWN_LEDGERS} ledgers)" +invoke "$HARVESTER_ID" initialize \ + --strategy "$STRATEGY_ID" \ + --usdc "$USDC" \ + --cooldown_ledgers "$COOLDOWN_LEDGERS" + +echo " VaultFlex.initialize" +invoke "$FLEX_ID" initialize \ + --admin "$ROUTER_ID" \ + --strategy "$STRATEGY_ID" + +echo " VaultL3.initialize (max_tvl=${DEFAULT_MAX_TVL})" +invoke "$L3_ID" initialize \ + --admin "$ROUTER_ID" \ + --governance "$GOVERNANCE_ID" \ + --guardian "$GUARDIAN_ID" \ + --strategy "$STRATEGY_ID" \ + --usdc "$USDC" \ + --max_tvl "$DEFAULT_MAX_TVL" + +echo " VaultL6.initialize (max_tvl=${DEFAULT_MAX_TVL})" +invoke "$L6_ID" initialize \ + --admin "$ROUTER_ID" \ + --governance "$GOVERNANCE_ID" \ + --strategy "$STRATEGY_ID" \ + --usdc "$USDC" \ + --max_tvl "$DEFAULT_MAX_TVL" + +echo " VaultL12.initialize (max_tvl=${DEFAULT_MAX_TVL})" +invoke "$L12_ID" initialize \ + --admin "$ROUTER_ID" \ + --governance "$GOVERNANCE_ID" \ + --strategy "$STRATEGY_ID" \ + --usdc "$USDC" \ + --max_tvl "$DEFAULT_MAX_TVL" + +echo " VaultRouter.initialize" +invoke "$ROUTER_ID" initialize \ + --admin "$DEPLOYER_ADDRESS" \ + --governance "$GOVERNANCE_ID" \ + --guardian "$GUARDIAN_ID" \ + --vault_flex "$FLEX_ID" \ + --vault_l3 "$L3_ID" \ + --vault_l6 "$L6_ID" \ + --vault_l12 "$L12_ID" \ + --initial_assets "[\"$USDC\"]" + +echo " Governance.initialize" +invoke "$GOVERNANCE_ID" initialize \ + --strategist "$DEPLOYER_ADDRESS" \ + --guardian "$GUARDIAN_ID" \ + --strategy_vault "$STRATEGY_ID" + +echo +echo "==> Writing $DEPLOYMENTS_FILE" +jq -n \ + --arg router "$ROUTER_ID" \ + --arg flex "$FLEX_ID" \ + --arg l3 "$L3_ID" \ + --arg l6 "$L6_ID" \ + --arg l12 "$L12_ID" \ + --arg strategy "$STRATEGY_ID" \ + --arg harvester "$HARVESTER_ID" \ + --arg governance "$GOVERNANCE_ID" \ + --arg deployedAt "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ + '{ + network: "testnet", + contracts: { + VaultRouter: $router, + VaultFlex: $flex, + VaultL3: $l3, + VaultL6: $l6, + VaultL12: $l12, + StrategyVault: $strategy, + Harvester: $harvester, + Governance: $governance + }, + deployedAt: $deployedAt + }' > "$DEPLOYMENTS_FILE" + +echo "Done. deployments/testnet.json updated." +echo "GuardianMultisig ($GUARDIAN_ID) is not tracked in deployments/testnet.json's schema — record it separately if you need it (e.g. scripts/.env.example or your own notes)." diff --git a/scripts/package.json b/scripts/package.json new file mode 100644 index 0000000..7b91419 --- /dev/null +++ b/scripts/package.json @@ -0,0 +1,23 @@ +{ + "name": "@yieldladder/scripts", + "version": "0.1.0", + "private": true, + "description": "Testnet deployment, reconciliation, observability, and demo tooling for issue #145.", + "type": "module", + "scripts": { + "reconcile": "tsx src/reconcile.ts", + "report": "tsx src/operational-report.ts", + "demo": "tsx src/e2e-demo.ts", + "typecheck": "tsc --noEmit", + "test": "vitest run" + }, + "dependencies": { + "@stellar/stellar-sdk": "^12.3.0" + }, + "devDependencies": { + "@types/node": "^20.11.0", + "tsx": "^4.15.0", + "typescript": "^5.9.3", + "vitest": "^1.6.0" + } +} diff --git a/scripts/src/e2e-demo.ts b/scripts/src/e2e-demo.ts new file mode 100644 index 0000000..e3579b8 --- /dev/null +++ b/scripts/src/e2e-demo.ts @@ -0,0 +1,120 @@ +#!/usr/bin/env tsx +/** + * End-to-end testnet payment demo (issue #145's acceptance criteria): + * connect wallet -> deposit into 2+ tiers -> observe confirmed status via + * polling -> trigger a harvest -> verify yield accrual -> withdraw/ + * early-exit -> confirm final balances reconcile with zero discrepancy. + * + * This drives the real @yieldladder/sdk (issue #139/#141's transaction + * pipeline and status tracking) against a live testnet deployment — it is + * not a mock. The one deliberate substitution: it signs with a + * `Keypair`-backed `Signer` instead of a browser Freighter extension, + * because Freighter can't be automated headlessly from Node. It implements + * the exact same `Signer` interface a WalletAdapter (issue #143) does + * (`signTransaction(xdr, opts)`), so swapping this script's signer for a + * real WalletAdapter to drive the same calls from the app is a one-line + * change — see scripts/README.md. + * + * Usage: + * DEMO_SECRET_KEY=S... USDC_TOKEN_CONTRACT_ID=C... pnpm demo + * + * Requires a funded testnet account (DEMO_SECRET_KEY) holding the deposit + * asset, and deployments/testnet.json populated by scripts/deploy-testnet.sh. + */ +import { Keypair } from '@stellar/stellar-sdk'; +import { YieldLadder, type Signer } from '../../sdks/typescript/src/index'; +import { loadDeployments, usdcContractId } from './env'; + +function keypairSigner(keypair: Keypair): Signer { + return { + async signTransaction(xdr: string) { + const { TransactionBuilder, Networks } = await import('@stellar/stellar-sdk'); + const tx = TransactionBuilder.fromXDR(xdr, Networks.TESTNET); + tx.sign(keypair); + return tx.toXDR(); + }, + }; +} + +async function main() { + const secret = process.env.DEMO_SECRET_KEY; + if (!secret) { + throw new Error('DEMO_SECRET_KEY is not set — fund a testnet account and pass its secret key.'); + } + const keypair = Keypair.fromSecret(secret); + const publicKey = keypair.publicKey(); + + const deployments = loadDeployments('testnet'); + const asset = usdcContractId(); + + const sdk = new YieldLadder({ + network: 'testnet', + publicKey, + signer: keypairSigner(keypair), + vaultRouterContractId: deployments.contracts.VaultRouter, + assetContractId: asset, + }); + + console.log(`Demo account: ${publicKey}`); + console.log('--- Step 1: deposit into Flex and L3 ---'); + + const flexTxHash = await sdk.deposit({ tier: 'Flex', amount: '10' }); + console.log(`Flex deposit submitted: ${flexTxHash}`); + await sdk.waitForConfirmation(flexTxHash, { + onStatus: (status) => console.log(` Flex deposit status: ${status}`), + }); + + const l3TxHash = await sdk.deposit({ tier: 'L3', amount: '50' }); + console.log(`L3 deposit submitted: ${l3TxHash}`); + await sdk.waitForConfirmation(l3TxHash, { + onStatus: (status) => console.log(` L3 deposit status: ${status}`), + }); + + const flexAfterDeposit = await sdk.positionForTier(publicKey, 'Flex'); + const l3AfterDeposit = await sdk.positionForTier(publicKey, 'L3'); + console.log('Positions after deposit:', { flexAfterDeposit, l3AfterDeposit }); + + console.log('--- Step 2: trigger a harvest ---'); + console.log( + 'Harvester.harvest() is permissionless but not exposed through the ' + + 'deposit-focused YieldLadder SDK surface — invoke it directly via the ' + + 'Harvester contract id in deployments/testnet.json, e.g.:\n' + + ` stellar contract invoke --id ${deployments.contracts.Harvester} ` + + '--source --network testnet -- harvest --caller \n' + + 'Then re-run this script from Step 3 onward, or check ' + + '`pnpm report` for last_harvest / next_harvest_ledger before retrying.', + ); + + console.log('--- Step 3: verify yield accrual (informational) ---'); + const flexBeforeExit = await sdk.positionForTier(publicKey, 'Flex'); + console.log('Flex position before exit (compare accruedYield to the pre-harvest snapshot above):', flexBeforeExit); + + console.log('--- Step 4: withdraw Flex, early-exit L3 ---'); + const withdrawTxHash = await sdk.withdraw({ tier: 'Flex' }); + console.log(`Flex withdraw submitted: ${withdrawTxHash}`); + await sdk.waitForConfirmation(withdrawTxHash, { + onStatus: (status) => console.log(` Flex withdraw status: ${status}`), + }); + + const earlyExitTxHash = await sdk.earlyExit({ tier: 'L3' }); + console.log(`L3 early-exit submitted: ${earlyExitTxHash}`); + await sdk.waitForConfirmation(earlyExitTxHash, { + onStatus: (status) => console.log(` L3 early-exit status: ${status}`), + }); + + console.log('--- Step 5: confirm final balances ---'); + const flexFinal = await sdk.positionForTier(publicKey, 'Flex'); + const l3Final = await sdk.positionForTier(publicKey, 'L3'); + console.log('Final positions (both principals should be back to 0):', { flexFinal, l3Final }); + + console.log( + '\nDemo complete. Run `pnpm reconcile --users ' + + publicKey + + '` to confirm these balances reconcile against contract storage with zero discrepancy.', + ); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/src/env.ts b/scripts/src/env.ts new file mode 100644 index 0000000..45dad4b --- /dev/null +++ b/scripts/src/env.ts @@ -0,0 +1,84 @@ +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import path from 'node:path'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = path.resolve(__dirname, '../..'); + +export interface Deployments { + network: string; + contracts: { + VaultRouter: string; + VaultFlex: string; + VaultL3: string; + VaultL6: string; + VaultL12: string; + StrategyVault: string; + Harvester: string; + Governance: string; + }; + deployedAt: string; +} + +const PLACEHOLDER = 'CC_PENDING_TESTNET'; + +/** + * Loads deployments/testnet.json from the repo root. Throws with a clear, + * actionable message if the file still holds placeholders — i.e. nobody has + * run `scripts/deploy-testnet.sh` yet — rather than letting every tool that + * reads this file fail with an opaque "CC_PENDING_TESTNET is not a valid + * contract id" error deep inside the RPC client. + */ +export function loadDeployments(network: 'testnet' | 'mainnet' = 'testnet'): Deployments { + const filePath = path.join(REPO_ROOT, 'deployments', `${network}.json`); + const raw = JSON.parse(readFileSync(filePath, 'utf-8')) as Deployments; + + const pending = Object.entries(raw.contracts) + .filter(([, id]) => !id || id === PLACEHOLDER) + .map(([name]) => name); + + if (pending.length > 0) { + throw new Error( + `deployments/${network}.json is missing real contract ids for: ${pending.join(', ')}. ` + + `Run scripts/deploy-testnet.sh first (see scripts/README.md).`, + ); + } + + return raw; +} + +export function rpcUrl(): string { + return process.env.SOROBAN_RPC_URL ?? 'https://soroban-testnet.stellar.org'; +} + +export function usdcContractId(): string { + const id = process.env.USDC_TOKEN_CONTRACT_ID; + if (!id) { + throw new Error( + 'USDC_TOKEN_CONTRACT_ID is not set. Point it at the deposit-asset SAC used for this ' + + 'deployment (see scripts/.env.example).', + ); + } + return id; +} + +/** + * Public key used as the transaction source for read-only simulation calls + * (Soroban's simulateTransaction needs a syntactically valid source account + * even though nothing is signed or submitted). Any existing testnet account + * works — it does not need to hold a position or even be funded for a pure + * read simulation in most RPC implementations, but funding it avoids edge + * cases on some providers. + */ +export function readerPublicKey(): string { + const key = process.env.RECONCILE_READER_PUBLIC_KEY; + if (!key) { + throw new Error( + 'RECONCILE_READER_PUBLIC_KEY is not set. Provide any existing testnet account public ' + + 'key to use as the simulation source (see scripts/.env.example).', + ); + } + return key; +} + +export { REPO_ROOT }; diff --git a/scripts/src/operational-report.ts b/scripts/src/operational-report.ts new file mode 100644 index 0000000..b7d5058 --- /dev/null +++ b/scripts/src/operational-report.ts @@ -0,0 +1,114 @@ +#!/usr/bin/env tsx +/** + * Operational health report (issue #145). Answers "is the payment system + * healthy right now?" without reading contract storage by hand: last + * harvest ledger vs. the expected cooldown-elapsed ledger, current TVL vs. + * cap per tier, and a summary of any reconciliation mismatches. + * + * Usage: + * RECONCILE_READER_PUBLIC_KEY=G... USDC_TOKEN_CONTRACT_ID=C... \ + * pnpm report + */ +import { SorobanRpc } from '@stellar/stellar-sdk'; +import { loadDeployments, readerPublicKey, rpcUrl } from './env'; +import { readContractValue } from './rpc'; +import type { Tier } from './reconcile.core'; + +const TIERS: Tier[] = ['Flex', 'L3', 'L6', 'L12']; + +interface TierHealth { + tier: Tier; + totalBalance: string; + maxTvl: string | null; + remainingCapacity: string | null; + pctOfCap: number | null; +} + +interface HarvestHealth { + lastHarvestLedger: number; + nextHarvestLedger: number; + currentLedger: number; + cooldownElapsed: boolean; + ledgersUntilNextHarvest: number; +} + +async function main() { + const deployments = loadDeployments('testnet'); + const server = new SorobanRpc.Server(rpcUrl()); + const source = readerPublicKey(); + + const latest = await server.getLatestLedger(); + const currentLedger = latest.sequence; + + const tierContracts: Record = { + Flex: deployments.contracts.VaultFlex, + L3: deployments.contracts.VaultL3, + L6: deployments.contracts.VaultL6, + L12: deployments.contracts.VaultL12, + }; + + const tierHealth: TierHealth[] = []; + for (const tier of TIERS) { + const contractId = tierContracts[tier]; + const totalBalance = BigInt( + (await readContractValue(server, source, contractId, 'total_balance')) as bigint, + ); + + // Flex has no cap/remaining_capacity getters (contracts/vault_flex/src/lib.rs) — only the + // locked tiers (L3/L6/L12) expose max_tvl/remaining_capacity. + let maxTvl: bigint | null = null; + let remaining: bigint | null = null; + if (tier !== 'Flex') { + maxTvl = BigInt((await readContractValue(server, source, contractId, 'max_tvl')) as bigint); + remaining = BigInt( + (await readContractValue(server, source, contractId, 'remaining_capacity')) as bigint, + ); + } + + tierHealth.push({ + tier, + totalBalance: totalBalance.toString(), + maxTvl: maxTvl?.toString() ?? null, + remainingCapacity: remaining?.toString() ?? null, + pctOfCap: maxTvl && maxTvl > 0n ? Number((totalBalance * 10_000n) / maxTvl) / 100 : null, + }); + } + + const lastHarvestLedger = Number( + await readContractValue(server, source, deployments.contracts.Harvester, 'last_harvest'), + ); + const nextHarvestLedger = Number( + await readContractValue( + server, + source, + deployments.contracts.Harvester, + 'next_harvest_ledger', + ), + ); + + const harvestHealth: HarvestHealth = { + lastHarvestLedger, + nextHarvestLedger, + currentLedger, + cooldownElapsed: currentLedger >= nextHarvestLedger, + ledgersUntilNextHarvest: Math.max(0, nextHarvestLedger - currentLedger), + }; + + const report = { + checkedAt: new Date().toISOString(), + network: deployments.network, + currentLedger, + tiers: tierHealth, + harvest: harvestHealth, + note: + 'Run `pnpm reconcile` alongside this report for balance/shares/harvest-split ' + + 'mismatch detection — this report only covers TVL-vs-cap and harvest cadence.', + }; + + console.log(JSON.stringify(report, null, 2)); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/src/reconcile.core.ts b/scripts/src/reconcile.core.ts new file mode 100644 index 0000000..24934e1 --- /dev/null +++ b/scripts/src/reconcile.core.ts @@ -0,0 +1,212 @@ +/** + * Pure reconciliation logic (issue #145) — no network I/O, so it is fully + * unit-testable. `reconcile.ts` is the thin CLI wrapper that fetches chain + * state and events, then calls the functions below. + */ + +export type Tier = 'Flex' | 'L3' | 'L6' | 'L12'; + +export interface VaultEvent { + tier: Tier; + asset: string; + user: string; + kind: 'deposit' | 'withdraw' | 'early_exit'; + /** + * The amount that moved the vault's balance for this event: the deposit + * amount, or the actual payout for withdraw/early_exit — for early_exit + * this is already net of any exit fee (or un-feed, under emergency + * unlock), because the contract emits the post-fee `net_amount` as the + * event data. Reconciliation therefore never needs to re-derive fee or + * emergency-unlock logic itself. + */ + amount: bigint; +} + +export interface VaultTotals { + tier: Tier; + /** vault.total_balance() — aggregated across ALL deposit assets for this tier (the contracts don't track a per-asset total). */ + totalBalance: bigint; +} + +export interface HarvestRecord { + ledger: number; + txHash: string; + harvested: bigint; + bounty: bigint; + remainder: bigint; +} + +export interface PositionPair { + tier: Tier; + asset: string; + user: string; + sdkBalance: bigint; + chainBalance: bigint; +} + +export type MismatchKind = + | 'negative_asset_balance' + | 'aggregate_balance' + | 'harvest_split' + | 'sdk_vs_chain_position'; + +export interface Mismatch { + kind: MismatchKind; + tier?: Tier; + asset?: string; + user?: string; + expected: string; + actual: string; + detail: string; +} + +/** (tier, asset) -> computed principal balance, from summing deposit/withdraw/early_exit events. */ +export function computeAssetBalances(events: VaultEvent[]): Map { + const balances = new Map(); + for (const ev of events) { + const key = `${ev.tier}|${ev.asset}`; + const current = balances.get(key) ?? 0n; + const delta = ev.kind === 'deposit' ? ev.amount : -ev.amount; + balances.set(key, current + delta); + } + return balances; +} + +/** + * Flags any (tier, asset) whose event-derived balance has gone negative — + * an impossible state that means withdrawals/early-exits were recorded + * (or amounts computed) that the deposit history doesn't support. Checking + * per-asset, rather than only the tier-wide aggregate below, is what + * catches a shortfall in one asset that a surplus in another would + * otherwise mask. + */ +export function reconcilePerAssetBalances(events: VaultEvent[]): Mismatch[] { + const balances = computeAssetBalances(events); + const mismatches: Mismatch[] = []; + for (const [key, balance] of balances) { + if (balance < 0n) { + const [tier, asset] = key.split('|') as [Tier, string]; + mismatches.push({ + kind: 'negative_asset_balance', + tier, + asset, + expected: '>= 0', + actual: balance.toString(), + detail: `Computed balance for ${tier}/${asset} went negative — withdrawals exceed recorded deposits.`, + }); + } + } + return mismatches; +} + +/** + * Cross-checks the event-derived balance, summed across every asset for a + * tier, against that tier vault's on-chain `total_balance()` — the only + * total the contracts expose (it is not tracked per-asset on-chain). + */ +export function reconcileAggregateBalances( + events: VaultEvent[], + vaultTotals: VaultTotals[], +): Mismatch[] { + const perAsset = computeAssetBalances(events); + const byTier = new Map(); + for (const [key, balance] of perAsset) { + const tier = key.split('|')[0] as Tier; + byTier.set(tier, (byTier.get(tier) ?? 0n) + balance); + } + + const mismatches: Mismatch[] = []; + for (const { tier, totalBalance } of vaultTotals) { + const computed = byTier.get(tier) ?? 0n; + if (computed !== totalBalance) { + mismatches.push({ + kind: 'aggregate_balance', + tier, + expected: totalBalance.toString(), + actual: computed.toString(), + detail: `${tier} vault total_balance() is ${totalBalance} but summed deposit/withdraw/early_exit events give ${computed}.`, + }); + } + } + return mismatches; +} + +/** + * Validates the harvester's bounty/remainder split against each harvest + * event's `harvested` amount — the invariant unit-tested in isolation at + * contracts/harvester/src/lib.rs:160-179, now checked against live chain + * state instead of just in-process arithmetic. A zero-yield harvest + * (harvested === 0) is expected to carry bounty === remainder === 0 and is + * NOT flagged as a discrepancy (contracts/harvester/src/lib.rs:80-87). + */ +export function reconcileHarvestSplits(records: HarvestRecord[]): Mismatch[] { + const mismatches: Mismatch[] = []; + for (const r of records) { + if (r.harvested === 0n) { + if (r.bounty !== 0n || r.remainder !== 0n) { + mismatches.push({ + kind: 'harvest_split', + expected: 'bounty=0, remainder=0', + actual: `bounty=${r.bounty}, remainder=${r.remainder}`, + detail: `Zero-yield harvest at ledger ${r.ledger} (tx ${r.txHash}) paid out a non-zero bounty/remainder.`, + }); + } + continue; + } + const sum = r.bounty + r.remainder; + if (sum !== r.harvested) { + mismatches.push({ + kind: 'harvest_split', + expected: r.harvested.toString(), + actual: sum.toString(), + detail: `Harvest at ledger ${r.ledger} (tx ${r.txHash}): bounty(${r.bounty}) + remainder(${r.remainder}) != harvested(${r.harvested}).`, + }); + } + } + return mismatches; +} + +/** + * Flags any user/tier/asset where the SDK-reported position + * (VaultRouter.position() via the TypeScript SDK) disagrees with a + * position read directly from the tier vault's own storage getters. + */ +export function reconcileSdkVsChain(pairs: PositionPair[]): Mismatch[] { + const mismatches: Mismatch[] = []; + for (const p of pairs) { + if (p.sdkBalance !== p.chainBalance) { + mismatches.push({ + kind: 'sdk_vs_chain_position', + tier: p.tier, + asset: p.asset, + user: p.user, + expected: p.chainBalance.toString(), + actual: p.sdkBalance.toString(), + detail: `${p.user}'s SDK-reported ${p.tier}/${p.asset} balance (${p.sdkBalance}) disagrees with the tier vault's own balance() (${p.chainBalance}).`, + }); + } + } + return mismatches; +} + +export interface ReconciliationReport { + mismatches: Mismatch[]; + checkedAt: string; +} + +export function runReconciliation(input: { + events: VaultEvent[]; + vaultTotals: VaultTotals[]; + harvests: HarvestRecord[]; + positions: PositionPair[]; +}): ReconciliationReport { + return { + mismatches: [ + ...reconcilePerAssetBalances(input.events), + ...reconcileAggregateBalances(input.events, input.vaultTotals), + ...reconcileHarvestSplits(input.harvests), + ...reconcileSdkVsChain(input.positions), + ], + checkedAt: new Date().toISOString(), + }; +} diff --git a/scripts/src/reconcile.test.ts b/scripts/src/reconcile.test.ts new file mode 100644 index 0000000..b6f9d01 --- /dev/null +++ b/scripts/src/reconcile.test.ts @@ -0,0 +1,177 @@ +import { describe, expect, it } from 'vitest'; +import { + computeAssetBalances, + reconcileAggregateBalances, + reconcileHarvestSplits, + reconcilePerAssetBalances, + reconcileSdkVsChain, + runReconciliation, + type HarvestRecord, + type PositionPair, + type VaultEvent, +} from './reconcile.core'; + +const USDC = 'CUSDC000000000000000000000000000000000000000000000000'; +const EURC = 'CEURC000000000000000000000000000000000000000000000000'; +const ALICE = 'GALICE00000000000000000000000000000000000000000000000'; +const BOB = 'GBOB00000000000000000000000000000000000000000000000000'; + +describe('computeAssetBalances', () => { + it('sums deposits and subtracts withdraw/early_exit per (tier, asset)', () => { + const events: VaultEvent[] = [ + { tier: 'Flex', asset: USDC, user: ALICE, kind: 'deposit', amount: 100n }, + { tier: 'Flex', asset: USDC, user: ALICE, kind: 'withdraw', amount: 40n }, + { tier: 'Flex', asset: EURC, user: BOB, kind: 'deposit', amount: 50n }, + ]; + const balances = computeAssetBalances(events); + expect(balances.get('Flex|' + USDC)).toBe(60n); + expect(balances.get('Flex|' + EURC)).toBe(50n); + }); +}); + +describe('reconcilePerAssetBalances', () => { + it('reports zero mismatches for a clean, balanced event history', () => { + const events: VaultEvent[] = [ + { tier: 'L3', asset: USDC, user: ALICE, kind: 'deposit', amount: 500n }, + { tier: 'L3', asset: USDC, user: ALICE, kind: 'withdraw', amount: 200n }, + ]; + expect(reconcilePerAssetBalances(events)).toEqual([]); + }); + + it('flags a (tier, asset) whose computed balance goes negative', () => { + const events: VaultEvent[] = [ + { tier: 'L3', asset: USDC, user: ALICE, kind: 'deposit', amount: 100n }, + { tier: 'L3', asset: USDC, user: ALICE, kind: 'withdraw', amount: 150n }, + ]; + const mismatches = reconcilePerAssetBalances(events); + expect(mismatches).toHaveLength(1); + expect(mismatches[0]).toMatchObject({ + kind: 'negative_asset_balance', + tier: 'L3', + asset: USDC, + actual: '-50', + }); + }); + + it('does not mask a shortfall in one asset with a surplus in another', () => { + // Aggregate net across both assets is +50 (healthy), but USDC alone is + // negative — a tier-wide-only check would miss this. + const events: VaultEvent[] = [ + { tier: 'L3', asset: USDC, user: ALICE, kind: 'deposit', amount: 100n }, + { tier: 'L3', asset: USDC, user: ALICE, kind: 'withdraw', amount: 150n }, + { tier: 'L3', asset: EURC, user: BOB, kind: 'deposit', amount: 100n }, + ]; + const mismatches = reconcilePerAssetBalances(events); + expect(mismatches).toHaveLength(1); + expect(mismatches[0].asset).toBe(USDC); + }); +}); + +describe('reconcileAggregateBalances', () => { + it('matches when the vault total equals the sum of computed per-asset balances', () => { + const events: VaultEvent[] = [ + { tier: 'Flex', asset: USDC, user: ALICE, kind: 'deposit', amount: 100n }, + { tier: 'Flex', asset: EURC, user: BOB, kind: 'deposit', amount: 50n }, + ]; + const mismatches = reconcileAggregateBalances(events, [{ tier: 'Flex', totalBalance: 150n }]); + expect(mismatches).toEqual([]); + }); + + it('flags drift between the on-chain aggregate and computed events', () => { + const events: VaultEvent[] = [ + { tier: 'Flex', asset: USDC, user: ALICE, kind: 'deposit', amount: 100n }, + ]; + const mismatches = reconcileAggregateBalances(events, [{ tier: 'Flex', totalBalance: 90n }]); + expect(mismatches).toHaveLength(1); + expect(mismatches[0]).toMatchObject({ kind: 'aggregate_balance', expected: '90', actual: '100' }); + }); + + it('does not false-flag a paused window with zero new deposit events', () => { + // No new events during a pause — the aggregate simply doesn't move, + // and reconciliation compares reality against reality rather than an + // expected-activity model, so a paused window with no deposits is + // indistinguishable from "nothing happened," which is correct. + const events: VaultEvent[] = [ + { tier: 'L6', asset: USDC, user: ALICE, kind: 'deposit', amount: 200n }, + ]; + const mismatches = reconcileAggregateBalances(events, [{ tier: 'L6', totalBalance: 200n }]); + expect(mismatches).toEqual([]); + }); +}); + +describe('reconcileHarvestSplits', () => { + it('passes a normal harvest whose bounty + remainder equals the harvested amount', () => { + const records: HarvestRecord[] = [ + { ledger: 100, txHash: 'abc', harvested: 10_000n, bounty: 10n, remainder: 9_990n }, + ]; + expect(reconcileHarvestSplits(records)).toEqual([]); + }); + + it('treats a documented zero-yield harvest as expected, not a discrepancy', () => { + const records: HarvestRecord[] = [ + { ledger: 100, txHash: 'abc', harvested: 0n, bounty: 0n, remainder: 0n }, + ]; + expect(reconcileHarvestSplits(records)).toEqual([]); + }); + + it('flags a zero-yield harvest that still paid out a bounty', () => { + const records: HarvestRecord[] = [ + { ledger: 100, txHash: 'abc', harvested: 0n, bounty: 5n, remainder: 0n }, + ]; + const mismatches = reconcileHarvestSplits(records); + expect(mismatches).toHaveLength(1); + expect(mismatches[0].kind).toBe('harvest_split'); + }); + + it('flags an injected bounty/remainder split mismatch', () => { + const records: HarvestRecord[] = [ + { ledger: 100, txHash: 'abc', harvested: 10_000n, bounty: 10n, remainder: 9_000n }, + ]; + const mismatches = reconcileHarvestSplits(records); + expect(mismatches).toHaveLength(1); + expect(mismatches[0]).toMatchObject({ kind: 'harvest_split', expected: '10000', actual: '9010' }); + }); +}); + +describe('reconcileSdkVsChain', () => { + it('passes when SDK-reported and direct chain reads agree', () => { + const pairs: PositionPair[] = [ + { tier: 'L12', asset: USDC, user: ALICE, sdkBalance: 2_500_000_000n, chainBalance: 2_500_000_000n }, + ]; + expect(reconcileSdkVsChain(pairs)).toEqual([]); + }); + + it('flags disagreement between the SDK and a direct contract-storage read', () => { + const pairs: PositionPair[] = [ + { tier: 'L12', asset: USDC, user: ALICE, sdkBalance: 2_500_000_000n, chainBalance: 2_400_000_000n }, + ]; + const mismatches = reconcileSdkVsChain(pairs); + expect(mismatches).toHaveLength(1); + expect(mismatches[0].kind).toBe('sdk_vs_chain_position'); + }); +}); + +describe('runReconciliation', () => { + it('reports zero mismatches on a fully clean, consistent snapshot', () => { + const report = runReconciliation({ + events: [{ tier: 'Flex', asset: USDC, user: ALICE, kind: 'deposit', amount: 100n }], + vaultTotals: [{ tier: 'Flex', totalBalance: 100n }], + harvests: [{ ledger: 1, txHash: 'x', harvested: 0n, bounty: 0n, remainder: 0n }], + positions: [{ tier: 'Flex', asset: USDC, user: ALICE, sdkBalance: 100n, chainBalance: 100n }], + }); + expect(report.mismatches).toEqual([]); + }); + + it('aggregates mismatches from every dimension when several are broken at once', () => { + const report = runReconciliation({ + events: [{ tier: 'Flex', asset: USDC, user: ALICE, kind: 'withdraw', amount: 100n }], + vaultTotals: [{ tier: 'Flex', totalBalance: 0n }], + harvests: [{ ledger: 1, txHash: 'x', harvested: 1000n, bounty: 1n, remainder: 998n }], + positions: [{ tier: 'Flex', asset: USDC, user: BOB, sdkBalance: 5n, chainBalance: 6n }], + }); + const kinds = report.mismatches.map((m) => m.kind).sort(); + expect(kinds).toEqual( + ['aggregate_balance', 'harvest_split', 'negative_asset_balance', 'sdk_vs_chain_position'].sort(), + ); + }); +}); diff --git a/scripts/src/reconcile.ts b/scripts/src/reconcile.ts new file mode 100644 index 0000000..cf8d270 --- /dev/null +++ b/scripts/src/reconcile.ts @@ -0,0 +1,167 @@ +#!/usr/bin/env tsx +/** + * Reconciliation job (issue #145). Cross-checks on-chain reality against + * expected payment outcomes: + * - sums per-user Balance/Shares (derived from indexed deposit/withdraw/ + * early_exit events) against each tier vault's total_balance() + * - verifies Harvester's bounty+remainder split against harvested, for + * every harvest event in the lookback window + * - flags any user whose SDK-reported position disagrees with a position + * read directly from the tier vault's own storage + * + * All of the actual mismatch-detection logic lives in reconcile.core.ts and + * is unit-tested there without touching the network. This file is just the + * chain I/O plumbing. + * + * Usage: + * RECONCILE_READER_PUBLIC_KEY=G... USDC_TOKEN_CONTRACT_ID=C... \ + * pnpm reconcile [--from-ledger ] [--users G...,G...,...] + * + * Exits non-zero if any mismatch is found, so it's safe to wire into CI/cron. + */ +import { SorobanRpc, scValToNative } from '@stellar/stellar-sdk'; +import { loadDeployments, readerPublicKey, rpcUrl, usdcContractId } from './env'; +import { getHarvestEvents, readContractValue } from './rpc'; +import { + runReconciliation, + type PositionPair, + type Tier, + type VaultEvent, + type VaultTotals, +} from './reconcile.core'; + +const TIERS: Tier[] = ['Flex', 'L3', 'L6', 'L12']; + +function parseArg(name: string): string | undefined { + const idx = process.argv.indexOf(`--${name}`); + return idx >= 0 ? process.argv[idx + 1] : undefined; +} + +/** + * Reads deposit/withdraw/early_exit events for one tier vault contract by + * decoding the topics/data shape emitted by the vault_flex/vault_l3/ + * vault_l6/vault_l12 contracts' event-emission additions (issue #145). + */ +async function getVaultEvents( + server: SorobanRpc.Server, + tier: Tier, + contractId: string, + startLedger: number, +): Promise { + const response = await server.getEvents({ + startLedger, + filters: [{ type: 'contract', contractIds: [contractId] }], + limit: 200, + }); + + const events: VaultEvent[] = []; + for (const raw of response.events) { + const topics = raw.topic.map((t) => scValToNative(t)); + const kind = topics[0] as string; + if (kind !== 'deposit' && kind !== 'withdraw' && kind !== 'early_exit') continue; + + const [user, asset] = [String(topics[1]), String(topics[2])]; + const amount = BigInt(scValToNative(raw.value) as bigint | number); + events.push({ tier, asset, user, kind, amount }); + } + return events; +} + +async function main() { + const deployments = loadDeployments('testnet'); + const server = new SorobanRpc.Server(rpcUrl()); + const source = readerPublicKey(); + const asset = usdcContractId(); + + const latest = await server.getLatestLedger(); + // Default lookback: ~1 day of ledgers (17,280 at ~5s/ledger) — enough for + // a routine health check without hitting most providers' event-retention + // ceiling. Override with --from-ledger for a wider historical run. + const startLedger = Number(parseArg('from-ledger') ?? latest.sequence - 17_280); + const explicitUsers = parseArg('users')?.split(',').filter(Boolean) ?? []; + + const tierContracts: Record = { + Flex: deployments.contracts.VaultFlex, + L3: deployments.contracts.VaultL3, + L6: deployments.contracts.VaultL6, + L12: deployments.contracts.VaultL12, + }; + + const allEvents: VaultEvent[] = []; + const vaultTotals: VaultTotals[] = []; + for (const tier of TIERS) { + const contractId = tierContracts[tier]; + const events = await getVaultEvents(server, tier, contractId, startLedger); + allEvents.push(...events); + + const totalBalance = (await readContractValue( + server, + source, + contractId, + 'total_balance', + )) as bigint; + vaultTotals.push({ tier, totalBalance: BigInt(totalBalance) }); + } + + const harvestEvents = await getHarvestEvents( + server, + deployments.contracts.Harvester, + startLedger, + ); + + // "SDK-reported vs contract storage read directly" — reads each user seen + // in the event window (or explicitly passed via --users) through + // VaultRouter.position() (the same call the SDK's queryTierPosition + // makes) and compares it to the tier vault's own balance() getter. + const users = explicitUsers.length > 0 ? explicitUsers : [...new Set(allEvents.map((e) => e.user))]; + const positions: PositionPair[] = []; + for (const tier of TIERS) { + const contractId = tierContracts[tier]; + for (const user of users) { + const [routerPosition, chainBalance] = await Promise.all([ + readContractValue(server, source, deployments.contracts.VaultRouter, 'position', [ + user, + tier, + asset, + ]), + readContractValue(server, source, contractId, 'balance', [user, asset]), + ]); + const sdkBalance = BigInt( + (routerPosition as { principal: bigint | number }).principal ?? 0, + ); + positions.push({ + tier, + asset, + user, + sdkBalance, + chainBalance: BigInt(chainBalance as bigint | number), + }); + } + } + + const report = runReconciliation({ + events: allEvents, + vaultTotals, + harvests: harvestEvents.map((h) => ({ + ledger: h.ledger, + txHash: h.txHash, + harvested: h.harvested, + bounty: h.bounty, + remainder: h.remainder, + })), + positions, + }); + + console.log(JSON.stringify(report, (_key, value) => (typeof value === 'bigint' ? value.toString() : value), 2)); + + if (report.mismatches.length > 0) { + console.error(`\n${report.mismatches.length} reconciliation mismatch(es) found.`); + process.exit(1); + } + console.log('\nReconciliation clean — zero mismatches.'); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/src/rpc.ts b/scripts/src/rpc.ts new file mode 100644 index 0000000..19f0b89 --- /dev/null +++ b/scripts/src/rpc.ts @@ -0,0 +1,109 @@ +import { + BASE_FEE, + Contract, + Networks, + SorobanRpc, + TransactionBuilder, + nativeToScVal, + scValToNative, + xdr, +} from '@stellar/stellar-sdk'; + +/** + * Reads a contract's view/getter method via `simulateTransaction` — no + * signing, no submission, no state change. Every tier-vault getter used by + * reconciliation (`balance`, `shares`, `total_balance`, `total_shares`, + * `max_tvl`, `remaining_capacity`, `emergency_unlock`, ...) and Harvester's + * (`last_harvest`, `next_harvest_ledger`) is a plain getter with no + * `require_auth`, so this works against any syntactically valid source + * account. + */ +export async function readContractValue( + server: SorobanRpc.Server, + sourcePublicKey: string, + contractId: string, + method: string, + args: unknown[] = [], +): Promise { + const account = await server.getAccount(sourcePublicKey); + const contract = new Contract(contractId); + const scArgs = args.map((a) => toScVal(a)); + + const tx = new TransactionBuilder(account, { + fee: BASE_FEE, + networkPassphrase: Networks.TESTNET, + }) + .addOperation(contract.call(method, ...scArgs)) + .setTimeout(30) + .build(); + + const sim = await server.simulateTransaction(tx); + + if (SorobanRpc.Api.isSimulationError(sim)) { + throw new Error(`simulate ${contractId}.${method} failed: ${sim.error}`); + } + if (!SorobanRpc.Api.isSimulationSuccess(sim) || sim.result === undefined) { + throw new Error(`simulate ${contractId}.${method} returned no result`); + } + return scValToNative(sim.result.retval); +} + +function toScVal(value: unknown): xdr.ScVal { + if (typeof value === 'string' && value.startsWith('G') && value.length === 56) { + return nativeToScVal(value, { type: 'address' }); + } + if (typeof value === 'bigint' || typeof value === 'number') { + return nativeToScVal(value, { type: 'i128' }); + } + return nativeToScVal(value); +} + +export interface HarvestEvent { + ledger: number; + txHash: string; + caller: string; + harvested: bigint; + bounty: bigint; + remainder: bigint; +} + +/** + * Fetches decoded `harvest` events (issue #145 event emission) for a + * Harvester contract in `[startLedger, latest]`. Public Soroban RPC + * providers only retain a rolling event window (commonly ~7 days of + * ledgers) — pass a recent `startLedger` or this will simply return fewer + * events than exist historically, which is expected, not an error. + */ +export async function getHarvestEvents( + server: SorobanRpc.Server, + harvesterContractId: string, + startLedger: number, +): Promise { + const response = await server.getEvents({ + startLedger, + filters: [ + { + type: 'contract', + contractIds: [harvesterContractId], + }, + ], + limit: 200, + }); + + const events: HarvestEvent[] = []; + for (const raw of response.events) { + const topics = raw.topic.map((t) => scValToNative(t)); + if (topics[0] !== 'harvest') continue; + + const data = scValToNative(raw.value) as [bigint, bigint, bigint]; + events.push({ + ledger: raw.ledger, + txHash: raw.txHash, + caller: String(topics[1]), + harvested: BigInt(data[0]), + bounty: BigInt(data[1]), + remainder: BigInt(data[2]), + }); + } + return events; +} diff --git a/scripts/tsconfig.json b/scripts/tsconfig.json new file mode 100644 index 0000000..289b8cb --- /dev/null +++ b/scripts/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "Bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "noEmit": true, + "types": ["node"] + }, + "include": ["src"] +} diff --git a/sdks/typescript/src/index.ts b/sdks/typescript/src/index.ts index a65e720..b8c3a47 100644 --- a/sdks/typescript/src/index.ts +++ b/sdks/typescript/src/index.ts @@ -200,6 +200,17 @@ export class YieldLadder { return positions.find(p => p.principal !== '0') ?? positions[0]; } + /** + * Reads `address`'s position in one specific `tier`. Unlike `position()`, + * which collapses all four tiers down to "the first with a non-zero + * principal," this doesn't discard the others — needed once a single + * account holds positions in more than one tier at a time (issue #145's + * end-to-end demo deposits into 2+ tiers and needs to observe each one). + */ + async positionForTier(address: string, tier: Tier): Promise { + return this.queryTierPosition(address, tier); + } + private toStroops(amount: string): bigint { const [whole = '0', frac = ''] = amount.split('.'); const fracPadded = frac.padEnd(USDC_DECIMALS, '0').slice(0, USDC_DECIMALS);