diff --git a/.cursor/debug-3cec5e.log b/.cursor/debug-3cec5e.log new file mode 100644 index 00000000..77964a9d --- /dev/null +++ b/.cursor/debug-3cec5e.log @@ -0,0 +1,12 @@ +{"sessionId":"3cec5e","location":"route.ts:POST","message":"Admin keypair info","data":{"adminPublicKey":"GDEQD7CITHS4AINJTA4VSACHOXK6ZOY6WTFUNLRHXTCLZXZ5TI4Y7Y5X","requestedAddress":"GA6Q2HIOUFZHNQ4RNL4INHZ7QPJ7RSB5YOIBRZEYHUJNKNL5FBE4M3C6"},"timestamp":1772938297455,"hypothesisId":"H1"} +{"sessionId":"3cec5e","location":"route.ts:path-check","message":"Which mint path","data":{"faucetContractId":"NOT_SET","usingBulk":false},"timestamp":1772938297456,"hypothesisId":"H3"} +{"sessionId":"3cec5e","location":"route.ts:legacy-mint-start","message":"Minting token (legacy)","data":{"symbol":"USTRY","contractId":"CC6SODKGOTFEDWVNPR6ESJC3GL7NC5Y4DVFKYGATZJ74F2YXHTW4RJ6D","mintAmount":"1000000000","adminPubKey":"GDEQD7CITHS4AINJTA4VSACHOXK6ZOY6WTFUNLRHXTCLZXZ5TI4Y7Y5X"},"timestamp":1772938297456,"hypothesisId":"H2"} +{"sessionId":"3cec5e","location":"route.ts:legacy-mint-ok","message":"Token minted OK","data":{"symbol":"USTRY","hash":"f8124f7df212c8080e3a0d42a45cd1a3fd9345ce9f86b346634d0aba224a6885"},"timestamp":1772938300940,"hypothesisId":"H2"} +{"sessionId":"3cec5e","location":"route.ts:legacy-mint-start","message":"Minting token (legacy)","data":{"symbol":"TESOURO","contractId":"CA55OO3U556GXABJKDYP3QCGZ6AFNZPB27TROYP42AQPFFYPKU5EDOUH","mintAmount":"1000000000","adminPubKey":"GDEQD7CITHS4AINJTA4VSACHOXK6ZOY6WTFUNLRHXTCLZXZ5TI4Y7Y5X"},"timestamp":1772938300943,"hypothesisId":"H2"} +{"sessionId":"3cec5e","location":"route.ts:legacy-mint-ok","message":"Token minted OK","data":{"symbol":"TESOURO","hash":"c9ad99e25fe023d1a143add23780edc3e94a9a8337947156d2726bea6f1e23b3"},"timestamp":1772938306315,"hypothesisId":"H2"} +{"sessionId":"3cec5e","location":"route.ts:legacy-mint-start","message":"Minting token (legacy)","data":{"symbol":"CETES","contractId":"CCGWKS4GLAGPYIAOLBH6JM5RKUPMUCN47VRAEXAJWJFKXSXQ33VIRUAA","mintAmount":"1000000000","adminPubKey":"GDEQD7CITHS4AINJTA4VSACHOXK6ZOY6WTFUNLRHXTCLZXZ5TI4Y7Y5X"},"timestamp":1772938306317,"hypothesisId":"H2"} +{"sessionId":"3cec5e","location":"route.ts:legacy-mint-ok","message":"Token minted OK","data":{"symbol":"CETES","hash":"6c916e63c42a96acb3a2fef48b8f801d67197e9cc661dc6d50083fef871f78fe"},"timestamp":1772938312060,"hypothesisId":"H2"} +{"sessionId":"3cec5e","location":"route.ts:legacy-mint-start","message":"Minting token (legacy)","data":{"symbol":"USDY","contractId":"CBVLFSVBZGHVAH6CV4JQYPBJSX75VFR2NJC7CQX7QKQ7KOLGQZOZAGQK","mintAmount":"1000000000","adminPubKey":"GDEQD7CITHS4AINJTA4VSACHOXK6ZOY6WTFUNLRHXTCLZXZ5TI4Y7Y5X"},"timestamp":1772938312063,"hypothesisId":"H2"} +{"sessionId":"3cec5e","location":"route.ts:legacy-mint-ok","message":"Token minted OK","data":{"symbol":"USDY","hash":"954b97f783d3f187f9f8f6c823559a16ba317e2883b16471b29923ee852793d5"},"timestamp":1772938316365,"hypothesisId":"H2"} +{"sessionId":"3cec5e","location":"route.ts:legacy-mint-start","message":"Minting token (legacy)","data":{"symbol":"PYUSD","contractId":"CBCB5UDYZENTIUVVA7SHQOCVVCDXDMHEKJHHFM3OQKU2E5CAF2B62TIO","mintAmount":"1000000000","adminPubKey":"GDEQD7CITHS4AINJTA4VSACHOXK6ZOY6WTFUNLRHXTCLZXZ5TI4Y7Y5X"},"timestamp":1772938316367,"hypothesisId":"H2"} +{"sessionId":"3cec5e","location":"route.ts:legacy-mint-ok","message":"Token minted OK","data":{"symbol":"PYUSD","hash":"2e05db887e2ff78b43d0b7c40ef3d79e51cedaeb3a3bd9df79664779b79fbd31"},"timestamp":1772938321012,"hypothesisId":"H2"} diff --git a/.gitignore b/.gitignore index 2ab1604d..e2f7f9c4 100644 --- a/.gitignore +++ b/.gitignore @@ -65,3 +65,6 @@ yarn-error.log* # Rust/Stellar contracts **/test_snapshots/ target/ + +deploy-full.cjs +deploy-pools.cjs diff --git a/apps/contracts/stellar-contracts/Cargo.lock b/apps/contracts/stellar-contracts/Cargo.lock index d80f826c..84af3fc7 100644 --- a/apps/contracts/stellar-contracts/Cargo.lock +++ b/apps/contracts/stellar-contracts/Cargo.lock @@ -1044,6 +1044,13 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "rwa-faucet" +version = "0.0.1" +dependencies = [ + "soroban-sdk", +] + [[package]] name = "rwa-lending" version = "0.0.1" diff --git a/apps/contracts/stellar-contracts/Cargo.toml b/apps/contracts/stellar-contracts/Cargo.toml index 145b6e11..112c3cbf 100644 --- a/apps/contracts/stellar-contracts/Cargo.toml +++ b/apps/contracts/stellar-contracts/Cargo.toml @@ -5,7 +5,8 @@ members = [ "rwa-lending", "rwa-oracle", "rwa-token", - "rwa-perps" + "rwa-perps", + "rwa-faucet" ] [workspace.package] diff --git a/apps/contracts/stellar-contracts/rwa-faucet/Cargo.toml b/apps/contracts/stellar-contracts/rwa-faucet/Cargo.toml new file mode 100644 index 00000000..59209e4d --- /dev/null +++ b/apps/contracts/stellar-contracts/rwa-faucet/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "rwa-faucet" +description = "RWA Faucet contract - Bulk mints multiple RWA tokens in a single Soroban invocation" +version.workspace = true +authors.workspace = true +license.workspace = true +edition.workspace = true +repository.workspace = true +publish = false + +[lib] +crate-type = ["cdylib", "rlib"] +doctest = false + +[dependencies] +soroban-sdk = { workspace = true } + +[dev-dependencies] +soroban-sdk = { workspace = true, features = ["testutils"] } + +[package.metadata.stellar] +contract = true +cargo_inherit = true diff --git a/apps/contracts/stellar-contracts/rwa-faucet/src/contract.rs b/apps/contracts/stellar-contracts/rwa-faucet/src/contract.rs new file mode 100644 index 00000000..31aec1c9 --- /dev/null +++ b/apps/contracts/stellar-contracts/rwa-faucet/src/contract.rs @@ -0,0 +1,42 @@ +use soroban_sdk::{contract, contractimpl, vec, Address, Env, IntoVal, Symbol, Vec}; + +use crate::storage::Storage; +use crate::types::MintRequest; + +#[contract] +pub struct Faucet; + +#[contractimpl] +impl Faucet { + /// Initialize the faucet with an admin address. + /// The admin must be the same account that controls the rwa-token contracts. + pub fn initialize(env: Env, admin: Address) { + assert!(!Storage::is_initialized(&env), "Faucet: already initialized"); + admin.require_auth(); + Storage::set_admin(&env, &admin); + Storage::set_initialized(&env); + } + + /// Mint multiple tokens in a single invocation (permissionless on testnet). + /// The faucet contract must be the admin of each token contract so that + /// cross-contract calls to set_authorized and mint are auto-authorized. + pub fn bulk_mint(env: Env, requests: Vec) { + for req in requests.iter() { + env.invoke_contract::<()>( + &req.token, + &Symbol::new(&env, "set_authorized"), + vec![&env, req.to.into_val(&env), true.into_val(&env)], + ); + env.invoke_contract::<()>( + &req.token, + &Symbol::new(&env, "mint"), + vec![&env, req.to.into_val(&env), req.amount.into_val(&env)], + ); + } + } + + /// Return the admin address. + pub fn admin(env: Env) -> Address { + Storage::get_admin(&env) + } +} diff --git a/apps/contracts/stellar-contracts/rwa-faucet/src/lib.rs b/apps/contracts/stellar-contracts/rwa-faucet/src/lib.rs new file mode 100644 index 00000000..318b6ee3 --- /dev/null +++ b/apps/contracts/stellar-contracts/rwa-faucet/src/lib.rs @@ -0,0 +1,8 @@ +#![no_std] + +pub mod contract; +pub mod storage; +pub mod types; + +#[cfg(test)] +mod test; diff --git a/apps/contracts/stellar-contracts/rwa-faucet/src/storage.rs b/apps/contracts/stellar-contracts/rwa-faucet/src/storage.rs new file mode 100644 index 00000000..099262b0 --- /dev/null +++ b/apps/contracts/stellar-contracts/rwa-faucet/src/storage.rs @@ -0,0 +1,33 @@ +use soroban_sdk::{contracttype, Address, Env}; + +#[contracttype] +enum DataKey { + Admin, + Initialized, +} + +pub struct Storage; + +impl Storage { + pub fn set_admin(env: &Env, admin: &Address) { + env.storage().instance().set(&DataKey::Admin, admin); + } + + pub fn get_admin(env: &Env) -> Address { + env.storage() + .instance() + .get(&DataKey::Admin) + .expect("Faucet: admin not set") + } + + pub fn set_initialized(env: &Env) { + env.storage().instance().set(&DataKey::Initialized, &true); + } + + pub fn is_initialized(env: &Env) -> bool { + env.storage() + .instance() + .get(&DataKey::Initialized) + .unwrap_or(false) + } +} diff --git a/apps/contracts/stellar-contracts/rwa-faucet/src/test.rs b/apps/contracts/stellar-contracts/rwa-faucet/src/test.rs new file mode 100644 index 00000000..4e20b81c --- /dev/null +++ b/apps/contracts/stellar-contracts/rwa-faucet/src/test.rs @@ -0,0 +1,151 @@ +#![cfg(test)] +extern crate std; + +use crate::contract::{Faucet, FaucetClient}; +use crate::types::MintRequest; +use soroban_sdk::{ + testutils::Address as _, + token::TokenClient, + Address, Env, Vec, +}; + +/// Use Stellar Asset Contract as the mintable token for testing. +/// The issuer acts as the admin who can mint. +fn create_test_token(env: &Env, admin: &Address) -> Address { + let contract = env.register_stellar_asset_contract_v2(admin.clone()); + contract.address() +} + +fn create_faucet<'a>(env: &Env) -> (FaucetClient<'a>, Address) { + let address = env.register(Faucet, ()); + let client = FaucetClient::new(env, &address); + (client, address) +} + +#[test] +fn test_initialize() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let (faucet, _) = create_faucet(&env); + + faucet.initialize(&admin); + assert_eq!(faucet.admin(), admin); +} + +#[test] +#[should_panic(expected = "Faucet: already initialized")] +fn test_initialize_twice_panics() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let (faucet, _) = create_faucet(&env); + + faucet.initialize(&admin); + faucet.initialize(&admin); +} + +#[test] +fn test_bulk_mint_single_token() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let user = Address::generate(&env); + + let token_addr = create_test_token(&env, &admin); + let token_client = TokenClient::new(&env, &token_addr); + + let (faucet, _) = create_faucet(&env); + faucet.initialize(&admin); + + let requests = Vec::from_array( + &env, + [MintRequest { + token: token_addr.clone(), + to: user.clone(), + amount: 1_000_0000000, // 1000 with 7 decimals + }], + ); + + faucet.bulk_mint(&requests); + + assert_eq!(token_client.balance(&user), 1_000_0000000); +} + +#[test] +fn test_bulk_mint_multiple_tokens() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let user = Address::generate(&env); + + let token_a = create_test_token(&env, &admin); + let token_b = create_test_token(&env, &admin); + let client_a = TokenClient::new(&env, &token_a); + let client_b = TokenClient::new(&env, &token_b); + + let (faucet, _) = create_faucet(&env); + faucet.initialize(&admin); + + let requests = Vec::from_array( + &env, + [ + MintRequest { + token: token_a.clone(), + to: user.clone(), + amount: 500_0000000, + }, + MintRequest { + token: token_b.clone(), + to: user.clone(), + amount: 100_0000000, + }, + ], + ); + + faucet.bulk_mint(&requests); + + assert_eq!(client_a.balance(&user), 500_0000000); + assert_eq!(client_b.balance(&user), 100_0000000); +} + +#[test] +fn test_bulk_mint_multiple_recipients() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let user_a = Address::generate(&env); + let user_b = Address::generate(&env); + + let token_addr = create_test_token(&env, &admin); + let token_client = TokenClient::new(&env, &token_addr); + + let (faucet, _) = create_faucet(&env); + faucet.initialize(&admin); + + let requests = Vec::from_array( + &env, + [ + MintRequest { + token: token_addr.clone(), + to: user_a.clone(), + amount: 200_0000000, + }, + MintRequest { + token: token_addr.clone(), + to: user_b.clone(), + amount: 300_0000000, + }, + ], + ); + + faucet.bulk_mint(&requests); + + assert_eq!(token_client.balance(&user_a), 200_0000000); + assert_eq!(token_client.balance(&user_b), 300_0000000); +} diff --git a/apps/contracts/stellar-contracts/rwa-faucet/src/types.rs b/apps/contracts/stellar-contracts/rwa-faucet/src/types.rs new file mode 100644 index 00000000..2c5eabc7 --- /dev/null +++ b/apps/contracts/stellar-contracts/rwa-faucet/src/types.rs @@ -0,0 +1,9 @@ +use soroban_sdk::{contracttype, Address}; + +#[contracttype] +#[derive(Clone, Debug)] +pub struct MintRequest { + pub token: Address, + pub to: Address, + pub amount: i128, +} diff --git a/apps/contracts/stellar-contracts/rwa-token/Cargo.toml b/apps/contracts/stellar-contracts/rwa-token/Cargo.toml index 5d0a2ea8..cc405ff3 100644 --- a/apps/contracts/stellar-contracts/rwa-token/Cargo.toml +++ b/apps/contracts/stellar-contracts/rwa-token/Cargo.toml @@ -14,10 +14,10 @@ doctest = false [dependencies] soroban-sdk = { workspace = true } -rwa-oracle = { path = "../rwa-oracle" } [dev-dependencies] soroban-sdk = { workspace = true, features = ["testutils"] } +rwa-oracle = { path = "../rwa-oracle" } [package.metadata.stellar] contract = true diff --git a/apps/contracts/stellar-contracts/rwa-token/src/admin/mod.rs b/apps/contracts/stellar-contracts/rwa-token/src/admin/mod.rs index 9471b350..823e5389 100644 --- a/apps/contracts/stellar-contracts/rwa-token/src/admin/mod.rs +++ b/apps/contracts/stellar-contracts/rwa-token/src/admin/mod.rs @@ -89,4 +89,10 @@ impl Admin { pub fn authorized(env: &Env, id: &Address) -> bool { AuthorizationStorage::get(env, id) } + + /// Transfer admin role to a new address. Current admin must authorize. + pub fn set_admin(env: &Env, new_admin: &Address) { + Self::require_admin(env); + MetadataStorage::update_admin(env, new_admin); + } } diff --git a/apps/contracts/stellar-contracts/rwa-token/src/common/metadata.rs b/apps/contracts/stellar-contracts/rwa-token/src/common/metadata.rs index b6fddc5f..1f8437df 100644 --- a/apps/contracts/stellar-contracts/rwa-token/src/common/metadata.rs +++ b/apps/contracts/stellar-contracts/rwa-token/src/common/metadata.rs @@ -18,6 +18,10 @@ impl MetadataStorage { env.storage().instance().set(&ADMIN_KEY, admin); } + pub fn update_admin(env: &Env, new_admin: &Address) { + env.storage().instance().set(&ADMIN_KEY, new_admin); + } + pub fn get_token(env: &Env) -> TokenStorage { env.storage() .instance() diff --git a/apps/contracts/stellar-contracts/rwa-token/src/contract.rs b/apps/contracts/stellar-contracts/rwa-token/src/contract.rs index ee6865a5..b6fb3340 100644 --- a/apps/contracts/stellar-contracts/rwa-token/src/contract.rs +++ b/apps/contracts/stellar-contracts/rwa-token/src/contract.rs @@ -42,6 +42,11 @@ impl RWATokenContract { Admin::get_admin(&env) } + /// Transfer admin role to a new address. Current admin must authorize. + pub fn set_admin(env: Env, new_admin: Address) { + Admin::set_admin(&env, &new_admin); + } + /// Mint tokens to an address. Admin-only. pub fn mint(env: Env, to: Address, amount: i128) { Admin::mint(&env, &to, amount); diff --git a/apps/contracts/stellar-contracts/rwa-token/src/lib.rs b/apps/contracts/stellar-contracts/rwa-token/src/lib.rs index cbb7229d..04e31b34 100644 --- a/apps/contracts/stellar-contracts/rwa-token/src/lib.rs +++ b/apps/contracts/stellar-contracts/rwa-token/src/lib.rs @@ -10,7 +10,7 @@ pub use common::error::Error; // Import RWA Oracle WASM for reading RWA asset prices pub mod rwa_oracle { - soroban_sdk::contractimport!(file = "../target/wasm32-unknown-unknown/release/rwa_oracle.wasm"); + soroban_sdk::contractimport!(file = "../target/wasm32v1-none/release/rwa_oracle.wasm"); } pub mod contract; diff --git a/apps/web-app/.env.example b/apps/web-app/.env.example index 52d6446c..9a115038 100644 --- a/apps/web-app/.env.example +++ b/apps/web-app/.env.example @@ -6,3 +6,8 @@ NEXT_PUBLIC_STELLAR_HORIZON_URL=https://horizon-testnet.stellar.org # SoroSwap ApiKey NEXT_PUBLIC_SOROSWAP_API_KEY= + +# Faucet contract (client-side, permissionless on testnet) +# The faucet contract is the admin of all RWA tokens and handles set_authorized + mint. +# Users call bulk_mint directly via Freighter (no server-side key needed). +NEXT_PUBLIC_FAUCET_CONTRACT_ID= diff --git a/apps/web-app/package.json b/apps/web-app/package.json index 0d780263..bf213c02 100644 --- a/apps/web-app/package.json +++ b/apps/web-app/package.json @@ -9,9 +9,6 @@ "lint": "eslint" }, "dependencies": { - "next": "16.1.6", - "react": "19.2.3", - "react-dom": "19.2.3", "@blend-capital/blend-sdk": "^3.2.2", "@creit.tech/stellar-wallets-kit": "^1.0.0", "@emotion/react": "^11.11.0", @@ -24,6 +21,7 @@ "@neko/oracle": "0.0.0", "@soroswap/sdk": "^0.3.8", "@stellar/design-system": "^3.2.7", + "@stellar/freighter-api": "^5.0.0", "@stellar/stellar-sdk": "^14.2.0", "@stellar/stellar-xdr-json": "^23.0.0", "@tanstack/react-query": "^5.62.11", @@ -35,9 +33,13 @@ "lodash": "^4.17.21", "lossless-json": "^1.0.4", "lucide-react": "^0.344.0", + "next": "16.1.6", + "react": "19.2.3", "react-chartjs-2": "^5.2.0", + "react-dom": "19.2.3", "react-hook-form": "^7.70.0", "recharts": "^2.10.0", + "sileo": "^0.1.5", "tailwind-merge": "^2.5.4", "tw-animate-css": "^1.4.0", "zod": "^3.25.76", diff --git a/apps/web-app/public/Neko_Blue.svg b/apps/web-app/public/Neko_Blue.svg deleted file mode 100644 index 23b9380f..00000000 --- a/apps/web-app/public/Neko_Blue.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/apps/web-app/public/Neko_Thumbs_Up.png b/apps/web-app/public/Neko_Thumbs_Up.png deleted file mode 100644 index 8d99b2e6..00000000 Binary files a/apps/web-app/public/Neko_Thumbs_Up.png and /dev/null differ diff --git a/apps/web-app/public/banners/settings.svg b/apps/web-app/public/banners/settings.svg new file mode 100644 index 00000000..a8d0ee35 --- /dev/null +++ b/apps/web-app/public/banners/settings.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/apps/web-app/src/app/(app)/layout.tsx b/apps/web-app/src/app/(app)/layout.tsx index 1cab63ab..fa13ffb2 100644 --- a/apps/web-app/src/app/(app)/layout.tsx +++ b/apps/web-app/src/app/(app)/layout.tsx @@ -8,7 +8,7 @@ export default function DashboardLayout({ children }: { children: ReactNode }) { - {/* On mobile: top padding = navbar (pt-4 + h-14). On desktop: left margin for the sidebar. */} + {}
{children}
diff --git a/apps/web-app/src/app/(app)/pools/[contractid]/page.tsx b/apps/web-app/src/app/(app)/pools/[contractid]/page.tsx deleted file mode 100644 index 40cf844a..00000000 --- a/apps/web-app/src/app/(app)/pools/[contractid]/page.tsx +++ /dev/null @@ -1,11 +0,0 @@ -"use client"; - -import PoolDetail from "@/features/pools/components/pages/PoolDetail"; - -interface PageProps { - params: Promise<{ contractid: string }>; -} - -export default function PoolDetailPage({ params }: PageProps) { - return ; -} diff --git a/apps/web-app/src/app/(app)/pools/page.tsx b/apps/web-app/src/app/(app)/pools/page.tsx deleted file mode 100644 index e3489524..00000000 --- a/apps/web-app/src/app/(app)/pools/page.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import type { Metadata } from "next"; -import Pools from "@/features/pools/components/pages/Pools"; - -export const metadata: Metadata = { - title: "Pools | Neko Protocol", - description: "Explore and manage liquidity pools on Neko Protocol.", -}; - -export default function PoolsPage() { - return ; -} diff --git a/apps/web-app/src/app/api/faucet/route.ts b/apps/web-app/src/app/api/faucet/route.ts new file mode 100644 index 00000000..ff8c9452 --- /dev/null +++ b/apps/web-app/src/app/api/faucet/route.ts @@ -0,0 +1,267 @@ +import { NextRequest, NextResponse } from "next/server"; +import { + Keypair, + Contract, + TransactionBuilder, + Address, + nativeToScVal, + rpc, + Horizon, +} from "@stellar/stellar-sdk"; +import { + getFaucetTokens, + buildMintRequestsScVal, + FAUCET_COOLDOWN_MS, +} from "@/lib/constants/faucet"; + +export const dynamic = "force-dynamic"; + +const rateLimitMap = new Map(); + +function checkRateLimit(address: string): boolean { + const lastMint = rateLimitMap.get(address); + if (lastMint && Date.now() - lastMint < FAUCET_COOLDOWN_MS) { + return false; + } + return true; +} + +async function bulkMint( + adminKeypair: Keypair, + sorobanServer: rpc.Server, + horizonServer: Horizon.Server, + faucetContractId: string, + toAddress: string, + passphrase: string +): Promise<{ hash: string }> { + const faucetContract = new Contract(faucetContractId); + const requestsScVal = buildMintRequestsScVal(toAddress); + + const operation = faucetContract.call("bulk_mint", requestsScVal); + + const adminAccount = await horizonServer.loadAccount( + adminKeypair.publicKey() + ); + + const transaction = new TransactionBuilder(adminAccount, { + fee: "10000000", + networkPassphrase: passphrase, + }) + .addOperation(operation) + .setTimeout(300) + .build(); + + const prepared = await sorobanServer.prepareTransaction(transaction); + prepared.sign(adminKeypair); + + const response = await sorobanServer.sendTransaction(prepared); + + if (response.status === "ERROR") { + throw new Error(`Transaction failed: ${response.status}`); + } + + if (response.status === "PENDING") { + let result = await sorobanServer.getTransaction(response.hash); + const maxRetries = 30; + let retries = 0; + while (result.status === "NOT_FOUND" && retries < maxRetries) { + await new Promise((r) => setTimeout(r, 1000)); + result = await sorobanServer.getTransaction(response.hash); + retries++; + } + + if (result.status === "FAILED") { + throw new Error("Transaction failed on-chain"); + } + } + + return { hash: response.hash }; +} + +async function mintTokenLegacy( + adminKeypair: Keypair, + sorobanServer: rpc.Server, + horizonServer: Horizon.Server, + contractId: string, + toAddress: string, + amount: bigint, + passphrase: string +): Promise<{ hash: string }> { + const contract = new Contract(contractId); + + const operation = contract.call( + "mint", + new Address(toAddress).toScVal(), + nativeToScVal(amount, { type: "i128" }) + ); + + const adminAccount = await horizonServer.loadAccount( + adminKeypair.publicKey() + ); + + const transaction = new TransactionBuilder(adminAccount, { + fee: "10000000", + networkPassphrase: passphrase, + }) + .addOperation(operation) + .setTimeout(300) + .build(); + + const prepared = await sorobanServer.prepareTransaction(transaction); + prepared.sign(adminKeypair); + + const response = await sorobanServer.sendTransaction(prepared); + + if (response.status === "ERROR") { + throw new Error(`Transaction failed: ${response.status}`); + } + + if (response.status === "PENDING") { + let result = await sorobanServer.getTransaction(response.hash); + const maxRetries = 30; + let retries = 0; + while (result.status === "NOT_FOUND" && retries < maxRetries) { + await new Promise((r) => setTimeout(r, 1000)); + result = await sorobanServer.getTransaction(response.hash); + retries++; + } + + if (result.status === "FAILED") { + throw new Error("Transaction failed on-chain"); + } + } + + return { hash: response.hash }; +} + +export async function POST(request: NextRequest) { + try { + const network = process.env.NEXT_PUBLIC_STELLAR_NETWORK ?? "TESTNET"; + if (network === "PUBLIC") { + return NextResponse.json( + { error: "Faucet is not available on mainnet" }, + { status: 403 } + ); + } + + const secretKey = process.env.FAUCET_SECRET_KEY; + if (!secretKey) { + return NextResponse.json( + { error: "Faucet is not configured" }, + { status: 503 } + ); + } + + const body = await request.json(); + const { address } = body as { address?: string }; + + if (!address || typeof address !== "string") { + return NextResponse.json( + { error: "Missing or invalid address" }, + { status: 400 } + ); + } + + if (!checkRateLimit(address)) { + const remaining = Math.ceil( + (FAUCET_COOLDOWN_MS - (Date.now() - (rateLimitMap.get(address) ?? 0))) / + 1000 + ); + return NextResponse.json( + { + error: `Rate limit: please wait ${remaining}s before requesting again`, + }, + { status: 429 } + ); + } + + const rpcUrl = + process.env.NEXT_PUBLIC_STELLAR_RPC_URL ?? + "https://soroban-testnet.stellar.org"; + const horizonUrl = + process.env.NEXT_PUBLIC_STELLAR_HORIZON_URL ?? + "https://horizon-testnet.stellar.org"; + const passphrase = + process.env.NEXT_PUBLIC_STELLAR_NETWORK_PASSPHRASE ?? + "Test SDF Network ; September 2015"; + + const adminKeypair = Keypair.fromSecret(secretKey); + const sorobanServer = new rpc.Server(rpcUrl, { + allowHttp: network === "LOCAL", + }); + const horizonServer = new Horizon.Server(horizonUrl); + + const faucetContractId = process.env.FAUCET_CONTRACT_ID; + + if (faucetContractId) { + const { hash } = await bulkMint( + adminKeypair, + sorobanServer, + horizonServer, + faucetContractId, + address, + passphrase + ); + + const tokens = getFaucetTokens(); + rateLimitMap.set(address, Date.now()); + + return NextResponse.json({ + success: true, + hash, + results: tokens.map((t) => ({ + token: t.symbol, + success: true, + hash, + })), + }); + } + + const allFaucetTokens = getFaucetTokens(); + const results: { + token: string; + success: boolean; + hash?: string; + error?: string; + }[] = []; + + for (const token of allFaucetTokens) { + try { + const { hash } = await mintTokenLegacy( + adminKeypair, + sorobanServer, + horizonServer, + token.contractId, + address, + token.mintAmount, + passphrase + ); + results.push({ token: token.symbol, success: true, hash }); + } catch (err) { + results.push({ + token: token.symbol, + success: false, + error: err instanceof Error ? err.message : String(err), + }); + } + } + + rateLimitMap.set(address, Date.now()); + + const allSucceeded = results.every((r) => r.success); + const noneSucceeded = results.every((r) => !r.success); + + return NextResponse.json( + { results, success: allSucceeded }, + { status: noneSucceeded ? 500 : 200 } + ); + } catch (error) { + return NextResponse.json( + { + error: "Faucet request failed", + details: error instanceof Error ? error.message : String(error), + }, + { status: 500 } + ); + } +} diff --git a/apps/web-app/src/app/not-found.tsx b/apps/web-app/src/app/not-found.tsx index a9b5434b..0e8cf44f 100644 --- a/apps/web-app/src/app/not-found.tsx +++ b/apps/web-app/src/app/not-found.tsx @@ -4,12 +4,12 @@ import Image from "next/image"; export default function NotFound() { return (
- {/* Decorative blur orbs */} + {}
- {/* Logo */} + {} - {/* 404 display */} + {}

404 @@ -33,7 +33,7 @@ export default function NotFound() {

- {/* Copy */} + {}

We lost this page. @@ -44,7 +44,7 @@ export default function NotFound() {

- {/* CTA */} + {}
- {children} + {children} ); } diff --git a/apps/web-app/src/components/GetTestTokensModal.tsx b/apps/web-app/src/components/GetTestTokensModal.tsx new file mode 100644 index 00000000..bcc09d1b --- /dev/null +++ b/apps/web-app/src/components/GetTestTokensModal.tsx @@ -0,0 +1,186 @@ +"use client"; + +import React, { useTransition } from "react"; +import { X, Coins, AlertCircle } from "lucide-react"; +import { ModalPortal } from "@/components/ui/ModalPortal"; +import { useToast } from "@/hooks/useToast"; +import { useWallet } from "@/hooks/useWallet"; +import { useSorobanTokenBalances } from "@/hooks/useSorobanTokenBalances"; +import { + buildFaucetTransaction, + getFaucetTokens, +} from "@/lib/constants/faucet"; +import { stellarNetwork } from "@/lib/constants/network"; +import { addFaucetTokensToFreighter } from "@/lib/helpers/stellar/freighter"; +import { signAndSendTransaction } from "@/lib/helpers/stellar/transaction"; +import { + rpcUrl, + networkPassphrase, + horizonUrl, +} from "@/lib/config/stellar.config"; + +export interface GetTestTokensModalProps { + isOpen: boolean; + onClose: () => void; +} + +function isMissing(balance: string): boolean { + const n = parseFloat(balance); + return Number.isNaN(n) || n <= 0; +} + +export function GetTestTokensModal({ + isOpen, + onClose, +}: GetTestTokensModalProps) { + const { address, signTransaction } = useWallet(); + const { addNotification } = useToast(); + const { balances, isFetching, invalidate } = useSorobanTokenBalances(); + const [isMinting, startMintTransition] = useTransition(); + + if (stellarNetwork === "PUBLIC") return null; + if (!isOpen) return null; + + const missingTokens = balances.filter((t) => isMissing(t.balance)); + const hasMissing = missingTokens.length > 0; + + const handleMintTestTokens = () => { + if (!address) return; + startMintTransition(async () => { + try { + const txXdr = await buildFaucetTransaction( + address, + rpcUrl, + horizonUrl, + networkPassphrase + ); + + await signAndSendTransaction(txXdr, signTransaction, { + networkPassphrase, + rpcUrl, + address, + }); + + const tokens = getFaucetTokens(); + addNotification( + `Minted: ${tokens.map((t) => t.symbol).join(", ")}`, + "success" + ); + + await invalidate(); + + try { + addNotification("Adding tokens to Freighter…", "info"); + await addFaucetTokensToFreighter(networkPassphrase); + addNotification("Tokens added to Freighter", "success"); + } catch { + addNotification( + "Mint succeeded but adding to Freighter failed. Use the wallet icon per token to add manually.", + "warning" + ); + } + } catch (err) { + const msg = err instanceof Error ? err.message : "Unknown error"; + addNotification(`Failed to mint test tokens: ${msg}`, "error"); + } + }); + }; + + return ( + +
+
+
+
+ +

Get Test Tokens

+
+ +
+ + {hasMissing && ( +
+ +
+

+ You're missing {missingTokens.length} token + {missingTokens.length > 1 ? "s" : ""} +

+

+ Mint USTRY, TESOURO, CETES, USDY & PYUSD to use Borrow, Lend + and Pools. +

+
+
+ )} + +
+
+ + RWA Token Balances + + {isFetching && ( + updating… + )} +
+
+ {balances.map((t) => { + const missing = isMissing(t.balance); + return ( +
+ + {t.symbol} + {missing && ( + + (empty) + + )} + + + {t.balance} + +
+ ); + })} +
+
+ + + +

+ Tokens are minted from the faucet and added to your wallet. +

+
+
+
+ ); +} diff --git a/apps/web-app/src/components/navigation/ConnectedCard.tsx b/apps/web-app/src/components/navigation/ConnectedCard.tsx index 6522ae2f..29985bed 100644 --- a/apps/web-app/src/components/navigation/ConnectedCard.tsx +++ b/apps/web-app/src/components/navigation/ConnectedCard.tsx @@ -24,10 +24,10 @@ export function ConnectedCard({ address, onDisconnect }: ConnectedCardProps) {

You're
- connect with: + connected with:

- {/* Address pill — click to reveal options */} + {} - {/* Expandable panel — always in DOM, animated via max-height + opacity */} + {}
{ - // eslint-disable-next-line react-hooks/set-state-in-effect setIsOpen(false); }, [pathname]); diff --git a/apps/web-app/src/components/ui/BannerPage.tsx b/apps/web-app/src/components/ui/BannerPage.tsx index 8e3cebb7..059e528e 100644 --- a/apps/web-app/src/components/ui/BannerPage.tsx +++ b/apps/web-app/src/components/ui/BannerPage.tsx @@ -4,50 +4,22 @@ import React from "react"; import Image from "next/image"; import { cn } from "@/lib/utils"; -// --------------------------------------------------------------------------- -// Types -// --------------------------------------------------------------------------- - export interface BannerPageProps { - /** Main heading shown in large type. */ title: string; - /** Optional supporting text rendered below the title. */ + subtitle?: string; - /** - * Optional pill label displayed above the title (e.g. "See prices in real time"). - * Omit to hide the badge entirely. - */ + badge?: string; - /** Path or URL of the right-side illustration. */ + imageSrc?: string; - /** Accessible description for the illustration (defaults to empty). */ + imageAlt?: string; - /** - * Slot for call-to-action buttons or links, rendered below the subtitle. - * Accepts any ReactNode so callers decide the exact markup. - */ + actions?: React.ReactNode; - /** Escape hatch for one-off layout tweaks on the outer wrapper. */ + className?: string; } -// --------------------------------------------------------------------------- -// Component -// --------------------------------------------------------------------------- - -/** - * BannerPage — full-width hero banner used at the top of feature pages. - * - * Fixed design tokens: - * - background: #229EDF - * - border-radius: 24 px - * - min-height: 223 px (grows with content on smaller viewports) - * - max-width: 1169 px (enforced by the page layout, not the banner itself) - * - * All textual content is supplied via props so the same component can be - * dropped into Swap, Borrow, Lend, Discover, and any future page without - * modifications. - */ export function BannerPage({ title, subtitle, @@ -65,7 +37,7 @@ export function BannerPage({ className )} > - {/* Left — text content: min-w-0 so flex allows shrinking and text wraps */} + {}
{badge && } @@ -84,7 +56,7 @@ export function BannerPage({ )}
- {/* Right — illustration (hidden on very small screens) */} + {} {imageSrc && (
@@ -94,10 +66,6 @@ export function BannerPage({ ); } -// --------------------------------------------------------------------------- -// Private sub-components — kept in this file to avoid fragmenting a simple UI -// --------------------------------------------------------------------------- - interface BadgeProps { label: string; } @@ -117,9 +85,6 @@ interface IllustrationProps { function Illustration({ src, alt }: IllustrationProps) { return ( - // Occupies the right 45 % of the banner and is clipped by the parent's - // overflow-hidden, which naturally creates the "image bleeding to the edge" - // effect visible in the design.
() => {}; + interface ModalPortalProps { children: React.ReactNode; lockScroll?: boolean; } export function ModalPortal({ children, lockScroll = true }: ModalPortalProps) { - const [mounted, setMounted] = useState(false); - - useEffect(() => { - setMounted(true); - }, []); + const mounted = useSyncExternalStore( + emptySubscribe, + () => true, + () => false + ); useEffect(() => { if (!mounted || !lockScroll) return; diff --git a/apps/web-app/src/components/ui/ReadonlyRow.tsx b/apps/web-app/src/components/ui/ReadonlyRow.tsx index a64fe0fc..5e01768a 100644 --- a/apps/web-app/src/components/ui/ReadonlyRow.tsx +++ b/apps/web-app/src/components/ui/ReadonlyRow.tsx @@ -7,11 +7,11 @@ import { cn } from "@/lib/utils"; export interface ReadonlyRowProps { label: string; value: string; - /** When provided, renders an external link instead of the copy button. */ + href?: string | null; - /** When provided with onCopy, enables copy button. */ + copyKey?: string; - /** Value to copy when different from displayed value (e.g. full address). */ + copyValue?: string; onCopy?: (key: string, value: string) => void; copiedKey?: string | null; diff --git a/apps/web-app/src/features/borrowing/components/pages/Borrow.tsx b/apps/web-app/src/features/borrowing/components/pages/Borrow.tsx index f362fd3b..46b1d73b 100644 --- a/apps/web-app/src/features/borrowing/components/pages/Borrow.tsx +++ b/apps/web-app/src/features/borrowing/components/pages/Borrow.tsx @@ -1,12 +1,18 @@ "use client"; +import { useState } from "react"; import { BannerPage } from "@/components/ui/BannerPage"; import { PageContainer } from "@/components/ui/PageContainer"; import { useBorrow } from "../../hooks/useBorrow"; import { BorrowTable } from "../ui/BorrowTable"; import { BorrowModal } from "../ui/BorrowModal"; +import MyBorrowPositions from "../ui/MyBorrowPositions"; +import { GetTestTokensBanner } from "@/features/wallet/components/GetTestTokensBanner"; + +type PageTab = "pools" | "positions"; const Borrow: React.FC = () => { + const [activeTab, setActiveTab] = useState("pools"); const { assets, paginatedAssets, @@ -14,8 +20,6 @@ const Borrow: React.FC = () => { poolsError, selectedAsset, isProcessing, - executionError, - success, isWalletConnected, page, totalRows, @@ -26,8 +30,6 @@ const Borrow: React.FC = () => { openModal, closeModal, handleSubmit, - clearSuccess, - clearError, } = useBorrow(); return ( @@ -41,33 +43,60 @@ const Borrow: React.FC = () => { className="mb-8" /> - + + +
+ + +
- {selectedAsset && ( - + {activeTab === "pools" && ( + <> + + + {selectedAsset && ( + + )} + )} + + {activeTab === "positions" && } ); }; diff --git a/apps/web-app/src/features/borrowing/components/ui/BorrowModal.tsx b/apps/web-app/src/features/borrowing/components/ui/BorrowModal.tsx index 57caf9f4..bc87bec6 100644 --- a/apps/web-app/src/features/borrowing/components/ui/BorrowModal.tsx +++ b/apps/web-app/src/features/borrowing/components/ui/BorrowModal.tsx @@ -9,25 +9,17 @@ import type { BorrowTableAsset } from "../../types/borrowing"; interface BorrowModalProps { asset: BorrowTableAsset; isProcessing: boolean; - error: string | null; - success: string | null; isWalletConnected: boolean; onClose: () => void; onSubmit: (collateral: string, borrow: string) => Promise; - onClearError: () => void; - onClearSuccess: () => void; } export function BorrowModal({ asset, isProcessing, - error, - success, isWalletConnected, onClose, onSubmit, - onClearError, - onClearSuccess, }: BorrowModalProps) { const [collateralAmount, setCollateralAmount] = useState(""); const [borrowAmount, setBorrowAmount] = useState(""); @@ -73,29 +65,6 @@ export function BorrowModal({
- {error && ( -
-

{error}

- -
- )} - {success && ( -
-

{success}

- -
- )} -

Collateral Token

diff --git a/apps/web-app/src/features/borrowing/components/ui/BorrowTable.tsx b/apps/web-app/src/features/borrowing/components/ui/BorrowTable.tsx index 24694347..80808106 100644 --- a/apps/web-app/src/features/borrowing/components/ui/BorrowTable.tsx +++ b/apps/web-app/src/features/borrowing/components/ui/BorrowTable.tsx @@ -36,8 +36,8 @@ export function BorrowTable({ const isEmpty = !isLoading && !poolsError && assets.length === 0; return ( -

- {/* ── Desktop table (md+) ─────────────────────────────────────────── */} +
+ {/* Desktop */}
@@ -89,13 +89,14 @@ export function BorrowTable({ token1={asset.pool.token1} token2={asset.pool.token2} fee={asset.pool.fee} + isAggregated={asset.isAggregated} /> - ); -} +import { LendTable } from "@/features/lending/components/ui/LendTable"; +import type { PoolData } from "@/features/lending/types/lending"; const AssetBreakdown: React.FC = () => { + const router = useRouter(); const { assets, isLoading, error } = useDashboardPools(); + const handleAction = (_pool: PoolData) => router.push("/lending"); + return (
@@ -55,7 +26,7 @@ const AssetBreakdown: React.FC = () => {

View all pools @@ -63,122 +34,14 @@ const AssetBreakdown: React.FC = () => {
-
-
{asset.borrowApr} - {asset.collateralFactorDisplay} + {asset.isAggregated ? "—" : asset.collateralFactorDisplay} {asset.liquidity} @@ -126,7 +127,7 @@ export function BorrowTable({ )} - {/* ── Mobile cards (< md) ─────────────────────────────────────────── */} + {/* Mobile */}
{isLoading ? ( @@ -142,17 +143,16 @@ export function BorrowTable({
    {paginatedAssets.map((asset) => (
  • - {/* Card header: pool pair + status badge */}
    - {/* Stats grid */}
    - {/* Action */} +
+ + {/* Current Debt — hero stat */} +
+

Current Debt

+

+ {position.debtFormatted} + + {position.assetCode} + +

+
+ + {/* Stats */} +
+
+
+ + + Borrow APR + +
+

+ {position.interestRate.toFixed(2)}% +

+
+ +
+
+ + + dToken Rate + +
+

+ {formatDRate(position.dRate)} +

+
+
+ + {/* dToken balance footer */} +
+

dToken Balance

+

+ {position.dTokens.toLocaleString()} +

+
+ + + + + + ); +} diff --git a/apps/web-app/src/features/borrowing/hooks/useBorrow.ts b/apps/web-app/src/features/borrowing/hooks/useBorrow.ts index 1f97d582..b73c4a02 100644 --- a/apps/web-app/src/features/borrowing/hooks/useBorrow.ts +++ b/apps/web-app/src/features/borrowing/hooks/useBorrow.ts @@ -4,6 +4,10 @@ import { useState, useMemo, useCallback } from "react"; import { useBorrowPools } from "./useBorrowPools"; import { useBorrowExecution } from "./useBorrowExecution"; import { poolsToTableAssets } from "../utils/borrowUtils"; +import { usePools, usePoolAction } from "@/lib/orchestrator"; +import type { PoolInfo } from "@/lib/orchestrator"; +import { fromSmallestUnit } from "@/lib/helpers/tokenUtils"; +import { formatLiquidity } from "@/lib/helpers/formatUtils"; import type { BorrowTableAsset } from "../types/borrowing"; export const ROWS_PER_PAGE_OPTIONS = [10, 25, 50] as const; @@ -12,25 +16,73 @@ export function useBorrow() { const [selectedAsset, setSelectedAsset] = useState( null ); - const [success, setSuccess] = useState(null); const [page, setPage] = useState(0); const [rowsPerPage, setRowsPerPage] = useState(10); const { data: borrowPools = [], - isLoading, - error: poolsError, + isLoading: isLoadingNeko, + error: nekoError, } = useBorrowPools(); + const { + data: orchestratorPools = [], + isLoading: isLoadingOrchestrator, + error: orchestratorError, + } = usePools(); + + const { mutateAsync: executePoolAction } = usePoolAction(); + const { handleBorrow, isLoading: isProcessing, - error: executionError, - clearError, isWalletConnected, } = useBorrowExecution(); - const assets = useMemo(() => poolsToTableAssets(borrowPools), [borrowPools]); + const isLoading = isLoadingNeko || isLoadingOrchestrator; + const poolsError = nekoError || orchestratorError; + + const assets = useMemo(() => { + const nekoAssets = poolsToTableAssets(borrowPools); + + const aggregated: BorrowTableAsset[] = orchestratorPools + .filter( + (p: PoolInfo) => + p.type !== "neko" && p.supportedActions.includes("borrow") + ) + .map((p: PoolInfo, i: number) => { + const token = p.tokens[0]; + const decimals = token?.decimals ?? 7; + const liquidity = formatLiquidity( + fromSmallestUnit(p.tvl.toString(), decimals) + ); + const borrowApy = + typeof p.metadata.borrowApy === "number" + ? p.metadata.borrowApy + : p.apy; + const contractId = p.id.split(":")[1] ?? p.id; + return { + id: `agg-borrow-${i}`, + pool: { + token1: token?.code ?? "?", + token2: p.name, + fee: "0%", + }, + borrowApr: borrowApy > 0 ? `${borrowApy.toFixed(2)}%` : "0.00%", + collateralFactorDisplay: "Aggregated", + liquidity, + isActive: p.state === "active", + assetCode: token?.code ?? "?", + collateralTokenCode: "", + collateralFactor: 0, + contractId, + isAggregated: true, + orchestratorId: p.id, + }; + }); + + return [...nekoAssets, ...aggregated]; + }, [borrowPools, orchestratorPools]); const totalRows = assets.length; const totalPages = Math.ceil(totalRows / rowsPerPage); @@ -39,24 +91,32 @@ export function useBorrow() { (page + 1) * rowsPerPage ); - const openModal = useCallback( - (asset: BorrowTableAsset) => { - setSelectedAsset(asset); - setSuccess(null); - clearError(); - }, - [clearError] - ); + const openModal = useCallback((asset: BorrowTableAsset) => { + setSelectedAsset(asset); + }, []); const closeModal = useCallback(() => { setSelectedAsset(null); - setSuccess(null); - clearError(); - }, [clearError]); + }, []); const handleSubmit = useCallback( async (collateralAmount: string, borrowAmount: string) => { if (!selectedAsset) return; + + if (selectedAsset.isAggregated && selectedAsset.orchestratorId) { + const decimals = 7; + const rawAmount = BigInt( + Math.floor(parseFloat(borrowAmount) * 10 ** decimals) + ); + await executePoolAction({ + poolId: selectedAsset.orchestratorId, + action: "borrow", + amount: rawAmount, + }); + closeModal(); + return; + } + const result = await handleBorrow({ collateralTokenCode: selectedAsset.collateralTokenCode, assetCode: selectedAsset.assetCode, @@ -64,12 +124,11 @@ export function useBorrow() { borrowAmount, collateralDecimals: 7, borrowDecimals: 7, + contractId: selectedAsset.contractId, }); - if (result?.success && result.message) { - setSuccess(result.message); - } + if (result?.success) closeModal(); }, - [selectedAsset, handleBorrow] + [selectedAsset, handleBorrow, executePoolAction, closeModal] ); const changeRowsPerPage = useCallback((value: number) => { @@ -84,8 +143,6 @@ export function useBorrow() { poolsError, selectedAsset, isProcessing, - executionError, - success, isWalletConnected: !!isWalletConnected, page, totalRows, @@ -96,7 +153,5 @@ export function useBorrow() { openModal, closeModal, handleSubmit, - clearSuccess: () => setSuccess(null), - clearError, }; } diff --git a/apps/web-app/src/features/borrowing/hooks/useBorrowExecution.ts b/apps/web-app/src/features/borrowing/hooks/useBorrowExecution.ts index 7a1dbbb4..34881d50 100644 --- a/apps/web-app/src/features/borrowing/hooks/useBorrowExecution.ts +++ b/apps/web-app/src/features/borrowing/hooks/useBorrowExecution.ts @@ -3,6 +3,7 @@ import { useState, useCallback } from "react"; import { Networks } from "@stellar/stellar-sdk"; import { useWallet } from "@/hooks/useWallet"; +import { useToast } from "@/hooks/useToast"; import { approveToken, addCollateral, @@ -14,19 +15,36 @@ import { } from "@/lib/helpers/stellar/transaction"; import { getAvailableTokens } from "@/lib/helpers/stellar/soroswap"; import { rpcUrl } from "@/lib/constants/network"; -import { networks } from "@neko/lending"; import { extractContractErrorOrNull } from "@/lib/helpers/stellar/contractErrors"; +import { TOAST_CONFIG } from "@/lib/constants/toast.config"; import type { BorrowExecutionParams } from "../types/borrowing"; export function useBorrowExecution() { + const { addNotification } = useToast(); const { address, signTransaction, networkPassphrase } = useWallet(); const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(null); + + const showError = useCallback( + (msg: string) => + addNotification("Something went wrong", "error", { + ...TOAST_CONFIG.defaultOpts, + description: msg, + }), + [addNotification] + ); + const showSuccess = useCallback( + (msg: string) => + addNotification("Success", "success", { + ...TOAST_CONFIG.defaultOpts, + description: msg, + }), + [addNotification] + ); const handleBorrow = useCallback( async (params: BorrowExecutionParams) => { if (!address) { - setError("Please connect your wallet first"); + showError("Please connect your wallet first"); return; } @@ -47,21 +65,20 @@ export function useBorrowExecution() { !Number.isFinite(borrowNum) || borrowNum <= 0 ) { - setError("Please enter valid collateral and borrow amounts"); + showError("Please enter valid collateral and borrow amounts"); return; } const availableTokens = getAvailableTokens(); const collateralToken = availableTokens[collateralTokenCode]; - const lendingContractId = networks.testnet.contractId; + const lendingContractId = params.contractId; if (!collateralToken?.contract) { - setError(`Collateral token ${collateralTokenCode} not found`); + showError(`Collateral token ${collateralTokenCode} not found`); return; } setIsLoading(true); - setError(null); const signAndSend = (xdr: string) => signAndSendTransaction(xdr, signTransaction as SignTransactionFn, { @@ -85,7 +102,8 @@ export function useBorrowExecution() { collateralToken.contract, collateralAmount, collateralDecimals, - address + address, + lendingContractId ); await signAndSend(addCollateralXdr); @@ -93,38 +111,33 @@ export function useBorrowExecution() { assetCode, borrowAmount, borrowDecimals, - address + address, + lendingContractId ); await signAndSend(borrowXdr); - return { - success: true as const, - message: `Successfully borrowed ${borrowNum} ${assetCode} using ${collateralNum} ${collateralTokenCode} as collateral`, - }; + showSuccess( + `Successfully borrowed ${borrowNum} ${assetCode} using ${collateralNum} ${collateralTokenCode} as collateral` + ); + return { success: true as const }; } catch (err) { const friendlyError = extractContractErrorOrNull(err); - if (friendlyError) { - setError( - typeof friendlyError === "string" - ? friendlyError - : "An unexpected error occurred. Please try again." - ); - } + showError( + typeof friendlyError === "string" + ? friendlyError + : "An unexpected error occurred. Please try again." + ); return { success: false as const, error: err }; } finally { setIsLoading(false); } }, - [address, networkPassphrase, signTransaction] + [address, networkPassphrase, signTransaction, showError, showSuccess] ); - const clearError = useCallback(() => setError(null), []); - return { handleBorrow, isLoading, - error, - clearError, isWalletConnected: Boolean(address), }; } diff --git a/apps/web-app/src/features/borrowing/hooks/useBorrowPools.ts b/apps/web-app/src/features/borrowing/hooks/useBorrowPools.ts index b719d757..8e89f1f8 100644 --- a/apps/web-app/src/features/borrowing/hooks/useBorrowPools.ts +++ b/apps/web-app/src/features/borrowing/hooks/useBorrowPools.ts @@ -12,166 +12,181 @@ import { parseInterestRateFromContractResult } from "@/lib/helpers/lendingUtils" import { RWA_TOKENS } from "@/lib/constants/wallet"; import type { BorrowPool } from "../types/borrowing"; -/** - * Hook to get all active borrow pools from the RWA lending contract - * Only shows pools where: - * - There's an RWA token configured as collateral - * - There's a debt asset configured - * - Pool has balance available to borrow - */ +/** Debt assets borrowable from Pool 2 (collateral = USDC/XLM) */ +const POOL2_DEBT_ASSETS = ["USTRY", "TESOURO", "CETES", "USDY", "PYUSD"]; +/** Collateral assets accepted by Pool 2 */ +const POOL2_COLLATERAL_ASSETS = ["USDC", "XLM"]; + +async function fetchPoolPools( + client: RwaLendingClient, + contractId: string, + collateralCodes: string[], + debtCodes: string[], + availableTokens: ReturnType +): Promise { + let poolState; + try { + const poolStateTx = await client.get_pool_state({ simulate: true }); + poolState = poolStateTx.result; + } catch { + return []; + } + + if (poolState?.tag !== "Active") return []; + + const pools: BorrowPool[] = []; + + for (const collateralCode of collateralCodes) { + const collateralToken = availableTokens[collateralCode]; + if (!collateralToken?.contract) continue; + + let collateralFactor = 0; + try { + const factorTx = await client.get_collateral_factor( + { rwa_token: collateralToken.contract }, + { simulate: true } + ); + const factorValue = factorTx.result; + if (factorValue) { + collateralFactor = Number(factorValue) / 100_000; + } + } catch { + continue; + } + + if (collateralFactor === 0) continue; + + for (const debtCode of debtCodes) { + const debtToken = availableTokens[debtCode]; + if (!debtToken?.contract) continue; + + try { + const balanceTx = await client.get_pool_balance( + { asset: debtCode }, + { simulate: true } + ); + const balanceValue = balanceTx.result; + if (balanceValue === undefined || balanceValue === null) continue; + + const decimals = debtToken.decimals || 7; + const balanceStr = + typeof balanceValue === "bigint" + ? balanceValue.toString() + : typeof balanceValue === "string" + ? balanceValue + : String(balanceValue); + const poolBalance = fromSmallestUnit( + BigInt(balanceStr).toString(), + decimals + ); + + let interestRate = 0; + try { + const rateTx = await client.get_interest_rate( + { asset: debtCode }, + { simulate: true } + ); + interestRate = parseInterestRateFromContractResult(rateTx.result); + } catch { + // use 0 + } + + pools.push({ + asset: debtToken.contract, + assetCode: debtCode, + collateralToken: collateralToken.contract, + collateralTokenCode: collateralCode, + collateralFactor, + interestRate, + poolBalance, + poolBalanceUSD: "Calculating...", + isActive: true, + contractId, + }); + } catch { + continue; + } + } + } + + return pools; +} + export const useBorrowPools = () => { - // Get available tokens (memoized to prevent re-computation on every render) const availableTokens = useMemo(() => getAvailableTokens(), []); - // All RWA tokens configured as collateral (memoized) - const rwaTokens = useMemo( - () => - RWA_TOKENS.filter((code) => { - const token = availableTokens[code]; - return token && token.contract; - }), + const pool1CollateralCodes = useMemo( + () => RWA_TOKENS.filter((c) => availableTokens[c]?.contract), + [availableTokens] + ); + + const pool1DebtCodes = useMemo( + () => ["USDC", "XLM"].filter((c) => availableTokens[c]?.contract), + [availableTokens] + ); + + const pool2CollateralCodes = useMemo( + () => POOL2_COLLATERAL_ASSETS.filter((c) => availableTokens[c]?.contract), [availableTokens] ); - // Known debt assets that can be borrowed (memoized) - const debtAssets = useMemo( - () => - ["USDC", "XLM"].filter((code) => { - const token = availableTokens[code]; - return token && token.contract; - }), + const pool2DebtCodes = useMemo( + () => POOL2_DEBT_ASSETS.filter((c) => availableTokens[c]?.contract), [availableTokens] ); - // Memoize the query function to prevent re-creation on every render const queryFn = useMemo( () => async (): Promise => { - // Create RWA lending client with new contract ID - const contractId = networks.testnet.contractId; - - const client = new RwaLendingClient({ - contractId: contractId, - rpcUrl: rpcUrl, - networkPassphrase: networkPassphrase, + const clientOptions = { + rpcUrl, + networkPassphrase, ...(allowHttpForSoroban && { allowHttp: true }), - }); - - // Get pool state - let poolState; - try { - const poolStateTx = await client.get_pool_state({ simulate: true }); - poolState = poolStateTx.result; - } catch { - return []; - } - - const isPoolActive = poolState?.tag === "Active"; - - if (!isPoolActive) { - return []; - } - - const pools: BorrowPool[] = []; - - // For each combination of RWA collateral token and debt asset - for (const rwaCode of rwaTokens) { - const rwaToken = availableTokens[rwaCode]; - if (!rwaToken?.contract) continue; + }; - // Get collateral factor for this RWA token - let collateralFactor = 0; - try { - const collateralFactorTx = await client.get_collateral_factor( - { rwa_token: rwaToken.contract }, - { simulate: true } - ); - const factorValue = collateralFactorTx.result; - if (factorValue) { - // Collateral factor is stored with 7 decimals (e.g., 7_500_000 = 75%) - collateralFactor = Number(factorValue) / 100_000; - } - } catch { - // If collateral factor fetch fails, skip this RWA token - continue; - } - - // Skip if collateral factor is 0 (not configured) - if (collateralFactor === 0) continue; - - // For each debt asset, create a borrow pool - for (const debtCode of debtAssets) { - const debtToken = availableTokens[debtCode]; - if (!debtToken?.contract) continue; - - try { - // Get pool balance for this debt asset - const balanceTx = await client.get_pool_balance( - { asset: debtCode }, - { simulate: true } - ); - const balanceValue = balanceTx.result; - // Only skip if balanceValue is undefined/null, NOT if it's 0 - if (balanceValue === undefined || balanceValue === null) continue; - - // Convert balance to human-readable format - const decimals = debtToken.decimals || 7; - // Handle different types: bigint, string, or number - const balanceStr = - typeof balanceValue === "bigint" - ? balanceValue.toString() - : typeof balanceValue === "string" - ? balanceValue - : String(balanceValue); - const balanceBigInt = BigInt(balanceStr); - const poolBalance = fromSmallestUnit( - balanceBigInt.toString(), - decimals - ); - - // Get interest rate for borrowing (basis points → percentage) - let interestRate = 0; - try { - const interestRateTx = await client.get_interest_rate( - { asset: debtCode }, - { simulate: true } - ); - interestRate = parseInterestRateFromContractResult( - interestRateTx.result - ); - } catch { - // If interest rate fetch fails, use 0 - } - - pools.push({ - asset: debtToken.contract, - assetCode: debtCode, - collateralToken: rwaToken.contract, - collateralTokenCode: rwaCode, - collateralFactor, - interestRate, - poolBalance, - poolBalanceUSD: "Calculating...", // Will be calculated in component - isActive: true, - }); - } catch { - // Skip assets that fail to fetch (not configured or error) - continue; - } - } - } + const pool1Client = new RwaLendingClient({ + contractId: networks.testnet.pool1ContractId, + ...clientOptions, + }); + const pool2Client = new RwaLendingClient({ + contractId: networks.testnet.pool2ContractId, + ...clientOptions, + }); - return pools; + const [pool1Pools, pool2Pools] = await Promise.all([ + fetchPoolPools( + pool1Client, + networks.testnet.pool1ContractId, + pool1CollateralCodes, + pool1DebtCodes, + availableTokens + ), + fetchPoolPools( + pool2Client, + networks.testnet.pool2ContractId, + pool2CollateralCodes, + pool2DebtCodes, + availableTokens + ), + ]); + + return [...pool1Pools, ...pool2Pools]; }, - [rwaTokens, debtAssets, availableTokens] + [ + pool1CollateralCodes, + pool1DebtCodes, + pool2CollateralCodes, + pool2DebtCodes, + availableTokens, + ] ); return useQuery({ queryKey: ["borrowPools"], queryFn, - staleTime: 2 * 60_000, // 2 min: avoid refetch when re-entering tab + staleTime: 2 * 60_000, gcTime: 10 * 60_000, - refetchInterval: 2 * 60_000, // 2 min background refresh - refetchOnWindowFocus: false, // don't refetch every time user switches to tab + refetchInterval: 2 * 60_000, + refetchOnWindowFocus: false, placeholderData: (prev) => prev, retry: 2, throwOnError: false, diff --git a/apps/web-app/src/features/borrowing/hooks/useHealthFactor.ts b/apps/web-app/src/features/borrowing/hooks/useHealthFactor.ts new file mode 100644 index 00000000..6cfc19be --- /dev/null +++ b/apps/web-app/src/features/borrowing/hooks/useHealthFactor.ts @@ -0,0 +1,62 @@ +"use client"; + +import { useQueries } from "@tanstack/react-query"; +import { networks } from "@neko/lending"; +import { lendingService } from "@/lib/services/lending.service"; + +export function getHealthFactorColor(hf: number | null): string { + if (hf === null) return "text-white/40"; + if (hf >= 1.5) return "text-green-400"; + if (hf >= 1.0) return "text-yellow-400"; + return "text-red-400"; +} + +export function getHealthFactorLabel(hf: number | null): string { + if (hf === null) return "No Position"; + if (hf >= 1.5) return "Safe"; + if (hf >= 1.0) return "Caution"; + return "At Risk"; +} + +const POOLS = [ + { + key: "pool1", + contractId: networks.testnet.pool1ContractId, + label: "Pool 1", + }, + { + key: "pool2", + contractId: networks.testnet.pool2ContractId, + label: "Pool 2", + }, +] as const; + +export interface PoolHealthFactor { + key: string; + label: string; + contractId: string; + healthFactor: number | null; +} + +export function useHealthFactor(borrower: string | undefined) { + const results = useQueries({ + queries: POOLS.map((pool) => ({ + queryKey: ["health-factor", pool.key, borrower], + queryFn: () => lendingService.getHealthFactor(borrower!, pool.contractId), + enabled: Boolean(borrower), + staleTime: 15_000, + refetchInterval: 15_000, + })), + }); + + const pools: PoolHealthFactor[] = POOLS.map((pool, i) => ({ + key: pool.key, + label: pool.label, + contractId: pool.contractId, + healthFactor: results[i].data ?? null, + })); + + const isLoading = results.some((r) => r.isLoading); + + return { pools, isLoading }; +} diff --git a/apps/web-app/src/features/borrowing/hooks/useUserBorrowPositions.ts b/apps/web-app/src/features/borrowing/hooks/useUserBorrowPositions.ts new file mode 100644 index 00000000..358ca2cd --- /dev/null +++ b/apps/web-app/src/features/borrowing/hooks/useUserBorrowPositions.ts @@ -0,0 +1,159 @@ +import { useQueries } from "@tanstack/react-query"; +import { useWallet } from "@/hooks/useWallet"; +import { Client as RwaLendingClient } from "@neko/lending"; +import { + rpcUrl, + networkPassphrase, + allowHttpForSoroban, +} from "@/lib/constants/network"; +import { formatAmount } from "@/lib/helpers/formatUtils"; +import { useBorrowPools } from "./useBorrowPools"; + +const SCALAR_12 = 1_000_000_000_000n; +const STELLAR_DECIMALS = 7; + +export interface BorrowPosition { + assetCode: string; + collateralTokenCode: string; + collateralToken: string; + contractId: string; + dTokens: bigint; + dTokensFormatted: string; + dRate: bigint; + debtRaw: bigint; + debtFormatted: string; + collateralRaw: bigint; + collateralFormatted: string; + interestRate: number; +} + +async function fetchPositionData( + assetCode: string, + collateralToken: string, + walletAddress: string, + contractId: string +): Promise<{ dTokens: bigint; dRate: bigint; collateral: bigint }> { + const client = new RwaLendingClient({ + contractId, + rpcUrl, + networkPassphrase, + ...(allowHttpForSoroban && { allowHttp: true }), + }); + + const [dTokensTx, dRateTx, collateralTx] = await Promise.all([ + client.get_d_token_balance( + { borrower: walletAddress, asset: assetCode }, + { simulate: true } + ), + client.get_d_token_rate({ asset: assetCode }, { simulate: true }), + client.get_collateral( + { borrower: walletAddress, rwa_token: collateralToken }, + { simulate: true } + ), + ]); + + const dTokens = + dTokensTx.result != null ? BigInt(String(dTokensTx.result)) : 0n; + const dRate = dRateTx.result != null ? BigInt(String(dRateTx.result)) : 0n; + const collateral = + collateralTx.result != null ? BigInt(String(collateralTx.result)) : 0n; + + return { dTokens, dRate, collateral }; +} + +export function useUserBorrowPositions() { + const { address } = useWallet(); + const { data: pools = [], isLoading: poolsLoading } = useBorrowPools(); + + // Deduplicate by assetCode — debt is per-asset, not per-pool + const uniqueAssets = pools.reduce< + Record< + string, + { + assetCode: string; + collateralTokenCode: string; + collateralToken: string; + interestRate: number; + contractId: string; + } + > + >((acc, pool) => { + if (!acc[pool.assetCode]) { + acc[pool.assetCode] = { + assetCode: pool.assetCode, + collateralTokenCode: pool.collateralTokenCode, + collateralToken: pool.collateralToken, + interestRate: pool.interestRate, + contractId: pool.contractId, + }; + } + return acc; + }, {}); + + const assets = Object.values(uniqueAssets); + + const positionQueries = useQueries({ + queries: assets.map((a) => ({ + queryKey: [ + "userBorrowPosition", + a.assetCode, + a.contractId, + a.collateralToken, + address, + ], + queryFn: () => + fetchPositionData( + a.assetCode, + a.collateralToken, + address!, + a.contractId + ), + enabled: Boolean(address) && assets.length > 0, + staleTime: 30_000, + gcTime: 5 * 60_000, + retry: 1, + })), + }); + + const isLoading = + poolsLoading || + (Boolean(address) && positionQueries.some((q) => q.isLoading)); + + const positions: BorrowPosition[] = []; + + positionQueries.forEach((q, i) => { + if (!q.data) return; + const { dTokens, dRate, collateral } = q.data; + if (dTokens === 0n) return; + + const asset = assets[i]; + const debtRaw = dRate > 0n ? (dTokens * dRate) / SCALAR_12 : 0n; + const debtFormatted = + debtRaw === 0n + ? "0" + : formatAmount(Number(debtRaw) / 10 ** STELLAR_DECIMALS); + const dTokensFormatted = formatAmount( + Number(dTokens) / 10 ** STELLAR_DECIMALS + ); + const collateralFormatted = formatAmount( + Number(collateral) / 10 ** STELLAR_DECIMALS + ); + + positions.push({ + assetCode: asset.assetCode, + collateralTokenCode: asset.collateralTokenCode, + collateralToken: asset.collateralToken, + contractId: asset.contractId, + dTokens, + dTokensFormatted, + dRate, + debtRaw, + debtFormatted, + collateralRaw: collateral, + collateralFormatted, + interestRate: asset.interestRate, + }); + }); + + return { positions, isLoading, hasWallet: Boolean(address) }; +} diff --git a/apps/web-app/src/features/borrowing/hooks/useUserDebt.ts b/apps/web-app/src/features/borrowing/hooks/useUserDebt.ts new file mode 100644 index 00000000..50d96996 --- /dev/null +++ b/apps/web-app/src/features/borrowing/hooks/useUserDebt.ts @@ -0,0 +1,44 @@ +import { useQuery } from "@tanstack/react-query"; +import { useWallet } from "@/hooks/useWallet"; +import { getDTokenBalance, getDTokenRate } from "@/lib/helpers/stellar/lending"; + +const SCALAR_12 = 1_000_000_000_000n; +const STELLAR_DECIMALS = 7; + +interface UserDebt { + dTokens: bigint; + dRate: bigint; + debtRaw: bigint; + debtFormatted: string; +} + +async function fetchUserDebt( + assetCode: string, + walletAddress: string +): Promise { + const [dTokens, dRate] = await Promise.all([ + getDTokenBalance(assetCode, walletAddress), + getDTokenRate(assetCode), + ]); + + const debtRaw = dRate > 0n ? (dTokens * dRate) / SCALAR_12 : 0n; + const debtFormatted = + debtRaw === 0n + ? "0" + : (Number(debtRaw) / 10 ** STELLAR_DECIMALS).toFixed(STELLAR_DECIMALS); + + return { dTokens, dRate, debtRaw, debtFormatted }; +} + +export function useUserDebt(assetCode: string | undefined) { + const { address } = useWallet(); + + return useQuery({ + queryKey: ["userDebt", assetCode, address], + queryFn: () => fetchUserDebt(assetCode!, address!), + enabled: Boolean(assetCode && address), + staleTime: 30_000, + gcTime: 5 * 60_000, + retry: 1, + }); +} diff --git a/apps/web-app/src/features/borrowing/hooks/useUserPosition.ts b/apps/web-app/src/features/borrowing/hooks/useUserPosition.ts new file mode 100644 index 00000000..541e20ff --- /dev/null +++ b/apps/web-app/src/features/borrowing/hooks/useUserPosition.ts @@ -0,0 +1,136 @@ +"use client"; + +import { useQueries } from "@tanstack/react-query"; +import { networks } from "@neko/lending"; +import { useUserLendingPositions } from "@/features/lending/hooks/useUserLendingPositions"; +import type { LendingPosition } from "@/features/lending/hooks/useUserLendingPositions"; +import { useUserBorrowPositions } from "./useUserBorrowPositions"; +import type { BorrowPosition } from "./useUserBorrowPositions"; +import { useHealthFactor } from "./useHealthFactor"; +import type { PoolHealthFactor } from "./useHealthFactor"; +import { lendingService } from "@/lib/services/lending.service"; +import { getAssetsConfig } from "@/lib/constants/assets.config"; + +export type { LendingPosition, BorrowPosition, PoolHealthFactor }; + +const STELLAR_DECIMALS = 7; + +const POOLS = [ + { + key: "pool1", + contractId: networks.testnet.pool1ContractId, + label: "Pool 1", + }, + { + key: "pool2", + contractId: networks.testnet.pool2ContractId, + label: "Pool 2", + }, +] as const; + +export interface CollateralPosition { + tokenCode: string; + contract: string; + raw: bigint; + formatted: string; +} + +export interface BorrowLimitEntry { + key: string; + label: string; + limitUsd: number | null; +} + +export interface UserPositionData { + deposits: LendingPosition[]; + debts: BorrowPosition[]; + healthFactors: PoolHealthFactor[]; + collateral: CollateralPosition[]; + borrowLimits: BorrowLimitEntry[]; + isLoading: boolean; + hasWallet: boolean; +} + +export function useUserPosition( + borrowerAddress: string | undefined +): UserPositionData { + const { positions: deposits, isLoading: depositsLoading } = + useUserLendingPositions(); + const { + positions: debts, + isLoading: debtsLoading, + hasWallet, + } = useUserBorrowPositions(); + const { pools: healthFactors, isLoading: hfLoading } = + useHealthFactor(borrowerAddress); + + const rwaTokens = Object.values(getAssetsConfig()).filter( + (a) => a.priceSource === "oracle" + ); + + const collateralQueries = useQueries({ + queries: rwaTokens.map((token) => ({ + queryKey: ["userCollateral", token.code, borrowerAddress], + queryFn: () => + lendingService.getCollateralRaw( + token.contract, + borrowerAddress!, + networks.testnet.pool1ContractId + ), + enabled: Boolean(borrowerAddress), + staleTime: 30_000, + gcTime: 5 * 60_000, + retry: 1, + })), + }); + + const borrowLimitQueries = useQueries({ + queries: POOLS.map((pool) => ({ + queryKey: ["borrowLimit", pool.key, borrowerAddress], + queryFn: () => + lendingService.getBorrowLimitForPool(borrowerAddress!, pool.contractId), + enabled: Boolean(borrowerAddress), + staleTime: 30_000, + gcTime: 5 * 60_000, + retry: 1, + })), + }); + + const isLoading = + depositsLoading || + debtsLoading || + hfLoading || + (Boolean(borrowerAddress) && collateralQueries.some((q) => q.isLoading)) || + (Boolean(borrowerAddress) && borrowLimitQueries.some((q) => q.isLoading)); + + const collateral: CollateralPosition[] = []; + collateralQueries.forEach((q, i) => { + const raw = q.data ?? 0n; + if (raw === 0n) return; + const token = rwaTokens[i]; + collateral.push({ + tokenCode: token.code, + contract: token.contract, + raw, + formatted: (Number(raw) / 10 ** STELLAR_DECIMALS).toFixed( + STELLAR_DECIMALS + ), + }); + }); + + const borrowLimits: BorrowLimitEntry[] = POOLS.map((pool, i) => ({ + key: pool.key, + label: pool.label, + limitUsd: borrowLimitQueries[i].data ?? null, + })); + + return { + deposits, + debts, + healthFactors, + collateral, + borrowLimits, + isLoading, + hasWallet, + }; +} diff --git a/apps/web-app/src/features/borrowing/types/borrowing.ts b/apps/web-app/src/features/borrowing/types/borrowing.ts index ea435046..4612bf66 100644 --- a/apps/web-app/src/features/borrowing/types/borrowing.ts +++ b/apps/web-app/src/features/borrowing/types/borrowing.ts @@ -1,8 +1,3 @@ -/** - * Shared types for the borrowing feature - */ - -/** Pool data from the RWA lending contract (useBorrowPools) */ export interface BorrowPool { asset: string; assetCode: string; @@ -13,9 +8,9 @@ export interface BorrowPool { poolBalance: string; poolBalanceUSD: string; isActive: boolean; + contractId: string; } -/** Row shape for the borrow table (derived from BorrowPool) */ export interface BorrowTableAsset { id: string; pool: { @@ -30,9 +25,11 @@ export interface BorrowTableAsset { assetCode: string; collateralTokenCode: string; collateralFactor: number; + contractId: string; + isAggregated?: boolean; + orchestratorId?: string; } -/** Params for executing the borrow flow (approve → addCollateral → borrow) */ export interface BorrowExecutionParams { collateralTokenCode: string; assetCode: string; @@ -40,9 +37,9 @@ export interface BorrowExecutionParams { borrowAmount: string; collateralDecimals?: number; borrowDecimals?: number; + contractId: string; } -/** Form state for the borrow modal */ export interface BorrowFormState { collateralAmount: string; borrowAmount: string; diff --git a/apps/web-app/src/features/borrowing/utils/borrowUtils.ts b/apps/web-app/src/features/borrowing/utils/borrowUtils.ts index 7e391656..840817e0 100644 --- a/apps/web-app/src/features/borrowing/utils/borrowUtils.ts +++ b/apps/web-app/src/features/borrowing/utils/borrowUtils.ts @@ -1,11 +1,6 @@ -/** - * Pure helpers for borrowing: formatting, calculations - */ - import { formatLiquidity } from "@/lib/helpers/formatUtils"; import type { BorrowPool, BorrowTableAsset } from "../types/borrowing"; -/** Borrow limit from collateral amount and collateral factor (percentage, e.g. 75) */ export function calculateBorrowLimit( collateralAmount: number, collateralFactorPct: number @@ -16,9 +11,6 @@ export function calculateBorrowLimit( return collateralAmount * (collateralFactorPct / 100); } -/** - * Map borrow pools to table row assets (id, pool, borrowApr, liquidity, etc.) - */ export function poolsToTableAssets(pools: BorrowPool[]): BorrowTableAsset[] { return pools.map((pool, index) => { const liquidity = formatLiquidity(pool.poolBalance); @@ -27,7 +19,7 @@ export function poolsToTableAssets(pools: BorrowPool[]): BorrowTableAsset[] { pool: { token1: pool.assetCode, token2: pool.collateralTokenCode, - fee: `${pool.collateralFactor}%`, + fee: "V1", }, borrowApr: `${pool.interestRate.toFixed(2)}%`, collateralFactorDisplay: `${pool.collateralFactor}%`, @@ -36,6 +28,7 @@ export function poolsToTableAssets(pools: BorrowPool[]): BorrowTableAsset[] { assetCode: pool.assetCode, collateralTokenCode: pool.collateralTokenCode, collateralFactor: pool.collateralFactor, + contractId: pool.contractId, }; }); } diff --git a/apps/web-app/src/features/dashboard/components/pages/Dashboard.tsx b/apps/web-app/src/features/dashboard/components/pages/Dashboard.tsx index 3e113fa6..d82980d7 100644 --- a/apps/web-app/src/features/dashboard/components/pages/Dashboard.tsx +++ b/apps/web-app/src/features/dashboard/components/pages/Dashboard.tsx @@ -8,6 +8,7 @@ import QuickActions from "@/features/dashboard/components/ui/QuickActions"; import DiscoverAssets from "@/features/dashboard/components/ui/DiscoverAssets"; import AssetBreakdown from "@/features/dashboard/components/ui/AssetBreakdown"; import YourPositions from "@/features/dashboard/components/ui/YourPositions"; +import { GetTestTokensBanner } from "@/features/wallet/components/GetTestTokensBanner"; const Dashboard: React.FC = () => { return ( @@ -20,6 +21,7 @@ const Dashboard: React.FC = () => { imageSrc="/banners/oracle.svg" imageAlt="Dashboard illustration" /> + diff --git a/apps/web-app/src/features/dashboard/components/ui/AssetBreakdown.tsx b/apps/web-app/src/features/dashboard/components/ui/AssetBreakdown.tsx index 35ef74f5..e986c3ce 100644 --- a/apps/web-app/src/features/dashboard/components/ui/AssetBreakdown.tsx +++ b/apps/web-app/src/features/dashboard/components/ui/AssetBreakdown.tsx @@ -2,47 +2,18 @@ import React from "react"; import Link from "next/link"; -import { - ArrowRight, - Hash, - Layers, - TrendingUp, - Percent, - Droplets, - Info, -} from "lucide-react"; +import { ArrowRight } from "lucide-react"; +import { useRouter } from "next/navigation"; import { useDashboardPools } from "@/features/dashboard/hooks/useDashboardPools"; - -function ColHeader({ - icon: Icon, - label, - tooltip, -}: { - icon: React.ElementType; - label: string; - tooltip?: string; -}) { - return ( -
-
- - {label} - {tooltip && ( - - - - {tooltip} - - - )} -
-
- - - - - - - - - - - {isLoading ? ( - - - - ) : error ? ( - - - - ) : assets.length === 0 ? ( - - - - ) : ( - assets.map((asset) => ( - - {/* ID */} - - - {/* Pool */} - - - {/* ROI */} - - - {/* Fee APY */} - - - {/* Liquidity */} - - - )) - )} - -
-
-
- - Loading pools... - -
-
-

- Error loading pools: {String(error)} -

-
-

No pools available

-
- - - {asset.id.length > 10 - ? asset.id.slice(0, 10) + "…" - : asset.id} - - -
-
-
- {asset.pool.token1[0]} -
-
- {asset.pool.token2[0]} -
-
- - {asset.pool.token1}/{asset.pool.token2} - - - {asset.pool.fee} - -
-
- - {asset.roi} - - - - {asset.feeApy} - - - - {asset.liquidity} - -
-
+
); }; diff --git a/apps/web-app/src/features/dashboard/components/ui/YourPositions.tsx b/apps/web-app/src/features/dashboard/components/ui/YourPositions.tsx index 50e935ae..363c4570 100644 --- a/apps/web-app/src/features/dashboard/components/ui/YourPositions.tsx +++ b/apps/web-app/src/features/dashboard/components/ui/YourPositions.tsx @@ -20,7 +20,7 @@ const YourPositions: React.FC = () => {

Active pool positions

Manage diff --git a/apps/web-app/src/features/dashboard/hooks/useDashboardPools.ts b/apps/web-app/src/features/dashboard/hooks/useDashboardPools.ts index 45de6130..a10c4c0a 100644 --- a/apps/web-app/src/features/dashboard/hooks/useDashboardPools.ts +++ b/apps/web-app/src/features/dashboard/hooks/useDashboardPools.ts @@ -1,14 +1,12 @@ import { useMemo } from "react"; import { usePools } from "@/lib/orchestrator"; import type { PoolInfo } from "@/lib/orchestrator"; -import type { AssetData } from "@/features/dashboard/types/dashboard"; +import type { PoolData } from "@/features/lending/types/lending"; import { fromSmallestUnit } from "@/lib/helpers/tokenUtils"; import { formatLiquidity } from "@/lib/helpers/formatUtils"; -const DASHBOARD_POOL_LIMIT = 3; - interface UseDashboardPoolsResult { - assets: AssetData[]; + assets: PoolData[]; isLoading: boolean; error: Error | null; } @@ -16,33 +14,39 @@ interface UseDashboardPoolsResult { export function useDashboardPools(): UseDashboardPoolsResult { const { data: allPools = [], isLoading, error } = usePools(); - const assets = useMemo(() => { - return allPools.slice(0, DASHBOARD_POOL_LIMIT).map((pool: PoolInfo) => { - const token1 = pool.tokens[0]?.code ?? "?"; - const token2 = - pool.tokens.length > 1 - ? (pool.tokens[1]?.code ?? "?") - : pool.type === "blend" || pool.type === "neko" - ? "Lending" - : "?"; - - const decimals = pool.tokens[0]?.decimals ?? 7; - const liquidity = formatLiquidity( - fromSmallestUnit(pool.tvl.toString(), decimals), - ); - - const apy = pool.apy > 0 ? `${pool.apy.toFixed(2)}%` : "0.00%"; - - return { - id: pool.id, - pool: { token1, token2, fee: "0%" }, - roi: apy, - feeApy: apy, - liquidity, - isActive: pool.state === "active", - type: pool.type, - }; - }); + const assets = useMemo(() => { + return allPools + .filter((pool: PoolInfo) => pool.supportedActions.includes("deposit")) + .slice(0, 4) + .map((pool: PoolInfo): PoolData => { + const token1 = pool.tokens[0]?.code ?? "?"; + const token2 = + pool.tokens.length > 1 ? (pool.tokens[1]?.code ?? "?") : "Lending"; + + const decimals = pool.tokens[0]?.decimals ?? 7; + const liquidity = formatLiquidity( + fromSmallestUnit(pool.tvl.toString(), decimals) + ); + + const apy = pool.apy > 0 ? `${pool.apy.toFixed(2)}%` : "0.00%"; + + return { + id: pool.id, + name: pool.name, + token1, + token2, + fee: "0%", + roi: apy, + feeApy: apy, + liquidity, + isActive: pool.state === "active", + assetCode: token1, + asset: token1, + contractId: pool.id, + isAggregated: pool.type !== "neko", + orchestratorId: pool.id, + }; + }); }, [allPools]); return { diff --git a/apps/web-app/src/features/dashboard/hooks/usePortfolioValue.ts b/apps/web-app/src/features/dashboard/hooks/usePortfolioValue.ts index bf139e7f..efebcdbd 100644 --- a/apps/web-app/src/features/dashboard/hooks/usePortfolioValue.ts +++ b/apps/web-app/src/features/dashboard/hooks/usePortfolioValue.ts @@ -1,9 +1,8 @@ import { useQuery } from "@tanstack/react-query"; import { useWallet } from "@/hooks/useWallet"; import { stellarPriceService } from "@/lib/services/stellar-price.service"; -import { getAvailableTokens } from "@/lib/helpers/stellar/soroswap/tokens"; +import { getAssetsConfig } from "@/lib/constants/assets.config"; import { parseBalance } from "@/lib/helpers/formatUtils"; -import { RWA_TOKENS } from "@/lib/constants/wallet"; export interface PortfolioHolding { code: string; @@ -19,14 +18,9 @@ interface PortfolioValue { error: Error | null; } -async function resolvePrice(code: string): Promise { - if (RWA_TOKENS.includes(code)) { - const tokens = getAvailableTokens(); - const tokenInfo = tokens[code]; - if (!tokenInfo) return 0; - return stellarPriceService.getRWAOraclePrice(tokenInfo.contract); - } - return stellarPriceService.getTokenPrice(code); +function getContractForToken(code: string): string | undefined { + const asset = getAssetsConfig()[code]; + return asset?.priceSource === "oracle" ? asset.contract : undefined; } export function usePortfolioValue(): PortfolioValue { @@ -44,8 +38,9 @@ export function usePortfolioValue(): PortfolioValue { } return { key, code, rawBalance: bal.balance }; }) - .filter((e): e is { key: string; code: string; rawBalance: string } => - e.code !== null && parseBalance(e.rawBalance) > 0 + .filter( + (e): e is { key: string; code: string; rawBalance: string } => + e.code !== null && parseBalance(e.rawBalance) > 0 ); const balanceKey = balanceEntries @@ -63,7 +58,10 @@ export function usePortfolioValue(): PortfolioValue { const results = await Promise.allSettled( balanceEntries.map(async (entry) => { const balance = parseBalance(entry.rawBalance); - const priceUsd = await resolvePrice(entry.code); + const priceUsd = await stellarPriceService.getPrice( + entry.code, + getContractForToken(entry.code) + ); return { code: entry.code, balance, diff --git a/apps/web-app/src/features/dashboard/utils/dashboardUtils.ts b/apps/web-app/src/features/dashboard/utils/dashboardUtils.ts deleted file mode 100644 index ed1893bb..00000000 --- a/apps/web-app/src/features/dashboard/utils/dashboardUtils.ts +++ /dev/null @@ -1 +0,0 @@ -export { formatLiquidity, parseBalance } from "@/lib/helpers/formatUtils"; diff --git a/apps/web-app/src/features/lending/components/pages/Lend.tsx b/apps/web-app/src/features/lending/components/pages/Lend.tsx index 65f79fd9..fb458408 100644 --- a/apps/web-app/src/features/lending/components/pages/Lend.tsx +++ b/apps/web-app/src/features/lending/components/pages/Lend.tsx @@ -1,12 +1,18 @@ "use client"; +import { useState } from "react"; import { BannerPage } from "@/components/ui/BannerPage"; import { PageContainer } from "@/components/ui/PageContainer"; import { useLend } from "../../hooks/useLend"; import { LendTable } from "../ui/LendTable"; import { LendModal } from "../ui/LendModal"; +import MyLendingPositions from "../ui/MyLendingPositions"; +import { GetTestTokensBanner } from "@/features/wallet/components/GetTestTokensBanner"; + +type PageTab = "pools" | "positions"; const Lend: React.FC = () => { + const [activeTab, setActiveTab] = useState("pools"); const { pools, isLoadingPools, @@ -15,7 +21,6 @@ const Lend: React.FC = () => { isModalOpen, isDeposit, isLoading, - error, bTokenBalance, isLoadingBalance, hasWallet, @@ -36,28 +41,58 @@ const Lend: React.FC = () => { className="mb-8" /> - openModal(pool, true)} - onWithdraw={(pool) => openModal(pool, false)} - /> + + +
+ + +
- {isModalOpen && selectedPool && ( - void refreshBalance()} - /> + {activeTab === "pools" && ( + <> + openModal(pool, true)} + onWithdraw={(pool) => openModal(pool, false)} + /> + + {isModalOpen && selectedPool && ( + void refreshBalance()} + /> + )} + )} + + {activeTab === "positions" && } ); }; diff --git a/apps/web-app/src/features/lending/components/ui/LendModal.tsx b/apps/web-app/src/features/lending/components/ui/LendModal.tsx index 4789333b..e42d2e6a 100644 --- a/apps/web-app/src/features/lending/components/ui/LendModal.tsx +++ b/apps/web-app/src/features/lending/components/ui/LendModal.tsx @@ -9,7 +9,6 @@ interface LendModalProps { pool: PoolData; isDeposit: boolean; isLoading: boolean; - error: string | null; bTokenBalance: string | null; isLoadingBalance: boolean; hasWallet: boolean; @@ -22,7 +21,6 @@ export function LendModal({ pool, isDeposit, isLoading, - error, bTokenBalance, isLoadingBalance, hasWallet, @@ -133,12 +131,6 @@ export function LendModal({ )}
- {error && ( -
-

{error}

-
- )} -
- -
+ {!hideActions && ( +
+ + +
+ )} ))} @@ -171,8 +178,6 @@ export function LendTable({ ); } -// ── Shared helpers ──────────────────────────────────────────────────────────── - function EmptyRow({ colSpan, message, diff --git a/apps/web-app/src/features/lending/components/ui/MyLendingPositions.tsx b/apps/web-app/src/features/lending/components/ui/MyLendingPositions.tsx new file mode 100644 index 00000000..cc16f3eb --- /dev/null +++ b/apps/web-app/src/features/lending/components/ui/MyLendingPositions.tsx @@ -0,0 +1,199 @@ +"use client"; + +import React from "react"; +import { Wallet, Coins, Hash, TrendingUp, Layers } from "lucide-react"; +import { useUserLendingPositions } from "../../hooks/useUserLendingPositions"; +import { useUserPositions } from "@/features/dashboard/hooks/useUserPositions"; +import { ColHeader } from "./ColHeader"; +import { TokenAvatar } from "@/features/borrowing/components/ui/TokenAvatar"; + +const MyLendingPositions: React.FC = () => { + const { positions, isLoading, hasWallet } = useUserLendingPositions(); + const { positions: allPositions, isLoading: isLoadingAggregated } = + useUserPositions(); + + const aggregatedPositions = allPositions.filter( + (p) => p.pool.type !== "neko" && p.position.deposited > 0n + ); + + if (!hasWallet) { + return ( +
+ +

+ Connect your wallet to see your lending positions +

+
+ ); + } + + return ( +
+
+ + Your Lending Positions + +
+ + + + + + + + + + + + {isLoading ? ( + + + + ) : positions.length === 0 ? ( + + + + ) : ( + positions.map((pos) => ( + + + + + + + )) + )} + +
+ Loading your positions... +
+ You don't have any active lending positions yet. +
+
+ + + {pos.assetCode} + +
+
+ {pos.bTokensFormatted} + + {pos.depositedFormatted}{" "} + + {pos.assetCode} + + + {pos.interestRate.toFixed(2)}% +
+ + {(isLoadingAggregated || aggregatedPositions.length > 0) && ( + <> +
+ + Aggregated Positions + + + Aggregated + +
+ + + + + + + + + + + + {isLoadingAggregated ? ( + + + + ) : aggregatedPositions.length === 0 ? ( + + + + ) : ( + aggregatedPositions.map((pos) => ( + + + + + + + )) + )} + +
+ Loading aggregated positions... +
+ No aggregated lending positions. +
+
+ + + {pos.pool.tokens[0]?.code ?? "?"} + +
+
+ {pos.position.depositedFormatted}{" "} + + {pos.pool.tokens[0]?.code ?? ""} + + + {pos.pool.apy.toFixed(2)}% + + {pos.pool.name} +
+ + )} +
+ ); +}; + +export default MyLendingPositions; diff --git a/apps/web-app/src/features/lending/components/ui/ProtocolCell.tsx b/apps/web-app/src/features/lending/components/ui/ProtocolCell.tsx index bcc4a6fc..2e01141f 100644 --- a/apps/web-app/src/features/lending/components/ui/ProtocolCell.tsx +++ b/apps/web-app/src/features/lending/components/ui/ProtocolCell.tsx @@ -23,9 +23,15 @@ export function ProtocolCell({ pool }: { pool: PoolData }) { )}
{pool.name} - - V2 - + {pool.isAggregated ? ( + + Aggregated + + ) : ( + + V1 + + )}
); } diff --git a/apps/web-app/src/features/lending/hooks/useLend.ts b/apps/web-app/src/features/lending/hooks/useLend.ts index d7435bd9..cf684bfd 100644 --- a/apps/web-app/src/features/lending/hooks/useLend.ts +++ b/apps/web-app/src/features/lending/hooks/useLend.ts @@ -4,6 +4,8 @@ import { useState, useMemo, useCallback } from "react"; import { TransactionBuilder, Networks } from "@stellar/stellar-sdk"; import { rpc } from "@stellar/stellar-sdk"; import { useWallet } from "@/hooks/useWallet"; +import { useToast } from "@/hooks/useToast"; +import { TOAST_CONFIG } from "@/lib/constants/toast.config"; import { useLendingPools } from "./useLendingPools"; import { approveToken, @@ -12,9 +14,12 @@ import { getBTokenBalance, } from "@/lib/helpers/stellar/lending"; import { getAvailableTokens } from "@/lib/helpers/stellar/soroswap"; -import { LENDING_CONTRACT_ID } from "@/lib/constants/contracts"; import { rpcUrl, stellarNetwork } from "@/lib/config/stellar.config"; import { extractContractErrorOrNull } from "@/lib/helpers/stellar/contractErrors"; +import { usePools, usePoolAction } from "@/lib/orchestrator"; +import type { PoolInfo } from "@/lib/orchestrator"; +import { fromSmallestUnit } from "@/lib/helpers/tokenUtils"; +import { formatLiquidity } from "@/lib/helpers/formatUtils"; import type { PoolData } from "../types/lending"; function toBTokens(tokensAmount: string, bTokenRate: string): string { @@ -32,22 +37,50 @@ export function useLend() { const [isModalOpen, setIsModalOpen] = useState(false); const [isDeposit, setIsDeposit] = useState(true); const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(null); const [bTokenBalance, setBTokenBalance] = useState(null); const [isLoadingBalance, setIsLoadingBalance] = useState(false); const [selectedPool, setSelectedPool] = useState(null); const { address, signTransaction, networkPassphrase } = useWallet(); + const { addNotification } = useToast(); + + const showError = useCallback( + (msg: string) => + addNotification("Something went wrong", "error", { + ...TOAST_CONFIG.defaultOpts, + description: msg, + }), + [addNotification] + ); + const showSuccess = useCallback( + (msg: string) => + addNotification("Success", "success", { + ...TOAST_CONFIG.defaultOpts, + description: msg, + }), + [addNotification] + ); const { data: lendingPools = [], - isLoading: isLoadingPools, - error: poolsError, + isLoading: isLoadingNekoPools, + error: nekoPoolsError, refetch: refetchPools, } = useLendingPools(); + const { + data: orchestratorPools = [], + isLoading: isLoadingOrchestratorPools, + error: orchestratorPoolsError, + } = usePools(); + + const { mutateAsync: executePoolAction } = usePoolAction(); + + const isLoadingPools = isLoadingNekoPools || isLoadingOrchestratorPools; + const poolsError = nekoPoolsError || orchestratorPoolsError; + const pools: PoolData[] = useMemo(() => { - return lendingPools.map((pool, index) => { + const nekoPools: PoolData[] = lendingPools.map((pool, index) => { const balanceNum = parseFloat(pool.poolBalance); const liquidity = balanceNum >= 1000 @@ -66,9 +99,43 @@ export function useLend() { assetCode: pool.assetCode, asset: pool.asset, bTokenRate: pool.bTokenRate, + contractId: pool.contractId, }; }); - }, [lendingPools]); + + const aggregated: PoolData[] = orchestratorPools + .filter( + (p: PoolInfo) => + p.type !== "neko" && p.supportedActions.includes("deposit") + ) + .map((p: PoolInfo) => { + const token = p.tokens[0]; + const decimals = token?.decimals ?? 7; + const liquidity = formatLiquidity( + fromSmallestUnit(p.tvl.toString(), decimals) + ); + const contractId = p.id.split(":")[1] ?? p.id; + return { + id: `agg-${p.id}`, + name: p.name, + token1: token?.code ?? "?", + token2: "Lending", + fee: "0%", + roi: p.apy > 0 ? `${p.apy.toFixed(2)}%` : "0.00%", + feeApy: p.apy > 0 ? `${p.apy.toFixed(2)}%` : "0.00%", + liquidity, + isActive: p.state === "active", + assetCode: token?.code ?? "?", + asset: token?.address ?? "", + bTokenRate: undefined, + contractId, + isAggregated: true, + orchestratorId: p.id, + }; + }); + + return [...nekoPools, ...aggregated]; + }, [lendingPools, orchestratorPools]); const loadBTokenBalance = useCallback(async () => { if (!selectedPool || !address) { @@ -77,7 +144,12 @@ export function useLend() { } setIsLoadingBalance(true); try { - const balance = await getBTokenBalance(selectedPool.assetCode, address); + const balance = await getBTokenBalance( + selectedPool.assetCode, + address, + 7, + selectedPool.contractId + ); setBTokenBalance(balance); } catch { setBTokenBalance("0"); @@ -90,7 +162,6 @@ export function useLend() { (pool: PoolData, deposit: boolean) => { setSelectedPool(pool); setIsDeposit(deposit); - setError(null); setBTokenBalance(null); setIsModalOpen(true); if (address) void loadBTokenBalance(); @@ -100,7 +171,6 @@ export function useLend() { const closeModal = useCallback(() => { setIsModalOpen(false); - setError(null); }, []); const handleConfirm = useCallback( @@ -109,9 +179,30 @@ export function useLend() { return; setIsLoading(true); - setError(null); try { + if (selectedPool.isAggregated && selectedPool.orchestratorId) { + const decimals = 7; + const rawAmount = BigInt( + Math.floor(parseFloat(amount) * 10 ** decimals) + ); + + await executePoolAction({ + poolId: selectedPool.orchestratorId, + action: isDeposit ? "deposit" : "withdraw", + amount: rawAmount, + }); + + await refetchPools(); + showSuccess( + isDeposit + ? `Successfully deposited ${amount} ${selectedPool.assetCode}` + : `Successfully withdrew ${amount} ${selectedPool.assetCode}` + ); + closeModal(); + return; + } + const availableTokens = getAvailableTokens(); const token = availableTokens[selectedPool.assetCode]; if (!token?.contract) @@ -123,15 +214,17 @@ export function useLend() { allowHttp: stellarNetwork === "LOCAL", }); + const lendingContractId = selectedPool.contractId; + if (isDeposit) { const approveXdr = await approveToken( token.contract, - LENDING_CONTRACT_ID, + lendingContractId, amount, decimals, address ); - // eslint-disable-next-line @typescript-eslint/no-explicit-any + const signedApprove = await signTransaction(approveXdr as any, { networkPassphrase: passphrase, address, @@ -145,9 +238,10 @@ export function useLend() { selectedPool.assetCode, amount, decimals, - address + address, + lendingContractId ); - // eslint-disable-next-line @typescript-eslint/no-explicit-any + const signedDeposit = await signTransaction(depositXdr as any, { networkPassphrase: passphrase, address, @@ -163,9 +257,10 @@ export function useLend() { selectedPool.assetCode, bTokensAmount, decimals, - address + address, + lendingContractId ); - // eslint-disable-next-line @typescript-eslint/no-explicit-any + const signedWithdraw = await signTransaction(withdrawXdr as any, { networkPassphrase: passphrase, address, @@ -178,15 +273,16 @@ export function useLend() { await new Promise((r) => setTimeout(r, 3000)); await loadBTokenBalance(); await refetchPools(); + showSuccess( + isDeposit + ? `Successfully deposited ${amount} ${selectedPool.assetCode}` + : `Successfully withdrew ${amount} ${selectedPool.assetCode}` + ); closeModal(); } catch (err) { const msg = extractContractErrorOrNull(err); - setError( - msg - ? typeof msg === "string" - ? msg - : "An unexpected error occurred." - : "An unexpected error occurred." + showError( + typeof msg === "string" ? msg : "An unexpected error occurred." ); } finally { setIsLoading(false); @@ -198,9 +294,12 @@ export function useLend() { isDeposit, networkPassphrase, signTransaction, + executePoolAction, loadBTokenBalance, refetchPools, closeModal, + showError, + showSuccess, ] ); @@ -212,7 +311,6 @@ export function useLend() { isModalOpen, isDeposit, isLoading, - error, bTokenBalance, isLoadingBalance, hasWallet: !!address, diff --git a/apps/web-app/src/features/lending/hooks/useLendingPools.ts b/apps/web-app/src/features/lending/hooks/useLendingPools.ts index 6d05d71b..7fbd10e8 100644 --- a/apps/web-app/src/features/lending/hooks/useLendingPools.ts +++ b/apps/web-app/src/features/lending/hooks/useLendingPools.ts @@ -10,7 +10,7 @@ import { fromSmallestUnit } from "@/lib/helpers/tokenUtils"; import { getAvailableTokens } from "@/lib/helpers/stellar/soroswap"; import { parseInterestRateFromContractResult } from "@/lib/helpers/lendingUtils"; -interface LendingPool { +export interface LendingPool { asset: string; assetCode: string; poolBalance: string; @@ -18,139 +18,148 @@ interface LendingPool { interestRate: number; bTokenRate: string; isActive: boolean; + contractId: string; } -/** - * Hook to get all active lending pools from the RWA lending contract - */ -export const useLendingPools = () => { - // Memoize available tokens to prevent unnecessary re-renders - const availableTokens = useMemo(() => getAvailableTokens(), []); - - // All available tokens are potential debt assets — filter to those with a contract - const debtAssets = useMemo(() => { - return Object.keys(availableTokens).filter((code) => { - const token = availableTokens[code]; - return token && token.contract; - }); - }, [availableTokens]); - - // Memoize the query function to prevent recreating it on every render - const queryFn = useMemo( - () => async () => { - const contractId = networks.testnet.contractId; - - const client = new RwaLendingClient({ - contractId: contractId, - rpcUrl: rpcUrl, - networkPassphrase: networkPassphrase, - ...(allowHttpForSoroban && { allowHttp: true }), - }); - - // Get pool state - let poolState; +/** Deposit assets for each pool */ +const POOL1_ASSETS = ["USDC", "XLM"]; +const POOL2_ASSETS = ["USTRY", "TESOURO", "CETES", "USDY", "PYUSD"]; + +async function fetchLendingPools( + client: RwaLendingClient, + contractId: string, + assetCodes: string[], + availableTokens: ReturnType +): Promise { + let poolState; + try { + const poolStateTx = await client.get_pool_state({ simulate: true }); + poolState = poolStateTx.result; + } catch { + return []; + } + + if (poolState?.tag !== "Active") return []; + + const pools: LendingPool[] = []; + + for (const assetCode of assetCodes) { + try { + const token = availableTokens[assetCode]; + if (!token?.contract) continue; + + const balanceTx = await client.get_pool_balance( + { asset: assetCode }, + { simulate: true } + ); + const balanceValue = balanceTx.result; + if (balanceValue === null || balanceValue === undefined) continue; + + const decimals = token.decimals || 7; + const balanceStr = + typeof balanceValue === "bigint" + ? balanceValue.toString() + : typeof balanceValue === "string" + ? balanceValue + : String(balanceValue); + const poolBalance = fromSmallestUnit( + BigInt(balanceStr).toString(), + decimals + ); + + let interestRate = 0; try { - const poolStateTx = await client.get_pool_state({ simulate: true }); - poolState = poolStateTx.result; + const rateTx = await client.get_interest_rate( + { asset: assetCode }, + { simulate: true } + ); + interestRate = parseInterestRateFromContractResult(rateTx.result); } catch { - return []; + // use 0 } - const isPoolActive = poolState?.tag === "Active"; - - if (!isPoolActive) { - return []; + let bTokenRate = "1.0"; + try { + const bRateTx = await client.get_b_token_rate( + { asset: assetCode }, + { simulate: true } + ); + const bRateValue = bRateTx.result; + if (bRateValue) { + bTokenRate = fromSmallestUnit( + BigInt(bRateValue.toString()).toString(), + 12 + ); + } + } catch { + // use 1.0 } - const pools: LendingPool[] = []; + pools.push({ + asset: token.contract, + assetCode, + poolBalance, + poolBalanceUSD: "Calculating...", + interestRate, + bTokenRate, + isActive: true, + contractId, + }); + } catch { + continue; + } + } - for (const assetCode of debtAssets) { - try { - const token = availableTokens[assetCode]; - if (!token?.contract) { - continue; - } + return pools; +} - const balanceTx = await client.get_pool_balance( - { asset: assetCode }, - { simulate: true } - ); - const balanceValue = balanceTx.result; - - if (balanceValue === null || balanceValue === undefined) { - continue; - } - - const decimals = token.decimals || 7; - const balanceStr = - typeof balanceValue === "bigint" - ? balanceValue.toString() - : typeof balanceValue === "string" - ? balanceValue - : String(balanceValue); - const balanceBigInt = BigInt(balanceStr); - const poolBalance = fromSmallestUnit( - balanceBigInt.toString(), - decimals - ); +export const useLendingPools = () => { + const availableTokens = useMemo(() => getAvailableTokens(), []); - // Get interest rate (basis points → percentage) - let interestRate = 0; - try { - const interestRateTx = await client.get_interest_rate( - { asset: assetCode }, - { simulate: true } - ); - interestRate = parseInterestRateFromContractResult( - interestRateTx.result - ); - } catch { - // If interest rate fetch fails, use 0 - } - - // Get bToken rate - let bTokenRate = "1.0"; - try { - const bTokenRateTx = await client.get_b_token_rate( - { asset: assetCode }, - { simulate: true } - ); - const bTokenRateValue = bTokenRateTx.result; - if (bTokenRateValue) { - const rateBigInt = BigInt(bTokenRateValue.toString()); - // b_rate uses 12 decimals (SCALAR_12 = 1_000_000_000_000), initial 1:1 rate - bTokenRate = fromSmallestUnit(rateBigInt.toString(), 12); - } - } catch { - // If bToken rate fetch fails, use 1.0 - } - - pools.push({ - asset: token.contract, - assetCode, - poolBalance, - poolBalanceUSD: "Calculating...", - interestRate, - bTokenRate, - isActive: true, - }); - } catch { - continue; - } - } + const queryFn = useMemo( + () => async (): Promise => { + const clientOptions = { + rpcUrl, + networkPassphrase, + ...(allowHttpForSoroban && { allowHttp: true }), + }; + + const pool1Client = new RwaLendingClient({ + contractId: networks.testnet.pool1ContractId, + ...clientOptions, + }); + const pool2Client = new RwaLendingClient({ + contractId: networks.testnet.pool2ContractId, + ...clientOptions, + }); - return pools; + const [pool1Pools, pool2Pools] = await Promise.all([ + fetchLendingPools( + pool1Client, + networks.testnet.pool1ContractId, + POOL1_ASSETS, + availableTokens + ), + fetchLendingPools( + pool2Client, + networks.testnet.pool2ContractId, + POOL2_ASSETS, + availableTokens + ), + ]); + + return [...pool1Pools, ...pool2Pools]; }, - [debtAssets, availableTokens] + [availableTokens] ); return useQuery({ queryKey: ["lendingPools"], queryFn, - staleTime: 2 * 60_000, // 2 min: avoid refetch when re-entering tab + staleTime: 2 * 60_000, gcTime: 10 * 60_000, - refetchInterval: 2 * 60_000, // 2 min background refresh - refetchOnWindowFocus: false, // don't refetch every time user switches to tab + refetchInterval: 2 * 60_000, + refetchOnWindowFocus: false, placeholderData: (prev) => prev, retry: 2, throwOnError: false, diff --git a/apps/web-app/src/features/lending/hooks/useUserLendingPositions.ts b/apps/web-app/src/features/lending/hooks/useUserLendingPositions.ts new file mode 100644 index 00000000..41822f8b --- /dev/null +++ b/apps/web-app/src/features/lending/hooks/useUserLendingPositions.ts @@ -0,0 +1,65 @@ +import { useQueries } from "@tanstack/react-query"; +import { useWallet } from "@/hooks/useWallet"; +import { getBTokenBalanceRaw } from "@/lib/helpers/stellar/lending"; +import { formatAmount } from "@/lib/helpers/formatUtils"; +import { useLendingPools } from "./useLendingPools"; + +const STELLAR_DECIMALS = 7; + +export interface LendingPosition { + assetCode: string; + bTokens: bigint; + bTokensFormatted: string; + bTokenRate: string; + depositedFormatted: string; + interestRate: number; +} + +export function useUserLendingPositions() { + const { address } = useWallet(); + const { data: pools = [], isLoading: poolsLoading } = useLendingPools(); + + const balanceQueries = useQueries({ + queries: pools.map((pool) => ({ + queryKey: [ + "userLendingBTokens", + pool.assetCode, + pool.contractId, + address, + ], + queryFn: () => + getBTokenBalanceRaw(pool.assetCode, address!, pool.contractId), + enabled: Boolean(address) && pools.length > 0, + staleTime: 30_000, + gcTime: 5 * 60_000, + retry: 1, + })), + }); + + const isLoading = + poolsLoading || + (Boolean(address) && balanceQueries.some((q) => q.isLoading)); + + const positions: LendingPosition[] = []; + + balanceQueries.forEach((q, i) => { + const bTokens = q.data ?? 0n; + if (bTokens === 0n) return; + + const pool = pools[i]; + const bTokenRate = pool.bTokenRate ?? "1"; + const bTokensHuman = Number(bTokens) / 10 ** STELLAR_DECIMALS; + const deposited = bTokensHuman * parseFloat(bTokenRate); + + positions.push({ + assetCode: pool.assetCode, + bTokens, + bTokensFormatted: formatAmount(bTokensHuman), + bTokenRate, + depositedFormatted: formatAmount(deposited), + interestRate: pool.interestRate, + }); + }); + + return { positions, isLoading, hasWallet: Boolean(address) }; +} diff --git a/apps/web-app/src/features/lending/types/lending.ts b/apps/web-app/src/features/lending/types/lending.ts index b16f1a50..6beedc2d 100644 --- a/apps/web-app/src/features/lending/types/lending.ts +++ b/apps/web-app/src/features/lending/types/lending.ts @@ -11,4 +11,7 @@ export interface PoolData { assetCode: string; asset: string; bTokenRate?: string; + contractId: string; + isAggregated?: boolean; + orchestratorId?: string; } diff --git a/apps/web-app/src/features/lending/utils/lending.ts b/apps/web-app/src/features/lending/utils/lending.ts index 8f860fd3..9f52bc55 100644 --- a/apps/web-app/src/features/lending/utils/lending.ts +++ b/apps/web-app/src/features/lending/utils/lending.ts @@ -1,7 +1,3 @@ -/** - * Utility functions for lending operations (deposit, withdraw) - */ - import { Contract, Address, @@ -20,9 +16,6 @@ import { } from "@/lib/constants/network"; import { toSmallestUnit } from "@/lib/helpers/tokenUtils"; -/** - * Approve token contract to spend tokens on behalf of the user - */ export const approveToken = async ( tokenContractAddress: string, spenderAddress: string, @@ -35,23 +28,16 @@ export const approveToken = async ( const horizonServer = new Horizon.Server(horizonUrl); const tokenContract = new Contract(tokenContractAddress); - // Get current ledger to calculate expiration const latestLedger = await sorobanServer.getLatestLedger(); const currentLedger = latestLedger.sequence; - // Calculate expiration ledger: current + ~30 days - // Stellar ledgers occur approximately every 5 seconds - // 30 days = 30 * 24 * 60 * 60 / 5 = 518,400 ledgers - // Use a safe value: current + 500,000 ledgers (~29 days) const expirationLedger = Math.min( currentLedger + 500000, 2147483647 // Max safe u32 value (but contract may have lower limit) ); - // Convert amount to smallest unit const amountInSmallestUnit = BigInt(toSmallestUnit(amount, decimals)); - // Call approve(from: Address, spender: Address, amount: i128, expiration_ledger: u32) const operation = tokenContract.call( "approve", new Address(walletAddress).toScVal(), @@ -60,10 +46,8 @@ export const approveToken = async ( nativeToScVal(expirationLedger, { type: "u32" }) ); - // Get account for transaction const account = await horizonServer.loadAccount(walletAddress); - // Build transaction const transaction = new TransactionBuilder(account, { fee: "100", networkPassphrase: networkPassphrase, @@ -72,7 +56,6 @@ export const approveToken = async ( .setTimeout(300) .build(); - // Return XDR for signing return transaction.toXDR(); } catch (error) { console.error("Error building approve transaction:", error); @@ -82,9 +65,6 @@ export const approveToken = async ( } }; -/** - * Deposit tokens to the lending pool - */ export const depositToPool = async ( assetCode: string, amount: string, @@ -96,13 +76,10 @@ export const depositToPool = async ( const horizonServer = new Horizon.Server(horizonUrl); const lendingContract = new Contract(networks.testnet.contractId); - // Convert amount to smallest unit (i128) const amountInSmallestUnit = BigInt(toSmallestUnit(amount, decimals)); - // Convert assetCode to Symbol (ScVal) const assetSymbol = xdr.ScVal.scvSymbol(assetCode); - // Call deposit(lender: Address, asset: Symbol, amount: i128) const operation = lendingContract.call( "deposit", new Address(walletAddress).toScVal(), @@ -110,10 +87,8 @@ export const depositToPool = async ( nativeToScVal(amountInSmallestUnit, { type: "i128" }) ); - // Get account for transaction const account = await horizonServer.loadAccount(walletAddress); - // Build transaction const transaction = new TransactionBuilder(account, { fee: "100", networkPassphrase: networkPassphrase, @@ -122,11 +97,9 @@ export const depositToPool = async ( .setTimeout(300) .build(); - // Simulate to get footprint and resource limits (ignore auth errors) try { await sorobanServer.simulateTransaction(transaction); } catch (simError) { - // Auth errors during simulation are expected since transaction isn't signed yet const errorMessage = simError instanceof Error ? simError.message : String(simError); if ( @@ -134,16 +107,12 @@ export const depositToPool = async ( !errorMessage.includes("require_auth") && !errorMessage.includes("InvalidAction") ) { - // If it's not an auth error, re-throw it throw simError; } - // Otherwise, continue - auth will be checked when transaction is signed } - // Prepare the transaction with the simulation results const preparedTx = await sorobanServer.prepareTransaction(transaction); - // Return the prepared XDR for signing return preparedTx.toXDR(); } catch (error) { console.error("Error building deposit transaction:", error); @@ -153,9 +122,6 @@ export const depositToPool = async ( } }; -/** - * Withdraw tokens from the lending pool - */ export const withdrawFromPool = async ( assetCode: string, bTokens: string, @@ -167,13 +133,10 @@ export const withdrawFromPool = async ( const horizonServer = new Horizon.Server(horizonUrl); const lendingContract = new Contract(networks.testnet.contractId); - // Convert bTokens to smallest unit (i128) const bTokensInSmallestUnit = BigInt(toSmallestUnit(bTokens, decimals)); - // Convert assetCode to Symbol (ScVal) const assetSymbol = xdr.ScVal.scvSymbol(assetCode); - // Call withdraw(lender: Address, asset: Symbol, b_tokens: i128) const operation = lendingContract.call( "withdraw", new Address(walletAddress).toScVal(), @@ -181,10 +144,8 @@ export const withdrawFromPool = async ( nativeToScVal(bTokensInSmallestUnit, { type: "i128" }) ); - // Get account for transaction const account = await horizonServer.loadAccount(walletAddress); - // Build transaction const transaction = new TransactionBuilder(account, { fee: "100", networkPassphrase: networkPassphrase, @@ -193,11 +154,9 @@ export const withdrawFromPool = async ( .setTimeout(300) .build(); - // Simulate to get footprint and resource limits (ignore auth errors) try { await sorobanServer.simulateTransaction(transaction); } catch (simError) { - // Auth errors during simulation are expected since transaction isn't signed yet const errorMessage = simError instanceof Error ? simError.message : String(simError); if ( @@ -205,16 +164,12 @@ export const withdrawFromPool = async ( !errorMessage.includes("require_auth") && !errorMessage.includes("InvalidAction") ) { - // If it's not an auth error, re-throw it throw simError; } - // Otherwise, continue - auth will be checked when transaction is signed } - // Prepare the transaction with the simulation results const preparedTx = await sorobanServer.prepareTransaction(transaction); - // Return the prepared XDR for signing return preparedTx.toXDR(); } catch (error) { console.error("Error building withdraw transaction:", error); @@ -224,9 +179,6 @@ export const withdrawFromPool = async ( } }; -/** - * Get bToken balance for a user - */ export const getBTokenBalance = async ( assetCode: string, walletAddress: string, @@ -253,7 +205,6 @@ export const getBTokenBalance = async ( return "0"; } - // Convert from smallest unit to human-readable const balanceStr = typeof balanceValue === "bigint" ? balanceValue.toString() @@ -279,16 +230,6 @@ export const getBTokenBalance = async ( } }; -/** - * Borrow tokens from the lending pool with collateral and approve - * Returns three separate transactions that need to be executed sequentially: - * 1. approve - * 2. add_collateral - * 3. borrow - * - * This is necessary because Stellar/Soroban doesn't support multiple contract - * operations in a single transaction with prepareTransaction. - */ export const borrowWithCollateral = async ( rwaTokenContract: string, collateralAmount: string, @@ -303,7 +244,6 @@ export const borrowWithCollateral = async ( borrowXdr: string; }> => { try { - // First two transactions: approve + add_collateral const { approveXdr, addCollateralXdr } = await addCollateralWithApprove( rwaTokenContract, collateralAmount, @@ -311,7 +251,6 @@ export const borrowWithCollateral = async ( walletAddress ); - // Third transaction: borrow const borrowXdr = await borrowFromPool( assetCode, borrowAmount, @@ -332,10 +271,6 @@ export const borrowWithCollateral = async ( } }; -/** - * Borrow tokens from the lending pool - * Note: Use borrowWithCollateral if you need to add collateral in the same transaction - */ export const borrowFromPool = async ( assetCode: string, amount: string, @@ -347,13 +282,10 @@ export const borrowFromPool = async ( const horizonServer = new Horizon.Server(horizonUrl); const lendingContract = new Contract(networks.testnet.contractId); - // Convert amount to smallest unit (i128) const amountInSmallestUnit = BigInt(toSmallestUnit(amount, decimals)); - // Convert assetCode to Symbol (ScVal) const assetSymbol = xdr.ScVal.scvSymbol(assetCode); - // Call borrow(borrower: Address, asset: Symbol, amount: i128) const operation = lendingContract.call( "borrow", new Address(walletAddress).toScVal(), @@ -361,10 +293,8 @@ export const borrowFromPool = async ( nativeToScVal(amountInSmallestUnit, { type: "i128" }) ); - // Get account for transaction const account = await horizonServer.loadAccount(walletAddress); - // Build transaction const transaction = new TransactionBuilder(account, { fee: "100", networkPassphrase: networkPassphrase, @@ -373,11 +303,9 @@ export const borrowFromPool = async ( .setTimeout(300) .build(); - // Simulate to get footprint and resource limits (ignore auth errors) try { await sorobanServer.simulateTransaction(transaction); } catch (simError) { - // Auth errors during simulation are expected since transaction isn't signed yet const errorMessage = simError instanceof Error ? simError.message : String(simError); if ( @@ -385,16 +313,12 @@ export const borrowFromPool = async ( !errorMessage.includes("require_auth") && !errorMessage.includes("InvalidAction") ) { - // If it's not an auth error, re-throw it throw simError; } - // Otherwise, continue - auth will be checked when transaction is signed } - // Prepare the transaction with the simulation results const preparedTx = await sorobanServer.prepareTransaction(transaction); - // Return the prepared XDR for signing return preparedTx.toXDR(); } catch (error) { console.error("Error building borrow transaction:", error); @@ -404,9 +328,6 @@ export const borrowFromPool = async ( } }; -/** - * Get borrow limit for a user - */ export const getBorrowLimit = async ( walletAddress: string ): Promise => { @@ -428,12 +349,9 @@ export const getBorrowLimit = async ( return "0"; } - // Result has structure { ok: i128 } or { err: Error } if ("ok" in borrowLimitResult && borrowLimitResult.ok) { - // Borrow limit is in USD value (from oracle calculations) - // Convert from smallest unit (assuming 7 decimals for USD) const limitValue = Number(borrowLimitResult.ok); - // Since this is USD value, we can divide by 1e7 to get human-readable + return (limitValue / 1e7).toFixed(2); } else { return "0"; @@ -444,12 +362,6 @@ export const getBorrowLimit = async ( } }; -/** - * Add collateral with approve - returns two separate transactions - * This is necessary because Stellar/Soroban doesn't support multiple contract - * operations in a single transaction with prepareTransaction. - * Returns: { approveXdr: string, addCollateralXdr: string } - */ export const addCollateralWithApprove = async ( rwaTokenContract: string, amount: string, @@ -457,7 +369,6 @@ export const addCollateralWithApprove = async ( walletAddress: string ): Promise<{ approveXdr: string; addCollateralXdr: string }> => { try { - // First transaction: approve const approveXdr = await approveToken( rwaTokenContract, networks.testnet.contractId, @@ -466,7 +377,6 @@ export const addCollateralWithApprove = async ( walletAddress ); - // Second transaction: add_collateral const addCollateralXdr = await buildAddCollateralTransaction( rwaTokenContract, amount, @@ -489,12 +399,6 @@ export const addCollateralWithApprove = async ( } }; -/** - * Add RWA token collateral to the lending pool - * Returns the XDR for the add_collateral transaction - * Note: The caller must first approve the token before calling this - * @deprecated Use addCollateralWithApprove instead for a single transaction - */ export const buildAddCollateralTransaction = async ( rwaTokenContract: string, amount: string, @@ -506,11 +410,8 @@ export const buildAddCollateralTransaction = async ( const horizonServer = new Horizon.Server(horizonUrl); const lendingContract = new Contract(networks.testnet.contractId); - // Convert amount to smallest unit (i128) const amountInSmallestUnit = BigInt(toSmallestUnit(amount, decimals)); - // Build add_collateral transaction - // Call add_collateral(borrower: Address, rwa_token: Address, amount: i128) const operation = lendingContract.call( "add_collateral", new Address(walletAddress).toScVal(), @@ -518,10 +419,8 @@ export const buildAddCollateralTransaction = async ( nativeToScVal(amountInSmallestUnit, { type: "i128" }) ); - // Get account for transaction const account = await horizonServer.loadAccount(walletAddress); - // Build transaction const transaction = new TransactionBuilder(account, { fee: "100", networkPassphrase: networkPassphrase, @@ -530,11 +429,9 @@ export const buildAddCollateralTransaction = async ( .setTimeout(300) .build(); - // Simulate to get footprint and resource limits (ignore auth errors) try { await sorobanServer.simulateTransaction(transaction); } catch (simError) { - // Auth errors during simulation are expected since transaction isn't signed yet const errorMessage = simError instanceof Error ? simError.message : String(simError); if ( @@ -542,16 +439,12 @@ export const buildAddCollateralTransaction = async ( !errorMessage.includes("require_auth") && !errorMessage.includes("InvalidAction") ) { - // If it's not an auth error, re-throw it throw simError; } - // Otherwise, continue - auth will be checked when transaction is signed } - // Prepare the transaction with the simulation results const preparedTx = await sorobanServer.prepareTransaction(transaction); - // Return the prepared XDR for signing return preparedTx.toXDR(); } catch (error) { console.error("Error building add collateral transaction:", error); @@ -561,9 +454,6 @@ export const buildAddCollateralTransaction = async ( } }; -/** - * Get collateral balance for a user and RWA token - */ export const getCollateral = async ( rwaTokenContract: string, walletAddress: string, @@ -590,7 +480,6 @@ export const getCollateral = async ( return "0"; } - // Convert from smallest unit to human-readable const collateralStr = typeof collateralValue === "bigint" ? collateralValue.toString() @@ -604,7 +493,6 @@ export const getCollateral = async ( const fractional = collateralBigInt % divisor; if (fractional === BigInt(0)) { - // Format large numbers with commas for readability return whole.toLocaleString("en-US"); } diff --git a/apps/web-app/src/features/pools/components/pages/PoolDetail.tsx b/apps/web-app/src/features/pools/components/pages/PoolDetail.tsx index 16f908d9..134841b6 100644 --- a/apps/web-app/src/features/pools/components/pages/PoolDetail.tsx +++ b/apps/web-app/src/features/pools/components/pages/PoolDetail.tsx @@ -81,10 +81,10 @@ const PoolDetail: React.FC = ({ params }) => {
- {/* Header */} + {}
- {/* Icon container sized to fit both overlapping circles without overflowing into text */} + {}
{(() => { const icon1 = getTokenIcon({ @@ -147,7 +147,7 @@ const PoolDetail: React.FC = ({ params }) => {
- {/* Stats Grid */} + {}

TVL

@@ -171,7 +171,7 @@ const PoolDetail: React.FC = ({ params }) => {
- {/* Your position */} + {} {address && (

@@ -217,7 +217,7 @@ const PoolDetail: React.FC = ({ params }) => {

)} - {/* Actions */} + {}
{(pool.type === "blend" || pool.type === "neko") && ( +
+
+ ))} +
+
+ )} + + {stellarNetwork !== "PUBLIC" && ( + + )} +
- {/* Error */} - {error && ( -
-

{error}

-
- )} - - {/* Primary CTA */} + {} + {}
{ />
- {/* Transaction result */} + {} {txHash && ( = ({ disabled={isSelected} className={`w-full flex items-center gap-3 p-3 rounded-xl transition-colors ${ isSelected - ? "bg-[#334EAC]/20 cursor-not-allowed border border-[#334EAC]/30" - : "bg-white hover:bg-gray-50 border border-transparent hover:border-gray-300" + ? "bg-[#229EDF]/20 cursor-not-allowed border border-[#229EDF]/30" + : "bg-white/5 hover:bg-white/10 border border-transparent hover:border-white/10" }`} >
@@ -66,7 +68,7 @@ const TokenItem: React.FC = ({ className="rounded-full shadow-md object-contain p-1" /> ) : ( -
+
{code[0]}
)} @@ -74,17 +76,17 @@ const TokenItem: React.FC = ({
{name}
-
{code}
+
{code}
{showBalance && (
-
${usdValue}
-
+
${usdValue}
+
{formatBalance(balance)}
@@ -176,166 +178,146 @@ const TokenSelectorModal: React.FC = ({ onClose(); }; - useEffect(() => { - if (!isOpen) { - setSearchQuery(""); - } - }, [isOpen]); - - useEffect(() => { - if (isOpen) { - document.body.style.overflow = "hidden"; - } else { - document.body.style.overflow = "unset"; - } - return () => { - document.body.style.overflow = "unset"; - }; - }, [isOpen]); + const handleClose = useCallback(() => { + setSearchQuery(""); + onClose(); + }, [onClose]); if (!isOpen) return null; return ( -
+
- -
-
-

Select a token

- -
+ + +
-
-
- - - - - setSearchQuery(e.target.value)} - placeholder="Search tokens" - className="w-full pl-10 pr-4 py-3 bg-white border-2 border-gray-300 rounded-xl text-gray-900 placeholder:text-gray-400 focus:outline-none focus:border-[#334EAC] transition-colors" - autoFocus - /> +
+
+ + + + + setSearchQuery(e.target.value)} + placeholder="Search tokens" + className="w-full pl-10 pr-4 py-3 bg-[#2A2A2A] border border-white/10 rounded-xl text-white placeholder:text-white/40 focus:outline-none focus:border-[#229EDF]/50 transition-colors" + autoFocus + /> +
-
-
- {userTokens.length > 0 && ( -
-

- Your tokens -

-
- {userTokens.map((code) => { - const isSelected = selectedToken - ? getTokenId(selectedToken) === code - : false; - const { balance, usdValue } = getTokenBalance(code); - const tokenInfo = availableTokens[code]; - const tokenName = tokenInfo?.name || code; - const tokenIcon = - code === "XLM" - ? "/assets/xlm-negro-logo.png" - : tokenInfo?.contract - ? getTokenIcon(tokenInfo.contract) - : null; +
+ {userTokens.length > 0 && ( +
+

+ Your tokens +

+
+ {userTokens.map((code) => { + const isSelected = selectedToken + ? getTokenId(selectedToken) === code + : false; + const { balance, usdValue } = getTokenBalance(code); + const tokenInfo = availableTokens[code]; + const tokenName = tokenInfo?.name || code; + const tokenIcon = + code === "XLM" + ? "/assets/xlm-negro-logo.png" + : tokenInfo?.contract + ? getTokenIcon(tokenInfo.contract) + : null; - return ( - handleTokenClick(code)} - /> - ); - })} + return ( + handleTokenClick(code)} + /> + ); + })} +
-
- )} + )} - {popularTokens.length > 0 && ( -
-

- Popular tokens -

-
- {popularTokens.map((code) => { - const isSelected = selectedToken - ? getTokenId(selectedToken) === code - : false; - const tokenInfo = availableTokens[code]; - const tokenName = tokenInfo?.name || code; - const tokenIcon = - code === "XLM" - ? "/assets/xlm-negro-logo.png" - : tokenInfo?.contract - ? getTokenIcon(tokenInfo.contract) - : null; + {popularTokens.length > 0 && ( +
+

+ Popular tokens +

+
+ {popularTokens.map((code) => { + const isSelected = selectedToken + ? getTokenId(selectedToken) === code + : false; + const tokenInfo = availableTokens[code]; + const tokenName = tokenInfo?.name || code; + const tokenIcon = + code === "XLM" + ? "/assets/xlm-negro-logo.png" + : tokenInfo?.contract + ? getTokenIcon(tokenInfo.contract) + : null; - return ( - handleTokenClick(code)} - showBalance={false} - /> - ); - })} + return ( + handleTokenClick(code)} + showBalance={false} + /> + ); + })} +
-
- )} + )} - {filteredTokens.length === 0 && ( -
-

No tokens found

-

- Try searching with a different term -

-
- )} + {filteredTokens.length === 0 && ( +
+

No tokens found

+

+ Try searching with a different term +

+
+ )} +
-
+ ); }; diff --git a/apps/web-app/src/features/swap/constants/swapConfig.ts b/apps/web-app/src/features/swap/constants/swapConfig.ts index 15a2d0d9..93410946 100644 --- a/apps/web-app/src/features/swap/constants/swapConfig.ts +++ b/apps/web-app/src/features/swap/constants/swapConfig.ts @@ -1,17 +1,11 @@ -/** Debounce delay before firing a quote request (ms) */ export const DEBOUNCE_MS = 50; -/** Quote auto-refresh interval (ms) */ export const QUOTE_REFRESH_INTERVAL_MS = 5000; -/** Default slippage tolerance in basis points (500 = 5%) */ export const DEFAULT_SLIPPAGE_BPS = 500; -/** Maximum route hops for Soroswap quotes */ export const MAX_HOPS = 1; -/** Threshold above which a swap output is flagged as suspiciously low (%) */ export const SUSPICIOUS_VALUE_THRESHOLD_PCT = 10; -/** Maximum number of orders shown in order history */ export const ORDER_HISTORY_LIMIT = 20; diff --git a/apps/web-app/src/features/swap/hooks/useStellarQuote.ts b/apps/web-app/src/features/swap/hooks/useStellarQuote.ts index 93b87d11..d384647e 100644 --- a/apps/web-app/src/features/swap/hooks/useStellarQuote.ts +++ b/apps/web-app/src/features/swap/hooks/useStellarQuote.ts @@ -7,11 +7,6 @@ import { MAX_HOPS, } from "@/features/swap/constants/swapConfig"; -/** - * Fetches a Soroswap quote for a Stellar swap. - * Manages its own AbortController to cancel in-flight requests. - * Returns `null` when a quote cannot be obtained. - */ export function useStellarQuote( amountIn: string, tokenIn: Token | string, diff --git a/apps/web-app/src/features/swap/hooks/useSwapQuote.ts b/apps/web-app/src/features/swap/hooks/useSwapQuote.ts index 68434569..e4997ef6 100644 --- a/apps/web-app/src/features/swap/hooks/useSwapQuote.ts +++ b/apps/web-app/src/features/swap/hooks/useSwapQuote.ts @@ -68,7 +68,6 @@ export function useSwapQuote( } }, [address, amountIn, fetchStellarQuote]); - // Debounced quote trigger useEffect(() => { if (quoteTimeoutRef.current) { clearTimeout(quoteTimeoutRef.current); @@ -125,7 +124,6 @@ export function useSwapQuote( cancelStellarQuote, ]); - // Auto-refresh quotes every interval while valid inputs exist useEffect(() => { if ( address && diff --git a/apps/web-app/src/features/swap/hooks/useSwapState.ts b/apps/web-app/src/features/swap/hooks/useSwapState.ts index fa124824..c150f9ec 100644 --- a/apps/web-app/src/features/swap/hooks/useSwapState.ts +++ b/apps/web-app/src/features/swap/hooks/useSwapState.ts @@ -5,7 +5,6 @@ export type OrderType = "swap" | "limit" | "twap"; export interface SwapState { isLoading: boolean; - error: string | null; orderType: OrderType; amountIn: string; amountOut: string; @@ -21,7 +20,6 @@ export interface SwapStateActions { setTokenIn: (token: Token | string) => void; setTokenOut: (token: Token | string) => void; setTxHash: (hash: string | null) => void; - setError: (error: string | null) => void; setIsLoading: (loading: boolean) => void; resetSwap: () => void; swapTokens: () => void; @@ -32,7 +30,6 @@ export function useSwapState( defaultTokenOut: Token | string ): SwapState & SwapStateActions { const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(null); const [orderType, setOrderType] = useState("swap"); const [amountIn, setAmountIn] = useState(""); const [amountOut, setAmountOut] = useState("0.0"); @@ -41,7 +38,6 @@ export function useSwapState( const [txHash, setTxHash] = useState(null); const resetSwap = useCallback(() => { - setError(null); setIsLoading(false); }, []); @@ -56,7 +52,6 @@ export function useSwapState( return { isLoading, - error, orderType, amountIn, amountOut, @@ -69,7 +64,6 @@ export function useSwapState( setTokenIn, setTokenOut, setTxHash, - setError, setIsLoading, resetSwap, swapTokens, diff --git a/apps/web-app/src/features/wallet/components/ConnectAccount.tsx b/apps/web-app/src/features/wallet/components/ConnectAccount.tsx index b95171e1..f3e18d64 100644 --- a/apps/web-app/src/features/wallet/components/ConnectAccount.tsx +++ b/apps/web-app/src/features/wallet/components/ConnectAccount.tsx @@ -3,6 +3,7 @@ import React from "react"; import { stellarNetwork } from "@/lib/constants/network"; import FundAccountButton from "./FundAccountButton"; +import MintTestTokensButton from "./MintTestTokensButton"; import NetworkPill from "./NetworkPill"; const ConnectAccount: React.FC = () => { @@ -16,7 +17,12 @@ const ConnectAccount: React.FC = () => { verticalAlign: "middle", }} > - {stellarNetwork !== "PUBLIC" && } + {stellarNetwork !== "PUBLIC" && ( + <> + + + + )}
); diff --git a/apps/web-app/src/features/wallet/components/FundAccountButton.tsx b/apps/web-app/src/features/wallet/components/FundAccountButton.tsx index 7748b702..6cd3e2c2 100644 --- a/apps/web-app/src/features/wallet/components/FundAccountButton.tsx +++ b/apps/web-app/src/features/wallet/components/FundAccountButton.tsx @@ -1,13 +1,13 @@ "use client"; import React, { useState, useTransition } from "react"; -import { useNotification } from "@/hooks/useNotification"; +import { useToast } from "@/hooks/useToast"; import { useWallet } from "@/hooks/useWallet"; import { Button, Tooltip } from "@stellar/design-system"; import { getFriendbotUrl } from "@/lib/helpers/stellar/friendbot"; const FundAccountButton: React.FC = () => { - const { addNotification } = useNotification(); + const { addNotification } = useToast(); const [isPending, startTransition] = useTransition(); const [isTooltipVisible, setIsTooltipVisible] = useState(false); const { address } = useWallet(); diff --git a/apps/web-app/src/features/wallet/components/GetTestTokensBanner.tsx b/apps/web-app/src/features/wallet/components/GetTestTokensBanner.tsx new file mode 100644 index 00000000..79cbea30 --- /dev/null +++ b/apps/web-app/src/features/wallet/components/GetTestTokensBanner.tsx @@ -0,0 +1,53 @@ +"use client"; + +import React, { useState } from "react"; +import { Coins } from "lucide-react"; +import { GetTestTokensModal } from "./GetTestTokensModal"; +import { useMissingRwaTokens } from "@/hooks/useMissingRwaTokens"; +import { cn } from "@/lib/utils"; + +export function GetTestTokensBanner({ className }: { className?: string }) { + const { balances, isFetching, shouldShowBanner } = useMissingRwaTokens(); + const [isModalOpen, setIsModalOpen] = useState(false); + + if (!shouldShowBanner) return null; + + return ( + <> +
+
+ +
+

+ Get your RWA test tokens +

+

+ Mint USTRY, TESOURO, CETES, USDY and PYUSD to use Borrow, Lend and + Pools +

+
+
+ +
+ + setIsModalOpen(false)} + balances={balances} + isFetching={isFetching} + /> + + ); +} diff --git a/apps/web-app/src/features/wallet/components/GetTestTokensModal.tsx b/apps/web-app/src/features/wallet/components/GetTestTokensModal.tsx new file mode 100644 index 00000000..972d46bd --- /dev/null +++ b/apps/web-app/src/features/wallet/components/GetTestTokensModal.tsx @@ -0,0 +1,189 @@ +"use client"; + +import React, { useTransition } from "react"; +import { X, Coins, AlertCircle } from "lucide-react"; +import { ModalPortal } from "@/components/ui/ModalPortal"; +import { useToast } from "@/hooks/useToast"; +import { useWallet } from "@/hooks/useWallet"; +import { useSorobanTokenBalances } from "@/hooks/useSorobanTokenBalances"; +import { + buildFaucetTransaction, + getFaucetTokens, +} from "@/lib/constants/faucet"; +import { addFaucetTokensToFreighter } from "@/lib/helpers/stellar/freighter"; +import { signAndSendTransaction } from "@/lib/helpers/stellar/transaction"; +import { + rpcUrl, + networkPassphrase, + horizonUrl, +} from "@/lib/config/stellar.config"; +import type { SorobanTokenBalance } from "@/lib/helpers/stellar/sorobanBalances"; + +export interface GetTestTokensModalProps { + isOpen: boolean; + onClose: () => void; + balances: SorobanTokenBalance[]; + isFetching: boolean; +} + +function isMissing(balance: string): boolean { + const n = parseFloat(balance); + return Number.isNaN(n) || n <= 0; +} + +export function GetTestTokensModal({ + isOpen, + onClose, + balances, + isFetching, +}: GetTestTokensModalProps) { + const { address, signTransaction } = useWallet(); + const { addNotification } = useToast(); + const { invalidate } = useSorobanTokenBalances(); + const [isMinting, startMintTransition] = useTransition(); + + const missingTokens = balances.filter((t) => isMissing(t.balance)); + const hasMissing = missingTokens.length > 0; + + const handleMintTestTokens = () => { + if (!address) return; + startMintTransition(async () => { + try { + const txXdr = await buildFaucetTransaction( + address, + rpcUrl, + horizonUrl, + networkPassphrase + ); + + await signAndSendTransaction(txXdr, signTransaction, { + networkPassphrase, + rpcUrl, + address, + }); + + const tokens = getFaucetTokens(); + addNotification( + `Minted: ${tokens.map((t) => t.symbol).join(", ")}`, + "success" + ); + + await invalidate(); + + try { + addNotification("Adding tokens to Freighter…", "info"); + await addFaucetTokensToFreighter(networkPassphrase); + addNotification("Tokens added to Freighter", "success"); + } catch { + addNotification( + "Mint succeeded but adding to Freighter failed. Use the wallet icon per token to add manually.", + "warning" + ); + } + } catch (err) { + const msg = err instanceof Error ? err.message : "Unknown error"; + addNotification(`Failed to mint test tokens: ${msg}`, "error"); + } + }); + }; + + if (!isOpen) return null; + + return ( + +
+
+
+
+ +

Get Test Tokens

+
+ +
+ + {hasMissing && ( +
+ +
+

+ You're missing {missingTokens.length} token + {missingTokens.length > 1 ? "s" : ""} +

+

+ Mint USTRY, TESOURO, CETES, USDY & PYUSD to use Borrow, Lend + and Pools. +

+
+
+ )} + +
+
+ + RWA Token Balances + + {isFetching && ( + updating… + )} +
+
+ {balances.map((t) => { + const missing = isMissing(t.balance); + return ( +
+ + {t.symbol} + {missing && ( + + (empty) + + )} + + + {t.balance} + +
+ ); + })} +
+
+ + + +

+ Tokens are minted from the faucet and added to your wallet. +

+
+
+
+ ); +} diff --git a/apps/web-app/src/features/wallet/components/MintTestTokensButton.tsx b/apps/web-app/src/features/wallet/components/MintTestTokensButton.tsx new file mode 100644 index 00000000..06c61dea --- /dev/null +++ b/apps/web-app/src/features/wallet/components/MintTestTokensButton.tsx @@ -0,0 +1,99 @@ +"use client"; + +import React, { useState, useTransition } from "react"; +import { useToast } from "@/hooks/useToast"; +import { useWallet } from "@/hooks/useWallet"; +import { useSorobanTokenBalances } from "@/hooks/useSorobanTokenBalances"; +import { Button, Tooltip } from "@stellar/design-system"; +import { + buildFaucetTransaction, + getFaucetTokens, +} from "@/lib/constants/faucet"; +import { addFaucetTokensToFreighter } from "@/lib/helpers/stellar/freighter"; +import { signAndSendTransaction } from "@/lib/helpers/stellar/transaction"; +import { + rpcUrl, + networkPassphrase, + horizonUrl, +} from "@/lib/config/stellar.config"; + +const MintTestTokensButton: React.FC = () => { + const { addNotification } = useToast(); + const [isPending, startTransition] = useTransition(); + const [isTooltipVisible, setIsTooltipVisible] = useState(false); + const { address, signTransaction } = useWallet(); + const { invalidate } = useSorobanTokenBalances(); + + if (!address) return null; + + const handleMintTokens = () => { + startTransition(async () => { + try { + const txXdr = await buildFaucetTransaction( + address, + rpcUrl, + horizonUrl, + networkPassphrase + ); + + await signAndSendTransaction(txXdr, signTransaction, { + networkPassphrase, + rpcUrl, + address, + }); + + const tokens = getFaucetTokens(); + addNotification( + `Minted test tokens: ${tokens.map((t) => t.symbol).join(", ")}`, + "success" + ); + + await invalidate(); + + try { + addNotification("Adding tokens to Freighter…", "info"); + await addFaucetTokensToFreighter(networkPassphrase); + addNotification("Tokens added to Freighter", "success"); + } catch { + addNotification( + "Mint succeeded but adding to Freighter failed. Use Settings to add tokens manually.", + "warning" + ); + } + } catch (err) { + const msg = err instanceof Error ? err.message : "Unknown error"; + addNotification(`Failed to mint test tokens: ${msg}`, "error"); + } + }); + }; + + return ( +
setIsTooltipVisible(true)} + onMouseLeave={() => setIsTooltipVisible(false)} + > + + {isPending ? "Minting..." : "Get Test Tokens"} + + } + > +
+ Mint USTRY, TESOURO, CETES, USDY & PYUSD to your wallet +
+
+
+ ); +}; + +export default MintTestTokensButton; diff --git a/apps/web-app/src/hooks/use24hPortfolioChange.ts b/apps/web-app/src/hooks/use24hPortfolioChange.ts index 6a638598..e23473bc 100644 --- a/apps/web-app/src/hooks/use24hPortfolioChange.ts +++ b/apps/web-app/src/hooks/use24hPortfolioChange.ts @@ -18,11 +18,6 @@ async function fetch24hChanges( return res.json(); } -/** - * Computes the portfolio-weighted 24h price change using CoinGecko data. - * Only tokens with a CoinGecko ID in COINGECKO_IDS are included. - * Returns a signed percentage string like "+2.4%" or "-1.1%", or null if unavailable. - */ export function use24hPortfolioChange(balances: MappedBalances) { const entries = Object.values(balances).flatMap((b) => { let code: string | null = null; diff --git a/apps/web-app/src/hooks/useBalances.ts b/apps/web-app/src/hooks/useBalances.ts index 06276e6f..6e5a0a48 100644 --- a/apps/web-app/src/hooks/useBalances.ts +++ b/apps/web-app/src/hooks/useBalances.ts @@ -4,40 +4,17 @@ import { type MappedBalances, } from "@/lib/helpers/stellar/wallet"; -/** - * Hook to fetch wallet balances using React Query. - * Provides intelligent polling, caching, and error handling. - * - * @param address - The Stellar wallet address to fetch balances for - * @param options - Configuration options for the query - * @returns React Query result with balances data - */ export const useBalances = ( address: string | undefined, options?: { - /** - * Enable or disable the query (useful when address is not available) - */ enabled?: boolean; - /** - * Polling interval in milliseconds. Set to 0 to disable polling. - * Default: 10000 (10 seconds) - */ + refetchInterval?: number; - /** - * Whether to refetch when window regains focus - * Default: false (to avoid excessive requests) - */ + refetchOnWindowFocus?: boolean; - /** - * Whether to keep previous data while fetching new data - * Default: true (to prevent flickering) - */ + keepPreviousData?: boolean; - /** - * Number of retry attempts on error - * Default: 2 - */ + retry?: number; } ) => { @@ -60,27 +37,25 @@ export const useBalances = ( enabled: enabled && Boolean(address), refetchInterval: refetchInterval > 0 ? refetchInterval : false, refetchOnWindowFocus, - // React Query v5: Use placeholderData to keep previous data while fetching new data - // This prevents flickering when data is being refetched + placeholderData: keepPreviousData ? (previousData) => previousData ?? ({} as MappedBalances) : undefined, retry: (failureCount, error) => { - // Don't retry if we've exceeded the retry limit if (failureCount >= retry) { return false; } - // Don't retry on "not found" errors (expected for unfunded accounts) + if (error instanceof Error && error.message.match(/not found/i)) { return false; } - // Retry on other errors + return true; }, retryOnMount: true, staleTime: 5000, // Consider data stale after 5 seconds gcTime: 60000, // Keep unused data in cache for 60 seconds - // Don't throw on errors - we handle them gracefully + throwOnError: false, }); }; diff --git a/apps/web-app/src/hooks/useCopyToClipboard.ts b/apps/web-app/src/hooks/useCopyToClipboard.ts index eecc31e0..18b597be 100644 --- a/apps/web-app/src/hooks/useCopyToClipboard.ts +++ b/apps/web-app/src/hooks/useCopyToClipboard.ts @@ -1,19 +1,15 @@ import { useState, useCallback } from "react"; -import { useNotification } from "@/hooks/useNotification"; +import { useToast } from "@/hooks/useToast"; const DEFAULT_RESET_MS = 1500; -/** - * Copy text to clipboard and track which key was last copied (for UI feedback). - * Optionally shows a success notification. - */ export function useCopyToClipboard(options?: { resetMs?: number; showNotification?: boolean; }) { const { resetMs = DEFAULT_RESET_MS, showNotification = false } = options ?? {}; - const { addNotification } = useNotification(); + const { addNotification } = useToast(); const [copiedKey, setCopiedKey] = useState(null); const copy = useCallback( diff --git a/apps/web-app/src/hooks/useMissingRwaTokens.ts b/apps/web-app/src/hooks/useMissingRwaTokens.ts new file mode 100644 index 00000000..73a1fb14 --- /dev/null +++ b/apps/web-app/src/hooks/useMissingRwaTokens.ts @@ -0,0 +1,32 @@ +"use client"; + +import { useMemo } from "react"; +import { useSorobanTokenBalances } from "@/hooks/useSorobanTokenBalances"; +import { useStellarWallet } from "@/hooks/useStellarWallet"; +import { stellarNetwork } from "@/lib/constants/network"; + +function isMissing(balance: string): boolean { + const n = parseFloat(balance); + return Number.isNaN(n) || n <= 0; +} + +export function useMissingRwaTokens() { + const { address, isConnected } = useStellarWallet(); + const { balances, isFetching } = useSorobanTokenBalances(); + + const hasMissing = useMemo( + () => balances.some((t) => isMissing(t.balance)), + [balances] + ); + + const shouldShowBanner = Boolean( + isConnected && address && stellarNetwork !== "PUBLIC" && hasMissing + ); + + return { + balances, + isFetching, + isConnected, + shouldShowBanner, + }; +} diff --git a/apps/web-app/src/hooks/useNotification.ts b/apps/web-app/src/hooks/useNotification.ts deleted file mode 100644 index d7289c7b..00000000 --- a/apps/web-app/src/hooks/useNotification.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { use } from "react"; -import { - NotificationContext, - NotificationContextType, -} from "../providers/NotificationProvider"; - -export const useNotification = (): NotificationContextType => { - const context = use(NotificationContext); - if (!context) { - throw new Error( - "useNotification must be used within a NotificationProvider", - ); - } - return context; -}; diff --git a/apps/web-app/src/hooks/useSorobanTokenBalances.ts b/apps/web-app/src/hooks/useSorobanTokenBalances.ts new file mode 100644 index 00000000..3a20cf3a --- /dev/null +++ b/apps/web-app/src/hooks/useSorobanTokenBalances.ts @@ -0,0 +1,49 @@ +"use client"; + +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useWallet } from "./useWallet"; +import { + fetchSorobanTokenBalances, + type SorobanTokenBalance, +} from "@/lib/helpers/stellar/sorobanBalances"; +import { + rpcUrl, + networkPassphrase, + horizonUrl, +} from "@/lib/config/stellar.config"; +import { useCallback } from "react"; + +const QUERY_KEY = "soroban-faucet-balances"; + +export function useSorobanTokenBalances() { + const { address } = useWallet(); + const queryClient = useQueryClient(); + + const { data, isFetching, refetch } = useQuery({ + queryKey: [QUERY_KEY, address], + queryFn: () => + fetchSorobanTokenBalances( + address!, + rpcUrl, + horizonUrl, + networkPassphrase + ), + enabled: Boolean(address), + refetchInterval: 15000, + staleTime: 5000, + retry: 2, + throwOnError: false, + placeholderData: (prev) => prev, + }); + + const invalidate = useCallback(async () => { + await queryClient.invalidateQueries({ queryKey: [QUERY_KEY, address] }); + }, [queryClient, address]); + + return { + balances: data ?? [], + isFetching, + refetch, + invalidate, + }; +} diff --git a/apps/web-app/src/hooks/useToast.ts b/apps/web-app/src/hooks/useToast.ts new file mode 100644 index 00000000..8f5ac1dc --- /dev/null +++ b/apps/web-app/src/hooks/useToast.ts @@ -0,0 +1,24 @@ +import type { SileoOptions } from "sileo"; +import { + notify, + promise, + action, + sileo, + type ToastType, + type PromiseOpts, +} from "@/lib/toast"; + +export function useToast() { + return { + addNotification: ( + msg: string, + type: ToastType, + opts?: Partial + ) => notify(msg, type, opts), + action, + promise: (p: Promise | (() => Promise), opts: PromiseOpts) => + promise(p, opts), + dismiss: sileo.dismiss, + clear: sileo.clear, + }; +} diff --git a/apps/web-app/src/hooks/useTokenBalance.ts b/apps/web-app/src/hooks/useTokenBalance.ts index a76c2edd..291d18b5 100644 --- a/apps/web-app/src/hooks/useTokenBalance.ts +++ b/apps/web-app/src/hooks/useTokenBalance.ts @@ -1,93 +1,10 @@ import { useQuery } from "@tanstack/react-query"; -import { - Contract, - Address, - rpc, - scValToNative, - TransactionBuilder, - Horizon, -} from "@stellar/stellar-sdk"; -import { - rpcUrl, - networkPassphrase, - horizonUrl, - XLM_TESTNET_ADDRESS, -} from "@/lib/config/stellar.config"; +import { XLM_TESTNET_ADDRESS } from "@/lib/config/stellar.config"; import { useWallet } from "./useWallet"; -import { fromSmallestUnit } from "@/lib/helpers/tokenUtils"; import { getTokens, getAvailableTokens } from "@/lib/helpers/stellar/soroswap"; import { getTokenAddress } from "@/lib/helpers/stellar/soroswap"; +import { getTokenBalanceFromContract } from "@/lib/helpers/stellar/sorobanBalance"; -/** - * Get token balance by simulating contract call - */ -const getTokenBalanceFromContract = async ( - contractAddress: string, - walletAddress: string, - decimals: number = 7 -): Promise => { - try { - const sorobanServer = new rpc.Server(rpcUrl, { allowHttp: true }); - const horizonServer = new Horizon.Server(horizonUrl); - const contract = new Contract(contractAddress); - - // Call balance(address) function - const operation = contract.call( - "balance", - new Address(walletAddress).toScVal() - ); - - // Get account for transaction from Horizon - let account; - try { - account = await horizonServer.loadAccount(walletAddress); - } catch { - // Account might not exist, return 0 - console.log(`Account ${walletAddress} not found for balance query`); - return "0"; - } - - // Build transaction - const transaction = new TransactionBuilder(account, { - fee: "100", - networkPassphrase: networkPassphrase, - }) - .addOperation(operation) - .setTimeout(300) - .build(); - - // Simulate transaction - const simulated = await sorobanServer.simulateTransaction(transaction); - - // Extract balance from result - const simulatedResult = simulated as - | { result?: { retval?: unknown } } - | null - | undefined; - const retval = simulatedResult?.result?.retval; - if (!retval) { - return "0"; - } - // Type guard to ensure retval is ScVal - scValToNative accepts ScVal which is a complex type - const balanceValue = scValToNative( - retval as Parameters[0] - ); - const balanceBigInt = BigInt(balanceValue as string); - - // Convert to human-readable format - return fromSmallestUnit(balanceBigInt.toString(), decimals); - } catch (error) { - console.error( - `Failed to get balance for contract ${contractAddress}:`, - error - ); - return "0"; - } -}; - -/** - * Get XLM balance from Horizon balances - */ const getXlmBalance = ( balances: Record ): string => { @@ -99,32 +16,23 @@ const getXlmBalance = ( return "0"; }; -/** - * Get decimals for a token - */ const getTokenDecimals = (tokenAddress: string): number => { const tokens = getTokens(); const availableTokens = getAvailableTokens(); - // Check if it's XLM (current network or testnet fallback) if (tokenAddress === tokens.XLM || tokenAddress === XLM_TESTNET_ADDRESS) { return 7; } - // Get decimals from available tokens for (const [, info] of Object.entries(availableTokens)) { if (info.contract === tokenAddress) { return info.decimals || 7; } } - // Default to 7 decimals return 7; }; -/** - * Hook to get balance of a specific token - */ export const useTokenBalance = ( token: | string @@ -133,19 +41,15 @@ export const useTokenBalance = ( ) => { const { address, balances } = useWallet(); - // Get token address const tokenAddress = token ? getTokenAddress(token) : null; - // Get current network tokens const tokens = getTokens(); - // Check if it's XLM (native or XLM wrapper) const isXlm = tokenAddress === tokens.XLM || (typeof token !== "string" && token?.type === "native") || tokenAddress === XLM_TESTNET_ADDRESS; - // For Soroban tokens, use contract simulation (always call hook, but disable when XLM) const { data: contractBalance = "0", isLoading, @@ -167,7 +71,6 @@ export const useTokenBalance = ( throwOnError: false, }); - // For XLM, use Horizon balances if (isXlm) { const balance = getXlmBalance(balances); return { @@ -177,7 +80,6 @@ export const useTokenBalance = ( }; } - // For Soroban tokens, return contract balance return { balance: contractBalance, isLoading, diff --git a/apps/web-app/src/hooks/useTokenPrice.ts b/apps/web-app/src/hooks/useTokenPrice.ts index 96178d13..cc88f50f 100644 --- a/apps/web-app/src/hooks/useTokenPrice.ts +++ b/apps/web-app/src/hooks/useTokenPrice.ts @@ -6,9 +6,6 @@ import { import type { Token } from "@/lib/helpers/stellar/soroswap"; import { stellarPriceService } from "@/lib/services/stellar-price.service"; -/** - * Hook to get token price in USD - */ export const useTokenPrice = (token: Token | string | undefined) => { const getTokenCode = (): string | null => { if (!token) { diff --git a/apps/web-app/src/hooks/useUserTotalDeposited.ts b/apps/web-app/src/hooks/useUserTotalDeposited.ts index 520b71b6..8e4aa51b 100644 --- a/apps/web-app/src/hooks/useUserTotalDeposited.ts +++ b/apps/web-app/src/hooks/useUserTotalDeposited.ts @@ -53,10 +53,6 @@ async function fetchTotalDeposited(userAddress: string) { return { totalUsd, positionCount: activePositions.length }; } -/** - * Returns the total USD value the user has deposited across all protocol pools. - * Only counts positions with deposited > 0 and tokens with a known CoinGecko ID. - */ export function useUserTotalDeposited(userAddress: string | undefined) { return useQuery({ queryKey: ["userTotalDeposited", userAddress], diff --git a/apps/web-app/src/lib/config/stellar.config.ts b/apps/web-app/src/lib/config/stellar.config.ts index e1861b8b..a1f2848f 100644 --- a/apps/web-app/src/lib/config/stellar.config.ts +++ b/apps/web-app/src/lib/config/stellar.config.ts @@ -70,7 +70,6 @@ export const rpcUrl = env.PUBLIC_STELLAR_RPC_URL; export const horizonUrl = env.PUBLIC_STELLAR_HORIZON_URL; -/** Stellar testnet native XLM contract address (wrapper asset) */ export const XLM_TESTNET_ADDRESS = "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC"; diff --git a/apps/web-app/src/lib/constants/assets.config.ts b/apps/web-app/src/lib/constants/assets.config.ts new file mode 100644 index 00000000..5a8e6f8d --- /dev/null +++ b/apps/web-app/src/lib/constants/assets.config.ts @@ -0,0 +1,155 @@ +export type PriceSource = "oracle" | "coingecko"; + +export interface AssetConfig { + code: string; + name: string; + contract: string; + decimals: number; + icon: string; + priceSource: PriceSource; + coinGeckoId?: string; + isStablecoin?: boolean; +} + +type NetworkId = "testnet" | "standalone" | "mainnet"; + +const ASSETS_BY_NETWORK: Record> = { + testnet: { + XLM: { + code: "XLM", + name: "Stellar Lumens", + contract: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC", + decimals: 7, + icon: "/assets/xlm-logo.png", + priceSource: "coingecko", + coinGeckoId: "stellar", + }, + USDC: { + code: "USDC", + name: "USD Coin", + contract: "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA", + decimals: 7, + icon: "/assets/usdc-logo.png", + priceSource: "coingecko", + coinGeckoId: "usd-coin", + isStablecoin: true, + }, + USTRY: { + code: "USTRY", + name: "US Treasury Token", + contract: "CCAYGJWQI5NJN7XRVNSENF47PICNSNTG4FAQHHFOJWZIRTEAC5JPMLGN", + decimals: 7, + icon: "/assets/ustry-logo.png", + priceSource: "oracle", + isStablecoin: false, + }, + TESOURO: { + code: "TESOURO", + name: "Tesouro Token", + contract: "CAPFX3QEAHE7JVT6E7PYZQTFSVS5Z7AV4RE7GRJRVCPKXGQHCWSCOMTW", + decimals: 7, + icon: "/assets/tesouro-logo.png", + priceSource: "oracle", + isStablecoin: false, + }, + CETES: { + code: "CETES", + name: "CETES Token", + contract: "CAJ4B2ZWU2GA7UYQZ7N7QQCTZAUSSXNKKQ326ADYVH3ALN4FFQ6LPO4U", + decimals: 7, + icon: "/assets/cetes-logo.png", + priceSource: "oracle", + isStablecoin: false, + }, + USDY: { + code: "USDY", + name: "US Dollar Yield", + contract: "CDRQV3D3GLWF73MWTEQWFZWMBQ47KZ3KECYPOBKBDRQBWQQ74KDH5ECT", + decimals: 7, + icon: "/assets/usdy-logo.png", + priceSource: "oracle", + isStablecoin: true, + }, + PYUSD: { + code: "PYUSD", + name: "PayPal USD", + contract: "CBNHH37BJ2G4ZT6PLWDXPOWHKLR75IGNLBRCXZNOS7YPAYS53JPEPSSS", + decimals: 7, + icon: "/assets/pyusd-logo.png", + priceSource: "oracle", + isStablecoin: true, + }, + }, + standalone: { + XLM: { + code: "XLM", + name: "Stellar Lumens", + contract: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC", + decimals: 7, + icon: "/assets/xlm-logo.png", + priceSource: "coingecko", + coinGeckoId: "stellar", + }, + USDC: { + code: "USDC", + name: "USD Coin", + contract: "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA", + decimals: 7, + icon: "/assets/usdc-logo.png", + priceSource: "coingecko", + coinGeckoId: "usd-coin", + isStablecoin: true, + }, + }, + mainnet: {}, +}; + +import { stellarNetwork } from "@/lib/constants/network"; + +function getNetworkId(): NetworkId { + const network = stellarNetwork?.toLowerCase() || "testnet"; + if (network === "local" || network === "standalone") return "standalone"; + if (network === "public" || network === "mainnet") return "mainnet"; + return "testnet"; +} + +export function getAssetsConfig(): Record { + const networkId = getNetworkId(); + return ASSETS_BY_NETWORK[networkId] ?? ASSETS_BY_NETWORK.testnet; +} + +export function getRwaTokenCodes(): string[] { + const assets = getAssetsConfig(); + return Object.values(assets) + .filter((a) => a.priceSource === "oracle") + .map((a) => a.code); +} + +export function getStablecoinCodes(): string[] { + const assets = getAssetsConfig(); + return Object.values(assets) + .filter((a) => a.isStablecoin) + .map((a) => a.code); +} + +export function getTokenIconMap(): Record { + const assets = getAssetsConfig(); + const map: Record = {}; + for (const a of Object.values(assets)) { + map[a.code] = a.icon; + } + return map; +} + +export function getCurrentNetworkId(): NetworkId { + return getNetworkId(); +} + +export function getContractToCodeMap(): Record { + const assets = getAssetsConfig(); + const map: Record = {}; + for (const a of Object.values(assets)) { + map[a.contract] = a.code; + } + return map; +} diff --git a/apps/web-app/src/lib/constants/contracts.ts b/apps/web-app/src/lib/constants/contracts.ts index 7d65bbc0..2d2fa64d 100644 --- a/apps/web-app/src/lib/constants/contracts.ts +++ b/apps/web-app/src/lib/constants/contracts.ts @@ -1,11 +1,3 @@ -/** - * Contract Configuration - * Centralized contract IDs and network configuration for Stellar/Soroban - */ - -// Re-export generated contract errors -// These are auto-generated from Rust source files by scripts/generate-error-types.js -// Run `npm run generate:errors` to regenerate export { CONTRACT_ERRORS, CONTRACT_ERRORS_BY_CONTRACT, @@ -20,10 +12,10 @@ export { } from "./generated/contract-errors"; export const LENDING_CONTRACT_ID = - "CD2ZCQLGQZRUBHO77QVGJXNFP6Y6UIQBBYG5WG6EX5PTQQDNEQITNB5J"; + "CCYYGFDBVHMTL3RWDH6L6IY777VLBXDGB4NYBBKTZEH4UR7URAFKWJMI"; export const LENDING_POOL2_CONTRACT_ID = - "CBSA75HUIMPR552MZLQFDZ4DH64IUHL3NR3NCJ2ZB4WY3QH3D7MH5HP4"; + "CANU6NELPY5IHDZGEHYH2AKBHKVNMXENMBOVXPOFZ4F6TBHL2FN55N3T"; export const ORACLE_CONTRACT_ID = - "CAYQIQ24IMRFYFXLTTCMSPA6HBQKUH2GLKRWU3B23VJF33X43WFOCC55"; + "CCL7MHNSFAG7537O3OJ7Z42YVSNXTYZCGPAHN5FI5RQHLJW43O3L7LLS"; diff --git a/apps/web-app/src/lib/constants/faucet.ts b/apps/web-app/src/lib/constants/faucet.ts new file mode 100644 index 00000000..9e13ad73 --- /dev/null +++ b/apps/web-app/src/lib/constants/faucet.ts @@ -0,0 +1,139 @@ +import { + xdr, + Address, + nativeToScVal, + Contract, + TransactionBuilder, + rpc, + Horizon, +} from "@stellar/stellar-sdk"; +export interface FaucetToken { + symbol: string; + contractId: string; + decimals: number; + mintAmount: bigint; +} + +const TESTNET_FAUCET_TOKENS: { + symbol: string; + contract: string; + decimals: number; + amount: number; +}[] = [ + { + symbol: "USTRY", + contract: "CC6SODKGOTFEDWVNPR6ESJC3GL7NC5Y4DVFKYGATZJ74F2YXHTW4RJ6D", + decimals: 7, + amount: 100, + }, + { + symbol: "TESOURO", + contract: "CA55OO3U556GXABJKDYP3QCGZ6AFNZPB27TROYP42AQPFFYPKU5EDOUH", + decimals: 7, + amount: 100, + }, + { + symbol: "CETES", + contract: "CCGWKS4GLAGPYIAOLBH6JM5RKUPMUCN47VRAEXAJWJFKXSXQ33VIRUAA", + decimals: 7, + amount: 100, + }, + { + symbol: "USDY", + contract: "CBVLFSVBZGHVAH6CV4JQYPBJSX75VFR2NJC7CQX7QKQ7KOLGQZOZAGQK", + decimals: 7, + amount: 100, + }, + { + symbol: "PYUSD", + contract: "CBCB5UDYZENTIUVVA7SHQOCVVCDXDMHEKJHHFM3OQKU2E5CAF2B62TIO", + decimals: 7, + amount: 100, + }, +]; + +export const FAUCET_CONTRACT_ID = + process.env.NEXT_PUBLIC_FAUCET_CONTRACT_ID ?? ""; + +export const FAUCET_COOLDOWN_MS = 5 * 60 * 1000; + +export function getFaucetTokens(): FaucetToken[] { + return TESTNET_FAUCET_TOKENS.map((t) => ({ + symbol: t.symbol, + contractId: t.contract, + decimals: t.decimals, + mintAmount: BigInt(t.amount) * 10n ** BigInt(t.decimals), + })); +} + +/** + * Build a single MintRequest as ScVal (Soroban struct encoded as a map). + * Fields are alphabetically ordered: amount, to, token. + */ +function mintRequestToScVal( + tokenContractId: string, + toAddress: string, + amount: bigint +): xdr.ScVal { + return xdr.ScVal.scvMap([ + new xdr.ScMapEntry({ + key: xdr.ScVal.scvSymbol("amount"), + val: nativeToScVal(amount, { type: "i128" }), + }), + new xdr.ScMapEntry({ + key: xdr.ScVal.scvSymbol("to"), + val: new Address(toAddress).toScVal(), + }), + new xdr.ScMapEntry({ + key: xdr.ScVal.scvSymbol("token"), + val: new Address(tokenContractId).toScVal(), + }), + ]); +} + +/** + * Build the full Vec ScVal for the bulk_mint contract call. + * Encodes all configured faucet tokens as MintRequest structs targeting `toAddress`. + */ +export function buildMintRequestsScVal(toAddress: string): xdr.ScVal { + const tokens = getFaucetTokens(); + const entries = tokens.map((t) => + mintRequestToScVal(t.contractId, toAddress, t.mintAmount) + ); + return xdr.ScVal.scvVec(entries); +} + +/** + * Build a prepared faucet bulk_mint transaction XDR ready for wallet signing. + * Uses the Blend pattern: build → prepareTransaction → return XDR for Freighter. + */ +export async function buildFaucetTransaction( + userAddress: string, + sorobanRpcUrl: string, + stellarHorizonUrl: string, + passphrase: string +): Promise { + if (!FAUCET_CONTRACT_ID) { + throw new Error("NEXT_PUBLIC_FAUCET_CONTRACT_ID is not configured"); + } + + const faucetContract = new Contract(FAUCET_CONTRACT_ID); + const requestsScVal = buildMintRequestsScVal(userAddress); + const operation = faucetContract.call("bulk_mint", requestsScVal); + + const horizonServer = new Horizon.Server(stellarHorizonUrl); + const account = await horizonServer.loadAccount(userAddress); + + const tx = new TransactionBuilder(account, { + fee: "10000000", + networkPassphrase: passphrase, + }) + .addOperation(operation) + .setTimeout(300) + .build(); + + const sorobanServer = new rpc.Server(sorobanRpcUrl, { allowHttp: true }); + const prepared = await sorobanServer.prepareTransaction(tx); + + return prepared.toXDR(); +} diff --git a/apps/web-app/src/lib/constants/generated/contract-errors.ts b/apps/web-app/src/lib/constants/generated/contract-errors.ts index f503b156..18da0627 100644 --- a/apps/web-app/src/lib/constants/generated/contract-errors.ts +++ b/apps/web-app/src/lib/constants/generated/contract-errors.ts @@ -4,7 +4,7 @@ * This file is automatically generated from Stellar smart contract error definitions. * To regenerate, run: npm run generate:errors * - * Generated at: 2026-03-05T05:40:55.630Z + * Generated at: 2026-03-08T07:15:16.526Z * * Source files: * - apps/contracts/stellar-contracts/rwa-lending/src/common/error.rs @@ -458,206 +458,6 @@ const _errorEntries: [number, ContractErrorInfo][] = [ contract: "rwa-oracle", }, ], - [ - 1, - { - code: "PositionNotFound", - message: "Position not found", - contract: "rwa-perps", - }, - ], - [ - 2, - { - code: "PositionAlreadyExists", - message: "Position already exists", - contract: "rwa-perps", - }, - ], - [ - 3, - { - code: "PositionNotLiquidatable", - message: "Position is not liquidatable", - contract: "rwa-perps", - }, - ], - [ - 10, - { - code: "MarginRatioHealthy", - message: "Margin ratio is healthy", - contract: "rwa-perps", - }, - ], - [ - 11, - { - code: "InsufficientMargin", - message: "Insufficient margin", - contract: "rwa-perps", - }, - ], - [ - 12, - { - code: "LiquidationPriceTooLow", - message: "Liquidation price is too low", - contract: "rwa-perps", - }, - ], - [ - 13, - { - code: "LiquidationPriceTooHigh", - message: "Liquidation price is too high", - contract: "rwa-perps", - }, - ], - [ - 20, - { - code: "MarketNotFound", - message: "Market not found", - contract: "rwa-perps", - }, - ], - [ - 21, - { - code: "MarketInactive", - message: "Market is inactive", - contract: "rwa-perps", - }, - ], - [ - 30, - { - code: "OraclePriceNotFound", - message: "Oracle price not found", - contract: "rwa-perps", - }, - ], - [ - 31, - { - code: "OraclePriceStale", - message: "Oracle price is stale", - contract: "rwa-perps", - }, - ], - [ - 40, - { - code: "ArithmeticError", - message: "A calculation error occurred. Please try a different amount", - contract: "rwa-perps", - }, - ], - [ - 41, - { - code: "Overflow", - message: "Arithmetic overflow occurred", - contract: "rwa-perps", - }, - ], - [ - 42, - { - code: "DivisionByZero", - message: "Division by zero", - contract: "rwa-perps", - }, - ], - [ - 50, - { - code: "Unauthorized", - message: "Unauthorized access", - contract: "rwa-perps", - }, - ], - [ - 60, - { - code: "InvalidInput", - message: "Invalid input provided", - contract: "rwa-perps", - }, - ], - [ - 61, - { - code: "NotInitialized", - message: "The lending protocol has not been initialized yet", - contract: "rwa-perps", - }, - ], - [ - 62, - { - code: "AlreadyInitialized", - message: "The lending protocol is already initialized", - contract: "rwa-perps", - }, - ], - [ - 63, - { - code: "ProtocolPaused", - message: "Protocol is paused", - contract: "rwa-perps", - }, - ], - [ - 70, - { - code: "InvalidFundingRate", - message: "Invalid funding rate", - contract: "rwa-perps", - }, - ], - [ - 71, - { - code: "FundingCalculationError", - message: "Funding calculation error", - contract: "rwa-perps", - }, - ], - [ - 72, - { - code: "MarginRatioBelowMaintenance", - message: "Margin removal would violate maintenance requirement", - contract: "rwa-perps", - }, - ], - [ - 73, - { - code: "MarginTokenNotSet", - message: "Margin token not configured", - contract: "rwa-perps", - }, - ], - [ - 80, - { - code: "ExceedsMaxLeverage", - message: "Leverage exceeds market maximum", - contract: "rwa-perps", - }, - ], - [ - 81, - { - code: "InsufficientInitialMargin", - message: "Margin below initial requirement", - contract: "rwa-perps", - }, - ], [ 1, { diff --git a/apps/web-app/src/lib/constants/index.ts b/apps/web-app/src/lib/constants/index.ts index b0522cc1..80702ce6 100644 --- a/apps/web-app/src/lib/constants/index.ts +++ b/apps/web-app/src/lib/constants/index.ts @@ -1,4 +1,4 @@ -// Constants exports export * from "./network"; export * from "./contracts"; export * from "./wallet"; +export * from "./toast.config"; diff --git a/apps/web-app/src/lib/constants/layout.ts b/apps/web-app/src/lib/constants/layout.ts deleted file mode 100644 index c2b89b67..00000000 --- a/apps/web-app/src/lib/constants/layout.ts +++ /dev/null @@ -1,3 +0,0 @@ -export const PAGE_PADDING_X = "px-4 sm:px-6"; -export const PAGE_PADDING_Y = "py-6 sm:py-8"; -export const PAGE_CONTAINER_PADDING = [PAGE_PADDING_X, PAGE_PADDING_Y] as const; diff --git a/apps/web-app/src/lib/constants/network.ts b/apps/web-app/src/lib/constants/network.ts index 4825ba05..1f4072e1 100644 --- a/apps/web-app/src/lib/constants/network.ts +++ b/apps/web-app/src/lib/constants/network.ts @@ -23,7 +23,6 @@ const envSchema = z.object({ PUBLIC_STELLAR_HORIZON_URL: z.string(), }); -// In Next.js, we use process.env with NEXT_PUBLIC_ prefix const envVars = { PUBLIC_STELLAR_NETWORK: process.env.NEXT_PUBLIC_STELLAR_NETWORK, PUBLIC_STELLAR_NETWORK_PASSPHRASE: @@ -67,10 +66,8 @@ export const labPrefix = () => { } }; -// NOTE: needs to be exported for contract files in this directory export const rpcUrl = env.PUBLIC_STELLAR_RPC_URL; -/** Set to true when RPC URL is HTTP (e.g. local dev). Pass as allowHttp to Soroban Contract Client. */ export const allowHttpForSoroban = typeof rpcUrl === "string" && rpcUrl.startsWith("http:"); diff --git a/apps/web-app/src/lib/constants/toast.config.ts b/apps/web-app/src/lib/constants/toast.config.ts new file mode 100644 index 00000000..7340530b --- /dev/null +++ b/apps/web-app/src/lib/constants/toast.config.ts @@ -0,0 +1,23 @@ +export const TOAST_CONFIG = { + /** Toaster component (position, theme, styles) */ + provider: { + position: "top-center" as const, + theme: "dark" as const, + fill: "#1C1C1C", + titleStyle: "text-white", + descriptionStyle: "text-white/75", + }, + + viewport: { + top: "1.5rem", + left: "55%", + transform: "translate(-50%, 0)", + zIndex: 99999, + width: "480px", + descriptionColor: "rgb(255, 255, 255)", + }, + + defaultOpts: { + position: "top-center" as const, + }, +} as const; diff --git a/apps/web-app/src/lib/constants/tokenIcons.ts b/apps/web-app/src/lib/constants/tokenIcons.ts index fe11a8aa..d7214da5 100644 --- a/apps/web-app/src/lib/constants/tokenIcons.ts +++ b/apps/web-app/src/lib/constants/tokenIcons.ts @@ -1,21 +1,8 @@ -/** Maps Stellar token symbols to their icon paths */ -export const STELLAR_TOKEN_ICON_MAP: Record = { - XLM: "/assets/xlm-logo.png", - USDC: "/assets/usdc-logo.png", - USTRY: "/assets/ustry-logo.png", - TESOURO: "/assets/tesouro-logo.png", - CETES: "/assets/cetes-logo.png", - USDY: "/assets/usdy-logo.png", - PYUSD: "/assets/pyusd-logo.png", -}; +import { + getTokenIconMap, + getContractToCodeMap, +} from "@/lib/constants/assets.config"; -/** Hardcoded Stellar contract address → symbol fallback (used when getAvailableTokens fails) */ -export const STELLAR_FALLBACK_CONTRACTS: Record = { - CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC: "XLM", - CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA: "USDC", - CC6SODKGOTFEDWVNPR6ESJC3GL7NC5Y4DVFKYGATZJ74F2YXHTW4RJ6D: "USTRY", - CA55OO3U556GXABJKDYP3QCGZ6AFNZPB27TROYP42AQPFFYPKU5EDOUH: "TESOURO", - CCGWKS4GLAGPYIAOLBH6JM5RKUPMUCN47VRAEXAJWJFKXSXQ33VIRUAA: "CETES", - CBVLFSVBZGHVAH6CV4JQYPBJSX75VFR2NJC7CQX7QKQ7KOLGQZOZAGQK: "USDY", - CBCB5UDYZENTIUVVA7SHQOCVVCDXDMHEKJHHFM3OQKU2E5CAF2B62TIO: "PYUSD", -}; +export const STELLAR_TOKEN_ICON_MAP = getTokenIconMap(); + +export const STELLAR_FALLBACK_CONTRACTS = getContractToCodeMap(); diff --git a/apps/web-app/src/lib/constants/wallet.ts b/apps/web-app/src/lib/constants/wallet.ts index 41bfeae0..2e1fc966 100644 --- a/apps/web-app/src/lib/constants/wallet.ts +++ b/apps/web-app/src/lib/constants/wallet.ts @@ -1,11 +1,8 @@ -/** - * Wallet and price-related constants shared across wallet provider and price services. - */ +import { getRwaTokenCodes } from "./assets.config"; -/** RWA token codes that use the oracle for pricing */ -export const RWA_TOKENS = ["USTRY", "TESOURO", "CETES", "USDY", "PYUSD"]; +export const RWA_TOKENS = getRwaTokenCodes(); +export const STABLECOIN_FALLBACK_USD = 1; -/** LocalStorage keys used by WalletProvider for Stellar wallet state */ export const STORAGE_KEYS = { walletId: "walletId", walletAddress: "walletAddress", @@ -13,8 +10,5 @@ export const STORAGE_KEYS = { networkPassphrase: "networkPassphrase", } as const; -/** Interval (ms) for polling Stellar wallet state in WalletProvider */ export const POLL_INTERVAL = 1000; - -/** Delay (ms) before resolving with error when price fetch fails; keeps loading state visible briefly. */ export const PRICE_ERROR_DELAY_MS = 800; diff --git a/apps/web-app/src/lib/helpers/addressUtils.ts b/apps/web-app/src/lib/helpers/addressUtils.ts index a5a027e4..e72dc0d6 100644 --- a/apps/web-app/src/lib/helpers/addressUtils.ts +++ b/apps/web-app/src/lib/helpers/addressUtils.ts @@ -1,22 +1,8 @@ -/** - * Address and network display helpers. - */ - -/** - * Truncate an address (or any string) with ellipsis. - * @param value - Full address string - * @param start - Number of characters to show at the start (default 4) - * @param end - Number of characters to show at the end (default 4) - */ export function truncateAddress(value: string, start = 4, end = 4): string { if (value.length <= start + end) return value; return `${value.slice(0, start)}…${value.slice(-end)}`; } -/** - * Format network name for display. - * STANDALONE (deprecated) is shown as "Local"; otherwise first letter uppercase, rest lowercase. - */ export function formatNetworkName(name: string): string { return name === "STANDALONE" ? "Local" diff --git a/apps/web-app/src/lib/helpers/formatUtils.ts b/apps/web-app/src/lib/helpers/formatUtils.ts index cd8f040d..e5515222 100644 --- a/apps/web-app/src/lib/helpers/formatUtils.ts +++ b/apps/web-app/src/lib/helpers/formatUtils.ts @@ -1,6 +1,3 @@ -/** - * NaN-safe parse: returns 0 for NaN, null, undefined, or invalid input. - */ export function safeParseFloat( value: string | number | null | undefined ): number { @@ -11,22 +8,36 @@ export function safeParseFloat( return Number.isFinite(parsed) ? parsed : 0; } -/** - * Safely parses a balance string (may contain commas) to a number. - * Returns 0 if the string is empty, null, undefined, or not a valid number. - */ export function parseBalance(balance: string | null | undefined): number { return safeParseFloat(balance); } -/** - * Formats a numeric value into a human-readable liquidity string. - * Accepts number or string; treats NaN/invalid as 0. - * e.g. 1500000 → "$1.50M", 2500 → "$2.50k", 42 → "$42.00" - */ export function formatLiquidity(value: number | string): string { const num = safeParseFloat(value); if (num >= 1_000_000) return `$${(num / 1_000_000).toFixed(2)}M`; if (num >= 1_000) return `$${(num / 1_000).toFixed(2)}k`; return `$${num.toFixed(2)}`; } + +/** + * Format a token amount for display, trimming trailing zeros and adapting + * decimal places to the magnitude of the value. + * + * Examples: + * 1.0000000 → "1" + * 0.0100000 → "0.01" + * 100.12300 → "100.123" + * 0.0000015 → "0.0000015" + */ +export function formatAmount(value: number | string): string { + const num = typeof value === "string" ? parseFloat(value) : value; + if (!Number.isFinite(num) || num === 0) return "0"; + + if (num >= 1_000_000) return `${parseFloat((num / 1_000_000).toFixed(2))}M`; + if (num >= 1_000) + return num.toLocaleString("en-US", { maximumFractionDigits: 2 }); + // For numbers >= 0.001 show up to 4 decimals, trim trailing zeros + if (num >= 0.001) return parseFloat(num.toFixed(4)).toString(); + // Very small: show up to 7 significant decimals, trim trailing zeros + return parseFloat(num.toFixed(7)).toString(); +} diff --git a/apps/web-app/src/lib/helpers/lendingUtils.ts b/apps/web-app/src/lib/helpers/lendingUtils.ts index e29e382e..567e97af 100644 --- a/apps/web-app/src/lib/helpers/lendingUtils.ts +++ b/apps/web-app/src/lib/helpers/lendingUtils.ts @@ -1,9 +1,3 @@ -/** - * Shared lending/borrowing utilities - * Used by useBorrowPools, useLendingPools, and other lending-related code - */ - -/** Contract result shape for get_interest_rate (Result) */ type InterestRateResult = | { tag?: string; values?: unknown[]; unwrap?: () => bigint } | bigint @@ -12,10 +6,6 @@ type InterestRateResult = | null | undefined; -/** - * Parse interest rate from RWA lending contract get_interest_rate result. - * Rate is stored as basis points (e.g. 213 = 2.13%). Returns percentage. - */ export function parseInterestRateFromContractResult( interestRateResult: InterestRateResult ): number { @@ -34,9 +24,7 @@ export function parseInterestRateFromContractResult( try { const unwrapped = result.unwrap(); rateValue = Number(unwrapped); - } catch { - // unwrap() failed, try other methods - } + } catch {} } else if ( result.tag === "Ok" && Array.isArray(result.values) && @@ -52,7 +40,5 @@ export function parseInterestRateFromContractResult( rateValue = parseInt(interestRateResult, 10); } - // Contract stores rate with 7 decimals (SCALAR_7 = 10_000_000) - // e.g. 100_000 = 1%, so to get percentage: value / SCALAR_7 * 100 = value / 100_000 return rateValue / 100_000; } diff --git a/apps/web-app/src/lib/helpers/stellar/contract.ts b/apps/web-app/src/lib/helpers/stellar/contract.ts index 40c7e41f..9b9eea0c 100644 --- a/apps/web-app/src/lib/helpers/stellar/contract.ts +++ b/apps/web-app/src/lib/helpers/stellar/contract.ts @@ -1,8 +1,3 @@ -/** - * Shortens a contract ID string by keeping the first `prefixLength` characters, - * an ellipsis, then the last `suffixLength` characters. - * If the ID is shorter than or equal to `prefixLength + suffixLength`, returns it unchanged. - */ export function shortenContractId( id: string, prefixLength = 5, diff --git a/apps/web-app/src/lib/helpers/stellar/contractErrors.ts b/apps/web-app/src/lib/helpers/stellar/contractErrors.ts index 0ea7e241..0c9f53a7 100644 --- a/apps/web-app/src/lib/helpers/stellar/contractErrors.ts +++ b/apps/web-app/src/lib/helpers/stellar/contractErrors.ts @@ -1,8 +1,3 @@ -/** - * Contract Error Utilities - * Extracts and formats error messages from Soroban contract errors - */ - import { CONTRACT_ERRORS, ContractErrorCode, @@ -10,10 +5,6 @@ import { getContractError, } from "@/lib/constants/contracts"; -/** - * Try to extract contract name from error string - * Looks for contract identifiers in the error message - */ function extractContractNameFromError(errorString: string): string | null { const contractPatterns = [ { pattern: /rwa-lending/i, name: "rwa-lending" }, @@ -35,12 +26,6 @@ function extractContractNameFromError(errorString: string): string | null { return null; } -/** - * Extract a user-friendly error message from a contract error - * @param error - The error object from a failed contract call - * @param contractName - Optional contract name to narrow down error lookup - * @returns A user-friendly error message - */ export function extractContractError( error: unknown, contractName?: string @@ -49,7 +34,6 @@ export function extractContractError( return "An unknown error occurred"; } - // Handle object errors more carefully let errorString: string; if (typeof error === "object") { const errorObj = error as Record; @@ -58,7 +42,6 @@ export function extractContractError( } else if (errorObj.name && errorObj.message) { errorString = `${String(errorObj.name)}: ${String(errorObj.message)}`; } else { - // Fallback: try to stringify but avoid [object Object] try { const stringified = JSON.stringify(error); errorString = @@ -73,13 +56,10 @@ export function extractContractError( errorString = String(error); } - // Try to extract error code from Soroban contract error format - // Format: "Error(Contract, #)" const contractErrorMatch = errorString.match(/Error\(Contract,\s*#(\d+)\)/); if (contractErrorMatch) { const errorCode = parseInt(contractErrorMatch[1], 10); - // Try contract-specific lookup first if contract name is provided or can be inferred const inferredContract = contractName || extractContractNameFromError(errorString); if (inferredContract) { @@ -89,7 +69,6 @@ export function extractContractError( } } - // Fall back to flattened lookup (backward compatibility) if (isValidErrorCode(errorCode)) { return CONTRACT_ERRORS[errorCode].message; } @@ -97,15 +76,12 @@ export function extractContractError( return `Contract error #${errorCode}`; } - // Try to extract from HostError format - // Format: "HostError: Error(Contract, #)" const hostErrorMatch = errorString.match( /HostError:.*Error\(Contract,\s*#(\d+)\)/ ); if (hostErrorMatch) { const errorCode = parseInt(hostErrorMatch[1], 10); - // Try contract-specific lookup first const inferredContract = contractName || extractContractNameFromError(errorString); if (inferredContract) { @@ -115,7 +91,6 @@ export function extractContractError( } } - // Fall back to flattened lookup if (isValidErrorCode(errorCode)) { return CONTRACT_ERRORS[errorCode].message; } @@ -123,14 +98,11 @@ export function extractContractError( return `Contract error #${errorCode}`; } - // Check for simulation failure if (errorString.includes("simulation failed")) { - // Try to extract the inner error const innerMatch = errorString.match(/Error\(Contract,\s*#(\d+)\)/); if (innerMatch) { const errorCode = parseInt(innerMatch[1], 10); - // Try contract-specific lookup const inferredContract = contractName || extractContractNameFromError(errorString); if (inferredContract) { @@ -140,7 +112,6 @@ export function extractContractError( } } - // Fall back to flattened lookup if (isValidErrorCode(errorCode)) { return CONTRACT_ERRORS[errorCode].message; } @@ -148,7 +119,6 @@ export function extractContractError( return "Transaction simulation failed. Please check your inputs and try again."; } - // Check for common wallet errors if ( errorString.toLowerCase().includes("user rejected") || errorString.toLowerCase().includes("user denied") || @@ -171,14 +141,11 @@ export function extractContractError( return "Network error. Please check your connection and try again."; } - // Return a sanitized version of the error message - // Remove technical details that might confuse users const cleanedError = errorString .replace(/Error:/gi, "") .replace(/\s+/g, " ") .trim(); - // If the error is too long or technical, return a generic message if ( cleanedError.length > 150 || cleanedError.includes("0x") || @@ -187,7 +154,6 @@ export function extractContractError( return "Transaction failed. Please try again or contact support if the issue persists."; } - // Final safety check: ensure we never return something that would display as "[object Object]" const finalError = cleanedError || "An unexpected error occurred"; if (finalError === "[object Object]" || finalError.includes("[object ")) { return "Transaction failed. Please try again."; @@ -196,11 +162,6 @@ export function extractContractError( return finalError; } -/** - * Get the error code name from a contract error - * @param error - The error object from a failed contract call - * @returns The error code name or null if not found - */ export function getContractErrorCode(error: unknown): string | null { if (!error) return null; @@ -217,12 +178,6 @@ export function getContractErrorCode(error: unknown): string | null { return null; } -/** - * Check if an error is a specific contract error - * @param error - The error object from a failed contract call - * @param errorCode - The error code to check for - * @returns True if the error matches the specified code - */ export function isContractError( error: unknown, errorCode: ContractErrorCode @@ -240,17 +195,11 @@ export function isContractError( return false; } -/** - * Check if an error is a user cancellation/rejection - * @param error - The error object from a failed transaction - * @returns True if the error was caused by user cancellation - */ export function isUserCancellationError(error: unknown): boolean { if (!error) return false; const errorString = String(error).toLowerCase(); - // Common wallet rejection patterns const cancellationPatterns = [ "user rejected", "user denied", @@ -264,7 +213,7 @@ export function isUserCancellationError(error: unknown): boolean { "request rejected", "transaction rejected", "signature rejected", - // Wallet-specific error codes/messages + "4001", // MetaMask user rejection code "-32000", // Generic user rejection "-32603", // Internal error that might be user cancellation @@ -273,13 +222,6 @@ export function isUserCancellationError(error: unknown): boolean { return cancellationPatterns.some((pattern) => errorString.includes(pattern)); } -/** - * Extract error message, returning null for user cancellations - * Useful for notification systems where user cancellation shouldn't trigger a notification - * @param error - The error object from a failed contract call - * @param contractName - Optional contract name to narrow down error lookup - * @returns A user-friendly error message, or null if user cancelled - */ export function extractContractErrorOrNull( error: unknown, contractName?: string diff --git a/apps/web-app/src/lib/helpers/stellar/freighter.ts b/apps/web-app/src/lib/helpers/stellar/freighter.ts new file mode 100644 index 00000000..5a06c54c --- /dev/null +++ b/apps/web-app/src/lib/helpers/stellar/freighter.ts @@ -0,0 +1,19 @@ +import { addToken } from "@stellar/freighter-api"; +import { getFaucetTokens } from "@/lib/constants/faucet"; + +/** + * Adds all faucet tokens to Freighter so the user can see and use them. + * Each token triggers a Freighter popup for the user to confirm. + * Call after a successful mint so the user can use the tokens from the wallet. + */ +export async function addFaucetTokensToFreighter( + networkPassphrase: string +): Promise { + const tokens = getFaucetTokens(); + for (const t of tokens) { + await addToken({ + contractId: t.contractId, + networkPassphrase, + }); + } +} diff --git a/apps/web-app/src/lib/helpers/stellar/friendbot.ts b/apps/web-app/src/lib/helpers/stellar/friendbot.ts index abe951e8..36538c3c 100644 --- a/apps/web-app/src/lib/helpers/stellar/friendbot.ts +++ b/apps/web-app/src/lib/helpers/stellar/friendbot.ts @@ -1,10 +1,8 @@ import { stellarNetwork } from "../../constants/network"; -// Utility to get the correct Friendbot URL based on environment export function getFriendbotUrl(address: string) { switch (stellarNetwork) { case "LOCAL": - // Use proxy in development for local return `/friendbot?addr=${address}`; case "FUTURENET": return `https://friendbot-futurenet.stellar.org/?addr=${address}`; diff --git a/apps/web-app/src/lib/helpers/stellar/lending.ts b/apps/web-app/src/lib/helpers/stellar/lending.ts index dc626eb8..5d2ca615 100644 --- a/apps/web-app/src/lib/helpers/stellar/lending.ts +++ b/apps/web-app/src/lib/helpers/stellar/lending.ts @@ -1,7 +1,3 @@ -/** - * Utility functions for lending operations (deposit, withdraw) - */ - import { Contract, Address, @@ -22,9 +18,6 @@ import { import { toSmallestUnit } from "../tokenUtils"; import { extractContractError } from "./contractErrors"; -/** - * Approve token contract to spend tokens on behalf of the user - */ export const approveToken = async ( tokenContractAddress: string, spenderAddress: string, @@ -39,23 +32,16 @@ export const approveToken = async ( const horizonServer = new Horizon.Server(horizonUrl); const tokenContract = new Contract(tokenContractAddress); - // Get current ledger to calculate expiration const latestLedger = await sorobanServer.getLatestLedger(); const currentLedger = latestLedger.sequence; - // Calculate expiration ledger: current + ~30 days - // Stellar ledgers occur approximately every 5 seconds - // 30 days = 30 * 24 * 60 * 60 / 5 = 518,400 ledgers - // Use a safe value: current + 500,000 ledgers (~29 days) const expirationLedger = Math.min( currentLedger + 500000, 2147483647 // Max safe u32 value (but contract may have lower limit) ); - // Convert amount to smallest unit const amountInSmallestUnit = BigInt(toSmallestUnit(amount, decimals)); - // Call approve(from: Address, spender: Address, amount: i128, expiration_ledger: u32) const operation = tokenContract.call( "approve", new Address(walletAddress).toScVal(), @@ -64,10 +50,8 @@ export const approveToken = async ( nativeToScVal(expirationLedger, { type: "u32" }) ); - // Get account for transaction const account = await horizonServer.loadAccount(walletAddress); - // Build transaction const transaction = new TransactionBuilder(account, { fee: "100", networkPassphrase: networkPassphrase, @@ -76,7 +60,6 @@ export const approveToken = async ( .setTimeout(300) .build(); - // Return XDR for signing return transaction.toXDR(); } catch (error) { console.error("Error building approve transaction:", error); @@ -85,29 +68,24 @@ export const approveToken = async ( } }; -/** - * Deposit tokens to the lending pool - */ export const depositToPool = async ( assetCode: string, amount: string, decimals: number = 7, - walletAddress: string + walletAddress: string, + contractId: string = networks.testnet.contractId ): Promise => { try { const sorobanServer = new rpc.Server(rpcUrl, { allowHttp: stellarNetwork === "LOCAL", }); const horizonServer = new Horizon.Server(horizonUrl); - const lendingContract = new Contract(networks.testnet.contractId); + const lendingContract = new Contract(contractId); - // Convert amount to smallest unit (i128) const amountInSmallestUnit = BigInt(toSmallestUnit(amount, decimals)); - // Convert assetCode to Symbol (ScVal) const assetSymbol = xdr.ScVal.scvSymbol(assetCode); - // Call deposit(lender: Address, asset: Symbol, amount: i128) const operation = lendingContract.call( "deposit", new Address(walletAddress).toScVal(), @@ -115,10 +93,8 @@ export const depositToPool = async ( nativeToScVal(amountInSmallestUnit, { type: "i128" }) ); - // Get account for transaction const account = await horizonServer.loadAccount(walletAddress); - // Build transaction const transaction = new TransactionBuilder(account, { fee: "100", networkPassphrase: networkPassphrase, @@ -127,11 +103,9 @@ export const depositToPool = async ( .setTimeout(300) .build(); - // Simulate to get footprint and resource limits (ignore auth errors) try { await sorobanServer.simulateTransaction(transaction); } catch (simError) { - // Auth errors during simulation are expected since transaction isn't signed yet const errorMessage = simError instanceof Error ? simError.message : String(simError); if ( @@ -139,21 +113,17 @@ export const depositToPool = async ( !errorMessage.includes("require_auth") && !errorMessage.includes("InvalidAction") ) { - // If it's not an auth error, extract and throw a user-friendly message const friendlyError = extractContractError(simError, "rwa-lending"); throw new Error(friendlyError); } - // Otherwise, continue - auth will be checked when transaction is signed } - // Prepare the transaction with the simulation results const preparedTx = await sorobanServer.prepareTransaction(transaction); - // Return the prepared XDR for signing return preparedTx.toXDR(); } catch (error) { console.error("Error building deposit transaction:", error); - // If error is already a user-friendly message, re-throw it + if ( error instanceof Error && error.message && @@ -161,35 +131,30 @@ export const depositToPool = async ( ) { throw error; } - // Otherwise, extract contract error + const friendlyError = extractContractError(error, "rwa-lending"); throw new Error(friendlyError); } }; -/** - * Withdraw tokens from the lending pool - */ export const withdrawFromPool = async ( assetCode: string, bTokens: string, decimals: number = 7, - walletAddress: string + walletAddress: string, + contractId: string = networks.testnet.contractId ): Promise => { try { const sorobanServer = new rpc.Server(rpcUrl, { allowHttp: stellarNetwork === "LOCAL", }); const horizonServer = new Horizon.Server(horizonUrl); - const lendingContract = new Contract(networks.testnet.contractId); + const lendingContract = new Contract(contractId); - // Convert bTokens to smallest unit (i128) const bTokensInSmallestUnit = BigInt(toSmallestUnit(bTokens, decimals)); - // Convert assetCode to Symbol (ScVal) const assetSymbol = xdr.ScVal.scvSymbol(assetCode); - // Call withdraw(lender: Address, asset: Symbol, b_tokens: i128) const operation = lendingContract.call( "withdraw", new Address(walletAddress).toScVal(), @@ -197,10 +162,8 @@ export const withdrawFromPool = async ( nativeToScVal(bTokensInSmallestUnit, { type: "i128" }) ); - // Get account for transaction const account = await horizonServer.loadAccount(walletAddress); - // Build transaction const transaction = new TransactionBuilder(account, { fee: "100", networkPassphrase: networkPassphrase, @@ -209,11 +172,9 @@ export const withdrawFromPool = async ( .setTimeout(300) .build(); - // Simulate to get footprint and resource limits (ignore auth errors) try { await sorobanServer.simulateTransaction(transaction); } catch (simError) { - // Auth errors during simulation are expected since transaction isn't signed yet const errorMessage = simError instanceof Error ? simError.message : String(simError); if ( @@ -221,17 +182,13 @@ export const withdrawFromPool = async ( !errorMessage.includes("require_auth") && !errorMessage.includes("InvalidAction") ) { - // If it's not an auth error, extract and throw a user-friendly message const friendlyError = extractContractError(simError, "rwa-lending"); throw new Error(friendlyError); } - // Otherwise, continue - auth will be checked when transaction is signed } - // Prepare the transaction with the simulation results const preparedTx = await sorobanServer.prepareTransaction(transaction); - // Return the prepared XDR for signing return preparedTx.toXDR(); } catch (error) { console.error("Error building withdraw transaction:", error); @@ -247,26 +204,22 @@ export const withdrawFromPool = async ( } }; -/** - * Add collateral to the lending pool for borrowing - */ export const addCollateral = async ( rwaTokenAddress: string, amount: string, decimals: number = 7, - walletAddress: string + walletAddress: string, + contractId: string = networks.testnet.contractId ): Promise => { try { const sorobanServer = new rpc.Server(rpcUrl, { allowHttp: stellarNetwork === "LOCAL", }); const horizonServer = new Horizon.Server(horizonUrl); - const lendingContract = new Contract(networks.testnet.contractId); + const lendingContract = new Contract(contractId); - // Convert amount to smallest unit (i128) const amountInSmallestUnit = BigInt(toSmallestUnit(amount, decimals)); - // Call add_collateral(borrower: Address, rwa_token: Address, amount: i128) const operation = lendingContract.call( "add_collateral", new Address(walletAddress).toScVal(), @@ -274,10 +227,8 @@ export const addCollateral = async ( nativeToScVal(amountInSmallestUnit, { type: "i128" }) ); - // Get account for transaction const account = await horizonServer.loadAccount(walletAddress); - // Build transaction const transaction = new TransactionBuilder(account, { fee: "100", networkPassphrase: networkPassphrase, @@ -286,7 +237,6 @@ export const addCollateral = async ( .setTimeout(300) .build(); - // Simulate to get footprint and resource limits try { await sorobanServer.simulateTransaction(transaction); } catch (simError) { @@ -302,10 +252,8 @@ export const addCollateral = async ( } } - // Prepare the transaction with the simulation results const preparedTx = await sorobanServer.prepareTransaction(transaction); - // Return the prepared XDR for signing return preparedTx.toXDR(); } catch (error) { console.error("Error building add_collateral transaction:", error); @@ -321,29 +269,24 @@ export const addCollateral = async ( } }; -/** - * Borrow tokens from the lending pool - */ export const borrowFromPool = async ( assetCode: string, amount: string, decimals: number = 7, - walletAddress: string + walletAddress: string, + contractId: string = networks.testnet.contractId ): Promise => { try { const sorobanServer = new rpc.Server(rpcUrl, { allowHttp: stellarNetwork === "LOCAL", }); const horizonServer = new Horizon.Server(horizonUrl); - const lendingContract = new Contract(networks.testnet.contractId); + const lendingContract = new Contract(contractId); - // Convert amount to smallest unit (i128) const amountInSmallestUnit = BigInt(toSmallestUnit(amount, decimals)); - // Convert assetCode to Symbol (ScVal) const assetSymbol = xdr.ScVal.scvSymbol(assetCode); - // Call borrow(borrower: Address, asset: Symbol, amount: i128) const operation = lendingContract.call( "borrow", new Address(walletAddress).toScVal(), @@ -351,10 +294,8 @@ export const borrowFromPool = async ( nativeToScVal(amountInSmallestUnit, { type: "i128" }) ); - // Get account for transaction const account = await horizonServer.loadAccount(walletAddress); - // Build transaction const transaction = new TransactionBuilder(account, { fee: "100", networkPassphrase: networkPassphrase, @@ -363,7 +304,6 @@ export const borrowFromPool = async ( .setTimeout(300) .build(); - // Simulate to get footprint and resource limits try { await sorobanServer.simulateTransaction(transaction); } catch (simError) { @@ -379,10 +319,8 @@ export const borrowFromPool = async ( } } - // Prepare the transaction with the simulation results const preparedTx = await sorobanServer.prepareTransaction(transaction); - // Return the prepared XDR for signing return preparedTx.toXDR(); } catch (error) { console.error("Error building borrow transaction:", error); @@ -399,16 +337,102 @@ export const borrowFromPool = async ( }; /** - * Get bToken balance for a user + * Get bToken balance for a lender (raw bigint, 7 Stellar decimals) + */ +export const getBTokenBalanceRaw = async ( + assetCode: string, + walletAddress: string, + contractId: string = networks.testnet.contractId +): Promise => { + try { + const client = new RwaLendingClient({ + contractId, + rpcUrl: rpcUrl, + networkPassphrase: networkPassphrase, + ...(allowHttpForSoroban && { allowHttp: true }), + }); + + const tx = await client.get_b_token_balance( + { lender: walletAddress, asset: assetCode }, + { simulate: true } + ); + + const value = tx.result; + if (!value) return 0n; + + return typeof value === "bigint" ? value : BigInt(String(value)); + } catch (error) { + console.error("Error getting bToken balance (raw):", error); + return 0n; + } +}; + +/** + * Get dToken balance for a borrower (raw dTokens, not actual debt) + */ +export const getDTokenBalance = async ( + assetCode: string, + walletAddress: string +): Promise => { + try { + const client = new RwaLendingClient({ + contractId: networks.testnet.contractId, + rpcUrl: rpcUrl, + networkPassphrase: networkPassphrase, + ...(allowHttpForSoroban && { allowHttp: true }), + }); + + const tx = await client.get_d_token_balance( + { borrower: walletAddress, asset: assetCode }, + { simulate: true } + ); + + const value = tx.result; + if (!value) return 0n; + + return typeof value === "bigint" ? value : BigInt(String(value)); + } catch (error) { + console.error("Error getting dToken balance:", error); + return 0n; + } +}; + +/** + * Get dToken → underlying conversion rate (12-decimal scalar) */ +export const getDTokenRate = async (assetCode: string): Promise => { + try { + const client = new RwaLendingClient({ + contractId: networks.testnet.contractId, + rpcUrl: rpcUrl, + networkPassphrase: networkPassphrase, + ...(allowHttpForSoroban && { allowHttp: true }), + }); + + const tx = await client.get_d_token_rate( + { asset: assetCode }, + { simulate: true } + ); + + const value = tx.result; + if (!value) return 0n; + + return typeof value === "bigint" ? value : BigInt(String(value)); + } catch (error) { + console.error("Error getting dToken rate:", error); + return 0n; + } +}; + export const getBTokenBalance = async ( assetCode: string, walletAddress: string, - decimals: number = 7 + decimals: number = 7, + contractId: string = networks.testnet.contractId ): Promise => { try { const client = new RwaLendingClient({ - contractId: networks.testnet.contractId, + contractId, rpcUrl: rpcUrl, networkPassphrase: networkPassphrase, ...(allowHttpForSoroban && { allowHttp: true }), @@ -427,7 +451,6 @@ export const getBTokenBalance = async ( return "0"; } - // Convert from smallest unit to human-readable const balanceStr = typeof balanceValue === "bigint" ? balanceValue.toString() diff --git a/apps/web-app/src/lib/helpers/stellar/sorobanBalance.ts b/apps/web-app/src/lib/helpers/stellar/sorobanBalance.ts new file mode 100644 index 00000000..f11eb82d --- /dev/null +++ b/apps/web-app/src/lib/helpers/stellar/sorobanBalance.ts @@ -0,0 +1,66 @@ +import { + Contract, + Address, + rpc, + scValToNative, + TransactionBuilder, + Horizon, +} from "@stellar/stellar-sdk"; +import { rpcUrl, networkPassphrase, horizonUrl } from "@/lib/constants/network"; +import { fromSmallestUnit } from "@/lib/helpers/tokenUtils"; + +export async function getTokenBalanceFromContract( + contractAddress: string, + walletAddress: string, + decimals: number = 7 +): Promise { + try { + const sorobanServer = new rpc.Server(rpcUrl, { allowHttp: true }); + const horizonServer = new Horizon.Server(horizonUrl); + const contract = new Contract(contractAddress); + + const operation = contract.call( + "balance", + new Address(walletAddress).toScVal() + ); + + let account; + try { + account = await horizonServer.loadAccount(walletAddress); + } catch (err) { + console.warn( + `[sorobanBalance] Account ${walletAddress} not found for balance query:`, + err + ); + return "0"; + } + + const transaction = new TransactionBuilder(account, { + fee: "100", + networkPassphrase: networkPassphrase, + }) + .addOperation(operation) + .setTimeout(300) + .build(); + + const simulated = await sorobanServer.simulateTransaction(transaction); + const simulatedResult = simulated as + | { result?: { retval?: unknown } } + | null + | undefined; + const retval = simulatedResult?.result?.retval; + if (!retval) return "0"; + + const balanceValue = scValToNative( + retval as Parameters[0] + ); + const balanceBigInt = BigInt(balanceValue as string); + return fromSmallestUnit(balanceBigInt.toString(), decimals); + } catch (err) { + console.warn( + `[sorobanBalance] Failed to fetch balance for contract ${contractAddress}:`, + err + ); + return "0"; + } +} diff --git a/apps/web-app/src/lib/helpers/stellar/sorobanBalances.ts b/apps/web-app/src/lib/helpers/stellar/sorobanBalances.ts new file mode 100644 index 00000000..0af8819c --- /dev/null +++ b/apps/web-app/src/lib/helpers/stellar/sorobanBalances.ts @@ -0,0 +1,99 @@ +import { + Contract, + Address, + rpc, + scValToNative, + TransactionBuilder, + Horizon, +} from "@stellar/stellar-sdk"; +import { getFaucetTokens } from "@/lib/constants/faucet"; +import { fromSmallestUnit } from "@/lib/helpers/tokenUtils"; + +export interface SorobanTokenBalance { + contractId: string; + symbol: string; + decimals: number; + balance: string; +} + +async function getContractBalance( + sorobanServer: rpc.Server, + horizonServer: Horizon.Server, + contractId: string, + walletAddress: string, + decimals: number, + passphrase: string +): Promise { + const contract = new Contract(contractId); + const operation = contract.call( + "balance", + new Address(walletAddress).toScVal() + ); + + let account; + try { + account = await horizonServer.loadAccount(walletAddress); + } catch { + return "0"; + } + + const tx = new TransactionBuilder(account, { + fee: "100", + networkPassphrase: passphrase, + }) + .addOperation(operation) + .setTimeout(300) + .build(); + + const simulated = await sorobanServer.simulateTransaction(tx); + const result = simulated as { result?: { retval?: unknown } } | null; + const retval = result?.result?.retval; + if (!retval) return "0"; + + const raw = scValToNative(retval as Parameters[0]); + return fromSmallestUnit(BigInt(raw as string).toString(), decimals); +} + +/** + * Fetch balances for all faucet tokens by simulating balance() calls on each contract. + */ +export async function fetchSorobanTokenBalances( + walletAddress: string, + sorobanRpcUrl: string, + stellarHorizonUrl: string, + passphrase: string +): Promise { + const tokens = getFaucetTokens(); + const sorobanServer = new rpc.Server(sorobanRpcUrl, { allowHttp: true }); + const horizonServer = new Horizon.Server(stellarHorizonUrl); + + const results = await Promise.allSettled( + tokens.map(async (t) => { + const balance = await getContractBalance( + sorobanServer, + horizonServer, + t.contractId, + walletAddress, + t.decimals, + passphrase + ); + return { + contractId: t.contractId, + symbol: t.symbol, + decimals: t.decimals, + balance, + }; + }) + ); + + return results.map((r, i) => + r.status === "fulfilled" + ? r.value + : { + contractId: tokens[i].contractId, + symbol: tokens[i].symbol, + decimals: tokens[i].decimals, + balance: "0", + } + ); +} diff --git a/apps/web-app/src/lib/helpers/stellar/soroswap/index.ts b/apps/web-app/src/lib/helpers/stellar/soroswap/index.ts index 6435ed28..5e270e61 100644 --- a/apps/web-app/src/lib/helpers/stellar/soroswap/index.ts +++ b/apps/web-app/src/lib/helpers/stellar/soroswap/index.ts @@ -1,14 +1,3 @@ -/** - * Soroswap helpers barrel - * Re-exports all public API for backwards compatibility. - * - * Internal modules: - * tokens.ts — token registry and lookup - * utils.ts — SDK management, API key, formatting helpers - * quotes.ts — quote, pool, build, send, and liquidity functions - */ - -// Types export type { Token, QuoteRequest, @@ -23,7 +12,6 @@ export type { GetPoolRequest, } from "../../../types/soroswapTypes"; -// Token registry export { getAvailableTokens, getTokens, @@ -31,7 +19,6 @@ export { TOKENS, } from "./tokens"; -// Utilities export { getApiKey, setApiKey, @@ -41,7 +28,6 @@ export { getTokenExplorerUrl, } from "./utils"; -// Quotes, pools, transactions, and liquidity export { getPool, getQuote, diff --git a/apps/web-app/src/lib/helpers/stellar/soroswap/quotes.ts b/apps/web-app/src/lib/helpers/stellar/soroswap/quotes.ts index 0094f10b..ab4f5664 100644 --- a/apps/web-app/src/lib/helpers/stellar/soroswap/quotes.ts +++ b/apps/web-app/src/lib/helpers/stellar/soroswap/quotes.ts @@ -24,13 +24,6 @@ import type { const SOROSWAP_API_URL = "https://api.soroswap.finance"; -// ======================================== -// POOL FUNCTIONS -// ======================================== - -/** - * Get pool information for two tokens using Soroswap API - */ export const getPool = async (request: GetPoolRequest): Promise => { const tokenA = formatTokenForAPI(request.tokenA); const tokenB = formatTokenForAPI(request.tokenB); @@ -103,13 +96,6 @@ export const getPool = async (request: GetPoolRequest): Promise => { } }; -// ======================================== -// QUOTE / SWAP FUNCTIONS -// ======================================== - -/** - * Get a quote for a swap using Soroswap SDK - */ export const getQuote = async ( request: QuoteRequest ): Promise => { @@ -344,9 +330,7 @@ export const getQuote = async ( }); } } - } catch { - // Keep default message - } + } catch {} const errorStr = errorMessage.toLowerCase(); @@ -395,13 +379,6 @@ export const getQuote = async ( } }; -// ======================================== -// TRANSACTION BUILD / SEND -// ======================================== - -/** - * Build a swap transaction XDR from a quote - */ export const buildTransaction = async ( request: BuildRequest ): Promise => { @@ -417,7 +394,6 @@ export const buildTransaction = async ( const buildResponse = await soroswapSDK.build( { - // eslint-disable-next-line @typescript-eslint/no-explicit-any quote: sdkQuote as any, from: request.from, }, @@ -440,9 +416,6 @@ export const buildTransaction = async ( } }; -/** - * Send a signed transaction using Soroswap SDK - */ export const sendTransaction = async ( request: SendRequest ): Promise => { @@ -485,13 +458,6 @@ export const sendTransaction = async ( } }; -// ======================================== -// LIQUIDITY -// ======================================== - -/** - * Add liquidity to a pool using Soroswap API - */ export const addLiquidity = async ( request: AddLiquidityRequest ): Promise => { diff --git a/apps/web-app/src/lib/helpers/stellar/soroswap/tokens.ts b/apps/web-app/src/lib/helpers/stellar/soroswap/tokens.ts index 144cae16..ba1f177a 100644 --- a/apps/web-app/src/lib/helpers/stellar/soroswap/tokens.ts +++ b/apps/web-app/src/lib/helpers/stellar/soroswap/tokens.ts @@ -1,126 +1,21 @@ -import { stellarNetwork } from "../../../constants/network"; import type { Token } from "../../../types/soroswapTypes"; +import { + getAssetsConfig, + getCurrentNetworkId, + type AssetConfig, +} from "@/lib/constants/assets.config"; -// ======================================== -// NETWORK DETECTION -// ======================================== +export type TokenInfo = Pick< + AssetConfig, + "name" | "contract" | "code" | "decimals" | "icon" +>; -/** - * Get current network name - */ -export const getCurrentNetwork = (): string => { - const network = stellarNetwork?.toLowerCase() || "testnet"; +export const getCurrentNetwork = (): string => getCurrentNetworkId(); - if (network === "local" || network === "standalone") { - return "standalone"; - } - if (network === "public" || network === "mainnet") { - return "mainnet"; - } - return "testnet"; -}; - -// ======================================== -// TOKEN DEFINITIONS -// ======================================== - -interface TokenInfo { - name: string; - contract: string; - code: string; - decimals: number; - icon?: string; -} - -const TOKENS_BY_NETWORK: Record> = { - testnet: { - XLM: { - name: "Stellar Lumens", - contract: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC", - code: "XLM", - decimals: 7, - icon: "/assets/xlm-logo.png", - }, - USDC: { - name: "USD Coin", - contract: "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA", - code: "USDC", - decimals: 7, - icon: "/assets/usdc-logo.png", - }, - USTRY: { - name: "US Treasury Token", - contract: "CC6SODKGOTFEDWVNPR6ESJC3GL7NC5Y4DVFKYGATZJ74F2YXHTW4RJ6D", - code: "USTRY", - decimals: 7, - icon: "/assets/ustry-logo.png", - }, - TESOURO: { - name: "Tesouro Token", - contract: "CA55OO3U556GXABJKDYP3QCGZ6AFNZPB27TROYP42AQPFFYPKU5EDOUH", - code: "TESOURO", - decimals: 7, - icon: "/assets/tesouro-logo.png", - }, - CETES: { - name: "CETES Token", - contract: "CCGWKS4GLAGPYIAOLBH6JM5RKUPMUCN47VRAEXAJWJFKXSXQ33VIRUAA", - code: "CETES", - decimals: 7, - icon: "/assets/cetes-logo.png", - }, - USDY: { - name: "US Dollar Yield", - contract: "CBVLFSVBZGHVAH6CV4JQYPBJSX75VFR2NJC7CQX7QKQ7KOLGQZOZAGQK", - code: "USDY", - decimals: 7, - icon: "/assets/usdy-logo.png", - }, - PYUSD: { - name: "PayPal USD", - contract: "CBCB5UDYZENTIUVVA7SHQOCVVCDXDMHEKJHHFM3OQKU2E5CAF2B62TIO", - code: "PYUSD", - decimals: 7, - icon: "/assets/pyusd-logo.png", - }, - }, - standalone: { - XLM: { - name: "Stellar Lumens", - contract: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC", - code: "XLM", - decimals: 7, - icon: "/assets/xlm-logo.png", - }, - USDC: { - name: "USD Coin", - contract: "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA", - code: "USDC", - decimals: 7, - icon: "/assets/usdc-logo.png", - }, - }, - mainnet: { - // Mainnet tokens - add as needed - }, -}; - -// ======================================== -// TOKEN LOOKUP -// ======================================== - -/** - * Get available tokens for current network - */ export const getAvailableTokens = (): Record => { - const network = getCurrentNetwork(); - return TOKENS_BY_NETWORK[network] ?? TOKENS_BY_NETWORK.testnet; + return getAssetsConfig(); }; -/** - * Get token addresses for current network (dynamic based on network) - * Use this instead of the static TOKENS object to ensure network-correct addresses - */ export const getTokens = (): Record => { const tokens = getAvailableTokens(); const result: Record = {}; @@ -130,9 +25,6 @@ export const getTokens = (): Record => { return result; }; -/** - * Get token address from Token object or string - */ export const getTokenAddress = (token: Token | string): string => { if (typeof token === "string") { return token; @@ -152,8 +44,4 @@ export const getTokenAddress = (token: Token | string): string => { ); }; -/** - * Common token definitions - using correct addresses for current network - * @deprecated Use getTokens() instead for network-aware token lookup - */ export const TOKENS: Record = getTokens(); diff --git a/apps/web-app/src/lib/helpers/stellar/soroswap/utils.ts b/apps/web-app/src/lib/helpers/stellar/soroswap/utils.ts index e8eacc71..ac9c98c1 100644 --- a/apps/web-app/src/lib/helpers/stellar/soroswap/utils.ts +++ b/apps/web-app/src/lib/helpers/stellar/soroswap/utils.ts @@ -5,13 +5,6 @@ import { getCurrentNetwork, getAvailableTokens, getTokens } from "./tokens"; const SOROSWAP_API_URL = "https://api.soroswap.finance"; const DEFAULT_TIMEOUT = 50000; -// ======================================== -// API KEY MANAGEMENT -// ======================================== - -/** - * Get API key from environment or localStorage - */ export const getApiKey = (): string | null => { const envKey = process.env.NEXT_PUBLIC_SOROSWAP_API_KEY; const envKeyStr = typeof envKey === "string" ? envKey : ""; @@ -25,27 +18,14 @@ export const getApiKey = (): string | null => { return null; }; -/** - * Set API key in localStorage - */ export const setApiKey = (apiKey: string): void => { localStorage.setItem("soroswap_api_key", apiKey); }; -/** - * Check if API key is configured - */ export const hasApiKey = (): boolean => { return getApiKey() !== null; }; -// ======================================== -// SDK MANAGEMENT -// ======================================== - -/** - * Get SDK network enum from current network string - */ export const getSDKNetwork = (): SupportedNetworks => { const network = getCurrentNetwork(); const networkLower = network.toLowerCase(); @@ -59,17 +39,11 @@ export const getSDKNetwork = (): SupportedNetworks => { let sdkInstance: SoroswapSDK | null = null; let sdkNetwork: SupportedNetworks | null = null; -/** - * Invalidate SDK instance (call when network changes) - */ export const invalidateSoroswapSDK = (): void => { sdkInstance = null; sdkNetwork = null; }; -/** - * Get or create Soroswap SDK instance (singleton pattern) - */ export const getSoroswapSDK = (): SoroswapSDK => { const currentNetwork = getSDKNetwork(); @@ -97,13 +71,6 @@ export const getSoroswapSDK = (): SoroswapSDK => { return sdkInstance; }; -// ======================================== -// API REQUEST HELPER -// ======================================== - -/** - * Make API request to Soroswap REST API - */ export const makeAPIRequest = async ( endpoint: string, options: RequestInit = {} @@ -144,13 +111,6 @@ export const makeAPIRequest = async ( } }; -// ======================================== -// AMOUNT CONVERSION -// ======================================== - -/** - * Convert amount to smallest unit (stroops for XLM with 7 decimals) - */ export const toSmallestUnit = ( amount: string, decimals: number = 7 @@ -163,20 +123,10 @@ export const toSmallestUnit = ( return BigInt(Math.floor(amountFloat)); }; -// ======================================== -// FORMAT / VALIDATION HELPERS -// ======================================== - -/** - * Verify if a contract address is valid format - */ export const isValidContractAddress = (address: string): boolean => { return address.startsWith("C") && address.length === 56; }; -/** - * Get explorer URL for a token contract - */ export const getTokenExplorerUrl = ( contractAddress: string, network: string = "testnet" @@ -185,10 +135,6 @@ export const getTokenExplorerUrl = ( return `https://stellar.expert/explorer${networkParam}/contract/${contractAddress}`; }; -/** - * Format token for API request - * Assets must be contract addresses as strings - */ export const formatTokenForAPI = (token: Token | string): string => { if (typeof token === "string") { return token; diff --git a/apps/web-app/src/lib/helpers/stellar/transaction.ts b/apps/web-app/src/lib/helpers/stellar/transaction.ts index 9a96419a..21fb1327 100644 --- a/apps/web-app/src/lib/helpers/stellar/transaction.ts +++ b/apps/web-app/src/lib/helpers/stellar/transaction.ts @@ -1,7 +1,3 @@ -/** - * Reusable sign → send pattern for Stellar/Soroban transactions - */ - import { TransactionBuilder, rpc } from "@stellar/stellar-sdk"; import { stellarNetwork } from "@/lib/constants/network"; @@ -12,10 +8,6 @@ export type SignTransactionFn = ( options?: { networkPassphrase?: string; address?: string } ) => Promise<{ signedTxXdr: string }>; -/** - * Build XDR → sign with wallet → send to network. - * Returns the send result; if status is PENDING, optionally waits before resolving. - */ export async function signAndSendTransaction( xdr: string, signTransaction: SignTransactionFn, diff --git a/apps/web-app/src/lib/helpers/stellar/wallet/balances.ts b/apps/web-app/src/lib/helpers/stellar/wallet/balances.ts index 9211c855..7f0b3743 100644 --- a/apps/web-app/src/lib/helpers/stellar/wallet/balances.ts +++ b/apps/web-app/src/lib/helpers/stellar/wallet/balances.ts @@ -1,5 +1,8 @@ import { Horizon } from "@stellar/stellar-sdk"; import { stellarNetwork, horizonUrl } from "@/lib/constants/network"; +import { getAvailableTokens } from "@/lib/helpers/stellar/soroswap/tokens"; +import { getTokenBalanceFromContract } from "@/lib/helpers/stellar/sorobanBalance"; +import { parseBalance } from "@/lib/helpers/formatUtils"; const getHorizon = (): Horizon.Server | null => { if (typeof window === "undefined") { @@ -19,7 +22,19 @@ const getHorizon = (): Horizon.Server | null => { const formatter = new Intl.NumberFormat(); -export type MappedBalances = Record; +type HorizonBalance = Horizon.HorizonApi.BalanceLine & { balance: string }; + +export interface SorobanBalanceEntry { + asset_type: "credit_alphanum4"; + asset_code: string; + asset_issuer: string; + balance: string; +} + +export type MappedBalances = Record< + string, + HorizonBalance | SorobanBalanceEntry +>; export const fetchBalances = async (address: string) => { if (typeof window === "undefined") { @@ -29,12 +44,14 @@ export const fetchBalances = async (address: string) => { if (!horizonInstance) { return {}; } + const mapped: MappedBalances = {}; + try { const { balances } = await horizonInstance .accounts() .accountId(address) .call(); - const mapped = balances.reduce((acc, b) => { + for (const b of balances) { const formattedBalance = formatter.format(Number(b.balance)); const balanceEntry = { ...b, balance: formattedBalance }; const key = @@ -43,11 +60,47 @@ export const fetchBalances = async (address: string) => { : b.asset_type === "liquidity_pool_shares" ? b.liquidity_pool_id : `${b.asset_code}:${b.asset_issuer}`; - acc[key] = balanceEntry; - return acc; - }, {} as MappedBalances); - return mapped; - } catch { - return {}; + mapped[key] = balanceEntry; + } + } catch (err) { + console.warn("Horizon balance fetch failed, continuing with Soroban:", err); } + + try { + const availableTokens = getAvailableTokens(); + const sorobanResults = await Promise.allSettled( + Object.entries(availableTokens).map( + async ([code, info]): Promise<{ code: string; balance: string }> => { + if (!info.contract || code === "XLM") return { code, balance: "0" }; + const balance = await getTokenBalanceFromContract( + info.contract, + address, + info.decimals ?? 7 + ); + return { code, balance }; + } + ) + ); + + for (const result of sorobanResults) { + if ( + result.status === "fulfilled" && + parseBalance(result.value.balance) > 0 + ) { + const { code, balance } = result.value; + const key = `soroban:${code}`; + const sorobanEntry: SorobanBalanceEntry = { + asset_type: "credit_alphanum4", + asset_code: code, + asset_issuer: "soroban", + balance: formatter.format(parseBalance(balance)), + }; + mapped[key] = sorobanEntry; + } + } + } catch (err) { + console.warn("Soroban balance fetch failed:", err); + } + + return mapped; }; diff --git a/apps/web-app/src/lib/helpers/stellarExplorer.ts b/apps/web-app/src/lib/helpers/stellarExplorer.ts deleted file mode 100644 index 366abc27..00000000 --- a/apps/web-app/src/lib/helpers/stellarExplorer.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type { NetworkType } from "@/lib/constants/network"; - -const EXPLORER_NETWORK_SEGMENT: Record = { - mainnet: "public", - testnet: "testnet", - futurenet: "futurenet", -}; - -/** - * Returns a Stellar Expert contract URL for the given network, or null for custom/local. - */ -export function getStellarExpertContractUrl( - contractId: string, - networkId: NetworkType -): string | null { - const seg = EXPLORER_NETWORK_SEGMENT[networkId]; - if (!seg) return null; - return `https://stellar.expert/explorer/${seg}/contract/${contractId}`; -} diff --git a/apps/web-app/src/lib/helpers/storage.ts b/apps/web-app/src/lib/helpers/storage.ts index 4e9006de..3066434a 100644 --- a/apps/web-app/src/lib/helpers/storage.ts +++ b/apps/web-app/src/lib/helpers/storage.ts @@ -1,14 +1,3 @@ -/** - * A typed wrapper around localStorage largely borrowed from (but less capable - * than) https://www.npmjs.com/package/typed-local-store - * - * Provides a fully-typed interface to localStorage, and is easy to modify for other storage strategies (i.e. sessionStorage) - */ - -/** - * Valid localStorage key names mapped to the type of their stored value. - * Extend this union when adding new persisted keys. - */ type Schema = { walletId: string; walletAddress: string; @@ -16,20 +5,13 @@ type Schema = { networkPassphrase: string; }; -/** - * Typed interface that follows the Web Storage API: https://developer.mozilla.org/en-US/docs/Web/API/Web_Storage_API - * - * Implementation has been borrowed and simplified from https://www.npmjs.com/package/typed-local-store - */ class TypedStorage { private readonly storage: Storage; constructor() { - // Check if we're in the browser (client-side) if (typeof window !== "undefined" && window.localStorage) { this.storage = localStorage; } else { - // Fallback for SSR: create a mock storage object this.storage = { getItem: () => null, setItem: () => {}, @@ -86,8 +68,5 @@ class TypedStorage { } } -/** - * Fully-typed wrapper around localStorage - */ const storage = new TypedStorage(); export default storage; diff --git a/apps/web-app/src/lib/helpers/tokenUtils.ts b/apps/web-app/src/lib/helpers/tokenUtils.ts index 5dd4acc2..0154a8b1 100644 --- a/apps/web-app/src/lib/helpers/tokenUtils.ts +++ b/apps/web-app/src/lib/helpers/tokenUtils.ts @@ -1,7 +1,3 @@ -/** - * Utility functions for swap operations (Stellar) - */ - import { getAvailableTokens } from "./stellar/soroswap"; import type { Token } from "./stellar/soroswap"; import { @@ -9,9 +5,6 @@ import { STELLAR_FALLBACK_CONTRACTS, } from "@/lib/constants/tokenIcons"; -/** - * Format amount based on token decimals - */ export const formatSwapAmount = ( amount: string | number, decimals: number = 7 @@ -27,9 +20,6 @@ export const formatSwapAmount = ( return formatter.format(numAmount); }; -/** - * Convert amount to smallest unit (stroops for XLM, smallest unit for tokens) - */ export const toSmallestUnit = ( amount: string | number, decimals: number = 7 @@ -42,9 +32,6 @@ export const toSmallestUnit = ( return result; }; -/** - * Convert from smallest unit to human-readable format - */ export const fromSmallestUnit = ( amount: string, decimals: number = 7 @@ -63,27 +50,17 @@ export const fromSmallestUnit = ( return `${whole}.${trimmedFractional}`; }; -/** - * Get explorer URL for a transaction - */ export const getExplorerUrl = (txHash: string, network?: string): string => { const networkParam = network === "PUBLIC" ? "" : `/${network?.toLowerCase()}`; return `https://stellar.expert/explorer${networkParam}/tx/${txHash}`; }; -/** - * Strips non-numeric characters and prevents multiple decimal points. - * Suitable for amount input fields. - */ export const sanitizeAmountInput = (value: string): string => { const stripped = value.replace(/[^0-9.]/g, ""); const parts = stripped.split("."); return parts.length > 2 ? parts[0] + "." + parts.slice(1).join("") : stripped; }; -/** - * Converts a Stellar token to a stable string identifier. - */ export const getTokenId = ( token: Token | string, availableTokens: Record @@ -106,10 +83,6 @@ export const getTokenId = ( return ""; }; -/** - * Get token icon/image path based on token code or address. - * Returns null if no icon is found. - */ export const getTokenIcon = ( token: | { type: "native" | "contract"; code?: string; contract?: string } @@ -118,7 +91,6 @@ export const getTokenIcon = ( let tokenCode: string | null = null; if (typeof token === "string") { - // Stellar contract address — look up in registry then fallback map try { const availableTokens = getAvailableTokens(); for (const [code, info] of Object.entries(availableTokens)) { diff --git a/apps/web-app/src/lib/orchestrator/adapters/BlendPoolAdapter.ts b/apps/web-app/src/lib/orchestrator/adapters/BlendPoolAdapter.ts index 8ce4ea3a..dd19977c 100644 --- a/apps/web-app/src/lib/orchestrator/adapters/BlendPoolAdapter.ts +++ b/apps/web-app/src/lib/orchestrator/adapters/BlendPoolAdapter.ts @@ -1,15 +1,3 @@ -/** - * BlendPoolAdapter — wraps `@blend-capital/blend-sdk` to expose - * Blend Protocol lending pools behind the `BasePoolAdapter` interface. - * - * A single Blend pool contract contains multiple **reserves** (assets). - * Each reserve is surfaced as its own `PoolInfo` with the id format: - * `blend::` - * - * Mutations use `PoolContractV2.submit()` with a `Request` array. - * Reads use `PoolV2.load()` + `pool.loadUser()`. - */ - import { PoolV2, PoolContractV2, @@ -51,10 +39,6 @@ const REQUEST_TYPE_MAP: Record = { repay: RequestType.Repay, }; -/** - * Parse a raw pool id (after the `blend:` prefix is stripped) into its - * constituent parts: `:`. - */ function parseRawId(rawId: string): { poolContractId: string; assetAddress: string; @@ -82,10 +66,6 @@ export class BlendPoolAdapter implements BasePoolAdapter { this.poolContractId = poolContractId; } - // ------------------------------------------------------------------ - // Reads - // ------------------------------------------------------------------ - async getPoolInfo(rawId: string): Promise { const { poolContractId, assetAddress } = parseRawId(rawId); const network = getBlendNetwork(); @@ -105,9 +85,7 @@ export class BlendPoolAdapter implements BasePoolAdapter { let tokenMeta: TokenMetadata | undefined; try { tokenMeta = await TokenMetadata.load(network, assetAddress); - } catch { - /* token metadata is optional */ - } + } catch {} const statusMap: Record = { 0: "active", @@ -160,9 +138,7 @@ export class BlendPoolAdapter implements BasePoolAdapter { let tokenMeta: TokenMetadata | undefined; try { tokenMeta = await TokenMetadata.load(network, assetAddress); - } catch { - /* continue without metadata */ - } + } catch {} const statusMap: Record = { 0: "active", @@ -232,8 +208,7 @@ export class BlendPoolAdapter implements BasePoolAdapter { ); const decimals = reserve.config.decimals; - // In Blend, deposited (supply + collateral) already includes accrued interest. - // There is no separate rewards field — the balance compounds in place. + const deposited = supplied + collateral; return { @@ -254,10 +229,6 @@ export class BlendPoolAdapter implements BasePoolAdapter { } } - // ------------------------------------------------------------------ - // Writes — all via PoolContractV2.submit() - // ------------------------------------------------------------------ - async deposit( rawId: string, userAddress: string, @@ -344,14 +315,6 @@ export class BlendPoolAdapter implements BasePoolAdapter { return SUPPORTED_ACTIONS.includes(action); } - // ------------------------------------------------------------------ - // Internal helpers - // ------------------------------------------------------------------ - - /** - * Build a `submit()` transaction for any request type - * (supply, withdraw, borrow, repay, supplyCollateral, withdrawCollateral). - */ private async buildSubmitTx( rawId: string, userAddress: string, @@ -386,10 +349,6 @@ export class BlendPoolAdapter implements BasePoolAdapter { } } - /** - * Wrap a base-64 operation XDR string from the Blend SDK into - * a fully prepared (simulated) transaction envelope ready for signing. - */ private async wrapOperation( opXdrBase64: string, userAddress: string @@ -416,10 +375,6 @@ export class BlendPoolAdapter implements BasePoolAdapter { } } -// ------------------------------------------------------------------ -// Utilities -// ------------------------------------------------------------------ - function emptyPosition(poolId: string): PoolPosition { return { poolId, diff --git a/apps/web-app/src/lib/orchestrator/adapters/NekoLendingAdapter.ts b/apps/web-app/src/lib/orchestrator/adapters/NekoLendingAdapter.ts index 043f639a..4c3e63b9 100644 --- a/apps/web-app/src/lib/orchestrator/adapters/NekoLendingAdapter.ts +++ b/apps/web-app/src/lib/orchestrator/adapters/NekoLendingAdapter.ts @@ -1,13 +1,3 @@ -/** - * NekoLendingAdapter — wraps the Neko RWA-Lending contract behind - * the `BasePoolAdapter` interface. - * - * Internally reuses: - * - `@neko/lending` generated client for read queries. - * - Existing transaction builders from `lib/helpers/lending.ts` - * for deposit / withdraw (they return XDR strings). - */ - import { Client as RwaLendingClient, networks } from "@neko/lending"; import { rpcUrl, @@ -30,6 +20,17 @@ import { AdapterError, UnsupportedActionError } from "../types/errors"; const SUPPORTED_ACTIONS: PoolAction[] = ["deposit", "withdraw"]; +/** Assets that belong to Pool 1 (RWA collateral → borrow USDC/XLM) */ +const POOL1_ASSETS = new Set(["USDC", "XLM"]); +/** Assets that belong to Pool 2 (USDC/XLM collateral → borrow RWA) */ +const POOL2_ASSETS = new Set(["USTRY", "TESOURO", "CETES", "USDY", "PYUSD"]); + +function getPoolContractId(assetCode: string): string { + return POOL2_ASSETS.has(assetCode) + ? networks.testnet.pool2ContractId + : networks.testnet.pool1ContractId; +} + /** * Unwrap a Stellar SDK `Result` value that may come back as * `{ tag, values, unwrap }` or a raw primitive. @@ -49,9 +50,7 @@ function unwrapResult(value: unknown): bigint { if (typeof obj.unwrap === "function") { try { return BigInt(obj.unwrap()); - } catch { - /* fall through */ - } + } catch {} } if (obj.tag === "Ok" && Array.isArray(obj.values) && obj.values.length > 0) { @@ -64,20 +63,28 @@ function unwrapResult(value: unknown): bigint { export class NekoLendingAdapter implements BasePoolAdapter { readonly type: PoolType = "neko"; - private client: RwaLendingClient; + private pool1Client: RwaLendingClient; + private pool2Client: RwaLendingClient; constructor() { - this.client = new RwaLendingClient({ - contractId: networks.testnet.contractId, + const clientOptions = { rpcUrl, networkPassphrase, ...(allowHttpForSoroban && { allowHttp: true }), + }; + this.pool1Client = new RwaLendingClient({ + contractId: networks.testnet.pool1ContractId, + ...clientOptions, + }); + this.pool2Client = new RwaLendingClient({ + contractId: networks.testnet.pool2ContractId, + ...clientOptions, }); } - // ------------------------------------------------------------------ - // Reads - // ------------------------------------------------------------------ + private clientFor(assetCode: string): RwaLendingClient { + return POOL2_ASSETS.has(assetCode) ? this.pool2Client : this.pool1Client; + } async getPoolInfo(poolId: string): Promise { const assetCode = poolId; @@ -92,13 +99,16 @@ export class NekoLendingAdapter implements BasePoolAdapter { ); } + const client = this.clientFor(assetCode); + const contractId = getPoolContractId(assetCode); + try { const [balanceTx, interestRateTx, poolStateTx] = await Promise.all([ - this.client.get_pool_balance({ asset: assetCode }, { simulate: true }), - this.client + client.get_pool_balance({ asset: assetCode }, { simulate: true }), + client .get_interest_rate({ asset: assetCode }, { simulate: true }) .catch(() => null), - this.client.get_pool_state({ simulate: true }).catch(() => null), + client.get_pool_state({ simulate: true }).catch(() => null), ]); const balance = @@ -129,7 +139,7 @@ export class NekoLendingAdapter implements BasePoolAdapter { : "unknown", supportedActions: SUPPORTED_ACTIONS, metadata: { - contractId: networks.testnet.contractId, + contractId, assetCode, }, }; @@ -140,20 +150,15 @@ export class NekoLendingAdapter implements BasePoolAdapter { async listPools(): Promise { const tokens = getAvailableTokens(); - const debtAssets = ["USDC", "XLM"].filter((c) => tokens[c]?.contract); - - let stateTag: string | undefined; - try { - const stateTx = await this.client.get_pool_state({ simulate: true }); - stateTag = (stateTx.result as { tag?: string } | undefined)?.tag; - } catch { - return []; - } - - if (stateTag !== "Active") return []; + // Pool 1 debt assets; Pool 2 debt assets + const pool1Assets = ["USDC", "XLM"].filter((c) => tokens[c]?.contract); + const pool2Assets = ["USTRY", "TESOURO", "CETES", "USDY", "PYUSD"].filter( + (c) => tokens[c]?.contract + ); + const allAssets = [...pool1Assets, ...pool2Assets]; const pools: PoolInfo[] = []; - for (const code of debtAssets) { + for (const code of allAssets) { try { const info = await this.getPoolInfo(code); pools.push(info); @@ -171,9 +176,10 @@ export class NekoLendingAdapter implements BasePoolAdapter { const assetCode = poolId; const tokens = getAvailableTokens(); const decimals = tokens[assetCode]?.decimals ?? 7; + const client = this.clientFor(assetCode); try { - const balanceTx = await this.client.get_b_token_balance( + const balanceTx = await client.get_b_token_balance( { lender: userAddress, asset: assetCode }, { simulate: true } ); @@ -200,10 +206,6 @@ export class NekoLendingAdapter implements BasePoolAdapter { } } - // ------------------------------------------------------------------ - // Writes — delegate to existing helpers that return XDR strings - // ------------------------------------------------------------------ - async deposit( poolId: string, userAddress: string, @@ -213,13 +215,15 @@ export class NekoLendingAdapter implements BasePoolAdapter { const tokens = getAvailableTokens(); const decimals = tokens[assetCode]?.decimals ?? 7; const humanAmount = fromSmallestUnit(amount.toString(), decimals); + const contractId = getPoolContractId(assetCode); try { const xdr = await depositToPool( assetCode, humanAmount, decimals, - userAddress + userAddress, + contractId ); return { xdr, networkPassphrase }; } catch (error) { @@ -236,13 +240,15 @@ export class NekoLendingAdapter implements BasePoolAdapter { const tokens = getAvailableTokens(); const decimals = tokens[assetCode]?.decimals ?? 7; const humanAmount = fromSmallestUnit(amount.toString(), decimals); + const contractId = getPoolContractId(assetCode); try { const xdr = await withdrawFromPool( assetCode, humanAmount, decimals, - userAddress + userAddress, + contractId ); return { xdr, networkPassphrase }; } catch (error) { diff --git a/apps/web-app/src/lib/orchestrator/adapters/SoroswapPoolAdapter.ts b/apps/web-app/src/lib/orchestrator/adapters/SoroswapPoolAdapter.ts index dbe54620..27abde45 100644 --- a/apps/web-app/src/lib/orchestrator/adapters/SoroswapPoolAdapter.ts +++ b/apps/web-app/src/lib/orchestrator/adapters/SoroswapPoolAdapter.ts @@ -1,12 +1,3 @@ -/** - * SoroswapPoolAdapter — wraps the SoroSwap SDK / API helpers - * behind the `BasePoolAdapter` interface. - * - * SoroSwap pools are AMM liquidity pools with token pairs. - * This adapter delegates to the existing helper functions in - * `lib/helpers/soroswap.ts` for pool queries and liquidity ops. - */ - import { getPool, addLiquidity, @@ -28,10 +19,6 @@ import { AdapterError, UnsupportedActionError } from "../types/errors"; const SUPPORTED_ACTIONS: PoolAction[] = ["deposit", "withdraw"]; -/** - * Parse a SoroSwap pool id. - * Expected format: `-` (e.g. `XLM-USDC`). - */ function parsePairId(poolId: string): { codeA: string; codeB: string } { const [codeA, codeB] = poolId.split("-"); if (!codeA || !codeB) { @@ -42,7 +29,6 @@ function parsePairId(poolId: string): { codeA: string; codeB: string } { return { codeA, codeB }; } -/** Resolve a token code to its on-chain TokenInfo. */ function resolveToken(code: string): TokenInfo { const tokens = getAvailableTokens(); const t = tokens[code]; @@ -60,10 +46,6 @@ function resolveToken(code: string): TokenInfo { export class SoroswapPoolAdapter implements BasePoolAdapter { readonly type: PoolType = "soroswap"; - // ------------------------------------------------------------------ - // Reads - // ------------------------------------------------------------------ - async getPoolInfo(poolId: string): Promise { const { codeA, codeB } = parsePairId(poolId); const tokenA = resolveToken(codeA); @@ -123,13 +105,6 @@ export class SoroswapPoolAdapter implements BasePoolAdapter { } } - /** - * List known SoroSwap pools for every pair combination - * of the protocol's configured tokens. - * - * Currently queries a curated set of likely pairs. Extend - * this list or switch to an "all-pools" API when available. - */ async listPools(): Promise { const tokens = getAvailableTokens(); const codes = Object.keys(tokens); @@ -152,11 +127,6 @@ export class SoroswapPoolAdapter implements BasePoolAdapter { .map((r) => r.value); } - /** - * SoroSwap does not expose per-user LP position via a simple - * query right now, so we return a zeroed position. - * Future: query LP token balance for the user. - */ async getUserPosition( poolId: string, _userAddress: string @@ -171,19 +141,6 @@ export class SoroswapPoolAdapter implements BasePoolAdapter { }; } - // ------------------------------------------------------------------ - // Writes - // ------------------------------------------------------------------ - - /** - * Deposit (add liquidity) to a SoroSwap pool. - * - * Because SoroSwap pools are dual-token, the caller must supply - * `amount` as the **tokenA** amount; the SDK computes the required - * tokenB amount internally through the `addLiquidity` helper. - * - * @param tokenIndex - Ignored for SoroSwap (both tokens required). - */ async deposit( poolId: string, userAddress: string, @@ -212,10 +169,6 @@ export class SoroswapPoolAdapter implements BasePoolAdapter { } } - /** - * Withdraw (remove liquidity) is not yet exposed by the SoroSwap - * REST API used in this codebase. Throws until implemented. - */ async withdraw(): Promise { throw new UnsupportedActionError( "soroswap", diff --git a/apps/web-app/src/lib/orchestrator/adapters/blend-config.ts b/apps/web-app/src/lib/orchestrator/adapters/blend-config.ts index 7fe2f92f..31cf637d 100644 --- a/apps/web-app/src/lib/orchestrator/adapters/blend-config.ts +++ b/apps/web-app/src/lib/orchestrator/adapters/blend-config.ts @@ -1,7 +1,3 @@ -/** - * Configuration for Blend Protocol pool discovery and network setup. - */ - import type { Network } from "@blend-capital/blend-sdk"; import { rpcUrl, @@ -13,18 +9,11 @@ export function getBlendNetwork(): Network { return { rpc: rpcUrl, passphrase: networkPassphrase }; } -/** - * Known Blend pool contract addresses per network. - * Add pool contract IDs as they become available. - */ export const BLEND_POOLS: Record = { TESTNET: ["CCEBVDYM32YNYCVNRXQKDFFPISJJCV557CDZEIRBEE4NCV4KHPQ44HGF"], - PUBLIC: [ - // Blend mainnet pool contract IDs - ], + PUBLIC: [], }; -/** Returns the pool contract IDs for the current network. */ export function getBlendPoolIds(): string[] { return BLEND_POOLS[stellarNetwork] ?? []; } diff --git a/apps/web-app/src/lib/orchestrator/core/Orchestrator.ts b/apps/web-app/src/lib/orchestrator/core/Orchestrator.ts index ce5d824e..a868e93d 100644 --- a/apps/web-app/src/lib/orchestrator/core/Orchestrator.ts +++ b/apps/web-app/src/lib/orchestrator/core/Orchestrator.ts @@ -1,17 +1,3 @@ -/** - * Orchestrator — the single entry-point the frontend uses to - * interact with **any** pool type in the Pulse protocol. - * - * Every method delegates to `PoolRegistry.resolve(poolId)` to find - * the right adapter, strips the `:` prefix, and forwards the - * call. Errors from adapters bubble up as-is (they are already - * instances of `OrchestratorError` subtypes). - * - * A pre-configured singleton (`orchestrator`) is exported at the - * bottom of the file — it registers the Neko, Blend, and SoroSwap - * adapters so the consumer doesn't need to do any wiring. - */ - import { PoolRegistry, poolRegistry } from "./PoolRegistry"; import { NekoLendingAdapter } from "../adapters/NekoLendingAdapter"; import { BlendPoolAdapter } from "../adapters/BlendPoolAdapter"; @@ -28,19 +14,12 @@ import type { export class Orchestrator { constructor(private registry: PoolRegistry) {} - /** - * Fetch normalised info for a single pool. - * @param poolId - Full id including type prefix, e.g. `blend:USDC`. - */ async getPoolInfo(poolId: string): Promise { const adapter = this.registry.resolve(poolId); const rawId = PoolRegistry.stripPrefix(poolId); return adapter.getPoolInfo(rawId); } - /** - * Get the connected user's position in a pool. - */ async getUserPosition( poolId: string, userAddress: string @@ -50,10 +29,6 @@ export class Orchestrator { return adapter.getUserPosition(rawId, userAddress); } - /** - * Build an unsigned deposit transaction. - * @param amount - Expressed in the token's smallest unit. - */ async deposit( poolId: string, userAddress: string, @@ -65,10 +40,6 @@ export class Orchestrator { return adapter.deposit(rawId, userAddress, amount, tokenIndex); } - /** - * Build an unsigned withdraw transaction. - * @param amount - Expressed in the token's smallest unit. - */ async withdraw( poolId: string, userAddress: string, @@ -80,10 +51,6 @@ export class Orchestrator { return adapter.withdraw(rawId, userAddress, amount, tokenIndex); } - /** - * Build an unsigned borrow transaction. - * Only supported by lending-protocol adapters (e.g. Blend). - */ async borrow( poolId: string, userAddress: string, @@ -97,10 +64,6 @@ export class Orchestrator { return adapter.borrow(rawId, userAddress, amount); } - /** - * Build an unsigned repay transaction. - * Only supported by lending-protocol adapters (e.g. Blend). - */ async repay( poolId: string, userAddress: string, @@ -114,10 +77,6 @@ export class Orchestrator { return adapter.repay(rawId, userAddress, amount); } - /** - * Build an unsigned claim-rewards transaction. - * Throws `UnsupportedActionError` if the pool doesn't support rewards. - */ async claimRewards( poolId: string, userAddress: string @@ -127,11 +86,6 @@ export class Orchestrator { return adapter.claimRewards(rawId, userAddress); } - /** - * Aggregate pools from **every** registered adapter. - * Individual adapter failures are caught so one broken adapter - * doesn't prevent the rest from loading. - */ async getAllPools(): Promise { const adapters = this.registry.getAdapters(); @@ -154,9 +108,6 @@ export class Orchestrator { return pools; } - /** - * Check at runtime whether a pool supports a given action. - */ supportsAction(poolId: string, action: string): boolean { try { const adapter = this.registry.resolve(poolId); @@ -169,10 +120,6 @@ export class Orchestrator { } } -// ------------------------------------------------------------------ -// Pre-configured singleton -// ------------------------------------------------------------------ - poolRegistry.register(new NekoLendingAdapter()); for (const blendPoolId of getBlendPoolIds()) { @@ -181,5 +128,4 @@ for (const blendPoolId of getBlendPoolIds()) { poolRegistry.register(new SoroswapPoolAdapter()); -/** Ready-to-use orchestrator instance with all adapters wired up. */ export const orchestrator = new Orchestrator(poolRegistry); diff --git a/apps/web-app/src/lib/orchestrator/core/PoolRegistry.ts b/apps/web-app/src/lib/orchestrator/core/PoolRegistry.ts index 5453da7a..41afd0ef 100644 --- a/apps/web-app/src/lib/orchestrator/core/PoolRegistry.ts +++ b/apps/web-app/src/lib/orchestrator/core/PoolRegistry.ts @@ -1,52 +1,20 @@ -/** - * PoolRegistry — maps pool identifiers to the correct adapter. - * - * Two resolution strategies: - * 1. **Explicit mapping** — `registerPool(poolId, type)` stores a direct - * poolId → PoolType entry. - * 2. **Convention-based** — pool ids prefixed with `:` (e.g. - * `blend:USDC`) are parsed at resolve time, so pools don't need - * to be registered one-by-one. - * - * The registry is exported as a singleton, matching the service pattern - * used elsewhere in the codebase (`lendingService`, etc.). - */ - import type { BasePoolAdapter } from "../types/adapter.types"; import type { PoolType } from "../types/pool.types"; import { PoolNotFoundError } from "../types/errors"; export class PoolRegistry { - /** One adapter instance per pool type. */ private adapters = new Map(); - /** Explicit poolId → PoolType overrides. */ private poolTypeMap = new Map(); - /** - * Register an adapter for a given pool type. - * Replaces any previously registered adapter for the same type. - */ register(adapter: BasePoolAdapter): void { this.adapters.set(adapter.type, adapter); } - /** - * Explicitly bind a pool id to a pool type. - * Useful when pool ids don't follow the `:` convention. - */ registerPool(poolId: string, type: PoolType): void { this.poolTypeMap.set(poolId, type); } - /** - * Resolve a pool id to its adapter. - * - * Resolution order: - * 1. Check explicit `poolTypeMap`. - * 2. Parse `:` prefix from the id. - * 3. Throw `PoolNotFoundError` if neither matches. - */ resolve(poolId: string): BasePoolAdapter { const explicitType = this.poolTypeMap.get(poolId); if (explicitType) { @@ -64,30 +32,22 @@ export class PoolRegistry { throw new PoolNotFoundError(poolId); } - /** - * Strip the `:` prefix from a pool id, returning the raw - * identifier the adapter expects. - */ static stripPrefix(poolId: string): string { const idx = poolId.indexOf(":"); return idx === -1 ? poolId : poolId.slice(idx + 1); } - /** Return all registered adapters. */ getAdapters(): BasePoolAdapter[] { return [...this.adapters.values()]; } - /** List all explicitly registered pool ids. */ listRegisteredPools(): string[] { return [...this.poolTypeMap.keys()]; } - /** Check whether a pool type has a registered adapter. */ hasAdapter(type: PoolType): boolean { return this.adapters.has(type); } } -/** Singleton registry shared across the application. */ export const poolRegistry = new PoolRegistry(); diff --git a/apps/web-app/src/lib/orchestrator/hooks/usePoolAction.ts b/apps/web-app/src/lib/orchestrator/hooks/usePoolAction.ts index 963968b0..8569f615 100644 --- a/apps/web-app/src/lib/orchestrator/hooks/usePoolAction.ts +++ b/apps/web-app/src/lib/orchestrator/hooks/usePoolAction.ts @@ -1,17 +1,9 @@ "use client"; -/** - * usePoolAction — React Query mutation hook for pool write operations - * (deposit, withdraw, claimRewards). - * - * Integrates with WalletProvider for transaction signing and - * NotificationProvider for user feedback. - */ - import { useMutation, useQueryClient } from "@tanstack/react-query"; import { rpc, TransactionBuilder } from "@stellar/stellar-sdk"; import { useWallet } from "@/hooks/useWallet"; -import { useNotification } from "@/hooks/useNotification"; +import { useToast } from "@/hooks/useToast"; import { rpcUrl } from "@/lib/constants/network"; import { isUserCancellationError } from "@/lib/helpers/stellar/contractErrors"; import { orchestrator } from "../core/Orchestrator"; @@ -25,16 +17,9 @@ interface PoolActionParams { tokenIndex?: number; } -/** - * Generic mutation that: - * 1. Calls the orchestrator to build the unsigned XDR. - * 2. Signs via `WalletProvider.signTransaction`. - * 3. Submits the signed tx to Soroban RPC. - * 4. Invalidates pool queries so data refreshes. - */ export function usePoolAction() { const { address, signTransaction } = useWallet(); - const { addNotification } = useNotification(); + const { addNotification } = useToast(); const queryClient = useQueryClient(); return useMutation({ @@ -97,7 +82,6 @@ export function usePoolAction() { throw new Error(`Transaction failed: ${txResult.status}`); } - // Wait for on-chain confirmation so refetch gets fresh data const confirmed = await server.pollTransaction(txResult.hash, { attempts: 30, }); diff --git a/apps/web-app/src/lib/orchestrator/hooks/usePools.ts b/apps/web-app/src/lib/orchestrator/hooks/usePools.ts index 5ea26f8b..351e6795 100644 --- a/apps/web-app/src/lib/orchestrator/hooks/usePools.ts +++ b/apps/web-app/src/lib/orchestrator/hooks/usePools.ts @@ -1,24 +1,11 @@ "use client"; -/** - * usePools — React Query hook that fetches **all** pools from every - * registered adapter via the Orchestrator singleton. - * - * Drop-in replacement for the old `useLendingPools` + `useBorrowPools` - * pattern used in the Pools page. - */ - import { useQuery } from "@tanstack/react-query"; import { orchestrator } from "../core/Orchestrator"; import type { PoolInfo, PoolPosition } from "../types/pool.types"; export const POOLS_QUERY_KEY = ["orchestrator", "pools"] as const; -/** - * Fetch all pools across adapters. - * - * @param enabled - Pass `false` to disable the query (e.g. while wallet is loading). - */ export function usePools(enabled = true) { return useQuery({ queryKey: [...POOLS_QUERY_KEY], @@ -31,9 +18,6 @@ export function usePools(enabled = true) { }); } -/** - * Fetch a single pool by its full id (e.g. `blend:USDC`). - */ export function usePoolInfo(poolId: string | undefined) { return useQuery({ queryKey: ["orchestrator", "pool", poolId], @@ -45,10 +29,6 @@ export function usePoolInfo(poolId: string | undefined) { }); } -/** - * Fetch the connected user's position in a pool (deposited amount, rewards). - * Returns null when wallet is not connected. - */ export function useUserPosition( poolId: string | undefined, userAddress: string | undefined diff --git a/apps/web-app/src/lib/orchestrator/index.ts b/apps/web-app/src/lib/orchestrator/index.ts index ee7b30ca..79e478c5 100644 --- a/apps/web-app/src/lib/orchestrator/index.ts +++ b/apps/web-app/src/lib/orchestrator/index.ts @@ -1,25 +1,12 @@ -/** - * Pool Orchestrator — public API. - * - * Import everything the UI needs from this single entry-point: - * - * ```ts - * import { orchestrator, usePools, usePoolAction } from "@/lib/orchestrator"; - * ``` - */ - -// Singleton + classes export { orchestrator, Orchestrator } from "./core"; export { poolRegistry, PoolRegistry } from "./core"; -// Adapters (for advanced consumers who need to register custom ones) export { NekoLendingAdapter, BlendPoolAdapter, SoroswapPoolAdapter, } from "./adapters"; -// React hooks export { usePools, usePoolInfo, @@ -28,7 +15,6 @@ export { POOLS_QUERY_KEY, } from "./hooks"; -// Types export type { PoolType, PoolState, @@ -41,7 +27,6 @@ export type { BasePoolAdapter, } from "./types"; -// Errors export { OrchestratorError, PoolNotFoundError, diff --git a/apps/web-app/src/lib/orchestrator/types/adapter.types.ts b/apps/web-app/src/lib/orchestrator/types/adapter.types.ts index b33e25bc..ee55da62 100644 --- a/apps/web-app/src/lib/orchestrator/types/adapter.types.ts +++ b/apps/web-app/src/lib/orchestrator/types/adapter.types.ts @@ -1,11 +1,3 @@ -/** - * BasePoolAdapter — the contract every pool adapter must satisfy. - * - * Adapters are stateless services: they receive all context - * (pool id, user address, amounts) as arguments and return - * normalized results the Orchestrator can forward to the UI. - */ - import type { PoolAction, PoolInfo, @@ -15,32 +7,14 @@ import type { } from "./pool.types"; export interface BasePoolAdapter { - /** The pool type this adapter handles. */ readonly type: PoolType; - /** - * Fetch normalized information for a single pool. - * @param poolId - Unique pool identifier (without the `type:` prefix). - */ getPoolInfo(poolId: string): Promise; - /** - * List every pool this adapter knows about. - * Used by `Orchestrator.getAllPools()` to aggregate across adapters. - */ listPools(): Promise; - /** - * Get the calling user's position in a given pool. - * Returns zeroed position when the user has no stake. - */ getUserPosition(poolId: string, userAddress: string): Promise; - /** - * Build an unsigned deposit transaction. - * @param tokenIndex - Which token in the pool's `tokens` array to deposit - * (only relevant for multi-token pools like SoroSwap). - */ deposit( poolId: string, userAddress: string, @@ -48,10 +22,6 @@ export interface BasePoolAdapter { tokenIndex?: number ): Promise; - /** - * Build an unsigned withdraw transaction. - * @param tokenIndex - Which token to withdraw (multi-token pools). - */ withdraw( poolId: string, userAddress: string, @@ -59,33 +29,19 @@ export interface BasePoolAdapter { tokenIndex?: number ): Promise; - /** - * Build an unsigned claim-rewards transaction. - * Adapters that don't support rewards should throw - * `UnsupportedActionError`. - */ claimRewards(poolId: string, userAddress: string): Promise; - /** - * Build an unsigned borrow transaction. - * Only required for lending-protocol adapters (Blend, etc.). - */ borrow?( poolId: string, userAddress: string, amount: bigint ): Promise; - /** - * Build an unsigned repay transaction. - * Only required for lending-protocol adapters (Blend, etc.). - */ repay?( poolId: string, userAddress: string, amount: bigint ): Promise; - /** Runtime check for whether this adapter supports a given action. */ supportsAction(action: PoolAction): boolean; } diff --git a/apps/web-app/src/lib/orchestrator/types/errors.ts b/apps/web-app/src/lib/orchestrator/types/errors.ts index f308b81a..a615fcc7 100644 --- a/apps/web-app/src/lib/orchestrator/types/errors.ts +++ b/apps/web-app/src/lib/orchestrator/types/errors.ts @@ -1,14 +1,5 @@ -/** - * Typed error hierarchy for the Pool Orchestrator. - * - * Every error extends `OrchestratorError` so callers can catch - * the entire family with a single `instanceof` check while still - * being able to narrow to specific sub-types. - */ - import { extractContractError } from "@/lib/helpers/stellar/contractErrors"; -/** Base class for all orchestrator errors. */ export class OrchestratorError extends Error { constructor(message: string, options?: ErrorOptions) { super(message, options); @@ -16,7 +7,6 @@ export class OrchestratorError extends Error { } } -/** Thrown when a pool id cannot be resolved to any registered adapter. */ export class PoolNotFoundError extends OrchestratorError { constructor(public readonly poolId: string) { super(`Pool not found: ${poolId}`); @@ -24,11 +14,6 @@ export class PoolNotFoundError extends OrchestratorError { } } -/** - * Thrown when an adapter encounters an error during a contract call. - * Wraps the original error and provides a user-friendly message - * extracted via the existing `extractContractError` utility. - */ export class AdapterError extends OrchestratorError { public readonly userMessage: string; @@ -40,7 +25,6 @@ export class AdapterError extends OrchestratorError { } } -/** Thrown when a caller requests an action the adapter does not support. */ export class UnsupportedActionError extends OrchestratorError { constructor( public readonly adapterType: string, diff --git a/apps/web-app/src/lib/orchestrator/types/pool.types.ts b/apps/web-app/src/lib/orchestrator/types/pool.types.ts index 7b2bb4ae..47e0d473 100644 --- a/apps/web-app/src/lib/orchestrator/types/pool.types.ts +++ b/apps/web-app/src/lib/orchestrator/types/pool.types.ts @@ -1,16 +1,7 @@ -/** - * Core domain types for the Pool Orchestrator. - * These are the unified types that the frontend consumes — - * every adapter normalizes its data into these shapes. - */ - -/** Discriminator for pool backend implementations. */ export type PoolType = "blend" | "neko" | "soroswap" | "custom"; -/** Lifecycle state of a pool (maps to Soroban PoolState or API status). */ export type PoolState = "active" | "frozen" | "on_ice" | "unknown"; -/** Actions a pool can support — not every pool supports every action. */ export type PoolAction = | "deposit" | "withdraw" @@ -20,74 +11,53 @@ export type PoolAction = | "supplyCollateral" | "withdrawCollateral"; -/** Minimal token descriptor used inside pool metadata. */ export interface TokenInfo { - /** Stellar contract address (C…). */ address: string; - /** Human-readable symbol, e.g. "USDC". */ + code: string; - /** Display name, e.g. "USDCoin". */ + name: string; - /** On-chain decimals (typically 7 on Stellar). */ + decimals: number; } -/** - * Normalized pool information returned by every adapter. - * The `metadata` bag carries adapter-specific extras the UI can opt-in to. - */ export interface PoolInfo { - /** Unique pool identifier (format: `:`). */ id: string; type: PoolType; name: string; tokens: TokenInfo[]; - /** Total value locked expressed in the pool's smallest unit. */ + tvl: bigint; - /** Annualised percentage yield (already divided, e.g. 5.25 = 5.25%). */ + apy: number; state: PoolState; - /** Actions this specific pool supports. */ + supportedActions: PoolAction[]; - /** Adapter-specific data the UI may render conditionally. */ + metadata: Record; } -/** - * A user's position in a pool. - * Adapters populate whichever fields are relevant. - */ export interface PoolPosition { poolId: string; - /** Deposited / supplied amount (smallest unit). */ + deposited: bigint; - /** Human-readable deposited string. */ + depositedFormatted: string; - /** Unclaimed rewards (smallest unit), 0n when N/A. */ + rewards: bigint; - /** Human-readable rewards string. */ + rewardsFormatted: string; - /** Adapter-specific position data. */ + metadata: Record; } -/** - * Result of a write operation — an unsigned XDR envelope - * ready for wallet signing. - */ export interface TransactionResult { - /** Base-64 XDR of the prepared (simulated) transaction. */ xdr: string; networkPassphrase: string; } -/** - * Wrapper returned by the Orchestrator for every mutation, - * including an optional pre-step (e.g. token approval). - */ export interface PoolActionResult { - /** Primary transaction XDR. */ tx: TransactionResult; - /** Optional preceding transaction (e.g. token approve). */ + preTx?: TransactionResult; } diff --git a/apps/web-app/src/lib/services/index.ts b/apps/web-app/src/lib/services/index.ts deleted file mode 100644 index 1dd66d49..00000000 --- a/apps/web-app/src/lib/services/index.ts +++ /dev/null @@ -1,19 +0,0 @@ -/** - * Services Index - * Centralized exports for all service classes - */ - -// Lending Service -export { lendingService } from "./lending.service"; -export type { - LendingOperationResult, - CollateralOperationResult, - BorrowWithCollateralResult, -} from "../types/lendingTypes"; - -// Price Service -export { priceService } from "./price.service"; -export type { TokenPriceResult, PriceFetchOptions } from "../types/priceTypes"; - -// Stellar Price Service -export { stellarPriceService } from "./stellar-price.service"; diff --git a/apps/web-app/src/lib/services/lending.service.ts b/apps/web-app/src/lib/services/lending.service.ts index a4928f7d..24d6f5ee 100644 --- a/apps/web-app/src/lib/services/lending.service.ts +++ b/apps/web-app/src/lib/services/lending.service.ts @@ -30,11 +30,19 @@ import { borrowFromPool, } from "../helpers/stellar/lending"; import { extractContractError } from "../helpers/stellar/contractErrors"; -import type { - LendingOperationResult, - CollateralOperationResult, - BorrowWithCollateralResult, -} from "../types/lendingTypes"; + +type LendingOperationResult = { xdr: string; error?: string }; +type CollateralOperationResult = { + approveXdr: string; + addCollateralXdr: string; + error?: string; +}; +type BorrowWithCollateralResult = { + approveXdr: string; + addCollateralXdr: string; + borrowXdr: string; + error?: string; +}; export class LendingService { private sorobanServer: rpc.Server; @@ -557,6 +565,49 @@ export class LendingService { } } + /** + * Get dToken balance for a borrower (raw dTokens) + */ + async getDTokenBalance( + assetCode: string, + walletAddress: string + ): Promise { + try { + const tx = await this.lendingClient.get_d_token_balance( + { borrower: walletAddress, asset: assetCode }, + { simulate: true } + ); + + const value = tx.result; + if (!value) return 0n; + + return typeof value === "bigint" ? value : BigInt(String(value)); + } catch (error) { + console.error("Error getting dToken balance:", error); + return 0n; + } + } + + /** + * Get dToken → underlying conversion rate (12-decimal scalar) + */ + async getDTokenRate(assetCode: string): Promise { + try { + const tx = await this.lendingClient.get_d_token_rate( + { asset: assetCode }, + { simulate: true } + ); + + const value = tx.result; + if (!value) return 0n; + + return typeof value === "bigint" ? value : BigInt(String(value)); + } catch (error) { + console.error("Error getting dToken rate:", error); + return 0n; + } + } + /** * Get borrow limit for a user */ @@ -588,6 +639,103 @@ export class LendingService { } } + /** + * Get health factor for a borrower from a specific lending contract. + * Returns a float (7 decimals: 10_000_000 = 1.0) or null if no open position. + */ + async getHealthFactor( + borrower: string, + contractId: string + ): Promise { + try { + const client = new RwaLendingClient({ + contractId, + rpcUrl: rpcUrl, + networkPassphrase: networkPassphrase, + ...(allowHttpForSoroban && { allowHttp: true }), + }); + + const tx = await client.calculate_health_factor( + { borrower }, + { simulate: true } + ); + + const result = tx.result; + if (!result) return null; + + if (result.isOk()) { + const raw = Number(result.unwrap()); + // u32::MAX means no active borrow (infinite health factor) + if (raw === 4294967295) return null; + return raw / 10_000_000; + } + + return null; + } catch (error) { + console.error("Error getting health factor:", error); + return null; + } + } + + /** + * Get raw collateral bigint for a user and RWA token from a specific pool contract. + */ + async getCollateralRaw( + rwaTokenContract: string, + walletAddress: string, + contractId: string + ): Promise { + try { + const client = new RwaLendingClient({ + contractId, + rpcUrl: rpcUrl, + networkPassphrase: networkPassphrase, + ...(allowHttpForSoroban && { allowHttp: true }), + }); + const tx = await client.get_collateral( + { borrower: walletAddress, rwa_token: rwaTokenContract }, + { simulate: true } + ); + const value = tx.result; + if (!value) return 0n; + return typeof value === "bigint" ? value : BigInt(String(value)); + } catch (error) { + console.error("Error getting collateral (raw):", error); + return 0n; + } + } + + /** + * Get remaining borrow capacity in USD (7 decimals) for a specific pool. + * Returns null when no position exists or on error. + */ + async getBorrowLimitForPool( + walletAddress: string, + contractId: string + ): Promise { + try { + const client = new RwaLendingClient({ + contractId, + rpcUrl: rpcUrl, + networkPassphrase: networkPassphrase, + ...(allowHttpForSoroban && { allowHttp: true }), + }); + const tx = await client.calculate_borrow_limit( + { borrower: walletAddress }, + { simulate: true } + ); + const result = tx.result; + if (!result) return null; + if (result.isOk()) { + return Number(result.unwrap()) / 1e7; + } + return null; + } catch (error) { + console.error("Error getting borrow limit for pool:", error); + return null; + } + } + /** * Get collateral balance for a user and RWA token */ diff --git a/apps/web-app/src/lib/services/price.service.ts b/apps/web-app/src/lib/services/price.service.ts deleted file mode 100644 index d3d1e884..00000000 --- a/apps/web-app/src/lib/services/price.service.ts +++ /dev/null @@ -1,206 +0,0 @@ -/** - * Price Service - * Handles token price fetching from various sources (CoinGecko, etc.) - */ - -import { PRICE_ERROR_DELAY_MS } from "@/lib/constants/wallet"; -import type { TokenPriceResult, PriceFetchOptions } from "../types/priceTypes"; - -const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); - -export class PriceService { - private priceCache = new Map(); - private readonly CACHE_DURATION = 30000; // 30 seconds - private readonly REQUEST_TIMEOUT = 5000; // 5 seconds - - // CoinGecko ID mapping for tokens with public market prices. - // RWA tokens (USTRY, TESOURO, CETES) use the oracle contract for pricing. - private readonly COINGECKO_ID_MAP: Record = { - XLM: "stellar", - USDC: "usd-coin", - USDY: "ondo-us-dollar-yield", - PYUSD: "paypal-usd", - }; - - /** - * Check if cached price is still valid - */ - private isPriceValid(cached: TokenPriceResult): boolean { - if (!cached.lastUpdated) return false; - const now = new Date(); - const age = now.getTime() - cached.lastUpdated.getTime(); - return age < this.CACHE_DURATION; - } - - /** - * Fetch price from CoinGecko API - */ - private async fetchFromCoinGecko( - coinGeckoId: string, - timeout: number = this.REQUEST_TIMEOUT - ): Promise { - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), timeout); - - try { - const url = `/api/coingecko/price?ids=${coinGeckoId}&vs_currencies=usd`; - - const response = await fetch(url, { - method: "GET", - signal: controller.signal, - }); - - clearTimeout(timeoutId); - - if (!response.ok) { - if (response.status === 429) { - throw new Error("RATE_LIMIT_EXCEEDED"); - } - - throw new Error( - `Failed to fetch price: ${response.status} ${response.statusText}` - ); - } - - const data = (await response.json()) as { - [key: string]: { usd?: number } | undefined; - }; - - const price = data[coinGeckoId]?.usd || 0; - return price; - } catch (error) { - clearTimeout(timeoutId); - - if (error instanceof Error && error.name === "AbortError") { - throw new Error("PRICE_FETCH_TIMEOUT"); - } - - throw error; - } - } - - /** - * Get CoinGecko ID for token symbol - */ - private getCoinGeckoId(tokenSymbol: string): string | null { - return this.COINGECKO_ID_MAP[tokenSymbol] || null; - } - - /** - * Fetch token price with caching and fallbacks - */ - async getTokenPrice( - tokenSymbol: string, - options: PriceFetchOptions = {} - ): Promise { - const { forceRefresh = false, timeout = this.REQUEST_TIMEOUT } = options; - - // Check cache first (unless force refresh) - if (!forceRefresh) { - const cached = this.priceCache.get(tokenSymbol); - if (cached && this.isPriceValid(cached)) { - return cached; - } - } - - const coinGeckoId = this.getCoinGeckoId(tokenSymbol); - - if (!coinGeckoId) { - await sleep(PRICE_ERROR_DELAY_MS); - const result: TokenPriceResult = { - price: 0, - source: "unavailable", - lastUpdated: new Date(), - }; - this.priceCache.set(tokenSymbol, result); - return result; - } - - try { - const price = await this.fetchFromCoinGecko(coinGeckoId, timeout); - const result: TokenPriceResult = { - price, - source: "coingecko", - lastUpdated: new Date(), - }; - this.priceCache.set(tokenSymbol, result); - return result; - } catch (error) { - console.warn(`Failed to fetch price for ${tokenSymbol}:`, error); - - await sleep(PRICE_ERROR_DELAY_MS); - const result: TokenPriceResult = { - price: 0, - source: "error", - lastUpdated: new Date(), - }; - this.priceCache.set(tokenSymbol, result); - return result; - } - } - - /** - * Get multiple token prices in batch - */ - async getTokenPrices( - tokenSymbols: string[], - options: PriceFetchOptions = {} - ): Promise> { - const results: Record = {}; - - // Use Promise.allSettled to handle partial failures - const promises = tokenSymbols.map(async (symbol) => { - try { - const price = await this.getTokenPrice(symbol, options); - return { symbol, price }; - } catch (error) { - console.warn(`Failed to get price for ${symbol}:`, error); - return { - symbol, - price: { - price: 0, - source: "error", - lastUpdated: new Date(), - } as TokenPriceResult, - }; - } - }); - - const settledResults = await Promise.allSettled(promises); - - settledResults.forEach((result) => { - if (result.status === "fulfilled") { - results[result.value.symbol] = result.value.price; - } - }); - - return results; - } - - /** - * Clear price cache - */ - clearCache(): void { - this.priceCache.clear(); - } - - /** - * Get cache statistics - */ - getCacheStats(): { size: number; entries: string[] } { - return { - size: this.priceCache.size, - entries: Array.from(this.priceCache.keys()), - }; - } - - /** - * Check if token is a stable coin - */ - isStableCoin(tokenSymbol: string): boolean { - return ["USDC", "USDT", "DAI", "USDP"].includes(tokenSymbol.toUpperCase()); - } -} - -// Export singleton instance -export const priceService = new PriceService(); diff --git a/apps/web-app/src/lib/services/stellar-price.service.ts b/apps/web-app/src/lib/services/stellar-price.service.ts index 3f8246b3..4e3affd2 100644 --- a/apps/web-app/src/lib/services/stellar-price.service.ts +++ b/apps/web-app/src/lib/services/stellar-price.service.ts @@ -1,18 +1,13 @@ -/** - * Stellar Price Service - * Handles token price fetching for Stellar: RWA Oracle and CoinGecko (non-RWA). - */ - import oracleClient from "@/lib/clients/oracle"; -import { PRICE_ERROR_DELAY_MS, RWA_TOKENS } from "@/lib/constants/wallet"; - -/** - * Map token codes to CoinGecko IDs (for non-RWA tokens) - */ -const TOKEN_PRICE_MAP: Record = { - XLM: "stellar", - USDC: "usd-coin", -}; +import { + PRICE_ERROR_DELAY_MS, + STABLECOIN_FALLBACK_USD, +} from "@/lib/constants/wallet"; +import { + getAssetsConfig, + getRwaTokenCodes, + getStablecoinCodes, +} from "@/lib/constants/assets.config"; const MAX_RETRIES = 3; const BASE_DELAY_MS = 1000; @@ -21,16 +16,10 @@ const REQUEST_TIMEOUT_MS = 10000; const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); export class StellarPriceService { - /** - * Whether the given token code is an RWA token (oracle-backed). - */ isRWAToken(tokenCode: string): boolean { - return RWA_TOKENS.includes(tokenCode); + return getRwaTokenCodes().includes(tokenCode); } - /** - * Get token price from RWA Oracle by contract address. - */ async getRWAOraclePrice(contractAddress: string): Promise { try { const asset: { tag: "Stellar"; values: readonly [string] } = { @@ -50,9 +39,11 @@ export class StellarPriceService { timestamp: bigint | string | number; }; } - | { tag: "None"; values: void }; + | { tag: "None"; values: void } + | null + | undefined; - if (optionResult.tag === "Some") { + if (optionResult?.tag === "Some") { const priceData = optionResult.values; let validTimestamp = Number(priceData.timestamp); const now = Math.floor(Date.now() / 1000); @@ -78,16 +69,13 @@ export class StellarPriceService { } } - /** - * Get token price in USD from CoinGecko (for non-RWA tokens). - * Uses retries with exponential backoff. No fallback prices; returns 0 on failure so UI can show an error. - */ async getTokenPrice(tokenCode: string, retryCount = 0): Promise { if (!tokenCode || typeof tokenCode !== "string") { return 0; } - const coinGeckoId = TOKEN_PRICE_MAP[tokenCode]; + const assets = getAssetsConfig(); + const coinGeckoId = assets[tokenCode]?.coinGeckoId; if (!coinGeckoId) { console.warn(`No CoinGecko ID mapping found for token: ${tokenCode}`); @@ -156,6 +144,31 @@ export class StellarPriceService { return 0; } } + + async getPrice(tokenCode: string, contractAddress?: string): Promise { + if (!tokenCode || typeof tokenCode !== "string") return 0; + + const assets = getAssetsConfig(); + const asset = assets[tokenCode]; + const isStablecoin = getStablecoinCodes().includes(tokenCode); + + if (asset?.priceSource === "oracle" && contractAddress) { + const price = await this.getRWAOraclePrice(contractAddress); + if (price > 0) return price; + if (isStablecoin) return STABLECOIN_FALLBACK_USD; + return 0; + } + + if (asset?.coinGeckoId) { + const price = await this.getTokenPrice(tokenCode); + if (price > 0) return price; + if (isStablecoin) return STABLECOIN_FALLBACK_USD; + return 0; + } + + if (isStablecoin) return STABLECOIN_FALLBACK_USD; + return 0; + } } export const stellarPriceService = new StellarPriceService(); diff --git a/apps/web-app/src/lib/theme/muiTheme.ts b/apps/web-app/src/lib/theme/muiTheme.ts deleted file mode 100644 index f2ce1344..00000000 --- a/apps/web-app/src/lib/theme/muiTheme.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { createTheme } from "@mui/material/styles"; - -export const nekoLightTheme = createTheme({ - palette: { - mode: "light", - primary: { - main: "#083dffff", - }, - background: { - default: "#ffffff", - paper: "#f9fafb", - }, - text: { - primary: "#081F5C", - secondary: "#7096D1", - }, - }, -}); diff --git a/apps/web-app/src/lib/toast.ts b/apps/web-app/src/lib/toast.ts new file mode 100644 index 00000000..ff42b852 --- /dev/null +++ b/apps/web-app/src/lib/toast.ts @@ -0,0 +1,47 @@ +import { sileo } from "sileo"; +import type { SileoOptions, SileoPosition } from "sileo"; + +export { sileo, Toaster } from "sileo"; +export type { SileoOptions, SileoPosition } from "sileo"; + +export type ToastType = "success" | "warning" | "info" | "error"; + +export type PromiseOpts = { + loading: SileoOptions; + success: SileoOptions | ((data: T) => SileoOptions); + error: SileoOptions | ((err: unknown) => SileoOptions); + position?: SileoPosition; +}; + +const TYPE_MAP: Record< + ToastType, + keyof Pick +> = { + success: "success", + error: "error", + warning: "warning", + info: "info", +}; + +export function notify( + message: string, + type: ToastType = "info", + options?: Partial +): string { + return sileo[TYPE_MAP[type]]({ title: message, ...options }); +} + +export function promise( + p: Promise | (() => Promise), + opts: PromiseOpts +): Promise { + return sileo.promise(p, opts); +} + +export function action( + message: string, + button: { title: string; onClick: () => void }, + options?: Partial +): string { + return sileo.action({ title: message, button, ...options }); +} diff --git a/apps/web-app/src/lib/types/lendingTypes.ts b/apps/web-app/src/lib/types/lendingTypes.ts deleted file mode 100644 index d153d07a..00000000 --- a/apps/web-app/src/lib/types/lendingTypes.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** - * Lending Service Types - * Type definitions for lending-related operations - */ - -export interface LendingOperationResult { - xdr: string; - error?: string; -} - -export interface CollateralOperationResult { - approveXdr: string; - addCollateralXdr: string; - error?: string; -} - -export interface BorrowWithCollateralResult { - approveXdr: string; - addCollateralXdr: string; - borrowXdr: string; - error?: string; -} diff --git a/apps/web-app/src/lib/types/priceTypes.ts b/apps/web-app/src/lib/types/priceTypes.ts deleted file mode 100644 index 7e6066f8..00000000 --- a/apps/web-app/src/lib/types/priceTypes.ts +++ /dev/null @@ -1,17 +0,0 @@ -/** - * Price Service Types - * Type definitions for price-related operations - */ - -export interface TokenPriceResult { - price: number; - source: string; - lastUpdated?: Date; -} - -export interface PriceFetchOptions { - /** Force refresh even if cached */ - forceRefresh?: boolean; - /** Timeout in milliseconds */ - timeout?: number; -} diff --git a/apps/web-app/src/lib/types/soroswapTypes.ts b/apps/web-app/src/lib/types/soroswapTypes.ts index 49f70c84..afd80c4b 100644 --- a/apps/web-app/src/lib/types/soroswapTypes.ts +++ b/apps/web-app/src/lib/types/soroswapTypes.ts @@ -1,11 +1,3 @@ -/** - * Soroswap Types - * Type definitions for Soroswap DEX integration - */ - -// ======================================== -// TOKEN INTERFACES -// ======================================== export interface Token { type: "native" | "contract"; code?: string; diff --git a/apps/web-app/src/lib/types/tokenTypes.ts b/apps/web-app/src/lib/types/tokenTypes.ts deleted file mode 100644 index 3d0dc6cf..00000000 --- a/apps/web-app/src/lib/types/tokenTypes.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** - * Token Service Types - * Type definitions for token-related operations - */ - -export interface TokenBalanceResult { - balance: string; - formattedBalance: string; - rawBalance: bigint; - symbol: string; - decimals: number; - error?: string; -} - -export interface TokenInfo { - address: string; - symbol: string; - decimals: number; - name?: string; - logoURI?: string; -} diff --git a/apps/web-app/src/providers/NotificationProvider.css b/apps/web-app/src/providers/NotificationProvider.css deleted file mode 100644 index bfae38b7..00000000 --- a/apps/web-app/src/providers/NotificationProvider.css +++ /dev/null @@ -1,39 +0,0 @@ -.notification-container { - position: fixed; - top: 20px; - right: 20px; - display: flex; - flex-direction: column; - gap: 10px; - z-index: 9999; - max-width: 400px; -} - -.notification { - transition: - transform 0.3s ease, - opacity 0.3s ease; - opacity: 1; - transform: translateX(0); -} - -.notification.slide-in { - transform: translateX(0); - animation: slideIn 0.3s ease; -} - -.notification.slide-out { - transform: translateX(100%); - opacity: 0; -} - -@keyframes slideIn { - from { - transform: translateX(100%); - opacity: 0; - } - to { - transform: translateX(0); - opacity: 1; - } -} diff --git a/apps/web-app/src/providers/NotificationProvider.tsx b/apps/web-app/src/providers/NotificationProvider.tsx deleted file mode 100644 index 99920da1..00000000 --- a/apps/web-app/src/providers/NotificationProvider.tsx +++ /dev/null @@ -1,100 +0,0 @@ -import React, { - createContext, - useState, - ReactNode, - useMemo, - useCallback, -} from "react"; -import { Notification as StellarNotification } from "@stellar/design-system"; -import "./NotificationProvider.css"; // Import CSS for sliding effect - -type NotificationType = - | "primary" - | "secondary" - | "success" - | "error" - | "warning"; -interface Notification { - id: string; - message: string; - type: NotificationType; - isVisible: boolean; -} - -interface NotificationContextType { - addNotification: (message: string, type: NotificationType) => void; -} - -const NotificationContext = createContext( - undefined -); - -export const NotificationProvider: React.FC<{ children: ReactNode }> = ({ - children, -}) => { - const [notifications, setNotifications] = useState([]); - - const addNotification = useCallback( - (message: string, type: NotificationType) => { - const newNotification = { - id: - (typeof crypto !== "undefined" && crypto.randomUUID?.()) || - `${type}-${Date.now().toString()}`, - message, - type, - isVisible: true, - }; - setNotifications((prev) => [...prev, newNotification]); - - setTimeout(() => { - setNotifications(markRead(newNotification.id)); - }, 2500); // Start transition out after 2.5 seconds - - setTimeout(() => { - setNotifications(filterOut(newNotification.id)); - }, 5000); // Remove after 5 seconds - }, - [] - ); - - const contextValue = useMemo(() => ({ addNotification }), [addNotification]); - - return ( - - {children} -
- {notifications.map((notification) => ( -
- -
- ))} -
-
- ); -}; - -function markRead( - id: Notification["id"] -): React.SetStateAction { - return (prev) => - prev.map((notification) => - notification.id === id - ? { ...notification, isVisible: true } - : notification - ); -} - -function filterOut( - id: Notification["id"] -): React.SetStateAction { - return (prev) => prev.filter((notification) => notification.id !== id); -} - -export { NotificationContext }; -export type { NotificationContextType }; diff --git a/apps/web-app/src/providers/ToastProvider.tsx b/apps/web-app/src/providers/ToastProvider.tsx new file mode 100644 index 00000000..75293d58 --- /dev/null +++ b/apps/web-app/src/providers/ToastProvider.tsx @@ -0,0 +1,39 @@ +"use client"; + +import { ReactNode } from "react"; +import { Toaster } from "sileo"; +import { TOAST_CONFIG } from "@/lib/constants/toast.config"; +import "sileo/styles.css"; +import "./toast-overrides.css"; + +export function ToastProvider({ children }: { children: ReactNode }) { + const { provider, viewport } = TOAST_CONFIG; + return ( + <> +