Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
80 changes: 74 additions & 6 deletions scripts/02-fund-accounts.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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");
37 changes: 37 additions & 0 deletions test/friendbot-fetch-stub.mjs
Original file line number Diff line number Diff line change
@@ -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),
};
};
137 changes: 137 additions & 0 deletions test/fund-accounts.test.cjs
Original file line number Diff line number Diff line change
@@ -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: '<html><body>502 Bad Gateway</body></html>',
});

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, []);
});