diff --git a/cli/src/commands/wallet/fund.ts b/cli/src/commands/wallet/fund.ts index 1c61c26..441f4f9 100644 --- a/cli/src/commands/wallet/fund.ts +++ b/cli/src/commands/wallet/fund.ts @@ -5,6 +5,16 @@ import { requireWallet } from '../../client.ts' import { chainCta, commandOutput, OutputContext } from '../../output.ts' import { synapseClient } from '../../synapse.ts' +const assetOutput = z.object({ + status: z.enum(['funded', 'missing', 'unconfirmed']), + balance: z.string().optional(), + txHash: z.string().optional(), + error: z.string().optional(), +}) + +type Asset = 'fil' | 'usdfc' +type AssetOutcome = z.infer + export const fundCommand = { description: 'Request testnet FIL and USDFC from faucet (testnet only)', mcp: { @@ -21,49 +31,143 @@ export const fundCommand = { }), alias: { chain: 'c' }, output: commandOutput({ - fil: z.string(), - usdfc: z.string(), + status: z.enum(['funded', 'partially_funded', 'not_funded', 'unconfirmed']), + fil: assetOutput, + usdfc: assetOutput, + faucetError: z.string().optional(), }), hint: 'Only works on Calibration testnet (chain 314159).', async run(c: any) { const out = new OutputContext(c) + if (c.options.chain !== 314159) { + return out.fail( + 'TESTNET_ONLY', + 'wallet fund only supports Filecoin Calibration (chain 314159). Fund mainnet wallets by sending FIL and USDFC to the wallet address.' + ) + } + const blocked = requireWallet(c, out) if (blocked) return blocked const { client, synapse } = synapseClient(c.options.chain) + const outcomes: Record = { + fil: { status: 'unconfirmed' }, + usdfc: { status: 'unconfirmed' }, + } + let faucetError: string | undefined try { out.step('Requesting faucet tokens') const hashes = await claimTokens({ address: client.account.address }) out.step('Waiting for transactions to be mined') - await waitForTransactionReceipt(client, { hash: hashes[0].tx_hash }) - - out.step('Fetching updated balances') - const filBalance = await synapse.payments.walletBalance() - const usdfcBalance = await synapse.payments.walletBalance({ - token: 'USDFC', - }) - - const result = { - fil: formatBalance({ value: filBalance }), - usdfc: formatBalance({ value: usdfcBalance }), + for (const transaction of hashes) { + const asset: Asset | undefined = + transaction.faucetInfo === 'CalibnetFIL' + ? 'fil' + : transaction.faucetInfo === 'CalibnetUSDFC' + ? 'usdfc' + : undefined + if (!asset) { + faucetError = `Unexpected faucet asset: ${transaction.faucetInfo}` + out.failStep(faucetError) + continue + } + outcomes[asset].txHash = transaction.tx_hash + try { + const receipt = await waitForTransactionReceipt(client, { + hash: transaction.tx_hash, + }) + outcomes[asset].status = 'missing' + if (receipt.status !== 'success') { + outcomes[asset].error = 'Faucet transaction reverted' + out.failStep(outcomes[asset].error) + } + } catch (error) { + outcomes[asset].error = (error as Error).message + out.failStep(outcomes[asset].error) + } } - - return out.done(result, { - cta: chainCta(c.options.chain, { - description: 'Next steps:', - commands: [ - { - command: 'wallet deposit', - args: { amount: '1' }, - description: 'Deposit USDFC into payment account', - }, - { command: 'wallet balance', description: 'Check balances' }, - ], - }), - }) } catch (error) { - return out.fail('FUND_FAILED', (error as Error).message) + faucetError = (error as Error).message + out.failStep(faucetError) + } + + out.step('Fetching updated balances') + for (const asset of ['fil', 'usdfc'] as const) { + try { + const balance = + asset === 'fil' + ? await synapse.payments.walletBalance() + : await synapse.payments.walletBalance({ token: 'USDFC' }) + outcomes[asset].balance = formatBalance({ value: balance }) + if (outcomes[asset].status === 'missing' && !outcomes[asset].error) { + outcomes[asset].status = balance > 0n ? 'funded' : 'unconfirmed' + } + } catch (error) { + if (!outcomes[asset].error) outcomes[asset].status = 'unconfirmed' + const balanceError = `Balance check failed: ${(error as Error).message}` + outcomes[asset].error = [outcomes[asset].error, balanceError] + .filter(Boolean) + .join('; ') + out.failStep(balanceError) + } } + + const funded = [outcomes.fil, outcomes.usdfc].filter( + ({ status }) => status === 'funded' + ).length + const hasUnconfirmed = [outcomes.fil, outcomes.usdfc].some( + ({ status }) => status === 'unconfirmed' + ) + const status = hasUnconfirmed + ? 'unconfirmed' + : funded === 2 + ? 'funded' + : funded === 1 + ? 'partially_funded' + : 'not_funded' + const unavailable = (['fil', 'usdfc'] as const).filter( + (asset) => outcomes[asset].status !== 'funded' + ) + const unavailableSummary = unavailable + .map((asset) => `${asset.toUpperCase()} is ${outcomes[asset].status}`) + .join(', ') + const hasMissing = unavailable.some( + (asset) => outcomes[asset].status === 'missing' + ) + const recovery = [ + hasMissing + ? 'Request only missing assets from a documented Calibration faucet.' + : '', + hasUnconfirmed ? 'Check unconfirmed balances before retrying.' : '', + ] + .filter(Boolean) + .join(' ') + const cta = + status === 'funded' + ? { + description: + 'Funding complete. Run wallet costs for the intended upload before depositing.', + commands: [], + } + : { + description: `Funding incomplete: ${unavailableSummary}. ${recovery}`, + commands: [ + { + command: 'wallet balance', + description: 'Verify wallet balances before continuing', + }, + ], + } + + return out.done( + { + status, + fil: outcomes.fil, + usdfc: outcomes.usdfc, + ...(faucetError ? { faucetError } : {}), + }, + { cta: chainCta(c.options.chain, cta) } + ) }, } diff --git a/cli/src/output.ts b/cli/src/output.ts index e1f87e4..31d1b46 100644 --- a/cli/src/output.ts +++ b/cli/src/output.ts @@ -159,6 +159,15 @@ export class OutputContext { } } + failStep(message: string) { + if (this.log.length > 0) { + this.log[this.log.length - 1].status = 'failed' + this.log[this.log.length - 1].error = message + } + this.stopSpinner() + if (!this.agent) p.log.error(message) + } + info(message: string) { if (!this.agent) { this.stopSpinner() diff --git a/cli/tests/command-mocks.ts b/cli/tests/command-mocks.ts index 92c471d..12c02d0 100644 --- a/cli/tests/command-mocks.ts +++ b/cli/tests/command-mocks.ts @@ -106,7 +106,10 @@ export const formatBalance = mock(({ value }: { value: bigint }) => { return `formatted:${value.toString()}` }) -export const claimTokens = mock(async () => [{ tx_hash: '0xfaucet' }]) +export const claimTokens = mock(async () => [ + { faucetInfo: 'CalibnetUSDFC', tx_hash: '0xusdfc' }, + { faucetInfo: 'CalibnetFIL', tx_hash: '0xfil' }, +]) export const fakeProvider = { id: 77n, @@ -419,7 +422,10 @@ export function resetCommandMocks() { formatBalance.mockImplementation(({ value }: { value: bigint }) => { return `formatted:${value.toString()}` }) - claimTokens.mockImplementation(async () => [{ tx_hash: '0xfaucet' }]) + claimTokens.mockImplementation(async () => [ + { faucetInfo: 'CalibnetUSDFC', tx_hash: '0xusdfc' }, + { faucetInfo: 'CalibnetFIL', tx_hash: '0xfil' }, + ]) synapsePayments.walletBalance.mockImplementation( async (options?: { token?: string }) => (options?.token ? 2000n : 1000n) diff --git a/cli/tests/synapse-commands.test.ts b/cli/tests/synapse-commands.test.ts index 0b77385..f55d52e 100644 --- a/cli/tests/synapse-commands.test.ts +++ b/cli/tests/synapse-commands.test.ts @@ -1212,23 +1212,244 @@ describe('wallet commands', () => { }) }) - test('wallet fund claims faucet tokens, waits for FIL, and returns updated balances', async () => { + test('wallet fund waits for both assets and returns their updated balances', async () => { const result = await fundCommand.run(commandContext()) expect(claimTokens).toHaveBeenCalledWith({ address: fakeWalletClient.account.address, }) - expect(waitForTransactionReceipt).toHaveBeenCalledWith(fakeWalletClient, { - hash: '0xfaucet', + expect(waitForTransactionReceipt).toHaveBeenNthCalledWith( + 1, + fakeWalletClient, + { hash: '0xusdfc' } + ) + expect(waitForTransactionReceipt).toHaveBeenNthCalledWith( + 2, + fakeWalletClient, + { hash: '0xfil' } + ) + expect(synapsePayments.walletBalance).toHaveBeenNthCalledWith(1) + expect(synapsePayments.walletBalance).toHaveBeenNthCalledWith(2, { + token: 'USDFC', + }) + expect(result).toMatchObject({ + status: 'funded', + fil: { + status: 'funded', + balance: 'formatted:1000', + txHash: '0xfil', + }, + usdfc: { + status: 'funded', + balance: 'formatted:2000', + txHash: '0xusdfc', + }, + }) + }) + + test('wallet fund does not treat an unknown faucet asset as USDFC', async () => { + claimTokens.mockResolvedValueOnce([ + { faucetInfo: 'unexpected', tx_hash: '0xunknown' }, + ] as any) + + const result = await fundCommand.run(commandContext()) + + expect(waitForTransactionReceipt).not.toHaveBeenCalled() + expect(result).toMatchObject({ + status: 'unconfirmed', + faucetError: 'Unexpected faucet asset: unexpected', + usdfc: { status: 'unconfirmed' }, + }) + expect(result.usdfc.txHash).toBeUndefined() + expect(result.processLog[1]).toMatchObject({ status: 'failed' }) + }) + + test('wallet fund leaves a successful claim unconfirmed until its balance appears', async () => { + synapsePayments.walletBalance.mockImplementation( + async (options?: { token?: string }) => (options?.token ? 0n : 1000n) + ) + + const result = await fundCommand.run(commandContext()) + + expect(result).toMatchObject({ + status: 'unconfirmed', + fil: { status: 'funded', balance: 'formatted:1000' }, + usdfc: { status: 'unconfirmed', balance: 'formatted:0' }, }) + expect(result.cta.description).toContain('USDFC is unconfirmed') + expect(result.cta.description).toContain( + 'Check unconfirmed balances before retrying' + ) + expect(result.cta.description).not.toContain('Request') + expect(result.cta.description).not.toContain('faucet') + expect(result.cta.commands).toEqual([ + { + command: 'wallet balance', + options: { chain: 314159 }, + description: 'Verify wallet balances before continuing', + }, + ]) + }) + + test('wallet fund directs successful claims through upload costing', async () => { + const result = await fundCommand.run(commandContext()) + + expect(result.cta).toEqual({ + description: + 'Funding complete. Run wallet costs for the intended upload before depositing.', + commands: [], + }) + }) + + test('wallet fund does not treat an existing balance as proof after a receipt timeout', async () => { + waitForTransactionReceipt.mockImplementation( + async (_client: any, { hash }: { hash: string }) => { + if (hash === '0xusdfc') throw new Error('receipt timed out') + return { status: 'success' } + } + ) + + const result = await fundCommand.run(commandContext()) + + expect(waitForTransactionReceipt).toHaveBeenCalledTimes(2) expect(synapsePayments.walletBalance).toHaveBeenNthCalledWith(1) expect(synapsePayments.walletBalance).toHaveBeenNthCalledWith(2, { token: 'USDFC', }) expect(result).toMatchObject({ - fil: 'formatted:1000', - usdfc: 'formatted:2000', + status: 'unconfirmed', + fil: { status: 'funded' }, + usdfc: { + status: 'unconfirmed', + balance: 'formatted:2000', + error: 'receipt timed out', + }, + }) + expect(result.processLog[1]).toEqual({ + step: 'Waiting for transactions to be mined', + status: 'failed', + error: 'receipt timed out', + }) + }) + + test('wallet fund does not retry an unconfirmed zero balance after a receipt timeout', async () => { + waitForTransactionReceipt.mockImplementation( + async (_client: any, { hash }: { hash: string }) => { + if (hash === '0xusdfc') throw new Error('receipt timed out') + return { status: 'success' } + } + ) + synapsePayments.walletBalance.mockImplementation( + async (options?: { token?: string }) => (options?.token ? 0n : 1000n) + ) + + const result = await fundCommand.run(commandContext()) + + expect(result).toMatchObject({ + status: 'unconfirmed', + fil: { status: 'funded' }, + usdfc: { + status: 'unconfirmed', + balance: 'formatted:0', + error: 'receipt timed out', + }, + }) + expect(result.cta.description).toContain( + 'Check unconfirmed balances before retrying' + ) + expect(result.cta.description).not.toContain('Request') + expect(result.cta.description).not.toContain('faucet') + }) + + test('wallet fund preserves a reverted outcome despite an existing balance', async () => { + waitForTransactionReceipt.mockImplementation( + async (_client: any, { hash }: { hash: string }) => ({ + status: hash === '0xusdfc' ? 'reverted' : 'success', + }) + ) + + const result = await fundCommand.run(commandContext()) + + expect(result).toMatchObject({ + status: 'partially_funded', + fil: { status: 'funded' }, + usdfc: { + status: 'missing', + balance: 'formatted:2000', + error: 'Faucet transaction reverted', + }, + }) + }) + + test('wallet fund preserves a reverted outcome when its balance check fails', async () => { + waitForTransactionReceipt.mockImplementation( + async (_client: any, { hash }: { hash: string }) => ({ + status: hash === '0xusdfc' ? 'reverted' : 'success', + }) + ) + synapsePayments.walletBalance.mockImplementation( + async (options?: { token?: string }) => { + if (options?.token) throw new Error('RPC unavailable') + return 1000n + } + ) + + const result = await fundCommand.run(commandContext()) + + expect(result).toMatchObject({ + status: 'partially_funded', + fil: { status: 'funded' }, + usdfc: { + status: 'missing', + error: + 'Faucet transaction reverted; Balance check failed: RPC unavailable', + }, + }) + expect(result.processLog[2]).toEqual({ + step: 'Fetching updated balances', + status: 'failed', + error: 'Balance check failed: RPC unavailable', + }) + }) + + test('wallet fund rechecks both balances after the faucet helper fails', async () => { + claimTokens.mockRejectedValueOnce(new Error('faucet unavailable')) + + const result = await fundCommand.run(commandContext()) + + expect(waitForTransactionReceipt).not.toHaveBeenCalled() + expect(synapsePayments.walletBalance).toHaveBeenNthCalledWith(1) + expect(synapsePayments.walletBalance).toHaveBeenNthCalledWith(2, { + token: 'USDFC', + }) + expect(result).toMatchObject({ + status: 'unconfirmed', + faucetError: 'faucet unavailable', + fil: { status: 'unconfirmed', balance: 'formatted:1000' }, + usdfc: { status: 'unconfirmed', balance: 'formatted:2000' }, + }) + expect(result.processLog[0]).toEqual({ + step: 'Requesting faucet tokens', + status: 'failed', + error: 'faucet unavailable', + }) + expect(result.error).toBeUndefined() + expect(result.cta.description).not.toContain('Request') + }) + + test('wallet fund rejects mainnet before creating a client or contacting a faucet', async () => { + const result = await fundCommand.run( + commandContext({ options: { chain: 314 } }) + ) + + expect(result.error).toEqual({ + code: 'TESTNET_ONLY', + message: + 'wallet fund only supports Filecoin Calibration (chain 314159). Fund mainnet wallets by sending FIL and USDFC to the wallet address.', }) + expect(privateKeyClient).not.toHaveBeenCalled() + expect(claimTokens).not.toHaveBeenCalled() + expect(synapsePayments.walletBalance).not.toHaveBeenCalled() }) test('wallet summary maps account summary balances and funding timeline', async () => { diff --git a/skills/foc-cli/references/troubleshooting.md b/skills/foc-cli/references/troubleshooting.md index e82133b..ca00667 100644 --- a/skills/foc-cli/references/troubleshooting.md +++ b/skills/foc-cli/references/troubleshooting.md @@ -4,6 +4,8 @@ How foc-cli reports failures: every command returns a structured error envelope **Retry semantics:** `retryable: true` means the same call may succeed if repeated (network/provider hiccups) — retry with backoff (e.g. 2s, 10s, 30s; give up after ~3 attempts). Errors without the flag are usually input, state, or funding problems: fix the cause instead of retrying. Never blind-retry fund-moving commands (`deposit`, `withdraw`, `upload`) — re-check state with `wallet balance` / `dataset list` first so a slow-but-successful transaction isn't repeated. +`wallet fund` is the exception to the error-envelope rule after a faucet attempt: it returns a structured result even when funding is partial, missing, or unconfirmed so the FIL and USDFC balances, transaction hashes, and errors remain available. `unconfirmed` means the submission or receipt outcome is unknown; run `wallet balance` before retrying. Invalid chains still return `TESTNET_ONLY` as an error envelope. + ## First checks — the causes behind most failures 1. **No wallet configured** — message contains `Private key not found`. Run `wallet init` (see Setup in SKILL.md). @@ -25,7 +27,7 @@ How foc-cli reports failures: every command returns a structured error envelope | `KEYSTORE_TIMED_OUT` | any signing command | `cast` ran but did not finish within 30s — almost always the password prompt with nobody to answer it | Yes (flagged), but the real fix is a private-key or key-reference wallet for automation | | `ADDRESS_NOT_ON_CHAIN` | `wallet balance` | Brand-new address with no onchain history yet — every balance is zero | No — fund the address first (`wallet fund` on testnet) | | `BALANCE_FETCH_FAILED` | `wallet balance` | RPC hiccup; no wallet configured | Once, if message looks network-y | -| `FUND_FAILED` | `wallet fund` | Faucet rate-limit or temporarily empty (testnet-only command); RPC hiccup | Later — faucets throttle per-address | +| `TESTNET_ONLY` | `wallet fund` | A chain other than Calibration (`314159`) was requested | No — fund mainnet by sending FIL and USDFC to the wallet address | | `DEPOSIT_FAILED` | `wallet deposit` | Insufficient USDFC in wallet; no FIL for gas; RPC/tx failure | Only after fixing funds | | `WITHDRAW_FAILED` | `wallet withdraw` | Commonly: amount exceeds *available* (unlocked) funds — active payment rails lock part of the deposit (`wallet summary` shows it). Also gas or RPC failures — read the message | Only after checking `wallet summary` | | `COSTS_FAILED`, `SUMMARY_FAILED` | `wallet costs` / `summary` | RPC hiccup; no wallet | Once |