From 4e282930316a75301074d453dc00ac07ee08357f Mon Sep 17 00:00:00 2001 From: Vladimir Rogojin Date: Tue, 9 Jun 2026 15:39:32 +0200 Subject: [PATCH] fix(cli)(sphere-sdk#455): mint test tokens locally instead of via HTTP faucet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The faucet/topup/top-up handlers now call sphere.payments.mintFungibleToken() to mint test tokens directly via the L3 aggregator. This removes the dependency on the external https://faucet.unicity.network HTTP service — which was the dominant cause of the soak flakiness reported in #455 (single-coin path lost Nostr events; bulk path stayed lucky because 7 parallel events raised retention odds). Behavioral changes: - Nametag precondition dropped — local mint goes to the wallet's own signing key; nothing on-chain depends on a registered nametag. - Default coin set unchanged (UCT 100, BTC 1, ETH 42, SOL 1000, USDT 1000, USDC 1000, USDU 1000) — soaks/playbooks see the same balances after `sphere faucet`. - Symbol resolution covers both the legacy faucet names ("unicity", "bitcoin", ...) and the canonical symbols ("UCT", "BTC", ...). - Bulk mint runs sequentially to keep output ordered and sidestep potential storage-write races between concurrent addToken() calls. - Help text + command listing refreshed to reflect "mint locally". Integration test: dropped the no-nametag dispatch pin (premise gone) and refreshed the live-mint test to match the new "Minting … locally" / "Received …" wording. Help-shape pins still cover all three aliases. Verified: typecheck clean; 127 unit tests pass; integration faucet suite 4/4 pass including live mint of 1 UCT against the testnet aggregator. --- src/legacy/legacy-cli.ts | 163 +++++++++-------- .../cli-faucet.integration.test.ts | 164 +++++------------- 2 files changed, 136 insertions(+), 191 deletions(-) diff --git a/src/legacy/legacy-cli.ts b/src/legacy/legacy-cli.ts index 0e155f9..1f17e1d 100644 --- a/src/legacy/legacy-cli.ts +++ b/src/legacy/legacy-cli.ts @@ -1148,10 +1148,10 @@ const COMMAND_HELP: Record = { }, 'topup': { usage: 'topup [ ]', - description: 'Request test tokens from the Unicity faucet. Without arguments, requests default amounts of all supported coins. With amount and coin, requests a specific coin.', + description: 'Mint test tokens locally (no external faucet service). Without arguments, mints default amounts of all default coins. With amount and coin, mints a specific coin.', flags: [ - { flag: '', description: 'Amount to request (numeric)' }, - { flag: '', description: 'Coin symbol (UCT, BTC, ETH, SOL, USDT, USDC, USDU, EURU, ALPHT) or faucet name (unicity, bitcoin, ethereum, solana, tether, usd-coin, unicity-usd)' }, + { flag: '', description: 'Whole-coin amount to mint (numeric)' }, + { flag: '', description: 'Coin symbol (UCT, BTC, ETH, SOL, USDT, USDC, USDU, EURU, ALPHT) or legacy faucet name (unicity, bitcoin, ethereum, solana, tether, usd-coin, unicity-usd)' }, ], examples: [ 'npm run cli -- topup', @@ -1160,13 +1160,13 @@ const COMMAND_HELP: Record = { 'npm run cli -- topup 42 ETH', ], notes: [ - 'Requires a registered nametag. The faucet is only available on testnet.', + 'Tokens are minted directly via the L3 aggregator — no nametag and no faucet HTTP service required.', 'Also accessible as "top-up" or "faucet".', ], }, 'top-up': { usage: 'top-up [ ]', - description: 'Alias for "topup". Request test tokens from the Unicity faucet.', + description: 'Alias for "topup". Mint test tokens locally (no external faucet service).', examples: [ 'npm run cli -- top-up', 'npm run cli -- top-up 2 BTC', @@ -1177,7 +1177,7 @@ const COMMAND_HELP: Record = { }, 'faucet': { usage: 'faucet [ ]', - description: 'Alias for "topup". Request test tokens from the Unicity faucet.', + description: 'Alias for "topup". Mint test tokens locally (no external faucet service).', examples: [ 'npm run cli -- faucet', 'npm run cli -- faucet 100 ETH', @@ -2121,7 +2121,7 @@ BALANCE & TOKENS: assets List all registered assets (coins & NFTs) asset-info Show detailed info for an asset l1-balance L1 (ALPHA) balance - topup [ ] Request test tokens from faucet + topup [ ] Mint test tokens locally verify-balance Detect spent tokens via aggregator sync Sync tokens with IPFS @@ -3831,90 +3831,117 @@ async function main(): Promise { case 'topup': case 'top-up': case 'faucet': { - // Get nametag from wallet + // Local mint replaces the legacy HTTP faucet (sphere-sdk#455). + // The wallet mints fungible tokens to itself via the L3 aggregator, + // so no nametag is required and no external faucet service is + // touched. Default coin amounts match the historical bulk faucet + // (100 UCT, 1 BTC, 42 ETH, 1000 SOL/USDT/USDC/USDU). const sphere = await getSphere(); - const nametag = sphere.getNametag(); - if (!nametag) { - console.error('Error: No nametag registered. Use "nametag " first.'); - await closeSphere(); - process.exit(1); - } // Parse options: topup [ ] const amountArg: string | undefined = args[1]; const coinArg: string | undefined = args[2]; - const FAUCET_URL = 'https://faucet.unicity.network/api/v1/faucet/request'; - - // Default amounts in whole coins (the faucet API converts to smallest units internally). - const DEFAULT_COINS: Record = { - 'unicity': 100, - 'bitcoin': 1, - 'ethereum': 42, - 'solana': 1000, - 'tether': 1000, - 'usd-coin': 1000, - 'unicity-usd': 1000, - }; + const registry = TokenRegistry.getInstance(); - async function requestFaucet(coin: string, amount: number): Promise<{ success: boolean; message?: string }> { - try { - const response = await fetch(FAUCET_URL, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - unicityId: nametag, // Without @ prefix - faucet API expects raw nametag - coin, - amount, - }), - }); - const result = await response.json() as { success: boolean; message?: string; error?: string }; - // API returns 'error' field on failure, normalize to 'message' - return { - success: result.success, - message: result.message || result.error, - }; - } catch (error) { - return { success: false, message: error instanceof Error ? error.message : 'Request failed' }; + // Default coin set — must stay in sync with the legacy faucet + // service's bulk response so existing soaks/playbooks see the + // same balances after a no-arg `sphere faucet`. + const DEFAULT_COINS: ReadonlyArray<{ symbol: string; amount: number }> = [ + { symbol: 'UCT', amount: 100 }, + { symbol: 'BTC', amount: 1 }, + { symbol: 'ETH', amount: 42 }, + { symbol: 'SOL', amount: 1000 }, + { symbol: 'USDT', amount: 1000 }, + { symbol: 'USDC', amount: 1000 }, + { symbol: 'USDU', amount: 1000 }, + ]; + + // FAUCET_COIN_MAP keys are symbols; values are legacy faucet + // names. Reverse the map so a legacy name argument (e.g. + // "unicity") still resolves to the symbol the registry knows. + const FAUCET_NAME_TO_SYMBOL: Record = Object.fromEntries( + Object.entries(FAUCET_COIN_MAP).map(([sym, name]) => [name.toLowerCase(), sym]) + ); + + function resolveSymbol(label: string): string | null { + const upper = label.toUpperCase(); + if (FAUCET_COIN_MAP[upper]) return upper; // already a symbol + const fromName = FAUCET_NAME_TO_SYMBOL[label.toLowerCase()]; + if (fromName) return fromName; + // Last resort — let the registry try (handles symbols not in + // FAUCET_COIN_MAP). + const def = registry.getDefinitionBySymbol(upper) ?? registry.getDefinitionByName(label); + return def?.symbol ?? null; + } + + async function mintLocal(symbol: string, wholeAmount: number): Promise<{ success: boolean; message?: string; symbol: string }> { + const def = registry.getDefinitionBySymbol(symbol); + if (!def || def.assetKind !== 'fungible') { + return { success: false, symbol, message: `Unknown fungible coin "${symbol}"` }; } + const decimals = def.decimals ?? 0; + // BigInt arithmetic for the multiplier; fractional whole-amounts + // (e.g. 0.5 BTC) are rounded to the nearest smallest-unit. + const scaled = Math.round(wholeAmount * Math.pow(10, decimals)); + if (!Number.isFinite(scaled) || scaled <= 0) { + return { success: false, symbol, message: `Invalid amount ${wholeAmount}` }; + } + const smallest = BigInt(scaled); + const r = await sphere.payments.mintFungibleToken(def.id, smallest); + if (r.success) return { success: true, symbol }; + return { success: false, symbol, message: r.error }; } if (coinArg) { - // Request specific coin — accept symbols (UCT, BTC) as well as faucet names (unicity, bitcoin) - const coin = FAUCET_COIN_MAP[coinArg.toUpperCase()] || coinArg.toLowerCase(); - const amount = amountArg ? parseFloat(amountArg) : (DEFAULT_COINS[coin] || 1); - - // The faucet API converts whole-coin amounts to smallest units internally. - console.log(`Requesting ${amount} ${coin} from faucet for @${nametag}...`); - const result = await requestFaucet(coin, amount); + const symbol = resolveSymbol(coinArg); + if (!symbol) { + console.error(`\n✗ Failed: Unknown coin "${coinArg}"`); + await closeSphere(); + process.exit(1); + } + const amount = amountArg ? parseFloat(amountArg) : 1; + if (!Number.isFinite(amount) || amount <= 0) { + console.error(`\n✗ Failed: Invalid amount "${amountArg}"`); + await closeSphere(); + process.exit(1); + } + console.log(`Minting ${amount} ${symbol} locally...`); + const result = await mintLocal(symbol, amount); if (result.success) { - console.log(`\n✓ Received ${amount} ${coin}`); + console.log(`\n✓ Received ${amount} ${symbol}`); } else { console.error(`\n✗ Failed: ${result.message || 'Unknown error'}`); + await closeSphere(); + process.exit(1); } } else { - // Request all coins - console.log(`Requesting all test tokens for @${nametag}...`); + // Bulk mint — historical default set. Sequential to keep output + // ordered and to sidestep storage-write races between concurrent + // addToken() calls. + console.log('Minting test tokens locally...'); console.log('─'.repeat(50)); - const results = await Promise.all( - Object.entries(DEFAULT_COINS).map(async ([coin, amount]) => { - const result = await requestFaucet(coin, amount); - return { coin, amount, ...result }; - }) - ); - - for (const result of results) { - if (result.success) { - console.log(`✓ ${result.coin}: ${result.amount}`); + const results: Array<{ symbol: string; amount: number; success: boolean; message?: string }> = []; + for (const c of DEFAULT_COINS) { + const r = await mintLocal(c.symbol, c.amount); + results.push({ symbol: c.symbol, amount: c.amount, success: r.success, message: r.message }); + // Print each line as it completes so progress is visible. + if (r.success) { + console.log(`✓ ${c.symbol}: ${c.amount}`); } else { - console.log(`✗ ${result.coin}: Failed - ${result.message || 'Unknown error'}`); + console.log(`✗ ${c.symbol}: ${r.message || 'Unknown error'}`); } } console.log('─'.repeat(50)); - console.log('TopUp complete! Run "balance" to see updated balances.'); + const failed = results.filter((r) => !r.success).length; + if (failed === 0) { + console.log('TopUp complete! Run "balance" to see updated balances.'); + } else { + console.log(`TopUp finished with ${failed} failure(s). Run "balance" to see what landed.`); + } } await closeSphere(); @@ -6178,7 +6205,7 @@ function getCompletionCommands(): CompletionCommand[] { { name: 'assets', description: 'List registered assets (coins & NFTs)', flags: ['--type'] }, { name: 'asset-info', description: 'Show detailed info for an asset' }, { name: 'l1-balance', description: 'Show L1 (ALPHA) balance' }, - { name: 'topup', description: 'Request test tokens from faucet' }, + { name: 'topup', description: 'Mint test tokens locally' }, { name: 'top-up', description: 'Alias for topup' }, { name: 'faucet', description: 'Alias for topup' }, { name: 'verify-balance', description: 'Verify tokens against aggregator', flags: ['--remove', '-v', '--verbose'] }, diff --git a/test/integration/cli-faucet.integration.test.ts b/test/integration/cli-faucet.integration.test.ts index b0f6517..564eee9 100644 --- a/test/integration/cli-faucet.integration.test.ts +++ b/test/integration/cli-faucet.integration.test.ts @@ -1,49 +1,32 @@ /** - * Integration test: `sphere {topup,top-up,faucet}` — testnet faucet surface. + * Integration test: `sphere {topup,top-up,faucet}` — local-mint surface. * * Backstop for the CLI extraction: the topup / top-up / faucet aliases - * post test tokens against the Unicity faucet HTTP endpoint - * (`https://faucet.unicity.network/api/v1/faucet/request`). All three - * names land in the same fall-through case in `src/legacy/legacy-cli.ts` - * (~line 2942), and `sphere faucet` is namespace-bridged to `topup` in - * `src/index.ts`. SDK-layer coverage doesn't exist for this — the faucet - * client is implemented entirely inside the CLI handler (it doesn't go - * through Sphere/SDK), so this file is the ONLY layer that pins it. + * locally mint test tokens via `sphere.payments.mintFungibleToken()` (L3 + * aggregator commit). The legacy HTTP faucet client was removed in + * sphere-sdk#455 — no external faucet service is touched any more. * - * Three layers of pins: + * All three names land in the same fall-through case in + * `src/legacy/legacy-cli.ts` (~line 3830), and `sphere faucet` is + * namespace-bridged to `topup` in `src/index.ts`. + * + * Two layers of pins: * * 1. **Help-shape pins (offline, 3 tests)** — `payments help ` * for each of `topup`, `top-up`, `faucet`. All three help blocks - * live in HELP_TEXT (~lines 597-636). Pinning all three catches a + * live in HELP_TEXT (~lines 1149-1188). Pinning all three catches a * refactor that removes one alias's doc without updating the * dispatch case below. * - * 2. **No-nametag dispatch pins (wallet init, no HTTP)** — All three - * aliases require a registered nametag before they'll hit the - * faucet API. A fresh wallet has no nametag → command exits 1 with - * "No nametag registered" stderr message BEFORE any fetch is made. - * Running each alias and asserting the same error proves: - * a. the namespace bridge (`sphere faucet` → `topup`) is wired, - * b. all three legacy-CLI fall-through cases land on the same - * handler (the case label union — if a refactor splits them, - * only one alias would still error this way), - * c. the nametag precondition fires before the faucet round-trip - * so a user with a broken-or-rate-limited faucet endpoint - * still gets a clean "wallet needs a nametag" message. - * No faucet HTTP call is made — wallet init talks to Nostr + - * aggregator only. - * - * 3. **Live faucet request (opt-in, E2E_RUN_FAUCET=1)** — When the - * env var is set, register a fresh `it_` nametag and request - * a small amount of unicity (UCT) from the faucet. Asserts a - * "✓ Received" success line. Gated because (a) the faucet has rate - * limits and drain protection, (b) external service flakiness - * shouldn't break the default test suite, (c) it consumes real - * testnet tokens. + * 2. **Live local-mint request (opt-in, E2E_RUN_FAUCET=1)** — When the + * env var is set, run `sphere faucet 1 UCT` on a fresh wallet (no + * nametag — local mint doesn't require one) and assert the + * "✓ Received" success line. Gated because (a) the L3 aggregator is + * an external dependency and (b) the test consumes time on the + * shared testnet aggregator. */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import { randomBytes } from 'node:crypto'; import { createSphereEnv, destroySphereEnv, @@ -52,12 +35,12 @@ import { type SphereEnv, } from './helpers.js'; -/** Opt-in gate for the live faucet round-trip — disabled by default. */ +/** Opt-in gate for the live local-mint round-trip — disabled by default. */ const RUN_FAUCET_E2E = process.env['E2E_RUN_FAUCET'] === '1'; /** * The three CLI verbs that all dispatch to the same `legacy-cli.ts` - * case block (~line 2942: `case 'topup': case 'top-up': case 'faucet':`). + * case block (~line 3830: `case 'topup': case 'top-up': case 'faucet':`). * * Registration is asymmetric — `faucet` is the only one in * `LEGACY_NAMESPACES` (src/index.ts:32), so it's the only bare top-level @@ -99,82 +82,29 @@ describe('sphere-cli — faucet/topup command shape (offline)', () => { it.each(FAUCET_ALIASES)('`sphere payments help $alias` lists documented usage', ({ alias }) => { const r = runSphere(env, ['payments', 'help', alias], { timeoutMs: 15_000 }); // Help dispatch is offline — pure HELP_TEXT lookup. Non-zero exit - // means an alias's help entry was dropped (~lines 597-636 of + // means an alias's help entry was dropped (~lines 1149-1188 of // legacy-cli.ts). expect(r.status).toBe(0); // Pin the usage line and the `[ ]` positional shape // — load-bearing for users scripting `topup 100 UCT` etc. expect(r.stdout).toMatch(new RegExp(`Usage:.*${alias}`)); expect(r.stdout).toMatch(/\[\s+\]/); - // The shared description (in `topup`) or alias note (in `top-up` - // and `faucet`) MUST reference the faucet — that's the verb's - // entire purpose, and the doc-string is what users grep when they - // forget which command requests tokens. + // The shared description references "mint" and the legacy "faucet" + // alias name — together they pin the verb's purpose without + // binding the test to a single phrasing. + expect(r.stdout).toMatch(/mint/i); expect(r.stdout).toMatch(/faucet/i); }); }); -describe.skipIf(integrationSkip)( - 'sphere-cli integration — faucet without nametag (real wallet, no HTTP)', - () => { - // One wallet shared across all three alias tests. The fresh wallet - // has no nametag → every faucet alias should bail BEFORE making - // an HTTP request. We don't tear down + re-init between aliases - // because none of them mutate wallet state on the error path. - let env: SphereEnv; - - beforeAll(() => { - env = createSphereEnv('faucet-no-nametag'); - const init = runSphere(env, ['wallet', 'init', '--network', 'testnet'], { timeoutMs: 120_000 }); - if (init.status !== 0) { - console.error('wallet init failed', { status: init.status, stdout: init.stdout, stderr: init.stderr }); - throw new Error('wallet init failed; cannot proceed with faucet error-path tests'); - } - // Sanity: the wallet has NO nametag — confirms we're testing the - // pre-faucet error path, not a stale-state regression. - const myNt = runSphere(env, ['nametag', 'my'], { timeoutMs: 60_000 }); - expect(myNt.stdout).toMatch(/No nametag registered/i); - }, 180_000); - - afterAll(() => { if (env) destroySphereEnv(env); }); - - it.each(FAUCET_ALIASES)( - '`sphere $cmd` on a wallet without nametag prints error and exits non-zero', - ({ alias, invoke }) => { - const r = runSphere(env, [...invoke], { timeoutMs: 60_000 }); - - // Exit code is the load-bearing signal for scripts wrapping - // `sphere faucet` to detect "must register nametag first" vs. - // a transient faucet outage. If the precondition check is - // moved AFTER the HTTP call, this exit-code shape changes - // (faucet failures exit 0 in the current handler). - expect(r.status).not.toBe(0); - - const out = `${r.stdout}\n${r.stderr}`; - // Exact wording from legacy-cli.ts ~2950: - // "Error: No nametag registered. Use \"nametag \" first." - // Match on the load-bearing prefix without binding to the exact - // "Use ..." hint — the hint may legitimately evolve to suggest - // `sphere nametag register ` (new namespace) instead of - // the legacy `nametag ` form. - expect(out, `${alias} should error on missing nametag`).toMatch( - /No nametag registered/i, - ); - }, - ); - }, -); - describe.skipIf(integrationSkip || !RUN_FAUCET_E2E)( - 'sphere-cli integration — live faucet request (E2E_RUN_FAUCET=1)', + 'sphere-cli integration — live local-mint request (E2E_RUN_FAUCET=1)', () => { // Gated separately from the default suite because: - // - external faucet may be rate-limited / down - // - request consumes real testnet tokens - // - the on-chain nametag mint adds ~20s to the test even when - // the faucet itself succeeds. + // - the L3 aggregator is an external dependency + // - the local mint waits for an inclusion proof (~1-2s on testnet) + // - testnet aggregator may be rate-limited / slow at times. let env: SphereEnv; - const randomName = `it_${randomBytes(4).toString('hex')}`; beforeAll(() => { env = createSphereEnv('faucet-live'); @@ -182,42 +112,30 @@ describe.skipIf(integrationSkip || !RUN_FAUCET_E2E)( if (init.status !== 0) { throw new Error(`wallet init failed:\n${init.stderr}`); } - // Register a nametag — required precondition for the faucet API. - // Reuses the same on-chain registration path pinned in - // cli-nametag.integration.test.ts (which is the SDK-layer pin - // for this dependency; we don't re-assert it here). - const reg = runSphere(env, ['nametag', 'register', randomName], { timeoutMs: 180_000 }); - if (reg.status !== 0) { - throw new Error(`nametag register failed:\n${reg.stderr}`); - } + // Deliberately do NOT register a nametag — local mint should + // succeed against a nametag-less wallet (one of the load-bearing + // changes from sphere-sdk#455 vs the legacy HTTP-faucet path). }, 240_000); afterAll(() => { if (env) destroySphereEnv(env); }); - it('`sphere faucet 1 UCT` returns a success line for the requested coin', () => { - // Request a small amount of UCT — the faucet's native testnet - // token. `UCT` is mapped via `FAUCET_COIN_MAP` to the `unicity` - // faucet name (see legacy-cli.ts ~2996), so this also pins the - // symbol→faucet-name resolution path. + it('`sphere faucet 1 UCT` mints UCT locally and prints success', () => { + // Request a small amount of UCT — the wallet mints to itself via + // PaymentsModule.mintFungibleToken (no external faucet service). const r = runSphere(env, ['faucet', '1', 'UCT'], { timeoutMs: 60_000 }); if (r.status !== 0) { - console.error('faucet request failed', { stdout: r.stdout, stderr: r.stderr }); + console.error('local mint failed', { stdout: r.stdout, stderr: r.stderr }); } - // Note: the handler does NOT exit non-zero on faucet API failure - // (see ~3005-3007: it logs "✗ Failed" but doesn't process.exit). - // We assert on stdout content, not just status, so a silent - // failure flips this red. expect(r.status).toBe(0); // Two load-bearing log lines from the specific-coin branch - // (~lines 3000-3007): - // "Requesting from faucet for @..." (always) - // "✓ Received " (success) - // Match the success suffix with the unicity faucet name — the - // ✓ glyph is non-ASCII; pin the "Received" word instead so a - // --no-emoji refactor doesn't flip red over cosmetics. - expect(r.stdout).toMatch(/Requesting 1 unicity from faucet/i); - expect(r.stdout).toMatch(/Received 1 unicity/i); + // (legacy-cli.ts ~lines 3893-3908): + // "Minting locally..." (always) + // "✓ Received " (success) + // Match the success suffix; pin "Received" (the ✓ glyph is + // non-ASCII). + expect(r.stdout).toMatch(/Minting 1 UCT locally/i); + expect(r.stdout).toMatch(/Received 1 UCT/i); }, 120_000); }, );