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; }