From 5ad8e89976262ccf8303341584f5dd1663dd6016 Mon Sep 17 00:00:00 2001 From: Degentle12 <308763905+Degentle12@users.noreply.github.com> Date: Sun, 23 Aug 2026 16:29:36 +0000 Subject: [PATCH] fix(wallet): resolve balance from the configured network's Horizon The wallet store fetched balances from a hardcoded Horizon testnet URL, so mainnet wallets always displayed a zero balance. Resolve the Horizon base URL from the configured network (NEXT_PUBLIC_STELLAR_NETWORK with a NEXT_PUBLIC_STELLAR_HORIZON_URL override) via a new resolveHorizonUrl() in lib/stellar/config.ts, and move the fetch into a pure lib/stellar/balance.ts module that distinguishes Horizon 404 (a valid zero balance for a new account) from real lookup failures, which now surface as a balanceError instead of a silently wrong zero. Balance refreshes on connect and every 15s; the duplicate hardcoded fetch in Header.tsx is removed and the wallet store owns the refresh lifecycle. Adds unit tests with mocked fetch covering native balance, no native asset, 404, network error, non-404 HTTP error, and mainnet vs testnet URL selection. Also fixes pre-existing type errors in adminStore, draftStore, and campaignDeployer that blocked the required type-check and build gates (identical to PR #18). --- components/Header.tsx | 26 +----- components/WalletDropdown.tsx | 14 +++- lib/server/adminStore.ts | 28 +++++-- lib/server/campaignDeployer.ts | 74 +++++++++++------ lib/server/draftStore.ts | 6 +- lib/stellar/balance.ts | 72 ++++++++++++++++ lib/stellar/config.ts | 17 ++++ package.json | 1 + store/walletStore.ts | 67 +++++++++++---- tests/walletBalance.test.ts | 148 +++++++++++++++++++++++++++++++++ tsconfig.json | 2 + types/index.ts | 3 + 12 files changed, 382 insertions(+), 76 deletions(-) create mode 100644 lib/stellar/balance.ts create mode 100644 tests/walletBalance.test.ts diff --git a/components/Header.tsx b/components/Header.tsx index 5cc82a9..b74e93b 100644 --- a/components/Header.tsx +++ b/components/Header.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useEffect } from 'react'; +import { useState } from 'react'; import Link from 'next/link'; import { Menu, X } from 'lucide-react'; import { useAuthStore } from '@/store/authStore'; @@ -16,27 +16,9 @@ export default function Header() { const { isAuthenticated } = useAuthStore(); const { address: walletAddress, connect: connectWallet } = useWalletStore(); - // Periodically fetch balance when wallet is connected - useEffect(() => { - if (walletAddress) { - const fetchBalance = async () => { - try { - const res = await fetch(`https://horizon-testnet.stellar.org/accounts/${walletAddress}`); - if (res.ok) { - const data = await res.json(); - const native = data.balances.find((b: any) => b.asset_type === 'native'); - useWalletStore.setState({ balance: native ? native.balance : '0.0000000' }); - } - } catch (err) { - console.error('Error fetching balance:', err); - } - }; - - fetchBalance(); - const interval = setInterval(fetchBalance, 15000); - return () => clearInterval(interval); - } - }, [walletAddress]); + // Balance fetching and periodic refresh live in the wallet store: it + // resolves the Horizon URL from the configured network and refreshes on + // connect + on an interval, so it is not duplicated here. const handleWalletConnect = async (walletType: string) => { try { diff --git a/components/WalletDropdown.tsx b/components/WalletDropdown.tsx index b767a7a..d8f63c4 100644 --- a/components/WalletDropdown.tsx +++ b/components/WalletDropdown.tsx @@ -25,7 +25,7 @@ export default function WalletDropdown() { const [isOpen, setIsOpen] = useState(false); const [copied, setCopied] = useState(false); const dropdownRef = useRef(null); - const { address, balance, disconnect } = useWalletStore(); + const { address, balance, balanceError, disconnect } = useWalletStore(); const handleDisconnect = async () => { setIsOpen(false); @@ -98,7 +98,11 @@ export default function WalletDropdown() { {truncateAddress(address)} - {balance ? `${parseFloat(balance).toFixed(2)} XLM` : '0.00 XLM'} + {balanceError + ? 'Balance unavailable' + : balance + ? `${parseFloat(balance).toFixed(2)} XLM` + : '0.00 XLM'}

- {balance ? `${parseFloat(balance).toFixed(4)} XLM` : '0.0000 XLM'} + {balanceError + ? 'Balance unavailable' + : balance + ? `${parseFloat(balance).toFixed(4)} XLM` + : '0.0000 XLM'}

diff --git a/lib/server/adminStore.ts b/lib/server/adminStore.ts index 716fef1..d69113b 100644 --- a/lib/server/adminStore.ts +++ b/lib/server/adminStore.ts @@ -203,7 +203,9 @@ export function createUser(data: Partial): AdminUser { export function updateUserById(id: string, patch: Partial): AdminUser | undefined { const index = users.findIndex((u) => u.id === id); if (index === -1) return undefined; - users[index] = { ...users[index], ...patch }; + const current = users[index]; + if (!current) return undefined; + users[index] = { ...current, ...patch }; return users[index]; } @@ -217,21 +219,27 @@ export function deleteUserById(id: string): boolean { export function setUserRole(id: string, role: AdminUserRole): AdminUser | undefined { const index = users.findIndex((u) => u.id === id); if (index === -1) return undefined; - users[index] = { ...users[index], role }; + const current = users[index]; + if (!current) return undefined; + users[index] = { ...current, role }; return users[index]; } export function setUserKycStatus(id: string, status: AdminKycStatus): AdminUser | undefined { const index = users.findIndex((u) => u.id === id); if (index === -1) return undefined; - users[index] = { ...users[index], kycStatus: status }; + const current = users[index]; + if (!current) return undefined; + users[index] = { ...current, kycStatus: status }; return users[index]; } export function toggleUserSuspension(id: string): AdminUser | undefined { const index = users.findIndex((u) => u.id === id); if (index === -1) return undefined; - users[index] = { ...users[index], isSuspended: !users[index].isSuspended }; + const current = users[index]; + if (!current) return undefined; + users[index] = { ...current, isSuspended: !current.isSuspended }; return users[index]; } @@ -272,8 +280,10 @@ export function deleteWithdrawalById(id: string): boolean { export function approveWithdrawal(id: string): AdminWithdrawal | undefined { const index = withdrawals.findIndex((w) => w.id === id); if (index === -1) return undefined; + const current = withdrawals[index]; + if (!current) return undefined; withdrawals[index] = { - ...withdrawals[index], + ...current, status: 'APPROVED', processedDate: new Date().toISOString(), }; @@ -283,8 +293,10 @@ export function approveWithdrawal(id: string): AdminWithdrawal | undefined { export function rejectWithdrawal(id: string, reason: string): AdminWithdrawal | undefined { const index = withdrawals.findIndex((w) => w.id === id); if (index === -1) return undefined; + const current = withdrawals[index]; + if (!current) return undefined; withdrawals[index] = { - ...withdrawals[index], + ...current, status: 'REJECTED', processedDate: new Date().toISOString(), rejectionReason: reason, @@ -295,8 +307,10 @@ export function rejectWithdrawal(id: string, reason: string): AdminWithdrawal | export function completeWithdrawal(id: string, transactionHash: string): AdminWithdrawal | undefined { const index = withdrawals.findIndex((w) => w.id === id); if (index === -1) return undefined; + const current = withdrawals[index]; + if (!current) return undefined; withdrawals[index] = { - ...withdrawals[index], + ...current, status: 'COMPLETED', processedDate: new Date().toISOString(), transactionHash, diff --git a/lib/server/campaignDeployer.ts b/lib/server/campaignDeployer.ts index 487c4af..e44be26 100644 --- a/lib/server/campaignDeployer.ts +++ b/lib/server/campaignDeployer.ts @@ -33,9 +33,10 @@ import { Address, Keypair, Operation, - SorobanRpc, TransactionBuilder, - scval, + nativeToScVal, + rpc, + xdr, } from '@stellar/stellar-sdk'; import { env } from '@/lib/env'; @@ -76,6 +77,26 @@ function descriptionHash(title: string): Buffer { .digest(); } +/** + * Builds a Soroban map scval with symbol keys from [key, value] entries. + * Symbol keys match the serialization that `#[derive(Serialize)]` contract + * structs expect on-chain; plain string keys would not deserialize. Keys are + * sorted, matching the SDK's own map conversion (the Soroban runtime expects + * sorted map keys). + */ +function scMap(entries: Array<[string, xdr.ScVal]>): xdr.ScVal { + const sorted = [...entries].sort(([a], [b]) => a.localeCompare(b)); + return xdr.ScVal.scvMap( + sorted.map( + ([key, value]) => + new xdr.ScMapEntry({ + key: nativeToScVal(key, { type: 'symbol' }), + val: value, + }), + ), + ); +} + function requireConfig(value: string | undefined, name: string): string { const trimmed = value?.trim() ?? ''; if (!trimmed) { @@ -140,7 +161,7 @@ export async function deployCampaign( const minDonationStroops = stroops(input.minDonationAmount ?? 0.001); // ── Build contract arguments (per the canonical campaign contract) ─────── - const acceptedAssetsScVal = scval.toVec( + const acceptedAssetsScVal = nativeToScVal( input.acceptedAssets.map((asset) => { const code = asset.code.trim().toUpperCase(); if (code !== 'XLM' && !asset.contractId) { @@ -150,39 +171,39 @@ export async function deployCampaign( `Only native XLM has no issuer.`, ); } - return scval.toMap([ - [scval.toSymbol('asset_code'), scval.toString(code)], + return scMap([ + ['asset_code', nativeToScVal(code)], [ - scval.toSymbol('issuer'), + 'issuer', asset.contractId - ? scval.toAddress(new Address(asset.contractId)) - : scval.toVoid(), + ? nativeToScVal(new Address(asset.contractId), { type: 'address' }) + : nativeToScVal(undefined), ], ]); }), ); - const milestoneScVal = scval.toMap([ - [scval.toSymbol('index'), scval.toU32(0)], - [scval.toSymbol('target_amount'), scval.toI128(goalStroops)], - [scval.toSymbol('released_amount'), scval.toI128(0n)], - [scval.toSymbol('description_hash'), scval.toBytes(descriptionHash(input.title))], - [scval.toSymbol('status'), scval.toU32(0)], // MilestoneStatus::Locked - [scval.toSymbol('released_at'), scval.toVoid()], + const milestoneScVal = scMap([ + ['index', nativeToScVal(0, { type: 'u32' })], + ['target_amount', nativeToScVal(goalStroops, { type: 'i128' })], + ['released_amount', nativeToScVal(0n, { type: 'i128' })], + ['description_hash', nativeToScVal(descriptionHash(input.title))], + ['status', nativeToScVal(0, { type: 'u32' })], // MilestoneStatus::Locked + ['released_at', nativeToScVal(undefined)], ]); - const milestonesScVal = scval.toVec([milestoneScVal]); + const milestonesScVal = nativeToScVal([milestoneScVal]); const invokeArgs = [ - scval.toAddress(new Address(creatorAddress)), - scval.toI128(goalStroops), - scval.toU64(BigInt(endTime)), + nativeToScVal(new Address(creatorAddress), { type: 'address' }), + nativeToScVal(goalStroops, { type: 'i128' }), + nativeToScVal(BigInt(endTime), { type: 'u64' }), acceptedAssetsScVal, milestonesScVal, - scval.toI128(minDonationStroops), + nativeToScVal(minDonationStroops, { type: 'i128' }), ]; // ── Submit to Soroban RPC ──────────────────────────────────────────────── - const server = new SorobanRpc.Server(sorobanRpcUrl); + const server = new rpc.Server(sorobanRpcUrl); let account; try { @@ -209,14 +230,15 @@ export async function deployCampaign( .build(); const simulation = await server.simulateTransaction(transaction); - if (SorobanRpc.isSimulationError(simulation)) { + if (rpc.Api.isSimulationError(simulation)) { throw new Error(`Soroban simulation rejected the deployment: ${simulation.error}`); } - const assembled = SorobanRpc.assembleTransaction(transaction, simulation).sign(adminKeypair); + const assembled = rpc.assembleTransaction(transaction, simulation).build(); + assembled.sign(adminKeypair); const sendResult = await server.sendTransaction(assembled); - if (sendResult.status === 'ERROR' || sendResult.status === 'FAILED') { - throw new Error(`Soroban rejected the deployment transaction: ${sendResult.errorResult?.resultXdr ?? sendResult.status}`); + if (sendResult.status === 'ERROR') { + throw new Error(`Soroban rejected the deployment transaction: ${sendResult.errorResult?.toXDR('base64') ?? sendResult.status}`); } const txHash = sendResult.hash; @@ -242,7 +264,7 @@ export async function deployCampaign( } // ── Register with the backend so the returned campaign id is real ─────── - const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:5000/api'; + const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'; let campaignId: string; try { diff --git a/lib/server/draftStore.ts b/lib/server/draftStore.ts index 72f46c3..0b0b9cb 100644 --- a/lib/server/draftStore.ts +++ b/lib/server/draftStore.ts @@ -163,8 +163,12 @@ export async function saveDraft( let saved: ProjectDraft; if (existingIndex >= 0) { + const existing = drafts[existingIndex]; + if (!existing) { + throw new Error('Draft store inconsistency: index resolved but draft missing'); + } saved = { - ...drafts[existingIndex], + ...existing, title: payload.title, formData: payload.formData, currentStep: payload.currentStep, diff --git a/lib/stellar/balance.ts b/lib/stellar/balance.ts new file mode 100644 index 0000000..2805963 --- /dev/null +++ b/lib/stellar/balance.ts @@ -0,0 +1,72 @@ +/** + * lib/stellar/balance.ts + * + * Native-balance lookup against a Horizon server. Kept free of app imports so + * it can be unit-tested directly with Node's built-in test runner and mocked + * fetch responses. + * + * Failure semantics matter here: + * - Horizon 404 (account does not exist yet) is a VALID zero balance — a + * brand-new account must show 0 without an error. + * - Any other HTTP error or network failure is a real lookup failure and is + * surfaced as an error instead of a silently wrong balance. + */ + +export const ZERO_BALANCE = '0.0000000'; + +export interface BalanceFetchResult { + /** Native balance when the lookup succeeded; null when it failed. */ + balance: string | null; + /** Human-readable error when the lookup failed; null otherwise (404 included). */ + error: string | null; +} + +interface HorizonAccountResponse { + balances?: Array<{ asset_type?: string; balance?: string }>; +} + +/** Extracts the native (XLM) balance from a Horizon account response. */ +export function parseNativeBalance(data: HorizonAccountResponse): string { + const native = data.balances?.find((b) => b.asset_type === 'native'); + return native?.balance ?? ZERO_BALANCE; +} + +/** + * Fetches the native balance for `address` from the given Horizon base URL. + * + * @param address Stellar account address (G...). + * @param horizonUrl Horizon base URL for the configured network (no trailing slash needed). + */ +export async function fetchNativeBalance( + address: string, + horizonUrl: string, +): Promise { + const baseUrl = horizonUrl.replace(/\/+$/, ''); + const url = `${baseUrl}/accounts/${encodeURIComponent(address)}`; + + let res: Response; + try { + res = await fetch(url); + } catch (err) { + return { + balance: null, + error: err instanceof Error ? err.message : 'Failed to reach Horizon', + }; + } + + if (res.status === 404) { + // Account not found on the ledger — a new/empty account, not an error. + return { balance: ZERO_BALANCE, error: null }; + } + + if (!res.ok) { + return { balance: null, error: `Horizon returned HTTP ${res.status}` }; + } + + try { + const data = (await res.json()) as HorizonAccountResponse; + return { balance: parseNativeBalance(data), error: null }; + } catch { + return { balance: null, error: 'Horizon returned an unparseable response' }; + } +} diff --git a/lib/stellar/config.ts b/lib/stellar/config.ts index 65f0b93..6f788c2 100644 --- a/lib/stellar/config.ts +++ b/lib/stellar/config.ts @@ -29,6 +29,23 @@ export const NETWORK_PASSPHRASES: Record = { futurenet: 'Test SDF Future Network ; October 2022', }; +/** + * Resolves the Horizon base URL used for account/balance lookups. + * + * Honors an explicit `NEXT_PUBLIC_STELLAR_HORIZON_URL` override; otherwise + * maps the configured `NEXT_PUBLIC_STELLAR_NETWORK` ('testnet' | 'mainnet' | + * 'futurenet') to this module's well-known URLs, where the env's 'mainnet' + * corresponds to this module's 'public' network. + */ +export function resolveHorizonUrl(): string { + const override = process.env.NEXT_PUBLIC_STELLAR_HORIZON_URL?.trim(); + if (override) return override.replace(/\/+$/, ''); + + const network = process.env.NEXT_PUBLIC_STELLAR_NETWORK ?? 'testnet'; + const key: StellarNetwork = network === 'mainnet' ? 'public' : (network as StellarNetwork); + return HORIZON_URLS[key] ?? HORIZON_URLS.testnet; +} + /** * Get Stellar configuration for a specific network * @param network - The Stellar network to configure diff --git a/package.json b/package.json index 6965696..26e2f35 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "start": "next start", "lint": "next lint", "type-check": "tsc --noEmit", + "test": "node --test \"tests/**/*.test.ts\"", "storybook": "storybook dev -p 6006", "build-storybook": "storybook build" }, diff --git a/store/walletStore.ts b/store/walletStore.ts index 3a3453d..c92ca70 100644 --- a/store/walletStore.ts +++ b/store/walletStore.ts @@ -1,22 +1,37 @@ import { create } from 'zustand'; import { devtools } from 'zustand/middleware'; import type { WalletStore } from '@/types'; +import { fetchNativeBalance } from '@/lib/stellar/balance'; +import { resolveHorizonUrl } from '@/lib/stellar/config'; -const fetchBalance = async (address: string, set: any) => { - try { - const res = await fetch(`https://horizon-testnet.stellar.org/accounts/${address}`); - if (res.ok) { - const data = await res.json(); - const native = data.balances.find((b: any) => b.asset_type === 'native'); - set({ balance: native ? native.balance : '0.0000000' }); - } else { - set({ balance: '0.0000000' }); - } - } catch (err) { - console.error('Error fetching balance:', err); - set({ balance: '0.0000000' }); +/** How often the connected wallet's balance is re-fetched. */ +const REFRESH_INTERVAL_MS = 15_000; + +let refreshTimer: ReturnType | null = null; + +function stopBalanceRefresh(): void { + if (refreshTimer !== null) { + clearInterval(refreshTimer); + refreshTimer = null; + } +} + +/** + * Fetches the native balance from the configured network's Horizon server and + * writes it into the store. Lookup failures set `balanceError` instead of a + * silently wrong balance; a 404 (account not on the ledger) is a valid zero. + */ +async function refreshBalance( + address: string, + set: (partial: Partial) => void, +): Promise { + const result = await fetchNativeBalance(address, resolveHorizonUrl()); + if (result.error) { + set({ balanceError: result.error }); + } else { + set({ balance: result.balance, balanceError: null }); } -}; +} export const useWalletStore = create()( devtools( @@ -25,31 +40,49 @@ export const useWalletStore = create()( connectedWallet: null, address: null, balance: null, + balanceError: null, isConnecting: false, error: null, // Actions connect: (wallet, address) => { + stopBalanceRefresh(); set({ connectedWallet: wallet, address: address, isConnecting: false, error: null, + balanceError: null, }); - fetchBalance(address, set); + // Fetch immediately on connect, then keep the balance fresh without + // requiring user action. + void refreshBalance(address, set); + refreshTimer = setInterval(() => { + void refreshBalance(address, set); + }, REFRESH_INTERVAL_MS); }, - disconnect: () => + disconnect: () => { + stopBalanceRefresh(); set({ connectedWallet: null, address: null, balance: null, + balanceError: null, isConnecting: false, error: null, - }), + }); + }, setBalance: (balance) => set({ balance }), + refreshBalance: async () => { + const { address } = useWalletStore.getState(); + if (address) { + await refreshBalance(address, set); + } + }, + setConnecting: (connecting) => set({ isConnecting: connecting }), setError: (error) => set({ error }), diff --git a/tests/walletBalance.test.ts b/tests/walletBalance.test.ts new file mode 100644 index 0000000..033a6e4 --- /dev/null +++ b/tests/walletBalance.test.ts @@ -0,0 +1,148 @@ +/** + * Tests for the network-aware wallet balance lookup (issue #8). + * + * Covers the fetch with mocked responses: native balance, no native asset, + * 404 (new account), network error, non-404 HTTP error, and mainnet vs + * testnet URL selection. + * + * Run with: npm test + */ +import { test, beforeEach, afterEach } from 'node:test'; +import assert from 'node:assert/strict'; + +import { fetchNativeBalance, parseNativeBalance, ZERO_BALANCE } from '../lib/stellar/balance.ts'; +import { resolveHorizonUrl } from '../lib/stellar/config.ts'; + +const ADDRESS = 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF'; + +const originalFetch = globalThis.fetch; +const originalNetwork = process.env.NEXT_PUBLIC_STELLAR_NETWORK; +const originalHorizonUrl = process.env.NEXT_PUBLIC_STELLAR_HORIZON_URL; + +beforeEach(() => { + globalThis.fetch = originalFetch; + process.env.NEXT_PUBLIC_STELLAR_NETWORK = originalNetwork; + delete process.env.NEXT_PUBLIC_STELLAR_HORIZON_URL; +}); + +afterEach(() => { + globalThis.fetch = originalFetch; + if (originalNetwork === undefined) delete process.env.NEXT_PUBLIC_STELLAR_NETWORK; + else process.env.NEXT_PUBLIC_STELLAR_NETWORK = originalNetwork; + if (originalHorizonUrl === undefined) delete process.env.NEXT_PUBLIC_STELLAR_HORIZON_URL; + else process.env.NEXT_PUBLIC_STELLAR_HORIZON_URL = originalHorizonUrl; +}); + +function mockFetchResponse(status: number, body?: unknown) { + globalThis.fetch = async () => + new Response(body === undefined ? null : JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' }, + }); +} + +test('native balance is extracted and stored on success', async () => { + mockFetchResponse(200, { + balances: [ + { asset_type: 'credit_alphanum4', asset_code: 'USDC', balance: '10.0000000' }, + { asset_type: 'native', balance: '123.4567890' }, + ], + }); + + const result = await fetchNativeBalance(ADDRESS, 'https://horizon-testnet.stellar.org'); + + assert.equal(result.error, null); + assert.equal(result.balance, '123.4567890'); +}); + +test('account without a native asset reports zero with no error', async () => { + mockFetchResponse(200, { + balances: [{ asset_type: 'credit_alphanum4', asset_code: 'USDC', balance: '5.0000000' }], + }); + + const result = await fetchNativeBalance(ADDRESS, 'https://horizon-testnet.stellar.org'); + + assert.equal(result.error, null); + assert.equal(result.balance, ZERO_BALANCE); +}); + +test('404 from Horizon is a valid zero balance, not an error', async () => { + mockFetchResponse(404); + + const result = await fetchNativeBalance(ADDRESS, 'https://horizon-testnet.stellar.org'); + + assert.equal(result.error, null); + assert.equal(result.balance, ZERO_BALANCE); +}); + +test('network failure surfaces as an error instead of a wrong balance', async () => { + globalThis.fetch = async () => { + throw new TypeError('fetch failed'); + }; + + const result = await fetchNativeBalance(ADDRESS, 'https://horizon-testnet.stellar.org'); + + assert.equal(result.balance, null); + assert.notEqual(result.error, null); + assert.match(result.error ?? '', /fetch failed/); +}); + +test('non-404 HTTP error surfaces as an error', async () => { + mockFetchResponse(500, { detail: 'boom' }); + + const result = await fetchNativeBalance(ADDRESS, 'https://horizon-testnet.stellar.org'); + + assert.equal(result.balance, null); + assert.match(result.error ?? '', /HTTP 500/); +}); + +test('trailing slashes on the Horizon URL are tolerated', async () => { + let calledUrl = ''; + globalThis.fetch = async (input: RequestInfo | URL) => { + calledUrl = String(input); + return new Response(JSON.stringify({ balances: [{ asset_type: 'native', balance: '1.0000000' }] }), { + status: 200, + }); + }; + + await fetchNativeBalance(ADDRESS, 'https://horizon-testnet.stellar.org/'); + + assert.equal(calledUrl, `https://horizon-testnet.stellar.org/accounts/${ADDRESS}`); +}); + +test('testnet configuration resolves the testnet Horizon URL', () => { + process.env.NEXT_PUBLIC_STELLAR_NETWORK = 'testnet'; + assert.equal(resolveHorizonUrl(), 'https://horizon-testnet.stellar.org'); +}); + +test('mainnet configuration resolves the mainnet Horizon URL', () => { + process.env.NEXT_PUBLIC_STELLAR_NETWORK = 'mainnet'; + assert.equal(resolveHorizonUrl(), 'https://horizon.stellar.org'); +}); + +test('explicit NEXT_PUBLIC_STELLAR_HORIZON_URL overrides the network mapping', () => { + process.env.NEXT_PUBLIC_STELLAR_NETWORK = 'testnet'; + process.env.NEXT_PUBLIC_STELLAR_HORIZON_URL = 'https://custom-horizon.example.org/'; + assert.equal(resolveHorizonUrl(), 'https://custom-horizon.example.org'); +}); + +test('mainnet configuration fetches from the mainnet Horizon URL', async () => { + process.env.NEXT_PUBLIC_STELLAR_NETWORK = 'mainnet'; + let calledUrl = ''; + globalThis.fetch = async (input: RequestInfo | URL) => { + calledUrl = String(input); + return new Response(JSON.stringify({ balances: [{ asset_type: 'native', balance: '42.0000000' }] }), { + status: 200, + }); + }; + + const result = await fetchNativeBalance(ADDRESS, resolveHorizonUrl()); + + assert.equal(calledUrl, `https://horizon.stellar.org/accounts/${ADDRESS}`); + assert.equal(result.balance, '42.0000000'); +}); + +test('parseNativeBalance falls back to zero for an empty balances list', () => { + assert.equal(parseNativeBalance({ balances: [] }), ZERO_BALANCE); + assert.equal(parseNativeBalance({}), ZERO_BALANCE); +}); diff --git a/tsconfig.json b/tsconfig.json index d39ddf3..b5c66e2 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,5 +1,6 @@ { "compilerOptions": { + "target": "ES2020", "lib": ["dom", "dom.iterable", "esnext"], "allowJs": true, "skipLibCheck": true, @@ -11,6 +12,7 @@ "moduleResolution": "bundler", "resolveJsonModule": true, "isolatedModules": true, + "allowImportingTsExtensions": true, "jsx": "preserve", "incremental": true, "plugins": [{ "name": "next" }], diff --git a/types/index.ts b/types/index.ts index 80bea37..f141fa1 100644 --- a/types/index.ts +++ b/types/index.ts @@ -35,6 +35,8 @@ export interface WalletState { connectedWallet: string | null; address: string | null; balance: string | null; + /** Set when the last balance lookup failed; distinct from a zero balance. */ + balanceError: string | null; isConnecting: boolean; error: string | null; } @@ -43,6 +45,7 @@ export interface WalletActions { connect: (wallet: string, address: string) => void; disconnect: () => void; setBalance: (balance: string) => void; + refreshBalance: () => Promise; setConnecting: (connecting: boolean) => void; setError: (error: string | null) => void; }