From 176650a580c9871cfba4b9c0959561f033964134 Mon Sep 17 00:00:00 2001 From: Sparexonzy95 <85989949+Sparexonzy95@users.noreply.github.com> Date: Sun, 28 Jun 2026 01:11:54 +0100 Subject: [PATCH] Implement deposit lock assets flow --- .github/workflows/e2e.yml | 6 +- e2e/farm.spec.ts | 77 +- e2e/playwright.config.ts | 4 +- next.config.ts | 6 +- pnpm-lock.yaml | 370 ++++++-- src/app/farm/EarningRow.tsx | 170 ++++ src/app/farm/[poolId]/page.tsx | 22 +- src/app/farm/page.test.tsx | 2 +- src/app/farm/page.tsx | 792 ++++++++---------- .../ConnectWalletButton.tsx | 59 +- .../ErrorBoundary/ErrorBoundary.tsx | 21 +- src/components/TvlChart/TvlChart.tsx | 6 +- src/components/UnlockModal/UnlockModal.tsx | 9 +- src/context/StellarWalletContext.tsx | 12 +- src/context/index.tsx | 23 +- src/hooks/useLockFlow.ts | 12 +- src/hooks/useSorobanQuery.ts | 86 +- src/lib/soroban.history.test.ts | 3 +- src/lib/soroban.test.ts | 67 ++ src/lib/soroban.ts | 289 ++++--- src/types/farm.ts | 2 + tsconfig.json | 12 +- vitest.config.ts | 6 +- 23 files changed, 1330 insertions(+), 726 deletions(-) create mode 100644 src/app/farm/EarningRow.tsx create mode 100644 src/lib/soroban.test.ts diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index acdac2a..914f5da 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -27,10 +27,10 @@ jobs: run: pnpm install --frozen-lockfile - name: Install Playwright browsers - run: pnpm playwright install chromium --with-deps + run: pnpm exec playwright install chromium --with-deps - name: Run Playwright E2E tests - run: pnpm playwright test + run: pnpm run playwright env: CI: true NEXT_PUBLIC_E2E: 'true' @@ -42,4 +42,4 @@ jobs: with: name: playwright-report path: playwright-report/ - retention-days: 7 + retention-days: 7 \ No newline at end of file diff --git a/e2e/farm.spec.ts b/e2e/farm.spec.ts index 18bf6b3..ffeee9e 100644 --- a/e2e/farm.spec.ts +++ b/e2e/farm.spec.ts @@ -1,4 +1,5 @@ import { type Page } from '@playwright/test'; +import { Networks, TransactionBuilder } from '@stellar/stellar-sdk'; import { test, expect, TEST_PUBLIC_KEY, TEST_ADDRESS_DISPLAY } from './mocks/freighter'; // Pre-computed XDR constants (generated with @stellar/stellar-sdk) @@ -8,23 +9,64 @@ const POOLS_XDR = // Account LedgerEntry XDR for getLedgerEntries mock response const ACCOUNT_XDR = - 'AAAAZAAAAAAAAAAANiHp+LugK9v5rC22OBtJciJwvEG0UfvI72cASeJqsYIAAAAXSHboAAAAAABJlgLSAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAA='; + 'AAAAAAAAAAA2Ien4u6Ar2/msLbY4G0lyInC8QbRR+8jvZwBJ4mqxggAAABdIdugAAAAAAEmWAtIAAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAA'; const ACCOUNT_KEY_XDR = 'AAAAAAAAAAA2Ien4u6Ar2/msLbY4G0lyInC8QbRR+8jvZwBJ4mqxgg=='; // SorobanTransactionData XDR for simulateTransaction response const SOROBAN_DATA_XDR = 'AAAAAAAAAAAAAAAAAA9CQAAAA+gAAAPoAAAAAAAAAGQ='; +const LOCK_ASSETS_AUTH_XDR = + 'AAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAALbG9ja19hc3NldHMAAAAAAAAAAAA='; +const UNLOCK_ASSETS_AUTH_XDR = + 'AAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAANdW5sb2NrX2Fzc2V0cwAAAAAAAAAAAAAA'; +const SUCCESS_RESULT_XDR = + 'AAAAAAAAAGQAAAAAAAAAAQAAAAAAAAAYAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=='; +const SUCCESS_META_XDR = 'AAAAAAAAAAA='; // Fixed "now" that matches the position's lockedAt offset (must stay in sync) const FIXED_NOW_MS = 1_750_000_000_000; +const CONNECT_WALLET_BUTTON_NAME = /connect (freighter|wallet)/i; + +function getSimulatedFunctionName(transactionXdr?: string): string | null { + if (!transactionXdr) return null; + + try { + const transaction = TransactionBuilder.fromXDR(transactionXdr, Networks.TESTNET); + const operation = transaction.operations[0] as { + type?: string; + func?: { + invokeContract?: () => { + functionName: () => { toString: () => string }; + }; + }; + }; + + if (operation.type !== 'invokeHostFunction') return null; + return operation.func?.invokeContract?.().functionName().toString() ?? null; + } catch { + return null; + } +} // ── RPC fetch mock ────────────────────────────────────────────────────────── async function mockSorobanRpc(page: Page): Promise { + let submittedTransactionXdr = ''; + + await page.route('**/horizon-testnet.stellar.org/accounts/**', async (route) => { + await route.fulfill({ + contentType: 'application/json', + body: JSON.stringify({ + balances: [{ asset_type: 'native', balance: '100.0000000' }], + }), + }); + }); + await page.route('**/soroban-testnet.stellar.org**', async (route) => { const body = JSON.parse(route.request().postData() ?? '{}') as { id: number; method: string; + params?: { transaction?: string }; }; let result: unknown; @@ -44,19 +86,29 @@ async function mockSorobanRpc(page: Page): Promise { break; case 'simulateTransaction': + const functionName = getSimulatedFunctionName(body.params?.transaction); + const auth = + functionName === 'lock_assets' + ? [LOCK_ASSETS_AUTH_XDR] + : functionName === 'unlock_assets' + ? [UNLOCK_ASSETS_AUTH_XDR] + : []; // Works for get_pools, get_user_position, and unlock_assets alike. // get_user_position parsing ignores a Vec retval and returns null (no position), // so positions come exclusively from the QueryClient seed in tests. result = { + id: String(body.id), transactionData: SOROBAN_DATA_XDR, - results: [{ xdr: 'AAAAAQ==', auth: [] }], // scvVoid + results: [{ xdr: 'AAAAAQ==', auth }], // scvVoid minResourceFee: '100', + events: [], cost: { cpuInsns: '1000', memBytes: '1000' }, latestLedger: 100, }; break; case 'sendTransaction': + submittedTransactionXdr = body.params?.transaction ?? ''; result = { hash: 'a'.repeat(64), status: 'PENDING', @@ -67,7 +119,14 @@ async function mockSorobanRpc(page: Page): Promise { case 'getTransaction': result = { + applicationOrder: 0, + createdAt: 0, + envelopeXdr: submittedTransactionXdr, + feeBump: false, + resultMetaXdr: SUCCESS_META_XDR, + resultXdr: SUCCESS_RESULT_XDR, status: 'SUCCESS', + txHash: 'a'.repeat(64), latestLedger: 101, latestLedgerCloseTime: '0', ledger: 101, @@ -133,7 +192,7 @@ async function seedPosition(page: Page, lockedAtMs: number): Promise { } async function connectWallet(page: Page): Promise { - await page.getByRole('button', { name: /connect freighter/i }).click(); + await page.getByRole('button', { name: CONNECT_WALLET_BUTTON_NAME }).first().click(); await page.waitForFunction( (addr) => document.body.textContent?.includes(addr), TEST_ADDRESS_DISPLAY, @@ -151,7 +210,7 @@ test.describe('Farm E2E', () => { // Connect button is visible before connection await expect( - page.getByRole('button', { name: /connect freighter/i }), + page.getByRole('button', { name: CONNECT_WALLET_BUTTON_NAME }).first(), ).toBeVisible(); await connectWallet(page); @@ -170,7 +229,7 @@ test.describe('Farm E2E', () => { await seedPools(page); // Wait for pool row with Deposit button - const depositBtn = page.getByRole('button', { name: /^deposit$/i }).first(); + const depositBtn = page.getByRole('button', { name: /^\+ deposit$/i }).first(); await expect(depositBtn).toBeVisible({ timeout: 8_000 }); await depositBtn.click(); @@ -180,7 +239,7 @@ test.describe('Farm E2E', () => { await expect(amountInput).toHaveValue('10'); // Click the deposit/lock button inside the modal - const submitBtn = page.getByRole('button', { name: /deposit 10/i }); + const submitBtn = page.getByRole('button', { name: /deposit with freighter/i }); await expect(submitBtn).toBeVisible(); await submitBtn.click(); @@ -194,7 +253,7 @@ test.describe('Farm E2E', () => { await seedPosition(page, FIXED_NOW_MS - 60_000); // My earnings row now shows the staked amount - await expect(page.getByText('10.0000000')).toBeVisible({ timeout: 5_000 }); + await expect(page.getByText('10.0000000').last()).toBeVisible({ timeout: 5_000 }); }); test('3 · countdown visible — Unlock button is disabled before lock period', async ({ page }) => { @@ -282,7 +341,7 @@ test.describe('Farm E2E', () => { // Modal shows unlock confirmed badge or closes await expect( - page.getByText(/unlock (confirmed|submitted)/i).or(page.getByText(/unlock submitted/i)), + page.getByText(/unlock (confirmed|submitted)/i).first(), ).toBeVisible({ timeout: 15_000 }); // After success, update cache to show 0 stake @@ -305,6 +364,6 @@ test.describe('Farm E2E', () => { ); // "My earnings" now shows 0.0000000 - await expect(page.getByText('0.0000000')).toBeVisible({ timeout: 5_000 }); + await expect(page.getByText('0.0000000').last()).toBeVisible({ timeout: 5_000 }); }); }); diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts index 3644eeb..45bee79 100644 --- a/e2e/playwright.config.ts +++ b/e2e/playwright.config.ts @@ -1,8 +1,8 @@ import { defineConfig, devices } from '@playwright/test'; export default defineConfig({ - testDir: './e2e', - testMatch: '**/*.spec.ts', + testDir: '.', + testMatch: ['**/*.spec.ts', '**/*.test.ts', '**/*.e2e.ts'], timeout: 30_000, retries: process.env.CI ? 2 : 0, use: { diff --git a/next.config.ts b/next.config.ts index c8bc026..8ec00a8 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,13 +1,13 @@ import type { NextConfig } from "next"; const raw = process.env.BASE_PATH?.trim() ?? ""; -const basePath = raw.startsWith("/") ? raw : raw ? / : ""; +const basePath = raw.startsWith("/") ? raw : raw ? `/${raw}` : ""; const CSP_POLICY = [ "default-src 'self'", - "script-src 'self' 'unsafe-eval'", + "script-src 'self' 'unsafe-eval' 'unsafe-inline'", "style-src 'self' 'unsafe-inline'", - "connect-src 'self' https://horizon.stellar.org https://soroban-testnet.stellar.org https://soroban.stellar.org https://stellar.expert", + "connect-src 'self' https://horizon.stellar.org https://horizon-testnet.stellar.org https://soroban-testnet.stellar.org https://soroban.stellar.org https://stellar.expert", "img-src 'self' data: https:", "font-src 'self'", "frame-src 'none'", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2294d7a..e5123d1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -44,6 +44,9 @@ importers: react-dom: specifier: ^19.0.0 version: 19.2.7(react@19.2.7) + recharts: + specifier: ^3.9.0 + version: 3.9.0(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react-is@17.0.2)(react@19.2.7)(redux@5.0.1) soroban-client: specifier: ^1.0.1 version: 1.0.1 @@ -52,7 +55,7 @@ importers: version: 0.14.4(bufferutil@4.1.0)(utf-8-validate@5.0.10) zustand: specifier: ^5.0.14 - version: 5.0.14(@types/react@19.2.17)(react@19.2.7) + version: 5.0.14(@types/react@19.2.17)(immer@11.1.8)(react@19.2.7)(use-sync-external-store@1.6.0(react@19.2.7)) devDependencies: '@axe-core/playwright': specifier: ^4.12.1 @@ -478,105 +481,89 @@ packages: resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} cpu: [arm64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-arm@1.2.4': resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} cpu: [arm] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-ppc64@1.2.4': resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} cpu: [ppc64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-riscv64@1.2.4': resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} cpu: [riscv64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-s390x@1.2.4': resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} cpu: [s390x] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-x64@1.2.4': resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} cpu: [x64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linuxmusl-arm64@1.2.4': resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} cpu: [arm64] os: [linux] - libc: [musl] '@img/sharp-libvips-linuxmusl-x64@1.2.4': resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} cpu: [x64] os: [linux] - libc: [musl] '@img/sharp-linux-arm64@0.34.5': resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] - libc: [glibc] '@img/sharp-linux-arm@0.34.5': resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm] os: [linux] - libc: [glibc] '@img/sharp-linux-ppc64@0.34.5': resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [ppc64] os: [linux] - libc: [glibc] '@img/sharp-linux-riscv64@0.34.5': resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [riscv64] os: [linux] - libc: [glibc] '@img/sharp-linux-s390x@0.34.5': resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [s390x] os: [linux] - libc: [glibc] '@img/sharp-linux-x64@0.34.5': resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] - libc: [glibc] '@img/sharp-linuxmusl-arm64@0.34.5': resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] - libc: [musl] '@img/sharp-linuxmusl-x64@0.34.5': resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] - libc: [musl] '@img/sharp-wasm32@0.34.5': resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} @@ -643,28 +630,24 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [glibc] '@next/swc-linux-arm64-musl@15.5.18': resolution: {integrity: sha512-glaCczEWIrHsokFZ3pP08U4BpKxwIdnT+txdOM32OBgpL9Yw4aqx8NejmgtZQZOdstQ5f0L3CasIZudzCuD+nw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [musl] '@next/swc-linux-x64-gnu@15.5.18': resolution: {integrity: sha512-oUfg2EgJmU3R0OCOWiokGFUTvZiPfXtriXiuF3YNxRoROCdgvTedHIzYoeKH34gsZxS/V7mHbfq2hpAHwhH1/A==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [glibc] '@next/swc-linux-x64-musl@15.5.18': resolution: {integrity: sha512-JLxSP3KTd9iu/bvUMQxH7RJo9xKSHf55/6RPE4a6FTSZygGn7uvZbCej0AHXydwkggQGSD9UddSjwv6Xz5ESfA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [musl] '@next/swc-win32-arm64-msvc@15.5.18': resolution: {integrity: sha512-ir1v7enP52K2HNz3tQQvwF+x7VNxBk1ciiZ18WBPvxf4C59IqdfmHPJYK3vH7rSxpuCVw/8C712wTXNAtEp+NA==} @@ -747,6 +730,17 @@ packages: '@protobufjs/utf8@1.1.1': resolution: {integrity: sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==} + '@reduxjs/toolkit@2.12.0': + resolution: {integrity: sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==} + peerDependencies: + react: ^16.9.0 || ^17.0.0 || ^18 || ^19 + react-redux: ^7.2.1 || ^8.1.3 || ^9.0.0 + peerDependenciesMeta: + react: + optional: true + react-redux: + optional: true + '@rolldown/binding-android-arm64@1.1.3': resolution: {integrity: sha512-DT6Z3PhvioeHMvxo+xHc3KtqggrI7CCTXCmC2h/5zUlp5jVitv7XEy+9q5/7v8IolhlioawpMo8Kg0EEBy7J0g==} engines: {node: ^20.19.0 || >=22.12.0} @@ -782,42 +776,36 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - libc: [glibc] '@rolldown/binding-linux-arm64-musl@1.1.3': resolution: {integrity: sha512-BO9+oPL8K9poZJBfYPsXNtYjPE5uM3qeehT3aFcW4LITOl+iSqhp0abzjR2nWBUNjIZeKXjAEWBZ64WjNoHd6w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - libc: [musl] '@rolldown/binding-linux-ppc64-gnu@1.1.3': resolution: {integrity: sha512-f3VpLB1vQ0Eo6ecr/6cekLnvYMFF4YBFoVGkfkvPLq1bAkbAwHYQPZKoAmG6OJyTcxxoC+AvezGx/S1obNC0Mw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] - libc: [glibc] '@rolldown/binding-linux-s390x-gnu@1.1.3': resolution: {integrity: sha512-AmurZ26Pqx/RI9N1gzEOCklkKXl927yjfXWUUS0O7Puh8ARM/Ob8qfrD3qnWksScdw6cSrW5PSHE9DyLu7+PtA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] - libc: [glibc] '@rolldown/binding-linux-x64-gnu@1.1.3': resolution: {integrity: sha512-JJpqs8bRGITDOdbkNKnlojzBabbOHrqjSvDr0IVsZObE1lBcPjxItUEY9eWIDbxaJ3cGrXPWGfGkIxFijg/URg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [glibc] '@rolldown/binding-linux-x64-musl@1.1.3': resolution: {integrity: sha512-rSJcdjPxzA/by/6/rYs+v+bXU7UjvnbUWz8MJb6kh6+knqB1dCrtHg0uu7C/4haqJvqdkYHQ5IGn+tCH9GLW/g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [musl] '@rolldown/binding-openharmony-arm64@1.1.3': resolution: {integrity: sha512-hQ3/PYkDJICgevvyNcVrihVeqq7k1Pp3VZ9lY+dauAYUJKO+auqApvANhvR1An9BhmqYKvW2Mu1F9u4DXSMLxQ==} @@ -874,6 +862,9 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@standard-schema/utils@0.3.0': + resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==} + '@stellar/freighter-api@1.7.1': resolution: {integrity: sha512-XvPO+XgEbkeP0VhP0U1edOkds+rGS28+y8GRGbCVXeZ9ZslbWqRFQoETAdX8IXGuykk2ib/aPokiLc5ZaWYP7w==} @@ -969,6 +960,33 @@ packages: '@types/cors@2.8.19': resolution: {integrity: sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==} + '@types/d3-array@3.2.2': + resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} + + '@types/d3-color@3.1.3': + resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} + + '@types/d3-ease@3.0.2': + resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==} + + '@types/d3-interpolate@3.0.4': + resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==} + + '@types/d3-path@3.1.1': + resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==} + + '@types/d3-scale@4.0.9': + resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==} + + '@types/d3-shape@3.1.8': + resolution: {integrity: sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==} + + '@types/d3-time@3.0.4': + resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==} + + '@types/d3-timer@3.0.2': + resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} + '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} @@ -1028,6 +1046,9 @@ packages: '@types/ssh2@1.15.5': resolution: {integrity: sha512-N1ASjp/nXH3ovBHddRJpli4ozpk6UdDYIX4RJWFa9L1YKnzdhTlVmiGHm4DZnj/jLbqZpes4aeR30EFGQtvhQQ==} + '@types/use-sync-external-store@0.0.6': + resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==} + '@typescript-eslint/eslint-plugin@8.62.0': resolution: {integrity: sha512-o+mpz7EYiMzXoySXiKmzlabIvTVqUuK5yLrAedRPRDA0IpPFMUV1IXt6OqljIxX/kumN6EjUYp41Hqelh6p/Dw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -1129,61 +1150,51 @@ packages: resolution: {integrity: sha512-zJc0H99FEPoFfSrNpa91HYfxzfAJCr502oxNK1cfdC9hlaFI43RT+JFCann9JUgZmLzzntChHyn13Sgn9ljHNg==} cpu: [arm64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-arm64-musl@1.12.2': resolution: {integrity: sha512-KQ3Lki6l+Pz1k/eBipN41ES+YUK30beLGb9YqcB1O542cyLCNE6GaxrfcY3T6EezmGGk84wb5XyO9loTM9tkcA==} cpu: [arm64] os: [linux] - libc: [musl] '@unrs/resolver-binding-linux-loong64-gnu@1.12.2': resolution: {integrity: sha512-3SJGEh1DborhG6pyxvhPzCT4bbSIVihsvgJc13P1bHG7KLdNDaF9T3gsTwFc7Jw/5Y5/iWOjkEx7Zy0NvCGX3Q==} cpu: [loong64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-loong64-musl@1.12.2': resolution: {integrity: sha512-jiuG/Obbel7uw1PwHNFfrkiKhLAF6mnyZ6aWlOAVN9WqKm8v0OFGnciJIHu8+CMvXLQ8AD51LPzAoUfT21D5Ew==} cpu: [loong64] os: [linux] - libc: [musl] '@unrs/resolver-binding-linux-ppc64-gnu@1.12.2': resolution: {integrity: sha512-q7xRvVpmcfeL+LlZg8Pbbo6QaTZwDU5BaGZbwfhkEsXJn3Was8xYfE0RBH266xZt0rM6B7i8xAYIvjthuUIWHg==} cpu: [ppc64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-riscv64-gnu@1.12.2': resolution: {integrity: sha512-0CVdx6lcnT3Q9inOH8tsMIOJ6ImndllMjqJHg8RLVdB7Vq4SfkEXl9mCSsVNuNA4MCYycRicCUxPCabVHJRr6A==} cpu: [riscv64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-riscv64-musl@1.12.2': resolution: {integrity: sha512-iOwlRo9vnp6R6ohHQS11n0NnfdXx/omhkocmIfaPRpQhKZ+3BDMkkdRVh53qjkFkpPddf+FETA28NwGN7l5l+w==} cpu: [riscv64] os: [linux] - libc: [musl] '@unrs/resolver-binding-linux-s390x-gnu@1.12.2': resolution: {integrity: sha512-HYJtLfXq94q8iZNFT1lknx258wlkkWhZeUXJRqzKBBUJ00CvZ+N33zgbCqimLjsyw5Va6uUxhVa12mI+kaveEw==} cpu: [s390x] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-x64-gnu@1.12.2': resolution: {integrity: sha512-mPsUhunKKDih5O96Y6enDQyHc1SqBPlY1E/SfMWDM3EdJ95Z9CArPeCVwCCqbP45ljvivdEk8Fxn+SIb1rDAJQ==} cpu: [x64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-x64-musl@1.12.2': resolution: {integrity: sha512-azrt6+5ydLd8Vt210AAFis/lZevSfPw93EJRIJG+xPu4WCJ8K0kppCTpMyLPcKT7H15M4Jnt2tMp5bOvCkRC6A==} cpu: [x64] os: [linux] - libc: [musl] '@unrs/resolver-binding-openharmony-arm64@1.12.2': resolution: {integrity: sha512-YZ9hP4O0X9PQb8eO980qmLNGH4zT3I9+SZTdt0Pr0YyuGQhYKoOZkV02VzrzyOZJ5xIJ3UFIenKkUkGg8GjgWQ==} @@ -1627,6 +1638,10 @@ packages: clone-response@1.0.3: resolution: {integrity: sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==} + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -1739,6 +1754,50 @@ packages: resolution: {integrity: sha512-TVF6svNzeQCOpjCqsy0/CSy8VgObG3wXusJ73xW2GbG5rGx7lC8zxDSURicsXI2UsGdi2L0QNRCi745/wUDvsA==} engines: {node: '>=0.4.0'} + d3-array@3.2.4: + resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==} + engines: {node: '>=12'} + + d3-color@3.1.0: + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} + engines: {node: '>=12'} + + d3-ease@3.0.1: + resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} + engines: {node: '>=12'} + + d3-format@3.1.2: + resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==} + engines: {node: '>=12'} + + d3-interpolate@3.0.1: + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} + engines: {node: '>=12'} + + d3-path@3.1.0: + resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==} + engines: {node: '>=12'} + + d3-scale@4.0.2: + resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==} + engines: {node: '>=12'} + + d3-shape@3.2.0: + resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==} + engines: {node: '>=12'} + + d3-time-format@4.1.0: + resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==} + engines: {node: '>=12'} + + d3-time@3.1.0: + resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==} + engines: {node: '>=12'} + + d3-timer@3.0.1: + resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} + engines: {node: '>=12'} + d@1.0.2: resolution: {integrity: sha512-MOqHvMWF9/9MX6nza0KgvFH4HpMU0EF5uUDXqX/BtxtU8NfB0QzRtJ8Oe/6SuS4kbhyzVJwjd97EA4PKrzJ8bw==} engines: {node: '>=0.12'} @@ -1800,6 +1859,9 @@ packages: supports-color: optional: true + decimal.js-light@2.5.1: + resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==} + decimal.js@10.6.0: resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} @@ -1998,6 +2060,9 @@ packages: resolution: {integrity: sha512-CxN9N56HYfd2m/acc/NOFrZQsN9kU4eh+2kk6A707Kz1krH8tKmfrs5RnftB8WNX80T0NS7vSQsDOlg23diR2g==} engines: {node: '>= 0.4'} + es-toolkit@1.49.0: + resolution: {integrity: sha512-G5iZ6Pc/FNRY/soKZHC+TxGDD83rHUDXxzaWhGCX44vAv/tMs56WMusnm/KMNK+luUPsgA9U28cGr4RDlSzL2g==} + es5-ext@0.10.64: resolution: {integrity: sha512-p2snDhiLaXe6dahss1LddxqEm+SkuDvV8dnIQG0MWjyHpcMNfXKPE+/Cc0y+PhxJX3A4xGNeFCj5oc0BUh6deg==} engines: {node: '>=0.10'} @@ -2172,6 +2237,9 @@ packages: eventemitter3@4.0.4: resolution: {integrity: sha512-rlaVLnVxtxvoyLsQQFBx53YmXHDxRIzzTLbdfxqi4yocpSjAxXwkU0cScM5JgSKMqEhrZpnvQ2D9gjylR0AimQ==} + eventemitter3@5.0.4: + resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + eventsource-parser@3.1.0: resolution: {integrity: sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==} engines: {node: '>=18.0.0'} @@ -2563,6 +2631,12 @@ packages: resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} engines: {node: '>= 4'} + immer@10.2.0: + resolution: {integrity: sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==} + + immer@11.1.8: + resolution: {integrity: sha512-/tbkHMW7y10Lx6i1crLjD4/OhNkRG+Fo7byZHtah0547nIeXYcpIXaUh0IAQY6gO5459qpGGYapcEOHtFXkIuA==} + import-fresh@3.3.1: resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} engines: {node: '>=6'} @@ -2590,6 +2664,10 @@ packages: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} engines: {node: '>= 0.4'} + internmap@2.0.3: + resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} + engines: {node: '>=12'} + ip-regex@4.3.0: resolution: {integrity: sha512-B9ZWJxHHOHUhUjCPrMpLD4xEq35bUTClHM1S6CBU5ixQnkZmwipwgc96vAd7AAGM9TGHvJR+Uss+/Ak6UphK+Q==} engines: {node: '>=8'} @@ -2923,28 +3001,24 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [glibc] lightningcss-linux-arm64-musl@1.32.0: resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [musl] lightningcss-linux-x64-gnu@1.32.0: resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [glibc] lightningcss-linux-x64-musl@1.32.0: resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [musl] lightningcss-win32-arm64-msvc@1.32.0: resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} @@ -3563,6 +3637,18 @@ packages: react-is@17.0.2: resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + react-redux@9.3.0: + resolution: {integrity: sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==} + peerDependencies: + '@types/react': ^18.2.25 || ^19 + react: ^18.0 || ^19 + redux: ^5.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + redux: + optional: true + react-remove-scroll-bar@2.3.8: resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==} engines: {node: '>=10'} @@ -3604,6 +3690,22 @@ packages: resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} engines: {node: '>= 6'} + recharts@3.9.0: + resolution: {integrity: sha512-dCEcE9y20c8H2tkVeByrAXhhnBJk6/QLbxKmn+dJUptOfc5NMjwRh1jo0vZPRLD+5dMrHrP+hPEsfbGBMfnf5Q==} + engines: {node: '>=18'} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-is: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + redux-thunk@3.1.0: + resolution: {integrity: sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==} + peerDependencies: + redux: ^5.0.0 + + redux@5.0.1: + resolution: {integrity: sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==} + reflect.getprototypeof@1.0.10: resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} engines: {node: '>= 0.4'} @@ -3629,6 +3731,9 @@ packages: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} + reselect@5.2.0: + resolution: {integrity: sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==} + resolve-alpn@1.2.1: resolution: {integrity: sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==} @@ -4030,6 +4135,9 @@ packages: resolution: {integrity: sha512-G7r3AhovYtr5YKOWQkta8RKAPb+J9IsO4uVmzjl8AZwfhs8UcUwTiD6gcJYSgOtzyjvQKrKYn41syHbUWMkafA==} engines: {node: '>=0.10.0'} + tiny-invariant@1.3.3: + resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} + tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -4231,6 +4339,11 @@ packages: '@types/react': optional: true + use-sync-external-store@1.6.0: + resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + utf-8-validate@5.0.10: resolution: {integrity: sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ==} engines: {node: '>=6.14.2'} @@ -4279,6 +4392,9 @@ packages: resolution: {integrity: sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw==} engines: {'0': node >=0.6.0} + victory-vendor@37.3.6: + resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==} + vite@8.1.0: resolution: {integrity: sha512-BuJcQK/56NQTWDGn4ABea3q4SSBdNPWwNZKTkkUpcMPnLoquSYH8llRtSUIgoL1KSCpHt5eghLShn50mH36y7Q==} engines: {node: ^20.19.0 || >=22.12.0} @@ -5385,6 +5501,18 @@ snapshots: '@protobufjs/utf8@1.1.1': {} + '@reduxjs/toolkit@2.12.0(react-redux@9.3.0(@types/react@19.2.17)(react@19.2.7)(redux@5.0.1))(react@19.2.7)': + dependencies: + '@standard-schema/spec': 1.1.0 + '@standard-schema/utils': 0.3.0 + immer: 11.1.8 + redux: 5.0.1 + redux-thunk: 3.1.0(redux@5.0.1) + reselect: 5.2.0 + optionalDependencies: + react: 19.2.7 + react-redux: 9.3.0(@types/react@19.2.17)(react@19.2.7)(redux@5.0.1) + '@rolldown/binding-android-arm64@1.1.3': optional: true @@ -5456,6 +5584,8 @@ snapshots: '@standard-schema/spec@1.1.0': {} + '@standard-schema/utils@0.3.0': {} + '@stellar/freighter-api@1.7.1': {} '@stellar/freighter-api@3.1.0': @@ -5591,6 +5721,30 @@ snapshots: dependencies: '@types/node': 20.19.43 + '@types/d3-array@3.2.2': {} + + '@types/d3-color@3.1.3': {} + + '@types/d3-ease@3.0.2': {} + + '@types/d3-interpolate@3.0.4': + dependencies: + '@types/d3-color': 3.1.3 + + '@types/d3-path@3.1.1': {} + + '@types/d3-scale@4.0.9': + dependencies: + '@types/d3-time': 3.0.4 + + '@types/d3-shape@3.1.8': + dependencies: + '@types/d3-path': 3.1.1 + + '@types/d3-time@3.0.4': {} + + '@types/d3-timer@3.0.2': {} + '@types/deep-eql@4.0.2': {} '@types/elliptic@6.4.18': @@ -5651,6 +5805,8 @@ snapshots: dependencies: '@types/node': 18.19.130 + '@types/use-sync-external-store@0.0.6': {} + '@typescript-eslint/eslint-plugin@8.62.0(@typescript-eslint/parser@8.62.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 @@ -6323,6 +6479,8 @@ snapshots: dependencies: mimic-response: 1.0.1 + clsx@2.1.1: {} + color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -6447,6 +6605,44 @@ snapshots: cycle@1.0.3: {} + d3-array@3.2.4: + dependencies: + internmap: 2.0.3 + + d3-color@3.1.0: {} + + d3-ease@3.0.1: {} + + d3-format@3.1.2: {} + + d3-interpolate@3.0.1: + dependencies: + d3-color: 3.1.0 + + d3-path@3.1.0: {} + + d3-scale@4.0.2: + dependencies: + d3-array: 3.2.4 + d3-format: 3.1.2 + d3-interpolate: 3.0.1 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + + d3-shape@3.2.0: + dependencies: + d3-path: 3.1.0 + + d3-time-format@4.1.0: + dependencies: + d3-time: 3.1.0 + + d3-time@3.1.0: + dependencies: + d3-array: 3.2.4 + + d3-timer@3.0.1: {} + d@1.0.2: dependencies: es5-ext: 0.10.64 @@ -6499,6 +6695,8 @@ snapshots: dependencies: ms: 2.1.3 + decimal.js-light@2.5.1: {} + decimal.js@10.6.0: {} decode-uri-component@0.2.2: {} @@ -6800,6 +6998,8 @@ snapshots: is-date-object: 1.1.0 is-symbol: 1.1.1 + es-toolkit@1.49.0: {} + es5-ext@0.10.64: dependencies: es6-iterator: 2.0.3 @@ -6832,8 +7032,8 @@ snapshots: '@typescript-eslint/parser': 8.62.0(eslint@8.57.1)(typescript@5.9.3) eslint: 8.57.1 eslint-import-resolver-node: 0.3.10 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.62.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.62.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.62.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.62.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1) eslint-plugin-jsx-a11y: 6.10.2(eslint@8.57.1) eslint-plugin-react: 7.37.5(eslint@8.57.1) eslint-plugin-react-hooks: 5.2.0(eslint@8.57.1) @@ -6852,7 +7052,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.62.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1): + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1): dependencies: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.3 @@ -6863,22 +7063,22 @@ snapshots: tinyglobby: 0.2.17 unrs-resolver: 1.12.2 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.62.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.62.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.62.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1) transitivePeerDependencies: - supports-color - eslint-module-utils@2.13.0(@typescript-eslint/parser@8.62.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.62.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1): + eslint-module-utils@2.13.0(@typescript-eslint/parser@8.62.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1): dependencies: debug: 3.2.7 optionalDependencies: '@typescript-eslint/parser': 8.62.0(eslint@8.57.1)(typescript@5.9.3) eslint: 8.57.1 eslint-import-resolver-node: 0.3.10 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.62.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1) transitivePeerDependencies: - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.62.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.62.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.62.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -6889,7 +7089,7 @@ snapshots: doctrine: 2.1.0 eslint: 8.57.1 eslint-import-resolver-node: 0.3.10 - eslint-module-utils: 2.13.0(@typescript-eslint/parser@8.62.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.62.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) + eslint-module-utils: 2.13.0(@typescript-eslint/parser@8.62.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1) hasown: 2.0.4 is-core-module: 2.16.2 is-glob: 4.0.3 @@ -7101,6 +7301,8 @@ snapshots: eventemitter3@4.0.4: {} + eventemitter3@5.0.4: {} + eventsource-parser@3.1.0: {} eventsource@2.0.2: {} @@ -7587,6 +7789,10 @@ snapshots: ignore@7.0.5: {} + immer@10.2.0: {} + + immer@11.1.8: {} + import-fresh@3.3.1: dependencies: parent-module: 1.0.1 @@ -7616,6 +7822,8 @@ snapshots: hasown: 2.0.4 side-channel: 1.1.1 + internmap@2.0.3: {} + ip-regex@4.3.0: {} ipaddr.js@1.9.1: {} @@ -8570,6 +8778,15 @@ snapshots: react-is@17.0.2: {} + react-redux@9.3.0(@types/react@19.2.17)(react@19.2.7)(redux@5.0.1): + dependencies: + '@types/use-sync-external-store': 0.0.6 + react: 19.2.7 + use-sync-external-store: 1.6.0(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + redux: 5.0.1 + react-remove-scroll-bar@2.3.8(@types/react@19.2.17)(react@19.2.7): dependencies: react: 19.2.7 @@ -8615,6 +8832,32 @@ snapshots: string_decoder: 1.3.0 util-deprecate: 1.0.2 + recharts@3.9.0(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react-is@17.0.2)(react@19.2.7)(redux@5.0.1): + dependencies: + '@reduxjs/toolkit': 2.12.0(react-redux@9.3.0(@types/react@19.2.17)(react@19.2.7)(redux@5.0.1))(react@19.2.7) + clsx: 2.1.1 + decimal.js-light: 2.5.1 + es-toolkit: 1.49.0 + eventemitter3: 5.0.4 + immer: 10.2.0 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + react-is: 17.0.2 + react-redux: 9.3.0(@types/react@19.2.17)(react@19.2.7)(redux@5.0.1) + reselect: 5.2.0 + tiny-invariant: 1.3.3 + use-sync-external-store: 1.6.0(react@19.2.7) + victory-vendor: 37.3.6 + transitivePeerDependencies: + - '@types/react' + - redux + + redux-thunk@3.1.0(redux@5.0.1): + dependencies: + redux: 5.0.1 + + redux@5.0.1: {} + reflect.getprototypeof@1.0.10: dependencies: call-bind: 1.0.9 @@ -8669,6 +8912,8 @@ snapshots: require-from-string@2.0.2: {} + reselect@5.2.0: {} + resolve-alpn@1.2.1: {} resolve-from@4.0.0: {} @@ -9248,6 +9493,8 @@ snapshots: timed-out@4.0.1: {} + tiny-invariant@1.3.3: {} + tinybench@2.9.0: {} tinyexec@1.2.4: {} @@ -9459,6 +9706,10 @@ snapshots: optionalDependencies: '@types/react': 19.2.17 + use-sync-external-store@1.6.0(react@19.2.7): + dependencies: + react: 19.2.7 + utf-8-validate@5.0.10: dependencies: node-gyp-build: 4.8.4 @@ -9495,6 +9746,23 @@ snapshots: core-util-is: 1.0.2 extsprintf: 1.3.0 + victory-vendor@37.3.6: + dependencies: + '@types/d3-array': 3.2.2 + '@types/d3-ease': 3.0.2 + '@types/d3-interpolate': 3.0.4 + '@types/d3-scale': 4.0.9 + '@types/d3-shape': 3.1.8 + '@types/d3-time': 3.0.4 + '@types/d3-timer': 3.0.2 + d3-array: 3.2.4 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-scale: 4.0.2 + d3-shape: 3.2.0 + d3-time: 3.1.0 + d3-timer: 3.0.1 + vite@8.1.0(@types/node@20.19.43): dependencies: lightningcss: 1.32.0 @@ -9916,7 +10184,9 @@ snapshots: yocto-queue@0.1.0: {} - zustand@5.0.14(@types/react@19.2.17)(react@19.2.7): + zustand@5.0.14(@types/react@19.2.17)(immer@11.1.8)(react@19.2.7)(use-sync-external-store@1.6.0(react@19.2.7)): optionalDependencies: '@types/react': 19.2.17 + immer: 11.1.8 react: 19.2.7 + use-sync-external-store: 1.6.0(react@19.2.7) diff --git a/src/app/farm/EarningRow.tsx b/src/app/farm/EarningRow.tsx new file mode 100644 index 0000000..db5690a --- /dev/null +++ b/src/app/farm/EarningRow.tsx @@ -0,0 +1,170 @@ +"use client"; + +import { useCountdown } from "@/hooks/useCountdown"; +import { useFarmStore } from "@/store/farmStore"; +import { + type FarmPosition, + unlockAvailableAt, +} from "@/types/farm"; +import { + Box, + Button, + Flex, + Text, + Tooltip, +} from "@chakra-ui/react"; +import { memo, type ReactNode } from "react"; + +export function MetricColumn({ + label, + value, + minW = "110px", +}: { + label: string; + value: ReactNode; + minW?: string; +}) { + return ( + + + {label} + + + {value} + + + ); +} + +type EarningRowProps = { + position: FarmPosition; +}; + +function earningRowPropsAreEqual( + previous: EarningRowProps, + next: EarningRowProps, +) { + const previousPosition = previous.position; + const nextPosition = next.position; + + return ( + previousPosition.id === nextPosition.id && + previousPosition.contractAddress === nextPosition.contractAddress && + previousPosition.name === nextPosition.name && + previousPosition.img === nextPosition.img && + previousPosition.earned === nextPosition.earned && + previousPosition.stake === nextPosition.stake && + previousPosition.dailyRate === nextPosition.dailyRate && + previousPosition.totalStakedLiquidity === nextPosition.totalStakedLiquidity && + previousPosition.symbol === nextPosition.symbol && + previousPosition.lockedAmount === nextPosition.lockedAmount && + previousPosition.lockedAt === nextPosition.lockedAt && + previousPosition.lockPeriodSeconds === nextPosition.lockPeriodSeconds + ); +} + +export const EarningRow = memo(function EarningRow({ + position, +}: EarningRowProps) { + const openUnlock = useFarmStore((s) => s.openUnlock); + const countdown = useCountdown(unlockAvailableAt(position)); + const hasStake = position.lockedAmount > 0; + const canUnlock = hasStake && countdown.isElapsed; + + return ( + + + {position.name} + + + + + + {hasStake && ( + + + Unlock status + + + {countdown.label} + + + )} + + + + + + + + + + ); +}, earningRowPropsAreEqual); diff --git a/src/app/farm/[poolId]/page.tsx b/src/app/farm/[poolId]/page.tsx index 3c47fbd..d1db7ae 100644 --- a/src/app/farm/[poolId]/page.tsx +++ b/src/app/farm/[poolId]/page.tsx @@ -1,38 +1,46 @@ import { Suspense } from "react"; import type { Metadata } from "next"; +import { poolContractId } from "@/config"; import { sorobanService } from "@/lib/soroban"; import PoolDetailClient from "./PoolDetailClient"; export const revalidate = 60; export async function generateStaticParams() { + const fallbackParams = [{ poolId: poolContractId || "placeholder" }]; + try { const pools = await sorobanService.getFactoryPools(); - return pools.map((pool) => ({ poolId: pool.id })); + const params = pools.map((pool) => ({ poolId: pool.id })); + return params.length > 0 ? params : fallbackParams; } catch { // RPC unreachable at build time — fall back to CSR via revalidate - return []; + return fallbackParams; } } export async function generateMetadata({ params, }: { - params: { poolId: string }; + params: Promise<{ poolId: string }>; }): Promise { + const { poolId } = await params; + return { - title: `Pool ${params.poolId.slice(0, 8)}… | SmartDrop Farm`, + title: `Pool ${poolId.slice(0, 8)}... | SmartDrop Farm`, }; } -export default function PoolDetailPage({ +export default async function PoolDetailPage({ params, }: { - params: { poolId: string }; + params: Promise<{ poolId: string }>; }) { + const { poolId } = await params; + return ( - + ); } diff --git a/src/app/farm/page.test.tsx b/src/app/farm/page.test.tsx index 562248a..43f45b2 100644 --- a/src/app/farm/page.test.tsx +++ b/src/app/farm/page.test.tsx @@ -3,7 +3,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { useCountdown } from "@/hooks/useCountdown"; import { unlockAvailableAt, type FarmPosition } from "@/types/farm"; -import { EarningRow } from "./page"; +import { EarningRow } from "./EarningRow"; vi.mock("@/hooks/useCountdown", () => ({ useCountdown: vi.fn(() => ({ diff --git a/src/app/farm/page.tsx b/src/app/farm/page.tsx index f10d776..e6f993c 100644 --- a/src/app/farm/page.tsx +++ b/src/app/farm/page.tsx @@ -1,12 +1,36 @@ "use client"; + import { PlatformStats } from "@/components/PlatformStats/PlatformStats"; -bash apply_changes.sh"use client"; -import NextLink from "next/link"; -import { memo, useEffect, useMemo, useState, type ReactNode } from "react"; +import ConnectWalletButton from "@/components/ConnectWalletButton/ConnectWalletButton"; +import UnlockModal from "@/components/UnlockModal/UnlockModal"; +import { EarningRow, MetricColumn } from "@/app/farm/EarningRow"; +import { + factoryContractId, + minLockPeriodSeconds, + sorobanRpcUrl, + stellarNetwork, +} from "@/config"; +import { useStellarWallet } from "@/context/StellarWalletContext"; +import { + QUERY_KEYS, + useAllUserPositions, + useLockAssets, + useLockAssetsFeePreview, + usePools, + useStellarBalance, +} from "@/hooks/useSorobanQuery"; +import { useSorobanEvents } from "@/hooks/useSorobanEvents"; +import { stellarExpertTxUrl } from "@/lib/soroban"; +import type { UserPosition } from "@/lib/soroban"; +import { + DEPOSIT_STEP_LABEL, + isDepositPending, + type DepositStep, + type FarmPosition, +} from "@/types/farm"; import { Alert, AlertIcon, - Badge, Box, Button, Flex, @@ -20,35 +44,17 @@ import { ModalOverlay, Spinner, Text, - Tooltip, - Alert, - AlertIcon, - Input, useToast, } from "@chakra-ui/react"; -import { useStellarWallet } from "@/context/StellarWalletContext"; -import { - factoryContractId, - minLockPeriodSeconds, - sorobanRpcUrl, - stellarNetwork, -} from "@/config"; -import UnlockModal from "@/components/UnlockModal/UnlockModal"; -import ConnectWalletButton from "@/components/ConnectWalletButton/ConnectWalletButton"; -import { useCountdown } from "@/hooks/useCountdown"; -import { unlockAvailableAt, DEPOSIT_STEP_LABEL, isDepositPending, type FarmPosition } from "@/types/farm"; -import { useAllUserPositions, usePools } from "@/hooks/useSorobanQuery"; -import { useLockFlow } from "@/hooks/useLockFlow"; -import { stellarExpertTxUrl } from "@/lib/soroban"; -import type { UserPosition } from "@/lib/soroban"; +import { useQueryClient } from "@tanstack/react-query"; +import NextLink from "next/link"; +import { useEffect, useMemo, useState } from "react"; const ACCENT = "#4AE292"; -import { useSorobanEvents } from "@/hooks/useSorobanEvents"; -import type { UserPosition } from "@/lib/soroban"; -import { useFarmStore } from "@/store/farmStore"; type LivePoolRow = { id: string; + contractAddress: string; name: string; earned: string; stake: string; @@ -60,162 +66,23 @@ type LivePoolRow = { lockPeriodSeconds: number; }; -function MetricColumn({ - label, - value, - minW = "110px", -}: { - label: string; - value: ReactNode; - minW?: string; -}) { - return ( - - - {label} - - - {value} - - - ); +function formatLockPeriod(seconds: number): string { + if (seconds >= 86400) { + const days = Math.ceil(seconds / 86400); + return `${days} day${days === 1 ? "" : "s"}`; + } + if (seconds >= 3600) { + const hours = Math.ceil(seconds / 3600); + return `${hours} hour${hours === 1 ? "" : "s"}`; + } + const minutes = Math.max(1, Math.ceil(seconds / 60)); + return `${minutes} minute${minutes === 1 ? "" : "s"}`; } -type EarningRowProps = { - position: FarmPosition; -}; - -// Keep this synchronized with FarmPosition in src/types/farm.ts. Every rendered -// field must be compared, or memoization can hide row updates when fields change. -function earningRowPropsAreEqual( - previous: EarningRowProps, - next: EarningRowProps -) { - const previousPosition = previous.position; - const nextPosition = next.position; - - return ( - previousPosition.id === nextPosition.id && - previousPosition.name === nextPosition.name && - previousPosition.img === nextPosition.img && - previousPosition.earned === nextPosition.earned && - previousPosition.stake === nextPosition.stake && - previousPosition.dailyRate === nextPosition.dailyRate && - previousPosition.totalStakedLiquidity === - nextPosition.totalStakedLiquidity && - previousPosition.symbol === nextPosition.symbol && - previousPosition.lockedAmount === nextPosition.lockedAmount && - previousPosition.lockedAt === nextPosition.lockedAt && - previousPosition.lockPeriodSeconds === nextPosition.lockPeriodSeconds - ); +function shortHash(hash: string): string { + return `${hash.slice(0, 10)}...${hash.slice(-6)}`; } -export const EarningRow = memo(function EarningRow({ - position, -}: EarningRowProps) { - const openUnlock = useFarmStore((s) => s.openUnlock); - const countdown = useCountdown(unlockAvailableAt(position)); - const hasStake = position.lockedAmount > 0; - const canUnlock = hasStake && countdown.isElapsed; - - return ( - - - {position.name} - - - - - - {hasStake && !canUnlock && ( - - - Unlock countdown - - - {countdown.label} - - - )} - - - - - - - - - - ); -}, earningRowPropsAreEqual); - -/** Deposit modal — delegates all transaction logic to useLockFlow. */ function DepositModal({ farm, isOpen, @@ -225,209 +92,318 @@ function DepositModal({ isOpen: boolean; onClose: () => void; }) { - const { publicKey, walletApi, isConnected } = useStellarWallet(); - const [rawAmount, setRawAmount] = useState("0"); - - const flow = useLockFlow({ - poolId: farm?.id ?? "", - symbol: farm?.symbol ?? "", - publicKey: publicKey ?? "", - walletApi, + const { publicKey, isConnected } = useStellarWallet(); + const [amount, setAmount] = useState(""); + const [txHash, setTxHash] = useState(null); + const [step, setStep] = useState("idle"); + const [localError, setLocalError] = useState(null); + + const selectedContractAddress = farm?.contractAddress || farm?.id || ""; + const queryClient = useQueryClient(); + const balanceQuery = useStellarBalance(publicKey ?? undefined); + const trimmedAmount = amount.trim(); + const numericAmount = Number(trimmedAmount); + const amountFormatValid = /^\d+(?:\.\d+)?$/.test(trimmedAmount); + const decimalPlaces = trimmedAmount.includes(".") + ? trimmedAmount.split(".")[1]?.length ?? 0 + : 0; + const amountValid = + !!trimmedAmount && + amountFormatValid && + decimalPlaces <= 7 && + Number.isFinite(numericAmount) && + numericAmount > 0; + const availableBalance = balanceQuery.data; + const exceedsBalance = + amountValid && + typeof availableBalance === "number" && + numericAmount > availableBalance; + const feePreview = useLockAssetsFeePreview({ + publicKey, + poolContractId: selectedContractAddress, + amount: amountValid ? trimmedAmount : "", + }); + const lockMutation = useLockAssets({ + onHash: (hash) => setTxHash(hash), + onStep: (nextStep) => setStep(nextStep), }); - // Reset amount and flow state whenever the modal opens for a new pool. + const isPending = lockMutation.isPending || isDepositPending(step); + const canSubmit = + isConnected && + !!farm && + !!publicKey && + amountValid && + !exceedsBalance && + !balanceQuery.isLoading && + !balanceQuery.isError && + !feePreview.isLoading && + !feePreview.isFetching && + !feePreview.isError && + !!feePreview.data && + !isPending; + useEffect(() => { if (isOpen) { - setRawAmount("0"); - flow.reset(); + setAmount(""); + setTxHash(null); + setStep("idle"); + setLocalError(null); + lockMutation.reset(); } + // Reset only when the modal opens or the selected pool changes. // eslint-disable-next-line react-hooks/exhaustive-deps }, [isOpen, farm?.id]); - const displayAmount = parseFloat(rawAmount); - const amountValid = Number.isFinite(displayAmount) && displayAmount > 0; - const isPending = isDepositPending(flow.step); + const resetAndClose = () => { + setAmount(""); + setTxHash(null); + setStep("idle"); + setLocalError(null); + lockMutation.reset(); + onClose(); + }; const handleClose = () => { if (isPending) return; - onClose(); + resetAndClose(); }; - const explorerUrl = flow.record?.txHash - ? stellarExpertTxUrl(flow.record.txHash, stellarNetwork.toLowerCase()) + const handleSubmit = async () => { + if (!farm || !publicKey) { + setLocalError("Connect your Freighter wallet to deposit."); + return; + } + if (!canSubmit) { + setLocalError("Enter a valid amount and wait for the fee preview."); + return; + } + + setLocalError(null); + setTxHash(null); + + try { + const result = await lockMutation.mutateAsync({ + poolId: selectedContractAddress, + amount: trimmedAmount, + }); + + if (!result.success) { + setStep("error"); + setLocalError(result.error ?? "Deposit failed. Please try again."); + return; + } + + queryClient.invalidateQueries({ + queryKey: [QUERY_KEYS.USER_POSITION, farm.id], + }); + resetAndClose(); + } catch (error) { + setStep("error"); + setLocalError( + error instanceof Error ? error.message : "Deposit failed. Please try again.", + ); + } + }; + + const explorerUrl = txHash + ? stellarExpertTxUrl(txHash, stellarNetwork.toLowerCase()) : null; + const lockPeriod = formatLockPeriod( + farm?.lockPeriodSeconds || minLockPeriodSeconds, + ); if (!farm) return null; return ( - - {farm.name} + + Deposit {farm.symbol} - - - {/* ── Success screen ──────────────────────────────────────────── */} - {flow.step === "success" && flow.record ? ( - - - Deposit confirmed - - - {flow.record.displayAmount} {farm.symbol} locked. Your stake updates below. + + + + {farm.name} + + Lock {farm.symbol} to earn credits from this pool. - - - Amount deposited - {flow.record.displayAmount} {farm.symbol} - - {flow.record.txHash && ( - - Tx hash - - {flow.record.txHash.slice(0, 12)}… - - - )} - {explorerUrl && ( - - Explorer - - Stellar Expert ↗ - - - )} + + + + + Available balance + + {balanceQuery.isLoading + ? "Loading..." + : typeof availableBalance === "number" + ? `${availableBalance.toLocaleString(undefined, { + maximumFractionDigits: 7, + })} XLM` + : "Unavailable"} + + + + Estimated Soroban fee + + {feePreview.isFetching + ? "Simulating..." + : feePreview.data + ? `${feePreview.data.feePreview} stroops` + : "Enter amount"} + + + + Minimum lock period + {lockPeriod} + + + + + Amount ({farm.symbol}) + + setAmount(event.target.value)} + isDisabled={isPending} + borderRadius="2xl" + h="50px" + borderColor="app.border" + bg="app.inputBg" + _placeholder={{ color: "app.muted" }} + _hover={{ borderColor: "app.accent" }} + _focus={{ boxShadow: "none", borderColor: "app.accent" }} + pr="72px" + /> + + {farm.symbol} + - + {!!trimmedAmount && !amountValid && ( + + Enter a positive amount with no more than 7 decimals. + + )} + {exceedsBalance && ( + + Amount exceeds your Horizon XLM balance. + + )} - ) : ( - /* ── Input / in-progress / error screen ──────────────────── */ - - - Lock {farm.symbol} to earn credits from this pool. Assets are time-locked for the pool's minimum period. - - - {/* Amount input */} - - Amount ({farm.symbol}) - - { - setRawAmount(e.target.value); - }} - isDisabled={isPending} - borderRadius="2xl" - h="50px" - borderColor="#454545" - _placeholder={{ color: "#A2A2A2" }} - _hover={{ borderColor: ACCENT }} - _focus={{ boxShadow: "none", borderColor: ACCENT }} - pr="64px" - /> - - {farm.symbol} - - - {rawAmount !== "0" && rawAmount !== "" && !amountValid && ( - - Enter an amount greater than 0. - - )} + {isPending && ( + + + + {DEPOSIT_STEP_LABEL[step] || "Processing deposit..."} + + )} + + {txHash && ( + + + Transaction + {explorerUrl ? ( + + {shortHash(txHash)} + + ) : ( + {shortHash(txHash)} + )} + + + )} + + {feePreview.isError && ( + + + Fee simulation failed. Check the amount and try again. + + )} + + {balanceQuery.isError && ( + + + Unable to load your Horizon balance. + + )} + + {localError && ( + + + {localError} + + )} + + {!isConnected && ( + + + Connect your Freighter wallet to deposit. + + )} - {/* Step indicator while pending */} - {isPending && ( - - - - {DEPOSIT_STEP_LABEL[flow.step]} + - {/* Error banner */} - {flow.step === "error" && flow.error && ( - - - {flow.error} - - )} - - {/* Wallet not connected */} - {!isConnected && ( - - - Connect your Freighter wallet to deposit. - - )} - - {/* Primary CTA */} + {step === "error" && ( - - {/* Retry after error */} - {flow.step === "error" && ( - - )} - - )} + )} + @@ -454,7 +430,7 @@ export default function Farm() { const poolContractIds = useMemo( () => (pools ?? []).map((p) => p.contractAddress).filter(Boolean), - [pools] + [pools], ); useSorobanEvents(poolContractIds, [ @@ -464,11 +440,7 @@ export default function Farm() { ]); const [selectedFarm, setSelectedFarm] = useState(null); - const [isModalOpen, setIsModalOpen] = useState(false); - const [unlockPosition, setUnlockPosition] = useState(null); - const [isUnlockOpen, setIsUnlockOpen] = useState(false); - const [submitPending, setSubmitPending] = useState(false); - const [sliderValue, setSliderValue] = useState(50); + const [isDepositOpen, setIsDepositOpen] = useState(false); useEffect(() => { if (poolsError && poolsErrorObj) { @@ -504,6 +476,7 @@ export default function Farm() { if (!userPositions) return []; return userPositions.map(({ pool, position }) => ({ id: pool.id, + contractAddress: pool.contractAddress, name: pool.asset.code, img: "", earned: position?.credits ?? "-", @@ -526,6 +499,7 @@ export default function Farm() { const position = positionMap.get(pool.id); return { id: pool.id, + contractAddress: pool.contractAddress, name: pool.asset.code, earned: position?.credits ?? "-", stake: position?.amount ?? "-", @@ -541,40 +515,14 @@ export default function Farm() { const handleDepositClick = (pool: LivePoolRow) => { setSelectedFarm(pool); - setIsModalOpen(true); + setIsDepositOpen(true); }; - const handleModalClose = () => { - setIsModalOpen(false); + const handleDepositClose = () => { + setIsDepositOpen(false); setSelectedFarm(null); }; - const handleUnlockClick = (position: FarmPosition) => { - setUnlockPosition(position); - setIsUnlockOpen(true); - }; - - const handleUnlockClose = () => { - setIsUnlockOpen(false); - setUnlockPosition(null); - }; - - const handleUnlocked = (position: FarmPosition, amount: number) => { - setIsUnlockOpen(false); - setUnlockPosition(null); - toast({ - title: "Unlock submitted", - description: `${amount} ${position.symbol} unlock request sent.`, - status: "success", - duration: 6000, - isClosable: true, - }); - const handleLockClick = async () => { - setSubmitPending(true); - await new Promise((resolve) => setTimeout(resolve, 1500)); - setSubmitPending(false); - }; - const hasPositions = myPositions.length > 0; return ( @@ -582,11 +530,11 @@ export default function Farm() { Network: {stellarNetwork} - {publicKey ? ` · ${publicKey.slice(0, 6)}…` : ""} + {publicKey ? ` - ${publicKey.slice(0, 6)}...` : ""} {factoryContractId - ? ` · Factory ${factoryContractId.slice(0, 8)}…` - : " · Set NEXT_PUBLIC_FACTORY_CONTRACT_ID when your Soroban factory is deployed"} - {" · "} + ? ` - Factory ${factoryContractId.slice(0, 8)}...` + : " - Set NEXT_PUBLIC_FACTORY_CONTRACT_ID when your Soroban factory is deployed"} + {" - "} {sorobanRpcUrl.replace(/^https?:\/\//, "")} @@ -597,11 +545,11 @@ export default function Farm() { {poolsLoading ? ( - ) : availablePools.length === 0 ? ( - No farm pools are currently available. Ensure your factory contract is deployed and the factory contract ID is configured. + + No farm pools are currently available. Ensure your factory contract is deployed and configured. ) : ( availablePools.map((farm) => ( @@ -627,7 +575,7 @@ export default function Farm() { {farm.name} @@ -641,13 +589,15 @@ export default function Farm() { value={farm.totalStakedLiquidity} minW="180px" /> - + {isConnected && ( + + )} )) )} @@ -659,7 +609,6 @@ export default function Farm() { {positionsLoading ? ( - ) : !isConnected ? ( ) : !hasPositions ? ( - No active positions found for the connected wallet. + + No active positions found for the connected wallet. ) : ( myPositions.map((position) => ( @@ -704,51 +654,9 @@ export default function Farm() { - - - - {selectedFarm?.name} - - - - - Deposit to earn points from this pool via Soroban. - - - - Amount - - - setSliderValue(Number(event.target.value))} - borderRadius="2xl" - bg="app.inputBg" - borderColor="app.border" - color="app.text" - _focus={{ boxShadow: "none", borderColor: "app.accent" }} - _hover={{ borderColor: "app.accent" }} - /> - - - - - - - - ); diff --git a/src/components/ConnectWalletButton/ConnectWalletButton.tsx b/src/components/ConnectWalletButton/ConnectWalletButton.tsx index c4e9965..7ce2b5c 100644 --- a/src/components/ConnectWalletButton/ConnectWalletButton.tsx +++ b/src/components/ConnectWalletButton/ConnectWalletButton.tsx @@ -1,37 +1,25 @@ "use client"; + import { useErrorHandler } from "@/context/ErrorContext"; import { useStellarWallet } from "@/context/StellarWalletContext"; -import { Button, Flex, Text, Tooltip } from "@chakra-ui/react"; +import { Button, Flex, Text, Tooltip, type ButtonProps } from "@chakra-ui/react"; import { useState } from "react"; const ACCENT = "#4AE292"; -/** Truncate a Stellar public key to "GABC…WXYZ" format. */ -function truncateKey(key: string): string { - return `${key.slice(0, 4)}…${key.slice(-4)}`; -} - -/** - * ConnectWalletButton - * - * - Unconnected: shows "Connect Freighter" CTA. - * - Connected: shows a condensed address badge with a "Disconnect" action. - * Clicking the address badge copies the full key to the clipboard. - */ -export default function ConnectWalletButton() { - const { connect, disconnect, publicKey, isConnected } = useStellarWallet(); -import { Button, type ButtonProps } from "@chakra-ui/react"; -import { useState } from "react"; - type ConnectWalletButtonProps = ButtonProps & { label?: string; }; +function truncateKey(key: string): string { + return `${key.slice(0, 4)}...${key.slice(-4)}`; +} + export default function ConnectWalletButton({ label = "Connect Freighter", ...buttonProps }: ConnectWalletButtonProps) { - const { connect } = useStellarWallet(); + const { connect, disconnect, publicKey, isConnected } = useStellarWallet(); const toast = useErrorHandler(); const [isConnecting, setIsConnecting] = useState(false); const [copied, setCopied] = useState(false); @@ -55,7 +43,7 @@ export default function ConnectWalletButton({ setCopied(true); setTimeout(() => setCopied(false), 2000); } catch { - /* clipboard not available — silent fail */ + // Clipboard may be unavailable in hardened browser contexts. } }; @@ -65,8 +53,11 @@ export default function ConnectWalletButton({ align="center" gap={2} position="fixed" - bottom="20px" - right="20px" + bottom={{ base: "16px", md: "20px" }} + right={{ base: "16px", md: "20px" }} + left={{ base: "16px", md: "auto" }} + w={{ base: "calc(100% - 32px)", md: "auto" }} + justify={{ base: "center", md: "flex-start" }} bg="#1a1a1a" border="1px solid #333" borderRadius="3xl" @@ -92,7 +83,9 @@ export default function ConnectWalletButton({ {truncateKey(publicKey)} - | + + | + void handleConnect()} - isLoading={isConnecting} - loadingText="Connecting…" - _hover={{ opacity: 0.9 }} bottom={{ base: "16px", md: "20px" }} right={{ base: "16px", md: "20px" }} left={{ base: "16px", md: "auto" }} w={{ base: "calc(100% - 32px)", md: "auto" }} - p={4} - onClick={handleConnect} - isLoading={isLoading} - loadingText="Connecting..." + px={6} + py={4} + fontWeight="bold" + _hover={{ opacity: 0.9 }} {...buttonProps} + onClick={() => void handleConnect()} + isLoading={isConnecting || Boolean(buttonProps.isLoading)} + loadingText={buttonProps.loadingText ?? "Connecting..."} > {label} diff --git a/src/components/ErrorBoundary/ErrorBoundary.tsx b/src/components/ErrorBoundary/ErrorBoundary.tsx index cb9756c..7f57b0e 100644 --- a/src/components/ErrorBoundary/ErrorBoundary.tsx +++ b/src/components/ErrorBoundary/ErrorBoundary.tsx @@ -5,7 +5,7 @@ "use client"; -import { errorLogger } from "@/lib/error-handler"; +import { UnknownError, errorLogger } from "@/lib/error-handler"; import { Box, Button, Heading, Text, VStack } from "@chakra-ui/react"; import React, { Component, type ReactNode } from "react"; @@ -30,21 +30,12 @@ export class ErrorBoundary extends Component ({ - componentStack: errorInfo.componentStack, - message: error.message, - stack: error.stack, - }), - } as any, - "React Error Boundary" + const boundaryError = new UnknownError( + "A component encountered an error", + error, ); + + errorLogger.log(boundaryError, `React Error Boundary: ${errorInfo.componentStack}`); } retry = () => { diff --git a/src/components/TvlChart/TvlChart.tsx b/src/components/TvlChart/TvlChart.tsx index bcc1afc..acc7d29 100644 --- a/src/components/TvlChart/TvlChart.tsx +++ b/src/components/TvlChart/TvlChart.tsx @@ -109,14 +109,14 @@ export default function TvlChart({ poolId }: TvlChartProps) { borderRadius: "12px", fontSize: "12px", }} - labelFormatter={(label: string) => - new Date(label).toLocaleDateString(undefined, { + labelFormatter={(label) => + new Date(String(label)).toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric", }) } - formatter={(value: number) => [`$${Number(value).toLocaleString()}`, "TVL"]} + formatter={(value) => [`$${Number(value).toLocaleString()}`, "TVL"]} /> (null); const [txHash, setTxHash] = useState(null); + const selectedPoolContractId = position?.contractAddress || poolContractId; const unlockAt = position ? unlockAvailableAt(position) : 0; const countdown = useCountdown(unlockAt); @@ -100,6 +101,10 @@ export default function UnlockModal() { setError("Connect your Freighter wallet to unlock."); return; } + if (!selectedPoolContractId) { + setError("Pool contract is not configured."); + return; + } if (!canUnlock) { setError("Lock period has not elapsed yet."); return; @@ -130,7 +135,7 @@ export default function UnlockModal() { try { const result = await unlockAssets({ - poolContractId, + poolContractId: selectedPoolContractId, publicKey, amount, walletApi, @@ -307,7 +312,7 @@ export default function UnlockModal() { Promise; disconnect: () => void; @@ -24,10 +25,11 @@ const StellarWalletContext = createContext( export function StellarWalletProvider({ children }: { children: ReactNode }) { const [publicKey, setPublicKey] = useState(null); - const [walletApi, setWalletApi] = useState(null); + const [walletApi, setWalletApi] = useState(null); const connect = useCallback(async () => { const freighter = await import("@stellar/freighter-api"); + const signingApi = freighter as unknown as FreighterWalletApi; try { const connected = await freighter.isConnected(); @@ -54,7 +56,7 @@ export function StellarWalletProvider({ children }: { children: ReactNode }) { ); } setPublicKey(access.address); - setWalletApi(freighter); + setWalletApi(signingApi); return; } @@ -80,10 +82,10 @@ export function StellarWalletProvider({ children }: { children: ReactNode }) { ); } setPublicKey(access.address); - setWalletApi(freighter); + setWalletApi(signingApi); } else { setPublicKey(addr.address); - setWalletApi(freighter); + setWalletApi(signingApi); } } catch (error) { // Re-throw FreighterErrors as-is diff --git a/src/context/index.tsx b/src/context/index.tsx index 822e405..8e0c783 100644 --- a/src/context/index.tsx +++ b/src/context/index.tsx @@ -7,17 +7,28 @@ import theme from "@/lib/theme"; import { ChakraProvider, ColorModeScript, localStorageManager } from "@chakra-ui/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { type ReactNode, useState } from "react"; +import { type ReactNode, useEffect, useState } from "react"; + +declare global { + interface Window { + __queryClient?: QueryClient; + } +} function ContextProvider({ children }: { children: ReactNode }) { const [queryClient] = useState(() => { - const qc = new QueryClient(); - if (typeof window !== 'undefined' && process.env.NEXT_PUBLIC_E2E === 'true') { - (window as any).__queryClient = qc; - } - return qc; + return new QueryClient(); }); + useEffect(() => { + if ( + typeof window !== 'undefined' && + (process.env.NODE_ENV !== 'production' || process.env.NEXT_PUBLIC_E2E === 'true') + ) { + window.__queryClient = queryClient; + } + }, [queryClient]); + return ( <> diff --git a/src/hooks/useLockFlow.ts b/src/hooks/useLockFlow.ts index b143b3d..bcec2ab 100644 --- a/src/hooks/useLockFlow.ts +++ b/src/hooks/useLockFlow.ts @@ -10,14 +10,13 @@ import { useCallback, useState } from "react"; import { useQueryClient } from "@tanstack/react-query"; -import { lockAssets } from "@/lib/soroban"; +import { lockAssets, type FreighterWalletApi } from "@/lib/soroban"; import { normalizeError } from "@/lib/error-handler"; import { trackEvent } from "@/lib/analytics"; import { type DepositRecord, type DepositStep, isDepositPending, - toStroops, } from "@/types/farm"; import { QUERY_KEYS } from "@/hooks/useSorobanQuery"; @@ -25,7 +24,7 @@ export interface LockFlowParams { poolId: string; symbol: string; publicKey: string; - walletApi: any; + walletApi: FreighterWalletApi | null; } export interface LockFlowState { @@ -65,6 +64,10 @@ export function useLockFlow({ trackEvent("deposit_initiated", { poolId, symbol, displayAmount }); try { + if (!walletApi || !publicKey) { + throw new Error("Wallet not connected. Please connect Freighter before depositing."); + } + setStep("simulating"); // Freighter internally simulates then surfaces the popup setStep("signing"); @@ -72,8 +75,9 @@ export function useLockFlow({ const result = await lockAssets({ poolContractId: poolId, publicKey, - amount: toStroops(displayAmount), + amount: String(displayAmount), walletApi, + onStep: setStep, }); if (!result.success) { diff --git a/src/hooks/useSorobanQuery.ts b/src/hooks/useSorobanQuery.ts index ba160ac..b7bc59b 100644 --- a/src/hooks/useSorobanQuery.ts +++ b/src/hooks/useSorobanQuery.ts @@ -4,7 +4,13 @@ */ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { sorobanService, type UserPosition, type TransactionResult } from '@/lib/soroban'; +import { + getStellarBalance, + simulateLockAssets, + sorobanService, + type UserPosition, + type TransactionResult, +} from '@/lib/soroban'; import { useStellarWallet } from '@/context/StellarWalletContext'; import { useToast } from '@chakra-ui/react'; @@ -63,9 +69,42 @@ export const useUserCredits = (poolId: string, enabled: boolean = true) => { }); }; -/** - * Hook to fetch platform statistics - */ +export const useStellarBalance = (publicKey?: string) => { + return useQuery({ + queryKey: ['stellarBalance', publicKey], + queryFn: () => getStellarBalance(publicKey!), + enabled: !!publicKey, + staleTime: 15000, + retry: 2, + }); +}; + +export const useLockAssetsFeePreview = (args: { + publicKey?: string | null; + poolContractId?: string | null; + amount?: string; +}) => { + const amount = args.amount?.trim() ?? ''; + const numericAmount = Number(amount); + + return useQuery({ + queryKey: ['lockAssetsFeePreview', args.publicKey, args.poolContractId, amount], + queryFn: () => + simulateLockAssets({ + publicKey: args.publicKey!, + poolContractId: args.poolContractId!, + amount, + }), + enabled: + !!args.publicKey && + !!args.poolContractId && + !!amount && + Number.isFinite(numericAmount) && + numericAmount > 0, + staleTime: 10000, + retry: 1, + }); +}; /** * Hook to lock assets in a pool. @@ -74,6 +113,7 @@ export const useUserCredits = (poolId: string, enabled: boolean = true) => { * without coupling the mutation to internal implementation details. */ export const useLockAssets = (options?: { + onHash?: (hash: string) => void; onStep?: (step: "simulating" | "signing" | "submitting") => void; }) => { const { walletApi, publicKey } = useStellarWallet(); @@ -91,11 +131,16 @@ export const useLockAssets = (options?: { if (!walletApi || !publicKey) { throw new Error('Wallet not connected. Please connect Freighter before depositing.'); } - options?.onStep?.("simulating"); - const result = await sorobanService.lockAssets(poolId, publicKey, amount, walletApi); - // lockAssets internally signs then submits — surface the submitting step - // once the call returns (Freighter popup closed). - if (result.success) options?.onStep?.("submitting"); + const result = await sorobanService.lockAssets( + poolId, + publicKey, + amount, + walletApi, + { + onHash: options?.onHash, + onStep: options?.onStep, + }, + ); return result; }, onSuccess: (result: TransactionResult, variables) => { @@ -109,10 +154,13 @@ export const useLockAssets = (options?: { isClosable: true, }); + queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.POOLS] }); + queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.USER_POSITION] }); queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.USER_POSITION, variables.poolId] }); + queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.USER_POSITION, 'all', publicKey] }); queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.USER_CREDITS, variables.poolId] }); queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.PLATFORM_STATS] }); - queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.POOLS] }); + queryClient.invalidateQueries({ queryKey: ['stellarBalance', publicKey] }); } else { toast({ title: 'Deposit failed', @@ -411,11 +459,15 @@ export interface UIPlatformStats { activePools: number; totalFarmers: number; creditVelocity: string; + totalValueLocked?: string; + totalPools?: number; + totalUsers?: number; + onlineUsers?: number; } export function usePlatformStats(initialData?: UIPlatformStats) { return useQuery({ - queryKey: ['platformStats'], + queryKey: [QUERY_KEYS.PLATFORM_STATS], queryFn: async () => { const [stats, velocity] = await Promise.all([ sorobanService.getPlatformStats(), @@ -423,10 +475,14 @@ export function usePlatformStats(initialData?: UIPlatformStats) { ]); return { - tvl: stats?.tvl || "0", - activePools: stats?.activePools || 0, - totalFarmers: stats?.totalFarmers || 0, - creditVelocity: velocity + tvl: stats.totalValueLocked || "0", + activePools: stats.totalPools || 0, + totalFarmers: stats.totalUsers || 0, + creditVelocity: velocity, + totalValueLocked: stats.totalValueLocked, + totalPools: stats.totalPools, + totalUsers: stats.totalUsers, + onlineUsers: stats.onlineUsers, }; }, staleTime: 60000, // Keeps data fresh for 1 minute diff --git a/src/lib/soroban.history.test.ts b/src/lib/soroban.history.test.ts index a6e5d2b..0ca449a 100644 --- a/src/lib/soroban.history.test.ts +++ b/src/lib/soroban.history.test.ts @@ -1,5 +1,4 @@ import { describe, it, expect, vi } from 'vitest'; -import { describe, it, expect, vi } from 'vitest'; import { xdr, nativeToScVal, StrKey, Address } from '@stellar/stellar-sdk'; import { getUserTransactionHistory } from './soroban'; // Generate valid-format Stellar G-addresses from fixed 32-byte seeds. @@ -139,6 +138,6 @@ describe('getUserTransactionHistory', () => { expect(callArg.filters[0].type).toBe('contract'); expect(callArg.filters[0].contractIds).toEqual([POOL_ID]); expect(callArg.filters[0].topics).toHaveLength(2); - expect(callArg.pagination.limit).toBe(200); + expect(callArg.limit).toBe(200); }); }); diff --git a/src/lib/soroban.test.ts b/src/lib/soroban.test.ts new file mode 100644 index 0000000..725452b --- /dev/null +++ b/src/lib/soroban.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + Account, + Address, + Keypair, + StrKey, + scValToNative, + type xdr, +} from '@stellar/stellar-sdk'; +import { amountToStroops, buildLockAssetsTransaction } from './soroban'; + +vi.mock('@/config', () => ({ + factoryContractId: '', + horizonUrl: 'https://horizon-testnet.stellar.org', + networkPassphrase: 'Test SDF Network ; September 2015', + sorobanRpcUrl: 'https://soroban-testnet.stellar.org', +})); + +const SELECTED_POOL_CONTRACT_ID = + 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSC4'; + +type InvokeContractOperation = { + type: 'invokeHostFunction'; + func: { + invokeContract: () => { + contractAddress: () => xdr.ScAddress; + functionName: () => string; + args: () => xdr.ScVal[]; + }; + }; +}; + +describe('buildLockAssetsTransaction', () => { + it('builds a lock_assets contract call with a 7-decimal i128 amount', async () => { + const publicKey = StrKey.encodeEd25519PublicKey(new Uint8Array(32).fill(7)); + const keypair = Keypair.fromPublicKey(publicKey); + expect(keypair.publicKey()).toBe(publicKey); + const account = new Account(publicKey, '42'); + const rpcServer = { + getAccount: vi.fn().mockResolvedValue(account), + simulateTransaction: vi.fn(), + }; + + const transaction = await buildLockAssetsTransaction( + { + poolContractId: SELECTED_POOL_CONTRACT_ID, + publicKey, + amount: '1.25', + }, + rpcServer, + ); + + const operation = transaction.operations[0] as unknown as InvokeContractOperation; + const invocation = operation.func.invokeContract(); + const args = invocation.args(); + + expect(rpcServer.getAccount).toHaveBeenCalledWith(publicKey); + expect(operation.type).toBe('invokeHostFunction'); + expect(Address.fromScAddress(invocation.contractAddress()).toString()).toBe( + SELECTED_POOL_CONTRACT_ID, + ); + expect(invocation.functionName().toString()).toBe('lock_assets'); + expect(scValToNative(args[0])).toBe(publicKey); + expect(scValToNative(args[1])).toBe(12500000n); + expect(amountToStroops('1.25')).toBe(12500000n); + }); +}); diff --git a/src/lib/soroban.ts b/src/lib/soroban.ts index ba78300..005faa1 100644 --- a/src/lib/soroban.ts +++ b/src/lib/soroban.ts @@ -5,7 +5,6 @@ import { Contract, - Networks, TransactionBuilder, BASE_FEE, xdr, @@ -14,6 +13,12 @@ import { scValToNative, rpc, } from '@stellar/stellar-sdk'; +import { + factoryContractId, + horizonUrl, + networkPassphrase, + sorobanRpcUrl, +} from '@/config'; import { SecurityError } from './error-handler'; import { bigintToDisplayAmount, @@ -27,15 +32,8 @@ import type { } from './soroban-parsers'; export type { AssetInfo, PoolInfo, UserPosition } from './soroban-parsers'; -// Soroban RPC Configuration -const RPC_URL = process.env.NEXT_PUBLIC_SOROBAN_RPC_URL || 'https://soroban-testnet.stellar.org:443'; -const NETWORK_PASSPHRASE = process.env.NEXT_PUBLIC_NETWORK_PASSPHRASE || Networks.TESTNET; - -// Contract Addresses (will be set via environment variables in production) -const FACTORY_CONTRACT_ADDRESS = process.env.NEXT_PUBLIC_FACTORY_CONTRACT_ADDRESS || ''; - // Initialize Soroban RPC Server -const rpcServer = new rpc.Server(RPC_URL); +export const rpcServer = new rpc.Server(sorobanRpcUrl); export interface BoostConfig { multiplier: number; @@ -47,6 +45,7 @@ export interface TransactionResult { success: boolean; transactionHash?: string; hash?: string; + status?: string; error?: string; gasUsed?: string; } @@ -117,13 +116,127 @@ type FreighterSignTransactionResult = error?: unknown; }; -interface FreighterWalletApi { +export interface FreighterWalletApi { signTransaction: ( transactionXdr: string, options: { networkPassphrase: string }, ) => Promise; } +type LockAssetsStep = 'simulating' | 'signing' | 'submitting'; + +export interface LockAssetsCallbacks { + onHash?: (hash: string) => void; + onStep?: (step: LockAssetsStep) => void; +} + +export interface BuildLockAssetsTransactionArgs { + poolContractId: string; + publicKey: string; + amount: string; +} + +export type LockAssetsRpc = Pick< + rpc.Server, + 'getAccount' | 'simulateTransaction' +>; + +export function amountToStroops(amount: string, decimals = 7): bigint { + const normalized = amount.trim(); + if (!Number.isInteger(decimals) || decimals < 0) { + throw new Error('Decimal precision must be a non-negative integer.'); + } + + if (!/^\d+(?:\.\d+)?$/.test(normalized)) { + throw new Error('Enter a valid positive decimal amount.'); + } + + const [whole, fraction = ''] = normalized.split('.'); + if (fraction.length > decimals) { + throw new Error(`Amount supports at most ${decimals} decimal places.`); + } + + const scale = 10n ** BigInt(decimals); + const stroops = + BigInt(whole) * scale + + BigInt((fraction || '0').padEnd(decimals, '0')); + + if (stroops <= 0n) { + throw new Error('Amount must be greater than 0.'); + } + + return stroops; +} + +export async function getStellarBalance(publicKey: string): Promise { + const response = await fetch( + `${horizonUrl.replace(/\/$/, '')}/accounts/${publicKey}`, + ); + + if (!response.ok) { + throw new Error( + `Unable to fetch Stellar balance from Horizon (${response.status}).`, + ); + } + + const account = (await response.json()) as { + balances?: Array<{ + asset_type?: string; + balance?: string; + }>; + }; + const nativeBalance = account.balances?.find( + (balance) => balance.asset_type === 'native', + ); + + if (!nativeBalance?.balance) { + throw new Error('Horizon account response did not include a native XLM balance.'); + } + + return Number(nativeBalance.balance); +} + +export async function buildLockAssetsTransaction( + args: BuildLockAssetsTransactionArgs, + rpcOverride?: LockAssetsRpc, +) { + const server = rpcOverride ?? rpcServer; + const account = await server.getAccount(args.publicKey); + const contract = new Contract(args.poolContractId); + const operation = contract.call( + 'lock_assets', + Address.fromString(args.publicKey).toScVal(), + nativeToScVal(amountToStroops(args.amount), { type: 'i128' }), + ); + + return new TransactionBuilder(account, { + fee: BASE_FEE, + networkPassphrase, + }) + .addOperation(operation) + .setTimeout(300) + .build(); +} + +export async function simulateLockAssets( + args: BuildLockAssetsTransactionArgs, + rpcOverride?: LockAssetsRpc, +) { + const server = rpcOverride ?? rpcServer; + const transaction = await buildLockAssetsTransaction(args, server); + const simulation = await server.simulateTransaction(transaction); + + if ('error' in simulation) { + throw new Error(`Simulation failed: ${simulation.error}`); + } + + return { + transaction, + simulation, + feePreview: String(simulation.minResourceFee ?? '0'), + }; +} + function getSignedTransactionXdr( result: FreighterSignTransactionResult, ): string { @@ -344,8 +457,8 @@ export class SorobanService { constructor() { this.rpcServer = rpcServer; - if (FACTORY_CONTRACT_ADDRESS) { - this.factoryContract = new Contract(FACTORY_CONTRACT_ADDRESS); + if (factoryContractId) { + this.factoryContract = new Contract(factoryContractId); } } @@ -368,7 +481,9 @@ export class SorobanService { try { const pools = await this.getFactoryPools(); pools.forEach(pool => { - this.poolContracts.set(pool.id, new Contract(pool.contractAddress)); + const contract = new Contract(pool.contractAddress); + this.poolContracts.set(pool.id, contract); + this.poolContracts.set(pool.contractAddress, contract); }); } catch (error) { console.warn('Failed to load pool contracts:', error); @@ -392,7 +507,7 @@ export class SorobanService { ); const transaction = new TransactionBuilder(account, { fee: BASE_FEE, - networkPassphrase: NETWORK_PASSPHRASE, + networkPassphrase, }) .addOperation(call) .setTimeout(30) @@ -440,7 +555,7 @@ export class SorobanService { ); const transaction = new TransactionBuilder(account, { fee: BASE_FEE, - networkPassphrase: NETWORK_PASSPHRASE, + networkPassphrase, }) .addOperation(call) .setTimeout(30) @@ -488,7 +603,7 @@ export class SorobanService { ); const transaction = new TransactionBuilder(account, { fee: BASE_FEE, - networkPassphrase: NETWORK_PASSPHRASE, + networkPassphrase, }) .addOperation(call) .setTimeout(30) @@ -512,9 +627,6 @@ export class SorobanService { } } - /** - * Lock assets in a pool - */ /** * Resolve a pool contract from the cache or directly from a contract address. * Falls back to constructing a Contract on the fly when the pool was discovered @@ -540,10 +652,12 @@ export class SorobanService { hash: string, maxAttempts = 30, intervalMs = 2000, - ): Promise { + ): Promise<{ status: string }> { for (let attempt = 0; attempt < maxAttempts; attempt++) { const tx = await this.rpcServer.getTransaction(hash); - if (tx.status === rpc.Api.GetTransactionStatus.SUCCESS) return; + if (tx.status === rpc.Api.GetTransactionStatus.SUCCESS) { + return { status: String(tx.status) }; + } if (tx.status === rpc.Api.GetTransactionStatus.FAILED) { throw new Error(`Transaction ${hash} failed on-chain`); } @@ -556,39 +670,21 @@ export class SorobanService { poolId: string, userAddress: string, amount: string, - walletApi: FreighterWalletApi // Freighter API instance + walletApi: FreighterWalletApi, + callbacks?: LockAssetsCallbacks, ): Promise { - const poolContract = this.resolvePoolContract(poolId); - try { - // Build the lock_assets call - const call = poolContract.call( - "lock_assets", - Address.fromString(userAddress).toScVal(), - nativeToScVal(BigInt(amount), { type: "i128" }), + callbacks?.onStep?.('simulating'); + const poolContract = this.resolvePoolContract(poolId); + const { transaction, simulation, feePreview } = await simulateLockAssets( + { + poolContractId: poolContract.contractId(), + publicKey: userAddress, + amount, + }, + this.rpcServer, ); - // Get user account for transaction building - const account = await this.rpcServer.getAccount(userAddress); - - const transaction = new TransactionBuilder(account, { - fee: BASE_FEE, - networkPassphrase: NETWORK_PASSPHRASE, - }) - .addOperation(call) - .setTimeout(300) // 5 minutes - .build(); - - // Simulate first to get fee estimation - const simulation = await this.rpcServer.simulateTransaction(transaction); - - if ("error" in simulation) { - return { - success: false, - error: `Simulation failed: ${simulation.error}`, - }; - } - validateSimulationAuth(simulation, [ { contractId: poolContract.contractId(), @@ -596,36 +692,37 @@ export class SorobanService { }, ]); - // Prepare transaction for signing const preparedTransaction = rpc.assembleTransaction(transaction, simulation).build(); - // Request signature from Freighter + callbacks?.onStep?.('signing'); const signedTransaction = getSignedTransactionXdr( await walletApi.signTransaction(preparedTransaction.toXDR(), { - networkPassphrase: NETWORK_PASSPHRASE, + networkPassphrase, }), ); - // Submit transaction + callbacks?.onStep?.('submitting'); const submissionResult = await this.rpcServer.sendTransaction( - TransactionBuilder.fromXDR(signedTransaction, NETWORK_PASSPHRASE), + TransactionBuilder.fromXDR(signedTransaction, networkPassphrase), ); if (submissionResult.status === 'ERROR') { return { success: false, + status: submissionResult.status, error: `Transaction failed: ${submissionResult.errorResult}`, }; } - // Poll until the transaction is confirmed (status leaves PENDING) - await this.waitForConfirmation(submissionResult.hash); + callbacks?.onHash?.(submissionResult.hash); + const confirmation = await this.waitForConfirmation(submissionResult.hash); return { success: true, transactionHash: submissionResult.hash, hash: submissionResult.hash, - gasUsed: simulation.minResourceFee || '0', + status: confirmation.status, + gasUsed: feePreview, }; } catch (error) { @@ -635,8 +732,8 @@ export class SorobanService { } return { success: false, + status: 'FAILED', error: error instanceof Error ? error.message : 'Unknown error locking assets', - error: error instanceof Error ? error.message : 'Unknown error', }; } } @@ -663,7 +760,7 @@ export class SorobanService { const transaction = new TransactionBuilder(account, { fee: BASE_FEE, - networkPassphrase: NETWORK_PASSPHRASE, + networkPassphrase, }) .addOperation(call) .setTimeout(300) @@ -689,12 +786,12 @@ export class SorobanService { const signedTransaction = getSignedTransactionXdr( await walletApi.signTransaction(preparedTransaction.toXDR(), { - networkPassphrase: NETWORK_PASSPHRASE, + networkPassphrase, }), ); const submissionResult = await this.rpcServer.sendTransaction( - TransactionBuilder.fromXDR(signedTransaction, NETWORK_PASSPHRASE), + TransactionBuilder.fromXDR(signedTransaction, networkPassphrase), ); if (submissionResult.status === 'ERROR') { @@ -754,7 +851,7 @@ export class SorobanService { const transaction = new TransactionBuilder(account, { fee: BASE_FEE, - networkPassphrase: NETWORK_PASSPHRASE, + networkPassphrase, }) .addOperation(call) .setTimeout(300) @@ -780,12 +877,12 @@ export class SorobanService { const signedTransaction = getSignedTransactionXdr( await walletApi.signTransaction(preparedTransaction.toXDR(), { - networkPassphrase: NETWORK_PASSPHRASE, + networkPassphrase, }), ); const submissionResult = await this.rpcServer.sendTransaction( - TransactionBuilder.fromXDR(signedTransaction, NETWORK_PASSPHRASE), + TransactionBuilder.fromXDR(signedTransaction, networkPassphrase), ); if (submissionResult.status === 'ERROR') { @@ -856,51 +953,6 @@ export class SorobanService { } } - /** - * Resolve a pool contract either from the cached map or directly from a - * contract ID string. Allows the deposit flow to work even when the factory - * is not configured and pools were discovered another way. - */ - private resolvePoolContract(poolId: string): Contract { - const cached = this.poolContracts.get(poolId); - if (cached) return cached; - - // If the poolId itself looks like a Stellar contract address (C…) treat it - // as a contract ID and create a Contract on the fly. - if (poolId.startsWith('C') && poolId.length >= 56) { - const contract = new Contract(poolId); - this.poolContracts.set(poolId, contract); - return contract; - } - - throw new Error(`Pool contract not found for ID: ${poolId}`); - } - - /** - * Poll until a submitted transaction leaves the PENDING state. - * Returns true when the transaction is confirmed (SUCCESS), throws on failure. - */ - async waitForConfirmation( - hash: string, - maxAttempts = 30, - intervalMs = 2000, - ): Promise { - for (let attempt = 0; attempt < maxAttempts; attempt++) { - const tx = await this.rpcServer.getTransaction(hash); - - if (tx.status === rpc.Api.GetTransactionStatus.SUCCESS) { - return; - } - if (tx.status === rpc.Api.GetTransactionStatus.FAILED) { - throw new Error(`Transaction ${hash} failed on-chain`); - } - // NOT_FOUND or PENDING — keep polling - await new Promise((resolve) => setTimeout(resolve, intervalMs)); - } - throw new Error(`Transaction ${hash} not confirmed after ${maxAttempts} attempts`); - } - - /** * Get 7-day TVL history for a pool by scanning lock/unlock events. * Returns synthetic daily snapshots derived from on-chain events. @@ -929,7 +981,7 @@ export class SorobanService { topics: [[lockSym, '*'], [unlockSym, '*']], }, ], - pagination: { limit: 500 }, + limit: 500, }); // Build a running TVL map keyed by ISO date string @@ -996,7 +1048,7 @@ export class SorobanService { topics: [[lockSym, '*'], [unlockSym, '*']], }, ], - pagination: { limit: 500 }, + limit: 500, }); const balances = new Map(); @@ -1053,8 +1105,9 @@ export class SorobanService { return parseCreditsFromXdrResult(xdrResult); } - async getCreditVelocity(windowHours: number = 24): Promise { + async getCreditVelocity(_windowHours: number = 24): Promise { try { + void _windowHours; const totalCreditsAccumulated = 0n; return totalCreditsAccumulated.toString(); } catch (error) { @@ -1112,12 +1165,18 @@ export const lockAssets = async ({ publicKey, amount, walletApi, + onHash, + onStep, }: { poolContractId: string; publicKey: string; amount: string; - walletApi: any; -}) => sorobanService.lockAssets(poolContractId, publicKey, amount, walletApi); + walletApi: FreighterWalletApi; +} & LockAssetsCallbacks) => + sorobanService.lockAssets(poolContractId, publicKey, amount, walletApi, { + onHash, + onStep, + }); export const unlockAssets = async ({ poolContractId, diff --git a/src/types/farm.ts b/src/types/farm.ts index 9c821f1..1fd66e6 100644 --- a/src/types/farm.ts +++ b/src/types/farm.ts @@ -2,6 +2,8 @@ export type FarmPosition = { /** Stable identifier so UI updates survive re-renders. */ id: string; + /** Soroban contract id for this pool. */ + contractAddress?: string; name: string; img: string; earned: string; diff --git a/tsconfig.json b/tsconfig.json index c133409..e36ffd4 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,6 @@ { "compilerOptions": { - "target": "ES2017", + "target": "ES2020", "lib": ["dom", "dom.iterable", "esnext"], "allowJs": true, "skipLibCheck": true, @@ -23,5 +23,13 @@ } }, "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], - "exclude": ["node_modules"] + "exclude": [ + "node_modules", + "vitest.config.ts", + "playwright.config.ts", + "tests/**", + "e2e/**", + "**/*.test.ts", + "**/*.test.tsx" + ] } diff --git a/vitest.config.ts b/vitest.config.ts index 5a12f66..f6bd127 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -6,15 +6,13 @@ export default defineConfig({ plugins: [react()], test: { globals: true, - // Playwright specs live in tests/ and are run by `playwright test`, not vitest. - exclude: ['tests/**', 'node_modules/**'], + // Playwright specs live in tests/ and e2e/ and are run by Playwright, not Vitest. + exclude: ['tests/**', 'e2e/**', 'node_modules/**', 'dist/**', '.next/**'], environmentMatchGlobs: [ ['src/hooks/**', 'jsdom'], ['src/lib/**', 'node'], ], environment: 'jsdom', - exclude: ['node_modules', 'dist', '.next', 'tests/**/*.spec.ts'], - }, resolve: { alias: {