diff --git a/README.md b/README.md index 24e975f..db9abbd 100644 --- a/README.md +++ b/README.md @@ -173,6 +173,10 @@ node scripts/02-fund-accounts.js \ Verify at: https://stellar.expert/explorer/testnet/account/G[DEPLOYER] +Every key is validated before Friendbot is contacted, an account that is +already funded counts as success, and the step exits non-zero if any account +could not be funded — so it can be chained with `&&`. + ### Step 3–4 — Deploy + init contracts ```bash diff --git a/scripts/02-fund-accounts.js b/scripts/02-fund-accounts.js index 0cebf8f..4194621 100644 --- a/scripts/02-fund-accounts.js +++ b/scripts/02-fund-accounts.js @@ -6,20 +6,72 @@ * (pass as many public keys as you like) * * Friendbot gives each account 10,000 XLM on testnet — free. + * + * Every key is validated before Friendbot is contacted, an account that is + * already funded counts as success, and the script exits non-zero when any + * account could not be funded — so the step can be chained with `&&`. */ +import { StrKey } from "@stellar/stellar-sdk"; + const FRIENDBOT = "https://friendbot.stellar.org"; +function short(key) { + return `${key.slice(0, 10)}…`; +} + +/** + * Reads a response body without assuming JSON: Friendbot answers some + * failures with HTML or plain text, and `res.json()` throws on those. + * Returns the parsed JSON, or the raw text when the body is not JSON. + */ +async function readBody(res) { + const text = await res.text(); + try { + return JSON.parse(text); + } catch { + return text; + } +} + +/** Friendbot reports an already-funded account as a 400 with this detail. */ +function isAlreadyFunded(body) { + const detail = typeof body === "string" ? body : body?.detail; + return typeof detail === "string" && /already funded/i.test(detail); +} + +/** + * Funds one account. + * Returns true when the account holds testnet XLM afterwards (freshly + * funded or already funded), false on any failure. + */ async function fund(publicKey) { const url = `${FRIENDBOT}?addr=${encodeURIComponent(publicKey)}`; - const res = await fetch(url); - const body = await res.json(); + + let res; + try { + res = await fetch(url); + } catch (err) { + console.error(`❌ Failed ${short(publicKey)} ${err.message}`); + return false; + } + + const body = await readBody(res); if (res.ok) { - console.log(`✅ Funded ${publicKey.slice(0, 10)}… tx: ${body.id ?? body.hash}`); - } else { - console.error(`❌ Failed ${publicKey.slice(0, 10)}… ${body.detail ?? JSON.stringify(body)}`); + console.log(`✅ Funded ${short(publicKey)} tx: ${body.id ?? body.hash}`); + return true; + } + + if (isAlreadyFunded(body)) { + console.log(`✅ ${short(publicKey)} already funded`); + return true; } + + const detail = + typeof body === "string" ? body.slice(0, 120) : (body.detail ?? JSON.stringify(body)); + console.error(`❌ Failed ${short(publicKey)} ${detail}`); + return false; } const keys = process.argv.slice(2); @@ -28,7 +80,23 @@ if (keys.length === 0) { process.exit(1); } +const invalid = keys.filter((k) => !StrKey.isValidEd25519PublicKey(k)); +if (invalid.length > 0) { + console.error(`Not valid ed25519 public keys: ${invalid.join(", ")}`); + process.exit(1); +} + console.log(`\nFunding ${keys.length} account(s) on Stellar testnet…\n`); -for (const k of keys) await fund(k); + +let failed = 0; +for (const k of keys) { + if (!(await fund(k))) failed++; +} + +if (failed > 0) { + console.error(`\n${failed} of ${keys.length} account(s) could not be funded.`); + process.exit(1); +} + console.log("\nNext → build + deploy contracts:"); console.log(" bash scripts/03-deploy-contracts.sh\n"); diff --git a/test/friendbot-fetch-stub.mjs b/test/friendbot-fetch-stub.mjs new file mode 100644 index 0000000..9c99e2f --- /dev/null +++ b/test/friendbot-fetch-stub.mjs @@ -0,0 +1,37 @@ +// Test helper: replaces globalThis.fetch with a scripted Friendbot so the +// CLI scripts can be exercised without network access. +// +// The scenario comes from the FRIENDBOT_STUB env var (JSON): +// { "status": 200, "json": {...} } -> ok response with a JSON body +// { "status": 400, "json": {...} } -> error response with a JSON body +// { "status": 502, "text": "..." } -> response with a non-JSON body +// { "throw": "message" } -> fetch rejects +// An array answers the calls in order (the last entry repeats). +// +// Every requested URL is appended to the file named by FRIENDBOT_STUB_LOG, +// so tests can assert whether Friendbot was contacted at all. +import fs from "node:fs"; + +const scenarios = Array.isArray(JSON.parse(process.env.FRIENDBOT_STUB ?? "{}")) + ? JSON.parse(process.env.FRIENDBOT_STUB) + : [JSON.parse(process.env.FRIENDBOT_STUB ?? "{}")]; +const log = process.env.FRIENDBOT_STUB_LOG; + +let call = 0; + +globalThis.fetch = async (url) => { + if (log) fs.appendFileSync(log, `${url}\n`); + + const scenario = scenarios[Math.min(call, scenarios.length - 1)]; + call += 1; + + if (scenario.throw) throw new Error(scenario.throw); + + const body = scenario.json ? JSON.stringify(scenario.json) : (scenario.text ?? ""); + return { + ok: scenario.status >= 200 && scenario.status < 300, + status: scenario.status, + text: async () => body, + json: async () => JSON.parse(body), + }; +}; diff --git a/test/fund-accounts.test.cjs b/test/fund-accounts.test.cjs new file mode 100644 index 0000000..cf2f148 --- /dev/null +++ b/test/fund-accounts.test.cjs @@ -0,0 +1,137 @@ +'use strict'; + +// Unit tests for scripts/02-fund-accounts.js: the failure/exit-code +// contract, exercised end-to-end with fetch stubbed out (no network). +// Run with: npm test + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { execFileSync } = require('child_process'); +const { pathToFileURL } = require('url'); + +const { Keypair } = require('@stellar/stellar-sdk'); + +const ROOT = path.resolve(__dirname, '..'); +const SCRIPT = path.join(ROOT, 'scripts', '02-fund-accounts.js'); +const FETCH_STUB = path.join(__dirname, 'friendbot-fetch-stub.mjs'); + +// A contract id (C...) is a valid StrKey, but not an ed25519 public key. +const CONTRACT_ID = 'CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA'; + +const FUNDED = { status: 200, json: { id: 'abc123tx', hash: 'def456hash' } }; +const ALREADY_FUNDED = { status: 400, json: { detail: 'account already funded' } }; + +/** + * Runs the script with globalThis.fetch replaced by the stub. + * `scenario` selects what the stub answers (see friendbot-fetch-stub.mjs). + * Returns the exit status, captured output and the URLs the stub saw. + */ +function runScript(args, scenario) { + const log = path.join( + os.tmpdir(), + `friendbot-stub-${process.pid}-${Math.random().toString(36).slice(2)}.log` + ); + + let status = 0; + let stdout = ''; + let stderr = ''; + try { + stdout = execFileSync(process.execPath, ['--import', pathToFileURL(FETCH_STUB).href, SCRIPT, ...args], { + env: { ...process.env, FRIENDBOT_STUB: JSON.stringify(scenario), FRIENDBOT_STUB_LOG: log }, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }); + } catch (err) { + status = err.status ?? 1; + stdout = err.stdout ?? ''; + stderr = err.stderr ?? ''; + } + + let requested = []; + try { + requested = fs.readFileSync(log, 'utf8').trim().split('\n').filter(Boolean); + } catch { + /* the stub was never called */ + } + fs.rmSync(log, { force: true }); + + return { status, stdout, stderr, requested }; +} + +test('funds every account and exits 0', () => { + const keys = [Keypair.random().publicKey(), Keypair.random().publicKey()]; + const { status, stdout, requested } = runScript(keys, FUNDED); + + assert.equal(status, 0); + assert.match(stdout, /Funding 2 account\(s\)/); + assert.match(stdout, /✅ Funded/); + assert.match(stdout, /tx: abc123tx/); + assert.match(stdout, /Next →/); + assert.equal(requested.length, 2); + for (const key of keys) { + assert.ok(requested.some((url) => url.includes(`addr=${key}`)), `funded ${key}`); + } +}); + +test('treats an already-funded account as success', () => { + const { status, stdout } = runScript([Keypair.random().publicKey()], ALREADY_FUNDED); + + assert.equal(status, 0); + assert.match(stdout, /already funded/); + assert.doesNotMatch(stdout, /❌/); +}); + +test('survives a non-JSON error body and exits non-zero', () => { + const { status, stderr } = runScript([Keypair.random().publicKey()], { + status: 502, + text: '502 Bad Gateway', + }); + + assert.equal(status, 1); + assert.match(stderr, /❌ Failed/); + assert.match(stderr, /1 of 1 account\(s\) could not be funded/); +}); + +test('exits non-zero when the request itself fails', () => { + const { status, stderr } = runScript([Keypair.random().publicKey()], { + throw: 'getaddrinfo ENOTFOUND friendbot.stellar.org', + }); + + assert.equal(status, 1); + assert.match(stderr, /ENOTFOUND/); +}); + +test('exits non-zero when one of several accounts fails', () => { + const keys = [Keypair.random().publicKey(), Keypair.random().publicKey()]; + const { status, stdout, stderr } = runScript(keys, [ + FUNDED, + { status: 400, json: { detail: 'friendbot is overloaded' } }, + ]); + + assert.equal(status, 1); + assert.match(stdout, /✅ Funded/); + assert.match(stderr, /friendbot is overloaded/); + assert.match(stderr, /1 of 2 account\(s\) could not be funded/); +}); + +test('rejects invalid keys before contacting Friendbot', () => { + const { status, stderr, requested } = runScript( + [Keypair.random().publicKey(), 'not-a-key', CONTRACT_ID], + FUNDED + ); + + assert.equal(status, 1); + assert.match(stderr, /Not valid ed25519 public keys: not-a-key, CBIELTK/); + assert.deepEqual(requested, []); +}); + +test('prints usage and exits non-zero without keys', () => { + const { status, stderr, requested } = runScript([], FUNDED); + + assert.equal(status, 1); + assert.match(stderr, /Usage: node scripts\/02-fund-accounts\.js/); + assert.deepEqual(requested, []); +});