From 4f24e8b9a520b366a53d1fc05709875dff5b25db Mon Sep 17 00:00:00 2001 From: Creed1759 Date: Thu, 25 Jun 2026 17:40:58 +0100 Subject: [PATCH] =?UTF-8?q?feat(contracts):=20implement=20Yield=20Vault=20?= =?UTF-8?q?=E2=80=94=20SC-016,=20SC-017,=20SC-018,=20SC-019?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #357 [SC-016] — Storage keys & configuration state Closes #358 [SC-017] — Yield Vault deposit function Closes #359 [SC-018] — Yield Vault withdraw function Closes #360 [SC-019] — Yield compounding math based on time/ledgers ──────────────────────────────────────────────────────────────────── NEW FILES ──────────────────────────────────────────────────────────────────── contracts/contracts/yield_vault/Cargo.toml Standard Soroban crate manifest (cdylib + rlib, soroban-sdk 20.0.0). contracts/contracts/yield_vault/src/lib.rs Single-file Soroban contract implementing all four issues. MODIFIED FILES contracts/Cargo.toml Added 'contracts/yield_vault' to the workspace members list. ──────────────────────────────────────────────────────────────────── SC-016 — Storage keys & configuration state ──────────────────────────────────────────────────────────────────── Defined seven Symbol constants as Soroban instance-storage keys: OWNER_KEY, TOKEN_KEY, APY_KEY, SHARES_KEY, ASSETS_KEY, IDX_KEY (yield index), IDX_LED_KEY (last checkpoint ledger). Defined a DataKey enum with variant UserShares(Address) for persistent per-user share balances. Implemented initialize(owner, token, apy_bps): - Panics with 'already initialized' on re-call (single-init guard). - Stores owner address, accepted token address, APY in basis points. - Seeds the yield index at PRECISION (1e8 = 1.0) and records the current ledger sequence as the index baseline. - Zeroes out total_shares and total_assets counters. ──────────────────────────────────────────────────────────────────── SC-019 — Yield compounding math based on time/ledgers ──────────────────────────────────────────────────────────────────── All math uses a fixed-point PRECISION factor of 1e8 to avoid floating-point and keep values within i128 for any realistic APY (up to tens of thousands of bps) and time window. current_index(env) — private helper: Reads old_index, apy_bps, and delta ledgers since last checkpoint. Accrued yield = old_index * apy_bps * delta / (10_000 * LEDGERS_PER_YEAR). Added to old_index with checked_mul / checked_add to panic on overflow rather than silently wrap. Returns old_index unchanged when delta == 0 or apy_bps == 0. checkpoint_index(env) — private helper: Calls current_index, persists the new value, and resets the reference ledger to the current sequence so subsequent calls accumulate yield only from this point forward. ──────────────────────────────────────────────────────────────────── SC-017 — Deposit function ──────────────────────────────────────────────────────────────────── deposit(depositor, amount): 1. Calls depositor.require_auth() — no unauthenticated deposits. 2. Asserts amount > 0. 3. Calls checkpoint_index to snapshot accrued yield first. 4. Uses soroban_sdk::token::Client to transfer 'amount' tokens from the depositor's address to the vault's own address (env.current_contract_address()), satisfying the pull-pattern requirement. 5. Calculates shares_minted = amount * PRECISION / current_index so early depositors get more shares (higher index over time → fewer shares per unit, meaning each share is worth more). 6. Adds shares to the user's persistent DataKey::UserShares entry. 7. Increments SHARES_KEY and ASSETS_KEY instance totals. 8. Emits a 'Deposited' event (depositor, amount, shares). ──────────────────────────────────────────────────────────────────── SC-018 — Withdraw function ──────────────────────────────────────────────────────────────────── withdraw(user, shares): 1. Calls user.require_auth() as required by the acceptance criteria. 2. Asserts shares > 0. 3. Calls checkpoint_index so withdrawers receive yield earned up to the exact ledger of the withdrawal. 4. Reads the user's share balance and panics with 'insufficient shares' if they do not own enough. 5. Calculates assets_out = shares * current_index / PRECISION — the inverse of the deposit formula, so principal + accrued yield is returned. 6. Deducts the redeemed shares from the user's persistent balance (burn-equivalent; shares are simply removed, not moved). 7. Decrements SHARES_KEY and ASSETS_KEY; clamps to 0 to prevent negative totals from integer rounding drift. 8. Transfers assets_out tokens from the vault to the user via token::Client.transfer(vault_addr, user, assets_out). 9. Emits a 'Withdrawn' event (user, shares, assets_out). View helpers exposed for off-chain indexing / dashboard use: shares_of(user), total_shares(), total_assets(), yield_index(). --- contracts/Cargo.toml | 1 + contracts/contracts/yield_vault/Cargo.toml | 13 ++ contracts/contracts/yield_vault/src/lib.rs | 166 +++++++++++++++++++++ 3 files changed, 180 insertions(+) create mode 100644 contracts/contracts/yield_vault/Cargo.toml create mode 100644 contracts/contracts/yield_vault/src/lib.rs diff --git a/contracts/Cargo.toml b/contracts/Cargo.toml index ab8e8fe..3e101dc 100644 --- a/contracts/Cargo.toml +++ b/contracts/Cargo.toml @@ -6,6 +6,7 @@ members = [ "contracts/naira_token", "contracts/social_graph", "contracts/allbridge_receiver", + "contracts/yield_vault", ] [profile.release] diff --git a/contracts/contracts/yield_vault/Cargo.toml b/contracts/contracts/yield_vault/Cargo.toml new file mode 100644 index 0000000..9b61b07 --- /dev/null +++ b/contracts/contracts/yield_vault/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "zaps-yield-vault" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +soroban-sdk = "20.0.0" + +[dev-dependencies] +soroban-sdk = { version = "20.0.0", features = ["testutils"] } diff --git a/contracts/contracts/yield_vault/src/lib.rs b/contracts/contracts/yield_vault/src/lib.rs new file mode 100644 index 0000000..bb06df3 --- /dev/null +++ b/contracts/contracts/yield_vault/src/lib.rs @@ -0,0 +1,166 @@ +#![no_std] +#![allow(unexpected_cfgs)] +use soroban_sdk::{contract, contractimpl, contracttype, symbol_short, token, Address, Env, Symbol}; + +// ─── SC-016: Storage keys & configuration ──────────────────────────────────── + +const OWNER_KEY: Symbol = symbol_short!("owner"); +const TOKEN_KEY: Symbol = symbol_short!("token"); +const APY_KEY: Symbol = symbol_short!("apy"); +const SHARES_KEY: Symbol = symbol_short!("tot_shr"); +const ASSETS_KEY: Symbol = symbol_short!("tot_ast"); +const IDX_KEY: Symbol = symbol_short!("yld_idx"); +const IDX_LED_KEY: Symbol = symbol_short!("idx_led"); + +/// Precision factor used in all fixed-point math (1e8). +const PRECISION: i128 = 100_000_000; + +#[contracttype] +enum DataKey { + UserShares(Address), +} + +#[contract] +pub struct YieldVaultContract; + +#[contractimpl] +impl YieldVaultContract { + /// SC-016: One-time initializer. Sets owner, token address, and initial APY. + /// `apy_bps` is the annual percentage yield in basis points (e.g. 500 = 5%). + pub fn initialize(env: Env, owner: Address, token: Address, apy_bps: u32) { + if env.storage().instance().has(&OWNER_KEY) { + panic!("already initialized"); + } + env.storage().instance().set(&OWNER_KEY, &owner); + env.storage().instance().set(&TOKEN_KEY, &token); + env.storage().instance().set(&APY_KEY, &apy_bps); + // Yield index starts at 1.0 (represented as PRECISION) + env.storage().instance().set(&IDX_KEY, &PRECISION); + env.storage().instance().set(&IDX_LED_KEY, &env.ledger().sequence()); + env.storage().instance().set(&SHARES_KEY, &0i128); + env.storage().instance().set(&ASSETS_KEY, &0i128); + } + + // ─── SC-019: Yield compounding math ────────────────────────────────────── + + /// Compute the current yield index by accruing yield since the last update. + /// Uses integer arithmetic scaled by PRECISION to avoid overflow. + /// Formula: new_index = old_index * (1 + apy_bps/10000 * delta_ledgers / ledgers_per_year) + /// Approximated as: new_index = old_index + old_index * apy_bps * delta / (10000 * LEDGERS_PER_YEAR) + fn current_index(env: &Env) -> i128 { + const LEDGERS_PER_YEAR: i128 = 6_307_200; // ~5s per ledger + let old_index: i128 = env.storage().instance().get(&IDX_KEY).unwrap_or(PRECISION); + let last_ledger: u32 = env.storage().instance().get(&IDX_LED_KEY).unwrap_or(0); + let apy_bps: u32 = env.storage().instance().get(&APY_KEY).unwrap_or(0); + let delta = (env.ledger().sequence() - last_ledger) as i128; + if delta == 0 || apy_bps == 0 { + return old_index; + } + // Scaled addition; all intermediate values stay within i128 for realistic APYs and time windows + let accrued = old_index + .checked_mul(apy_bps as i128).expect("overflow") + .checked_mul(delta).expect("overflow") + / (10_000i128.checked_mul(LEDGERS_PER_YEAR).expect("overflow")); + old_index.checked_add(accrued).expect("overflow") + } + + /// Persist the latest yield index and reset the reference ledger. + fn checkpoint_index(env: &Env) { + let idx = Self::current_index(env); + env.storage().instance().set(&IDX_KEY, &idx); + env.storage().instance().set(&IDX_LED_KEY, &env.ledger().sequence()); + } + + // ─── SC-017: Deposit ────────────────────────────────────────────────────── + + /// Deposit `amount` tokens from `depositor` into the vault. + /// Mints vault shares proportional to the current yield index. + /// shares_minted = amount * PRECISION / current_index + pub fn deposit(env: Env, depositor: Address, amount: i128) { + depositor.require_auth(); + assert!(amount > 0, "amount must be positive"); + + Self::checkpoint_index(&env); + + let token_addr: Address = env.storage().instance().get(&TOKEN_KEY).expect("not initialized"); + let vault_addr = env.current_contract_address(); + + // Pull tokens from depositor into vault + token::Client::new(&env, &token_addr).transfer(&depositor, &vault_addr, &amount); + + let index = Self::current_index(&env); + let shares = amount.checked_mul(PRECISION).expect("overflow") / index; + assert!(shares > 0, "deposit too small"); + + // Update user shares + let user_key = DataKey::UserShares(depositor.clone()); + let prev_shares: i128 = env.storage().persistent().get(&user_key).unwrap_or(0); + env.storage().persistent().set(&user_key, &(prev_shares + shares)); + + // Update totals + let tot_shares: i128 = env.storage().instance().get(&SHARES_KEY).unwrap_or(0); + let tot_assets: i128 = env.storage().instance().get(&ASSETS_KEY).unwrap_or(0); + env.storage().instance().set(&SHARES_KEY, &(tot_shares + shares)); + env.storage().instance().set(&ASSETS_KEY, &(tot_assets + amount)); + + env.events().publish( + (Symbol::new(&env, "Deposited"),), + (depositor, amount, shares), + ); + } + + // ─── SC-018: Withdraw ───────────────────────────────────────────────────── + + /// Burn `shares` from `user` and return the equivalent tokens (principal + yield). + /// assets_out = shares * current_index / PRECISION + pub fn withdraw(env: Env, user: Address, shares: i128) { + user.require_auth(); + assert!(shares > 0, "shares must be positive"); + + Self::checkpoint_index(&env); + + let user_key = DataKey::UserShares(user.clone()); + let user_shares: i128 = env.storage().persistent().get(&user_key).unwrap_or(0); + assert!(user_shares >= shares, "insufficient shares"); + + let index = Self::current_index(&env); + let assets_out = shares.checked_mul(index).expect("overflow") / PRECISION; + assert!(assets_out > 0, "withdrawal too small"); + + // Deduct shares + env.storage().persistent().set(&user_key, &(user_shares - shares)); + + // Update totals (clamp to zero to guard against rounding drift) + let tot_shares: i128 = env.storage().instance().get(&SHARES_KEY).unwrap_or(0); + let tot_assets: i128 = env.storage().instance().get(&ASSETS_KEY).unwrap_or(0); + env.storage().instance().set(&SHARES_KEY, &(tot_shares - shares).max(0)); + env.storage().instance().set(&ASSETS_KEY, &(tot_assets - assets_out).max(0)); + + let token_addr: Address = env.storage().instance().get(&TOKEN_KEY).expect("not initialized"); + let vault_addr = env.current_contract_address(); + token::Client::new(&env, &token_addr).transfer(&vault_addr, &user, &assets_out); + + env.events().publish( + (Symbol::new(&env, "Withdrawn"),), + (user, shares, assets_out), + ); + } + + // ─── View helpers ───────────────────────────────────────────────────────── + + pub fn shares_of(env: Env, user: Address) -> i128 { + env.storage().persistent().get(&DataKey::UserShares(user)).unwrap_or(0) + } + + pub fn total_shares(env: Env) -> i128 { + env.storage().instance().get(&SHARES_KEY).unwrap_or(0) + } + + pub fn total_assets(env: Env) -> i128 { + env.storage().instance().get(&ASSETS_KEY).unwrap_or(0) + } + + pub fn yield_index(env: Env) -> i128 { + Self::current_index(&env) + } +}