From abc368b94a59c81b08fe89dcaa8a1bc575be2a62 Mon Sep 17 00:00:00 2001 From: Asher <141028690+No-bodyq@users.noreply.github.com> Date: Fri, 29 May 2026 09:39:59 +0100 Subject: [PATCH 1/4] Implement wallet balance display --- .../src/components/WalletBalance.tsx | 154 +++++++++--------- .../src/services/walletBalance.ts | 28 +++- 2 files changed, 102 insertions(+), 80 deletions(-) diff --git a/wata-board-frontend/src/components/WalletBalance.tsx b/wata-board-frontend/src/components/WalletBalance.tsx index 48037c08..d4ec2bf0 100644 --- a/wata-board-frontend/src/components/WalletBalance.tsx +++ b/wata-board-frontend/src/components/WalletBalance.tsx @@ -1,4 +1,3 @@ -import React from 'react'; import type { WalletBalance as WalletBalanceType } from '../services/walletBalance'; import { balanceUtils } from '../services/walletBalance'; import { useWalletBalance } from '../hooks/useWalletBalance'; @@ -10,16 +9,34 @@ interface WalletBalanceProps { isConnected?: boolean; isLowBalance?: boolean; lastUpdated?: Date | null; - refreshBalance?: () => void; + refreshBalance?: () => void | Promise; showDetails?: boolean; showRefreshButton?: boolean; className?: string; } -export const WalletBalance: React.FC = (props) => { - // Use internal hook if props are not provided +const formatUsd = (value?: number) => + typeof value === 'number' && Number.isFinite(value) + ? value.toLocaleString('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 2 }) + : 'USD unavailable'; + +function BalanceSkeleton({ className = '' }: { className?: string }) { + return ( +
+
+
+
+
+
+
+
+
+
+ ); +} + +export function WalletBalance(props: WalletBalanceProps) { const internal = useWalletBalance(); - const { balance = internal.balance, isLoading = internal.isLoading, @@ -28,129 +45,108 @@ export const WalletBalance: React.FC = (props) => { isLowBalance = internal.isLowBalance, lastUpdated = internal.lastUpdated, refreshBalance = internal.refreshBalance, - showDetails = false, + showDetails = true, showRefreshButton = true, - className = '' + className = '', } = props; - console.log('[WalletBalance Component] Render state:', { isConnected, isLoading, hasBalance: !!balance, hasError: !!error }); - if (!isConnected) { return ( -
-
Wallet not connected
-
+
+

Wallet not connected

+

Connect your Stellar wallet to view balances.

+
); } if (error) { return ( -
-
+
+
-
Balance Error
-
{error}
+

Balance unavailable

+

{error}

{showRefreshButton && ( )}
-
+ ); } if (isLoading || !balance) { - return ( -
-
-
-
Loading balance...
-
-
- ); + return ; } - const xlmBalance = balance.balances.find(b => b.isNative); + const xlmBalance = balance.balances.find((assetBalance) => assetBalance.isNative); const balanceStatusColor = balanceUtils.getBalanceStatusColor(balance); const balanceStatusText = balanceUtils.getBalanceStatusText(balance); return ( -
- {/* Main Balance Display */} -
+
+
-
- Wallet Balance -
-
- {xlmBalance ? balanceUtils.formatXLM(xlmBalance.balance) : '0 XLM'} -
-
- {balanceStatusText} - {isLowBalance && ( - ⚠️ Low Balance - )} -
+

Wallet Balance

+

+ {xlmBalance ? balanceUtils.formatXLM(xlmBalance.balance) : '0.00 XLM'} +

+

{formatUsd(balance.totalBalanceUSD)}

+

{balanceStatusText}

- + {showRefreshButton && ( )}
- {/* Low Balance Warning */} {isLowBalance && ( -
-
- ⚠️ Low balance detected. You may need additional XLM for transaction fees. -
+
+

Low balance detected. Add XLM to cover payments and network fees.

)} - {/* Last Updated */} - {lastUpdated && ( -
- Last updated: {lastUpdated.toLocaleTimeString()} -
- )} + {lastUpdated &&

Last updated: {lastUpdated.toLocaleTimeString()}

} - {/* Detailed Balance Information */} - {showDetails && ( -
-
- All Balances -
- {balance.balances.map((assetBalance, index) => ( -
-
-
- - {balanceUtils.getAssetDisplayName(assetBalance)} - + {showDetails && balance.balances.length > 0 && ( +
+

Assets

+ {balance.balances.map((assetBalance) => ( +
+
+ + {balanceUtils.getAssetDisplayName(assetBalance)}
- - {balanceUtils.formatBalance(assetBalance.balance)} - + {balanceUtils.formatBalance(assetBalance.balance)}
))}
)} -
+
); -}; +} diff --git a/wata-board-frontend/src/services/walletBalance.ts b/wata-board-frontend/src/services/walletBalance.ts index 2c2da0e9..1c0d0122 100644 --- a/wata-board-frontend/src/services/walletBalance.ts +++ b/wata-board-frontend/src/services/walletBalance.ts @@ -1,4 +1,4 @@ -import { Horizon, Asset, Networks, StrKey } from '@stellar/stellar-sdk'; +import { Horizon, Networks, StrKey } from '@stellar/stellar-sdk'; import { requestAccess, isConnected } from '../utils/wallet-bridge'; import { getCurrentNetworkConfig, NETWORK_CHANGE_EVENT } from '../utils/network-config'; @@ -14,6 +14,7 @@ export interface WalletBalance { publicKey: string; balances: BalanceInfo[]; nativeBalance: number; + xlmPriceUSD?: number; totalBalanceUSD?: number; lastUpdated: Date; network: string; @@ -61,7 +62,9 @@ export class WalletBalanceService { private balanceCache: Map = new Map(); private updateCallbacks: Set = new Set(); private refreshInterval: NodeJS.Timeout | null = null; + private priceCache: { price: number; timestamp: number } | null = null; private readonly CACHE_DURATION = 30000; + private readonly PRICE_CACHE_DURATION = 60000; private networkChangeHandler: (() => void) | null = null; constructor() { @@ -97,6 +100,26 @@ export class WalletBalanceService { return this.getWalletBalance(); } + private async getXlmPriceUSD(): Promise { + if (this.priceCache && Date.now() - this.priceCache.timestamp < this.PRICE_CACHE_DURATION) { + return this.priceCache.price; + } + + try { + const response = await fetch('https://api.coingecko.com/api/v3/simple/price?ids=stellar&vs_currencies=usd'); + if (!response.ok) return undefined; + + const data = (await response.json()) as { stellar?: { usd?: number } }; + const price = data.stellar?.usd; + if (typeof price !== 'number' || !Number.isFinite(price)) return undefined; + + this.priceCache = { price, timestamp: Date.now() }; + return price; + } catch { + return undefined; + } + } + async getWalletBalance(): Promise { try { const connectResult = await isConnected(); @@ -138,11 +161,14 @@ export class WalletBalanceService { const balances = this.parseBalances(account.balances); const nativeBalance = this.getNativeBalance(balances); + const xlmPriceUSD = await this.getXlmPriceUSD(); const walletBalance: WalletBalance = { publicKey: pubKeyString, balances, nativeBalance, + xlmPriceUSD, + totalBalanceUSD: xlmPriceUSD ? nativeBalance * xlmPriceUSD : undefined, lastUpdated: new Date(), network: this.networkConfig.networkPassphrase === Networks.PUBLIC ? 'mainnet' : 'testnet' }; From 7eff6d811661494ce1526b46b60539c40f004ee9 Mon Sep 17 00:00:00 2001 From: Asher <141028690+No-bodyq@users.noreply.github.com> Date: Fri, 29 May 2026 09:48:10 +0100 Subject: [PATCH 2/4] Add missing test runner dependency --- package-lock.json | 187 +++++++++++++++++++++++++++++++++++++++------- package.json | 1 + 2 files changed, 163 insertions(+), 25 deletions(-) diff --git a/package-lock.json b/package-lock.json index da9824ed..e3f05033 100644 --- a/package-lock.json +++ b/package-lock.json @@ -37,6 +37,7 @@ "eslint-config-next": "15.2.0", "sharp": "^0.34.5", "tailwindcss": "^4", + "ts-node": "^10.9.2", "typescript": "^5" } }, @@ -53,6 +54,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/@discoveryjs/json-ext": { "version": "0.5.7", "resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz", @@ -120,7 +134,6 @@ "version": "1.9.1", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz", "integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -354,7 +367,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -377,7 +389,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -400,7 +411,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -417,7 +427,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -434,7 +443,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -451,7 +459,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -468,7 +475,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -485,7 +491,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -502,7 +507,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -519,7 +523,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -536,7 +539,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -553,7 +555,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -570,7 +571,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -593,7 +593,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -616,7 +615,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -639,7 +637,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -662,7 +659,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -685,7 +681,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -708,7 +703,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -731,7 +725,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -754,7 +747,6 @@ "cpu": [ "wasm32" ], - "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", "optional": true, "dependencies": { @@ -774,7 +766,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later", "optional": true, "os": [ @@ -794,7 +785,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later", "optional": true, "os": [ @@ -814,7 +804,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later", "optional": true, "os": [ @@ -827,6 +816,34 @@ "url": "https://opencollective.com/libvips" } }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, "node_modules/@next/bundle-analyzer": { "version": "15.5.14", "resolved": "https://registry.npmjs.org/@next/bundle-analyzer/-/bundle-analyzer-15.5.14.tgz", @@ -1413,6 +1430,34 @@ "tailwindcss": "4.0.9" } }, + "node_modules/@tsconfig/node10": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", + "integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node12": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", + "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node14": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", + "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node16": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", + "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/d3-array": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", @@ -1863,6 +1908,13 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "dev": true, + "license": "MIT" + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -2396,6 +2448,13 @@ "dev": true, "license": "MIT" }, + "node_modules/create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "dev": true, + "license": "MIT" + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -2714,6 +2773,16 @@ "node": ">=0.10" } }, + "node_modules/diff": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", + "integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, "node_modules/dijkstrajs": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", @@ -4845,6 +4914,13 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true, + "license": "ISC" + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -6404,6 +6480,50 @@ "typescript": ">=4.8.4" } }, + "node_modules/ts-node": { + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", + "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "^0.8.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.1", + "yn": "3.1.1" + }, + "bin": { + "ts-node": "dist/bin.js", + "ts-node-cwd": "dist/bin-cwd.js", + "ts-node-esm": "dist/bin-esm.js", + "ts-node-script": "dist/bin-script.js", + "ts-node-transpile-only": "dist/bin-transpile.js", + "ts-script": "dist/bin-script-deprecated.js" + }, + "peerDependencies": { + "@swc/core": ">=1.2.50", + "@swc/wasm": ">=1.2.50", + "@types/node": "*", + "typescript": ">=2.7" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "@swc/wasm": { + "optional": true + } + } + }, "node_modules/tsconfig-paths": { "version": "3.15.0", "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", @@ -6583,6 +6703,13 @@ "integrity": "sha512-OZeJfZP+R0z9D6TmBgLq2LHzSSptGMGDGigGiEe0pr8UBe/7fdflgHlHBNDASTXB5jnFuxHpNaJywSg8YFeGng==", "license": "MIT" }, + "node_modules/v8-compile-cache-lib": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", + "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", + "dev": true, + "license": "MIT" + }, "node_modules/victory-vendor": { "version": "37.3.6", "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", @@ -6881,6 +7008,16 @@ "node": ">=8" } }, + "node_modules/yn": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index c14077c7..b2ae41da 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "eslint-config-next": "15.2.0", "sharp": "^0.34.5", "tailwindcss": "^4", + "ts-node": "^10.9.2", "typescript": "^5" } } From 7f699426536f4f8e9f0fe72956cab8312da669a9 Mon Sep 17 00:00:00 2001 From: Asher <141028690+No-bodyq@users.noreply.github.com> Date: Fri, 29 May 2026 10:03:42 +0100 Subject: [PATCH 3/4] Fix Wata board test startup --- wata-board-frontend/postcss.config.mjs | 3 + .../src/services/feeEstimation.ts | 122 +++++++++--------- 2 files changed, 67 insertions(+), 58 deletions(-) create mode 100644 wata-board-frontend/postcss.config.mjs diff --git a/wata-board-frontend/postcss.config.mjs b/wata-board-frontend/postcss.config.mjs new file mode 100644 index 00000000..3e0d24c4 --- /dev/null +++ b/wata-board-frontend/postcss.config.mjs @@ -0,0 +1,3 @@ +export default { + plugins: {}, +}; diff --git a/wata-board-frontend/src/services/feeEstimation.ts b/wata-board-frontend/src/services/feeEstimation.ts index 68e00515..8a8a9017 100644 --- a/wata-board-frontend/src/services/feeEstimation.ts +++ b/wata-board-frontend/src/services/feeEstimation.ts @@ -1,29 +1,32 @@ /** * Fee Estimation Service for Stellar Transactions - * Queries Horizon fee_stats endpoint for accurate, real-time fee data. + * Queries Horizon fee_stats for current fee tiers and payment estimates. */ -import { Horizon, BASE_FEE } from '@stellar/stellar-sdk'; -import { getCurrentNetworkConfig } from '../utils/network-config'; -import { Horizon, Networks, TransactionBuilder, Operation, Asset, BASE_FEE } from '@stellar/stellar-sdk'; -import { requestAccess } from '../utils/wallet-bridge'; +import { BASE_FEE, Horizon } from '@stellar/stellar-sdk'; import { getCurrentNetworkConfig, NETWORK_CHANGE_EVENT } from '../utils/network-config'; const STROOPS_PER_XLM = 10_000_000; -const CACHE_TTL_MS = 10_000; // 10 second TTL -const SURGE_MULTIPLIER = 1.5; // Applied when network is congested +const CACHE_TTL_MS = 10_000; +const SURGE_MULTIPLIER = 1.5; export interface FeeTiers { - min: number; // Minimum fee in XLM - recommended: number; // Recommended fee in XLM (p50) - max: number; // High-priority fee in XLM (p90) + min: number; + recommended: number; + max: number; } export interface FeeEstimate { tiers: FeeTiers; - totalFee: number; // recommended total fee in XLM for given op count + totalFee: number; operationCount: number; - isSurge: boolean; // true when surge pricing is active + isSurge: boolean; + estimatedTimeSeconds: number; +} + +export interface FeeRecommendation { + label: 'low' | 'recommended' | 'priority'; + fee: number; estimatedTimeSeconds: number; } @@ -33,42 +36,31 @@ interface FeeCache { timestamp: number; } -// Utility conversions export const stroopsToXLM = (stroops: number): number => stroops / STROOPS_PER_XLM; export const xlmToStroops = (xlm: number): number => Math.floor(xlm * STROOPS_PER_XLM); export class FeeEstimationService { private server: Horizon.Server; private cache: FeeCache | null = null; - - constructor() { - const config = getCurrentNetworkConfig(); - const horizonUrl = config.rpcUrl.replace('soroban', 'horizon'); - private networkConfig: any; private networkChangeHandler: (() => void) | null = null; constructor() { - this.networkConfig = getCurrentNetworkConfig(); - this.updateServer(); - - // Listen for network changes + this.server = this.createServer(); + if (typeof window !== 'undefined') { this.networkChangeHandler = () => { - this.networkConfig = getCurrentNetworkConfig(); - this.updateServer(); + this.server = this.createServer(); + this.clearCache(); }; - window.addEventListener(NETWORK_CHANGE_EVENT, this.networkChangeHandler as any); + window.addEventListener(NETWORK_CHANGE_EVENT, this.networkChangeHandler); } } - private updateServer(): void { - const horizonUrl = this.networkConfig.rpcUrl.replace('soroban', 'horizon'); - this.server = new Horizon.Server(horizonUrl); + private createServer(): Horizon.Server { + const config = getCurrentNetworkConfig(); + return new Horizon.Server(config.rpcUrl.replace('soroban', 'horizon')); } - /** - * Fetch fee tiers from Horizon fee_stats, with TTL caching. - */ async getFeeTiers(): Promise<{ tiers: FeeTiers; isSurge: boolean }> { if (this.cache && Date.now() - this.cache.timestamp < CACHE_TTL_MS) { return { tiers: this.cache.tiers, isSurge: this.cache.isSurge }; @@ -76,26 +68,23 @@ export class FeeEstimationService { try { const feeStats = await this.server.feeStats(); - - const p10 = parseInt(feeStats.fee_charged.p10); - const p50 = parseInt(feeStats.fee_charged.p50); - const p90 = parseInt(feeStats.fee_charged.p90); - const ledgerCapacityUsage = parseFloat(feeStats.ledger_capacity_usage); - + const p10 = Number.parseInt(feeStats.fee_charged.p10, 10); + const p50 = Number.parseInt(feeStats.fee_charged.p50, 10); + const p90 = Number.parseInt(feeStats.fee_charged.p90, 10); + const ledgerCapacityUsage = Number.parseFloat(feeStats.ledger_capacity_usage); const isSurge = ledgerCapacityUsage > 0.8; - const surgeMultiplier = isSurge ? SURGE_MULTIPLIER : 1; + const multiplier = isSurge ? SURGE_MULTIPLIER : 1; const tiers: FeeTiers = { - min: stroopsToXLM(Math.max(p10, parseInt(BASE_FEE))), - recommended: stroopsToXLM(Math.ceil(p50 * surgeMultiplier)), - max: stroopsToXLM(Math.ceil(p90 * surgeMultiplier)), + min: stroopsToXLM(Math.max(p10, Number.parseInt(BASE_FEE, 10))), + recommended: stroopsToXLM(Math.ceil(p50 * multiplier)), + max: stroopsToXLM(Math.ceil(p90 * multiplier)), }; this.cache = { tiers, isSurge, timestamp: Date.now() }; return { tiers, isSurge }; } catch { - // Fallback to BASE_FEE if Horizon is unreachable - const base = parseInt(BASE_FEE); + const base = Number.parseInt(BASE_FEE, 10); const tiers: FeeTiers = { min: stroopsToXLM(base), recommended: stroopsToXLM(base * 2), @@ -105,41 +94,58 @@ export class FeeEstimationService { } } - /** - * Estimate fee for a transaction with the given number of operations. - */ - async estimateFee(operationCount: number = 1): Promise { + async estimateFee(operationCount = 1): Promise { const { tiers, isSurge } = await this.getFeeTiers(); - const totalFee = tiers.recommended * operationCount; - const estimatedTimeSeconds = isSurge ? 10 : 5; - return { tiers, - totalFee, + totalFee: tiers.recommended * operationCount, operationCount, isSurge, - estimatedTimeSeconds, + estimatedTimeSeconds: isSurge ? 10 : 5, }; } - /** Format a fee value in XLM for display. */ - formatFee(feeXLM: number, decimals: number = 7): string { + async estimatePaymentFee(amount: string, destination?: string): Promise { + const operationCount = destination ? 1 : 1; + const parsedAmount = Number.parseFloat(amount); + + if (!Number.isFinite(parsedAmount) || parsedAmount <= 0) { + throw new Error('Enter a valid payment amount before estimating fees.'); + } + + return this.estimateFee(operationCount); + } + + async getFeeRecommendations(): Promise { + const { tiers, isSurge } = await this.getFeeTiers(); + + return [ + { label: 'low', fee: tiers.min, estimatedTimeSeconds: isSurge ? 20 : 10 }, + { label: 'recommended', fee: tiers.recommended, estimatedTimeSeconds: isSurge ? 10 : 5 }, + { label: 'priority', fee: tiers.max, estimatedTimeSeconds: isSurge ? 5 : 3 }, + ]; + } + + formatFee(feeXLM: number, decimals = 7): string { return `${feeXLM.toFixed(decimals)} XLM`; } - /** Total cost of a payment including the recommended fee. */ - async totalCost(amountXLM: number, operationCount: number = 1): Promise { + async totalCost(amountXLM: number, operationCount = 1): Promise { const estimate = await this.estimateFee(operationCount); return amountXLM + estimate.totalFee; } - /** Invalidate the cache (useful for testing). */ clearCache(): void { this.cache = null; } + + dispose(): void { + if (this.networkChangeHandler && typeof window !== 'undefined') { + window.removeEventListener(NETWORK_CHANGE_EVENT, this.networkChangeHandler); + } + } } export const feeEstimationService = new FeeEstimationService(); export default feeEstimationService; -export default feeEstimationService; From b7d322da61c3b6ad1e2380b310e2fcf9339db97e Mon Sep 17 00:00:00 2001 From: Asher <141028690+No-bodyq@users.noreply.github.com> Date: Fri, 29 May 2026 10:13:36 +0100 Subject: [PATCH 4/4] Tighten wallet balance service types --- .../src/services/walletBalance.ts | 37 ++++++++++++++----- 1 file changed, 27 insertions(+), 10 deletions(-) diff --git a/wata-board-frontend/src/services/walletBalance.ts b/wata-board-frontend/src/services/walletBalance.ts index 1c0d0122..291f4642 100644 --- a/wata-board-frontend/src/services/walletBalance.ts +++ b/wata-board-frontend/src/services/walletBalance.ts @@ -1,6 +1,6 @@ import { Horizon, Networks, StrKey } from '@stellar/stellar-sdk'; import { requestAccess, isConnected } from '../utils/wallet-bridge'; -import { getCurrentNetworkConfig, NETWORK_CHANGE_EVENT } from '../utils/network-config'; +import { getCurrentNetworkConfig, NETWORK_CHANGE_EVENT, type NetworkConfig } from '../utils/network-config'; export interface BalanceInfo { assetCode: string; @@ -24,6 +24,22 @@ export interface BalanceUpdateCallback { (balance: WalletBalance): void; } +interface StellarBalanceLine { + asset_type: BalanceInfo['assetType']; + balance: string; + asset_code?: string; + asset_issuer?: string; +} + +interface StellarAccountLike { + id: string; + balances: StellarBalanceLine[]; +} + +interface WalletTestWindow extends Window { + __MOCK_STELLAR_ACCOUNT__?: (publicKey: string) => StellarAccountLike; +} + export const balanceUtils = { formatBalance: (balance: string, decimals: number = 7): string => { const num = parseFloat(balance); @@ -58,7 +74,7 @@ export const balanceUtils = { export class WalletBalanceService { private server: Horizon.Server; - private networkConfig: any; + private networkConfig: NetworkConfig; private balanceCache: Map = new Map(); private updateCallbacks: Set = new Set(); private refreshInterval: NodeJS.Timeout | null = null; @@ -80,7 +96,7 @@ export class WalletBalanceService { this.server = new Horizon.Server(horizonUrl); this.clearCache(); }; - window.addEventListener(NETWORK_CHANGE_EVENT, this.networkChangeHandler as any); + window.addEventListener(NETWORK_CHANGE_EVENT, this.networkChangeHandler); } } @@ -145,17 +161,18 @@ export class WalletBalanceService { console.log(`[WalletBalanceService] Loading account for ${pubKeyString} from ${this.server.serverURL.toString()}`); // FOR TESTS: Bypass loadAccount if mock is provided - let account; - if ((window as any).__MOCK_STELLAR_ACCOUNT__) { + let account: StellarAccountLike; + const testWindow = window as WalletTestWindow; + if (testWindow.__MOCK_STELLAR_ACCOUNT__) { console.log('[WalletBalanceService] Using mock stellar account'); - const mockData = (window as any).__MOCK_STELLAR_ACCOUNT__(pubKeyString); + const mockData = testWindow.__MOCK_STELLAR_ACCOUNT__(pubKeyString); account = { id: pubKeyString, sequence: '100', balances: mockData.balances || [{ asset_type: 'native', balance: '1000.00' }] - }; + } as StellarAccountLike; } else { - account = await this.server.loadAccount(pubKeyString); + account = await this.server.loadAccount(pubKeyString) as StellarAccountLike; console.log(`[WalletBalanceService] Account loaded:`, account.id); } @@ -189,9 +206,9 @@ export class WalletBalanceService { } } - private parseBalances(stellarBalances: any[]): BalanceInfo[] { + private parseBalances(stellarBalances: StellarBalanceLine[]): BalanceInfo[] { return stellarBalances.map(balance => ({ - assetCode: balance.asset_type === 'native' ? 'XLM' : balance.asset_code, + assetCode: balance.asset_type === 'native' ? 'XLM' : (balance.asset_code ?? 'UNKNOWN'), assetIssuer: balance.asset_issuer, balance: balance.balance, assetType: balance.asset_type,