diff --git a/README.md b/README.md index a1e8209a..bbb36ca1 100644 --- a/README.md +++ b/README.md @@ -135,12 +135,16 @@ integration suites against that deployment: ```sh npm run test:happy-path -w scripts/harness npm run test:guards -w scripts/harness +CARGO_TARGET_DIR=target/short-wind-down stellar contract build --package async-vault --features short_wind_down +npm run test:wind-down -w scripts/harness ``` Deploy afresh before each run of the suites. The guards leave the vault's notice at 10 seconds, and lowering it back waits for the timelock, so a second run against the same deployment can fail wherever it prices an epoch right -after closing it. +after closing it. `test:wind-down` ignores `deployed.local.json`: it deploys +its own throwaway vault set, built from the `short_wind_down` wasm above, so +it can reach an active wind-down without the real seven-day minimum. ### Keeping a deployment alive diff --git a/docs/TESTING.md b/docs/TESTING.md index af002604..8c0709ed 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -128,3 +128,14 @@ fixture wires them as the deploy does and finishes the vault's setup; | Upgrading replaces the vault's code and migrates once | [`contracts/async-vault/src/test/upgrade.rs`](../contracts/async-vault/src/test/upgrade.rs) (needs `upgrade_wasm`) | `applying_a_wasm_change_replaces_the_code`, `a_migration_refuses_a_second_run` | | The share token upgrades through the vault's timelock under live balances, an open deposit and an open redemption, and migrates once; called directly, `upgrade` does not exist | [`contracts/async-vault/src/test/upgrade.rs`](../contracts/async-vault/src/test/upgrade.rs) (needs `upgrade_wasm`) | `the_token_upgrades_under_live_balances_and_open_requests`, `the_token_exposes_no_immediate_upgrade` | | The oracle upgrades through the vault's timelock under a live valuation, keeps its config, roles and vault, and migrates once; called directly, `upgrade` does not exist | [`contracts/nav-oracle/src/test_governance.rs`](../contracts/nav-oracle/src/test_governance.rs) (needs `upgrade_wasm`) | `the_oracle_upgrades_under_a_live_valuation`, `the_oracle_exposes_no_immediate_upgrade` | + +## Wind-down on the network + +`scripts/harness/wind-down.test.ts` deploys a throwaway vault set on the local +network from a vault built with `--features short_wind_down`. That build drops +the seven-day minimum and carries a `build` meta entry, and is never deployed +elsewhere. The test runs a deposit, a claim, a redeem request, a wind-down, a +round and a claim, then checks that every function in the vault's spec was +called through the generated client. The full wind-down logic is tested in +Rust (`contracts/async-vault/src/test/wind_down.rs`). The testnet run is +spread over seven days in the Tranche 2 release. diff --git a/scripts/harness/deploy.ts b/scripts/harness/deploy.ts index 4f7c4c79..0b1492c1 100644 --- a/scripts/harness/deploy.ts +++ b/scripts/harness/deploy.ts @@ -2,7 +2,7 @@ import { existsSync } from "node:fs" import { join } from "node:path" import { pathToFileURL } from "node:url" -import { Asset, rpc, type Keypair } from "@stellar/stellar-sdk" +import { Asset, Keypair, rpc } from "@stellar/stellar-sdk" import { basicNodeSigner } from "@stellar/stellar-sdk/contract" import type * as AsyncVaultModule from "../../app-lib/clients/async_vault/dist/index.js" @@ -26,8 +26,17 @@ import { } from "./lib/deployment.js" import { network, type Network } from "./lib/network.js" -const wasmOf = (name: ContractName): string => - join(REPO_ROOT, "target", "wasm32v1-none", "release", `${name}.wasm`) +const RELEASE_DIR = join(REPO_ROOT, "target", "wasm32v1-none", "release") +export const SHORT_WIND_DOWN_DIR = join( + REPO_ROOT, + "target", + "short-wind-down", + "wasm32v1-none", + "release", +) + +export const wasmOf = (name: ContractName, dir = RELEASE_DIR): string => + join(dir, `${name}.wasm`) const clientDir = (name: ContractName): string => join(REPO_ROOT, "app-lib", "clients", name) @@ -35,8 +44,10 @@ const clientDir = (name: ContractName): string => const clientEntry = (name: ContractName): string => pathToFileURL(join(clientDir(name), "dist", "index.js")).href -function requireWasm(): void { - const missing = CONTRACT_NAMES.filter((name) => !existsSync(wasmOf(name))) +function requireWasm(vaultWasm: string = wasmOf("async_vault")): void { + const missing = CONTRACT_NAMES.filter( + (name) => !existsSync(name === "async_vault" ? vaultWasm : wasmOf(name)), + ) if (missing.length > 0) { throw new Error( `no wasm for ${missing.join(", ")}; run \`stellar contract build\` first`, @@ -59,40 +70,36 @@ async function deployContracts( net: Network, accounts: Accounts, assetContract: string, + wasms: Record, ): Promise> { const pk = (name: keyof Accounts): string => accounts[name].publicKey() const deployer = accounts.deployer const governance = pk("governance") - const compliance = await deployContract(net, wasmOf("compliance"), deployer, { + const compliance = await deployContract(net, wasms.compliance, deployer, { admin: governance, }) console.log("compliance", compliance) const identityVerifier = await deployContract( net, - wasmOf("identity_verifier"), + wasms.identity_verifier, deployer, { admin: pk("compliance") }, ) console.log("identity_verifier", identityVerifier) - const shareToken = await deployContract( - net, - wasmOf("share_token"), - deployer, - { - name: "Strata Vault USDC", - symbol: "bvUSDC", - admin: governance, - compliance_authority: pk("compliance"), - compliance, - identity_verifier: identityVerifier, - }, - ) + const shareToken = await deployContract(net, wasms.share_token, deployer, { + name: "Strata Vault USDC", + symbol: "bvUSDC", + admin: governance, + compliance_authority: pk("compliance"), + compliance, + identity_verifier: identityVerifier, + }) console.log("share_token", shareToken) - const navOracle = await deployContract(net, wasmOf("nav_oracle"), deployer, { + const navOracle = await deployContract(net, wasms.nav_oracle, deployer, { admin: governance, attester: pk("attester"), guardian: pk("guardian"), @@ -100,24 +107,19 @@ async function deployContracts( }) console.log("nav_oracle", navOracle) - const asyncVault = await deployContract( - net, - wasmOf("async_vault"), - deployer, - { - asset: assetContract, - share_token: shareToken, - oracle: navOracle, - roles: JSON.stringify({ - governance, - manager: pk("manager"), - treasury: pk("treasury"), - guardian: pk("guardian"), - compliance: pk("compliance"), - attester: pk("attester"), - }), - }, - ) + const asyncVault = await deployContract(net, wasms.async_vault, deployer, { + asset: assetContract, + share_token: shareToken, + oracle: navOracle, + roles: JSON.stringify({ + governance, + manager: pk("manager"), + treasury: pk("treasury"), + guardian: pk("guardian"), + compliance: pk("compliance"), + attester: pk("attester"), + }), + }) console.log("async_vault", asyncVault) return { @@ -253,12 +255,11 @@ async function finishVaultSetup( throw new Error(`the vault is not wired: ${broken.join(", ")}`) } -async function main(): Promise { - requireWasm() - const net = network() +async function freshSet( + net: Network, + wasms: Record, +): 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`) @@ -267,17 +268,9 @@ async function main(): Promise { const assetContract = await deployAssetContract(net, asset, accounts.issuer) console.log("asset contract", assetContract) - const contracts = await deployContracts(net, accounts, assetContract) - - for (const name of CONTRACT_NAMES) { - await generateClient(wasmOf(name), clientDir(name)) - } - console.log("clients generated") - - await grantAndVerify(net, accounts, contracts) - await finishVaultSetup(net, accounts, contracts) + const contracts = await deployContracts(net, accounts, assetContract, wasms) - const record: Deployment = { + return { network: net.name, deployedAt: new Date().toISOString(), ledger: (await server.getLatestLedger()).sequence, @@ -294,6 +287,52 @@ async function main(): Promise { ]), ) as Deployment["accounts"], } +} + +const keysOf = (record: Deployment): Accounts => + Object.fromEntries( + Object.entries(record.accounts).map(([name, key]) => [ + name, + Keypair.fromSecret(key.secret), + ]), + ) as Accounts + +// A full, wired vault set built from a different vault Wasm. It reuses the +// clients the main deploy generated, and nothing is written to disk. +export async function deployThrowaway( + net: Network, + vaultWasm: string, +): Promise { + requireWasm(vaultWasm) + const wasms = Object.fromEntries( + CONTRACT_NAMES.map((name) => [name, wasmOf(name)]), + ) as Record + wasms.async_vault = vaultWasm + const record = await freshSet(net, wasms) + const accounts = keysOf(record) + await grantAndVerify(net, accounts, record.contracts) + await finishVaultSetup(net, accounts, record.contracts) + return record +} + +async function main(): Promise { + requireWasm() + const net = network() + console.log(`deploying to ${net.name} (${net.rpcUrl})`) + const wasms = Object.fromEntries( + CONTRACT_NAMES.map((name) => [name, wasmOf(name)]), + ) as Record + const record = await freshSet(net, wasms) + + for (const name of CONTRACT_NAMES) { + await generateClient(wasmOf(name), clientDir(name)) + } + console.log("clients generated") + + const accounts = keysOf(record) + await grantAndVerify(net, accounts, record.contracts) + await finishVaultSetup(net, accounts, record.contracts) + saveDeployment(record) console.log(`wrote deployed.${net.name}.json`) if (net.name === "testnet") { @@ -301,7 +340,13 @@ async function main(): Promise { } } -main().catch((error: unknown) => { - console.error(error instanceof Error ? error.message : error) - process.exitCode = 1 -}) +const invokedDirectly = + process.argv[1] !== undefined && + import.meta.url === pathToFileURL(process.argv[1]).href + +if (invokedDirectly) { + main().catch((error: unknown) => { + console.error(error instanceof Error ? error.message : error) + process.exitCode = 1 + }) +} diff --git a/scripts/harness/lib/clients.ts b/scripts/harness/lib/clients.ts index 203db0b0..d7e0963b 100644 --- a/scripts/harness/lib/clients.ts +++ b/scripts/harness/lib/clients.ts @@ -23,7 +23,10 @@ export interface Connected { export function connect(): Connected { const net = network() - const deployment = loadDeployment(net.name) + return connectTo(net, loadDeployment(net.name)) +} + +export function connectTo(net: Network, deployment: Deployment): Connected { const keys = Object.fromEntries( ACCOUNT_NAMES.map((name) => [ name, diff --git a/scripts/harness/package.json b/scripts/harness/package.json index 84ed1aa9..9ad6f363 100644 --- a/scripts/harness/package.json +++ b/scripts/harness/package.json @@ -12,6 +12,7 @@ "typecheck": "tsc --noEmit", "test:happy-path": "vitest run happy-path.test.ts", "test:guards": "vitest run guards.test.ts", + "test:wind-down": "vitest run wind-down.test.ts", "seed": "tsx seed.ts" }, "dependencies": { diff --git a/scripts/harness/wind-down.test.ts b/scripts/harness/wind-down.test.ts new file mode 100644 index 00000000..66355822 --- /dev/null +++ b/scripts/harness/wind-down.test.ts @@ -0,0 +1,276 @@ +import { beforeAll, describe, expect, it } from "vitest" + +import { SHORT_WIND_DOWN_DIR, deployThrowaway, wasmOf } from "./deploy.js" +import { pay, trustline } from "./lib/accounts.js" +import { units } from "./lib/amounts.js" +import { + connectTo, + identityVerifier, + send, + shareToken, + usdcBalance, + vault, + type Connected, +} from "./lib/clients.js" +import { network } from "./lib/network.js" +import { attest } from "./lib/oracle.js" +import { outcome } from "./lib/refused.js" + +const net = network() +if (net.name !== "local") { + throw new Error( + "the wind-down run needs the local network: it deploys a vault built without the wind-down minimum", + ) +} + +let c: Connected +let decimals = 7 +const usdc = (whole: number) => units(whole, decimals) +const called = new Set() + +type Signer = + "governance" | "manager" | "guardian" | "treasury" | "holder-1" | "outsider" + +type Call = (args: Record) => Promise<{ + simulation?: unknown + result: unknown + signAndSend(): Promise<{ result: unknown }> +}> + +// Simulates one vault call, and sends it when it would succeed and `submit` is +// set. Either way the binding has encoded the arguments and reached the +// contract. +async function call( + name: string, + as: Signer, + args: Record = {}, + submit = false, +): Promise { + const client = vault(c, c.keys[as]) + const method = (client as unknown as Record)[name] + if (method === undefined) throw new Error(`the vault client has no ${name}`) + const tx = await method.call(client, args) + called.add(name) + const result = outcome(tx, client) + if (submit && result === "accepted") await send(Promise.resolve(tx)) + return result +} + +beforeAll(async () => { + const deployment = await deployThrowaway( + net, + wasmOf("async_vault", SHORT_WIND_DOWN_DIR), + ) + c = connectTo(net, deployment) + decimals = (await shareToken(c, c.keys.outsider).decimals()).result + const holder = c.keys["holder-1"] + await trustline(c.net, c.server, holder, c.asset) + await trustline(c.net, c.server, c.keys.custodian, c.asset) + await pay(c.net, c.server, c.keys.issuer, holder.publicKey(), c.asset, "1000") + await send( + identityVerifier(c, c.keys.compliance).allow({ + account: holder.publicKey(), + allowed: true, + caller: c.keys.compliance.publicKey(), + }), + ) +}) + +describe("a wind-down on a throwaway vault", () => { + const holder = () => c.keys["holder-1"].publicKey() + + it("a holder subscribes, is priced and claims shares", async () => { + expect( + await call( + "request_deposit", + "holder-1", + { from: holder(), amount: usdc(200) }, + true, + ), + ).toBe("accepted") + expect( + await call( + "close_epoch", + "manager", + { caller: c.keys.manager.publicKey() }, + true, + ), + ).toBe("accepted") + await attest(c, 2n * 10n ** 18n) + expect( + await call("fulfill_epoch", "outsider", { epoch_id: 1n }, true), + ).toBe("accepted") + expect( + await call( + "claim_deposit", + "holder-1", + { caller: holder(), epoch_id: 1n }, + true, + ), + ).toBe("accepted") + }) + + it("the holder asks to redeem part of it", async () => { + expect( + await call( + "request_redeem", + "holder-1", + { from: holder(), shares: usdc(10) }, + true, + ), + ).toBe("accepted") + }) + + it("governance proposes the wind-down and anyone activates it", async () => { + const gov = c.keys.governance.publicKey() + expect( + await call("propose_wind_down", "governance", { caller: gov }, true), + ).toBe("accepted") + expect(await call("activate_wind_down", "outsider", {}, true)).toBe( + "accepted", + ) + }) + + it("anyone finalises a round and the holder claims", async () => { + expect(await call("finalize_wind_down_round", "outsider", {}, true)).toBe( + "accepted", + ) + + const reader = vault(c, c.keys.outsider) + const owedBefore = (await reader.wind_down_owed()).result + const claimableBefore = ( + await reader.wind_down_claimable({ holder: holder() }) + ).result + const balanceBefore = await usdcBalance(c, holder()) + + called.add("claim_wind_down") + const paid = await send( + vault(c, c.keys["holder-1"]).claim_wind_down({ holder: holder() }), + ) + + expect(paid).toBeGreaterThan(0n) + expect(paid).toBe(claimableBefore) + expect(await usdcBalance(c, holder())).toBe(balanceBefore + paid) + expect((await reader.wind_down_owed()).result).toBe(owedBefore - paid) + }) + + it("reads the economic supply after the claim", async () => { + await call("total_economic_supply", "outsider") + }) + + it("every other vault function answers through its binding", async () => { + const gov = c.keys.governance.publicKey() + const someone = c.keys.outsider.publicKey() + const views = [ + "asset", + "share_token", + "oracle", + "governance", + "manager", + "treasury", + "guardian", + "custodian", + "net_deployed", + "free_reserve", + "committed", + "uncovered", + "liquid_reserve", + "cancellable_escrow", + "pending_mint_shares", + "pending_burn_shares", + "deposit_cap", + "wind_down_delay", + "wind_down", + "wind_down_owed", + "wind_down_supply", + "wind_down_acc", + "notice", + "current_epoch", + "upgrade_delay", + "schema_version", + "pending_changes", + "setup_open", + "health", + "timelock_clock", + "get_admin", + "get_existing_roles", + "paused", + ] + for (const name of views) await call(name, "outsider") + await call("wind_down_claimable", "outsider", { holder: holder() }) + await call("get_epoch", "outsider", { epoch_id: 1n }) + await call("get_epoch_settlement", "outsider", { epoch_id: 1n }) + await call("get_deposit_request", "outsider", { + epoch_id: 1n, + controller: holder(), + }) + await call("get_redeem_request", "outsider", { + epoch_id: 2n, + controller: holder(), + }) + await call("has_role", "outsider", { account: gov, role: "manager" }) + await call("get_role_member_count", "outsider", { role: "manager" }) + await call("get_role_member", "outsider", { role: "manager", index: 0 }) + await call("get_role_admin", "outsider", { role: "manager" }) + + // Writes the wind-down or the timelock refuses: the call still has to + // encode and reach the contract. + const refusedWrites: Array<[string, Signer, Record]> = [ + ["set_deposit_cap", "governance", { cap: 1n, caller: gov }], + ["set_custodian", "governance", { custodian: someone, caller: gov }], + ["set_wind_down_delay", "governance", { secs: 1n, caller: gov }], + ["cancel_wind_down_proposal", "governance", { caller: gov }], + ["set_notice", "governance", { secs: 1n, caller: gov }], + ["finish_setup", "governance", { caller: gov }], + [ + "propose_change", + "governance", + { change: { tag: "Custodian", values: [someone] }, caller: gov }, + ], + ["cancel_change", "governance", { id: 1, caller: gov }], + ["apply_change", "governance", { id: 1, caller: gov }], + [ + "deploy_to_custodian", + "treasury", + { caller: c.keys.treasury.publicKey(), assets: 1n }, + ], + ["fund", "outsider", { from: someone, assets: 1n }], + ["cancel_deposit", "holder-1", { from: holder(), epoch_id: 1n }], + ["cancel_redeem", "holder-1", { from: holder(), epoch_id: 2n }], + ["claim_redeem", "holder-1", { caller: holder(), epoch_id: 2n }], + [ + "grant_role", + "governance", + { account: someone, role: "manager", caller: gov }, + ], + [ + "revoke_role", + "governance", + { account: someone, role: "manager", caller: gov }, + ], + ["renounce_role", "outsider", { role: "manager", caller: someone }], + [ + "transfer_admin_role", + "governance", + { new_admin: someone, live_until_ledger: 1 }, + ], + ["accept_admin_transfer", "outsider", {}], + [ + "set_role_admin", + "governance", + { role: "manager", admin_role: "guardian" }, + ], + ["renounce_admin", "governance", {}], + ["pause", "guardian", { caller: c.keys.guardian.publicKey() }], + ["unpause", "governance", { caller: gov }], + ] + for (const [name, as, args] of refusedWrites) await call(name, as, args) + + const spec = vault(c, c.keys.outsider).spec + const every = spec + .funcs() + .map((f) => f.name().toString()) + .filter((name) => name !== "__constructor") + expect(every.filter((name) => !called.has(name))).toEqual([]) + }) +})