diff --git a/CHANGELOG.md b/CHANGELOG.md index c2cfc17..1a3d872 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,16 @@ - Transaction history - Dark mode - Exchange rate display +- Test coverage for `horizonUrl`/`fetchAccountFromHorizon`'s + testnet/mainnet host selection, error handling, and account-info + mapping (#21) ### Fixed - EscrowForm now rejects self-escrow (beneficiary === depositor) and arbiter addresses that match the depositor or beneficiary (#23) + +### Removed +- Removed the orphaned `useStellarAccount` hook: it had no call sites, + hardcoded mainnet Horizon regardless of the selected network, and + duplicated the network-aware account fetching already provided by + `useWallet`/`fetchAccountFromHorizon` (#21) diff --git a/src/hooks/useStellarAccount.ts b/src/hooks/useStellarAccount.ts deleted file mode 100644 index 672d55d..0000000 --- a/src/hooks/useStellarAccount.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { useState, useCallback } from 'react' - -interface AccountInfo { - address: string - balance: string - sequence: string -} - -export function useStellarAccount() { - const [account, setAccount] = useState(null) - const [loading, setLoading] = useState(false) - const [error, setError] = useState(null) - - const fetchAccount = useCallback(async (address: string) => { - setLoading(true) - setError(null) - try { - const res = await fetch(`https://horizon.stellar.org/accounts/${address}`) - if (res.status === 404) throw new Error('Account not found') - if (!res.ok) throw new Error('Network error') - const data = await res.json() - const xlmBalance = data.balances?.find((b: { asset_type: string }) => b.asset_type === 'native') - setAccount({ - address, - balance: xlmBalance?.balance ?? '0', - sequence: data.sequence, - }) - } catch (e) { - setError((e as Error).message) - } finally { - setLoading(false) - } - }, []) - - return { account, loading, error, fetchAccount } -} diff --git a/src/lib/api.test.ts b/src/lib/api.test.ts new file mode 100644 index 0000000..bd1e5fb --- /dev/null +++ b/src/lib/api.test.ts @@ -0,0 +1,111 @@ +import { describe, it, expect, vi, afterEach } from 'vitest' +import { horizonUrl, fetchAccountFromHorizon } from './api' + +describe('horizonUrl', () => { + it('returns the testnet Horizon host for network "testnet"', () => { + expect(horizonUrl('testnet')).toBe('https://horizon-testnet.stellar.org') + }) + + it('returns the mainnet Horizon host for network "mainnet"', () => { + expect(horizonUrl('mainnet')).toBe('https://horizon.stellar.org') + }) +}) + +const PUBLIC_KEY = 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5' + +function mockFetchOnce(body: unknown, status = 200) { + const fetchMock = vi.fn().mockResolvedValue({ + ok: status >= 200 && status < 300, + status, + json: () => Promise.resolve(body), + }) + vi.stubGlobal('fetch', fetchMock) + return fetchMock +} + +const minimalHorizonAccount = { + account_id: PUBLIC_KEY, + sequence: '1', + subentry_count: 0, + last_modified_ledger: 1, + thresholds: { low_threshold: 0, med_threshold: 0, high_threshold: 0 }, + flags: { auth_required: false, auth_revocable: false, auth_immutable: false }, + balances: [], +} + +describe('fetchAccountFromHorizon', () => { + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('requests the testnet host when network is "testnet"', async () => { + const fetchMock = mockFetchOnce(minimalHorizonAccount) + await fetchAccountFromHorizon(PUBLIC_KEY, 'testnet') + + expect(fetchMock).toHaveBeenCalledWith( + `https://horizon-testnet.stellar.org/accounts/${PUBLIC_KEY}`, + ) + }) + + it('requests the mainnet host when network is "mainnet"', async () => { + const fetchMock = mockFetchOnce(minimalHorizonAccount) + await fetchAccountFromHorizon(PUBLIC_KEY, 'mainnet') + + expect(fetchMock).toHaveBeenCalledWith( + `https://horizon.stellar.org/accounts/${PUBLIC_KEY}`, + ) + }) + + it('throws a clear "not found" error on a 404, instead of the raw status', async () => { + mockFetchOnce({}, 404) + + await expect(fetchAccountFromHorizon(PUBLIC_KEY, 'testnet')).rejects.toThrow( + 'Account not found on Stellar network', + ) + }) + + it('throws a Horizon-error message on other non-ok statuses', async () => { + mockFetchOnce({}, 503) + + await expect(fetchAccountFromHorizon(PUBLIC_KEY, 'testnet')).rejects.toThrow( + 'Horizon error: 503', + ) + }) + + it('maps thresholds, flags, and every balance — not just the native XLM one', async () => { + mockFetchOnce({ + account_id: PUBLIC_KEY, + sequence: '42', + subentry_count: 2, + last_modified_ledger: 100, + thresholds: { low_threshold: 1, med_threshold: 2, high_threshold: 3 }, + flags: { auth_required: true, auth_revocable: false, auth_immutable: false }, + balances: [ + { asset_type: 'native', balance: '100.5000000' }, + { + asset_type: 'credit_alphanum4', + asset_code: 'USDC', + asset_issuer: 'GISSUER', + balance: '10.0000000', + buying_liabilities: '0', + selling_liabilities: '0', + }, + ], + }) + + const account = await fetchAccountFromHorizon(PUBLIC_KEY, 'testnet') + + expect(account.sequence).toBe('42') + expect(account.subentryCount).toBe(2) + expect(account.thresholds).toEqual({ lowThreshold: 1, medThreshold: 2, highThreshold: 3 }) + expect(account.flags).toEqual({ + authRequired: true, + authRevocable: false, + authImmutable: false, + }) + expect(account.balances).toHaveLength(2) + expect(account.balances[0].asset.code).toBe('XLM') + expect(account.balances[1].asset.code).toBe('USDC') + expect(account.balances[1].asset.issuer).toBe('GISSUER') + }) +}) diff --git a/src/lib/api.ts b/src/lib/api.ts index 5e7ecdc..0811bc0 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -324,10 +324,17 @@ export const transactionApi = { const HORIZON_TESTNET = 'https://horizon-testnet.stellar.org' const HORIZON_MAINNET = 'https://horizon.stellar.org' +/** Resolves the Horizon host for `network` — always thread the caller's + * currently selected network through here rather than hardcoding a host; + * a hardcoded mainnet URL is what made the now-removed `useStellarAccount` + * hook silently 404 on valid testnet accounts (#21). */ export function horizonUrl(network: 'testnet' | 'mainnet') { return network === 'testnet' ? HORIZON_TESTNET : HORIZON_MAINNET } +/** Fetches full account state from Horizon for the given `network`. This is + * the network-aware source of truth `useWallet`'s account state is built + * on — prefer it (or that hook) over any new ad-hoc Horizon call. */ export async function fetchAccountFromHorizon( publicKey: string, network: 'testnet' | 'mainnet',