Skip to content
Merged
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
36 changes: 0 additions & 36 deletions src/hooks/useStellarAccount.ts

This file was deleted.

111 changes: 111 additions & 0 deletions src/lib/api.test.ts
Original file line number Diff line number Diff line change
@@ -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')
})
})
7 changes: 7 additions & 0 deletions src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Loading