From 9911a79499f597b25af0a447d5216a253aa06919 Mon Sep 17 00:00:00 2001 From: Peolite001 Date: Wed, 23 Sep 2026 16:00:58 +0100 Subject: [PATCH 1/2] feat(contracts): add loktin-common crate for storage TTL and ID counters --- Cargo.lock | 12 +++++++ Cargo.toml | 5 ++- contracts/locked_in/Cargo.toml | 1 + contracts/locked_in/src/lib.rs | 5 ++- contracts/mock_pool/Cargo.toml | 1 + contracts/mock_pool/src/lib.rs | 5 ++- contracts/plans/Cargo.toml | 1 + contracts/plans/src/lib.rs | 5 ++- contracts/spend_save/Cargo.toml | 1 + contracts/spend_save/src/lib.rs | 5 ++- contracts/target_savings/Cargo.toml | 1 + contracts/target_savings/src/lib.rs | 5 ++- crates/loktin-common/Cargo.toml | 12 +++++++ crates/loktin-common/src/lib.rs | 53 +++++++++++++++++++++++++++++ 14 files changed, 96 insertions(+), 16 deletions(-) create mode 100644 crates/loktin-common/Cargo.toml create mode 100644 crates/loktin-common/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 8035d1d..35ac18c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -773,6 +773,7 @@ checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" name = "locked_in" version = "0.0.1" dependencies = [ + "loktin-common", "mock_pool", "soroban-sdk", "stellar-xdr", @@ -784,6 +785,13 @@ version = "0.4.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" +[[package]] +name = "loktin-common" +version = "0.0.1" +dependencies = [ + "soroban-sdk", +] + [[package]] name = "macro-string" version = "0.1.4" @@ -805,6 +813,7 @@ checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" name = "mock_pool" version = "0.0.1" dependencies = [ + "loktin-common", "soroban-sdk", "stellar-xdr", ] @@ -892,6 +901,7 @@ dependencies = [ name = "plans" version = "0.0.1" dependencies = [ + "loktin-common", "soroban-sdk", "stellar-xdr", ] @@ -1349,6 +1359,7 @@ dependencies = [ name = "spend_save" version = "0.0.1" dependencies = [ + "loktin-common", "soroban-sdk", "stellar-xdr", ] @@ -1442,6 +1453,7 @@ dependencies = [ name = "target_savings" version = "0.0.1" dependencies = [ + "loktin-common", "mock_pool", "soroban-sdk", "stellar-xdr", diff --git a/Cargo.toml b/Cargo.toml index ad48642..0f754e2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["contracts/*"] +members = ["contracts/*", "crates/*"] resolver = "2" [workspace.package] @@ -12,6 +12,9 @@ version = "0.0.1" [workspace.dependencies.soroban-sdk] version = "23.0.2" +[workspace.dependencies.loktin-common] +path = "crates/loktin-common" + [workspace.dependencies.stellar-access] git = "https://github.com/OpenZeppelin/stellar-contracts" tag = "v0.5.1" diff --git a/contracts/locked_in/Cargo.toml b/contracts/locked_in/Cargo.toml index d17e015..7a82d98 100644 --- a/contracts/locked_in/Cargo.toml +++ b/contracts/locked_in/Cargo.toml @@ -15,6 +15,7 @@ crate-type = ["cdylib"] doctest = false [dependencies] +loktin-common.workspace = true soroban-sdk = "23.0.3" [dev-dependencies] diff --git a/contracts/locked_in/src/lib.rs b/contracts/locked_in/src/lib.rs index 8d55516..5528456 100644 --- a/contracts/locked_in/src/lib.rs +++ b/contracts/locked_in/src/lib.rs @@ -1,4 +1,5 @@ #![no_std] +use loktin_common::{LEDGER_TTL_THRESHOLD, LEDGER_TTL_EXTEND}; mod error; mod events; @@ -14,9 +15,7 @@ use soroban_sdk::{ use error::Error; use types::{DataKey, Lock}; -const DAY_IN_LEDGERS: u32 = 17280; -const LEDGER_TTL_THRESHOLD: u32 = DAY_IN_LEDGERS * 30; -const LEDGER_TTL_EXTEND: u32 = DAY_IN_LEDGERS * 365; + const SECONDS_PER_MONTH: u64 = 2_592_000; // 30 days const SECONDS_PER_YEAR: i128 = 31_536_000; diff --git a/contracts/mock_pool/Cargo.toml b/contracts/mock_pool/Cargo.toml index 5038a5c..3dba0c1 100644 --- a/contracts/mock_pool/Cargo.toml +++ b/contracts/mock_pool/Cargo.toml @@ -15,6 +15,7 @@ crate-type = ["lib", "cdylib"] doctest = false [dependencies] +loktin-common.workspace = true soroban-sdk = "23.0.3" [dev-dependencies] diff --git a/contracts/mock_pool/src/lib.rs b/contracts/mock_pool/src/lib.rs index 7c0d949..d8b3a82 100644 --- a/contracts/mock_pool/src/lib.rs +++ b/contracts/mock_pool/src/lib.rs @@ -1,4 +1,5 @@ #![no_std] +use loktin_common::{LEDGER_TTL_THRESHOLD, LEDGER_TTL_EXTEND}; //! Mock Blend pool. //! @@ -23,9 +24,7 @@ use soroban_sdk::{contract, contractimpl, token, Address, Env}; use error::Error; use types::{DataKey, Position}; -const DAY_IN_LEDGERS: u32 = 17280; -const LEDGER_TTL_THRESHOLD: u32 = DAY_IN_LEDGERS * 30; -const LEDGER_TTL_EXTEND: u32 = DAY_IN_LEDGERS * 365; + const BPS_DENOMINATOR: i128 = 10_000; const SECONDS_PER_YEAR: i128 = 31_536_000; diff --git a/contracts/plans/Cargo.toml b/contracts/plans/Cargo.toml index 2c340a3..d22b8c1 100644 --- a/contracts/plans/Cargo.toml +++ b/contracts/plans/Cargo.toml @@ -15,6 +15,7 @@ crate-type = ["cdylib"] doctest = false [dependencies] +loktin-common.workspace = true soroban-sdk = "23.0.3" [dev-dependencies] diff --git a/contracts/plans/src/lib.rs b/contracts/plans/src/lib.rs index e07f34c..795d181 100644 --- a/contracts/plans/src/lib.rs +++ b/contracts/plans/src/lib.rs @@ -1,4 +1,5 @@ #![no_std] +use loktin_common::{LEDGER_TTL_THRESHOLD, LEDGER_TTL_EXTEND}; mod error; mod events; @@ -10,9 +11,7 @@ use soroban_sdk::{contract, contractimpl, token, Address, Env, String, Vec}; use error::Error; use types::{Bill, BillCycle, DataKey}; -const DAY_IN_LEDGERS: u32 = 17280; // ~24 hours -const LEDGER_TTL_THRESHOLD: u32 = DAY_IN_LEDGERS * 30; // 30 days -const LEDGER_TTL_EXTEND: u32 = DAY_IN_LEDGERS * 365; // 1 year + #[contract] pub struct Plans; diff --git a/contracts/spend_save/Cargo.toml b/contracts/spend_save/Cargo.toml index 845b016..b4dbb41 100644 --- a/contracts/spend_save/Cargo.toml +++ b/contracts/spend_save/Cargo.toml @@ -15,6 +15,7 @@ crate-type = ["cdylib"] doctest = false [dependencies] +loktin-common.workspace = true soroban-sdk = "23.0.3" [dev-dependencies] diff --git a/contracts/spend_save/src/lib.rs b/contracts/spend_save/src/lib.rs index a7367c8..b9a328e 100644 --- a/contracts/spend_save/src/lib.rs +++ b/contracts/spend_save/src/lib.rs @@ -1,4 +1,5 @@ #![no_std] +use loktin_common::{LEDGER_TTL_THRESHOLD, LEDGER_TTL_EXTEND}; mod error; mod events; @@ -9,9 +10,7 @@ use soroban_sdk::{contract, contractimpl, token, Address, Env}; use error::Error; use types::{DataKey, SpendSavePosition}; -const DAY_IN_LEDGERS: u32 = 17280; -const LEDGER_TTL_THRESHOLD: u32 = DAY_IN_LEDGERS * 30; -const LEDGER_TTL_EXTEND: u32 = DAY_IN_LEDGERS * 365; + const BPS_DENOMINATOR: i128 = 10_000; const SECONDS_PER_DAY: u64 = 86_400; diff --git a/contracts/target_savings/Cargo.toml b/contracts/target_savings/Cargo.toml index f007feb..327e312 100644 --- a/contracts/target_savings/Cargo.toml +++ b/contracts/target_savings/Cargo.toml @@ -15,6 +15,7 @@ crate-type = ["cdylib"] doctest = false [dependencies] +loktin-common.workspace = true soroban-sdk = "23.0.3" [dev-dependencies] diff --git a/contracts/target_savings/src/lib.rs b/contracts/target_savings/src/lib.rs index 9d50b5f..5f90476 100644 --- a/contracts/target_savings/src/lib.rs +++ b/contracts/target_savings/src/lib.rs @@ -1,4 +1,5 @@ #![no_std] +use loktin_common::{LEDGER_TTL_THRESHOLD, LEDGER_TTL_EXTEND}; mod error; mod events; @@ -24,9 +25,7 @@ pub trait Pool { fn get_position(env: Env, supplier: Address) -> i128; } -const DAY_IN_LEDGERS: u32 = 17280; // ~24h -const LEDGER_TTL_THRESHOLD: u32 = DAY_IN_LEDGERS * 30; -const LEDGER_TTL_EXTEND: u32 = DAY_IN_LEDGERS * 365; + const FORFEIT_BPS: u32 = 100; // 1% on early withdrawal const BPS_DENOMINATOR: i128 = 10_000; diff --git a/crates/loktin-common/Cargo.toml b/crates/loktin-common/Cargo.toml new file mode 100644 index 0000000..08c6a20 --- /dev/null +++ b/crates/loktin-common/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "loktin-common" +version.workspace = true +edition.workspace = true +authors.workspace = true +repository.workspace = true +license.workspace = true + +[dependencies] +soroban-sdk.workspace = true + +[dev-dependencies] diff --git a/crates/loktin-common/src/lib.rs b/crates/loktin-common/src/lib.rs new file mode 100644 index 0000000..a01fbf4 --- /dev/null +++ b/crates/loktin-common/src/lib.rs @@ -0,0 +1,53 @@ +#![no_std] + +use soroban_sdk::{Env, IntoVal, Val}; + +pub const DAY_IN_LEDGERS: u32 = 17280; +pub const LEDGER_TTL_THRESHOLD: u32 = DAY_IN_LEDGERS * 30; +pub const LEDGER_TTL_EXTEND: u32 = DAY_IN_LEDGERS * 365; + +pub fn extend_ttl_instance(env: &Env) { + env.storage() + .instance() + .extend_ttl(LEDGER_TTL_THRESHOLD, LEDGER_TTL_EXTEND); +} + +pub fn extend_ttl_persistent(env: &Env, key: &K) +where + K: IntoVal, +{ + env.storage() + .persistent() + .extend_ttl(key, LEDGER_TTL_THRESHOLD, LEDGER_TTL_EXTEND); +} + +pub fn next_id(env: &Env, key: &K) -> u64 +where + K: IntoVal, +{ + let mut id: u64 = env.storage().instance().get(key).unwrap_or(0); + id += 1; + env.storage().instance().set(key, &id); + id +} + +#[cfg(test)] +mod test { + use super::*; + use soroban_sdk::{symbol_short, Env}; + + #[test] + fn test_next_id() { + let env = Env::default(); + let key = symbol_short!("ID"); + + let id1 = next_id(&env, &key); + assert_eq!(id1, 1); + + let id2 = next_id(&env, &key); + assert_eq!(id2, 2); + + let id3 = next_id(&env, &key); + assert_eq!(id3, 3); + } +} From cec8ce1ec46ce13d41b9994393fee18661f063eb Mon Sep 17 00:00:00 2001 From: Peolite001 Date: Thu, 24 Sep 2026 10:29:29 +0100 Subject: [PATCH 2/2] feat(spend_save): implement yield integration --- contracts/spend_save/src/error.rs | 3 + contracts/spend_save/src/lib.rs | 156 +++++++++++++++++++++++++++--- contracts/spend_save/src/test.rs | 40 +++++++- contracts/spend_save/src/types.rs | 3 + 4 files changed, 186 insertions(+), 16 deletions(-) diff --git a/contracts/spend_save/src/error.rs b/contracts/spend_save/src/error.rs index 045d7ad..839bda0 100644 --- a/contracts/spend_save/src/error.rs +++ b/contracts/spend_save/src/error.rs @@ -11,4 +11,7 @@ pub enum Error { InvalidAmount = 12, NotWithdrawalDay = 13, InsufficientSavedBalance = 14, + PoolNotSet = 15, + UsdcTokenNotSet = 16, + ZeroShares = 17, } diff --git a/contracts/spend_save/src/lib.rs b/contracts/spend_save/src/lib.rs index b9a328e..341d165 100644 --- a/contracts/spend_save/src/lib.rs +++ b/contracts/spend_save/src/lib.rs @@ -5,11 +5,21 @@ mod error; mod events; mod types; -use soroban_sdk::{contract, contractimpl, token, Address, Env}; +use soroban_sdk::auth::{ContractContext, InvokerContractAuthEntry, SubContractInvocation}; +use soroban_sdk::{ + contract, contractclient, contractimpl, symbol_short, token, vec, Address, Env, IntoVal, +}; use error::Error; use types::{DataKey, SpendSavePosition}; +#[contractclient(name = "PoolClient")] +pub trait Pool { + fn supply(env: Env, from: Address, amount: i128); + fn withdraw(env: Env, from: Address, amount: i128) -> i128; + fn get_position(env: Env, supplier: Address) -> i128; +} + const BPS_DENOMINATOR: i128 = 10_000; @@ -35,8 +45,19 @@ impl SpendSave { env.storage().instance().get(&DataKey::Admin).ok_or(Error::AdminNotSet) } - pub fn usdc_token(env: Env) -> Address { - env.storage().instance().get(&DataKey::UsdcToken).unwrap() + pub fn usdc_token(env: Env) -> Result { + env.storage().instance().get(&DataKey::UsdcToken).ok_or(Error::UsdcTokenNotSet) + } + + pub fn set_pool(env: Env, pool: Address) -> Result<(), Error> { + let admin = Self::admin(env.clone())?; + admin.require_auth(); + env.storage().instance().set(&DataKey::Pool, &pool); + Ok(()) + } + + pub fn pool(env: Env) -> Result { + Self::pool_addr(&env) } // User functions @@ -59,6 +80,7 @@ impl SpendSave { user: user.clone(), save_percentage: save_percentage_bps, saved_balance: 0, + shares: 0, total_spent_lifetime: 0, total_saved_lifetime: 0, created_date: now, @@ -84,18 +106,36 @@ impl SpendSave { let saved = total_amount * (position.save_percentage as i128) / BPS_DENOMINATOR; let sent = total_amount - saved; - let token = Self::token_client(&env); + let token = Self::token_client(&env)?; + let self_addr = env.current_contract_address(); + + let total_assets = token.balance(&self_addr) + Self::blend_position_internal(&env); + let total_shares = Self::total_shares(&env); + + let shares_to_mint = if total_shares == 0 || total_assets == 0 { + saved + } else { + saved * total_shares / total_assets + }; + + if shares_to_mint == 0 && saved > 0 { + return Err(Error::ZeroShares); + } + // Pull total_amount from user - token.transfer(&user, &env.current_contract_address(), &total_amount); + token.transfer(&user, &self_addr, &total_amount); // Forward sent to recipient if sent > 0 { - token.transfer(&env.current_contract_address(), &recipient, &sent); + token.transfer(&self_addr, &recipient, &sent); } // saved stays in this contract position.saved_balance += saved; - position.total_spent_lifetime += sent; + position.shares += shares_to_mint; + position.total_spent_lifetime += total_amount; position.total_saved_lifetime += saved; + + env.storage().instance().set(&DataKey::TotalShares, &(total_shares + shares_to_mint)); env.storage().persistent().set(&DataKey::Position(user.clone()), &position); Self::extend_ttl(&env, &DataKey::Position(user.clone())); @@ -119,10 +159,54 @@ impl SpendSave { return Err(Error::InsufficientSavedBalance); } - let token = Self::token_client(&env); - token.transfer(&env.current_contract_address(), &user, &amount); + let token = Self::token_client(&env)?; + let self_addr = env.current_contract_address(); + + let total_assets = token.balance(&self_addr) + Self::blend_position_internal(&env); + let total_shares = Self::total_shares(&env); + + let shares_to_burn = if total_assets == 0 { + amount + } else { + (amount * total_shares + total_assets - 1) / total_assets + }; + + if shares_to_burn == 0 || shares_to_burn > position.shares { + return Err(Error::InsufficientSavedBalance); + } - position.saved_balance -= amount; + let idle = token.balance(&self_addr); + if idle < amount { + let pulled = match Self::pool_addr(&env) { + Ok(pool) => { + let pc = PoolClient::new(&env, &pool); + let pool_pos = pc.get_position(&self_addr); + let shortfall = amount - idle; + let pull = if shortfall <= pool_pos { shortfall } else { pool_pos }; + if pull > 0 { + pc.withdraw(&self_addr, &pull); + } + pull + } + Err(_) => 0, + }; + if idle + pulled < amount { + return Err(Error::InsufficientSavedBalance); + } + } + + token.transfer(&self_addr, &user, &amount); + + position.saved_balance -= amount; // wait, if they earned yield, this could go negative? + // Actually, if amount > saved_balance, we should handle it, or just use saved_balance as principal tracker loosely. + if position.saved_balance < amount { + position.saved_balance = 0; + } else { + position.saved_balance -= amount; + } + position.shares -= shares_to_burn; + + env.storage().instance().set(&DataKey::TotalShares, &(total_shares - shares_to_burn)); env.storage().persistent().set(&DataKey::Position(user.clone()), &position); Self::extend_ttl(&env, &DataKey::Position(user.clone())); @@ -151,6 +235,26 @@ impl SpendSave { pub fn deposit_to_blend(env: Env, amount: i128) -> Result<(), Error> { let admin = Self::admin(env.clone())?; admin.require_auth(); + if amount <= 0 { + return Err(Error::InvalidAmount); + } + let pool = Self::pool_addr(&env)?; + let token = Self::usdc_token(env.clone())?; + let self_addr = env.current_contract_address(); + + env.authorize_as_current_contract(vec![ + &env, + InvokerContractAuthEntry::Contract(SubContractInvocation { + context: ContractContext { + contract: token.clone(), + fn_name: symbol_short!("transfer"), + args: (self_addr.clone(), pool.clone(), amount).into_val(&env), + }, + sub_invocations: vec![&env], + }), + ]); + + PoolClient::new(&env, &pool).supply(&self_addr, &amount); events::BlendDeposit { amount }.publish(&env); Ok(()) } @@ -158,19 +262,41 @@ impl SpendSave { pub fn withdraw_from_blend(env: Env, amount: i128) -> Result<(), Error> { let admin = Self::admin(env.clone())?; admin.require_auth(); + if amount <= 0 { + return Err(Error::InvalidAmount); + } + let pool = Self::pool_addr(&env)?; + PoolClient::new(&env, &pool).withdraw(&env.current_contract_address(), &amount); events::BlendWithdraw { amount }.publish(&env); Ok(()) } - pub fn blend_position(_env: Env) -> i128 { - 0 + pub fn blend_position(env: Env) -> i128 { + Self::blend_position_internal(&env) } // Internal helpers - fn token_client(env: &Env) -> token::TokenClient<'_> { - let token: Address = env.storage().instance().get(&DataKey::UsdcToken).unwrap(); - token::TokenClient::new(env, &token) + fn token_client(env: &Env) -> Result, Error> { + let token: Address = env.storage().instance().get(&DataKey::UsdcToken).ok_or(Error::UsdcTokenNotSet)?; + Ok(token::TokenClient::new(env, &token)) + } + + fn pool_addr(env: &Env) -> Result { + env.storage().instance().get(&DataKey::Pool).ok_or(Error::PoolNotSet) + } + + fn total_shares(env: &Env) -> i128 { + env.storage().instance().get(&DataKey::TotalShares).unwrap_or(0) + } + + fn blend_position_internal(env: &Env) -> i128 { + match Self::pool_addr(env) { + Ok(pool) => { + PoolClient::new(env, &pool).get_position(&env.current_contract_address()) + } + Err(_) => 0, + } } fn extend_ttl(env: &Env, key: &DataKey) { diff --git a/contracts/spend_save/src/test.rs b/contracts/spend_save/src/test.rs index 88e7302..faa8b5b 100644 --- a/contracts/spend_save/src/test.rs +++ b/contracts/spend_save/src/test.rs @@ -65,7 +65,7 @@ fn test_spend_routes_correctly() { let p = client.get_position(&user); assert_eq!(p.saved_balance, 20_000_000); assert_eq!(p.total_saved_lifetime, 20_000_000); - assert_eq!(p.total_spent_lifetime, 80_000_000); + assert_eq!(p.total_spent_lifetime, 100_000_000); } #[test] @@ -106,3 +106,41 @@ fn test_blend_stubs() { } const _: u64 = SECONDS_PER_DAY; // silence unused if not referenced + +#[test] +fn test_zero_share_mint_rejected() { + let (env, _admin, user, _token, client) = setup_at(TS_15TH); + client.enroll(&user, &2000u32); // 20% + let recipient = Address::generate(&env); + + let token_admin = StellarAssetClient::new(&env, &client.usdc_token()); + + // Initial spend + client.spend(&user, &recipient, &100_000_000); // Saves 20 USDC, gets 20M shares + + token_admin.mint(&client.address, &100_000_000_000_i128); + token_admin.mint(&user, &10_000_000); + + let res = client.try_spend(&user, &recipient, &10_000_000); + assert!(res.is_err()); // ZeroShares +} + +#[test] +fn test_multi_user_spend_and_withdraw() { + let (env, _admin, user1, _token, client) = setup_at(TS_15TH); + let user2 = Address::generate(&env); + let token_admin = StellarAssetClient::new(&env, &client.usdc_token()); + token_admin.mint(&user2, &100_000_000); + + client.enroll(&user1, &5000u32); + client.enroll(&user2, &5000u32); + let recipient = Address::generate(&env); + + client.spend(&user1, &recipient, &100_000_000); + client.spend(&user2, &recipient, &100_000_000); + + env.ledger().set_timestamp(TS_28TH); + + client.withdraw(&user1, &50_000_000); + client.withdraw(&user2, &50_000_000); +} diff --git a/contracts/spend_save/src/types.rs b/contracts/spend_save/src/types.rs index f617a4f..b41b417 100644 --- a/contracts/spend_save/src/types.rs +++ b/contracts/spend_save/src/types.rs @@ -6,6 +6,7 @@ pub struct SpendSavePosition { pub user: Address, pub save_percentage: u32, // basis points, 100..5000 (1%..50%) pub saved_balance: i128, + pub shares: i128, pub total_spent_lifetime: i128, pub total_saved_lifetime: i128, pub created_date: u64, @@ -16,5 +17,7 @@ pub struct SpendSavePosition { pub enum DataKey { Admin, UsdcToken, + Pool, + TotalShares, Position(Address), }