From 2bb6e5bbd98925ec5534471d17867c47ea2d36d6 Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Thu, 22 Oct 2026 10:47:06 -0300 Subject: [PATCH 1/2] feat(harness): keep account keys between deploys Every deploy made new random keys for the thirteen harness accounts and kept them only in deployed..json, which the next deploy overwrote. A deploy now reads the keys from scripts/harness/.env.keys., adds a key only for an account without one, and writes the file with mode 0600 before any transaction. `npm run keys` does the same without deploying and prints public keys only. Funding skips accounts that already exist, the issuer's existing asset contract is reused, and the previous record is kept as deployed...json. The harness unit tests need no network and run in the root test run. --- README.md | 24 ++-- scripts/harness/.gitignore | 1 + scripts/harness/README.md | 49 ++++++- scripts/harness/deploy.ts | 31 +++-- scripts/harness/keys.ts | 24 ++++ scripts/harness/lib/accounts.test.ts | 107 +++++++++++++++ scripts/harness/lib/accounts.ts | 29 ++-- scripts/harness/lib/cli.test.ts | 34 +++++ scripts/harness/lib/cli.ts | 25 +++- scripts/harness/lib/deployment.test.ts | 102 ++++++++++++++ scripts/harness/lib/deployment.ts | 57 ++++++-- scripts/harness/lib/keys.test.ts | 178 +++++++++++++++++++++++++ scripts/harness/lib/keys.ts | 98 ++++++++++++++ scripts/harness/package.json | 2 + scripts/harness/tsconfig.json | 1 + scripts/harness/vitest.config.ts | 2 +- vitest.config.ts | 2 +- 17 files changed, 727 insertions(+), 39 deletions(-) create mode 100644 scripts/harness/keys.ts create mode 100644 scripts/harness/lib/accounts.test.ts create mode 100644 scripts/harness/lib/cli.test.ts create mode 100644 scripts/harness/lib/deployment.test.ts create mode 100644 scripts/harness/lib/keys.test.ts create mode 100644 scripts/harness/lib/keys.ts diff --git a/README.md b/README.md index ee24fbdf..130f7b13 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,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 +`scripts/harness/.env.keys.`, 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 @@ -111,13 +117,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 scripts/harness/.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 @@ -125,7 +132,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/.gitignore b/scripts/harness/.gitignore index 0d4f3f1b..026a9eac 100644 --- a/scripts/harness/.gitignore +++ b/scripts/harness/.gitignore @@ -1,2 +1,3 @@ deployed.*.json +.env.keys.* runs/ diff --git a/scripts/harness/README.md b/scripts/harness/README.md index 3ac3fd88..a5505fa3 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.` in this directory, 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: + +```sh +echo 'HOLDER_1_SECRET=S...' >> .env.keys.testnet +HARNESS_NETWORK=testnet npm run keys +``` + +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..c08c415b 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,19 +9,21 @@ 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 { CONTRACT_NAMES, + PACKAGE_ROOT, REPO_ROOT, saveDeployment, 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 +200,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, PACKAGE_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 +245,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..868dfe69 --- /dev/null +++ b/scripts/harness/keys.ts @@ -0,0 +1,24 @@ +import { relative } from "node:path" + +import { ACCOUNT_NAMES } from "./lib/accounts.js" +import { PACKAGE_ROOT, 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, PACKAGE_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"], }, }, { From 8943368b82c016f030b9819afb3fbb5c76dcbf21 Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Thu, 22 Oct 2026 12:18:20 -0300 Subject: [PATCH 2/2] refactor(harness): keep the keys file at the repository root Env files usually live at the root, where they are easier to find, and the root .gitignore already ignores them. --- README.md | 6 +++--- scripts/harness/.gitignore | 1 - scripts/harness/README.md | 6 +++--- scripts/harness/deploy.ts | 3 +-- scripts/harness/keys.ts | 4 ++-- 5 files changed, 9 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 130f7b13..a0ce0f76 100644 --- a/README.md +++ b/README.md @@ -105,8 +105,8 @@ 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 -`scripts/harness/.env.keys.`, and later deploys reuse them. Back that -file up and never commit it. The +`.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. @@ -117,7 +117,7 @@ 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) -account keys in scripts/harness/.env.keys.local (13 new) +account keys in .env.keys.local (13 new) funded 13 accounts; 0 already existed asset contract CDKWXRAHPQVFOAEBOOWWR62ZXAE2IV4V47PATFN7IX4XDW5QIUUL7D2B compliance CDGKQCYSMTDRBO7SYV3V72H6AMCQ3UNQFKUU4AD5GSU2I32XDYFS6F3U diff --git a/scripts/harness/.gitignore b/scripts/harness/.gitignore index 026a9eac..0d4f3f1b 100644 --- a/scripts/harness/.gitignore +++ b/scripts/harness/.gitignore @@ -1,3 +1,2 @@ deployed.*.json -.env.keys.* runs/ diff --git a/scripts/harness/README.md b/scripts/harness/README.md index a5505fa3..fd5a0c8c 100644 --- a/scripts/harness/README.md +++ b/scripts/harness/README.md @@ -26,7 +26,7 @@ npm run test:guards 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.` in this directory, one line per account. +`.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 @@ -40,11 +40,11 @@ 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: +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 +HARNESS_NETWORK=testnet npm run keys -w scripts/harness ``` Each line is the account name in capitals, with `_` for `-`, then `_SECRET`: diff --git a/scripts/harness/deploy.ts b/scripts/harness/deploy.ts index c08c415b..1987170c 100644 --- a/scripts/harness/deploy.ts +++ b/scripts/harness/deploy.ts @@ -17,7 +17,6 @@ import { } from "./lib/cli.js" import { CONTRACT_NAMES, - PACKAGE_ROOT, REPO_ROOT, saveDeployment, type ContractName, @@ -200,7 +199,7 @@ async function main(): Promise { const server = new rpc.Server(net.rpcUrl, { allowHttp: net.name === "local" }) console.log(`deploying to ${net.name} (${net.rpcUrl})`) - const { file, accounts, created } = ensureKeys(net.name, PACKAGE_ROOT) + const { file, accounts, created } = ensureKeys(net.name, REPO_ROOT) console.log( `account keys in ${relative(REPO_ROOT, file)} (${created.length} new)`, ) diff --git a/scripts/harness/keys.ts b/scripts/harness/keys.ts index 868dfe69..9dd4cdab 100644 --- a/scripts/harness/keys.ts +++ b/scripts/harness/keys.ts @@ -1,12 +1,12 @@ import { relative } from "node:path" import { ACCOUNT_NAMES } from "./lib/accounts.js" -import { PACKAGE_ROOT, REPO_ROOT } from "./lib/deployment.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, PACKAGE_ROOT) + const { file, accounts, created } = ensureKeys(network().name, REPO_ROOT) console.log( `account keys in ${relative(REPO_ROOT, file)} (${created.length} new)`, )