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
24 changes: 16 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,28 +124,36 @@ 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.<network>` 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
> @strata-vault-kit/harness@0.1.0 deploy
> 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
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.<timestamp>.json`. Then run the
integration suites against that deployment:

```sh
Expand Down
49 changes: 48 additions & 1 deletion scripts/harness/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.<network>.json` records deployed contract IDs and generated accounts. Test reports write to `runs/<network>.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.<network>` 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.<network>.json` records the deployed contract IDs and the accounts,
secrets included. A new deploy keeps the previous record as
`deployed.<network>.<timestamp>.json`, named after the time of that deploy,
instead of overwriting it. Git ignores these files too.

Test reports write to `runs/<network>.json`.
30 changes: 22 additions & 8 deletions scripts/harness/deploy.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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 {
Expand All @@ -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 =>
Expand Down Expand Up @@ -198,12 +199,23 @@ async function main(): Promise<void> {
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)
Expand Down Expand Up @@ -232,7 +244,9 @@ async function main(): Promise<void> {
]),
) as Deployment["accounts"],
}
saveDeployment(record)
const kept = saveDeployment(record)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The record still carries every secret (line 243) and each deploy now keeps another 0644 copy. With the keys file as the source of truth, maybe store public keys only, or write the records 0600?

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]")
Expand Down
24 changes: 24 additions & 0 deletions scripts/harness/keys.ts
Original file line number Diff line number Diff line change
@@ -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
}
107 changes: 107 additions & 0 deletions scripts/harness/lib/accounts.test.ts
Original file line number Diff line number Diff line change
@@ -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<string>) {
const created: string[][] = []
const server = {
getAccount(publicKey: string): Promise<Account> {
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()])
})
})
29 changes: 19 additions & 10 deletions scripts/harness/lib/accounts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,6 @@ export const ACCOUNT_NAMES = [
export type AccountName = (typeof ACCOUNT_NAMES)[number]
export type Accounts = Record<AccountName, Keypair>

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,
Expand Down Expand Up @@ -119,17 +113,32 @@ async function awaitVisible(
}
}

async function exists(server: rpc.Server, publicKey: string): Promise<boolean> {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

exists() treats any RPC error as a missing account, so a timeout ends in a confusing "already exists" at funding. Maybe return false only on "Account not found" and rethrow the rest?

try {
await server.getAccount(publicKey)
return true
} catch {
return false
}
}

export async function fund(
net: Network,
server: rpc.Server,
keys: readonly Keypair[],
): Promise<void> {
): Promise<number> {
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(
Expand Down
34 changes: 34 additions & 0 deletions scripts/harness/lib/cli.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
Loading
Loading