diff --git a/README.md b/README.md index 4a6ec1a7..ec7491ed 100644 --- a/README.md +++ b/README.md @@ -124,6 +124,12 @@ HARNESS_NETWORK=local npm run deploy -w scripts/harness the addresses table above. The local deploy funds its accounts from the quickstart root key, so it needs no `stellar keys` identity. +The first deploy writes a secret key for each harness account to +`.env.keys.` at the repository root, and later deploys reuse them. Back +that file up and never commit it. The +[harness README](./scripts/harness/README.md#account-keys) explains how to use a +key you already hold for an account. + Output of a real run from a fresh clone. Contract IDs differ on every run. ```text @@ -131,13 +137,14 @@ Output of a real run from a fresh clone. Contract IDs differ on every run. > tsx deploy.ts deploying to local (http://localhost:8000/rpc) -funded 13 accounts -asset contract CB7IYQNW5Z5NY4RLR3PRNDAI6DNCEXD6I7NRWQZSGBNQ6YFANFPZ7ZSE -compliance CBNEUKZOUMYIGZYCCEKNVRMLPBOCMRSE32XRGEL64QKYAHOAQTL4R2I4 -identity_verifier CBZC4FWQD33HC4TKOIODFLX7H527XVP6RL346LPYSLE2GUKMFN7HT6TZ -share_token CD6ISWB4DFY6XQEXS4ECBWA3TDGOUTK7SEACMVBUEQ3JFW65XMG43QFV -nav_oracle CCAMOCE2U5LBMEYDNXAMOZZSGDTX6A5DNQRXCYMUIYFTPRKZS6QUCDKD -async_vault CAZPVYS3JITMZYE2CYBOKJ2FBQ46RK4E4NBDHA53RSV7ICUXFZOOJJYR +account keys in .env.keys.local (13 new) +funded 13 accounts; 0 already existed +asset contract CDKWXRAHPQVFOAEBOOWWR62ZXAE2IV4V47PATFN7IX4XDW5QIUUL7D2B +compliance CDGKQCYSMTDRBO7SYV3V72H6AMCQ3UNQFKUU4AD5GSU2I32XDYFS6F3U +identity_verifier CCZE76VWEWJ47NSFMIXCKU3L55CZZTI5JJY4CSYAWPO3WXPDFXUCA5G6 +share_token CD6IA3BOPFX7VNADJ3GFK7IZGPVPNZA6HBCF425CNPSQ2GN7QZ3UUCP2 +nav_oracle CAHD5ZKMIDD67ZEJUB5ACR62FMAYMQJ2KCRV2MBDNXWT7WEZ2VV3MRDD +async_vault CDQNMAZP7D5IA7AXZGK3XWYPFD2LDR6V5HQD2OLXZJYNQN4BMNN6SXWJ clients generated vault holds share_token manager role vault is allowlisted @@ -145,7 +152,8 @@ share_token bound to compliance wrote deployed.local.json ``` -The addresses land in `scripts/harness/deployed.local.json`. Then run the +The addresses land in `scripts/harness/deployed.local.json`; a later deploy +keeps the previous file as `deployed.local..json`. Then run the integration suites against that deployment: ```sh diff --git a/scripts/harness/README.md b/scripts/harness/README.md index 3ac3fd88..fd5a0c8c 100644 --- a/scripts/harness/README.md +++ b/scripts/harness/README.md @@ -5,12 +5,59 @@ Deploys the vault contracts to a network and runs integration tests. ## Usage ```sh +# Create or complete the account keys, without deploying +npm run keys + # Deploy contracts (local or testnet) npm run deploy +# Unit tests of the harness itself, no network needed +npm run test:unit + # Run test suites npm run test:happy-path npm run test:guards ``` -`deployed..json` records deployed contract IDs and generated accounts. Test reports write to `runs/.json`. +`HARNESS_NETWORK` picks the network: `local` (the default) or `testnet`. + +## Account keys + +The harness signs with thirteen accounts: `deployer`, `governance`, `manager`, +`treasury`, `guardian`, `compliance`, `attester`, `custodian`, `issuer`, +`holder-1`, `holder-2`, `holder-3` and `outsider`. Their secret keys live in +`.env.keys.` at the repository root, one line per account. + +- The first deploy creates the file, with a new key for every account. +- Later deploys reuse the keys in the file. A deploy adds a key only for an + account that has none, and never replaces one. +- A deploy does not fund an account that already exists on the network, and + reuses the deposit asset contract the issuer already has. +- `npm run keys` creates or completes the file without deploying, and prints + each account's public key. It never prints a secret. + +Back the file up, and never commit it. Git ignores it, so `git clean -x` +deletes it. + +To use a key you already hold for one account, write its line before the first +deploy, then let `npm run keys` add the others. From the repository root: + +```sh +echo 'HOLDER_1_SECRET=S...' >> .env.keys.testnet +HARNESS_NETWORK=testnet npm run keys -w scripts/harness +``` + +Each line is the account name in capitals, with `_` for `-`, then `_SECRET`: +`DEPLOYER_SECRET`, `HOLDER_1_SECRET`, `OUTSIDER_SECRET`. The harness stops and +names the line if a secret is not a valid Stellar secret key, an account +appears twice, or two accounts share a key. Lines it does not know stay in the +file. + +## Deployment records + +`deployed..json` records the deployed contract IDs and the accounts, +secrets included. A new deploy keeps the previous record as +`deployed...json`, named after the time of that deploy, +instead of overwriting it. Git ignores these files too. + +Test reports write to `runs/.json`. diff --git a/scripts/harness/deploy.ts b/scripts/harness/deploy.ts index 8b230e0f..1987170c 100644 --- a/scripts/harness/deploy.ts +++ b/scripts/harness/deploy.ts @@ -1,5 +1,5 @@ import { existsSync } from "node:fs" -import { join } from "node:path" +import { basename, join, relative } from "node:path" import { pathToFileURL } from "node:url" import { Asset, rpc, type Keypair } from "@stellar/stellar-sdk" @@ -9,10 +9,10 @@ import type * as ComplianceModule from "../../app-lib/clients/compliance/dist/in import type * as IdentityVerifierModule from "../../app-lib/clients/identity_verifier/dist/index.js" import type * as ShareTokenModule from "../../app-lib/clients/share_token/dist/index.js" -import { freshAccounts, fund, type Accounts } from "./lib/accounts.js" +import { fund, type Accounts } from "./lib/accounts.js" import { - deployAssetContract, deployContract, + ensureAssetContract, generateClient, } from "./lib/cli.js" import { @@ -22,6 +22,7 @@ import { type ContractName, type Deployment, } from "./lib/deployment.js" +import { ensureKeys } from "./lib/keys.js" import { network, type Network } from "./lib/network.js" const wasmOf = (name: ContractName): string => @@ -198,12 +199,23 @@ async function main(): Promise { const server = new rpc.Server(net.rpcUrl, { allowHttp: net.name === "local" }) console.log(`deploying to ${net.name} (${net.rpcUrl})`) - const accounts = freshAccounts() - await fund(net, server, Object.values(accounts)) - console.log(`funded ${Object.keys(accounts).length} accounts`) + const { file, accounts, created } = ensureKeys(net.name, REPO_ROOT) + console.log( + `account keys in ${relative(REPO_ROOT, file)} (${created.length} new)`, + ) + const keys = Object.values(accounts) + const funded = await fund(net, server, keys) + console.log( + `funded ${funded} accounts; ${keys.length - funded} already existed`, + ) const asset = new Asset("USDC", accounts.issuer.publicKey()) - const assetContract = await deployAssetContract(net, asset, accounts.issuer) + const assetContract = await ensureAssetContract( + net, + server, + asset, + accounts.issuer, + ) console.log("asset contract", assetContract) const contracts = await deployContracts(net, accounts, assetContract) @@ -232,7 +244,9 @@ async function main(): Promise { ]), ) as Deployment["accounts"], } - saveDeployment(record) + const kept = saveDeployment(record) + if (kept !== undefined) + console.log(`kept the previous record as ${basename(kept)}`) console.log(`wrote deployed.${net.name}.json`) if (net.name === "testnet") { console.log("updated environments.toml [staging.contracts]") diff --git a/scripts/harness/keys.ts b/scripts/harness/keys.ts new file mode 100644 index 00000000..9dd4cdab --- /dev/null +++ b/scripts/harness/keys.ts @@ -0,0 +1,24 @@ +import { relative } from "node:path" + +import { ACCOUNT_NAMES } from "./lib/accounts.js" +import { REPO_ROOT } from "./lib/deployment.js" +import { ensureKeys } from "./lib/keys.js" +import { network } from "./lib/network.js" + +function main(): void { + const { file, accounts, created } = ensureKeys(network().name, REPO_ROOT) + console.log( + `account keys in ${relative(REPO_ROOT, file)} (${created.length} new)`, + ) + for (const name of ACCOUNT_NAMES) { + const mark = created.includes(name) ? " new" : "" + console.log(`${name.padEnd(10)} ${accounts[name].publicKey()}${mark}`) + } +} + +try { + main() +} catch (error: unknown) { + console.error(error instanceof Error ? error.message : error) + process.exitCode = 1 +} diff --git a/scripts/harness/lib/accounts.test.ts b/scripts/harness/lib/accounts.test.ts new file mode 100644 index 00000000..75d11405 --- /dev/null +++ b/scripts/harness/lib/accounts.test.ts @@ -0,0 +1,107 @@ +import { + Account, + Keypair, + Networks, + rpc, + type Operation, + type Transaction, +} from "@stellar/stellar-sdk" +import { afterEach, describe, expect, it, vi } from "vitest" + +import { fund } from "./accounts.js" +import { type Funding, type Network } from "./network.js" + +const networkWith = (funding: Funding): Network => ({ + name: "local", + rpcUrl: "http://localhost:8000/rpc", + passphrase: Networks.STANDALONE, + funding, + oracle: { + freshness_duration: 300, + cooldown_secs: 5, + max_up_bps: 1000, + max_down_bps: null, + min_answer: "1", + max_answer: "2", + }, +}) + +// Knows which accounts exist, and creates the ones a transaction creates. +function fakeServer(existing: Set) { + const created: string[][] = [] + const server = { + getAccount(publicKey: string): Promise { + if (!existing.has(publicKey)) + return Promise.reject(new Error(`Account not found: ${publicKey}`)) + return Promise.resolve(new Account(publicKey, "1")) + }, + sendTransaction(tx: Transaction) { + const destinations = tx.operations.map( + (op) => (op as Operation.CreateAccount).destination, + ) + created.push(destinations) + for (const destination of destinations) existing.add(destination) + return Promise.resolve({ status: "PENDING", hash: "hash" }) + }, + pollTransaction() { + return Promise.resolve({ + status: rpc.Api.GetTransactionStatus.SUCCESS, + }) + }, + } + return { server: server as unknown as rpc.Server, created } +} + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe("fund from the root account", () => { + const root = Keypair.random() + const net = networkWith({ kind: "root", secret: root.secret() }) + + it("creates only the accounts that do not exist yet", async () => { + const [known, fresh1, fresh2] = [0, 1, 2].map(() => Keypair.random()) + const { server, created } = fakeServer( + new Set([root.publicKey(), known!.publicKey()]), + ) + + const funded = await fund(net, server, [known!, fresh1!, fresh2!]) + + expect(funded).toBe(2) + expect(created).toEqual([[fresh1!.publicKey(), fresh2!.publicKey()]]) + }) + + it("sends nothing when every account exists", async () => { + const keys = [Keypair.random(), Keypair.random()] + const { server, created } = fakeServer( + new Set([root.publicKey(), ...keys.map((key) => key.publicKey())]), + ) + + expect(await fund(net, server, keys)).toBe(0) + expect(created).toEqual([]) + }) +}) + +describe("fund from friendbot", () => { + const url = "https://friendbot.example" + const net = networkWith({ kind: "friendbot", url }) + + it("asks only for the accounts that do not exist yet", async () => { + const [known, fresh] = [Keypair.random(), Keypair.random()] + const existing = new Set([known.publicKey()]) + const { server } = fakeServer(existing) + const asked: string[] = [] + vi.stubGlobal("fetch", (request: string) => { + const address = new URL(request).searchParams.get("addr") ?? "" + asked.push(address) + existing.add(address) + return Promise.resolve(new Response("{}")) + }) + + const funded = await fund(net, server, [known, fresh]) + + expect(funded).toBe(1) + expect(asked).toEqual([fresh.publicKey()]) + }) +}) diff --git a/scripts/harness/lib/accounts.ts b/scripts/harness/lib/accounts.ts index 3be7e9b7..d6a49be0 100644 --- a/scripts/harness/lib/accounts.ts +++ b/scripts/harness/lib/accounts.ts @@ -31,12 +31,6 @@ export const ACCOUNT_NAMES = [ export type AccountName = (typeof ACCOUNT_NAMES)[number] export type Accounts = Record -export function freshAccounts(): Accounts { - return Object.fromEntries( - ACCOUNT_NAMES.map((name) => [name, Keypair.random()]), - ) as Accounts -} - export async function submit( server: rpc.Server, tx: Transaction, @@ -119,17 +113,32 @@ async function awaitVisible( } } +async function exists(server: rpc.Server, publicKey: string): Promise { + try { + await server.getAccount(publicKey) + return true + } catch { + return false + } +} + export async function fund( net: Network, server: rpc.Server, keys: readonly Keypair[], -): Promise { +): Promise { + const missing: Keypair[] = [] + for (const key of keys) { + if (!(await exists(server, key.publicKey()))) missing.push(key) + } + if (missing.length === 0) return 0 if (net.funding.kind === "root") { - await fundFromRoot(net, server, net.funding.secret, keys) + await fundFromRoot(net, server, net.funding.secret, missing) } else { - await fundFromFriendbot(net.funding.url, keys) + await fundFromFriendbot(net.funding.url, missing) } - for (const key of keys) await awaitVisible(server, key.publicKey()) + for (const key of missing) await awaitVisible(server, key.publicKey()) + return missing.length } async function operate( diff --git a/scripts/harness/lib/cli.test.ts b/scripts/harness/lib/cli.test.ts new file mode 100644 index 00000000..068a78c0 --- /dev/null +++ b/scripts/harness/lib/cli.test.ts @@ -0,0 +1,34 @@ +import { + Address, + Asset, + Keypair, + Networks, + type rpc, + type xdr, +} from "@stellar/stellar-sdk" +import { describe, expect, it } from "vitest" + +import { ensureAssetContract } from "./cli.js" +import { type Network } from "./network.js" + +describe("ensureAssetContract", () => { + it("returns the asset contract already on the network", async () => { + const net = { passphrase: Networks.STANDALONE } as Network + const issuer = Keypair.random() + const asset = new Asset("USDC", issuer.publicKey()) + const id = asset.contractId(net.passphrase) + const server = { + getLedgerEntries(key: xdr.LedgerKey) { + const contract = Address.fromScAddress( + key.contractData().contract(), + ).toString() + return Promise.resolve({ + entries: contract === id ? [{}] : [], + latestLedger: 1, + }) + }, + } as unknown as rpc.Server + + expect(await ensureAssetContract(net, server, asset, issuer)).toBe(id) + }) +}) diff --git a/scripts/harness/lib/cli.ts b/scripts/harness/lib/cli.ts index a9c3b019..1e4dbd69 100644 --- a/scripts/harness/lib/cli.ts +++ b/scripts/harness/lib/cli.ts @@ -4,7 +4,13 @@ import { createRequire } from "node:module" import { basename, join } from "node:path" import { promisify } from "node:util" -import { StrKey, type Asset, type Keypair } from "@stellar/stellar-sdk" +import { + Contract, + StrKey, + type Asset, + type Keypair, + type rpc, +} from "@stellar/stellar-sdk" import { type Network } from "./network.js" @@ -90,7 +96,7 @@ export async function deployContract( return lastContractId(out) } -export async function deployAssetContract( +async function deployAssetContract( net: Network, asset: Asset, source: Keypair, @@ -109,6 +115,21 @@ export async function deployAssetContract( return lastContractId(out) } +// The asset contract's id follows from the asset, so a kept issuer may have one. +export async function ensureAssetContract( + net: Network, + server: rpc.Server, + asset: Asset, + source: Keypair, +): Promise { + const id = asset.contractId(net.passphrase) + const { entries } = await server.getLedgerEntries( + new Contract(id).getFootprint(), + ) + if (entries.length > 0) return id + return deployAssetContract(net, asset, source) +} + export async function generateClient( wasm: string, outDir: string, diff --git a/scripts/harness/lib/deployment.test.ts b/scripts/harness/lib/deployment.test.ts new file mode 100644 index 00000000..a42cdcba --- /dev/null +++ b/scripts/harness/lib/deployment.test.ts @@ -0,0 +1,102 @@ +import { + mkdtempSync, + readdirSync, + readFileSync, + rmSync, + utimesSync, + writeFileSync, +} from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" + +import { afterEach, beforeEach, describe, expect, it } from "vitest" + +import { + deploymentFile, + saveDeployment, + type Deployment, +} from "./deployment.js" + +let dir = "" + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "harness-records-")) +}) + +afterEach(() => { + rmSync(dir, { recursive: true, force: true }) +}) + +const record = (deployedAt: string): Deployment => ({ + network: "local", + deployedAt, + ledger: 1, + asset: { code: "USDC", issuer: "G", contract: "C" }, + contracts: { + compliance: "C1", + identity_verifier: "C2", + share_token: "C3", + nav_oracle: "C4", + async_vault: "C5", + }, + accounts: {} as Deployment["accounts"], +}) + +const deployedAtOf = (file: string): unknown => + (JSON.parse(readFileSync(file, "utf8")) as { deployedAt?: unknown }) + .deployedAt + +describe("saveDeployment", () => { + it("keeps nothing aside on the first deploy", () => { + expect( + saveDeployment(record("2026-10-01T12:30:45.678Z"), dir), + ).toBeUndefined() + expect(readdirSync(dir)).toEqual(["deployed.local.json"]) + }) + + it("keeps the previous record under the time it was deployed", () => { + saveDeployment(record("2026-10-01T12:30:45.678Z"), dir) + + const kept = saveDeployment(record("2026-10-22T09:00:00.000Z"), dir) + + expect(kept).toBe(join(dir, "deployed.local.2026-10-01T12-30-45-678Z.json")) + expect(deployedAtOf(kept ?? "")).toBe("2026-10-01T12:30:45.678Z") + expect(deployedAtOf(deploymentFile("local", dir))).toBe( + "2026-10-22T09:00:00.000Z", + ) + }) + + it("never overwrites a record it kept before", () => { + const twice = "2026-10-01T12:30:45.678Z" + saveDeployment(record(twice), dir) + saveDeployment(record(twice), dir) + + const kept = saveDeployment(record("2026-10-22T09:00:00.000Z"), dir) + + expect(kept).toBe( + join(dir, "deployed.local.2026-10-01T12-30-45-678Z-1.json"), + ) + expect(readdirSync(dir).sort()).toEqual([ + "deployed.local.2026-10-01T12-30-45-678Z-1.json", + "deployed.local.2026-10-01T12-30-45-678Z.json", + "deployed.local.json", + ]) + }) + + it.each(["{}\n", "not json\n"])( + "names a record without a readable time after the file's time: %j", + (content) => { + const file = deploymentFile("local", dir) + writeFileSync(file, content) + const modified = new Date("2026-03-04T05:06:07.000Z") + utimesSync(file, modified, modified) + + const kept = saveDeployment(record("2026-10-22T09:00:00.000Z"), dir) + + expect(kept).toBe( + join(dir, "deployed.local.2026-03-04T05-06-07-000Z.json"), + ) + expect(readFileSync(kept ?? "", "utf8")).toBe(content) + }, + ) +}) diff --git a/scripts/harness/lib/deployment.ts b/scripts/harness/lib/deployment.ts index 4d04e33b..08e8f29d 100644 --- a/scripts/harness/lib/deployment.ts +++ b/scripts/harness/lib/deployment.ts @@ -1,4 +1,10 @@ -import { readFileSync, writeFileSync } from "node:fs" +import { + existsSync, + readFileSync, + renameSync, + statSync, + writeFileSync, +} from "node:fs" import { dirname, join, resolve } from "node:path" import { fileURLToPath } from "node:url" @@ -30,8 +36,11 @@ export const PACKAGE_ROOT = resolve( ) export const REPO_ROOT = resolve(PACKAGE_ROOT, "..", "..") -export function deploymentFile(network: NetworkName): string { - return join(PACKAGE_ROOT, `deployed.${network}.json`) +export function deploymentFile( + network: NetworkName, + dir = PACKAGE_ROOT, +): string { + return join(dir, `deployed.${network}.json`) } const README_START = "" @@ -102,15 +111,47 @@ export function syncEnvironmentsStaging(record: Deployment): void { writeFileSync(envPath, content) } -export function saveDeployment(record: Deployment): void { - writeFileSync( - deploymentFile(record.network), - `${JSON.stringify(record, null, 2)}\n`, - ) +function recordedTime(file: string): unknown { + try { + const record = JSON.parse(readFileSync(file, "utf8")) as { + deployedAt?: unknown + } | null + return record?.deployedAt + } catch { + return undefined + } +} + +function deployedAt(file: string): Date { + const recorded = recordedTime(file) + return typeof recorded === "string" && !Number.isNaN(Date.parse(recorded)) + ? new Date(recorded) + : statSync(file).mtime +} + +function keepPrevious(file: string): string | undefined { + if (!existsSync(file)) return undefined + const stamp = deployedAt(file).toISOString().replaceAll(/[:.]/g, "-") + const base = file.replace(/\.json$/, `.${stamp}`) + let kept = `${base}.json` + for (let n = 1; existsSync(kept); n += 1) kept = `${base}-${n}.json` + renameSync(file, kept) + return kept +} + +// Returns where the previous record was kept, if there was one. +export function saveDeployment( + record: Deployment, + dir = PACKAGE_ROOT, +): string | undefined { + const file = deploymentFile(record.network, dir) + const kept = keepPrevious(file) + writeFileSync(file, `${JSON.stringify(record, null, 2)}\n`) if (record.network === "testnet") { syncEnvironmentsStaging(record) syncReadmeAddresses(record) } + return kept } export function loadDeployment(network: NetworkName): Deployment { diff --git a/scripts/harness/lib/keys.test.ts b/scripts/harness/lib/keys.test.ts new file mode 100644 index 00000000..776d02b1 --- /dev/null +++ b/scripts/harness/lib/keys.test.ts @@ -0,0 +1,178 @@ +import { + chmodSync, + mkdtempSync, + readFileSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" + +import { Keypair, StrKey } from "@stellar/stellar-sdk" +import { afterEach, beforeEach, describe, expect, it } from "vitest" + +import { ACCOUNT_NAMES } from "./accounts.js" +import { ensureKeys, keysFile } from "./keys.js" + +const VARIABLES = [ + "DEPLOYER_SECRET", + "GOVERNANCE_SECRET", + "MANAGER_SECRET", + "TREASURY_SECRET", + "GUARDIAN_SECRET", + "COMPLIANCE_SECRET", + "ATTESTER_SECRET", + "CUSTODIAN_SECRET", + "ISSUER_SECRET", + "HOLDER_1_SECRET", + "HOLDER_2_SECRET", + "HOLDER_3_SECRET", + "OUTSIDER_SECRET", +] + +let dir = "" + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "harness-keys-")) +}) + +afterEach(() => { + rmSync(dir, { recursive: true, force: true }) +}) + +const linesOf = (file: string): string[] => + readFileSync(file, "utf8") + .split("\n") + .filter((line) => line !== "") + +function errorOf(run: () => unknown): string { + try { + run() + } catch (error) { + return error instanceof Error ? error.message : String(error) + } + throw new Error("expected an error") +} + +describe("ensureKeys", () => { + it("creates a key for every account when there is no file", () => { + const { file, accounts, created } = ensureKeys("local", dir) + + expect(file).toBe(join(dir, ".env.keys.local")) + const [header, ...entries] = linesOf(file) + expect(header).toMatch(/^# .*secret.*git.*back/i) + expect(entries.map((line) => line.split("=")[0])).toEqual(VARIABLES) + ACCOUNT_NAMES.forEach((name, i) => { + const secret = entries[i]?.split("=")[1] ?? "" + expect(StrKey.isValidEd25519SecretSeed(secret)).toBe(true) + expect(accounts[name].secret()).toBe(secret) + }) + expect(created).toEqual([...ACCOUNT_NAMES]) + }) + + it("completes a partial file without changing the keys already in it", () => { + const file = keysFile("local", dir) + const chosen = Keypair.random() + const deployer = Keypair.random() + writeFileSync( + file, + `HOLDER_1_SECRET=${chosen.secret()}\nDEPLOYER_SECRET=${deployer.secret()}\n`, + ) + + const { accounts, created } = ensureKeys("local", dir) + + expect(accounts["holder-1"].publicKey()).toBe(chosen.publicKey()) + expect(accounts.deployer.publicKey()).toBe(deployer.publicKey()) + expect(created).toEqual( + ACCOUNT_NAMES.filter( + (name) => name !== "holder-1" && name !== "deployer", + ), + ) + const lines = linesOf(file) + expect(lines).toHaveLength(1 + ACCOUNT_NAMES.length) + expect(lines).toContain(`HOLDER_1_SECRET=${chosen.secret()}`) + expect(lines).toContain(`DEPLOYER_SECRET=${deployer.secret()}`) + }) + + it("reuses every key on the next run", () => { + const first = ensureKeys("local", dir) + const written = readFileSync(first.file, "utf8") + + const second = ensureKeys("local", dir) + + expect(second.created).toEqual([]) + for (const name of ACCOUNT_NAMES) { + expect(second.accounts[name].publicKey()).toBe( + first.accounts[name].publicKey(), + ) + } + expect(readFileSync(second.file, "utf8")).toBe(written) + }) + + it("refuses an invalid secret, naming its line but not its value", () => { + const file = keysFile("local", dir) + const original = "# my note\nGOVERNANCE_SECRET=SNOTAKEY\n" + writeFileSync(file, original) + + const message = errorOf(() => ensureKeys("local", dir)) + + expect(message).toBe( + `${file}:2: GOVERNANCE_SECRET is not a valid Stellar secret key`, + ) + expect(message).not.toContain("SNOTAKEY") + expect(readFileSync(file, "utf8")).toBe(original) + }) + + it("refuses an account whose key is set twice", () => { + const file = keysFile("local", dir) + writeFileSync( + file, + `DEPLOYER_SECRET=${Keypair.random().secret()}\nDEPLOYER_SECRET=${Keypair.random().secret()}\n`, + ) + + expect(errorOf(() => ensureKeys("local", dir))).toBe( + `${file}:2: DEPLOYER_SECRET is set twice`, + ) + }) + + it("refuses one key for two accounts", () => { + const file = keysFile("local", dir) + const shared = Keypair.random().secret() + writeFileSync( + file, + `DEPLOYER_SECRET=${shared}\nHOLDER_1_SECRET=${shared}\n`, + ) + + const message = errorOf(() => ensureKeys("local", dir)) + + expect(message).toBe( + `${file}:2: HOLDER_1_SECRET has the same key as DEPLOYER_SECRET`, + ) + expect(message).not.toContain(shared) + }) + + it("leaves the file readable by its owner only", () => { + const created = ensureKeys("local", dir).file + expect(statSync(created).mode & 0o777).toBe(0o600) + + const handMade = keysFile("testnet", dir) + writeFileSync(handMade, "") + chmodSync(handMade, 0o644) + ensureKeys("testnet", dir) + expect(statSync(handMade).mode & 0o777).toBe(0o600) + }) + + it("keeps lines it does not recognise", () => { + const file = keysFile("local", dir) + const own = ["# my note", "HOLDER_4_SECRET=whatever", "RPC=http://x"] + writeFileSync(file, `${own.join("\n")}\n`) + + ensureKeys("local", dir) + ensureKeys("local", dir) + + const lines = linesOf(file) + expect(lines).toHaveLength(1 + ACCOUNT_NAMES.length + own.length) + expect(lines.slice(-own.length)).toEqual(own) + }) +}) diff --git a/scripts/harness/lib/keys.ts b/scripts/harness/lib/keys.ts new file mode 100644 index 00000000..a9ae0bb7 --- /dev/null +++ b/scripts/harness/lib/keys.ts @@ -0,0 +1,98 @@ +import { + chmodSync, + existsSync, + readFileSync, + renameSync, + rmSync, + writeFileSync, +} from "node:fs" +import { join } from "node:path" + +import { Keypair, StrKey } from "@stellar/stellar-sdk" + +import { ACCOUNT_NAMES, type AccountName, type Accounts } from "./accounts.js" +import { type NetworkName } from "./network.js" + +export interface AccountKeys { + file: string + accounts: Accounts + created: AccountName[] +} + +export function keysFile(network: NetworkName, dir: string): string { + return join(dir, `.env.keys.${network}`) +} + +const variableOf = (name: AccountName): string => + `${name.toUpperCase().replaceAll("-", "_")}_SECRET` + +const headerOf = (network: NetworkName): string => + `# Secret keys of the harness accounts on ${network}. Keep this file out of git and back it up.` + +function parse( + text: string, + file: string, + header: string, +): { secrets: Map; others: string[] } { + const known = new Map(ACCOUNT_NAMES.map((name) => [variableOf(name), name])) + const secrets = new Map() + const owners = new Map() + const others: string[] = [] + for (const [index, raw] of text.split(/\r?\n/).entries()) { + const line = raw.trim() + if (line === "" || line === header) continue + const at = line.indexOf("=") + const name = at === -1 ? undefined : known.get(line.slice(0, at).trim()) + if (name === undefined) { + others.push(raw) + continue + } + const where = `${file}:${index + 1}: ${variableOf(name)}` + if (secrets.has(name)) throw new Error(`${where} is set twice`) + const secret = line.slice(at + 1).trim() + if (!StrKey.isValidEd25519SecretSeed(secret)) { + throw new Error(`${where} is not a valid Stellar secret key`) + } + const owner = owners.get(secret) + if (owner !== undefined) { + throw new Error(`${where} has the same key as ${variableOf(owner)}`) + } + secrets.set(name, secret) + owners.set(secret, name) + } + return { secrets, others } +} + +export function ensureKeys(network: NetworkName, dir: string): AccountKeys { + const file = keysFile(network, dir) + const header = headerOf(network) + const original = existsSync(file) ? readFileSync(file, "utf8") : undefined + const { secrets, others } = parse(original ?? "", file, header) + + const created = ACCOUNT_NAMES.filter((name) => !secrets.has(name)) + const secretOf = Object.fromEntries( + ACCOUNT_NAMES.map((name) => [ + name, + secrets.get(name) ?? Keypair.random().secret(), + ]), + ) as Record + + const content = `${[ + header, + ...ACCOUNT_NAMES.map((name) => `${variableOf(name)}=${secretOf[name]}`), + ...others, + ].join("\n")}\n` + if (content !== original) { + // Replace the file whole, so a failed write never leaves it half written. + const draft = `${file}.tmp` + rmSync(draft, { force: true }) + writeFileSync(draft, content, { mode: 0o600 }) + renameSync(draft, file) + } + chmodSync(file, 0o600) + + const accounts = Object.fromEntries( + ACCOUNT_NAMES.map((name) => [name, Keypair.fromSecret(secretOf[name])]), + ) as Accounts + return { file, accounts, created } +} diff --git a/scripts/harness/package.json b/scripts/harness/package.json index be8dcf42..f43c7724 100644 --- a/scripts/harness/package.json +++ b/scripts/harness/package.json @@ -6,9 +6,11 @@ "description": "Deploys the vault set to a network and runs integration tests.", "scripts": { "deploy": "tsx deploy.ts", + "keys": "tsx keys.ts", "build:clients": "tsx build-clients.ts", "lint": "eslint .", "typecheck": "tsc --noEmit", + "test:unit": "vitest run lib/ --reporter=default", "test:happy-path": "vitest run happy-path.test.ts", "test:guards": "vitest run guards.test.ts", "seed": "tsx seed.ts" diff --git a/scripts/harness/tsconfig.json b/scripts/harness/tsconfig.json index df950756..eaef5fec 100644 --- a/scripts/harness/tsconfig.json +++ b/scripts/harness/tsconfig.json @@ -15,6 +15,7 @@ }, "include": [ "deploy.ts", + "keys.ts", "build-clients.ts", "seed.ts", "lib/**/*.ts", diff --git a/scripts/harness/vitest.config.ts b/scripts/harness/vitest.config.ts index 68137109..fe72178c 100644 --- a/scripts/harness/vitest.config.ts +++ b/scripts/harness/vitest.config.ts @@ -4,7 +4,7 @@ const network = process.env["HARNESS_NETWORK"] ?? "local" export default defineConfig({ test: { - include: ["*.test.ts"], + include: ["*.test.ts", "lib/**/*.test.ts"], environment: "node", fileParallelism: false, sequence: { concurrent: false }, diff --git a/vitest.config.ts b/vitest.config.ts index 79b82ca6..662d0ea6 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -35,7 +35,7 @@ export default defineConfig({ test: { name: "scripts", environment: "node", - include: ["lib/**/*.test.mjs"], + include: ["lib/**/*.test.mjs", "harness/lib/**/*.test.ts"], }, }, {