From 82f2771bc3a5b14223e2355b77ec55bd9a7d2d17 Mon Sep 17 00:00:00 2001 From: Kalebtron1 Date: Tue, 23 Jun 2026 23:20:00 -0600 Subject: [PATCH] feat(staking): integrate real DeFindex vault yield (USDC, testnet) Replace the synthetic-APY staking_pool with a DeFindex vault-backed single liquid deposit (apartado) that earns real yield from share appreciation. Contract (backend/contracts/staking_pool): - Pool-custody model: deposit routes USDC into the DeFindex vault as from=contract; per-user Shares(user) ledger; authorize_as_current_contract for the vault's nested token.transfer. - get_position(user) returns live redeemable value via get_asset_amounts_per_shares. - Configurable vault address via init(token, vault); re-init guarded. - ContractError-based handling replaces all unwrap()/assert!/panic!. - Score-support getters: get_total_deposited / get_total_withdrawn / get_tx_count. - Removed stake/unstake, StakeInfo and all synthetic APY math. - 9 unit tests with a mock DeFindex vault (deposit/yield/withdraw/errors/auth). Frontend (USDC + real yield): - queries: get_balance -> get_position (+ total_deposited/withdrawn). - Retiros: removed staking section; balance from get_position; XLM -> USDC. - AppContext: removed STAKING_APY/StakePosition/addStake. Scoring data source (loop intact, no new SBT): - get-user-data / evaluate-and-mint read get_position + get_total_deposited. - calculate-score logic unchanged (hybrid formula deferred). Tooling: - CI workflow (fmt/clippy on staking_pool; build+test all three contracts). - deploy_contracts.sh: staking_pool init now takes --token and --vault. - smoke_test_staking.sh for the testnet deposit/withdraw round-trip. --- .github/workflows/ci.yml | 53 ++++ .gitignore | 2 + api/evaluate-and-mint.js | 46 ++- api/get-user-data.js | 70 +++-- backend/contracts/staking_pool/src/lib.rs | 328 +++++++++++++++------ backend/contracts/staking_pool/src/test.rs | 251 ++++++++++++++++ scripts/deploy_contracts.sh | 13 +- scripts/smoke_test_staking.sh | 69 +++++ src/context/AppContext.tsx | 44 --- src/i18n/locales/en.ts | 22 +- src/i18n/locales/es.ts | 22 +- src/pages/Retiros.tsx | 261 ++++------------ src/stellar/contracts.ts | 3 + src/stellar/queries.ts | 91 +++--- 14 files changed, 823 insertions(+), 452 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 backend/contracts/staking_pool/src/test.rs create mode 100644 scripts/smoke_test_staking.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..a4624bc --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,53 @@ +name: contracts-ci + +# Compila y prueba los contratos Soroban. fmt/clippy se aplican a staking_pool +# (el contrato refactorizado para DeFindex); build + test corren sobre los tres. + +on: + push: + branches: [main] + pull_request: + +jobs: + contracts: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + targets: wasm32-unknown-unknown + + - name: Cache cargo + uses: Swatinem/rust-cache@v2 + with: + workspaces: | + backend/contracts/staking_pool + backend/contracts/vinculo_sbt + backend/contracts/vinculo_lending + + - name: Format check (staking_pool) + working-directory: backend/contracts/staking_pool + run: cargo fmt --check + + - name: Clippy (staking_pool) + working-directory: backend/contracts/staking_pool + run: cargo clippy --all-targets -- -D warnings + + - name: Unit tests (all contracts) + working-directory: backend/contracts + run: | + for c in staking_pool vinculo_sbt vinculo_lending; do + echo "== cargo test: $c ==" + (cd "$c" && cargo test) + done + + - name: Build wasm release (all contracts) + working-directory: backend/contracts + run: | + for c in staking_pool vinculo_sbt vinculo_lending; do + echo "== cargo build (wasm release): $c ==" + (cd "$c" && cargo build --release --target wasm32-unknown-unknown) + done diff --git a/.gitignore b/.gitignore index 17aad7c..f8fdeca 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,5 @@ backend/.env target/ **/target/ Cargo.lock.bak +# Soroban test execution snapshots (generated by `cargo test`) +**/test_snapshots/ diff --git a/api/evaluate-and-mint.js b/api/evaluate-and-mint.js index 77c3def..1bcd949 100644 --- a/api/evaluate-and-mint.js +++ b/api/evaluate-and-mint.js @@ -1,11 +1,12 @@ -import { - Keypair, - rpc, - TransactionBuilder, - Networks, - Operation, - BASE_FEE, +import { + Keypair, + rpc, + TransactionBuilder, + Networks, + Operation, + BASE_FEE, nativeToScVal, + scValToNative, } from "@stellar/stellar-sdk"; import dotenv from "dotenv"; import { createLogger } from "./_logger.js"; @@ -13,9 +14,11 @@ import { validateBody, evaluateAndMintBodySchema, reportValidationError } from " dotenv.config(); -const HORIZON_URL = "https://horizon-testnet.stellar.org"; const RPC_URL = "https://soroban-testnet.stellar.org"; const server = new rpc.Server(RPC_URL); +// staking_pool respaldado por DeFindex. Actualizar tras el redeploy de testnet. +const STAKING_CONTRACT_ID = + process.env.STAKING_CONTRACT_ID || "CAIYBGMKSA5V5EYUFKGD5OCWWS5M34YC7MKUKE3BOQE2WZP3R7A4S2D2"; function tierNameFromValue(tier) { if (tier === 4) return "Platino"; @@ -133,16 +136,31 @@ export default async function handler(req, res) { let effectiveTotalVolume = Number(totalVolume) || fallbackVolumeFromDeposits; + // Fallback: lee la posición real on-chain (depósito + rendimiento del vault DeFindex) + // en lugar del balance nativo XLM, para que el score refleje el apartado del usuario. if (!effectiveTotalVolume || effectiveTotalVolume <= 0) { try { - const resp = await fetch(`${HORIZON_URL}/accounts/${userAddress}`); - if (resp.ok) { - const acct = await resp.json(); - const native = (acct.balances || []).find(b => b.asset_type === 'native'); - effectiveTotalVolume = Number(native?.balance) || effectiveTotalVolume; + const account = await server.getAccount(userAddress); + const tx = new TransactionBuilder(account, { + fee: BASE_FEE, + networkPassphrase: Networks.TESTNET, + }) + .addOperation( + Operation.invokeContractFunction({ + contract: STAKING_CONTRACT_ID, + function: "get_position", + args: [nativeToScVal(userAddress, { type: "address" })], + }) + ) + .setTimeout(30) + .build(); + const sim = await server.simulateTransaction(tx); + if (rpc.Api.isSimulationSuccess(sim) && sim.result) { + const positionXLM = Number(scValToNative(sim.result.retval)) / 10000000; + effectiveTotalVolume = positionXLM || effectiveTotalVolume; } } catch (e) { - log.warn("evaluate_and_mint.balance_fetch_failed", { userAddress, err: e.message }); + log.warn("evaluate_and_mint.position_fetch_failed", { userAddress, err: e.message }); } } diff --git a/api/get-user-data.js b/api/get-user-data.js index bbeed3d..b8defd0 100644 --- a/api/get-user-data.js +++ b/api/get-user-data.js @@ -24,7 +24,7 @@ export default async function handler(req, res) { try { const server = new rpc.Server(RPC_URL); - + let account; try { account = await server.getAccount(address); @@ -33,42 +33,52 @@ export default async function handler(req, res) { return res.status(200).json({ totalXlmDeposited: 0, depositCount: 0, nftLevel: 0 }); } - const transaction = new TransactionBuilder(account, { - fee: BASE_FEE, - networkPassphrase: Networks.TESTNET, - }) - .addOperation( - Operation.invokeContractFunction({ - contract: CONTRACT_ID, - function: "get_balance", - args: [nativeToScVal(address, { type: "address" })], - }) - ) - .setTimeout(30) - .build(); + // Lee un getter del contrato vía simulación y devuelve el valor nativo. + const readContract = async (fnName) => { + const tx = new TransactionBuilder(account, { + fee: BASE_FEE, + networkPassphrase: Networks.TESTNET, + }) + .addOperation( + Operation.invokeContractFunction({ + contract: CONTRACT_ID, + function: fnName, + args: [nativeToScVal(address, { type: "address" })], + }) + ) + .setTimeout(30) + .build(); - const response = await server.simulateTransaction(transaction); + const response = await server.simulateTransaction(tx); + if (rpc.Api.isSimulationSuccess(response) && response.result) { + return scValToNative(response.result.retval); + } + return 0; + }; - let balanceXLM = 0; - if (rpc.Api.isSimulationSuccess(response) && response.result) { - const balanceInStroops = scValToNative(response.result.retval); - balanceXLM = Number(balanceInStroops) / 10000000; - } + // Posición real (depósito + rendimiento del vault), ingreso acumulado y nº de operaciones. + const [positionRaw, depositedRaw, txCountRaw] = await Promise.all([ + readContract("get_position"), + readContract("get_total_deposited"), + readContract("get_tx_count"), + ]); - let nftLevel = 0; - if (balanceXLM >= 50) nftLevel = 1; - if (balanceXLM >= 150) nftLevel = 2; - if (balanceXLM >= 500) nftLevel = 3; - if (balanceXLM >= 1000) nftLevel = 4; + const positionXLM = Number(positionRaw) / 10000000; + const totalDeposited = Number(depositedRaw) / 10000000; + const depositCount = Number(txCountRaw) || 0; - let depositCount = balanceXLM > 0 ? 1 : 0; - if (balanceXLM >= 150) depositCount = 3; - if (balanceXLM >= 500) depositCount = 10; + // El nivel del NFT se aproxima por la posición real (el score canónico lo refina). + let nftLevel = 0; + if (positionXLM >= 50) nftLevel = 1; + if (positionXLM >= 150) nftLevel = 2; + if (positionXLM >= 500) nftLevel = 3; + if (positionXLM >= 1000) nftLevel = 4; - log.info("get_user_data.done", { address, balanceXLM, nftLevel, depositCount }); + log.info("get_user_data.done", { address, positionXLM, totalDeposited, nftLevel, depositCount }); return res.status(200).json({ - totalXlmDeposited: balanceXLM, + totalXlmDeposited: totalDeposited, + currentPosition: positionXLM, depositCount, nftLevel, }); diff --git a/backend/contracts/staking_pool/src/lib.rs b/backend/contracts/staking_pool/src/lib.rs index acb98e5..09dab33 100644 --- a/backend/contracts/staking_pool/src/lib.rs +++ b/backend/contracts/staking_pool/src/lib.rs @@ -1,21 +1,80 @@ #![no_std] -use soroban_sdk::{contract, contractimpl, contracttype, token, Address, Env}; +//! StakingPool — "apartado" con rendimiento REAL vía un vault de DeFindex. +//! +//! El usuario deposita un token (USDC en testnet) y el contrato lo coloca en un +//! vault de DeFindex configurable. El contrato es el custodio: deposita al vault +//! como `from = current_contract_address()` y mantiene un libro interno +//! `Shares(user)` con las shares (dfTokens) que le corresponden a cada usuario. +//! +//! El rendimiento es real: proviene de la apreciación de las shares del vault, no +//! de una fórmula de APY sintética. `get_position(user)` devuelve el valor +//! redimible en vivo consultando `get_asset_amounts_per_shares` del vault. +//! +//! Además expone contadores de ingreso/actividad (`TotalDeposited`, +//! `TotalWithdrawn`, `TxCount`) para que el motor de reputación off-chain pueda +//! seguir midiendo el comportamiento del usuario y habilitar el minteo del SBT. + +use soroban_sdk::auth::{ContractContext, InvokerContractAuthEntry, SubContractInvocation}; +use soroban_sdk::{ + contract, contractclient, contracterror, contractimpl, contracttype, token, vec, Address, Env, + IntoVal, Symbol, Val, Vec, +}; + +// ─── Cliente del vault DeFindex ──────────────────────────────────────────────── +// +// Espejo mínimo de `paltalabs/defindex` (`apps/contracts/vault/src/interface.rs`) +// con solo las funciones que usamos. `deposit` devuelve una tupla compleja en el +// vault real; aquí la recibimos como `Val` (siempre decodifica) y obtenemos las +// shares minteadas por diferencia de balance del dfToken (SEP-41) del vault, que +// es la vía más robusta frente a la codificación exacta del retorno. + +#[contractclient(name = "VaultClient")] +pub trait VaultInterface { + fn deposit( + e: Env, + amounts_desired: Vec, + amounts_min: Vec, + from: Address, + invest: bool, + ) -> Val; + + fn withdraw(e: Env, df_amount: i128, min_amounts_out: Vec, from: Address) -> Vec; + + fn get_asset_amounts_per_shares(e: Env, vault_shares: i128) -> Vec; + + /// dfToken (shares) balance — el vault es un token SEP-41. + fn balance(e: Env, id: Address) -> i128; +} + +// ─── Errores ─────────────────────────────────────────────────────────────────── + +#[contracterror] +#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] +#[repr(u32)] +pub enum Error { + NotInitialized = 1, + AlreadyInitialized = 2, + InvalidAmount = 3, + InsufficientPosition = 4, +} + +// ─── Storage ─────────────────────────────────────────────────────────────────── #[contracttype] #[derive(Clone)] pub enum DataKey { + /// Token subyacente (USDC SAC). Token, - Balance(Address), - Stake(Address), -} - -#[contracttype] -#[derive(Clone, Default)] -pub struct StakeInfo { - pub amount: i128, - pub unlock_time: u64, - pub months: u64, // Necesario para calcular el interés - pub apy: u64, // Guardamos la tasa de interés + /// Dirección del vault DeFindex (configurable vía `init`). + Vault, + /// Shares (dfTokens) del usuario custodiadas por el contrato. + Shares(Address), + /// Ingreso acumulado del usuario (para scoring de reputación). + TotalDeposited(Address), + /// Retiro acumulado del usuario (para scoring de reputación). + TotalWithdrawn(Address), + /// Conteo de operaciones (depósitos + retiros) del usuario. + TxCount(Address), } #[contract] @@ -23,109 +82,210 @@ pub struct StakingContract; #[contractimpl] impl StakingContract { - pub fn init(env: Env, token: Address) { + /// Inicializa el contrato con el token subyacente y el vault DeFindex. + /// Rechaza una segunda inicialización. + pub fn init(env: Env, token: Address, vault: Address) -> Result<(), Error> { + if env.storage().instance().has(&DataKey::Token) { + return Err(Error::AlreadyInitialized); + } env.storage().instance().set(&DataKey::Token, &token); + env.storage().instance().set(&DataKey::Vault, &vault); + Ok(()) } - pub fn deposit(env: Env, user: Address, amount: i128) { + /// Deposita `amount` del token en el vault y acredita las shares al usuario. + /// Devuelve las shares (dfTokens) minteadas. + pub fn deposit(env: Env, user: Address, amount: i128) -> Result { user.require_auth(); - assert!(amount > 0, "El monto debe ser mayor a 0"); + if amount <= 0 { + return Err(Error::InvalidAmount); + } - let token_addr: Address = env.storage().instance().get(&DataKey::Token).unwrap(); - let client = token::Client::new(&env, &token_addr); - // OPT: pass &user directly — no clone needed before the storage key below - client.transfer(&user, &env.current_contract_address(), &amount); + let token_addr = Self::read_token(&env)?; + let vault_addr = Self::read_vault(&env)?; + let this = env.current_contract_address(); - let key = DataKey::Balance(user); - let mut balance: i128 = env.storage().persistent().get(&key).unwrap_or(0); - balance += amount; - env.storage().persistent().set(&key, &balance); - } + // 1. El usuario transfiere el token al contrato. + let token_client = token::Client::new(&env, &token_addr); + token_client.transfer(&user, &this, &amount); - pub fn withdraw(env: Env, user: Address, amount: i128) { - user.require_auth(); - assert!(amount > 0, "El monto debe ser mayor a 0"); + // 2. El contrato deposita al vault como custodio e invierte para generar yield. + // El vault hará `token.transfer(this -> vault, amount)` dos niveles abajo; + // autorizamos ese sub-invoke en nombre del contrato (la `require_auth` del + // propio `deposit` la cubre la auth de invocador por ser el llamador directo). + let vault = VaultClient::new(&env, &vault_addr); + let amounts_desired = vec![&env, amount]; + let amounts_min = vec![&env, amount]; - let key = DataKey::Balance(user.clone()); - let mut balance: i128 = env.storage().persistent().get(&key).unwrap_or(0); - assert!(balance >= amount, "Saldo disponible insuficiente"); + let transfer_args: Vec = (this.clone(), vault_addr.clone(), amount).into_val(&env); + env.authorize_as_current_contract(vec![ + &env, + InvokerContractAuthEntry::Contract(SubContractInvocation { + context: ContractContext { + contract: token_addr.clone(), + fn_name: Symbol::new(&env, "transfer"), + args: transfer_args, + }, + sub_invocations: vec![&env], + }), + ]); - balance -= amount; - env.storage().persistent().set(&key, &balance); + let shares_before = vault.balance(&this); + vault.deposit(&amounts_desired, &amounts_min, &this, &true); + let shares_after = vault.balance(&this); + let minted = shares_after - shares_before; - let token_addr: Address = env.storage().instance().get(&DataKey::Token).unwrap(); - let client = token::Client::new(&env, &token_addr); - // OPT: &user is still valid — no second clone needed - client.transfer(&env.current_contract_address(), &user, &amount); + // 3. Contabilidad interna por usuario. + let shares_key = DataKey::Shares(user.clone()); + let prev_shares: i128 = env.storage().persistent().get(&shares_key).unwrap_or(0); + env.storage() + .persistent() + .set(&shares_key, &(prev_shares + minted)); + + Self::bump_counter(&env, &DataKey::TotalDeposited(user.clone()), amount); + Self::increment_tx(&env, &user); + + Ok(minted) } - pub fn stake(env: Env, user: Address, amount: i128, months: u64) { + /// Retira hasta `amount` del token redimiendo shares del vault. + /// Si `amount` supera la posición, retira todo. Devuelve el monto realmente + /// entregado al usuario (puede diferir levemente por redondeo del vault). + pub fn withdraw(env: Env, user: Address, amount: i128) -> Result { user.require_auth(); - assert!(amount > 0, "El monto debe ser mayor a 0"); - assert!(months == 1 || months == 3 || months == 6 || months == 12, "Plazo invalido"); + if amount <= 0 { + return Err(Error::InvalidAmount); + } - // OPT: build keys once, reuse — eliminates 6 redundant user.clone() calls - let stake_key = DataKey::Stake(user.clone()); - let balance_key = DataKey::Balance(user); + let token_addr = Self::read_token(&env)?; + let vault_addr = Self::read_vault(&env)?; + let this = env.current_contract_address(); - let stake_info: StakeInfo = env.storage().persistent().get(&stake_key).unwrap_or_default(); - assert!(stake_info.amount == 0, "Ya tienes un stake activo."); + let shares_key = DataKey::Shares(user.clone()); + let user_shares: i128 = env.storage().persistent().get(&shares_key).unwrap_or(0); + if user_shares <= 0 { + return Err(Error::InsufficientPosition); + } - let mut balance: i128 = env.storage().persistent().get(&balance_key).unwrap_or(0); - assert!(balance >= amount, "Saldo insuficiente para stakear"); + let vault = VaultClient::new(&env, &vault_addr); + let position = vault + .get_asset_amounts_per_shares(&user_shares) + .get(0) + .unwrap_or(0); + if position <= 0 { + return Err(Error::InsufficientPosition); + } - balance -= amount; - env.storage().persistent().set(&balance_key, &balance); - - let apy = match months { - 1 => 4, - 3 => 7, - 6 => 11, - 12 => 18, - _ => 17, + // Shares a quemar: todas si se pide >= posición, si no proporcional (floor). + let shares_to_burn = if amount >= position { + user_shares + } else { + (user_shares * amount) / position }; + if shares_to_burn <= 0 { + return Err(Error::InvalidAmount); + } + + // Redime del vault y mide el token efectivamente recibido. + let token_client = token::Client::new(&env, &token_addr); + let token_before = token_client.balance(&this); + let min_amounts_out = vec![&env, 0i128]; + vault.withdraw(&shares_to_burn, &min_amounts_out, &this); + let token_after = token_client.balance(&this); + let received = token_after - token_before; + + // Entrega al usuario y actualiza contabilidad. + token_client.transfer(&this, &user, &received); + env.storage() + .persistent() + .set(&shares_key, &(user_shares - shares_to_burn)); + + Self::bump_counter(&env, &DataKey::TotalWithdrawn(user.clone()), received); + Self::increment_tx(&env, &user); + + Ok(received) + } - // MODO HACKATHON: 1 mes = 60 segundos - let new_stake = StakeInfo { - amount, - unlock_time: env.ledger().timestamp() + months * 60, - months, - apy, + /// Valor redimible en vivo (token subyacente) de la posición del usuario, + /// incluyendo el rendimiento real acumulado. Reemplaza a `get_balance`. + pub fn get_position(env: Env, user: Address) -> i128 { + let user_shares: i128 = env + .storage() + .persistent() + .get(&DataKey::Shares(user)) + .unwrap_or(0); + if user_shares <= 0 { + return 0; + } + let Ok(vault_addr) = Self::read_vault(&env) else { + return 0; }; - env.storage().persistent().set(&stake_key, &new_stake); + let vault = VaultClient::new(&env, &vault_addr); + vault + .get_asset_amounts_per_shares(&user_shares) + .get(0) + .unwrap_or(0) } - pub fn unstake(env: Env, user: Address) { - user.require_auth(); + /// Shares (dfTokens) del usuario custodiadas por el contrato. + pub fn get_shares(env: Env, user: Address) -> i128 { + env.storage() + .persistent() + .get(&DataKey::Shares(user)) + .unwrap_or(0) + } - // OPT: build keys once — eliminates 4 redundant user.clone() calls - let stake_key = DataKey::Stake(user.clone()); - let balance_key = DataKey::Balance(user); + /// Ingreso acumulado del usuario (para scoring). + pub fn get_total_deposited(env: Env, user: Address) -> i128 { + env.storage() + .persistent() + .get(&DataKey::TotalDeposited(user)) + .unwrap_or(0) + } - let stake_info: StakeInfo = env.storage().persistent().get(&stake_key).unwrap_or_default(); - assert!(stake_info.amount > 0, "No tienes fondos en stake"); + /// Retiro acumulado del usuario (para scoring). + pub fn get_total_withdrawn(env: Env, user: Address) -> i128 { + env.storage() + .persistent() + .get(&DataKey::TotalWithdrawn(user)) + .unwrap_or(0) + } - let current_time = env.ledger().timestamp(); - assert!(current_time >= stake_info.unlock_time, "El periodo de staking aun no termina"); + /// Conteo de operaciones del usuario (para scoring de actividad). + pub fn get_tx_count(env: Env, user: Address) -> u32 { + env.storage() + .persistent() + .get(&DataKey::TxCount(user)) + .unwrap_or(0) + } - // Calculo de intereses: (Monto * APY * Meses) / 1200 - let interest = (stake_info.amount * (stake_info.apy as i128) * (stake_info.months as i128)) / 1200; - let total_to_return = stake_info.amount + interest; + // ─── Helpers internos ────────────────────────────────────────────────────── - // OPT: replace field-by-field zeroing with Default::default() — single host call - env.storage().persistent().set(&stake_key, &StakeInfo::default()); + fn read_token(env: &Env) -> Result { + env.storage() + .instance() + .get(&DataKey::Token) + .ok_or(Error::NotInitialized) + } - let mut balance: i128 = env.storage().persistent().get(&balance_key).unwrap_or(0); - balance += total_to_return; - env.storage().persistent().set(&balance_key, &balance); + fn read_vault(env: &Env) -> Result { + env.storage() + .instance() + .get(&DataKey::Vault) + .ok_or(Error::NotInitialized) } - pub fn get_balance(env: Env, user: Address) -> i128 { - env.storage().persistent().get(&DataKey::Balance(user)).unwrap_or(0) + fn bump_counter(env: &Env, key: &DataKey, delta: i128) { + let prev: i128 = env.storage().persistent().get(key).unwrap_or(0); + env.storage().persistent().set(key, &(prev + delta)); } - pub fn get_stake(env: Env, user: Address) -> (i128, u64, u64, u64) { - let s: StakeInfo = env.storage().persistent().get(&DataKey::Stake(user)).unwrap_or_default(); - (s.amount, s.unlock_time, s.months, s.apy) + fn increment_tx(env: &Env, user: &Address) { + let key = DataKey::TxCount(user.clone()); + let prev: u32 = env.storage().persistent().get(&key).unwrap_or(0); + env.storage().persistent().set(&key, &(prev + 1)); } } + +#[cfg(test)] +mod test; diff --git a/backend/contracts/staking_pool/src/test.rs b/backend/contracts/staking_pool/src/test.rs new file mode 100644 index 0000000..fdec715 --- /dev/null +++ b/backend/contracts/staking_pool/src/test.rs @@ -0,0 +1,251 @@ +#![cfg(test)] +extern crate std; + +use crate::{Error, StakingContract, StakingContractClient}; +use soroban_sdk::{ + contract, contractimpl, contracttype, testutils::Address as _, token, vec, Address, Env, Vec, +}; + +// ─── Mock vault DeFindex ─────────────────────────────────────────────────────── +// +// Implementa la interfaz mínima que usa `staking_pool`. Mintea shares 1:1 con el +// monto depositado y simula la apreciación (rendimiento real) con un `rate` en +// basis points: `valor = shares * rate / 10000`. Por defecto 10000 (1.0x). + +#[contracttype] +#[derive(Clone)] +enum VaultKey { + Token, + Rate, + Shares(Address), +} + +#[contract] +pub struct MockVault; + +#[contractimpl] +impl MockVault { + pub fn init(env: Env, token: Address) { + env.storage().instance().set(&VaultKey::Token, &token); + env.storage().instance().set(&VaultKey::Rate, &10_000i128); + } + + /// Ajusta el factor de apreciación (bps) para simular yield acumulado. + pub fn set_rate(env: Env, bps: i128) { + env.storage().instance().set(&VaultKey::Rate, &bps); + } + + fn rate(env: &Env) -> i128 { + env.storage() + .instance() + .get(&VaultKey::Rate) + .unwrap_or(10_000) + } + + pub fn deposit( + env: Env, + amounts_desired: Vec, + _amounts_min: Vec, + from: Address, + _invest: bool, + ) -> i128 { + from.require_auth(); + let amount = amounts_desired.get(0).unwrap_or(0); + let token_addr: Address = env.storage().instance().get(&VaultKey::Token).unwrap(); + token::Client::new(&env, &token_addr).transfer( + &from, + &env.current_contract_address(), + &amount, + ); + // Mintea shares 1:1 con el monto depositado. + let key = VaultKey::Shares(from); + let prev: i128 = env.storage().persistent().get(&key).unwrap_or(0); + env.storage().persistent().set(&key, &(prev + amount)); + amount + } + + pub fn withdraw( + env: Env, + df_amount: i128, + _min_amounts_out: Vec, + from: Address, + ) -> Vec { + from.require_auth(); + let out = df_amount * Self::rate(&env) / 10_000; + let key = VaultKey::Shares(from.clone()); + let prev: i128 = env.storage().persistent().get(&key).unwrap_or(0); + env.storage().persistent().set(&key, &(prev - df_amount)); + let token_addr: Address = env.storage().instance().get(&VaultKey::Token).unwrap(); + token::Client::new(&env, &token_addr).transfer( + &env.current_contract_address(), + &from, + &out, + ); + vec![&env, out] + } + + pub fn get_asset_amounts_per_shares(env: Env, vault_shares: i128) -> Vec { + vec![&env, vault_shares * Self::rate(&env) / 10_000] + } + + pub fn balance(env: Env, id: Address) -> i128 { + env.storage() + .persistent() + .get(&VaultKey::Shares(id)) + .unwrap_or(0) + } +} + +// ─── Helpers de test ─────────────────────────────────────────────────────────── + +struct Setup { + env: Env, + staking: StakingContractClient<'static>, + token_addr: Address, + token_admin: soroban_sdk::token::StellarAssetClient<'static>, + vault_addr: Address, + vault: MockVaultClient<'static>, +} + +fn setup() -> Setup { + let env = Env::default(); + env.mock_all_auths_allowing_non_root_auth(); + + // USDC simulado (Stellar Asset Contract). + let issuer = Address::generate(&env); + let sac = env.register_stellar_asset_contract_v2(issuer); + let token_addr = sac.address(); + let token_admin = soroban_sdk::token::StellarAssetClient::new(&env, &token_addr); + + // Mock vault inicializado con ese token. + let vault_addr = env.register_contract(None, MockVault); + let vault = MockVaultClient::new(&env, &vault_addr); + vault.init(&token_addr); + + // Staking pool inicializado con token + vault. + let staking_addr = env.register_contract(None, StakingContract); + let staking = StakingContractClient::new(&env, &staking_addr); + staking.init(&token_addr, &vault_addr); + + Setup { + env, + staking, + token_addr, + token_admin, + vault_addr, + vault, + } +} + +#[test] +fn test_init_rejects_reinit() { + let s = setup(); + let res = s.staking.try_init(&s.token_addr, &s.vault_addr); + assert_eq!(res, Err(Ok(Error::AlreadyInitialized))); +} + +#[test] +fn test_deposit_records_shares_and_counters() { + let s = setup(); + let user = Address::generate(&s.env); + s.token_admin.mint(&user, &1_000); + + let minted = s.staking.deposit(&user, &1_000); + assert_eq!(minted, 1_000); // 1:1 al rate por defecto + + assert_eq!(s.staking.get_shares(&user), 1_000); + assert_eq!(s.staking.get_position(&user), 1_000); + assert_eq!(s.staking.get_total_deposited(&user), 1_000); + assert_eq!(s.staking.get_tx_count(&user), 1); + // El token salió de la wallet del usuario hacia el vault. + assert_eq!(token::Client::new(&s.env, &s.token_addr).balance(&user), 0); +} + +#[test] +fn test_get_position_reflects_real_yield() { + let s = setup(); + let user = Address::generate(&s.env); + s.token_admin.mint(&user, &1_000); + s.staking.deposit(&user, &1_000); + + // El vault aprecia 10%: la posición sube sin tocar las shares. + s.vault.set_rate(&11_000); + assert_eq!(s.staking.get_shares(&user), 1_000); + assert_eq!(s.staking.get_position(&user), 1_100); +} + +#[test] +fn test_partial_withdraw() { + let s = setup(); + let user = Address::generate(&s.env); + s.token_admin.mint(&user, &1_000); + s.staking.deposit(&user, &1_000); + + let received = s.staking.withdraw(&user, &400); + assert_eq!(received, 400); + assert_eq!(s.staking.get_shares(&user), 600); + assert_eq!(s.staking.get_position(&user), 600); + assert_eq!(s.staking.get_total_withdrawn(&user), 400); + assert_eq!(s.staking.get_tx_count(&user), 2); // 1 depósito + 1 retiro + assert_eq!( + token::Client::new(&s.env, &s.token_addr).balance(&user), + 400 + ); +} + +#[test] +fn test_full_withdraw_with_yield() { + let s = setup(); + let user = Address::generate(&s.env); + s.token_admin.mint(&user, &1_000); + s.staking.deposit(&user, &1_000); + + // Fondea el vault para poder pagar el rendimiento y aprecia 10%. + s.token_admin.mint(&s.vault_addr, &1_000); + s.vault.set_rate(&11_000); + + // Pedir más que la posición retira todo (principal + yield). + let received = s.staking.withdraw(&user, &10_000); + assert_eq!(received, 1_100); + assert_eq!(s.staking.get_shares(&user), 0); + assert_eq!(s.staking.get_position(&user), 0); + assert_eq!( + token::Client::new(&s.env, &s.token_addr).balance(&user), + 1_100 + ); +} + +#[test] +fn test_deposit_invalid_amount() { + let s = setup(); + let user = Address::generate(&s.env); + let res = s.staking.try_deposit(&user, &0); + assert_eq!(res, Err(Ok(Error::InvalidAmount))); +} + +#[test] +fn test_withdraw_without_position() { + let s = setup(); + let user = Address::generate(&s.env); + let res = s.staking.try_withdraw(&user, &100); + assert_eq!(res, Err(Ok(Error::InsufficientPosition))); +} + +#[test] +fn test_not_initialized() { + let env = Env::default(); + env.mock_all_auths(); + let staking_addr = env.register_contract(None, StakingContract); + let staking = StakingContractClient::new(&env, &staking_addr); + let user = Address::generate(&env); + let res = staking.try_deposit(&user, &100); + assert_eq!(res, Err(Ok(Error::NotInitialized))); +} + +#[test] +fn test_get_position_zero_for_new_user() { + let s = setup(); + let user = Address::generate(&s.env); + assert_eq!(s.staking.get_position(&user), 0); + assert_eq!(s.staking.get_shares(&user), 0); +} diff --git a/scripts/deploy_contracts.sh b/scripts/deploy_contracts.sh index c3ba645..523f698 100755 --- a/scripts/deploy_contracts.sh +++ b/scripts/deploy_contracts.sh @@ -5,7 +5,9 @@ # Required env vars: # ADMIN_SECRET — deployer secret key (S...) # ADMIN_PUBLIC — matching public key (G...) -# TOKEN_ADDRESS — SAC address of the token used by staking_pool and vinculo_lending +# TOKEN_ADDRESS — SAC address of the underlying token (USDC en testnet) usado por +# staking_pool y vinculo_lending. Debe ser el MISMO activo del vault. +# VAULT_ADDRESS — dirección del vault DeFindex (USDC) que respalda el staking_pool # # Optional: # NETWORK — defaults to "testnet" @@ -13,7 +15,8 @@ # Usage: # export ADMIN_SECRET="S..." # export ADMIN_PUBLIC="G..." -# export TOKEN_ADDRESS="C..." +# export TOKEN_ADDRESS="C..." # SAC del USDC del vault +# export VAULT_ADDRESS="CBMVK2JK6NTOT2O4HNQAIQFJY232BHKGLIMXDVQVHIIZKDACXDFZDWHN" # bash scripts/deploy_contracts.sh set -euo pipefail @@ -25,6 +28,7 @@ CONTRACTS_DIR="backend/contracts" : "${ADMIN_SECRET:?Set ADMIN_SECRET before running this script}" : "${ADMIN_PUBLIC:?Set ADMIN_PUBLIC before running this script}" : "${TOKEN_ADDRESS:?Set TOKEN_ADDRESS before running this script}" +: "${VAULT_ADDRESS:?Set VAULT_ADDRESS (DeFindex vault) before running this script}" echo "==> Network: $NETWORK" echo "==> Admin: $ADMIN_PUBLIC" @@ -79,8 +83,9 @@ stellar contract invoke \ --source "$ADMIN_SECRET" \ --network "$NETWORK" \ -- init \ - --token "$TOKEN_ADDRESS" -echo "==> staking_pool initialized" + --token "$TOKEN_ADDRESS" \ + --vault "$VAULT_ADDRESS" +echo "==> staking_pool initialized (token + DeFindex vault)" # ── 3. vinculo_lending ──────────────────────────────────────────────────────── LENDING_CONTRACT_ID=$(deploy_contract "vinculo_lending") diff --git a/scripts/smoke_test_staking.sh b/scripts/smoke_test_staking.sh new file mode 100644 index 0000000..9d58392 --- /dev/null +++ b/scripts/smoke_test_staking.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +# smoke_test_staking.sh — prueba end-to-end del staking_pool respaldado por DeFindex en testnet. +# +# Flujo: verifica el vault → deposita → lee get_position → retira, imprimiendo tx hashes. +# +# Requisitos: +# - stellar CLI instalado y una identidad fondeada (alias en SOURCE). +# - La identidad debe tener el activo USDC del vault (trustline + saldo). El activo es +# USDC:GATALTGTWIOT6BUDBCZM3Q4OQ4BO2COLOAZ7IYSKPLC2PMSOPPGF5V56 +# (SAC: CAQCFVLOBK5GIULPNZRGATJJMIZL5BSP7X5YJVMGCPTUEPFM4AVSRCJU) +# +# Env vars: +# SOURCE — alias de identidad stellar (p.ej. "vyn_smoke") o secret key S... +# STAKING_CONTRACT_ID — id del staking_pool ya desplegado e inicializado +# AMOUNT — monto en stroops (7 decimales). Default 10000000 (= 1 USDC) +# VAULT_ADDRESS — default: vault USDC de DeFindex en testnet +# TOKEN_ADDRESS — default: SAC del USDC del vault +# NETWORK — default: testnet +# +# Uso: +# export SOURCE="vyn_smoke" +# export STAKING_CONTRACT_ID="C..." +# bash scripts/smoke_test_staking.sh + +set -euo pipefail + +NETWORK="${NETWORK:-testnet}" +AMOUNT="${AMOUNT:-10000000}" +VAULT_ADDRESS="${VAULT_ADDRESS:-CBMVK2JK6NTOT2O4HNQAIQFJY232BHKGLIMXDVQVHIIZKDACXDFZDWHN}" +TOKEN_ADDRESS="${TOKEN_ADDRESS:-CAQCFVLOBK5GIULPNZRGATJJMIZL5BSP7X5YJVMGCPTUEPFM4AVSRCJU}" + +: "${SOURCE:?Set SOURCE (stellar identity alias or secret key)}" +: "${STAKING_CONTRACT_ID:?Set STAKING_CONTRACT_ID (deployed + initialized staking_pool)}" + +USER_ADDR=$(stellar keys address "$SOURCE" 2>/dev/null || echo "$SOURCE") + +echo "==> Network: $NETWORK" +echo "==> Staking pool: $STAKING_CONTRACT_ID" +echo "==> Vault: $VAULT_ADDRESS" +echo "==> Token (USDC): $TOKEN_ADDRESS" +echo "==> User: $USER_ADDR" +echo "==> Amount: $AMOUNT stroops" + +echo "" +echo "── 0. Verificar que el vault está vivo (get_assets) ──" +stellar contract invoke --id "$VAULT_ADDRESS" --source "$SOURCE" --network "$NETWORK" -- get_assets + +echo "" +echo "── 1. Posición inicial (get_position) ──" +stellar contract invoke --id "$STAKING_CONTRACT_ID" --source "$SOURCE" --network "$NETWORK" -- get_position --user "$USER_ADDR" + +echo "" +echo "── 2. Depositar $AMOUNT (deposit) ──" +stellar contract invoke --send=yes --id "$STAKING_CONTRACT_ID" --source "$SOURCE" --network "$NETWORK" -- deposit --user "$USER_ADDR" --amount "$AMOUNT" + +echo "" +echo "── 3. Posición tras depósito (get_position) ──" +stellar contract invoke --id "$STAKING_CONTRACT_ID" --source "$SOURCE" --network "$NETWORK" -- get_position --user "$USER_ADDR" + +echo "" +echo "── 4. Retirar todo (withdraw, monto alto = todo) ──" +stellar contract invoke --send=yes --id "$STAKING_CONTRACT_ID" --source "$SOURCE" --network "$NETWORK" -- withdraw --user "$USER_ADDR" --amount 1000000000000 + +echo "" +echo "── 5. Posición final (get_position) ──" +stellar contract invoke --id "$STAKING_CONTRACT_ID" --source "$SOURCE" --network "$NETWORK" -- get_position --user "$USER_ADDR" + +echo "" +echo "✅ Smoke-test completado. Revisa los tx hashes arriba en https://stellar.expert/explorer/testnet" diff --git a/src/context/AppContext.tsx b/src/context/AppContext.tsx index 890eb82..e0dcc27 100644 --- a/src/context/AppContext.tsx +++ b/src/context/AppContext.tsx @@ -16,16 +16,6 @@ export interface Withdrawal { txHash: string; } -export interface StakePosition { - id: string; - amount: number; - months: number; - apy: number; - startDate: Date; - endDate: Date; - status: "active" | "completed"; -} - interface AppState { balance: number; deposits: Deposit[]; @@ -35,7 +25,6 @@ interface AppState { creditAmount: number; creditWithdrawn: boolean; withdrawals: Withdrawal[]; - stakes: StakePosition[]; } interface AppContextType extends AppState { @@ -43,7 +32,6 @@ interface AppContextType extends AppState { // -> simulateWeek eliminada withdrawCredit: () => void; addWithdrawal: (amount: number, txHash: string) => void; - addStake: (amount: number, months: number) => void; showSuccess: boolean; setShowSuccess: (v: boolean) => void; showUnlockCelebration: boolean; @@ -52,13 +40,6 @@ interface AppContextType extends AppState { setScoreAnomaly: (v: boolean) => void; } -const STAKING_APY: Record = { - 1: 4, - 3: 7, - 6: 11, - 12: 18, -}; - const AppContext = createContext(null); export const useApp = () => { @@ -77,7 +58,6 @@ export const AppProvider: React.FC<{ children: React.ReactNode }> = ({ children creditAmount: 300, creditWithdrawn: false, withdrawals: [], - stakes: [], }); const [showSuccess, setShowSuccess] = useState(false); const [showUnlockCelebration, setShowUnlockCelebration] = useState(false); @@ -132,29 +112,6 @@ export const AppProvider: React.FC<{ children: React.ReactNode }> = ({ children })); }, []); - const addStake = useCallback((amount: number, months: number) => { - const apy = STAKING_APY[months] || 4; - const startDate = new Date(); - const endDate = new Date(startDate); - endDate.setMonth(endDate.getMonth() + months); - setState((prev) => ({ - ...prev, - balance: Math.max(0, prev.balance - amount), - stakes: [ - { - id: uuidv4(), - amount, - months, - apy, - startDate, - endDate, - status: "active", - }, - ...prev.stakes, - ], - })); - }, []); - return ( = ({ children // -> simulateWeek eliminada del Provider withdrawCredit, addWithdrawal, - addStake, showSuccess, setShowSuccess, showUnlockCelebration, diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 8967992..367bb83 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -226,30 +226,16 @@ const en = { subtitle: "Send funds to your wallet", available_balance: "Available balance", withdraw_button: "Withdraw", - staking_section_title: "Staking", - staking_card_title: "Generate returns", - staking_card_description: "Lock your funds and earn interest", - staking_empty_title: "No staking positions", - staking_empty_description: - "Choose a term above to start generating returns", - staking_active_label: "Active Position", - staking_locked_label: "Locked for {{months}} month(s)", - staking_apy_label: "{{apy}}% APY Generated", - staking_earnings_label: "Earnings", - staking_ready: "Ready to withdraw!", - staking_time_left: "{{minutes}}m {{seconds}}s remaining", - unstake_button: "Withdraw Investment", + yield_card_title: "Real yield", + yield_card_description: "Your savings earn yield in the DeFindex vault", + yield_label: "Earned", modal_withdraw_title: "Withdraw funds", modal_withdraw_subtitle: "Enter the amount you want to send to your wallet", modal_confirm_button: "Confirm Withdrawal", modal_all_button: "ALL", modal_signing: "Processing...", modal_success_title: "Withdrawal Successful", - modal_staking_title: "Staking", - modal_staking_confirm: "Lock Funds", - modal_staking_signing: "Processing on Soroban...", - modal_staking_signing_sub: "Confirm in Freighter", - modal_staking_success: "Transaction Successful!", + modal_error_title: "Couldn't complete", error_insufficient: "Insufficient balance", }, diff --git a/src/i18n/locales/es.ts b/src/i18n/locales/es.ts index 635dfb3..df4c372 100644 --- a/src/i18n/locales/es.ts +++ b/src/i18n/locales/es.ts @@ -255,30 +255,16 @@ const es = { subtitle: "Envía fondos a tu wallet", available_balance: "Saldo disponible", withdraw_button: "Retirar", - staking_section_title: "Staking", - staking_card_title: "Generar rendimientos", - staking_card_description: "Bloquea tus fondos y gana intereses", - staking_empty_title: "Sin posiciones de staking", - staking_empty_description: - "Elige un plazo arriba para empezar a generar rendimientos", - staking_active_label: "Posición Activa", - staking_locked_label: "Bloqueado a {{months}} mes(es)", - staking_apy_label: "{{apy}}% APY Generado", - staking_earnings_label: "Ganancia", - staking_ready: "¡Listo para retirar!", - staking_time_left: "{{minutes}}m {{seconds}}s restantes", - unstake_button: "Retirar Inversión", + yield_card_title: "Rendimiento real", + yield_card_description: "Tu apartado genera yield en el vault DeFindex", + yield_label: "Generado", modal_withdraw_title: "Retirar fondos", modal_withdraw_subtitle: "Indica la cantidad que deseas enviar a tu wallet", modal_confirm_button: "Confirmar Retiro", modal_all_button: "TODO", modal_signing: "Procesando...", modal_success_title: "Retiro Exitoso", - modal_staking_title: "Staking", - modal_staking_confirm: "Bloquear Fondos", - modal_staking_signing: "Procesando en Soroban...", - modal_staking_signing_sub: "Confirma en Freighter", - modal_staking_success: "¡Transacción Exitosa!", + modal_error_title: "No se pudo completar", error_insufficient: "Saldo insuficiente", }, diff --git a/src/pages/Retiros.tsx b/src/pages/Retiros.tsx index ea0fe68..cb95dd6 100644 --- a/src/pages/Retiros.tsx +++ b/src/pages/Retiros.tsx @@ -1,7 +1,7 @@ import { useState, useEffect, useCallback } from "react"; import { ArrowDownToLine, Fingerprint, CheckCircle2, - X, Lock, TrendingUp, Clock, Sparkles, Unlock, Smartphone + X, TrendingUp, Sparkles, Smartphone } from "lucide-react"; import { useTranslation } from "react-i18next"; import { useApp } from "@/context/AppContext"; @@ -10,42 +10,26 @@ import BottomNav from "@/components/BottomNav"; import logoVin from "@/assets/logo-vin.png"; import { useMobileWallet } from "@/hooks/useMobileWallet"; -import { TransactionBuilder, Networks, Operation, BASE_FEE, nativeToScVal, rpc } from "@stellar/stellar-sdk"; +import { TransactionBuilder, Networks, Operation, BASE_FEE, nativeToScVal, rpc, xdr } from "@stellar/stellar-sdk"; import { CONTRACT_ID, RPC_URL } from "@/stellar/contracts"; -import { fetchContractBalance, fetchStakeInfo } from "@/stellar/queries"; - -const STAKING_OPTIONS = [ - { months: 1, apy: 4, label: "1 Mes" }, - { months: 3, apy: 7, label: "3 Meses" }, - { months: 6, apy: 11, label: "6 Meses" }, - { months: 12, apy: 18, label: "12 Meses" }, -]; +import { fetchContractBalance, fetchTotalDeposited, fetchTotalWithdrawn } from "@/stellar/queries"; const Retiros = () => { const { addWithdrawal } = useApp(); const { isMobile, provider, connect, sign } = useMobileWallet(); const { t } = useTranslation(); + // Posición real en vivo (depósito + rendimiento del vault DeFindex). const [realBalance, setRealBalance] = useState(0); + const [netPrincipal, setNetPrincipal] = useState(0); const [walletAddress, setWalletAddress] = useState(null); - - // Estado real del staking on-chain - const [onChainStake, setOnChainStake] = useState({ amount: 0, unlockTime: 0, months: 0, apy: 0 }); - const [timeLeftStr, setTimeLeftStr] = useState(""); - const [canUnstake, setCanUnstake] = useState(false); - // Modal de Retiro Normal + // Modal de Retiro const [modalOpen, setModalOpen] = useState(false); const [amount, setAmount] = useState("25"); const [step, setStep] = useState<"input" | "signing" | "success" | "error">("input"); const [errorMsg, setErrorMsg] = useState(""); - // Modal de Staking / Unstaking - const [stakeModalOpen, setStakeModalOpen] = useState(false); - const [stakeAmount, setStakeAmount] = useState("50"); - const [selectedMonths, setSelectedMonths] = useState(3); - const [stakeStep, setStakeStep] = useState<"input" | "signing" | "success" | "unstaking" | "error">("input"); - useEffect(() => { const initWallet = async () => { try { @@ -65,12 +49,13 @@ const Retiros = () => { const loadData = useCallback(async (address: string) => { try { - const [balance, stakeData] = await Promise.all([ + const [position, deposited, withdrawn] = await Promise.all([ fetchContractBalance(address), - fetchStakeInfo(address) + fetchTotalDeposited(address), + fetchTotalWithdrawn(address), ]); - setRealBalance(balance); - setOnChainStake(stakeData); + setRealBalance(position); + setNetPrincipal(Math.max(0, deposited - withdrawn)); } catch (error) { console.error("Error al cargar datos:", error); } }, []); @@ -81,42 +66,13 @@ const Retiros = () => { return () => clearInterval(intervalId); }, [walletAddress, loadData]); - // Reloj regresivo para el Unstake - useEffect(() => { - if (onChainStake.amount === 0) return; - - const updateTimer = () => { - const now = Math.floor(Date.now() / 1000); - const diff = onChainStake.unlockTime - now; - if (diff <= 0) { - setTimeLeftStr("¡Listo para retirar!"); - setCanUnstake(true); - } else { - const m = Math.floor(diff / 60); - const s = diff % 60; - setTimeLeftStr(`${m}m ${s}s restantes`); - setCanUnstake(false); - } - }; - - updateTimer(); - const t = setInterval(updateTimer, 1000); - return () => clearInterval(t); - }, [onChainStake]); - const handleClose = () => { setStep("input"); setAmount("25"); setErrorMsg(""); setModalOpen(false); if (walletAddress) loadData(walletAddress); }; - const handleStakeClose = () => { - setStakeStep("input"); setStakeAmount("50"); setSelectedMonths(3); - setStakeModalOpen(false); - if (walletAddress) loadData(walletAddress); - }; - - const processContractCall = async (functionName: string, args: any[], successStep: any) => { + const processContractCall = async (functionName: string, args: xdr.ScVal[]) => { try { const server = new rpc.Server(RPC_URL); @@ -152,7 +108,7 @@ const Retiros = () => { } const txToSubmit = TransactionBuilder.fromXDR(signResult.signedXdr, Networks.TESTNET); - const submitRes = await server.sendTransaction(txToSubmit) as any; + const submitRes = await server.sendTransaction(txToSubmit); const currentStatus = (submitRes.status ?? "").toUpperCase(); if (currentStatus !== "PENDING" && currentStatus !== "SUCCESS") { throw new Error("Transacción rechazada por la red."); @@ -168,20 +124,15 @@ const Retiros = () => { if (txStatus === "SUCCESS") { if (walletAddress) loadData(walletAddress); - if (functionName === "withdraw") { - addWithdrawal(parseFloat(amount), submitRes.hash); - setStep("success"); - } else { - setStakeStep(successStep); - } + addWithdrawal(parseFloat(amount), submitRes.hash); + setStep("success"); confetti({ particleCount: 100, spread: 70, origin: { y: 0.65 } }); } else { throw new Error("Transacción fallida en el contrato."); } - } catch (err: any) { + } catch (err) { console.error(err); - if (functionName === "withdraw") { setErrorMsg(err.message); setStep("error"); } - else { alert(err.message); setStakeStep("input"); setStakeModalOpen(false); } + setErrorMsg(err instanceof Error ? err.message : String(err)); setStep("error"); } }; @@ -193,28 +144,11 @@ const Retiros = () => { await processContractCall("withdraw", [ nativeToScVal(walletAddress, { type: "address" }), nativeToScVal(valStroops, { type: "i128" }) - ], "success"); + ]); }; - const handleStakeConfirm = async () => { - const val = parseFloat(stakeAmount); - if (!val || val <= 0 || val > realBalance) return; - setStakeStep("signing"); - const valStroops = BigInt(Math.floor(val * 10000000)); - await processContractCall("stake", [ - nativeToScVal(walletAddress, { type: "address" }), - nativeToScVal(valStroops, { type: "i128" }), - nativeToScVal(BigInt(selectedMonths), { type: "u64" }) - ], "success"); - }; - - const handleUnstake = async () => { - setStakeStep("unstaking"); - setStakeModalOpen(true); - await processContractCall("unstake", [nativeToScVal(walletAddress, { type: "address" })], "success"); - }; - - const earnedInterest = onChainStake.amount > 0 ? (onChainStake.amount * (onChainStake.apy / 100) * (onChainStake.months / 12)) : 0; + // Rendimiento real generado (posición − principal neto aportado). + const yieldEarned = Math.max(0, realBalance - netPrincipal); return (
@@ -227,86 +161,36 @@ const Retiros = () => {
- {/* Balance Card */} + {/* Balance Card — posición real con rendimiento incluido */}

{t("withdrawals.available_balance")}

-

{realBalance.toFixed(2)} XLM

+

{realBalance.toFixed(2)} USDC

- {/* STAKING SECTION */} -
-
- -

{t("withdrawals.staking_section_title")}

-
- - - - {onChainStake.amount > 0 ? ( -
-
- {t("withdrawals.staking_active_label")} - {onChainStake.amount.toFixed(2)} XLM -
-
-
-
-
- {canUnstake ? : } -
-
-

{t("withdrawals.staking_locked_label", { months: onChainStake.months })}

-

{t("withdrawals.staking_apy_label", { apy: onChainStake.apy })}

-
-
-
-

+{earnedInterest.toFixed(2)} XLM

-

{t("withdrawals.staking_earnings_label")}

-
-
- - +
+

{t("withdrawals.yield_card_title")}

+

{t("withdrawals.yield_card_description")}

- ) : ( -
-
- -
-

{t("withdrawals.staking_empty_title")}

-

{t("withdrawals.staking_empty_description")}

+
+

+{yieldEarned.toFixed(2)} USDC

+

+ {t("withdrawals.yield_label")} +

- )} +
@@ -318,29 +202,29 @@ const Retiros = () => {
{step !== "signing" && } - + {step === "input" && ( <>

{t("withdrawals.modal_withdraw_title")}

{t("withdrawals.modal_withdraw_subtitle")}

- +
setAmount(e.target.value)} className="w-full text-3xl font-bold bg-secondary rounded-xl px-4 py-4 outline-none tabular-nums" />
{[10, 25, 50].map((v) => ( - ))} {/* 🚀 BOTÓN RETIRAR TODO (MÁX) */} -
{errorMsg &&

{errorMsg}

} - - )} - + {step === "signing" && (
{isMobile @@ -370,54 +254,17 @@ const Retiros = () => {

)} - + {step === "success" && (

{t("withdrawals.modal_success_title")}

)} -
-
- )} - - {/* MODAL DE STAKING */} - {stakeModalOpen && ( -
-
-
- {(stakeStep !== "signing" && stakeStep !== "unstaking") && } - - {stakeStep === "input" && onChainStake.amount === 0 && ( - <> -

{t("withdrawals.modal_staking_title")}

-
- {STAKING_OPTIONS.map((opt) => ( - - ))} -
- setStakeAmount(e.target.value)} className="w-full text-3xl font-bold bg-secondary rounded-xl px-4 py-4 mb-4" /> - - - )} - - {(stakeStep === "signing" || stakeStep === "unstaking") && ( -
- {isMobile - ? - : } -

{t("withdrawals.modal_staking_signing")}

-

- {isMobile ? `Aprueba en ${provider === "albedo" ? "Albedo" : "tu wallet"}` : t("withdrawals.modal_staking_signing_sub")} -

-
- )} - {stakeStep === "success" && ( + {step === "error" && (
- -

{t("withdrawals.modal_staking_success")}

- + +

{t("withdrawals.modal_error_title")}

+ {errorMsg &&

{errorMsg}

} +
)}
@@ -427,4 +274,4 @@ const Retiros = () => { ); }; -export default Retiros; \ No newline at end of file +export default Retiros; diff --git a/src/stellar/contracts.ts b/src/stellar/contracts.ts index e8606bb..ac675f0 100644 --- a/src/stellar/contracts.ts +++ b/src/stellar/contracts.ts @@ -1,4 +1,7 @@ import { xdr, nativeToScVal } from "@stellar/stellar-sdk"; +// staking_pool respaldado por DeFindex. Actualizar tras el redeploy de testnet. export const CONTRACT_ID = "CAIYBGMKSA5V5EYUFKGD5OCWWS5M34YC7MKUKE3BOQE2WZP3R7A4S2D2"; +// Vault USDC de DeFindex en testnet (referencia; el contrato lo guarda vía init). +export const VAULT_ID = "CBMVK2JK6NTOT2O4HNQAIQFJY232BHKGLIMXDVQVHIIZKDACXDFZDWHN"; export const RPC_URL = "https://soroban-testnet.stellar.org"; \ No newline at end of file diff --git a/src/stellar/queries.ts b/src/stellar/queries.ts index 0b40184..9000d3f 100644 --- a/src/stellar/queries.ts +++ b/src/stellar/queries.ts @@ -1,18 +1,19 @@ import { TransactionBuilder, Networks, Operation, BASE_FEE, nativeToScVal, scValToNative, rpc } from "@stellar/stellar-sdk"; import { CONTRACT_ID, RPC_URL } from "./contracts"; -// 1. Función para obtener el saldo disponible (Ahorro regular) +// Valor redimible en vivo de la posición del usuario (depósito + rendimiento real +// del vault DeFindex), en unidades del token (USDC, 7 decimales como XLM). export async function fetchContractBalance(userAddress: string): Promise { try { const server = new rpc.Server(RPC_URL); - + // 🛡️ PROTECCIÓN 1: Si la cuenta no existe en la red, no puede tener saldo. let account; try { account = await server.getAccount(userAddress); } catch (e) { console.warn("La cuenta no está fondeada en Testnet. Saldo 0 por defecto."); - return 0; + return 0; } const transaction = new TransactionBuilder(account, { @@ -22,7 +23,7 @@ export async function fetchContractBalance(userAddress: string): Promise .addOperation( Operation.invokeContractFunction({ contract: CONTRACT_ID, - function: "get_balance", + function: "get_position", args: [ nativeToScVal(userAddress, { type: "address" }) ], @@ -34,32 +35,67 @@ export async function fetchContractBalance(userAddress: string): Promise const response = await server.simulateTransaction(transaction); if (rpc.Api.isSimulationSuccess(response) && response.result) { - const balanceInStroops = scValToNative(response.result.retval); - + const positionInStroops = scValToNative(response.result.retval); + // 🛡️ PROTECCIÓN 2: Convertir explícitamente el BigInt a Number antes de operar - return Number(balanceInStroops) / 10000000; + return Number(positionInStroops) / 10000000; } return 0; } catch (error) { - console.error("Error consultando get_balance:", error); + console.error("Error consultando get_position:", error); return 0; } } -// 2. Función para obtener los datos del Staking -export async function fetchStakeInfo(userAddress: string) { - // Objeto por defecto para evitar errores en React - const defaultStake = { amount: 0, unlockTime: 0, months: 0, apy: 0 }; +// Ingreso acumulado del usuario (para mostrar el rendimiento generado = posición − ingreso neto). +export async function fetchTotalDeposited(userAddress: string): Promise { + try { + const server = new rpc.Server(RPC_URL); + + let account; + try { + account = await server.getAccount(userAddress); + } catch (e) { + return 0; + } + + const transaction = new TransactionBuilder(account, { + fee: BASE_FEE, + networkPassphrase: Networks.TESTNET, + }) + .addOperation( + Operation.invokeContractFunction({ + contract: CONTRACT_ID, + function: "get_total_deposited", + args: [nativeToScVal(userAddress, { type: "address" })], + }) + ) + .setTimeout(30) + .build(); + + const response = await server.simulateTransaction(transaction); + if (rpc.Api.isSimulationSuccess(response) && response.result) { + const depositedInStroops = scValToNative(response.result.retval); + return Number(depositedInStroops) / 10000000; + } + return 0; + } catch (error) { + console.error("Error consultando get_total_deposited:", error); + return 0; + } +} + +// Retiro acumulado del usuario (para estimar el rendimiento neto generado). +export async function fetchTotalWithdrawn(userAddress: string): Promise { try { const server = new rpc.Server(RPC_URL); - - // 🛡️ PROTECCIÓN 1: Misma validación de cuenta + let account; try { account = await server.getAccount(userAddress); } catch (e) { - return defaultStake; + return 0; } const transaction = new TransactionBuilder(account, { @@ -69,10 +105,8 @@ export async function fetchStakeInfo(userAddress: string) { .addOperation( Operation.invokeContractFunction({ contract: CONTRACT_ID, - function: "get_stake", - args: [ - nativeToScVal(userAddress, { type: "address" }) - ], + function: "get_total_withdrawn", + args: [nativeToScVal(userAddress, { type: "address" })], }) ) .setTimeout(30) @@ -81,21 +115,12 @@ export async function fetchStakeInfo(userAddress: string) { const response = await server.simulateTransaction(transaction); if (rpc.Api.isSimulationSuccess(response) && response.result) { - const resultVal = scValToNative(response.result.retval); - - // Soroban devuelve una Tupla de Rust, que en JS es un Array de BigInts - if (Array.isArray(resultVal)) { - return { - amount: Number(resultVal[0]) / 10000000, // Dividimos para pasar de Stroops a XLM - unlockTime: Number(resultVal[1]), // Timestamp en segundos - months: Number(resultVal[2]), - apy: Number(resultVal[3]) - }; - } + const withdrawnInStroops = scValToNative(response.result.retval); + return Number(withdrawnInStroops) / 10000000; } - return defaultStake; + return 0; } catch (error) { - console.error("Error consultando get_stake:", error); - return defaultStake; + console.error("Error consultando get_total_withdrawn:", error); + return 0; } } \ No newline at end of file