From 4c8e19cc20970646e0fda30ed0e24697f71d9791 Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Mon, 2 Nov 2026 12:13:35 -0300 Subject: [PATCH 01/18] test: run the js tests against the generated clients Drop the vitest alias that swapped every generated client for a stub. `npm run build:clients` now writes all six clients from the local Wasm with no network: `src/` from the SDK's generator, which the app's own build uses, and `dist/` from the CLI's bindings for the harness. The asset's client comes from the SDK's Stellar Asset Contract spec. `npm run test` stops with the command to run when any client is missing. Refs #420 --- AGENTS.md | 23 ++++-- README.md | 12 ++++ app-lib/contracts.test.ts | 8 ++- app-lib/multisig.test.ts | 4 +- package.json | 2 +- scripts/clients-guard.mjs | 25 +++++++ scripts/generate-clients.mjs | 19 +---- scripts/harness/build-clients.ts | 14 +++- scripts/harness/lib/asset-client.test.ts | 16 +++++ scripts/harness/lib/asset-client.ts | 28 ++++++++ scripts/lib/clients.d.mts | 9 +++ scripts/lib/clients.mjs | 37 ++++++++++ scripts/lib/clients.test.mjs | 89 ++++++++++++++++++++++++ vitest.clients.ts | 5 -- vitest.config.ts | 15 +--- 15 files changed, 257 insertions(+), 49 deletions(-) create mode 100644 scripts/clients-guard.mjs create mode 100644 scripts/harness/lib/asset-client.test.ts create mode 100644 scripts/harness/lib/asset-client.ts create mode 100644 scripts/lib/clients.d.mts create mode 100644 scripts/lib/clients.mjs create mode 100644 scripts/lib/clients.test.mjs delete mode 100644 vitest.clients.ts diff --git a/AGENTS.md b/AGENTS.md index 7ad69795..49bdcbff 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -51,11 +51,13 @@ does not carry over. Read it for Soroban and OZ mechanics only. enable an experimental `soroban-sdk` feature (`spec_shaking_v2`) that only works through the CLI wrapper. The devshell pins the Stellar CLI it expects. - **Tests:** `cargo test` from the repo root runs every workspace member against - the in-memory `Env`. There is no unit-test runner for `app/` or `app-lib/`; - `e2e/` runs Playwright separately. CI runs `cargo test --locked` on every pull - request except one that changes only Markdown files outside `docs/evidence/`: - that one skips the Rust and local-network jobs. The secret scan runs on - every pull request. + the in-memory `Env`. `npm run test` runs vitest over `app/`, `app-lib/` and + `scripts/` against the generated contract clients, so run + `stellar contract build && npm run build:clients` first. `e2e/` runs + Playwright separately. CI runs `cargo test --locked` on every pull request + except one that changes only Markdown files outside `docs/evidence/`: that + one skips the Rust and local-network jobs. The secret scan runs on every pull + request. - **Toolchain:** `nix develop` provides it, or rustup honours `rust-toolchain.toml`. @@ -97,11 +99,18 @@ apply as the corresponding code lands here. re-exports them and builds each runtime client with `new Client(...)` from its generated `src/index`, whose embedded spec means no page fetches a contract's instance or Wasm. Vite dedupes `@stellar/stellar-sdk` so a client's nested copy - never reaches the bundle, and vitest aliases the generated modules to - `vitest.clients.ts`, since tests run without them. + never reaches the bundle. A fresh clone builds `app` with no cargo, no stellar CLI and no local network, but needs RPC access to the selected network at build time. A local scaffold build writes the same directory from the WASM. +- Two generators write `app-lib/clients/`, and they type an empty `Option` + differently: the SDK's as `null`, the stellar CLI's as `undefined`. The app + is written against the SDK's, which its build uses. `npm run build:clients` + writes both from the local Wasm, with no network: `src/` from the SDK's + generator for the app and its tests, and `dist/` compiled from the CLI's + bindings for the harness. The scaffold's `--build-clients` writes the CLI's + in both places, so run `npm run build:clients` again before type-checking the + app. - When `cargo` itself builds a contract for the Wasm target, rather than `stellar contract build` (a size check with `cargo build --release --target wasm32v1-none`, for example), set diff --git a/README.md b/README.md index 6944d796..f2d9c0dd 100644 --- a/README.md +++ b/README.md @@ -152,6 +152,18 @@ Formatting and secret-scanning run on commit via a `prek` hook — see `.pre-commit-config.yaml` and run `prek install` once. CI also scans every pull request's commits with the same gitleaks version. +### Console and script tests + +The tests for `app/`, `app-lib/` and `scripts/` run against the TypeScript +clients generated from the contracts. Generate them once, and again after a +contract's interface changes: + +```sh +npm ci +stellar contract build && npm run build:clients +npm run test # stops and names any client that is missing +``` + ### Integration & On-Chain Tests From a fresh clone to a vault set deployed on a local network. Run these inside diff --git a/app-lib/contracts.test.ts b/app-lib/contracts.test.ts index 85b30bd7..aa29ef79 100644 --- a/app-lib/contracts.test.ts +++ b/app-lib/contracts.test.ts @@ -1,6 +1,8 @@ -import type { ClientOptions } from "@stellar/stellar-sdk/contract" +import { + Client as ContractClient, + type ClientOptions, +} from "@stellar/stellar-sdk/contract" import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" -import { Client as GeneratedClient } from "../vitest.clients" import { connectAsset, connectAsyncVault, @@ -12,7 +14,7 @@ import { import { server } from "./rpc" const optionsOf = (client: unknown): ClientOptions => { - if (!(client instanceof GeneratedClient)) + if (!(client instanceof ContractClient)) throw new Error("not built from the generated bindings") return client.options } diff --git a/app-lib/multisig.test.ts b/app-lib/multisig.test.ts index ba13adc6..54feebf0 100644 --- a/app-lib/multisig.test.ts +++ b/app-lib/multisig.test.ts @@ -16,12 +16,12 @@ import { } from "@stellar/stellar-sdk" import { AssembledTransaction, + Client as ContractClient, Spec, type ClientOptions, } from "@stellar/stellar-sdk/contract" import { Api } from "@stellar/stellar-sdk/rpc" import { beforeEach, describe, expect, it, vi } from "vitest" -import { Client as GeneratedClient } from "../vitest.clients" import { connectAsyncVault } from "./contracts" import { networkPassphrase } from "./env" import { @@ -552,7 +552,7 @@ describe("transactionStatus", () => { }) const timeoutOf = (client: unknown): number | undefined => { - if (!(client instanceof GeneratedClient)) + if (!(client instanceof ContractClient)) throw new Error("not the generated vault client") return client.options.timeoutInSeconds } diff --git a/package.json b/package.json index 667a3685..e9e8f11a 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "format:check": "prettier . --check", "build:clients": "npm run build:clients -w scripts/harness", "seed": "npm run seed -w scripts/harness --", - "pretest": "node scripts/generate-addresses.mjs", + "pretest": "node scripts/clients-guard.mjs && node scripts/generate-addresses.mjs", "test": "vitest run" }, "workspaces": [ diff --git a/scripts/clients-guard.mjs b/scripts/clients-guard.mjs new file mode 100644 index 00000000..5bccc337 --- /dev/null +++ b/scripts/clients-guard.mjs @@ -0,0 +1,25 @@ +import { dirname, resolve } from "node:path" +import { fileURLToPath } from "node:url" +import { missingClients } from "./lib/clients.mjs" + +// Runs as the root `pretest` hook. The tests import the generated contract +// clients, so without them every test file fails on a module it cannot resolve. +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..") + +const missing = missingClients(repoRoot) +if (missing.length > 0) { + console.error( + [ + "", + "Cannot test — the generated contract clients are missing:", + "", + ...missing.map((name) => ` - app-lib/clients/${name}`), + "", + "Build the contracts and generate the clients first:", + "", + " stellar contract build && npm run build:clients", + "", + ].join("\n"), + ) + process.exit(1) +} diff --git a/scripts/generate-clients.mjs b/scripts/generate-clients.mjs index 76ee8957..539fe937 100644 --- a/scripts/generate-clients.mjs +++ b/scripts/generate-clients.mjs @@ -1,10 +1,11 @@ #!/usr/bin/env node // Generates a typed client per deployed contract from its on-chain spec, so a // build needs Node and RPC access only, with no cargo or Stellar CLI. -import { mkdirSync, readFileSync, writeFileSync } from "node:fs" +import { readFileSync } from "node:fs" import { dirname, resolve } from "node:path" import { fileURLToPath } from "node:url" import { BindingGenerator, rpc } from "@stellar/stellar-sdk" +import { writeClient } from "./lib/clients.mjs" import { contractIds, networkOf, @@ -26,21 +27,7 @@ const generate = async ({ name, id }) => { rpcUrl, networkPassphrase, }) - const dir = resolve(repoRoot, "app-lib/clients", name) - mkdirSync(resolve(dir, "src"), { recursive: true }) - const sources = { - "src/index.ts": files.index, - "src/types.ts": files.types, - "src/client.ts": files.client, - "package.json": files.packageJson.replace( - /"name": "[^"]+"/, - `"name": "${name}"`, - ), - "tsconfig.json": files.tsConfig, - } - for (const [path, content] of Object.entries(sources)) { - writeFileSync(resolve(dir, path), content) - } + writeClient(repoRoot, name, files) } await Promise.all(contracts.map(generate)) diff --git a/scripts/harness/build-clients.ts b/scripts/harness/build-clients.ts index d7e3e1da..b3106285 100644 --- a/scripts/harness/build-clients.ts +++ b/scripts/harness/build-clients.ts @@ -1,5 +1,10 @@ -import { existsSync } from "node:fs" +import { existsSync, readFileSync } from "node:fs" import { join } from "node:path" + +import { BindingGenerator } from "@stellar/stellar-sdk" + +import { writeClient } from "../lib/clients.mjs" +import { stellarAssetGenerator } from "./lib/asset-client.js" import { generateClient, rootSdkVersion } from "./lib/cli.js" import { CONTRACT_NAMES, @@ -21,10 +26,17 @@ async function main(): Promise { ) } + // The harness imports each client's `dist/`, compiled from the CLI's + // bindings. The app imports `src/`, written by the SDK's generator as the + // app's own build does; the two type an empty `Option` differently. const sdkVersion = rootSdkVersion() for (const name of CONTRACT_NAMES) { await generateClient(wasmOf(name), clientDir(name), sdkVersion) + const generator = BindingGenerator.fromWasm(readFileSync(wasmOf(name))) + writeClient(REPO_ROOT, name, generator.generate({ contractName: name })) } + const asset = await stellarAssetGenerator() + writeClient(REPO_ROOT, "asset", asset.generate({ contractName: "asset" })) console.log("clients generated") } diff --git a/scripts/harness/lib/asset-client.test.ts b/scripts/harness/lib/asset-client.test.ts new file mode 100644 index 00000000..104f8c74 --- /dev/null +++ b/scripts/harness/lib/asset-client.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest" + +import { stellarAssetGenerator } from "./asset-client.js" + +describe("stellarAssetGenerator", () => { + it("generates the Stellar Asset Contract's client without a network", async () => { + const generator = await stellarAssetGenerator() + + const { client } = generator.generate({ contractName: "asset" }) + + expect(client).toContain("export class Client") + for (const method of ["balance", "transfer", "decimals", "clawback"]) { + expect(client).toMatch(new RegExp(`\\b${method}\\b`)) + } + }) +}) diff --git a/scripts/harness/lib/asset-client.ts b/scripts/harness/lib/asset-client.ts new file mode 100644 index 00000000..2d1ff900 --- /dev/null +++ b/scripts/harness/lib/asset-client.ts @@ -0,0 +1,28 @@ +import { BindingGenerator, StrKey, xdr, type rpc } from "@stellar/stellar-sdk" + +// The SDK carries the Stellar Asset Contract's spec but hands it out only for a +// contract it looks up, so this answers that one lookup as a Stellar asset. +const stellarAssetLookup = { + getLedgerEntries(key: xdr.LedgerKey) { + const instance = new xdr.ScContractInstance({ + executable: xdr.ContractExecutable.contractExecutableStellarAsset(), + storage: null, + }) + const val = xdr.LedgerEntryData.contractData( + new xdr.ContractDataEntry({ + ext: new xdr.ExtensionPoint(0), + contract: key.contractData().contract(), + key: key.contractData().key(), + durability: xdr.ContractDataDurability.persistent(), + val: xdr.ScVal.scvContractInstance(instance), + }), + ) + return Promise.resolve({ entries: [{ key, val }] }) + }, +} as unknown as rpc.Server + +export const stellarAssetGenerator = (): Promise => + BindingGenerator.fromContractId( + StrKey.encodeContract(Buffer.alloc(32)), + stellarAssetLookup, + ) diff --git a/scripts/lib/clients.d.mts b/scripts/lib/clients.d.mts new file mode 100644 index 00000000..8834f21b --- /dev/null +++ b/scripts/lib/clients.d.mts @@ -0,0 +1,9 @@ +import type { BindingGenerator } from "@stellar/stellar-sdk" + +export function missingClients(root: string): string[] + +export function writeClient( + root: string, + name: string, + files: ReturnType, +): void diff --git a/scripts/lib/clients.mjs b/scripts/lib/clients.mjs new file mode 100644 index 00000000..27ea245c --- /dev/null +++ b/scripts/lib/clients.mjs @@ -0,0 +1,37 @@ +import { existsSync, mkdirSync, readdirSync, writeFileSync } from "node:fs" +import { join } from "node:path" + +const clientDir = (root, name) => join(root, "app-lib", "clients", name) + +// One client per contract crate, plus the Stellar Asset Contract the vault holds. +const expectedClients = (root) => [ + ...readdirSync(join(root, "contracts"), { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name.replaceAll("-", "_")) + .sort(), + "asset", +] + +export const missingClients = (root) => + expectedClients(root).filter( + (name) => !existsSync(join(clientDir(root, name), "src", "index.ts")), + ) + +// Writes the SDK generator's bindings where `app-lib/contracts.ts` imports them. +export const writeClient = (root, name, files) => { + const dir = clientDir(root, name) + mkdirSync(join(dir, "src"), { recursive: true }) + const sources = { + "src/index.ts": files.index, + "src/types.ts": files.types, + "src/client.ts": files.client, + "package.json": files.packageJson.replace( + /"name": "[^"]+"/, + `"name": "${name}"`, + ), + "tsconfig.json": files.tsConfig, + } + for (const [path, content] of Object.entries(sources)) { + writeFileSync(join(dir, path), content) + } +} diff --git a/scripts/lib/clients.test.mjs b/scripts/lib/clients.test.mjs new file mode 100644 index 00000000..d350a5f3 --- /dev/null +++ b/scripts/lib/clients.test.mjs @@ -0,0 +1,89 @@ +import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" + +import { describe, expect, it } from "vitest" +import { missingClients, writeClient } from "./clients.mjs" + +const repo = (contracts, clients) => { + const root = mkdtempSync(join(tmpdir(), "clients-")) + for (const name of contracts) { + mkdirSync(join(root, "contracts", name), { recursive: true }) + } + for (const name of clients) { + const src = join(root, "app-lib", "clients", name, "src") + mkdirSync(src, { recursive: true }) + writeFileSync(join(src, "index.ts"), "export {}\n") + } + return root +} + +describe("missingClients", () => { + it("finds none when every contract and the asset have a client", () => { + const root = repo( + ["async-vault", "nav-oracle"], + ["async_vault", "nav_oracle", "asset"], + ) + + expect(missingClients(root)).toEqual([]) + }) + + it("names each contract without a client, in its underscored form", () => { + const root = repo(["async-vault", "nav-oracle"], ["async_vault", "asset"]) + + expect(missingClients(root)).toEqual(["nav_oracle"]) + }) + + it("needs a client for the asset, which has no crate", () => { + const root = repo(["async-vault"], ["async_vault"]) + + expect(missingClients(root)).toEqual(["asset"]) + }) + + it("names them all when the clients folder is empty", () => { + const root = repo(["async-vault", "share-token"], []) + mkdirSync(join(root, "app-lib", "clients"), { recursive: true }) + + expect(missingClients(root)).toEqual([ + "async_vault", + "share_token", + "asset", + ]) + }) +}) + +describe("writeClient", () => { + const files = { + index: "export * from './client.js'\n", + types: "export {}\n", + client: "export class Client {}\n", + packageJson: '{\n "name": "generated-name",\n "version": "0.0.1"\n}\n', + tsConfig: "{}\n", + } + + it("writes the generated sources where the app imports them", () => { + const root = repo(["async-vault"], []) + + writeClient(root, "async_vault", files) + + const src = join(root, "app-lib", "clients", "async_vault", "src") + expect(readFileSync(join(src, "index.ts"), "utf8")).toBe(files.index) + expect(readFileSync(join(src, "types.ts"), "utf8")).toBe(files.types) + expect(readFileSync(join(src, "client.ts"), "utf8")).toBe(files.client) + expect(missingClients(root)).toEqual(["asset"]) + }) + + it("names the package after the client", () => { + const root = repo([], []) + + writeClient(root, "nav_oracle", files) + + const pkg = JSON.parse( + readFileSync( + join(root, "app-lib", "clients", "nav_oracle", "package.json"), + "utf8", + ), + ) + expect(pkg).toEqual({ name: "nav_oracle", version: "0.0.1" }) + }) +}) diff --git a/vitest.clients.ts b/vitest.clients.ts deleted file mode 100644 index d3864a0a..00000000 --- a/vitest.clients.ts +++ /dev/null @@ -1,5 +0,0 @@ -import type { ClientOptions } from "@stellar/stellar-sdk/contract" - -export class Client { - constructor(public readonly options: ClientOptions) {} -} diff --git a/vitest.config.ts b/vitest.config.ts index 662d0ea6..546a5d5a 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -3,23 +3,11 @@ import { configDefaults, defineConfig } from "vitest/config" const setupFiles = [resolve(import.meta.dirname, "vitest.setup.ts")] -// The generated bindings are fetched from the network at build time, so CI -// tests run without them. -const resolveGeneratedClients = { - alias: [ - { - find: /^\.\/clients\/\w+\/src\/index$/, - replacement: resolve(import.meta.dirname, "vitest.clients.ts"), - }, - ], -} - export default defineConfig({ test: { projects: [ { root: "./app", - resolve: resolveGeneratedClients, test: { name: "app", environment: "jsdom", @@ -40,12 +28,11 @@ export default defineConfig({ }, { root: "./app-lib", - resolve: resolveGeneratedClients, test: { name: "app-lib", environment: "node", include: ["**/*.test.ts"], - // `clients/` is generated and does not exist yet. + // Generated code, with no tests of its own. exclude: [...configDefaults.exclude, "clients/**"], }, }, From 8ebf183b0d8df77c7cafdc8d0adb873db8fd3c69 Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Mon, 2 Nov 2026 12:42:04 -0300 Subject: [PATCH 02/18] build: type-check app, app-lib and the harness Add a root `npm run typecheck` over the three workspaces. The app's script ran plain `tsc` on a tsconfig that only references other projects, so it checked nothing; it now runs `tsc -b`. app-lib gets its own TypeScript and a global `Buffer` type like the app's, and its multisig tests now match the types they use. Refs #420 --- AGENTS.md | 3 +- README.md | 7 ++-- app-lib/buffer.d.ts | 7 ++++ app-lib/multisig.test.ts | 8 +++- app-lib/package.json | 6 +++ app/package.json | 2 +- package-lock.json | 89 +++++++++++++++++++++++++++++++++++++++- package.json | 3 +- 8 files changed, 116 insertions(+), 9 deletions(-) create mode 100644 app-lib/buffer.d.ts diff --git a/AGENTS.md b/AGENTS.md index 49bdcbff..ce1088fe 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -52,7 +52,8 @@ does not carry over. Read it for Soroban and OZ mechanics only. works through the CLI wrapper. The devshell pins the Stellar CLI it expects. - **Tests:** `cargo test` from the repo root runs every workspace member against the in-memory `Env`. `npm run test` runs vitest over `app/`, `app-lib/` and - `scripts/` against the generated contract clients, so run + `scripts/`, and `npm run typecheck` checks `app/`, `app-lib/` and the + harness. Both use the generated contract clients, so run `stellar contract build && npm run build:clients` first. `e2e/` runs Playwright separately. CI runs `cargo test --locked` on every pull request except one that changes only Markdown files outside `docs/evidence/`: that diff --git a/README.md b/README.md index f2d9c0dd..6a284556 100644 --- a/README.md +++ b/README.md @@ -154,14 +154,15 @@ pull request's commits with the same gitleaks version. ### Console and script tests -The tests for `app/`, `app-lib/` and `scripts/` run against the TypeScript -clients generated from the contracts. Generate them once, and again after a -contract's interface changes: +The tests and the type check for `app/`, `app-lib/` and `scripts/` run against +the TypeScript clients generated from the contracts. Generate them once, and +again after a contract's interface changes: ```sh npm ci stellar contract build && npm run build:clients npm run test # stops and names any client that is missing +npm run typecheck # app, app-lib and the harness ``` ### Integration & On-Chain Tests diff --git a/app-lib/buffer.d.ts b/app-lib/buffer.d.ts new file mode 100644 index 00000000..6f67cd86 --- /dev/null +++ b/app-lib/buffer.d.ts @@ -0,0 +1,7 @@ +import { type Buffer as BufferPackage } from "buffer" + +// The generated clients name `Buffer` as a global; in the browser it comes from +// the `buffer` package, as in the app. +declare global { + type Buffer = BufferPackage +} diff --git a/app-lib/multisig.test.ts b/app-lib/multisig.test.ts index 54feebf0..d4cf294b 100644 --- a/app-lib/multisig.test.ts +++ b/app-lib/multisig.test.ts @@ -19,9 +19,11 @@ import { Client as ContractClient, Spec, type ClientOptions, + type MethodOptions, } from "@stellar/stellar-sdk/contract" import { Api } from "@stellar/stellar-sdk/rpc" import { beforeEach, describe, expect, it, vi } from "vitest" +import type { AccountSigners } from "./accounts" import { connectAsyncVault } from "./contracts" import { networkPassphrase } from "./env" import { @@ -89,12 +91,13 @@ const signedBy = (...keypairs: Keypair[]) => { return tx } -const signers = { +const signers: AccountSigners = { signers: [ { key: signerOne.publicKey(), weight: 1 }, { key: signerTwo.publicKey(), weight: 2 }, ], thresholds: { low: 1, med: 3, high: 3 }, + sequence: "1", } const resultArm = (code: string) => @@ -554,7 +557,8 @@ describe("transactionStatus", () => { const timeoutOf = (client: unknown): number | undefined => { if (!(client instanceof ContractClient)) throw new Error("not the generated vault client") - return client.options.timeoutInSeconds + // The client hands its options to each call, method defaults included. + return (client.options as ClientOptions & MethodOptions).timeoutInSeconds } describe("operator timeout", () => { diff --git a/app-lib/package.json b/app-lib/package.json index 7bfd33cc..041b57e9 100644 --- a/app-lib/package.json +++ b/app-lib/package.json @@ -7,9 +7,15 @@ ".": "./index.ts", "./styles.css": "./styles.css" }, + "scripts": { + "typecheck": "tsc" + }, "dependencies": { "@creit.tech/stellar-wallets-kit": "^2.5.0", "@stellar/stellar-sdk": "^16.4.1", "zod": "^4.3.5" + }, + "devDependencies": { + "typescript": "^6.0.2" } } diff --git a/app/package.json b/app/package.json index 74bbc631..3a0be82f 100644 --- a/app/package.json +++ b/app/package.json @@ -12,7 +12,7 @@ "preview": "vite preview", "lint": "eslint .", "format": "prettier . --write", - "typecheck": "tsc" + "typecheck": "tsc -b" }, "dependencies": { "@stellar-scaffold/app-lib": "*", diff --git a/package-lock.json b/package-lock.json index 1a8f144e..cba52664 100644 --- a/package-lock.json +++ b/package-lock.json @@ -58,6 +58,9 @@ "@creit.tech/stellar-wallets-kit": "^2.5.0", "@stellar/stellar-sdk": "^16.4.1", "zod": "^4.3.5" + }, + "devDependencies": { + "typescript": "^6.0.2" } }, "app-lib/clients/asset": { @@ -70,6 +73,20 @@ "typescript": "^5.6.2" } }, + "app-lib/clients/asset/node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "app-lib/clients/async_vault": { "version": "0.0.0", "dependencies": { @@ -80,6 +97,20 @@ "typescript": "^5.6.2" } }, + "app-lib/clients/async_vault/node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "app-lib/clients/compliance": { "version": "0.0.0", "dependencies": { @@ -90,6 +121,20 @@ "typescript": "^5.6.2" } }, + "app-lib/clients/compliance/node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "app-lib/clients/identity_verifier": { "version": "0.0.0", "dependencies": { @@ -100,6 +145,20 @@ "typescript": "^5.6.2" } }, + "app-lib/clients/identity_verifier/node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "app-lib/clients/nav_oracle": { "version": "0.0.0", "dependencies": { @@ -110,6 +169,20 @@ "typescript": "^5.6.2" } }, + "app-lib/clients/nav_oracle/node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "app-lib/clients/share_token": { "version": "0.0.0", "dependencies": { @@ -120,7 +193,7 @@ "typescript": "^5.6.2" } }, - "app-lib/node_modules/typescript": { + "app-lib/clients/share_token/node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", @@ -134,6 +207,20 @@ "node": ">=14.17" } }, + "app-lib/node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "app/node_modules/typescript": { "version": "6.0.3", "dev": true, diff --git a/package.json b/package.json index e9e8f11a..50e7a198 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,8 @@ "build:clients": "npm run build:clients -w scripts/harness", "seed": "npm run seed -w scripts/harness --", "pretest": "node scripts/clients-guard.mjs && node scripts/generate-addresses.mjs", - "test": "vitest run" + "test": "vitest run", + "typecheck": "npm run typecheck -w app -w app-lib -w scripts/harness" }, "workspaces": [ "app", From f242f33bc565aee1e770e7f303d03c83179194dd Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Mon, 2 Nov 2026 12:44:04 -0300 Subject: [PATCH 03/18] ci: build the contracts and clients once before the js checks A new `contracts` job builds the Wasm, checks it embeds no build path, and generates the TypeScript clients from it. The `js` job waits for it, downloads that output, and adds a type check before the tests. The harness type check moves there from the `network` job, which keeps its own Scaffold build and does not wait. Refs #420 --- .github/workflows/build.yml | 62 +++++++++++++++++++++++++++++-------- 1 file changed, 49 insertions(+), 13 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 15ba9be4..f156ef11 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -50,7 +50,46 @@ jobs: fi echo "code=$code" | tee -a "$GITHUB_OUTPUT" + # Runs for docs-only changes too: the js tests read the README. + contracts: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 + with: + cache-bin: "false" + - name: Install the stellar CLI + run: scripts/ci/install-tools.sh stellar + - name: Build the contracts + run: stellar contract build + # A build path in the Wasm would tie its hash to the checkout location. + - name: Print the Wasm hashes and check no build path is embedded + run: | + sha256sum target/wasm32v1-none/release/*.wasm + if grep -l -a -E '/home/runner|/Users/|\.cargo/registry' target/wasm32v1-none/release/*.wasm; then + echo "These Wasm files embed a build path, so their hashes depend on where the repository is checked out." + exit 1 + fi + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 22 + - run: npm i -g npm@11.7.0 + - run: npm ci + - name: Generate the TypeScript clients from the Wasm + run: npm run build:clients + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: contracts + path: | + target/wasm32v1-none/release/*.wasm + app-lib/clients/ + if-no-files-found: error + retention-days: 7 + js: + needs: contracts runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -62,8 +101,13 @@ jobs: - run: npm i -g npm@11.7.0 - run: npm --version - run: npm ci + - name: Download the Wasm and the generated clients + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: contracts - run: npm run lint - run: npm run format:check + - run: npm run typecheck - run: npm run test secret-scan: @@ -163,14 +207,6 @@ jobs: # The same cargo build Scaffold runs, so its build below reuses the Wasm. - name: Build the contracts run: stellar contract build - # A build path in the Wasm would tie its hash to the checkout location. - - name: Print the Wasm hashes and check no build path is embedded - run: | - sha256sum target/wasm32v1-none/release/*.wasm - if grep -l -a -E '/home/runner|/Users/|\.cargo/registry' target/wasm32v1-none/release/*.wasm; then - echo "These Wasm files embed a build path, so their hashes depend on where the repository is checked out." - exit 1 - fi - name: Build the vault without the wind-down minimum run: CARGO_TARGET_DIR=target/short-wind-down stellar contract build @@ -295,8 +331,6 @@ jobs: run: npm run deploy -w scripts/harness env: HARNESS_NETWORK: local - - name: Typecheck the harness workspace - run: npm run typecheck -w scripts/harness - name: Run the vault happy path on the local network run: npm run test:happy-path -w scripts/harness env: @@ -344,13 +378,15 @@ jobs: # The check branch protection requires. Rust and network skip only for docs; the secret scan runs on pull requests. build: if: always() - needs: [changes, js, secret-scan, rust-tests, rust-checks, network] + needs: + [changes, contracts, js, secret-scan, rust-tests, rust-checks, network] runs-on: ubuntu-latest steps: - name: Check the results env: CODE: ${{ needs.changes.outputs.code }} CHANGES: ${{ needs.changes.result }} + CONTRACTS: ${{ needs.contracts.result }} JS: ${{ needs.js.result }} EVENT: ${{ github.event_name }} SECRET_SCAN: ${{ needs.secret-scan.result }} @@ -358,8 +394,8 @@ jobs: RUST_CHECKS: ${{ needs.rust-checks.result }} NETWORK: ${{ needs.network.result }} run: | - echo "changes=$CHANGES js=$JS event=$EVENT secret-scan=$SECRET_SCAN rust-tests=$RUST_TESTS rust-checks=$RUST_CHECKS network=$NETWORK code=$CODE" - [ "$CHANGES" = success ] && [ "$JS" = success ] || exit 1 + echo "changes=$CHANGES contracts=$CONTRACTS js=$JS event=$EVENT secret-scan=$SECRET_SCAN rust-tests=$RUST_TESTS rust-checks=$RUST_CHECKS network=$NETWORK code=$CODE" + [ "$CHANGES" = success ] && [ "$CONTRACTS" = success ] && [ "$JS" = success ] || exit 1 if [ "$EVENT" = pull_request ]; then [ "$SECRET_SCAN" = success ] || exit 1; fi if [ "$CODE" != false ]; then [ "$RUST_TESTS" = success ] && [ "$RUST_CHECKS" = success ] && [ "$NETWORK" = success ] || exit 1 From 105c76b7e778a1f9f763764bf039a7863035642f Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Mon, 2 Nov 2026 12:58:22 -0300 Subject: [PATCH 04/18] fix(ci): type-check app-lib with the app's compiler TypeScript 6 in app-lib's own folder also became the compiler for the generated client packages, which the network job builds and which TypeScript 6 rejects. The app's `tsc -b` now type-checks app-lib as a referenced project instead, so app-lib needs no TypeScript of its own. The type check generates the addresses module first, as the tests do, and both share the clients guard. Refs #420 --- app-lib/package.json | 6 --- app/tsconfig.json | 3 +- package-lock.json | 89 +-------------------------------------- package.json | 3 +- scripts/clients-guard.mjs | 6 +-- 5 files changed, 8 insertions(+), 99 deletions(-) diff --git a/app-lib/package.json b/app-lib/package.json index 041b57e9..7bfd33cc 100644 --- a/app-lib/package.json +++ b/app-lib/package.json @@ -7,15 +7,9 @@ ".": "./index.ts", "./styles.css": "./styles.css" }, - "scripts": { - "typecheck": "tsc" - }, "dependencies": { "@creit.tech/stellar-wallets-kit": "^2.5.0", "@stellar/stellar-sdk": "^16.4.1", "zod": "^4.3.5" - }, - "devDependencies": { - "typescript": "^6.0.2" } } diff --git a/app/tsconfig.json b/app/tsconfig.json index fb124188..6e920220 100644 --- a/app/tsconfig.json +++ b/app/tsconfig.json @@ -2,6 +2,7 @@ "files": [], "references": [ { "path": "./tsconfig.app.json" }, - { "path": "./tsconfig.node.json" } + { "path": "./tsconfig.node.json" }, + { "path": "../app-lib" } ] } diff --git a/package-lock.json b/package-lock.json index cba52664..1a8f144e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -58,9 +58,6 @@ "@creit.tech/stellar-wallets-kit": "^2.5.0", "@stellar/stellar-sdk": "^16.4.1", "zod": "^4.3.5" - }, - "devDependencies": { - "typescript": "^6.0.2" } }, "app-lib/clients/asset": { @@ -73,20 +70,6 @@ "typescript": "^5.6.2" } }, - "app-lib/clients/asset/node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, "app-lib/clients/async_vault": { "version": "0.0.0", "dependencies": { @@ -97,20 +80,6 @@ "typescript": "^5.6.2" } }, - "app-lib/clients/async_vault/node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, "app-lib/clients/compliance": { "version": "0.0.0", "dependencies": { @@ -121,20 +90,6 @@ "typescript": "^5.6.2" } }, - "app-lib/clients/compliance/node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, "app-lib/clients/identity_verifier": { "version": "0.0.0", "dependencies": { @@ -145,20 +100,6 @@ "typescript": "^5.6.2" } }, - "app-lib/clients/identity_verifier/node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, "app-lib/clients/nav_oracle": { "version": "0.0.0", "dependencies": { @@ -169,20 +110,6 @@ "typescript": "^5.6.2" } }, - "app-lib/clients/nav_oracle/node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, "app-lib/clients/share_token": { "version": "0.0.0", "dependencies": { @@ -193,7 +120,7 @@ "typescript": "^5.6.2" } }, - "app-lib/clients/share_token/node_modules/typescript": { + "app-lib/node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", @@ -207,20 +134,6 @@ "node": ">=14.17" } }, - "app-lib/node_modules/typescript": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", - "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, "app/node_modules/typescript": { "version": "6.0.3", "dev": true, diff --git a/package.json b/package.json index 50e7a198..6b99681c 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,8 @@ "seed": "npm run seed -w scripts/harness --", "pretest": "node scripts/clients-guard.mjs && node scripts/generate-addresses.mjs", "test": "vitest run", - "typecheck": "npm run typecheck -w app -w app-lib -w scripts/harness" + "pretypecheck": "node scripts/clients-guard.mjs && node scripts/generate-addresses.mjs", + "typecheck": "npm run typecheck -w app -w scripts/harness" }, "workspaces": [ "app", diff --git a/scripts/clients-guard.mjs b/scripts/clients-guard.mjs index 5bccc337..c48de5b8 100644 --- a/scripts/clients-guard.mjs +++ b/scripts/clients-guard.mjs @@ -2,8 +2,8 @@ import { dirname, resolve } from "node:path" import { fileURLToPath } from "node:url" import { missingClients } from "./lib/clients.mjs" -// Runs as the root `pretest` hook. The tests import the generated contract -// clients, so without them every test file fails on a module it cannot resolve. +// Runs before the root `test` and `typecheck`. Both import the generated +// contract clients, so without them they fail on modules they cannot resolve. const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..") const missing = missingClients(repoRoot) @@ -11,7 +11,7 @@ if (missing.length > 0) { console.error( [ "", - "Cannot test — the generated contract clients are missing:", + "The generated contract clients are missing:", "", ...missing.map((name) => ` - app-lib/clients/${name}`), "", From c9c75ec40856625a4275da3a3997a15a43765e76 Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Mon, 2 Nov 2026 13:16:22 -0300 Subject: [PATCH 05/18] test(app): fake the contract clients with one typed helper Every test that mocked `config/clients` built its own client object by hand. They now mock it with one module whose fakes take their call names, arguments and results from the generated clients, so each test names the calls it uses and a wrong name or result shape fails the type check. The stricter types caught a few fixtures that no longer matched the contracts: enum values, pending changes and holder arguments. Refs #420 --- app/src/hooks/useActivateWindDown.test.ts | 13 +- app/src/hooks/useAdminAuthority.test.ts | 57 +++-- app/src/hooks/useAdminTransaction.test.ts | 119 +++++---- app/src/hooks/useAttest.test.ts | 13 +- app/src/hooks/useCancelDeposit.test.ts | 14 +- app/src/hooks/useCancelRedeem.test.ts | 14 +- app/src/hooks/useClaimDeposit.test.ts | 14 +- app/src/hooks/useClaimRedeem.test.ts | 14 +- app/src/hooks/useClaimWindDown.test.ts | 9 +- app/src/hooks/useCloseEpoch.test.ts | 13 +- app/src/hooks/useComplianceState.test.ts | 29 +-- app/src/hooks/useConfigurationState.test.ts | 34 ++- app/src/hooks/useCycleState.test.ts | 71 +++--- app/src/hooks/useDeployToCustodian.test.ts | 13 +- app/src/hooks/useDepositBalance.test.ts | 6 +- app/src/hooks/useEpochHistory.test.ts | 10 +- .../hooks/useFinalizeWindDownRound.test.ts | 13 +- app/src/hooks/useFulfillEpoch.test.ts | 13 +- app/src/hooks/useFund.test.ts | 13 +- app/src/hooks/useGovernanceState.test.ts | 28 +-- app/src/hooks/useHolderFrozen.test.ts | 12 +- app/src/hooks/useInvestorRequests.test.ts | 17 +- app/src/hooks/useIsAllowed.test.ts | 17 +- app/src/hooks/useNavPrice.test.ts | 7 +- app/src/hooks/useOracleState.test.ts | 9 +- app/src/hooks/useRequestDeposit.test.ts | 14 +- app/src/hooks/useRequestRedeem.test.ts | 14 +- app/src/hooks/useSharePosition.test.ts | 6 +- app/src/hooks/useSignPending.test.ts | 29 +-- app/src/hooks/useTokenPaused.test.ts | 9 +- app/src/hooks/useTokenSymbols.test.ts | 8 +- app/src/hooks/useVaultAuthorities.test.ts | 19 +- app/src/hooks/useVaultPaused.test.ts | 10 +- app/src/hooks/useWindDownActive.test.ts | 12 +- app/src/hooks/useWindDownClaimable.test.ts | 14 +- app/src/pages/SignPage.test.tsx | 12 +- app/src/pages/VaultPreview.test.tsx | 228 ++++++++++-------- app/src/test/fakeClients.test.ts | 79 ++++++ app/src/test/fakeClients.ts | 90 +++++++ 39 files changed, 613 insertions(+), 503 deletions(-) create mode 100644 app/src/test/fakeClients.test.ts create mode 100644 app/src/test/fakeClients.ts diff --git a/app/src/hooks/useActivateWindDown.test.ts b/app/src/hooks/useActivateWindDown.test.ts index 06ab2ecc..d13af049 100644 --- a/app/src/hooks/useActivateWindDown.test.ts +++ b/app/src/hooks/useActivateWindDown.test.ts @@ -1,18 +1,17 @@ import { AssembledTransaction } from "@stellar/stellar-sdk/contract" import { act, waitFor } from "@testing-library/react" import { beforeEach, describe, expect, it, vi } from "vitest" +import { + asyncVaultWriter as asyncVaultWriterMock, + fakeAsyncVault, +} from "../test/fakeClients" import { deferred, renderWithWallet } from "./testSupport" import { useActivateWindDown } from "./useActivateWindDown" import { cycleWriteKeys } from "./useCycleState" -const { vaultMock, asyncVaultWriterMock } = vi.hoisted(() => ({ - vaultMock: { activate_wind_down: vi.fn() }, - asyncVaultWriterMock: vi.fn(), -})) +vi.mock("../config/clients", () => import("../test/fakeClients")) -vi.mock("../config/clients", () => ({ - asyncVaultWriter: asyncVaultWriterMock, -})) +const vaultMock = fakeAsyncVault("activate_wind_down") const renderActivateWindDown = () => renderWithWallet(useActivateWindDown) diff --git a/app/src/hooks/useAdminAuthority.test.ts b/app/src/hooks/useAdminAuthority.test.ts index 3901e0df..fc8fc7f8 100644 --- a/app/src/hooks/useAdminAuthority.test.ts +++ b/app/src/hooks/useAdminAuthority.test.ts @@ -3,6 +3,13 @@ import { waitFor } from "@testing-library/react" import { beforeEach, describe, expect, it, vi } from "vitest" import { addresses } from "../config/addresses" import { vaultContractId } from "../config/contracts" +import { + fakeAsyncVault, + fakeNavOracle, + fakeShareToken, + navOracle as navOracleMock, + shareToken as shareTokenMock, +} from "../test/fakeClients" import { pendingAdminsQuery } from "./pendingAdmins" import { deferred, renderWithWallet, investorAddress } from "./testSupport" import { @@ -299,49 +306,39 @@ describe("toSurfaces", () => { }) const { - vaultMock, - oracleMock, - tokenMock, connectNavOracleMock, connectShareTokenMock, - navOracleMock, - shareTokenMock, accountSignersMock, readPendingAdminsMock, readContractAdminMock, } = vi.hoisted(() => ({ - vaultMock: { - governance: vi.fn(), - get_role_member_count: vi.fn(), - get_role_member: vi.fn(), - oracle: vi.fn(), - share_token: vi.fn(), - }, - oracleMock: { - get_role_member_count: vi.fn(), - get_role_member: vi.fn(), - get_admin: vi.fn(), - }, - tokenMock: { - get_role_member_count: vi.fn(), - get_role_member: vi.fn(), - get_admin: vi.fn(), - identity_verifier: vi.fn(), - }, connectNavOracleMock: vi.fn(), connectShareTokenMock: vi.fn(), - navOracleMock: vi.fn(), - shareTokenMock: vi.fn(), accountSignersMock: vi.fn(), readPendingAdminsMock: vi.fn(), readContractAdminMock: vi.fn(), })) -vi.mock("../config/clients", () => ({ - asyncVault: async () => vaultMock, - navOracle: navOracleMock, - shareToken: shareTokenMock, -})) +vi.mock("../config/clients", () => import("../test/fakeClients")) + +const vaultMock = fakeAsyncVault( + "governance", + "get_role_member_count", + "get_role_member", + "oracle", + "share_token", +) +const oracleMock = fakeNavOracle( + "get_role_member_count", + "get_role_member", + "get_admin", +) +const tokenMock = fakeShareToken( + "get_role_member_count", + "get_role_member", + "get_admin", + "identity_verifier", +) vi.mock("@stellar-scaffold/app-lib", async (importOriginal) => { const actual = await importOriginal() diff --git a/app/src/hooks/useAdminTransaction.test.ts b/app/src/hooks/useAdminTransaction.test.ts index 68771092..0dc1059a 100644 --- a/app/src/hooks/useAdminTransaction.test.ts +++ b/app/src/hooks/useAdminTransaction.test.ts @@ -4,6 +4,14 @@ import type * as AppLib from "@stellar-scaffold/app-lib" import { OPERATOR_TIMEOUT_SECONDS } from "@stellar-scaffold/app-lib" import { act, waitFor } from "@testing-library/react" import { beforeEach, describe, expect, it, vi } from "vitest" +import { + asyncVaultWriter as asyncVaultWriterMock, + fakeAsyncVault, + fakeIdentityVerifier, + fakeNavOracle, + fakeShareToken, + identityVerifierWriter as verifierWriterMock, +} from "../test/fakeClients" import { governanceReadsKey } from "./pendingAdmins" import { deferred, investorAddress, renderWithWallet } from "./testSupport" import { adminAuthorityKey, type Grant } from "./useAdminAuthority" @@ -80,65 +88,9 @@ import { tokenPausedKey } from "./useTokenPaused" import { vaultAuthoritiesKey } from "./useVaultAuthorities" import { vaultPausedKey } from "./useVaultPaused" -const { - vault, - token, - oracle, - verifier, - verifierWriterMock, - asyncVaultWriterMock, - submitSignedMock, - decodePendingMock, -} = vi.hoisted(() => ({ - asyncVaultWriterMock: vi.fn(), +const { submitSignedMock, decodePendingMock } = vi.hoisted(() => ({ submitSignedMock: vi.fn(), decodePendingMock: vi.fn(), - vault: { - pause: vi.fn(), - unpause: vi.fn(), - set_deposit_cap: vi.fn(), - set_custodian: vi.fn(), - set_notice: vi.fn(), - set_wind_down_delay: vi.fn(), - grant_role: vi.fn(), - revoke_role: vi.fn(), - transfer_admin_role: vi.fn(), - accept_admin_transfer: vi.fn(), - propose_change: vi.fn(), - apply_change: vi.fn(), - cancel_change: vi.fn(), - propose_wind_down: vi.fn(), - cancel_wind_down_proposal: vi.fn(), - }, - token: { - pause: vi.fn(), - unpause: vi.fn(), - set_compliance: vi.fn(), - set_identity_verifier: vi.fn(), - grant_role: vi.fn(), - revoke_role: vi.fn(), - transfer_admin_role: vi.fn(), - accept_admin_transfer: vi.fn(), - set_address_frozen: vi.fn(), - freeze_partial_tokens: vi.fn(), - unfreeze_partial_tokens: vi.fn(), - legal_transfer: vi.fn(), - propose_change: vi.fn(), - apply_change: vi.fn(), - cancel_change: vi.fn(), - }, - verifier: { allow: vi.fn() }, - verifierWriterMock: vi.fn(), - oracle: { - pause_nav: vi.fn(), - set_nav_paused: vi.fn(), - clear_latest: vi.fn(), - set_config: vi.fn(), - grant_role: vi.fn(), - revoke_role: vi.fn(), - transfer_admin_role: vi.fn(), - accept_admin_transfer: vi.fn(), - }, })) vi.mock("@stellar-scaffold/app-lib", async (importOriginal) => ({ @@ -147,12 +99,53 @@ vi.mock("@stellar-scaffold/app-lib", async (importOriginal) => ({ decodePending: decodePendingMock, })) -vi.mock("../config/clients", () => ({ - asyncVaultWriter: asyncVaultWriterMock, - shareTokenWriter: async () => token, - navOracleWriter: async () => oracle, - identityVerifierWriter: verifierWriterMock, -})) +vi.mock("../config/clients", () => import("../test/fakeClients")) + +const vault = fakeAsyncVault( + "pause", + "unpause", + "set_deposit_cap", + "set_custodian", + "set_notice", + "set_wind_down_delay", + "grant_role", + "revoke_role", + "transfer_admin_role", + "accept_admin_transfer", + "propose_change", + "apply_change", + "cancel_change", + "propose_wind_down", + "cancel_wind_down_proposal", +) +const token = fakeShareToken( + "pause", + "unpause", + "set_compliance", + "set_identity_verifier", + "grant_role", + "revoke_role", + "transfer_admin_role", + "accept_admin_transfer", + "set_address_frozen", + "freeze_partial_tokens", + "unfreeze_partial_tokens", + "legal_transfer", + "propose_change", + "apply_change", + "cancel_change", +) +const verifier = fakeIdentityVerifier("allow") +const oracle = fakeNavOracle( + "pause_nav", + "set_nav_paused", + "clear_latest", + "set_config", + "grant_role", + "revoke_role", + "transfer_admin_role", + "accept_admin_transfer", +) const CONTRACT = "CCONTRACTADDRESS1234567890" const ORACLE_CONFIG = { diff --git a/app/src/hooks/useAttest.test.ts b/app/src/hooks/useAttest.test.ts index 65cd994c..9a099e76 100644 --- a/app/src/hooks/useAttest.test.ts +++ b/app/src/hooks/useAttest.test.ts @@ -3,19 +3,18 @@ import { AssembledTransaction } from "@stellar/stellar-sdk/contract" import { type Price } from "@stellar-scaffold/app-lib" import { act, waitFor } from "@testing-library/react" import { beforeEach, describe, expect, it, vi } from "vitest" +import { + navOracleWriter as navOracleWriterMock, + fakeNavOracle, +} from "../test/fakeClients" import { deferred, investorAddress, renderWithWallet } from "./testSupport" import { attestWriteKeys, useAttest } from "./useAttest" import { investorRequestsKey } from "./useInvestorRequests" import { navLatestKey } from "./useNavPrice" -const { oracleMock, navOracleWriterMock } = vi.hoisted(() => ({ - oracleMock: { attest: vi.fn() }, - navOracleWriterMock: vi.fn(), -})) +vi.mock("../config/clients", () => import("../test/fakeClients")) -vi.mock("../config/clients", () => ({ - navOracleWriter: navOracleWriterMock, -})) +const oracleMock = fakeNavOracle("attest") const attestation = { price: 1_040_000_000_000_000_000n as Price, diff --git a/app/src/hooks/useCancelDeposit.test.ts b/app/src/hooks/useCancelDeposit.test.ts index 8d2884f5..21f89a3e 100644 --- a/app/src/hooks/useCancelDeposit.test.ts +++ b/app/src/hooks/useCancelDeposit.test.ts @@ -1,20 +1,18 @@ import { AssembledTransaction } from "@stellar/stellar-sdk/contract" import { act, waitFor } from "@testing-library/react" import { beforeEach, describe, expect, it, vi } from "vitest" +import { + asyncVaultWriter as asyncVaultWriterMock, + fakeAsyncVault, +} from "../test/fakeClients" import { deferred, investorAddress, renderWithWallet } from "./testSupport" import { useCancelDeposit } from "./useCancelDeposit" import { depositBalanceKey } from "./useDepositBalance" import { investorRequestsKey } from "./useInvestorRequests" -const { vaultMock, asyncVaultWriterMock } = vi.hoisted(() => ({ - vaultMock: { cancel_deposit: vi.fn() }, - asyncVaultWriterMock: vi.fn(), -})) -asyncVaultWriterMock.mockResolvedValue(vaultMock) +vi.mock("../config/clients", () => import("../test/fakeClients")) -vi.mock("../config/clients", () => ({ - asyncVaultWriter: asyncVaultWriterMock, -})) +const vaultMock = fakeAsyncVault("cancel_deposit") const epochId = 3n const otherEpochId = 4n diff --git a/app/src/hooks/useCancelRedeem.test.ts b/app/src/hooks/useCancelRedeem.test.ts index 7b41d9d8..5cd791b5 100644 --- a/app/src/hooks/useCancelRedeem.test.ts +++ b/app/src/hooks/useCancelRedeem.test.ts @@ -2,6 +2,10 @@ import { nativeToScVal } from "@stellar/stellar-sdk" import { AssembledTransaction } from "@stellar/stellar-sdk/contract" import { act, waitFor } from "@testing-library/react" import { beforeEach, describe, expect, it, vi } from "vitest" +import { + asyncVaultWriter as asyncVaultWriterMock, + fakeAsyncVault, +} from "../test/fakeClients" import { contractEvent, deferred, @@ -13,15 +17,9 @@ import { useCancelRedeem } from "./useCancelRedeem" import { investorRequestsKey } from "./useInvestorRequests" import { sharePositionKey } from "./useSharePosition" -const { vaultMock, asyncVaultWriterMock } = vi.hoisted(() => ({ - vaultMock: { cancel_redeem: vi.fn() }, - asyncVaultWriterMock: vi.fn(), -})) -asyncVaultWriterMock.mockResolvedValue(vaultMock) +vi.mock("../config/clients", () => import("../test/fakeClients")) -vi.mock("../config/clients", () => ({ - asyncVaultWriter: asyncVaultWriterMock, -})) +const vaultMock = fakeAsyncVault("cancel_redeem") const epochId = 3n const otherEpochId = 4n diff --git a/app/src/hooks/useClaimDeposit.test.ts b/app/src/hooks/useClaimDeposit.test.ts index f519f16f..8b3b5250 100644 --- a/app/src/hooks/useClaimDeposit.test.ts +++ b/app/src/hooks/useClaimDeposit.test.ts @@ -2,6 +2,10 @@ import { nativeToScVal } from "@stellar/stellar-sdk" import { AssembledTransaction } from "@stellar/stellar-sdk/contract" import { act, waitFor } from "@testing-library/react" import { beforeEach, describe, expect, it, vi } from "vitest" +import { + asyncVaultWriter as asyncVaultWriterMock, + fakeAsyncVault, +} from "../test/fakeClients" import { contractEvent, deferred, @@ -14,15 +18,9 @@ import { depositBalanceKey } from "./useDepositBalance" import { investorRequestsKey } from "./useInvestorRequests" import { sharePositionKey } from "./useSharePosition" -const { vaultMock, asyncVaultWriterMock } = vi.hoisted(() => ({ - vaultMock: { claim_deposit: vi.fn() }, - asyncVaultWriterMock: vi.fn(), -})) -asyncVaultWriterMock.mockResolvedValue(vaultMock) +vi.mock("../config/clients", () => import("../test/fakeClients")) -vi.mock("../config/clients", () => ({ - asyncVaultWriter: asyncVaultWriterMock, -})) +const vaultMock = fakeAsyncVault("claim_deposit") const epochId = 3n diff --git a/app/src/hooks/useClaimRedeem.test.ts b/app/src/hooks/useClaimRedeem.test.ts index 7e6f1b63..4a6f6e6e 100644 --- a/app/src/hooks/useClaimRedeem.test.ts +++ b/app/src/hooks/useClaimRedeem.test.ts @@ -2,6 +2,10 @@ import { xdr } from "@stellar/stellar-sdk" import { AssembledTransaction } from "@stellar/stellar-sdk/contract" import { act, waitFor } from "@testing-library/react" import { beforeEach, describe, expect, it, vi } from "vitest" +import { + asyncVaultWriter as asyncVaultWriterMock, + fakeAsyncVault, +} from "../test/fakeClients" import { contractEvent, deferred, @@ -14,15 +18,9 @@ import { depositBalanceKey } from "./useDepositBalance" import { investorRequestsKey } from "./useInvestorRequests" import { sharePositionKey } from "./useSharePosition" -const { vaultMock, asyncVaultWriterMock } = vi.hoisted(() => ({ - vaultMock: { claim_redeem: vi.fn() }, - asyncVaultWriterMock: vi.fn(), -})) -asyncVaultWriterMock.mockResolvedValue(vaultMock) +vi.mock("../config/clients", () => import("../test/fakeClients")) -vi.mock("../config/clients", () => ({ - asyncVaultWriter: asyncVaultWriterMock, -})) +const vaultMock = fakeAsyncVault("claim_redeem") const epochId = 3n diff --git a/app/src/hooks/useClaimWindDown.test.ts b/app/src/hooks/useClaimWindDown.test.ts index 4f94ed60..dff9e533 100644 --- a/app/src/hooks/useClaimWindDown.test.ts +++ b/app/src/hooks/useClaimWindDown.test.ts @@ -1,6 +1,7 @@ import { nativeToScVal } from "@stellar/stellar-sdk" import { act } from "@testing-library/react" import { beforeEach, describe, expect, it, vi } from "vitest" +import { fakeAsyncVault } from "../test/fakeClients" import { contractEvent, investorAddress, @@ -10,13 +11,9 @@ import { import { useClaimWindDown } from "./useClaimWindDown" import { windDownClaimableKey } from "./useWindDownClaimable" -const { vaultMock } = vi.hoisted(() => ({ - vaultMock: { claim_wind_down: vi.fn() }, -})) +vi.mock("../config/clients", () => import("../test/fakeClients")) -vi.mock("../config/clients", () => ({ - asyncVaultWriter: async () => vaultMock, -})) +const vaultMock = fakeAsyncVault("claim_wind_down") const claimed = (surrendered: bigint, assets: bigint) => contractEvent( diff --git a/app/src/hooks/useCloseEpoch.test.ts b/app/src/hooks/useCloseEpoch.test.ts index 4691e904..253a067b 100644 --- a/app/src/hooks/useCloseEpoch.test.ts +++ b/app/src/hooks/useCloseEpoch.test.ts @@ -1,19 +1,18 @@ import { AssembledTransaction } from "@stellar/stellar-sdk/contract" import { act, waitFor } from "@testing-library/react" import { beforeEach, describe, expect, it, vi } from "vitest" +import { + asyncVaultWriter as asyncVaultWriterMock, + fakeAsyncVault, +} from "../test/fakeClients" import { deferred, investorAddress, renderWithWallet } from "./testSupport" import { useCloseEpoch } from "./useCloseEpoch" import { cycleWriteKeys } from "./useCycleState" import { investorRequestsKey } from "./useInvestorRequests" -const { vaultMock, asyncVaultWriterMock } = vi.hoisted(() => ({ - vaultMock: { close_epoch: vi.fn() }, - asyncVaultWriterMock: vi.fn(), -})) +vi.mock("../config/clients", () => import("../test/fakeClients")) -vi.mock("../config/clients", () => ({ - asyncVaultWriter: asyncVaultWriterMock, -})) +const vaultMock = fakeAsyncVault("close_epoch") const renderCloseEpoch = () => renderWithWallet(useCloseEpoch) diff --git a/app/src/hooks/useComplianceState.test.ts b/app/src/hooks/useComplianceState.test.ts index a341b774..c5891187 100644 --- a/app/src/hooks/useComplianceState.test.ts +++ b/app/src/hooks/useComplianceState.test.ts @@ -1,22 +1,17 @@ import { waitFor } from "@testing-library/react" import { beforeEach, describe, expect, it, vi } from "vitest" +import { + fakeIdentityVerifier, + fakeShareToken, + identityVerifierAt as identityVerifierAtMock, +} from "../test/fakeClients" import { renderWithWallet } from "./testSupport" import { useComplianceState } from "./useComplianceState" -const { tokenMock, verifierMock, identityVerifierAtMock } = vi.hoisted(() => ({ - tokenMock: { - is_frozen: vi.fn(), - balance: vi.fn(), - get_frozen_tokens: vi.fn(), - }, - verifierMock: { is_allowed: vi.fn() }, - identityVerifierAtMock: vi.fn(), -})) +vi.mock("../config/clients", () => import("../test/fakeClients")) -vi.mock("../config/clients", () => ({ - shareToken: async () => tokenMock, - identityVerifierAt: identityVerifierAtMock, -})) +const tokenMock = fakeShareToken("is_frozen", "balance", "get_frozen_tokens") +const verifierMock = fakeIdentityVerifier("is_allowed") const VERIFIER = "CVERIFIERADDRESS" const TARGET = "GTARGETADDRESS1234" @@ -26,11 +21,9 @@ describe("useComplianceState", () => { beforeEach(() => { vi.clearAllMocks() identityVerifierAtMock.mockImplementation(async () => verifierMock) - verifierMock.is_allowed.mockImplementation( - async ({ account }: { account: string }) => ({ - result: account === TARGET, - }), - ) + verifierMock.is_allowed.mockImplementation(async ({ account }) => ({ + result: account === TARGET, + })) tokenMock.is_frozen.mockResolvedValue({ result: true }) tokenMock.balance.mockResolvedValue({ result: 100n }) tokenMock.get_frozen_tokens.mockResolvedValue({ result: 40n }) diff --git a/app/src/hooks/useConfigurationState.test.ts b/app/src/hooks/useConfigurationState.test.ts index 345305f2..4a86d283 100644 --- a/app/src/hooks/useConfigurationState.test.ts +++ b/app/src/hooks/useConfigurationState.test.ts @@ -1,31 +1,27 @@ import { waitFor } from "@testing-library/react" import { beforeEach, describe, expect, it, vi } from "vitest" +import { + fakeAsyncVault, + fakeNavOracle, + fakeShareToken, +} from "../test/fakeClients" import { renderWithWallet } from "./testSupport" import { useConfigurationState } from "./useConfigurationState" -const { - vaultMock, - tokenMock, - oracleMock, - useCycleReadsMock, - useVaultAuthoritiesMock, -} = vi.hoisted(() => ({ - vaultMock: { upgrade_delay: vi.fn(), setup_open: vi.fn() }, - tokenMock: { - compliance: vi.fn(), - identity_verifier: vi.fn(), - setup_open: vi.fn(), - }, - oracleMock: { setup_open: vi.fn() }, +const { useCycleReadsMock, useVaultAuthoritiesMock } = vi.hoisted(() => ({ useCycleReadsMock: vi.fn(), useVaultAuthoritiesMock: vi.fn(), })) -vi.mock("../config/clients", () => ({ - asyncVault: async () => vaultMock, - shareToken: async () => tokenMock, - navOracle: async () => oracleMock, -})) +vi.mock("../config/clients", () => import("../test/fakeClients")) + +const vaultMock = fakeAsyncVault("upgrade_delay", "setup_open") +const tokenMock = fakeShareToken( + "compliance", + "identity_verifier", + "setup_open", +) +const oracleMock = fakeNavOracle("setup_open") vi.mock("./useCycleState", async (importOriginal) => ({ ...(await importOriginal()), useCycleReads: useCycleReadsMock, diff --git a/app/src/hooks/useCycleState.test.ts b/app/src/hooks/useCycleState.test.ts index ed89cc20..1d463ec8 100644 --- a/app/src/hooks/useCycleState.test.ts +++ b/app/src/hooks/useCycleState.test.ts @@ -11,6 +11,7 @@ import { import { waitFor } from "@testing-library/react" import { beforeEach, describe, expect, it, vi } from "vitest" import { addresses } from "../config/addresses" +import { fakeAsset, fakeAsyncVault, fakeNavOracle } from "../test/fakeClients" import { renderWithWallet } from "./testSupport" import { toEpoch, @@ -22,43 +23,39 @@ import { } from "./useCycleState" import { type EpochHistoryRead, type EpochRecord } from "./useEpochHistory" -const { vaultMock, oracleMock, assetMock, readEpochsMock, readEventsMock } = - vi.hoisted(() => ({ - vaultMock: { - current_epoch: vi.fn(), - notice: vi.fn(), - free_reserve: vi.fn(), - deposit_cap: vi.fn(), - min_epoch_duration: vi.fn(), - epoch_opened_at: vi.fn(), - wind_down: vi.fn(), - wind_down_delay: vi.fn(), - wind_down_owed: vi.fn(), - wind_down_supply: vi.fn(), - liquid_reserve: vi.fn(), - committed: vi.fn(), - uncovered: vi.fn(), - total_economic_supply: vi.fn(), - net_deployed: vi.fn(), - paused: vi.fn(), - governance: vi.fn(), - manager: vi.fn(), - treasury: vi.fn(), - guardian: vi.fn(), - custodian: vi.fn(), - }, - oracleMock: { state: vi.fn(), latest: vi.fn(), config: vi.fn() }, - assetMock: { balance: vi.fn() }, - readEpochsMock: vi.fn(), - readEventsMock: vi.fn(), - })) - -vi.mock("../config/clients", () => ({ - asyncVault: async () => vaultMock, - navOracle: async () => oracleMock, - asset: async () => assetMock, +const { readEpochsMock, readEventsMock } = vi.hoisted(() => ({ + readEpochsMock: vi.fn(), + readEventsMock: vi.fn(), })) +vi.mock("../config/clients", () => import("../test/fakeClients")) + +const vaultMock = fakeAsyncVault( + "current_epoch", + "notice", + "free_reserve", + "deposit_cap", + "min_epoch_duration", + "epoch_opened_at", + "wind_down", + "wind_down_delay", + "wind_down_owed", + "wind_down_supply", + "liquid_reserve", + "committed", + "uncovered", + "total_economic_supply", + "net_deployed", + "paused", + "governance", + "manager", + "treasury", + "guardian", + "custodian", +) +const oracleMock = fakeNavOracle("state", "latest", "config") +const assetMock = fakeAsset("balance") + vi.mock("@stellar-scaffold/app-lib", async (importOriginal) => ({ ...(await importOriginal()), readEpochs: readEpochsMock, @@ -299,7 +296,9 @@ const resolveVault = () => { vaultMock.treasury.mockResolvedValue({ result: "GTREASURY" }) vaultMock.guardian.mockResolvedValue({ result: "GGUARDIAN" }) vaultMock.custodian.mockResolvedValue({ result: "GCUSTODIAN" }) - oracleMock.state.mockResolvedValue({ result: { tag: "Valid" } }) + oracleMock.state.mockResolvedValue({ + result: { tag: "Valid", values: undefined }, + }) oracleMock.latest.mockResolvedValue({ result: report }) oracleMock.config.mockResolvedValue({ result: config }) assetMock.balance.mockResolvedValue({ result: 150n }) diff --git a/app/src/hooks/useDeployToCustodian.test.ts b/app/src/hooks/useDeployToCustodian.test.ts index 762b23ec..f07bfbd6 100644 --- a/app/src/hooks/useDeployToCustodian.test.ts +++ b/app/src/hooks/useDeployToCustodian.test.ts @@ -2,18 +2,17 @@ import { AssembledTransaction } from "@stellar/stellar-sdk/contract" import { type Amount } from "@stellar-scaffold/app-lib" import { act, waitFor } from "@testing-library/react" import { beforeEach, describe, expect, it, vi } from "vitest" +import { + asyncVaultWriter as asyncVaultWriterMock, + fakeAsyncVault, +} from "../test/fakeClients" import { deferred, investorAddress, renderWithWallet } from "./testSupport" import { cycleWriteKeys } from "./useCycleState" import { useDeployToCustodian } from "./useDeployToCustodian" -const { vaultMock, asyncVaultWriterMock } = vi.hoisted(() => ({ - vaultMock: { deploy_to_custodian: vi.fn() }, - asyncVaultWriterMock: vi.fn(), -})) +vi.mock("../config/clients", () => import("../test/fakeClients")) -vi.mock("../config/clients", () => ({ - asyncVaultWriter: asyncVaultWriterMock, -})) +const vaultMock = fakeAsyncVault("deploy_to_custodian") const assets = 2_500_000_000n as Amount diff --git a/app/src/hooks/useDepositBalance.test.ts b/app/src/hooks/useDepositBalance.test.ts index 74033d22..d3082853 100644 --- a/app/src/hooks/useDepositBalance.test.ts +++ b/app/src/hooks/useDepositBalance.test.ts @@ -2,11 +2,7 @@ import { type Amount, type ContractRead } from "@stellar-scaffold/app-lib" import { describe, expect, it, vi } from "vitest" import { classifyDepositBalance, depositBalanceKey } from "./useDepositBalance" -vi.mock("../config/clients", () => ({ - asset: async () => ({ - balance: async () => ({}), - }), -})) +vi.mock("../config/clients", () => import("../test/fakeClients")) const zero: ContractRead = { kind: "value", value: 0n } const held: ContractRead = { kind: "value", value: 500_0000000n } diff --git a/app/src/hooks/useEpochHistory.test.ts b/app/src/hooks/useEpochHistory.test.ts index b153d825..2b9bafef 100644 --- a/app/src/hooks/useEpochHistory.test.ts +++ b/app/src/hooks/useEpochHistory.test.ts @@ -6,17 +6,17 @@ import { import type * as AppLib from "@stellar-scaffold/app-lib" import { waitFor } from "@testing-library/react" import { beforeEach, describe, expect, it, vi } from "vitest" +import { fakeAsyncVault } from "../test/fakeClients" import { renderWithWallet } from "./testSupport" import { fetchEpochHistory, useEpochHistory } from "./useEpochHistory" -const { vaultMock, asyncVaultMock, readEpochsMock } = vi.hoisted(() => ({ - vaultMock: { current_epoch: vi.fn() }, - asyncVaultMock: vi.fn(), +const { readEpochsMock } = vi.hoisted(() => ({ readEpochsMock: vi.fn(), })) -asyncVaultMock.mockResolvedValue(vaultMock) -vi.mock("../config/clients", () => ({ asyncVault: asyncVaultMock })) +vi.mock("../config/clients", () => import("../test/fakeClients")) + +const vaultMock = fakeAsyncVault("current_epoch") vi.mock("@stellar-scaffold/app-lib", async (importOriginal) => ({ ...(await importOriginal()), diff --git a/app/src/hooks/useFinalizeWindDownRound.test.ts b/app/src/hooks/useFinalizeWindDownRound.test.ts index 90e647fd..329c5f06 100644 --- a/app/src/hooks/useFinalizeWindDownRound.test.ts +++ b/app/src/hooks/useFinalizeWindDownRound.test.ts @@ -1,18 +1,17 @@ import { AssembledTransaction } from "@stellar/stellar-sdk/contract" import { act, waitFor } from "@testing-library/react" import { beforeEach, describe, expect, it, vi } from "vitest" +import { + asyncVaultWriter as asyncVaultWriterMock, + fakeAsyncVault, +} from "../test/fakeClients" import { deferred, renderWithWallet } from "./testSupport" import { cycleWriteKeys } from "./useCycleState" import { useFinalizeWindDownRound } from "./useFinalizeWindDownRound" -const { vaultMock, asyncVaultWriterMock } = vi.hoisted(() => ({ - vaultMock: { finalize_wind_down_round: vi.fn() }, - asyncVaultWriterMock: vi.fn(), -})) +vi.mock("../config/clients", () => import("../test/fakeClients")) -vi.mock("../config/clients", () => ({ - asyncVaultWriter: asyncVaultWriterMock, -})) +const vaultMock = fakeAsyncVault("finalize_wind_down_round") const credited = 9_000_000_000n diff --git a/app/src/hooks/useFulfillEpoch.test.ts b/app/src/hooks/useFulfillEpoch.test.ts index c1cb42ce..92a1303c 100644 --- a/app/src/hooks/useFulfillEpoch.test.ts +++ b/app/src/hooks/useFulfillEpoch.test.ts @@ -1,18 +1,17 @@ import { AssembledTransaction } from "@stellar/stellar-sdk/contract" import { act, waitFor } from "@testing-library/react" import { beforeEach, describe, expect, it, vi } from "vitest" +import { + asyncVaultWriter as asyncVaultWriterMock, + fakeAsyncVault, +} from "../test/fakeClients" import { deferred, renderWithWallet } from "./testSupport" import { cycleWriteKeys } from "./useCycleState" import { useFulfillEpoch } from "./useFulfillEpoch" -const { vaultMock, asyncVaultWriterMock } = vi.hoisted(() => ({ - vaultMock: { fulfill_epoch: vi.fn() }, - asyncVaultWriterMock: vi.fn(), -})) +vi.mock("../config/clients", () => import("../test/fakeClients")) -vi.mock("../config/clients", () => ({ - asyncVaultWriter: asyncVaultWriterMock, -})) +const vaultMock = fakeAsyncVault("fulfill_epoch") const epochId = 4n const sharePrice = 1_250_000_000_000_000_000n diff --git a/app/src/hooks/useFund.test.ts b/app/src/hooks/useFund.test.ts index 0b5f1861..3f365508 100644 --- a/app/src/hooks/useFund.test.ts +++ b/app/src/hooks/useFund.test.ts @@ -2,6 +2,10 @@ import { AssembledTransaction } from "@stellar/stellar-sdk/contract" import { type Amount } from "@stellar-scaffold/app-lib" import { act, waitFor } from "@testing-library/react" import { beforeEach, describe, expect, it, vi } from "vitest" +import { + asyncVaultWriter as asyncVaultWriterMock, + fakeAsyncVault, +} from "../test/fakeClients" import { deferred, investorAddress, renderWithWallet } from "./testSupport" import type * as ContractTransactionModule from "./useContractTransaction" import { useContractTransaction } from "./useContractTransaction" @@ -9,14 +13,9 @@ import { cycleWriteKeys } from "./useCycleState" import { depositBalanceKey } from "./useDepositBalance" import { useFund } from "./useFund" -const { vaultMock, asyncVaultWriterMock } = vi.hoisted(() => ({ - vaultMock: { fund: vi.fn() }, - asyncVaultWriterMock: vi.fn(), -})) +vi.mock("../config/clients", () => import("../test/fakeClients")) -vi.mock("../config/clients", () => ({ - asyncVaultWriter: asyncVaultWriterMock, -})) +const vaultMock = fakeAsyncVault("fund") vi.mock("./useContractTransaction", async (importOriginal) => { const actual = await importOriginal() diff --git a/app/src/hooks/useGovernanceState.test.ts b/app/src/hooks/useGovernanceState.test.ts index 86e135a1..0e8c4914 100644 --- a/app/src/hooks/useGovernanceState.test.ts +++ b/app/src/hooks/useGovernanceState.test.ts @@ -2,33 +2,33 @@ import { Buffer } from "buffer" import type * as AppLib from "@stellar-scaffold/app-lib" import { waitFor } from "@testing-library/react" import { beforeEach, describe, expect, it, vi } from "vitest" +import { + fakeAsyncVault, + fakeNavOracle, + fakeShareToken, +} from "../test/fakeClients" import { governanceReadsKey } from "./pendingAdmins" import { renderWithWallet } from "./testSupport" import { useGovernanceState } from "./useGovernanceState" const { readPendingAdminsMock, - vaultMock, - tokenMock, - oracleMock, useCycleReadsMock, useConfigurationStateMock, useVaultPausedMock, } = vi.hoisted(() => ({ readPendingAdminsMock: vi.fn(), - vaultMock: { pending_changes: vi.fn() }, - tokenMock: { pending_changes: vi.fn() }, - oracleMock: { pending_changes: vi.fn() }, useCycleReadsMock: vi.fn(), useConfigurationStateMock: vi.fn(), useVaultPausedMock: vi.fn(), })) -vi.mock("../config/clients", () => ({ - asyncVault: async () => vaultMock, - shareToken: async () => tokenMock, - navOracle: async () => oracleMock, -})) +vi.mock("../config/clients", () => import("../test/fakeClients")) + +const vaultMock = fakeAsyncVault("pending_changes") +const tokenMock = fakeShareToken("pending_changes") +const oracleMock = fakeNavOracle("pending_changes") + vi.mock("./useCycleState", async (importOriginal) => ({ ...(await importOriginal()), useCycleReads: useCycleReadsMock, @@ -176,7 +176,7 @@ describe("useGovernanceState", () => { }) it("reads every pending change as the vault reports it", async () => { - const pending = [ + const pending: AppLib.VaultPendingChange[] = [ { id: 2, change: { tag: "Upgrade", values: [Buffer.alloc(32, 0xab)] }, @@ -215,7 +215,7 @@ describe("useGovernanceState", () => { }) it("reads the share token's own queue and setup state on the vault's clock", async () => { - const pending = [ + const pending: AppLib.TokenPendingChange[] = [ { id: 1, change: { tag: "Compliance", values: ["CCOMPLIANCE"] }, @@ -245,7 +245,7 @@ describe("useGovernanceState", () => { }) it("reads the oracle's own queue and setup state on the vault's clock", async () => { - const pending = [ + const pending: AppLib.OraclePendingChange[] = [ { id: 1, change: { tag: "Admin", values: ["GNEXT"] }, eta: 8n }, ] oracleMock.pending_changes.mockResolvedValue({ result: pending }) diff --git a/app/src/hooks/useHolderFrozen.test.ts b/app/src/hooks/useHolderFrozen.test.ts index 02dfaea6..58b3b112 100644 --- a/app/src/hooks/useHolderFrozen.test.ts +++ b/app/src/hooks/useHolderFrozen.test.ts @@ -1,17 +1,13 @@ import { waitFor } from "@testing-library/react" import { describe, expect, it, vi } from "vitest" +import { fakeShareToken } from "../test/fakeClients" import { investorAddress, renderWithWallet } from "./testSupport" import { useHolderFrozen } from "./useHolderFrozen" -const { isFrozenMock } = vi.hoisted(() => ({ - isFrozenMock: vi.fn(async (_args: { user_address: string }) => ({ - result: true, - })), -})) +vi.mock("../config/clients", () => import("../test/fakeClients")) -vi.mock("../config/clients", () => ({ - shareToken: async () => ({ is_frozen: isFrozenMock }), -})) +const { is_frozen: isFrozenMock } = fakeShareToken("is_frozen") +isFrozenMock.mockResolvedValue({ result: true }) describe("useHolderFrozen", () => { it("asks the share token whether the connected wallet is frozen", async () => { diff --git a/app/src/hooks/useInvestorRequests.test.ts b/app/src/hooks/useInvestorRequests.test.ts index 79adcb69..07885a1d 100644 --- a/app/src/hooks/useInvestorRequests.test.ts +++ b/app/src/hooks/useInvestorRequests.test.ts @@ -18,6 +18,10 @@ import { WalletContext, type WalletContextType, } from "../providers/WalletProvider" +import { + asyncVault as asyncVaultMock, + fakeAsyncVault, +} from "../test/fakeClients" import { classifyRequest, fetchInvestorRequests, @@ -25,18 +29,13 @@ import { useInvestorRequests, } from "./useInvestorRequests" -const { vaultMock, asyncVaultMock, readEpochRequestsMock } = vi.hoisted(() => ({ - vaultMock: { - current_epoch: vi.fn(), - }, - asyncVaultMock: vi.fn(), +const { readEpochRequestsMock } = vi.hoisted(() => ({ readEpochRequestsMock: vi.fn(), })) -asyncVaultMock.mockResolvedValue(vaultMock) -vi.mock("../config/clients", () => ({ - asyncVault: asyncVaultMock, -})) +vi.mock("../config/clients", () => import("../test/fakeClients")) + +const vaultMock = fakeAsyncVault("current_epoch") vi.mock("@stellar-scaffold/app-lib", async (importOriginal) => ({ ...(await importOriginal()), diff --git a/app/src/hooks/useIsAllowed.test.ts b/app/src/hooks/useIsAllowed.test.ts index 02740870..b7953681 100644 --- a/app/src/hooks/useIsAllowed.test.ts +++ b/app/src/hooks/useIsAllowed.test.ts @@ -3,6 +3,11 @@ import { QueryClientProvider, useQuery } from "@tanstack/react-query" import { renderHook, waitFor } from "@testing-library/react" import { createElement, type ReactNode } from "react" import { beforeEach, describe, expect, it, vi } from "vitest" +import { + fakeIdentityVerifier, + fakeShareToken, + identityVerifierAt as identityVerifierAtMock, +} from "../test/fakeClients" import { investorAddress, renderWithWallet } from "./testSupport" import { classifyAllowance, @@ -10,16 +15,10 @@ import { useIsAllowed, } from "./useIsAllowed" -const { tokenMock, verifierMock, identityVerifierAtMock } = vi.hoisted(() => ({ - tokenMock: { identity_verifier: vi.fn() }, - verifierMock: { is_allowed: vi.fn() }, - identityVerifierAtMock: vi.fn(), -})) +vi.mock("../config/clients", () => import("../test/fakeClients")) -vi.mock("../config/clients", () => ({ - shareToken: async () => tokenMock, - identityVerifierAt: identityVerifierAtMock, -})) +const tokenMock = fakeShareToken("identity_verifier") +const verifierMock = fakeIdentityVerifier("is_allowed") const allowed: ContractRead = { kind: "value", value: true } const notAllowed: ContractRead = { kind: "value", value: false } diff --git a/app/src/hooks/useNavPrice.test.ts b/app/src/hooks/useNavPrice.test.ts index 0afca360..34661f81 100644 --- a/app/src/hooks/useNavPrice.test.ts +++ b/app/src/hooks/useNavPrice.test.ts @@ -7,12 +7,7 @@ import { import { describe, expect, it, vi } from "vitest" import { classifyNav, type NavClassification } from "./useNavPrice" -vi.mock("../config/clients", () => ({ - navOracle: async () => ({ - state: async () => ({}), - latest: async () => ({}), - }), -})) +vi.mock("../config/clients", () => import("../test/fakeClients")) const valid: ContractRead = { kind: "value", diff --git a/app/src/hooks/useOracleState.test.ts b/app/src/hooks/useOracleState.test.ts index ecfbe479..373db073 100644 --- a/app/src/hooks/useOracleState.test.ts +++ b/app/src/hooks/useOracleState.test.ts @@ -1,16 +1,13 @@ import { type ContractRead, type OracleState } from "@stellar-scaffold/app-lib" import { waitFor } from "@testing-library/react" import { beforeEach, describe, expect, it, vi } from "vitest" +import { fakeNavOracle } from "../test/fakeClients" import { renderWithWallet } from "./testSupport" import { classifyOracle, useOracleState } from "./useOracleState" -const { oracleMock } = vi.hoisted(() => ({ - oracleMock: { state: vi.fn() }, -})) +vi.mock("../config/clients", () => import("../test/fakeClients")) -vi.mock("../config/clients", () => ({ - navOracle: async () => oracleMock, -})) +const oracleMock = fakeNavOracle("state") const tagged = (tag: OracleState["tag"]): ContractRead => ({ kind: "value", diff --git a/app/src/hooks/useRequestDeposit.test.ts b/app/src/hooks/useRequestDeposit.test.ts index 63865ea6..3ed75e6d 100644 --- a/app/src/hooks/useRequestDeposit.test.ts +++ b/app/src/hooks/useRequestDeposit.test.ts @@ -2,20 +2,18 @@ import { AssembledTransaction } from "@stellar/stellar-sdk/contract" import { AMOUNT_DECIMALS, type Amount } from "@stellar-scaffold/app-lib" import { act, waitFor } from "@testing-library/react" import { beforeEach, describe, expect, it, vi } from "vitest" +import { + asyncVaultWriter as asyncVaultWriterMock, + fakeAsyncVault, +} from "../test/fakeClients" import { deferred, investorAddress, renderWithWallet } from "./testSupport" import { depositBalanceKey } from "./useDepositBalance" import { investorRequestsKey } from "./useInvestorRequests" import { useRequestDeposit } from "./useRequestDeposit" -const { vaultMock, asyncVaultWriterMock } = vi.hoisted(() => ({ - vaultMock: { request_deposit: vi.fn() }, - asyncVaultWriterMock: vi.fn(), -})) -asyncVaultWriterMock.mockResolvedValue(vaultMock) +vi.mock("../config/clients", () => import("../test/fakeClients")) -vi.mock("../config/clients", () => ({ - asyncVaultWriter: asyncVaultWriterMock, -})) +const vaultMock = fakeAsyncVault("request_deposit") const amount = (100n * 10n ** BigInt(AMOUNT_DECIMALS)) as Amount diff --git a/app/src/hooks/useRequestRedeem.test.ts b/app/src/hooks/useRequestRedeem.test.ts index 06ad9952..24547da3 100644 --- a/app/src/hooks/useRequestRedeem.test.ts +++ b/app/src/hooks/useRequestRedeem.test.ts @@ -2,20 +2,18 @@ import { AssembledTransaction } from "@stellar/stellar-sdk/contract" import { AMOUNT_DECIMALS, type Amount } from "@stellar-scaffold/app-lib" import { act, waitFor } from "@testing-library/react" import { beforeEach, describe, expect, it, vi } from "vitest" +import { + asyncVaultWriter as asyncVaultWriterMock, + fakeAsyncVault, +} from "../test/fakeClients" import { deferred, investorAddress, renderWithWallet } from "./testSupport" import { investorRequestsKey } from "./useInvestorRequests" import { useRequestRedeem } from "./useRequestRedeem" import { sharePositionKey } from "./useSharePosition" -const { vaultMock, asyncVaultWriterMock } = vi.hoisted(() => ({ - vaultMock: { request_redeem: vi.fn() }, - asyncVaultWriterMock: vi.fn(), -})) -asyncVaultWriterMock.mockResolvedValue(vaultMock) +vi.mock("../config/clients", () => import("../test/fakeClients")) -vi.mock("../config/clients", () => ({ - asyncVaultWriter: asyncVaultWriterMock, -})) +const vaultMock = fakeAsyncVault("request_redeem") const shares = (100n * 10n ** BigInt(AMOUNT_DECIMALS)) as Amount diff --git a/app/src/hooks/useSharePosition.test.ts b/app/src/hooks/useSharePosition.test.ts index 939f6d4e..0ea4f8e6 100644 --- a/app/src/hooks/useSharePosition.test.ts +++ b/app/src/hooks/useSharePosition.test.ts @@ -2,11 +2,7 @@ import { type ContractRead, type Amount } from "@stellar-scaffold/app-lib" import { describe, expect, it, vi } from "vitest" import { classifyShares } from "./useSharePosition" -vi.mock("../config/clients", () => ({ - shareToken: async () => ({ - balance: async () => ({}), - }), -})) +vi.mock("../config/clients", () => import("../test/fakeClients")) const zero: ContractRead = { kind: "value", value: 0n } const held: ContractRead = { kind: "value", value: 500_0000000n } diff --git a/app/src/hooks/useSignPending.test.ts b/app/src/hooks/useSignPending.test.ts index c2165c16..4307f97d 100644 --- a/app/src/hooks/useSignPending.test.ts +++ b/app/src/hooks/useSignPending.test.ts @@ -14,6 +14,11 @@ import { WalletContext, type WalletContextType, } from "../providers/WalletProvider" +import { + fakeAsyncVault, + fakeNavOracle, + fakeShareToken, +} from "../test/fakeClients" import { useSignPending } from "./useSignPending" const { @@ -23,9 +28,6 @@ const { authorizePendingMock, prepareCoSignedMock, latestLedgerMock, - tokenMock, - vaultMock, - oracleMock, linkTarget, } = vi.hoisted(() => ({ accountSignersMock: vi.fn(), @@ -34,22 +36,17 @@ const { authorizePendingMock: vi.fn(), prepareCoSignedMock: vi.fn(), latestLedgerMock: vi.fn(), - tokenMock: { - identity_verifier: vi.fn(), - pending_changes: vi.fn(), - get_admin: vi.fn(), - txFromXDR: vi.fn(), - }, - vaultMock: { pending_changes: vi.fn() }, - oracleMock: { pending_changes: vi.fn() }, linkTarget: { contractId: "", expiresAt: new Date(0) }, })) -vi.mock("../config/clients", () => ({ - shareToken: async () => tokenMock, - asyncVault: async () => vaultMock, - navOracle: async () => oracleMock, -})) +vi.mock("../config/clients", () => import("../test/fakeClients")) + +const tokenMock = Object.assign( + fakeShareToken("identity_verifier", "pending_changes", "get_admin"), + { txFromXDR: vi.fn() }, +) +const vaultMock = fakeAsyncVault("pending_changes") +const oracleMock = fakeNavOracle("pending_changes") type Marker = { signedBy: string[] } diff --git a/app/src/hooks/useTokenPaused.test.ts b/app/src/hooks/useTokenPaused.test.ts index c872ac14..c74db943 100644 --- a/app/src/hooks/useTokenPaused.test.ts +++ b/app/src/hooks/useTokenPaused.test.ts @@ -1,15 +1,12 @@ import { waitFor } from "@testing-library/react" import { beforeEach, describe, expect, it, vi } from "vitest" +import { fakeShareToken } from "../test/fakeClients" import { renderWithWallet } from "./testSupport" import { useTokenPaused } from "./useTokenPaused" -const { tokenMock } = vi.hoisted(() => ({ - tokenMock: { paused: vi.fn() }, -})) +vi.mock("../config/clients", () => import("../test/fakeClients")) -vi.mock("../config/clients", () => ({ - shareToken: async () => tokenMock, -})) +const tokenMock = fakeShareToken("paused") describe("useTokenPaused", () => { beforeEach(() => { diff --git a/app/src/hooks/useTokenSymbols.test.ts b/app/src/hooks/useTokenSymbols.test.ts index a12b55ee..3182061a 100644 --- a/app/src/hooks/useTokenSymbols.test.ts +++ b/app/src/hooks/useTokenSymbols.test.ts @@ -2,13 +2,7 @@ import { type ContractRead } from "@stellar-scaffold/app-lib" import { describe, expect, it, vi } from "vitest" import { toSymbols } from "./useTokenSymbols" -vi.mock("../config/clients", () => ({ - asset: async () => ({ symbol: async () => ({}) }), - shareToken: async () => ({ - symbol: async () => ({}), - name: async () => ({}), - }), -})) +vi.mock("../config/clients", () => import("../test/fakeClients")) const value = (result: string): ContractRead => ({ kind: "value", diff --git a/app/src/hooks/useVaultAuthorities.test.ts b/app/src/hooks/useVaultAuthorities.test.ts index 98b70f32..0b95d0f7 100644 --- a/app/src/hooks/useVaultAuthorities.test.ts +++ b/app/src/hooks/useVaultAuthorities.test.ts @@ -1,19 +1,18 @@ import { waitFor } from "@testing-library/react" import { beforeEach, describe, expect, it, vi } from "vitest" +import { fakeAsyncVault } from "../test/fakeClients" import { renderWithWallet } from "./testSupport" import { useVaultAuthorities } from "./useVaultAuthorities" -const { vaultMock } = vi.hoisted(() => ({ - vaultMock: { - governance: vi.fn(), - manager: vi.fn(), - treasury: vi.fn(), - guardian: vi.fn(), - custodian: vi.fn(), - }, -})) +vi.mock("../config/clients", () => import("../test/fakeClients")) -vi.mock("../config/clients", () => ({ asyncVault: async () => vaultMock })) +const vaultMock = fakeAsyncVault( + "governance", + "manager", + "treasury", + "guardian", + "custodian", +) const settled = async () => { const rendered = renderWithWallet(useVaultAuthorities) diff --git a/app/src/hooks/useVaultPaused.test.ts b/app/src/hooks/useVaultPaused.test.ts index 6b72e97f..adeb4910 100644 --- a/app/src/hooks/useVaultPaused.test.ts +++ b/app/src/hooks/useVaultPaused.test.ts @@ -3,19 +3,17 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query" import { act, renderHook, waitFor } from "@testing-library/react" import { createElement, type ReactNode } from "react" import { afterEach, describe, expect, it, vi } from "vitest" +import { fakeAsyncVault } from "../test/fakeClients" import { classifyPause, PAUSE_REFRESH_MS, useVaultPaused, } from "./useVaultPaused" -const { pausedMock } = vi.hoisted(() => ({ - pausedMock: vi.fn(async () => ({ result: false })), -})) +vi.mock("../config/clients", () => import("../test/fakeClients")) -vi.mock("../config/clients", () => ({ - asyncVault: async () => ({ paused: pausedMock }), -})) +const { paused: pausedMock } = fakeAsyncVault("paused") +pausedMock.mockResolvedValue({ result: false }) const setPageVisibility = (state: DocumentVisibilityState) => { Object.defineProperty(document, "visibilityState", { diff --git a/app/src/hooks/useWindDownActive.test.ts b/app/src/hooks/useWindDownActive.test.ts index 4d999cef..a4fbd5db 100644 --- a/app/src/hooks/useWindDownActive.test.ts +++ b/app/src/hooks/useWindDownActive.test.ts @@ -1,18 +1,14 @@ import { type WindDownInfo } from "@stellar-scaffold/app-lib" import { waitFor } from "@testing-library/react" import { describe, expect, it, vi } from "vitest" +import { fakeAsyncVault } from "../test/fakeClients" import { renderWithWallet } from "./testSupport" import { useWindDownActive } from "./useWindDownActive" -const { windDownMock } = vi.hoisted(() => ({ - windDownMock: vi.fn(async (): Promise<{ result: WindDownInfo | null }> => ({ - result: null, - })), -})) +vi.mock("../config/clients", () => import("../test/fakeClients")) -vi.mock("../config/clients", () => ({ - asyncVault: async () => ({ wind_down: windDownMock }), -})) +const { wind_down: windDownMock } = fakeAsyncVault("wind_down") +windDownMock.mockResolvedValue({ result: null }) const info = (tag: "Proposed" | "Active"): WindDownInfo => ({ active_at: 1_700_000_000n, diff --git a/app/src/hooks/useWindDownClaimable.test.ts b/app/src/hooks/useWindDownClaimable.test.ts index 9f928149..42acdc31 100644 --- a/app/src/hooks/useWindDownClaimable.test.ts +++ b/app/src/hooks/useWindDownClaimable.test.ts @@ -1,17 +1,15 @@ import { waitFor } from "@testing-library/react" import { describe, expect, it, vi } from "vitest" +import { fakeAsyncVault } from "../test/fakeClients" import { investorAddress, renderWithWallet } from "./testSupport" import { useWindDownClaimable } from "./useWindDownClaimable" -const { claimableMock } = vi.hoisted(() => ({ - claimableMock: vi.fn(async (_args: { holder: string }) => ({ - result: 25_0000000n, - })), -})) +vi.mock("../config/clients", () => import("../test/fakeClients")) -vi.mock("../config/clients", () => ({ - asyncVault: async () => ({ wind_down_claimable: claimableMock }), -})) +const { wind_down_claimable: claimableMock } = fakeAsyncVault( + "wind_down_claimable", +) +claimableMock.mockResolvedValue({ result: 25_0000000n }) describe("useWindDownClaimable", () => { it("reads what the wind-down would pay the connected wallet now", async () => { diff --git a/app/src/pages/SignPage.test.tsx b/app/src/pages/SignPage.test.tsx index b91e585a..dd421574 100644 --- a/app/src/pages/SignPage.test.tsx +++ b/app/src/pages/SignPage.test.tsx @@ -20,6 +20,7 @@ import { WalletContext, type WalletContextType, } from "../providers/WalletProvider" +import { fakeShareToken } from "../test/fakeClients" import SignPage from "./SignPage" const { accountSignersMock, submitSignedMock, transactionStatusMock } = @@ -31,12 +32,11 @@ const { accountSignersMock, submitSignedMock, transactionStatusMock } = const tokenAdmin = "GTOKENADMINACCOUNT" -vi.mock("../config/clients", () => ({ - shareToken: async () => ({ - identity_verifier: async () => ({ result: "CLIVEVERIFIER" }), - get_admin: async () => ({ result: tokenAdmin }), - }), -})) +vi.mock("../config/clients", () => import("../test/fakeClients")) + +const token = fakeShareToken("identity_verifier", "get_admin") +token.identity_verifier.mockResolvedValue({ result: "CLIVEVERIFIER" }) +token.get_admin.mockResolvedValue({ result: tokenAdmin }) type Marker = { signedBy: string[] } diff --git a/app/src/pages/VaultPreview.test.tsx b/app/src/pages/VaultPreview.test.tsx index d4d98648..5d08d113 100644 --- a/app/src/pages/VaultPreview.test.tsx +++ b/app/src/pages/VaultPreview.test.tsx @@ -1,4 +1,5 @@ import { Buffer } from "buffer" +import { type Address } from "@stellar/stellar-sdk" import { AssembledTransaction } from "@stellar/stellar-sdk/contract" import { formatDate, @@ -18,6 +19,13 @@ import { WalletContext, type WalletContextType, } from "../providers/WalletProvider" +import { + fakeAsset, + fakeAsyncVault, + fakeIdentityVerifier, + fakeNavOracle, + fakeShareToken, +} from "../test/fakeClients" import VaultPreview from "./VaultPreview" const { @@ -30,14 +38,7 @@ const { mockVault, mockFigures, mockIdentity, - requestDepositMock, - cancelDepositMock, - claimDepositMock, - requestRedeemMock, - cancelRedeemMock, - claimRedeemMock, readEpochRequestsMock, - claimWindDownMock, } = vi.hoisted(() => ({ mockVaultId: "CMOCKVAULTADDRESS1234567890", mockGovernanceAddress: "GGOVERNANCEADDRESS1234567890", @@ -86,14 +87,7 @@ const { { shares: bigint; claimed: boolean } | undefined >(), }, - requestDepositMock: vi.fn(), - cancelDepositMock: vi.fn(), - claimDepositMock: vi.fn(), - requestRedeemMock: vi.fn(), - cancelRedeemMock: vi.fn(), - claimRedeemMock: vi.fn(), readEpochRequestsMock: vi.fn(), - claimWindDownMock: vi.fn(), })) vi.mock("@stellar-scaffold/app-lib", async (importOriginal) => ({ @@ -104,7 +98,7 @@ vi.mock("@stellar-scaffold/app-lib", async (importOriginal) => ({ const defaultCancelDepositImpl = async ({ epoch_id, }: { - holder: string + holder: string | Address epoch_id: bigint }) => { const request = mockRequests.deposits.get(epoch_id) @@ -129,7 +123,7 @@ const defaultCancelDepositImpl = async ({ const defaultRequestDepositImpl = async ({ amount, }: { - holder: string + holder: string | Address amount: bigint }) => { const epochId = mockRequests.currentEpoch @@ -150,7 +144,7 @@ const defaultRequestDepositImpl = async ({ const defaultRequestRedeemImpl = async ({ shares, }: { - holder: string + holder: string | Address shares: bigint }) => { const epochId = mockRequests.currentEpoch @@ -171,7 +165,7 @@ const defaultRequestRedeemImpl = async ({ const defaultCancelRedeemImpl = async ({ epoch_id, }: { - holder: string + holder: string | Address epoch_id: bigint }) => { const request = mockRequests.redeems.get(epoch_id) @@ -198,7 +192,7 @@ const WAD_SCALE = 1_000000000000000000n const defaultClaimDepositImpl = async ({ epoch_id, }: { - holder: string + holder: string | Address epoch_id: bigint }) => { const request = mockRequests.deposits.get(epoch_id) @@ -227,7 +221,7 @@ const defaultClaimDepositImpl = async ({ const defaultClaimRedeemImpl = async ({ epoch_id, }: { - holder: string + holder: string | Address epoch_id: bigint }) => { const request = mockRequests.redeems.get(epoch_id) @@ -326,86 +320,120 @@ vi.mock("../config/contracts", () => { return { vaultContractId: mockVaultId, contractRows } }) -vi.mock("../config/clients", () => { - const figure = (result: bigint) => async () => ({ result }) - const address = (result: string | null | undefined) => async () => ({ - result, - }) - const vault = { - liquid_reserve: figure(184000000000n), - committed: figure(62000000000n), - uncovered: async () => { - if (mockFigures.uncovered === null) throw new Error("boom") - return { result: mockFigures.uncovered } - }, - total_economic_supply: figure(100000000000000n), - net_deployed: figure(50000000000n), - governance: address(mockGovernanceAddress), - manager: address(undefined), - treasury: address(undefined), - guardian: address(undefined), - custodian: address(null), - current_epoch: async () => { - if (!mockRequests.readable) throw new Error("boom") - return { result: mockRequests.currentEpoch } - }, - paused: async () => ({ result: mockVault.paused }), - wind_down: async () => ({ - result: mockVault.windingDown - ? { - active_at: 1_700_000_000n, - round: 0, - status: { tag: "Active", values: undefined }, - } - : null, - }), - wind_down_claimable: async () => ({ - result: mockVault.windDownClaimable, - }), - claim_wind_down: claimWindDownMock, - request_deposit: requestDepositMock, - cancel_deposit: cancelDepositMock, - claim_deposit: claimDepositMock, - request_redeem: requestRedeemMock, - cancel_redeem: cancelRedeemMock, - claim_redeem: claimRedeemMock, - } - const oracle = { - state: async () => ({ result: { tag: "Valid", values: undefined } }), - latest: async () => ({ - result: { - nav_per_share: 1500000000000000000n, - expires_at: 1800000000n, - accepted_at: 1757900000n, - proof_hash: Buffer.alloc(32, 0xab), - proof_uri: "", - }, - }), - } - const identity = { - is_allowed: async () => ({ result: mockIdentity.allowed }), - } - const shares = { - balance: async () => ({ result: mockShares.balance }), - symbol: async () => ({ result: mockSymbols.shareToken }), - name: async () => ({ result: mockSymbols.vaultName }), - identity_verifier: async () => ({ result: "CVERIFIER" }), - is_frozen: async () => ({ result: mockIdentity.frozen }), - } - const depositAsset = { - symbol: async () => ({ result: mockSymbols.token }), - balance: async () => ({ result: mockDeposit.balance }), - } +vi.mock("../config/clients", () => import("../test/fakeClients")) - return { - asyncVault: async () => vault, - asyncVaultWriter: async () => vault, - navOracle: async () => oracle, - identityVerifierAt: async () => identity, - shareToken: async () => shares, - asset: async () => depositAsset, - } +const figure = (result: bigint) => async () => ({ result }) +const address = (result: string | null | undefined) => async () => ({ + result, }) +const vault = fakeAsyncVault( + "liquid_reserve", + "committed", + "uncovered", + "total_economic_supply", + "net_deployed", + "governance", + "manager", + "treasury", + "guardian", + "custodian", + "current_epoch", + "paused", + "wind_down", + "wind_down_claimable", + "claim_wind_down", + "request_deposit", + "cancel_deposit", + "claim_deposit", + "request_redeem", + "cancel_redeem", + "claim_redeem", +) +vault.liquid_reserve.mockImplementation(figure(184000000000n)) +vault.committed.mockImplementation(figure(62000000000n)) +vault.uncovered.mockImplementation(async () => { + if (mockFigures.uncovered === null) throw new Error("boom") + return { result: mockFigures.uncovered } +}) +vault.total_economic_supply.mockImplementation(figure(100000000000000n)) +vault.net_deployed.mockImplementation(figure(50000000000n)) +vault.governance.mockImplementation(address(mockGovernanceAddress)) +vault.manager.mockImplementation(address(undefined)) +vault.treasury.mockImplementation(address(undefined)) +vault.guardian.mockImplementation(address(undefined)) +vault.custodian.mockImplementation(address(null)) +vault.current_epoch.mockImplementation(async () => { + if (!mockRequests.readable) throw new Error("boom") + return { result: mockRequests.currentEpoch } +}) +vault.paused.mockImplementation(async () => ({ result: mockVault.paused })) +vault.wind_down.mockImplementation(async () => ({ + result: mockVault.windingDown + ? { + active_at: 1_700_000_000n, + round: 0, + status: { tag: "Active", values: undefined }, + } + : null, +})) +vault.wind_down_claimable.mockImplementation(async () => ({ + result: mockVault.windDownClaimable, +})) +const { + claim_wind_down: claimWindDownMock, + request_deposit: requestDepositMock, + cancel_deposit: cancelDepositMock, + claim_deposit: claimDepositMock, + request_redeem: requestRedeemMock, + cancel_redeem: cancelRedeemMock, + claim_redeem: claimRedeemMock, +} = vault + +const oracle = fakeNavOracle("state", "latest") +oracle.state.mockImplementation(async () => ({ + result: { tag: "Valid", values: undefined }, +})) +oracle.latest.mockImplementation(async () => ({ + result: { + nav_per_share: 1500000000000000000n, + expires_at: 1800000000n, + accepted_at: 1757900000n, + proof_hash: Buffer.alloc(32, 0xab), + proof_uri: "", + }, +})) + +const identity = fakeIdentityVerifier("is_allowed") +identity.is_allowed.mockImplementation(async () => ({ + result: mockIdentity.allowed, +})) + +const shares = fakeShareToken( + "balance", + "symbol", + "name", + "identity_verifier", + "is_frozen", +) +shares.balance.mockImplementation(async () => ({ result: mockShares.balance })) +shares.symbol.mockImplementation(async () => ({ + result: mockSymbols.shareToken, +})) +shares.name.mockImplementation(async () => ({ result: mockSymbols.vaultName })) +shares.identity_verifier.mockImplementation(async () => ({ + result: "CVERIFIER", +})) +shares.is_frozen.mockImplementation(async () => ({ + result: mockIdentity.frozen, +})) + +const depositAsset = fakeAsset("symbol", "balance") +depositAsset.symbol.mockImplementation(async () => ({ + result: mockSymbols.token, +})) +depositAsset.balance.mockImplementation(async () => ({ + result: mockDeposit.balance, +})) const investorAddress = "GINVESTORADDRESS1234567890" @@ -976,7 +1004,7 @@ describe("VaultPreview", () => { resolveSend = resolve }) requestDepositMock.mockImplementationOnce( - async ({ amount }: { holder: string; amount: bigint }) => ({ + async ({ amount }: { holder: string | Address; amount: bigint }) => ({ simulation: undefined, signAndSend: async ({ watcher, @@ -1025,7 +1053,7 @@ describe("VaultPreview", () => { resolveSend = resolve }) requestDepositMock.mockImplementationOnce( - async ({ amount }: { holder: string; amount: bigint }) => ({ + async ({ amount }: { holder: string | Address; amount: bigint }) => ({ simulation: undefined, signAndSend: async ({ watcher, diff --git a/app/src/test/fakeClients.test.ts b/app/src/test/fakeClients.test.ts new file mode 100644 index 00000000..e766db89 --- /dev/null +++ b/app/src/test/fakeClients.test.ts @@ -0,0 +1,79 @@ +import { type Address } from "@stellar/stellar-sdk" +import { describe, expect, expectTypeOf, it, vi } from "vitest" +import type * as Clients from "../config/clients" +import * as fakeClients from "./fakeClients" +import { + asset, + asyncVault, + asyncVaultWriter, + fakeAsset, + fakeAsyncVault, + fakeIdentityVerifier, + fakeNavOracle, + fakeShareToken, + identityVerifierAt, + identityVerifierWriter, + navOracle, + navOracleWriter, + shareToken, + shareTokenWriter, +} from "./fakeClients" + +const signer = { publicKey: "GSIGNER", signTransaction: vi.fn() } + +describe("fake clients", () => { + it("fakes every client config/clients hands out", async () => { + const real = await vi.importActual("../config/clients") + + for (const name of Object.keys(real)) { + expect(fakeClients).toHaveProperty(name) + } + }) + + it("gives each named call its own mock and nothing else", () => { + const vault = fakeAsyncVault("current_epoch", "cancel_deposit") + + expect(Object.keys(vault)).toEqual(["current_epoch", "cancel_deposit"]) + expect(vi.isMockFunction(vault.current_epoch)).toBe(true) + expect(vault.current_epoch).not.toBe(vault.cancel_deposit) + }) + + it("hands the last fake of each contract to its reader and writer", async () => { + const vault = fakeAsyncVault("paused") + const token = fakeShareToken("paused") + const oracle = fakeNavOracle("state") + const usdc = fakeAsset("balance") + const verifier = fakeIdentityVerifier("is_allowed") + + expect(await asyncVault()).toBe(vault) + expect(await asyncVaultWriter(signer)).toBe(vault) + expect(await shareToken()).toBe(token) + expect(await shareTokenWriter(signer)).toBe(token) + expect(await navOracle()).toBe(oracle) + expect(await navOracleWriter(signer)).toBe(oracle) + expect(await asset()).toBe(usdc) + expect(await identityVerifierAt("CVERIFIER")).toBe(verifier) + expect(await identityVerifierWriter("CVERIFIER")(signer)).toBe(verifier) + }) + + it("types each call by the generated client", () => { + const vault = fakeAsyncVault("current_epoch", "cancel_deposit") + const token = fakeShareToken("identity_verifier") + + vault.current_epoch.mockResolvedValue({ result: 3n }) + token.identity_verifier.mockResolvedValue({ result: "CVERIFIER" }) + expectTypeOf(vault.cancel_deposit).parameter(0).toEqualTypeOf<{ + holder: string | Address + epoch_id: bigint + }>() + + // @ts-expect-error the vault has no such call + fakeAsyncVault("current_epochs") + // @ts-expect-error current_epoch returns a bigint + vault.current_epoch.mockResolvedValue({ result: "3" }) + // @ts-expect-error not a member of an assembled transaction + vault.current_epoch.mockResolvedValue({ results: 3n }) + // @ts-expect-error the call was not declared + void vault.paused + }) +}) diff --git a/app/src/test/fakeClients.ts b/app/src/test/fakeClients.ts new file mode 100644 index 00000000..6d7d0f3e --- /dev/null +++ b/app/src/test/fakeClients.ts @@ -0,0 +1,90 @@ +import { type AssembledTransaction } from "@stellar/stellar-sdk/contract" +import { + type AssetApi, + type AsyncVaultApi, + type IdentityVerifierApi, + type NavOracleApi, + type ShareTokenApi, +} from "@stellar-scaffold/app-lib" +import { vi, type Mock } from "vitest" +import { type WriterSigner as Signer } from "../config/clients" + +type CallName = { + [Name in keyof Client]: Client[Name] extends ( + ...args: never[] + ) => Promise> + ? Name + : never +}[keyof Client] + +// What a faked call resolves to. Only `result` takes its type from the +// contract; the other members are SDK internals that tests stand in for loosely. +export type FakeTransaction = { result?: Result } & { + [Member in Exclude, "result">]?: unknown +} + +type FakeCall = Call extends ( + ...args: infer Args +) => Promise> + ? Mock<(...args: Args) => Promise>> + : never + +export type FakeClient< + Client, + Name extends CallName = CallName, +> = { [N in Name]: FakeCall } + +type AnyFake = Partial> + +const fakes: { + vault: AnyFake + token: AnyFake + oracle: AnyFake + asset: AnyFake + verifier: AnyFake | undefined +} = { vault: {}, token: {}, oracle: {}, asset: {}, verifier: undefined } + +const fakeOf = + () => + >(...names: Name[]): FakeClient => + Object.fromEntries(names.map((name) => [name, vi.fn()])) as FakeClient< + Client, + Name + > + +// Each fake becomes the client `config/clients` hands out for its contract, +// to readers and writers alike, until the next fake of that contract. +export const fakeAsyncVault = >( + ...names: Name[] +) => (fakes.vault = fakeOf()(...names)) + +export const fakeShareToken = >( + ...names: Name[] +) => (fakes.token = fakeOf()(...names)) + +export const fakeNavOracle = >( + ...names: Name[] +) => (fakes.oracle = fakeOf()(...names)) + +export const fakeAsset = >(...names: Name[]) => + (fakes.asset = fakeOf()(...names)) + +export const fakeIdentityVerifier = < + Name extends CallName, +>( + ...names: Name[] +) => (fakes.verifier = fakeOf()(...names)) + +export const asyncVault = vi.fn(async () => fakes.vault) +export const asyncVaultWriter = vi.fn(async (_signer: Signer) => fakes.vault) +export const shareToken = vi.fn(async () => fakes.token) +export const shareTokenWriter = vi.fn(async (_signer: Signer) => fakes.token) +export const navOracle = vi.fn(async () => fakes.oracle) +export const navOracleWriter = vi.fn(async (_signer: Signer) => fakes.oracle) +export const asset = vi.fn(async () => fakes.asset) +export const identityVerifierAt = vi.fn( + async (_address: string) => fakes.verifier, +) +export const identityVerifierWriter = vi.fn( + (_address: string) => async (_signer: Signer) => fakes.verifier, +) From 80acc8e0d2165ac1fdbdc25d7728eba7fd420808 Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Mon, 2 Nov 2026 13:23:15 -0300 Subject: [PATCH 06/18] refactor(app): split TransactionModal into section modules The modal's 2,158-line file held the copy and status text for every action. That content now lives in one module per section (investor, cycle, emergency, configuration, governance, compliance) beside the shared failure text, the admin copy helpers, the step tracker and the action types. `TransactionModal.tsx` keeps the component and `ShareLink`, and re-exports what callers already import from it. The code moved unchanged. Refs #420 --- app/src/components/vault/TransactionModal.tsx | 2006 +---------------- .../vault/transactionModal/adminCopy.ts | 201 ++ .../vault/transactionModal/compliance.ts | 68 + .../vault/transactionModal/configuration.ts | 82 + .../vault/transactionModal/content.ts | 180 ++ .../vault/transactionModal/cycle.ts | 434 ++++ .../vault/transactionModal/emergency.ts | 59 + .../vault/transactionModal/governance.ts | 196 ++ .../vault/transactionModal/investor.ts | 495 ++++ .../vault/transactionModal/outcome.ts | 76 + .../vault/transactionModal/steps.ts | 88 + .../vault/transactionModal/types.ts | 180 ++ 12 files changed, 2077 insertions(+), 1988 deletions(-) create mode 100644 app/src/components/vault/transactionModal/adminCopy.ts create mode 100644 app/src/components/vault/transactionModal/compliance.ts create mode 100644 app/src/components/vault/transactionModal/configuration.ts create mode 100644 app/src/components/vault/transactionModal/content.ts create mode 100644 app/src/components/vault/transactionModal/cycle.ts create mode 100644 app/src/components/vault/transactionModal/emergency.ts create mode 100644 app/src/components/vault/transactionModal/governance.ts create mode 100644 app/src/components/vault/transactionModal/investor.ts create mode 100644 app/src/components/vault/transactionModal/outcome.ts create mode 100644 app/src/components/vault/transactionModal/steps.ts create mode 100644 app/src/components/vault/transactionModal/types.ts diff --git a/app/src/components/vault/TransactionModal.tsx b/app/src/components/vault/TransactionModal.tsx index e017090d..dd82933c 100644 --- a/app/src/components/vault/TransactionModal.tsx +++ b/app/src/components/vault/TransactionModal.tsx @@ -1,1999 +1,29 @@ -import { - AMOUNT_DECIMALS, - explorerTransaction, - formatDate, - formatExact, - formatScaled, - PRICE_DECIMALS, - shortAddress, - type SubmitFailure, -} from "@stellar-scaffold/app-lib" +import { explorerTransaction, shortAddress } from "@stellar-scaffold/app-lib" import React from "react" -import { type ActivateWindDownStatus } from "../../hooks/useActivateWindDown" -import { type AttestStatus } from "../../hooks/useAttest" -import { type CancelDepositStatus } from "../../hooks/useCancelDeposit" -import { type CancelRedeemStatus } from "../../hooks/useCancelRedeem" -import { type ClaimDepositStatus } from "../../hooks/useClaimDeposit" -import { type ClaimRedeemStatus } from "../../hooks/useClaimRedeem" -import { type ClaimWindDownStatus } from "../../hooks/useClaimWindDown" -import { type CloseEpochStatus } from "../../hooks/useCloseEpoch" -import { - type TransactionFailure, - type TransactionStatus, -} from "../../hooks/useContractTransaction" -import { type DeployToCustodianStatus } from "../../hooks/useDeployToCustodian" -import { type FinalizeWindDownRoundStatus } from "../../hooks/useFinalizeWindDownRound" -import { type FulfillEpochStatus } from "../../hooks/useFulfillEpoch" -import { type FundStatus } from "../../hooks/useFund" -import { type RequestDepositStatus } from "../../hooks/useRequestDeposit" -import { type RequestRedeemStatus } from "../../hooks/useRequestRedeem" -import { describeStanding } from "../../pages/actions" -import { type ComplianceActionId } from "../../pages/compliance" -import { EFFECTS } from "../../pages/configuration" -import { type GovernanceActionId } from "../../pages/governance" import typeStyles from "../../styles/type.module.css" import Close from "../icons/Close" import ExternalLink from "../icons/ExternalLink" +import { describeStatus } from "./transactionModal/content" +import { + STEP_ORDER, + stepLabel, + computeSteps, + stepStateClassName, + stepAnnouncement, +} from "./transactionModal/steps" +import { type TransactionModalProps } from "./transactionModal/types" import styles from "./TransactionModal.module.css" -export type EmergencyModalAction = - | "pause-vault" - | "unpause-vault" - | "pause-nav" - | "unpause-nav" - | "clear-latest" - | "pause-token" - | "unpause-token" - -export type ConfigurationModalAction = - | "set-deposit-cap" - | "set-custodian" - | "set-notice" - | "set-wind-down-delay" - | "set-min-epoch-duration" - | "propose-custodian" - | "propose-notice" - | "propose-wind-down-delay" - | "propose-min-epoch-duration" - | "set-oracle-config" - | "propose-oracle-config" - | "set-compliance" - | "propose-compliance" - | "set-identity-verifier" - | "propose-identity-verifier" - -export type GovernanceModalAction = - GovernanceActionId | "propose-oracle-revoke-role" - -export type ComplianceModalAction = ComplianceActionId - -export type AdminModalAction = - | EmergencyModalAction - | ConfigurationModalAction - | GovernanceModalAction - | ComplianceModalAction - -export type TransactionModalProps = - | { - action: EmergencyModalAction - status: TransactionStatus - onClose: () => void - onRetry: () => void - } - | { - action: - ConfigurationModalAction | GovernanceModalAction | ComplianceModalAction - status: TransactionStatus - value: string - onClose: () => void - onRetry: () => void - } - | { - action: "subscribe" - status: RequestDepositStatus - amount: string - ticker: string - onClose: () => void - onRetry: () => void - } - | { - action: "cancel" - status: CancelDepositStatus - amount: string - ticker: string - onClose: () => void - onRetry: () => void - } - | { - action: "cancel-redeem" - status: CancelRedeemStatus - amount: string - ticker: string - assetTicker: string - windingDown?: boolean - onClose: () => void - onRetry: () => void - } - | { - action: "claim" - status: ClaimDepositStatus - amount: string - ticker: string - shareTicker: string - onClose: () => void - onRetry: () => void - } - | { - action: "redeem" - status: RequestRedeemStatus - amount: string - ticker: string - onClose: () => void - onRetry: () => void - } - | { - action: "claim-redeem" - status: ClaimRedeemStatus - amount: string - ticker: string - uncovered?: string - onClose: () => void - onRetry: () => void - } - | { - action: "claim-wind-down" - status: ClaimWindDownStatus - amount: string - ticker: string - shareTicker: string - onClose: () => void - onRetry: () => void - } - | { - action: "close-epoch" - status: CloseEpochStatus - epoch: string - onClose: () => void - onRetry: () => void - } - | { - action: "fulfill-epoch" - status: FulfillEpochStatus - epoch: string - onClose: () => void - onRetry: () => void - } - | { - action: "attest" - status: AttestStatus - price: string - validFor: string | null - onClose: () => void - onRetry: () => void - } - | { - action: "deploy" - status: DeployToCustodianStatus - amount: string - onClose: () => void - onRetry: () => void - } - | { - action: "fund" - status: FundStatus - amount: string - onClose: () => void - onRetry: () => void - } - | { - action: "activate-wind-down" - status: ActivateWindDownStatus - onClose: () => void - onRetry: () => void - } - | { - action: "finalize-round" - status: FinalizeWindDownRoundStatus - round: string - onClose: () => void - onRetry: () => void - } - -const GENERIC_REFUSAL = - "The vault refused this request. Try again, and contact support if it keeps happening." - -export const NOT_ALLOWLISTED = "Your address is not allowlisted." - -const SHARES_NOT_RETURNABLE = - "Your address cannot receive these shares right now, so they stay with the vault." - -const FROZEN = "Your address is frozen." - -export const HELD_WHILE_FROZEN = `${FROZEN} This request is held until the freeze is lifted.` - -const subscribeContractErrorReason = (code: number): string => { - switch (code) { - case 6007: - return "Enter an amount greater than zero." - case 6009: - return "You already have a subscription request open." - case 6014: - return "That amount is too large for the vault to accept." - case 6046: - return "The vault is winding down and is not accepting new subscriptions." - case 6064: - return "The vault's setup is still open. Subscriptions start once it closes." - case 6044: - return "The vault's price feed is not valid right now, so subscriptions are paused until it is." - case 6076: - return "The vault does not hold enough to cover its pending deposits right now, so it is not taking new subscriptions until it is funded again." - case 6068: - return `${NOT_ALLOWLISTED} Only allowlisted addresses can subscribe.` - case 6071: - return `${FROZEN} It cannot subscribe until the freeze is lifted.` - default: - return GENERIC_REFUSAL - } -} - -const cancelContractErrorReason = (code: number): string => { - switch (code) { - case 6001: - return "This request no longer exists to cancel." - case 6029: - return "This request could not be found." - case 6039: - return "This request has already been priced. Claim your shares instead of cancelling." - case 6041: - return "A price is available, so this request can no longer be cancelled. It will be priced shortly, and your shares are claimable once it is." - case 6076: - return "The vault does not hold enough to return this deposit right now. You can cancel once it is funded again." - case 6071: - return HELD_WHILE_FROZEN - default: - return GENERIC_REFUSAL - } -} - -const cancelRedeemContractErrorReason = ( - code: number, - assetTicker: string, -): string => { - const claimInstead = `Once priced, claim the ${assetTicker} it owes you instead.` - switch (code) { - case 6068: - return `${NOT_ALLOWLISTED} These shares cannot come back to you. ${claimInstead}` - case 6073: - return `${SHARES_NOT_RETURNABLE} ${claimInstead}` - default: - return cancelContractErrorReason(code) - } -} - -const claimContractErrorReason = (code: number): string => { - switch (code) { - case 6001: - return "You have no request to claim." - case 6014: - return "The share conversion for this claim is too large to complete." - case 6029: - return "This request could not be found." - case 6031: - return "The vault has not published a valid price for this request yet." - case 6035: - return "This request has already been claimed." - case 6037: - return "The vault does not yet hold enough in reserve to cover this claim. Try again shortly." - case 6071: - return HELD_WHILE_FROZEN - default: - return GENERIC_REFUSAL - } -} - -const redeemContractErrorReason = (code: number): string => { - switch (code) { - case 6007: - return "Enter an amount greater than zero." - case 6009: - return "You already have a redemption request open." - case 6014: - return "That amount is too large for the vault to accept." - case 6029: - return "This request could not be found." - case 6046: - return "The vault is winding down and is not accepting new redemptions." - case 6064: - return "The vault's setup is still open. Redemptions start once it closes." - case 6071: - return "Your address or some of these shares are frozen. Redeem only your free shares, or wait until the freeze is lifted." - default: - return GENERIC_REFUSAL - } -} - -const claimRedeemContractErrorReason = ( - code: number, - ticker: string, - uncovered?: string, -): string => { - switch (code) { - case 6034: - return "This request has not priced yet. Check back after the vault's next update." - case 6037: - return uncovered !== undefined - ? `The vault is ${uncovered} short of covering every priced claim. Try again once the reserve catches up.` - : `The vault does not yet hold enough ${ticker} in reserve to cover this claim. Try again shortly.` - case 6073: - return `The price left nothing to pay out, so the vault tried to return your shares. ${SHARES_NOT_RETURNABLE}` - default: - return claimContractErrorReason(code) - } -} - -const windDownClaimContractErrorReason = (code: number): string => { - switch (code) { - case 6051: - return "The wind-down has not made its first payout yet. Check back once it does." - case 6054: - return "You have nothing to claim from the wind-down right now." - case 6071: - return HELD_WHILE_FROZEN - default: - return GENERIC_REFUSAL - } -} - -const closeEpochContractErrorReason = (code: number): string => { - switch (code) { - case 6046: - return "The vault is winding down, so no more epochs can be sealed." - case 6029: - return "The current epoch could not be found." - case 6032: - return "The current epoch is not open, so there is nothing to seal." - case 2000: - return "This wallet is not authorized to close epochs." - case 2007: - return "This wallet does not hold the manager role." - case 6077: - return "Only the manager can seal this epoch until it has been open for the minimum duration." - case 6064: - return "The vault's setup is still open, so no epoch can be sealed yet." - default: - return GENERIC_REFUSAL - } -} - -const fulfillEpochContractErrorReason = (code: number): string => { - switch (code) { - case 6046: - return "The vault is winding down, so epochs can no longer be priced." - case 6029: - return "This epoch could not be found." - case 6038: - return "This epoch is not sealed, so it cannot be priced." - case 6042: - return "The notice period has not elapsed yet." - case 6044: - return "The oracle price is not valid right now." - case 6043: - return "The latest price was attested before this epoch was sealed. A fresh attestation is needed." - case 6031: - return "The vault could not resolve a valid share price." - case 6014: - return "The settlement total is too large for the vault to record." - case 1000: - return "The vault is paused, so epochs cannot be priced." - case 6064: - return "The vault's setup is still open, so no epoch can be priced yet." - default: - return GENERIC_REFUSAL - } -} - -const attestContractErrorReason = (code: number): string => { - switch (code) { - case 3002: - return "That price is outside the band the oracle accepts." - case 3005: - return "The attestation would already have expired when recorded." - case 3003: - return "The cooldown since the last attestation has not elapsed yet." - case 3004: - return "That price moves further from the last attested price than the oracle allows." - case 3010: - return "Unpause the NAV first." - case 3023: - return "The proof hash cannot be all zeros." - case 3024: - return "The proof URI is longer than 256 bytes." - default: - return GENERIC_REFUSAL - } -} - -const deployContractErrorReason = (code: number): string => { - switch (code) { - case 6046: - return "The vault is winding down, so nothing can leave for the custodian." - case 6007: - return "Enter an amount greater than zero." - case 6012: - return "No custodian is set, so there is nowhere to deploy to." - case 6005: - return "That amount exceeds the free reserve. The rest is committed to priced exits." - case 6014: - return "That amount is too large for the vault to record." - case 2000: - return "This wallet is not authorized to deploy to the custodian." - case 2007: - return "This wallet does not hold the treasury role." - default: - return GENERIC_REFUSAL - } -} - -const fundContractErrorReason = (code: number): string => { - switch (code) { - case 6007: - return "Enter an amount greater than zero." - case 6014: - return "That amount is too large for the vault to record." - default: - return code < 6000 - ? "Your wallet could not cover the transfer. Check the balance and the trustline." - : GENERIC_REFUSAL - } -} - -const activateWindDownContractErrorReason = (code: number): string => { - switch (code) { - case 6047: - return "No wind-down has been proposed, so there is nothing to activate." - case 6046: - return "The wind-down is already active." - case 6049: - return "The wind-down delay has not elapsed yet." - default: - return GENERIC_REFUSAL - } -} - -const finalizeRoundContractErrorReason = (code: number): string => { - switch (code) { - case 6050: - return "The wind-down is not active, so no round can be finalized." - case 6052: - return "There is nothing to distribute: the free reserve is empty or too small to split across holders." - case 6014: - return "The round is too large for the vault to record." - default: - return GENERIC_REFUSAL - } -} - -const INVESTOR_FOLLOW_UP = "check your requests before doing anything else" -const OPERATOR_FOLLOW_UP = "check the Cycle card before trying again" -const EMERGENCY_FOLLOW_UP = "check the Emergency tab before trying again" -const CONFIGURATION_FOLLOW_UP = - "check the Configuration tab before trying again" -const GOVERNANCE_FOLLOW_UP = "check the Governance tab before trying again" -const COMPLIANCE_FOLLOW_UP = "check the Compliance tab before trying again" - -const NETWORK_REASONS: Record, string> = { - expired: "The transaction expired before it was submitted.", - "sequence-consumed": - "The account moved on; this transaction is no longer valid.", - "bad-auth": "The signatures do not match the account's threshold.", - fee: "The account cannot pay the fee.", - "fee-too-low": "The fee is below the network minimum. Propose it again.", - "not-accepted": "The network did not accept it. Try again.", -} - -const unknownOutcome = (followUp: string) => ({ - heading: "Something went wrong", - body: `We could not confirm whether this reached the network. We don't yet know if it went through, so ${followUp}.`, -}) - -const describeFailure = ( - failure: TransactionFailure, - contractErrorReason: (code: number) => string, - followUp = INVESTOR_FOLLOW_UP, - refuser = "vault", -): { heading: string; body: string } => { - switch (failure.kind) { - case "network": - if (failure.reason === "unknown") return unknownOutcome(followUp) - return { - heading: "The network rejected it", - body: NETWORK_REASONS[failure.reason], - } - case "declined": - return { - heading: "You declined the request", - body: "You chose not to sign, so nothing was sent. Try again when you're ready.", - } - case "unsigned": - return { - heading: "The wallet did not sign", - body: "Nothing was sent. Try again when you're ready.", - } - case "cannot-authorize": - return { - heading: "This wallet cannot authorize", - body: "This wallet cannot sign authorizations. Use Freighter, Hana or WalletConnect.", - } - case "contract-error": - return { - heading: `The ${refuser} refused this request`, - body: contractErrorReason(failure.code), - } - case "interrupted": - return { - heading: "We couldn't reach the vault", - body: "Nothing was requested from your wallet. Try again when you're ready.", - } - case "stale": - return { - heading: "The transaction went stale", - body: "The account moved on since it was prepared, so nothing was sent. Try again.", - } - case "unknown": - return unknownOutcome(followUp) - } -} - -const describeShared = (expiresAt: Date | null) => { - const invitation = "Share the link with the next signer." - if (expiresAt === null) return invitation - const seconds = BigInt(Math.floor(expiresAt.getTime() / 1000)) - return `${invitation} Valid until ${formatDate(seconds)}.` -} - -const describeSubscribeStatus = ( - status: RequestDepositStatus, - amount: string, - ticker: string, -): { heading: string; body: string; hash?: string } | undefined => { - switch (status.status) { - case "idle": - return undefined - case "preparing": - return { - heading: "Preparing your request", - body: "We are getting your request ready. Your wallet will ask you to approve it next.", - } - case "awaiting-signature": - return { - heading: "Confirm in your wallet", - body: `Your shares are set once the vault prices your request, not today. Signing locks ${amount} ${ticker} in escrow until then.`, - } - case "submitted": - return { - heading: "Sending your request", - body: "Your request is on its way to the network. This should only take a moment, while pricing comes later and takes longer. Closing this window will not cancel it.", - hash: status.hash, - } - case "confirmed": - return { - heading: "Request submitted", - body: `${amount} ${ticker} prices at the vault's next update.`, - hash: status.hash, - } - case "failed": - return { - ...describeFailure(status.failure, subscribeContractErrorReason), - hash: status.hash, - } - } -} - -const describeCancelStatus = ( - status: CancelDepositStatus, - amount: string, - ticker: string, -): { heading: string; body: string; hash?: string } | undefined => { - switch (status.status) { - case "idle": - return undefined - case "preparing": - return { - heading: "Preparing your cancellation", - body: "We are getting your cancellation ready. Your wallet will ask you to approve it next.", - } - case "awaiting-signature": - return { - heading: "Confirm in your wallet", - body: `Your request is withdrawn, not priced. Signing returns ${amount} ${ticker} from escrow to your wallet.`, - } - case "submitted": - return { - heading: "Sending your cancellation", - body: "Your cancellation is on its way to the network. This should only take a moment. Closing this window will not stop it.", - hash: status.hash, - } - case "confirmed": - return { - heading: "Request cancelled", - body: `${formatExact(status.refundedAmount, AMOUNT_DECIMALS)} ${ticker} has been returned to your wallet.`, - hash: status.hash, - } - case "failed": - return { - ...describeFailure(status.failure, cancelContractErrorReason), - hash: status.hash, - } - } -} - -const describeCancelRedeemStatus = ( - status: CancelRedeemStatus, - amount: string, - ticker: string, - assetTicker: string, - windingDown: boolean, -): { heading: string; body: string; hash?: string } | undefined => { - switch (status.status) { - case "idle": - return undefined - case "preparing": - return { - heading: "Preparing your cancellation", - body: "We are getting your cancellation ready. Your wallet will ask you to approve it next.", - } - case "awaiting-signature": - return { - heading: "Confirm in your wallet", - body: windingDown - ? `Your request is withdrawn, not priced. The vault is winding down, so signing moves ${amount} ${ticker} into your wind-down claim instead of your wallet.` - : `Your request is withdrawn, not priced. Signing returns ${amount} ${ticker} from escrow to your wallet.`, - } - case "submitted": - return { - heading: "Sending your cancellation", - body: "Your cancellation is on its way to the network. This should only take a moment. Closing this window will not stop it.", - hash: status.hash, - } - case "confirmed": - return status.surrendered - ? { - heading: MOVED_TO_WIND_DOWN, - body: "The vault is winding down, so these shares were added to your wind-down claim instead of coming back to your wallet.", - hash: status.hash, - } - : { - heading: "Request cancelled", - body: `${formatExact(status.returnedShares, AMOUNT_DECIMALS)} ${ticker} has been returned to your wallet.`, - hash: status.hash, - } - case "failed": - return { - ...describeFailure(status.failure, (code) => - cancelRedeemContractErrorReason(code, assetTicker), - ), - hash: status.hash, - } - } -} - -const MOVED_TO_WIND_DOWN = "Moved to your wind-down claim" - -const convertedOutcome = ( - queuedInto: bigint, - ticker: string, -): { heading: string; body: string } => - queuedInto === 0n - ? { - heading: MOVED_TO_WIND_DOWN, - body: `${NOT_ALLOWLISTED} Your shares were added to your wind-down claim instead.`, - } - : { - heading: "Queued as a redemption", - body: `${NOT_ALLOWLISTED} Your shares were queued as a redemption instead, and pay out in ${ticker} at the next price.`, - } - -const describeClaimStatus = ( - status: ClaimDepositStatus, - amount: string, - ticker: string, - shareTicker: string, -): { heading: string; body: string; hash?: string } | undefined => { - switch (status.status) { - case "idle": - return undefined - case "preparing": - return { - heading: "Preparing your claim", - body: "We are getting your claim ready. Your wallet will ask you to approve it next.", - } - case "awaiting-signature": - return { - heading: "Confirm in your wallet", - body: `You receive what you're owed. If the price leaves no shares to claim, ${amount} ${ticker} returns to your wallet instead.`, - } - case "submitted": - return { - heading: "Sending your claim", - body: "Your claim is on its way to the network. This should only take a moment. Closing this window will not stop it.", - hash: status.hash, - } - case "confirmed": - if (status.queuedInto !== undefined) - return { - ...convertedOutcome(status.queuedInto, ticker), - hash: status.hash, - } - return status.sharesMinted > 0n - ? { - heading: "Shares claimed", - body: `${formatExact(status.sharesMinted, AMOUNT_DECIMALS)} ${shareTicker} has been added to your wallet.`, - hash: status.hash, - } - : { - heading: "Deposit returned", - body: `The price left no shares to claim, so ${amount} ${ticker} has been returned to your wallet instead.`, - hash: status.hash, - } - case "failed": - return { - ...describeFailure(status.failure, claimContractErrorReason), - hash: status.hash, - } - } -} - -const describeClaimWindDownStatus = ( - status: ClaimWindDownStatus, - amount: string, - ticker: string, - shareTicker: string, -): { heading: string; body: string; hash?: string } | undefined => { - switch (status.status) { - case "idle": - return undefined - case "preparing": - return { - heading: "Preparing your claim", - body: "We are getting your claim ready. Your wallet will ask you to approve it next.", - } - case "awaiting-signature": - return { - heading: "Confirm in your wallet", - body: `You receive ${amount} ${ticker}, your share of the wind-down so far. Signing also turns in your unfrozen ${shareTicker}, so later rounds pay you for them.`, - } - case "submitted": - return { - heading: "Sending your claim", - body: "Your claim is on its way to the network. This should only take a moment. Closing this window will not stop it.", - hash: status.hash, - } - case "confirmed": - return { - ...windDownClaimOutcome(status, ticker, shareTicker), - hash: status.hash, - } - case "failed": - return { - ...describeFailure(status.failure, windDownClaimContractErrorReason), - hash: status.hash, - } - } -} - -const windDownClaimOutcome = ( - { - assetsClaimed, - sharesSurrendered, - }: Extract, - ticker: string, - shareTicker: string, -): { heading: string; body: string } => { - const turnedIn = - sharesSurrendered > 0n - ? `${formatExact(sharesSurrendered, AMOUNT_DECIMALS)} ${shareTicker} were turned in, and later rounds pay you for them.` - : "Later rounds pay what you are owed." - return assetsClaimed > 0n - ? { - heading: `${ticker} claimed`, - body: `${formatExact(assetsClaimed, AMOUNT_DECIMALS)} ${ticker} has been added to your wallet. ${turnedIn}`, - } - : { - heading: "Shares turned in", - body: `Nothing was paid this time. ${turnedIn}`, - } -} - -const describeRedeemStatus = ( - status: RequestRedeemStatus, - amount: string, - ticker: string, -): { heading: string; body: string; hash?: string } | undefined => { - switch (status.status) { - case "idle": - return undefined - case "preparing": - return { - heading: "Preparing your redemption request", - body: "We are getting your redemption request ready. Your wallet will ask you to approve it next.", - } - case "awaiting-signature": - return { - heading: "Confirm in your wallet", - body: `What you're owed is set once the vault prices your request, not today. Signing locks ${amount} ${ticker} in escrow until then.`, - } - case "submitted": - return { - heading: "Sending your redemption request", - body: "Your redemption request is on its way to the network. This should only take a moment, while pricing comes later and takes longer. Closing this window will not cancel it.", - hash: status.hash, - } - case "confirmed": - return { - heading: "Redemption request submitted", - body: `${amount} ${ticker} prices at the vault's next update.`, - hash: status.hash, - } - case "failed": - return { - ...describeFailure(status.failure, redeemContractErrorReason), - hash: status.hash, - } - } -} - -const describeClaimRedeemStatus = ( - status: ClaimRedeemStatus, - amount: string, - ticker: string, - uncovered?: string, -): { heading: string; body: string; hash?: string } | undefined => { - switch (status.status) { - case "idle": - return undefined - case "preparing": - return { - heading: "Preparing your claim", - body: "We are getting your claim ready. Your wallet will ask you to approve it next.", - } - case "awaiting-signature": - return { - heading: "Confirm in your wallet", - body: `You receive what you're owed. Signing sends ${amount} ${ticker} to your wallet.`, - } - case "submitted": - return { - heading: "Sending your claim", - body: "Your claim is on its way to the network. This should only take a moment. Closing this window will not stop it.", - hash: status.hash, - } - case "confirmed": - return status.assetsClaimed > 0n - ? { - heading: `${ticker} claimed`, - body: `${formatExact(status.assetsClaimed, AMOUNT_DECIMALS)} ${ticker} has been added to your wallet.`, - hash: status.hash, - } - : status.sharesReturned - ? { - heading: "Shares returned", - body: "The price left nothing to claim, so your shares have been returned to your wallet instead.", - hash: status.hash, - } - : { - heading: "Nothing to pay out", - body: "The price left nothing to pay out, and the vault is winding down, so these shares were burned.", - hash: status.hash, - } - case "failed": - return { - ...describeFailure(status.failure, (code) => - claimRedeemContractErrorReason(code, ticker, uncovered), - ), - hash: status.hash, - } - } -} - -const describeCloseEpochStatus = ( - status: CloseEpochStatus, - epoch: string, -): { heading: string; body: string; hash?: string } | undefined => { - switch (status.status) { - case "idle": - return undefined - case "preparing": - return { - heading: `Preparing to close epoch ${epoch}`, - body: "We are getting the close ready. Your wallet will ask you to approve it next.", - } - case "awaiting-signature": - return { - heading: "Confirm in your wallet", - body: `Signing seals epoch ${epoch} and opens the next one. Requests in epoch ${epoch} wait for a price.`, - } - case "submitted": - return { - heading: "Sending the close", - body: "The close is on its way to the network. This should only take a moment. Closing this window will not stop it.", - hash: status.hash, - } - case "confirmed": - return { - heading: `Epoch ${status.sealedEpoch} sealed`, - body: `Epoch ${status.sealedEpoch + 1n} is open and accepting requests. Epoch ${status.sealedEpoch} prices once the notice elapses and a valid price is attested.`, - hash: status.hash, - } - case "failed": - return { - ...describeFailure( - status.failure, - closeEpochContractErrorReason, - OPERATOR_FOLLOW_UP, - ), - hash: status.hash, - } - } -} - -const describeFulfillEpochStatus = ( - status: FulfillEpochStatus, - epoch: string, -): { heading: string; body: string; hash?: string } | undefined => { - switch (status.status) { - case "idle": - return undefined - case "preparing": - return { - heading: `Preparing to price epoch ${epoch}`, - body: "We are getting the settlement ready. Your wallet will ask you to approve it next.", - } - case "awaiting-signature": - return { - heading: "Confirm in your wallet", - body: `Signing prices epoch ${epoch} at the oracle's current price and settles its requests.`, - } - case "submitted": - return { - heading: "Sending the settlement", - body: "The settlement is on its way to the network. This should only take a moment. Closing this window will not stop it.", - hash: status.hash, - } - case "confirmed": - return { - heading: `Epoch ${epoch} priced`, - body: `Settled at ${formatScaled(status.sharePrice, PRICE_DECIMALS, 4)} per share. Share claims are ready; redemption claims wait until the reserve covers them.`, - hash: status.hash, - } - case "failed": - return { - ...describeFailure( - status.failure, - fulfillEpochContractErrorReason, - OPERATOR_FOLLOW_UP, - ), - hash: status.hash, - } - } -} - -const describeAttestStatus = ( - status: AttestStatus, - price: string, - validFor: string | null, -): { heading: string; body: string; hash?: string } | undefined => { - switch (status.status) { - case "idle": - return undefined - case "preparing": - return { - heading: `Preparing to attest ${price}`, - body: "We are getting the attestation ready. Your wallet will ask you to approve it next.", - } - case "awaiting-signature": - return { - heading: "Confirm in your wallet", - body: `Signing records ${price} as the share price, with its proof. Sealed epochs settle at it once their notice elapses.`, - } - case "submitted": - return { - heading: "Sending the attestation", - body: "The attestation is on its way to the network. This should only take a moment. Closing this window will not stop it.", - hash: status.hash, - } - case "confirmed": - return { - heading: "Price attested", - body: - validFor === null - ? `${price} is the share price.` - : `${price} is the share price. Valid for ${validFor}.`, - hash: status.hash, - } - case "failed": - return { - ...describeFailure( - status.failure, - attestContractErrorReason, - OPERATOR_FOLLOW_UP, - ), - hash: status.hash, - } - } -} - -const describeDeployStatus = ( - status: DeployToCustodianStatus, - amount: string, -): { heading: string; body: string; hash?: string } | undefined => { - switch (status.status) { - case "idle": - return undefined - case "preparing": - return { - heading: `Preparing to deploy ${amount}`, - body: "We are getting the deployment ready. Your wallet will ask you to approve it next.", - } - case "awaiting-signature": - return { - heading: "Confirm in your wallet", - body: `Signing sends ${amount} from the vault's reserve to the custodian.`, - } - case "submitted": - return { - heading: "Sending the deployment", - body: "The deployment is on its way to the network. This should only take a moment. Closing this window will not stop it.", - hash: status.hash, - } - case "confirmed": - return { - heading: "Deployed", - body: `${amount} left the vault for the custodian.`, - hash: status.hash, - } - case "failed": - return { - ...describeFailure( - status.failure, - deployContractErrorReason, - OPERATOR_FOLLOW_UP, - ), - hash: status.hash, - } - } -} - -const describeFundStatus = ( - status: FundStatus, - amount: string, -): { heading: string; body: string; hash?: string } | undefined => { - switch (status.status) { - case "idle": - return undefined - case "preparing": - return { - heading: `Preparing to fund ${amount}`, - body: "We are getting the funding ready. Your wallet will ask you to approve it next.", - } - case "awaiting-signature": - return { - heading: "Confirm in your wallet", - body: `Signing moves ${amount} from your wallet into the vault's reserve.`, - } - case "submitted": - return { - heading: "Sending the funding", - body: "The funding is on its way to the network. This should only take a moment. Closing this window will not stop it.", - hash: status.hash, - } - case "confirmed": - return { - heading: "Funded", - body: `${amount} joined the reserve.`, - hash: status.hash, - } - case "failed": - return { - ...describeFailure( - status.failure, - fundContractErrorReason, - OPERATOR_FOLLOW_UP, - ), - hash: status.hash, - } - } -} - -const describeActivateWindDownStatus = ( - status: ActivateWindDownStatus, -): { heading: string; body: string; hash?: string } | undefined => { - switch (status.status) { - case "idle": - return undefined - case "preparing": - return { - heading: "Preparing to activate the wind-down", - body: "We are getting the activation ready. Your wallet will ask you to approve it next.", - } - case "awaiting-signature": - return { - heading: "Confirm in your wallet", - body: "Signing stops new requests and opens distribution rounds.", - } - case "submitted": - return { - heading: "Sending the activation", - body: "The activation is on its way to the network. This should only take a moment. Closing this window will not stop it.", - hash: status.hash, - } - case "confirmed": - return { - heading: "Wind-down active", - body: "New requests are closed. Rounds can now distribute the free reserve.", - hash: status.hash, - } - case "failed": - return { - ...describeFailure( - status.failure, - activateWindDownContractErrorReason, - OPERATOR_FOLLOW_UP, - ), - hash: status.hash, - } - } -} - -const describeFinalizeRoundStatus = ( - status: FinalizeWindDownRoundStatus, - round: string, -): { heading: string; body: string; hash?: string } | undefined => { - switch (status.status) { - case "idle": - return undefined - case "preparing": - return { - heading: `Preparing round ${round}`, - body: "We are getting the round ready. Your wallet will ask you to approve it next.", - } - case "awaiting-signature": - return { - heading: "Confirm in your wallet", - body: `Signing distributes the free reserve to holders as round ${round}.`, - } - case "submitted": - return { - heading: "Sending the round", - body: "The round is on its way to the network. This should only take a moment. Closing this window will not stop it.", - hash: status.hash, - } - case "confirmed": - return { - heading: `Round ${round} finalized`, - body: `${formatExact(status.credited, AMOUNT_DECIMALS)} is claimable by holders.`, - hash: status.hash, - } - case "failed": - return { - ...describeFailure( - status.failure, - finalizeRoundContractErrorReason, - OPERATOR_FOLLOW_UP, - ), - hash: status.hash, - } - } -} - -type AdminCopy = { - refuser: "vault" | "share token" | "oracle" | "identity verifier" - reasons?: Partial> - verb: string - signing: string - done: string - doneBody: string -} - -const EMERGENCY_COPY: Record = { - "pause-vault": { - refuser: "vault", - verb: "pause the vault", - signing: "Signing stops new deposits, pricing and upgrades.", - done: "Vault paused", - doneBody: - "New deposits, pricing and upgrades are stopped. Redemptions, claims, cancels and closing epochs stay open.", - }, - "unpause-vault": { - refuser: "vault", - verb: "unpause the vault", - signing: "Signing resumes deposits, pricing and upgrades.", - done: "Vault unpaused", - doneBody: "Deposits, pricing and upgrades are open again.", - }, - "pause-nav": { - refuser: "oracle", - verb: "pause the NAV", - signing: "Signing stops pricing until the NAV is unpaused.", - done: "NAV paused", - doneBody: - "Pricing is stopped. No epoch can be fulfilled until the NAV is unpaused.", - }, - "unpause-nav": { - refuser: "oracle", - verb: "unpause the NAV", - signing: "Signing resumes pricing.", - done: "NAV unpaused", - doneBody: "Epochs can be fulfilled again while the price is valid.", - }, - "clear-latest": { - refuser: "oracle", - verb: "clear the latest price", - signing: "Signing removes the recorded price.", - done: "Latest price cleared", - doneBody: - "The next attestation is not limited by its distance from the last. Pricing stays stopped until the NAV is unpaused.", - }, - "pause-token": { - refuser: "share token", - verb: "pause the share token", - signing: "Signing stops holder transfers only.", - done: "Share token paused", - doneBody: - "Holder transfers are stopped. Minting, burning, the vault's escrow moves and legal transfers continue.", - }, - "unpause-token": { - refuser: "share token", - verb: "unpause the share token", - signing: "Signing resumes holder transfers.", - done: "Share token unpaused", - doneBody: "Holder transfers are open again.", - }, -} - -const setterCopy = - (refuser: AdminCopy["refuser"], subject: string, effect?: string) => - (value: string): AdminCopy => ({ - refuser, - verb: `set the ${subject.toLowerCase()}`, - signing: `New ${subject.toLowerCase()}: ${value}.`, - done: `${subject} updated`, - doneBody: effect ?? `${subject}: ${value}.`, - }) - -const capitalize = (text: string): string => - `${text.charAt(0).toUpperCase()}${text.slice(1)}` - -type TimelockRefuser = Extract< - AdminCopy["refuser"], - "vault" | "share token" | "oracle" -> - -const WAITS: Record = { - vault: "the upgrade delay", - "share token": "the vault's upgrade delay", - oracle: "the vault's upgrade delay", -} - -const proposeCopy = - (refuser: TimelockRefuser, verb: string, subject: string) => - (value: string): AdminCopy => ({ - refuser, - verb, - signing: `Signing queues ${subject} ${value}.`, - done: "Change proposed", - doneBody: `${capitalize(subject)} ${value} applies once ${WAITS[refuser]} passes.`, - }) - -const MIN_EPOCH_DURATION_REASONS = { - 6007: "The minimum epoch duration must be 1 to 30 days.", -} - -const CONFIGURATION_COPY: Record< +export type { + AdminModalAction, + ComplianceModalAction, ConfigurationModalAction, - (value: string) => AdminCopy -> = { - "set-deposit-cap": setterCopy("vault", "Deposit cap"), - "set-custodian": setterCopy("vault", "Custodian", EFFECTS.custodian), - "set-notice": setterCopy("vault", "Notice", EFFECTS.notice), - "set-wind-down-delay": setterCopy( - "vault", - "Wind-down delay", - EFFECTS.windDownDelay, - ), - "set-min-epoch-duration": (value) => ({ - ...setterCopy( - "vault", - "Minimum epoch duration", - EFFECTS.minEpochDuration, - )(value), - reasons: MIN_EPOCH_DURATION_REASONS, - }), - "propose-custodian": proposeCopy("vault", "propose a custodian", "custodian"), - "propose-notice": proposeCopy("vault", "propose a notice", "notice"), - "propose-wind-down-delay": proposeCopy( - "vault", - "propose a wind-down delay", - "wind-down delay", - ), - "propose-min-epoch-duration": (value) => ({ - ...proposeCopy( - "vault", - "propose a minimum epoch duration", - "minimum epoch duration", - )(value), - reasons: MIN_EPOCH_DURATION_REASONS, - }), - "set-oracle-config": () => ({ - refuser: "oracle", - verb: "set the oracle limits", - signing: "Signing replaces every oracle limit.", - done: "Oracle limits updated", - doneBody: "The new limits are recorded.", - }), - "propose-oracle-config": () => ({ - refuser: "oracle", - verb: "propose oracle limits", - signing: "Signing queues every new oracle limit.", - done: "Change proposed", - doneBody: `The new oracle limits apply once ${WAITS.oracle} passes.`, - }), - "set-compliance": setterCopy("share token", "Compliance contract"), - "propose-compliance": proposeCopy( - "share token", - "propose a compliance contract", - "compliance contract", - ), - "set-identity-verifier": setterCopy("share token", "Identity verifier"), - "propose-identity-verifier": proposeCopy( - "share token", - "propose an identity verifier", - "identity verifier", - ), -} - -const grantCopy = - (refuser: AdminCopy["refuser"]) => - (value: string): AdminCopy => ({ - refuser, - verb: "grant a role", - signing: `Signing grants ${value}.`, - done: "Role granted", - doneBody: `Granted ${value}.`, - }) - -const revokeCopy = - (refuser: AdminCopy["refuser"]) => - (value: string): AdminCopy => ({ - refuser, - verb: "revoke a role", - signing: `Signing revokes ${value}.`, - done: "Role revoked", - doneBody: `Revoked ${value}.`, - }) - -const handOverCopy = - (refuser: AdminCopy["refuser"]) => - (value: string): AdminCopy => ({ - refuser, - verb: "hand over the admin", - signing: "Signing proposes the handover.", - done: "Handover proposed", - doneBody: value, - }) - -const cancelHandoverCopy = - (refuser: AdminCopy["refuser"]) => - (value: string): AdminCopy => ({ - refuser, - verb: "cancel the handover", - signing: `Signing withdraws the offer to ${value}.`, - done: "Handover cancelled", - doneBody: `${value} can no longer accept.`, - }) - -const ACCEPT_WIND_DOWN_CODES: Record = { - vault: 6046, - oracle: 3018, - "share token": 7008, -} - -const acceptAdminCopy = (refuser: TimelockRefuser) => (): AdminCopy => ({ - refuser, - reasons: { - [ACCEPT_WIND_DOWN_CODES[refuser]]: - "The vault is winding down, so the admin role can no longer change hands.", - }, - verb: "accept the admin role", - signing: `Signing makes this wallet the ${refuser} admin.`, - done: "Admin accepted", - doneBody: `This wallet is now the ${refuser} admin.`, -}) - -const proposalCopy = - (refuser: TimelockRefuser) => - (value: string): AdminCopy => ({ - refuser, - verb: "propose an upgrade", - signing: `Signing queues ${value}.`, - done: "Upgrade proposed", - doneBody: `${value} can apply once ${WAITS[refuser]} passes.`, - }) - -const VAULT_APPLY_REASONS = { - 1000: "The vault is paused; unpause it first.", - 6046: "The vault is winding down, so no change can apply.", -} - -const applyChangeCopy = - (refuser: TimelockRefuser) => - (value: string): AdminCopy => ({ - refuser, - ...(refuser === "vault" ? { reasons: VAULT_APPLY_REASONS } : {}), - verb: "apply the change", - signing: `Signing applies ${value}.`, - done: "Change applied", - doneBody: `Applied ${value}.`, - }) - -const cancelChangeCopy = - (refuser: TimelockRefuser) => - (value: string): AdminCopy => ({ - refuser, - verb: "cancel the change", - signing: `Signing withdraws ${value}.`, - done: "Change cancelled", - doneBody: `Withdrew ${value}.`, - }) - -const proposeWindDownCopy = (value: string): AdminCopy => ({ - refuser: "vault", - verb: "propose a wind-down", - signing: "Signing starts the wind-down countdown.", - done: "Wind-down proposed", - doneBody: value, -}) - -const cancelWindDownCopy = (): AdminCopy => ({ - refuser: "vault", - verb: "cancel the wind-down", - signing: "Signing withdraws the wind-down.", - done: "Wind-down cancelled", - doneBody: "The vault stays open.", -}) - -const GOVERNANCE_COPY: Record< + EmergencyModalAction, GovernanceModalAction, - (value: string) => AdminCopy -> = { - "grant-vault-role": grantCopy("vault"), - "revoke-vault-role": revokeCopy("vault"), - "grant-oracle-role": grantCopy("oracle"), - "revoke-oracle-role": revokeCopy("oracle"), - "grant-token-role": grantCopy("share token"), - "revoke-token-role": revokeCopy("share token"), - "hand-over-vault-admin": handOverCopy("vault"), - "cancel-vault-handover": cancelHandoverCopy("vault"), - "accept-vault-admin": acceptAdminCopy("vault"), - "hand-over-oracle-admin": handOverCopy("oracle"), - "cancel-oracle-handover": cancelHandoverCopy("oracle"), - "accept-oracle-admin": acceptAdminCopy("oracle"), - "hand-over-token-admin": handOverCopy("share token"), - "cancel-token-handover": cancelHandoverCopy("share token"), - "accept-token-admin": acceptAdminCopy("share token"), - "propose-grant-role": proposeCopy( - "vault", - "propose a role grant", - "the grant of", - ), - "propose-revoke-role": proposeCopy( - "vault", - "propose a role revoke", - "the revoke of", - ), - "propose-admin": proposeCopy( - "vault", - "propose the hand-over", - "the hand-over to", - ), - "propose-token-grant-role": proposeCopy( - "share token", - "propose a role grant", - "the grant of", - ), - "propose-token-revoke-role": proposeCopy( - "share token", - "propose a role revoke", - "the revoke of", - ), - "propose-token-admin": proposeCopy( - "share token", - "propose the hand-over", - "the hand-over to", - ), - "propose-oracle-grant-role": proposeCopy( - "oracle", - "propose a role grant", - "the grant of", - ), - "propose-oracle-revoke-role": proposeCopy( - "oracle", - "propose a role revoke", - "the revoke of", - ), - "propose-oracle-admin": proposeCopy( - "oracle", - "propose the hand-over", - "the hand-over to", - ), - "propose-code-upgrade": proposalCopy("vault"), - "propose-upgrade-delay": proposalCopy("vault"), - "propose-oracle-upgrade": proposalCopy("oracle"), - "apply-change": applyChangeCopy("vault"), - "cancel-change": cancelChangeCopy("vault"), - "apply-token-change": applyChangeCopy("share token"), - "cancel-token-change": cancelChangeCopy("share token"), - "apply-oracle-change": applyChangeCopy("oracle"), - "cancel-oracle-change": cancelChangeCopy("oracle"), - "propose-wind-down": proposeWindDownCopy, - "cancel-wind-down": cancelWindDownCopy, -} - -const LEGAL_TRANSFER_REASONS: Partial> = { - 7012: "A legal transfer never moves shares into or out of the vault: they are other investors' escrow.", - 7013: "The compliance account and the token admin must be two different accounts.", - 7015: "Enter a reference of 1 to 128 bytes.", - 304: "The destination is not allowlisted, so it cannot receive shares.", -} - -const COMPLIANCE_COPY: Record< - ComplianceModalAction, - (value: string) => AdminCopy -> = { - "allow-address": (value) => ({ - refuser: "identity verifier", - verb: "allow the address", - signing: `Signing adds ${value} to the allowlist.`, - done: "Address allowed", - doneBody: `${value} is on the allowlist.`, - }), - "disallow-address": (value) => ({ - refuser: "identity verifier", - verb: "remove the address from the allowlist", - signing: `Signing removes ${value} from the allowlist.`, - done: "Removed from the allowlist", - doneBody: `${value} is off the allowlist.`, - }), - "freeze-address": (value) => ({ - refuser: "share token", - verb: "freeze the address", - signing: `Signing freezes ${value}.`, - done: "Address frozen", - doneBody: `${value} is frozen.`, - }), - "unfreeze-address": (value) => ({ - refuser: "share token", - verb: "unfreeze the address", - signing: `Signing unfreezes ${value}.`, - done: "Address unfrozen", - doneBody: `${value} is no longer frozen.`, - }), - "freeze-shares": (value) => ({ - refuser: "share token", - verb: "freeze shares", - signing: `Signing freezes ${value}.`, - done: "Shares frozen", - doneBody: `Froze ${value}.`, - }), - "unfreeze-shares": (value) => ({ - refuser: "share token", - verb: "unfreeze shares", - signing: `Signing unfreezes ${value}.`, - done: "Shares unfrozen", - doneBody: `Unfroze ${value}.`, - }), - "legal-transfer": (value) => ({ - refuser: "share token", - reasons: LEGAL_TRANSFER_REASONS, - verb: "make a legal transfer", - signing: `Signing approves moving ${value}.`, - done: "Legal transfer made", - doneBody: `Moved ${value}.`, - }), -} - -const verbsOf = ( - table: Record AdminCopy)>, -): Record => - Object.fromEntries( - Object.entries AdminCopy)>(table).map( - ([id, copy]) => [id, (typeof copy === "function" ? copy("") : copy).verb], - ), - ) as Record - -export const ADMIN_VERBS: Record = { - ...verbsOf(EMERGENCY_COPY), - ...verbsOf(CONFIGURATION_COPY), - ...verbsOf(GOVERNANCE_COPY), - ...verbsOf(COMPLIANCE_COPY), -} - -const adminContractErrorReason = - (refuser: AdminCopy["refuser"]) => - (code: number): string => { - switch (code) { - case 1000: - return `The ${refuser} is already paused.` - case 1001: - return `The ${refuser} is not paused, so there is nothing to unpause.` - case 300: - return "The address does not hold enough shares." - case 301: - return "The amount cannot be negative." - case 303: - return "The address does not have that many frozen shares." - case 2000: - return "This wallet does not hold the role this action needs." - case 2001: - return `The ${refuser} has no admin.` - case 2002: - return "The role's holders changed before this was recorded." - case 2007: - return "The account does not hold this role." - case 2010: - return `The ${refuser} already has the maximum number of roles.` - case 2200: - return "There is no pending handover." - case 2201: - return "The expiry is outside what the network allows." - case 2202: - return "The pending handover is to a different address." - case 2203: - return "The handover expired before it was accepted." - case 6046: - return "The wind-down is already active." - case 6047: - return "No wind-down is proposed." - case 6048: - return "A wind-down is already proposed." - case 6055: - return "A code change is already pending; one at a time." - case 6056: - return "There is no pending change with that id." - case 6057: - return "The change's delay has not passed yet." - case 6058: - return "The upgrade delay cannot be under 7 days." - case 6059: - return "The upgrade delay cannot be under the notice." - case 6060: - return "The upgrade delay cannot exceed 90 days." - case 6063: - return "There are already 16 pending changes; apply or cancel one first." - case 6064: - return "Setup is still open, so no change can be proposed yet." - case 6065: - return "After setup this change goes through the timelock." - case 6069: - return "The wind-down delay cannot be under 7 days." - case 6075: - return "The custodian cannot be the vault itself." - case 3001: - return "One of the oracle limits is out of range." - case 3008: - return "The NAV is not paused, so the latest price cannot be cleared." - case 3011: - return "There is no pending change with that id." - case 3012: - return "The change's delay has not passed yet." - case 3013: - return "There are already 16 pending changes; apply or cancel one first." - case 3014: - return "Setup is still open, so no change can be proposed yet." - case 3015: - return "After setup this change goes through the timelock." - case 3016: - return "The oracle's setup has already finished." - case 3017: - return "The oracle is not bound to a vault yet." - case 3018: - return "The vault is winding down, so no change can be proposed or applied." - case 3019: - return "The vault is paused, which holds every oracle change; unpause it first." - case 3020: - return "The change's delay is shorter than the vault's notice now, so it cannot apply." - case 3021: - return "A code upgrade is already pending; cancel it first." - case 6007: - return "The deposit cap cannot be negative." - case 6045: - return "The notice cannot exceed 30 days." - case 6053: - return "The wind-down delay cannot exceed 90 days." - case 6061: - return "The notice cannot exceed the upgrade delay." - case 7001: - return "There is no pending change with that id." - case 7002: - return "The change's delay has not passed yet." - case 7003: - return "There are already 16 pending changes; apply or cancel one first." - case 7004: - return "Setup is still open, so no change can be proposed yet." - case 7005: - return "After setup this change goes through the timelock." - case 7008: - return "The vault is winding down, so no change can be proposed or applied." - case 7009: - return "The vault is paused, which holds every share-token change; unpause it first." - case 7011: - return "A code upgrade is already pending; cancel it first." - case 7016: - return "An admin hand-over is still pending; cancel it first." - default: - return `The ${refuser} refused this request. Try again, and contact support if it keeps happening.` - } - } - -const describeAdminStatus = ( - copy: AdminCopy, - status: TransactionStatus, - followUp: string, -): { heading: string; body: string; hash?: string } | undefined => { - switch (status.status) { - case "idle": - return undefined - case "preparing": - return { - heading: `Preparing to ${copy.verb}`, - body: "We are getting it ready. Your wallet will ask you to approve it next.", - } - case "awaiting-signature": - return { heading: "Confirm in your wallet", body: copy.signing } - case "submitted": - return { - heading: "Sending", - body: "It is on its way to the network. This should only take a moment. Closing this window will not stop it.", - hash: status.hash, - } - case "shared": - if (status.standing === "admin-authorization") - return { - heading: "Needs the token admin's authorization", - body: "Share the link with the token admin. Once they authorize it, compliance's signers sign it.", - } - return { - heading: describeStanding(status.standing), - body: describeShared(status.expiresAt), - } - case "confirmed": - return { heading: copy.done, body: copy.doneBody, hash: status.hash } - case "failed": - return { - ...describeFailure( - status.failure, - (code) => - copy.reasons?.[code] ?? - adminContractErrorReason(copy.refuser)(code), - followUp, - copy.refuser, - ), - hash: status.hash, - } - } -} - -const describeStatus = ( - props: TransactionModalProps, -): { heading: string; body: string; hash?: string } | undefined => { - switch (props.action) { - case "subscribe": - return describeSubscribeStatus(props.status, props.amount, props.ticker) - case "cancel": - return describeCancelStatus(props.status, props.amount, props.ticker) - case "cancel-redeem": - return describeCancelRedeemStatus( - props.status, - props.amount, - props.ticker, - props.assetTicker, - props.windingDown === true, - ) - case "claim": - return describeClaimStatus( - props.status, - props.amount, - props.ticker, - props.shareTicker, - ) - case "redeem": - return describeRedeemStatus(props.status, props.amount, props.ticker) - case "claim-redeem": - return describeClaimRedeemStatus( - props.status, - props.amount, - props.ticker, - props.uncovered, - ) - case "claim-wind-down": - return describeClaimWindDownStatus( - props.status, - props.amount, - props.ticker, - props.shareTicker, - ) - case "close-epoch": - return describeCloseEpochStatus(props.status, props.epoch) - case "fulfill-epoch": - return describeFulfillEpochStatus(props.status, props.epoch) - case "attest": - return describeAttestStatus(props.status, props.price, props.validFor) - case "deploy": - return describeDeployStatus(props.status, props.amount) - case "fund": - return describeFundStatus(props.status, props.amount) - case "activate-wind-down": - return describeActivateWindDownStatus(props.status) - case "finalize-round": - return describeFinalizeRoundStatus(props.status, props.round) - case "pause-vault": - case "unpause-vault": - case "pause-nav": - case "unpause-nav": - case "clear-latest": - case "pause-token": - case "unpause-token": - return describeAdminStatus( - EMERGENCY_COPY[props.action], - props.status, - EMERGENCY_FOLLOW_UP, - ) - case "set-deposit-cap": - case "set-custodian": - case "set-notice": - case "set-wind-down-delay": - case "set-min-epoch-duration": - case "propose-custodian": - case "propose-notice": - case "propose-wind-down-delay": - case "propose-min-epoch-duration": - case "set-oracle-config": - case "propose-oracle-config": - case "set-compliance": - case "propose-compliance": - case "set-identity-verifier": - case "propose-identity-verifier": - return describeAdminStatus( - CONFIGURATION_COPY[props.action](props.value), - props.status, - CONFIGURATION_FOLLOW_UP, - ) - case "grant-vault-role": - case "revoke-vault-role": - case "grant-oracle-role": - case "revoke-oracle-role": - case "grant-token-role": - case "revoke-token-role": - case "hand-over-vault-admin": - case "cancel-vault-handover": - case "accept-vault-admin": - case "hand-over-oracle-admin": - case "cancel-oracle-handover": - case "accept-oracle-admin": - case "hand-over-token-admin": - case "cancel-token-handover": - case "accept-token-admin": - case "propose-grant-role": - case "propose-revoke-role": - case "propose-admin": - case "propose-token-grant-role": - case "propose-token-revoke-role": - case "propose-token-admin": - case "propose-code-upgrade": - case "propose-upgrade-delay": - case "apply-change": - case "cancel-change": - case "apply-token-change": - case "cancel-token-change": - case "propose-oracle-grant-role": - case "propose-oracle-revoke-role": - case "propose-oracle-admin": - case "propose-oracle-upgrade": - case "apply-oracle-change": - case "cancel-oracle-change": - case "propose-wind-down": - case "cancel-wind-down": - return describeAdminStatus( - GOVERNANCE_COPY[props.action](props.value), - props.status, - GOVERNANCE_FOLLOW_UP, - ) - case "allow-address": - case "disallow-address": - case "freeze-address": - case "unfreeze-address": - case "freeze-shares": - case "unfreeze-shares": - case "legal-transfer": - return describeAdminStatus( - COMPLIANCE_COPY[props.action](props.value), - props.status, - COMPLIANCE_FOLLOW_UP, - ) - } -} - -type StepId = "signature" | "network" | "recorded" -type StepState = "done" | "current" | "upcoming" | "failed" - -const STEP_ORDER: StepId[] = ["signature", "network", "recorded"] - -const stepLabel: Record = { - signature: "Approved in your wallet", - network: "Sent to the network", - recorded: "Recorded", -} - -const computeSteps = ( - status: - | RequestDepositStatus - | CancelDepositStatus - | CancelRedeemStatus - | ClaimDepositStatus - | RequestRedeemStatus - | ClaimRedeemStatus - | CloseEpochStatus - | FulfillEpochStatus - | AttestStatus - | DeployToCustodianStatus - | FundStatus - | ActivateWindDownStatus - | FinalizeWindDownRoundStatus - | TransactionStatus, -): Record | undefined => { - switch (status.status) { - case "idle": - return undefined - case "preparing": - return { - signature: "upcoming", - network: "upcoming", - recorded: "upcoming", - } - case "awaiting-signature": - return { signature: "current", network: "upcoming", recorded: "upcoming" } - case "submitted": - return { signature: "done", network: "current", recorded: "upcoming" } - case "shared": - return { signature: "done", network: "upcoming", recorded: "upcoming" } - case "confirmed": - return { signature: "done", network: "done", recorded: "done" } - case "failed": - if (status.failure.kind === "unknown" && status.hash !== undefined) { - return { signature: "done", network: "done", recorded: "failed" } - } - if ( - status.failure.kind === "unknown" || - status.failure.kind === "network" || - status.failure.kind === "stale" - ) { - return { signature: "done", network: "failed", recorded: "upcoming" } - } - return { signature: "failed", network: "upcoming", recorded: "upcoming" } - } -} - -const stepStateClassName: Record = { - done: styles.stepDone, - current: styles.stepCurrent, - failed: styles.stepFailed, - upcoming: styles.stepUpcoming, -} - -const stepAnnouncement: Partial> = { - done: "done", - current: "in progress", - failed: "failed", -} + TransactionModalProps, +} from "./transactionModal/types" +export { ADMIN_VERBS } from "./transactionModal/content" +export { HELD_WHILE_FROZEN, NOT_ALLOWLISTED } from "./transactionModal/investor" export const ShareLink: React.FC<{ xdr: string }> = ({ xdr }) => { const [copied, setCopied] = React.useState(false) diff --git a/app/src/components/vault/transactionModal/adminCopy.ts b/app/src/components/vault/transactionModal/adminCopy.ts new file mode 100644 index 00000000..abef1cc0 --- /dev/null +++ b/app/src/components/vault/transactionModal/adminCopy.ts @@ -0,0 +1,201 @@ +import { type TransactionStatus } from "../../../hooks/useContractTransaction" +import { describeStanding } from "../../../pages/actions" +import { describeFailure, describeShared } from "./outcome" + +export type AdminCopy = { + refuser: "vault" | "share token" | "oracle" | "identity verifier" + reasons?: Partial> + verb: string + signing: string + done: string + doneBody: string +} + +const capitalize = (text: string): string => + `${text.charAt(0).toUpperCase()}${text.slice(1)}` + +export type TimelockRefuser = Extract< + AdminCopy["refuser"], + "vault" | "share token" | "oracle" +> + +export const WAITS: Record = { + vault: "the upgrade delay", + "share token": "the vault's upgrade delay", + oracle: "the vault's upgrade delay", +} + +export const proposeCopy = + (refuser: TimelockRefuser, verb: string, subject: string) => + (value: string): AdminCopy => ({ + refuser, + verb, + signing: `Signing queues ${subject} ${value}.`, + done: "Change proposed", + doneBody: `${capitalize(subject)} ${value} applies once ${WAITS[refuser]} passes.`, + }) + +const adminContractErrorReason = + (refuser: AdminCopy["refuser"]) => + (code: number): string => { + switch (code) { + case 1000: + return `The ${refuser} is already paused.` + case 1001: + return `The ${refuser} is not paused, so there is nothing to unpause.` + case 300: + return "The address does not hold enough shares." + case 301: + return "The amount cannot be negative." + case 303: + return "The address does not have that many frozen shares." + case 2000: + return "This wallet does not hold the role this action needs." + case 2001: + return `The ${refuser} has no admin.` + case 2002: + return "The role's holders changed before this was recorded." + case 2007: + return "The account does not hold this role." + case 2010: + return `The ${refuser} already has the maximum number of roles.` + case 2200: + return "There is no pending handover." + case 2201: + return "The expiry is outside what the network allows." + case 2202: + return "The pending handover is to a different address." + case 2203: + return "The handover expired before it was accepted." + case 6046: + return "The wind-down is already active." + case 6047: + return "No wind-down is proposed." + case 6048: + return "A wind-down is already proposed." + case 6055: + return "A code change is already pending; one at a time." + case 6056: + return "There is no pending change with that id." + case 6057: + return "The change's delay has not passed yet." + case 6058: + return "The upgrade delay cannot be under 7 days." + case 6059: + return "The upgrade delay cannot be under the notice." + case 6060: + return "The upgrade delay cannot exceed 90 days." + case 6063: + return "There are already 16 pending changes; apply or cancel one first." + case 6064: + return "Setup is still open, so no change can be proposed yet." + case 6065: + return "After setup this change goes through the timelock." + case 6069: + return "The wind-down delay cannot be under 7 days." + case 6075: + return "The custodian cannot be the vault itself." + case 3001: + return "One of the oracle limits is out of range." + case 3008: + return "The NAV is not paused, so the latest price cannot be cleared." + case 3011: + return "There is no pending change with that id." + case 3012: + return "The change's delay has not passed yet." + case 3013: + return "There are already 16 pending changes; apply or cancel one first." + case 3014: + return "Setup is still open, so no change can be proposed yet." + case 3015: + return "After setup this change goes through the timelock." + case 3016: + return "The oracle's setup has already finished." + case 3017: + return "The oracle is not bound to a vault yet." + case 3018: + return "The vault is winding down, so no change can be proposed or applied." + case 3019: + return "The vault is paused, which holds every oracle change; unpause it first." + case 3020: + return "The change's delay is shorter than the vault's notice now, so it cannot apply." + case 3021: + return "A code upgrade is already pending; cancel it first." + case 6007: + return "The deposit cap cannot be negative." + case 6045: + return "The notice cannot exceed 30 days." + case 6053: + return "The wind-down delay cannot exceed 90 days." + case 6061: + return "The notice cannot exceed the upgrade delay." + case 7001: + return "There is no pending change with that id." + case 7002: + return "The change's delay has not passed yet." + case 7003: + return "There are already 16 pending changes; apply or cancel one first." + case 7004: + return "Setup is still open, so no change can be proposed yet." + case 7005: + return "After setup this change goes through the timelock." + case 7008: + return "The vault is winding down, so no change can be proposed or applied." + case 7009: + return "The vault is paused, which holds every share-token change; unpause it first." + case 7011: + return "A code upgrade is already pending; cancel it first." + case 7016: + return "An admin hand-over is still pending; cancel it first." + default: + return `The ${refuser} refused this request. Try again, and contact support if it keeps happening.` + } + } + +export const describeAdminStatus = ( + copy: AdminCopy, + status: TransactionStatus, + followUp: string, +): { heading: string; body: string; hash?: string } | undefined => { + switch (status.status) { + case "idle": + return undefined + case "preparing": + return { + heading: `Preparing to ${copy.verb}`, + body: "We are getting it ready. Your wallet will ask you to approve it next.", + } + case "awaiting-signature": + return { heading: "Confirm in your wallet", body: copy.signing } + case "submitted": + return { + heading: "Sending", + body: "It is on its way to the network. This should only take a moment. Closing this window will not stop it.", + hash: status.hash, + } + case "shared": + if (status.standing === "admin-authorization") + return { + heading: "Needs the token admin's authorization", + body: "Share the link with the token admin. Once they authorize it, compliance's signers sign it.", + } + return { + heading: describeStanding(status.standing), + body: describeShared(status.expiresAt), + } + case "confirmed": + return { heading: copy.done, body: copy.doneBody, hash: status.hash } + case "failed": + return { + ...describeFailure( + status.failure, + (code) => + copy.reasons?.[code] ?? + adminContractErrorReason(copy.refuser)(code), + followUp, + copy.refuser, + ), + hash: status.hash, + } + } +} diff --git a/app/src/components/vault/transactionModal/compliance.ts b/app/src/components/vault/transactionModal/compliance.ts new file mode 100644 index 00000000..24444535 --- /dev/null +++ b/app/src/components/vault/transactionModal/compliance.ts @@ -0,0 +1,68 @@ +import { type AdminCopy } from "./adminCopy" +import { type ComplianceModalAction } from "./types" + +export const COMPLIANCE_FOLLOW_UP = + "check the Compliance tab before trying again" + +const LEGAL_TRANSFER_REASONS: Partial> = { + 7012: "A legal transfer never moves shares into or out of the vault: they are other investors' escrow.", + 7013: "The compliance account and the token admin must be two different accounts.", + 7015: "Enter a reference of 1 to 128 bytes.", + 304: "The destination is not allowlisted, so it cannot receive shares.", +} + +export const COMPLIANCE_COPY: Record< + ComplianceModalAction, + (value: string) => AdminCopy +> = { + "allow-address": (value) => ({ + refuser: "identity verifier", + verb: "allow the address", + signing: `Signing adds ${value} to the allowlist.`, + done: "Address allowed", + doneBody: `${value} is on the allowlist.`, + }), + "disallow-address": (value) => ({ + refuser: "identity verifier", + verb: "remove the address from the allowlist", + signing: `Signing removes ${value} from the allowlist.`, + done: "Removed from the allowlist", + doneBody: `${value} is off the allowlist.`, + }), + "freeze-address": (value) => ({ + refuser: "share token", + verb: "freeze the address", + signing: `Signing freezes ${value}.`, + done: "Address frozen", + doneBody: `${value} is frozen.`, + }), + "unfreeze-address": (value) => ({ + refuser: "share token", + verb: "unfreeze the address", + signing: `Signing unfreezes ${value}.`, + done: "Address unfrozen", + doneBody: `${value} is no longer frozen.`, + }), + "freeze-shares": (value) => ({ + refuser: "share token", + verb: "freeze shares", + signing: `Signing freezes ${value}.`, + done: "Shares frozen", + doneBody: `Froze ${value}.`, + }), + "unfreeze-shares": (value) => ({ + refuser: "share token", + verb: "unfreeze shares", + signing: `Signing unfreezes ${value}.`, + done: "Shares unfrozen", + doneBody: `Unfroze ${value}.`, + }), + "legal-transfer": (value) => ({ + refuser: "share token", + reasons: LEGAL_TRANSFER_REASONS, + verb: "make a legal transfer", + signing: `Signing approves moving ${value}.`, + done: "Legal transfer made", + doneBody: `Moved ${value}.`, + }), +} diff --git a/app/src/components/vault/transactionModal/configuration.ts b/app/src/components/vault/transactionModal/configuration.ts new file mode 100644 index 00000000..98d37e37 --- /dev/null +++ b/app/src/components/vault/transactionModal/configuration.ts @@ -0,0 +1,82 @@ +import { EFFECTS } from "../../../pages/configuration" +import { type AdminCopy, WAITS, proposeCopy } from "./adminCopy" +import { type ConfigurationModalAction } from "./types" + +export const CONFIGURATION_FOLLOW_UP = + "check the Configuration tab before trying again" +const setterCopy = + (refuser: AdminCopy["refuser"], subject: string, effect?: string) => + (value: string): AdminCopy => ({ + refuser, + verb: `set the ${subject.toLowerCase()}`, + signing: `New ${subject.toLowerCase()}: ${value}.`, + done: `${subject} updated`, + doneBody: effect ?? `${subject}: ${value}.`, + }) + +const MIN_EPOCH_DURATION_REASONS = { + 6007: "The minimum epoch duration must be 1 to 30 days.", +} + +export const CONFIGURATION_COPY: Record< + ConfigurationModalAction, + (value: string) => AdminCopy +> = { + "set-deposit-cap": setterCopy("vault", "Deposit cap"), + "set-custodian": setterCopy("vault", "Custodian", EFFECTS.custodian), + "set-notice": setterCopy("vault", "Notice", EFFECTS.notice), + "set-wind-down-delay": setterCopy( + "vault", + "Wind-down delay", + EFFECTS.windDownDelay, + ), + "set-min-epoch-duration": (value) => ({ + ...setterCopy( + "vault", + "Minimum epoch duration", + EFFECTS.minEpochDuration, + )(value), + reasons: MIN_EPOCH_DURATION_REASONS, + }), + "propose-custodian": proposeCopy("vault", "propose a custodian", "custodian"), + "propose-notice": proposeCopy("vault", "propose a notice", "notice"), + "propose-wind-down-delay": proposeCopy( + "vault", + "propose a wind-down delay", + "wind-down delay", + ), + "propose-min-epoch-duration": (value) => ({ + ...proposeCopy( + "vault", + "propose a minimum epoch duration", + "minimum epoch duration", + )(value), + reasons: MIN_EPOCH_DURATION_REASONS, + }), + "set-oracle-config": () => ({ + refuser: "oracle", + verb: "set the oracle limits", + signing: "Signing replaces every oracle limit.", + done: "Oracle limits updated", + doneBody: "The new limits are recorded.", + }), + "propose-oracle-config": () => ({ + refuser: "oracle", + verb: "propose oracle limits", + signing: "Signing queues every new oracle limit.", + done: "Change proposed", + doneBody: `The new oracle limits apply once ${WAITS.oracle} passes.`, + }), + "set-compliance": setterCopy("share token", "Compliance contract"), + "propose-compliance": proposeCopy( + "share token", + "propose a compliance contract", + "compliance contract", + ), + "set-identity-verifier": setterCopy("share token", "Identity verifier"), + "propose-identity-verifier": proposeCopy( + "share token", + "propose an identity verifier", + "identity verifier", + ), +} diff --git a/app/src/components/vault/transactionModal/content.ts b/app/src/components/vault/transactionModal/content.ts new file mode 100644 index 00000000..93bafda3 --- /dev/null +++ b/app/src/components/vault/transactionModal/content.ts @@ -0,0 +1,180 @@ +import { type AdminCopy, describeAdminStatus } from "./adminCopy" +import { COMPLIANCE_FOLLOW_UP, COMPLIANCE_COPY } from "./compliance" +import { CONFIGURATION_FOLLOW_UP, CONFIGURATION_COPY } from "./configuration" +import { + describeCloseEpochStatus, + describeFulfillEpochStatus, + describeAttestStatus, + describeDeployStatus, + describeFundStatus, + describeActivateWindDownStatus, + describeFinalizeRoundStatus, +} from "./cycle" +import { EMERGENCY_FOLLOW_UP, EMERGENCY_COPY } from "./emergency" +import { GOVERNANCE_FOLLOW_UP, GOVERNANCE_COPY } from "./governance" +import { + describeSubscribeStatus, + describeCancelStatus, + describeCancelRedeemStatus, + describeClaimStatus, + describeClaimWindDownStatus, + describeRedeemStatus, + describeClaimRedeemStatus, +} from "./investor" +import { type AdminModalAction, type TransactionModalProps } from "./types" + +const verbsOf = ( + table: Record AdminCopy)>, +): Record => + Object.fromEntries( + Object.entries AdminCopy)>(table).map( + ([id, copy]) => [id, (typeof copy === "function" ? copy("") : copy).verb], + ), + ) as Record + +export const ADMIN_VERBS: Record = { + ...verbsOf(EMERGENCY_COPY), + ...verbsOf(CONFIGURATION_COPY), + ...verbsOf(GOVERNANCE_COPY), + ...verbsOf(COMPLIANCE_COPY), +} + +export const describeStatus = ( + props: TransactionModalProps, +): { heading: string; body: string; hash?: string } | undefined => { + switch (props.action) { + case "subscribe": + return describeSubscribeStatus(props.status, props.amount, props.ticker) + case "cancel": + return describeCancelStatus(props.status, props.amount, props.ticker) + case "cancel-redeem": + return describeCancelRedeemStatus( + props.status, + props.amount, + props.ticker, + props.assetTicker, + props.windingDown === true, + ) + case "claim": + return describeClaimStatus( + props.status, + props.amount, + props.ticker, + props.shareTicker, + ) + case "redeem": + return describeRedeemStatus(props.status, props.amount, props.ticker) + case "claim-redeem": + return describeClaimRedeemStatus( + props.status, + props.amount, + props.ticker, + props.uncovered, + ) + case "claim-wind-down": + return describeClaimWindDownStatus( + props.status, + props.amount, + props.ticker, + props.shareTicker, + ) + case "close-epoch": + return describeCloseEpochStatus(props.status, props.epoch) + case "fulfill-epoch": + return describeFulfillEpochStatus(props.status, props.epoch) + case "attest": + return describeAttestStatus(props.status, props.price, props.validFor) + case "deploy": + return describeDeployStatus(props.status, props.amount) + case "fund": + return describeFundStatus(props.status, props.amount) + case "activate-wind-down": + return describeActivateWindDownStatus(props.status) + case "finalize-round": + return describeFinalizeRoundStatus(props.status, props.round) + case "pause-vault": + case "unpause-vault": + case "pause-nav": + case "unpause-nav": + case "clear-latest": + case "pause-token": + case "unpause-token": + return describeAdminStatus( + EMERGENCY_COPY[props.action], + props.status, + EMERGENCY_FOLLOW_UP, + ) + case "set-deposit-cap": + case "set-custodian": + case "set-notice": + case "set-wind-down-delay": + case "set-min-epoch-duration": + case "propose-custodian": + case "propose-notice": + case "propose-wind-down-delay": + case "propose-min-epoch-duration": + case "set-oracle-config": + case "propose-oracle-config": + case "set-compliance": + case "propose-compliance": + case "set-identity-verifier": + case "propose-identity-verifier": + return describeAdminStatus( + CONFIGURATION_COPY[props.action](props.value), + props.status, + CONFIGURATION_FOLLOW_UP, + ) + case "grant-vault-role": + case "revoke-vault-role": + case "grant-oracle-role": + case "revoke-oracle-role": + case "grant-token-role": + case "revoke-token-role": + case "hand-over-vault-admin": + case "cancel-vault-handover": + case "accept-vault-admin": + case "hand-over-oracle-admin": + case "cancel-oracle-handover": + case "accept-oracle-admin": + case "hand-over-token-admin": + case "cancel-token-handover": + case "accept-token-admin": + case "propose-grant-role": + case "propose-revoke-role": + case "propose-admin": + case "propose-token-grant-role": + case "propose-token-revoke-role": + case "propose-token-admin": + case "propose-code-upgrade": + case "propose-upgrade-delay": + case "apply-change": + case "cancel-change": + case "apply-token-change": + case "cancel-token-change": + case "propose-oracle-grant-role": + case "propose-oracle-revoke-role": + case "propose-oracle-admin": + case "propose-oracle-upgrade": + case "apply-oracle-change": + case "cancel-oracle-change": + case "propose-wind-down": + case "cancel-wind-down": + return describeAdminStatus( + GOVERNANCE_COPY[props.action](props.value), + props.status, + GOVERNANCE_FOLLOW_UP, + ) + case "allow-address": + case "disallow-address": + case "freeze-address": + case "unfreeze-address": + case "freeze-shares": + case "unfreeze-shares": + case "legal-transfer": + return describeAdminStatus( + COMPLIANCE_COPY[props.action](props.value), + props.status, + COMPLIANCE_FOLLOW_UP, + ) + } +} diff --git a/app/src/components/vault/transactionModal/cycle.ts b/app/src/components/vault/transactionModal/cycle.ts new file mode 100644 index 00000000..a5db32f9 --- /dev/null +++ b/app/src/components/vault/transactionModal/cycle.ts @@ -0,0 +1,434 @@ +import { + AMOUNT_DECIMALS, + formatExact, + formatScaled, + PRICE_DECIMALS, +} from "@stellar-scaffold/app-lib" +import { type ActivateWindDownStatus } from "../../../hooks/useActivateWindDown" +import { type AttestStatus } from "../../../hooks/useAttest" +import { type CloseEpochStatus } from "../../../hooks/useCloseEpoch" +import { type DeployToCustodianStatus } from "../../../hooks/useDeployToCustodian" +import { type FinalizeWindDownRoundStatus } from "../../../hooks/useFinalizeWindDownRound" +import { type FulfillEpochStatus } from "../../../hooks/useFulfillEpoch" +import { type FundStatus } from "../../../hooks/useFund" +import { GENERIC_REFUSAL, describeFailure } from "./outcome" + +const closeEpochContractErrorReason = (code: number): string => { + switch (code) { + case 6046: + return "The vault is winding down, so no more epochs can be sealed." + case 6029: + return "The current epoch could not be found." + case 6032: + return "The current epoch is not open, so there is nothing to seal." + case 2000: + return "This wallet is not authorized to close epochs." + case 2007: + return "This wallet does not hold the manager role." + case 6077: + return "Only the manager can seal this epoch until it has been open for the minimum duration." + case 6064: + return "The vault's setup is still open, so no epoch can be sealed yet." + default: + return GENERIC_REFUSAL + } +} + +const fulfillEpochContractErrorReason = (code: number): string => { + switch (code) { + case 6046: + return "The vault is winding down, so epochs can no longer be priced." + case 6029: + return "This epoch could not be found." + case 6038: + return "This epoch is not sealed, so it cannot be priced." + case 6042: + return "The notice period has not elapsed yet." + case 6044: + return "The oracle price is not valid right now." + case 6043: + return "The latest price was attested before this epoch was sealed. A fresh attestation is needed." + case 6031: + return "The vault could not resolve a valid share price." + case 6014: + return "The settlement total is too large for the vault to record." + case 1000: + return "The vault is paused, so epochs cannot be priced." + case 6064: + return "The vault's setup is still open, so no epoch can be priced yet." + default: + return GENERIC_REFUSAL + } +} + +const attestContractErrorReason = (code: number): string => { + switch (code) { + case 3002: + return "That price is outside the band the oracle accepts." + case 3005: + return "The attestation would already have expired when recorded." + case 3003: + return "The cooldown since the last attestation has not elapsed yet." + case 3004: + return "That price moves further from the last attested price than the oracle allows." + case 3010: + return "Unpause the NAV first." + case 3023: + return "The proof hash cannot be all zeros." + case 3024: + return "The proof URI is longer than 256 bytes." + default: + return GENERIC_REFUSAL + } +} + +const deployContractErrorReason = (code: number): string => { + switch (code) { + case 6046: + return "The vault is winding down, so nothing can leave for the custodian." + case 6007: + return "Enter an amount greater than zero." + case 6012: + return "No custodian is set, so there is nowhere to deploy to." + case 6005: + return "That amount exceeds the free reserve. The rest is committed to priced exits." + case 6014: + return "That amount is too large for the vault to record." + case 2000: + return "This wallet is not authorized to deploy to the custodian." + case 2007: + return "This wallet does not hold the treasury role." + default: + return GENERIC_REFUSAL + } +} + +const fundContractErrorReason = (code: number): string => { + switch (code) { + case 6007: + return "Enter an amount greater than zero." + case 6014: + return "That amount is too large for the vault to record." + default: + return code < 6000 + ? "Your wallet could not cover the transfer. Check the balance and the trustline." + : GENERIC_REFUSAL + } +} + +const activateWindDownContractErrorReason = (code: number): string => { + switch (code) { + case 6047: + return "No wind-down has been proposed, so there is nothing to activate." + case 6046: + return "The wind-down is already active." + case 6049: + return "The wind-down delay has not elapsed yet." + default: + return GENERIC_REFUSAL + } +} + +const finalizeRoundContractErrorReason = (code: number): string => { + switch (code) { + case 6050: + return "The wind-down is not active, so no round can be finalized." + case 6052: + return "There is nothing to distribute: the free reserve is empty or too small to split across holders." + case 6014: + return "The round is too large for the vault to record." + default: + return GENERIC_REFUSAL + } +} + +const OPERATOR_FOLLOW_UP = "check the Cycle card before trying again" +export const describeCloseEpochStatus = ( + status: CloseEpochStatus, + epoch: string, +): { heading: string; body: string; hash?: string } | undefined => { + switch (status.status) { + case "idle": + return undefined + case "preparing": + return { + heading: `Preparing to close epoch ${epoch}`, + body: "We are getting the close ready. Your wallet will ask you to approve it next.", + } + case "awaiting-signature": + return { + heading: "Confirm in your wallet", + body: `Signing seals epoch ${epoch} and opens the next one. Requests in epoch ${epoch} wait for a price.`, + } + case "submitted": + return { + heading: "Sending the close", + body: "The close is on its way to the network. This should only take a moment. Closing this window will not stop it.", + hash: status.hash, + } + case "confirmed": + return { + heading: `Epoch ${status.sealedEpoch} sealed`, + body: `Epoch ${status.sealedEpoch + 1n} is open and accepting requests. Epoch ${status.sealedEpoch} prices once the notice elapses and a valid price is attested.`, + hash: status.hash, + } + case "failed": + return { + ...describeFailure( + status.failure, + closeEpochContractErrorReason, + OPERATOR_FOLLOW_UP, + ), + hash: status.hash, + } + } +} + +export const describeFulfillEpochStatus = ( + status: FulfillEpochStatus, + epoch: string, +): { heading: string; body: string; hash?: string } | undefined => { + switch (status.status) { + case "idle": + return undefined + case "preparing": + return { + heading: `Preparing to price epoch ${epoch}`, + body: "We are getting the settlement ready. Your wallet will ask you to approve it next.", + } + case "awaiting-signature": + return { + heading: "Confirm in your wallet", + body: `Signing prices epoch ${epoch} at the oracle's current price and settles its requests.`, + } + case "submitted": + return { + heading: "Sending the settlement", + body: "The settlement is on its way to the network. This should only take a moment. Closing this window will not stop it.", + hash: status.hash, + } + case "confirmed": + return { + heading: `Epoch ${epoch} priced`, + body: `Settled at ${formatScaled(status.sharePrice, PRICE_DECIMALS, 4)} per share. Share claims are ready; redemption claims wait until the reserve covers them.`, + hash: status.hash, + } + case "failed": + return { + ...describeFailure( + status.failure, + fulfillEpochContractErrorReason, + OPERATOR_FOLLOW_UP, + ), + hash: status.hash, + } + } +} + +export const describeAttestStatus = ( + status: AttestStatus, + price: string, + validFor: string | null, +): { heading: string; body: string; hash?: string } | undefined => { + switch (status.status) { + case "idle": + return undefined + case "preparing": + return { + heading: `Preparing to attest ${price}`, + body: "We are getting the attestation ready. Your wallet will ask you to approve it next.", + } + case "awaiting-signature": + return { + heading: "Confirm in your wallet", + body: `Signing records ${price} as the share price, with its proof. Sealed epochs settle at it once their notice elapses.`, + } + case "submitted": + return { + heading: "Sending the attestation", + body: "The attestation is on its way to the network. This should only take a moment. Closing this window will not stop it.", + hash: status.hash, + } + case "confirmed": + return { + heading: "Price attested", + body: + validFor === null + ? `${price} is the share price.` + : `${price} is the share price. Valid for ${validFor}.`, + hash: status.hash, + } + case "failed": + return { + ...describeFailure( + status.failure, + attestContractErrorReason, + OPERATOR_FOLLOW_UP, + ), + hash: status.hash, + } + } +} + +export const describeDeployStatus = ( + status: DeployToCustodianStatus, + amount: string, +): { heading: string; body: string; hash?: string } | undefined => { + switch (status.status) { + case "idle": + return undefined + case "preparing": + return { + heading: `Preparing to deploy ${amount}`, + body: "We are getting the deployment ready. Your wallet will ask you to approve it next.", + } + case "awaiting-signature": + return { + heading: "Confirm in your wallet", + body: `Signing sends ${amount} from the vault's reserve to the custodian.`, + } + case "submitted": + return { + heading: "Sending the deployment", + body: "The deployment is on its way to the network. This should only take a moment. Closing this window will not stop it.", + hash: status.hash, + } + case "confirmed": + return { + heading: "Deployed", + body: `${amount} left the vault for the custodian.`, + hash: status.hash, + } + case "failed": + return { + ...describeFailure( + status.failure, + deployContractErrorReason, + OPERATOR_FOLLOW_UP, + ), + hash: status.hash, + } + } +} + +export const describeFundStatus = ( + status: FundStatus, + amount: string, +): { heading: string; body: string; hash?: string } | undefined => { + switch (status.status) { + case "idle": + return undefined + case "preparing": + return { + heading: `Preparing to fund ${amount}`, + body: "We are getting the funding ready. Your wallet will ask you to approve it next.", + } + case "awaiting-signature": + return { + heading: "Confirm in your wallet", + body: `Signing moves ${amount} from your wallet into the vault's reserve.`, + } + case "submitted": + return { + heading: "Sending the funding", + body: "The funding is on its way to the network. This should only take a moment. Closing this window will not stop it.", + hash: status.hash, + } + case "confirmed": + return { + heading: "Funded", + body: `${amount} joined the reserve.`, + hash: status.hash, + } + case "failed": + return { + ...describeFailure( + status.failure, + fundContractErrorReason, + OPERATOR_FOLLOW_UP, + ), + hash: status.hash, + } + } +} + +export const describeActivateWindDownStatus = ( + status: ActivateWindDownStatus, +): { heading: string; body: string; hash?: string } | undefined => { + switch (status.status) { + case "idle": + return undefined + case "preparing": + return { + heading: "Preparing to activate the wind-down", + body: "We are getting the activation ready. Your wallet will ask you to approve it next.", + } + case "awaiting-signature": + return { + heading: "Confirm in your wallet", + body: "Signing stops new requests and opens distribution rounds.", + } + case "submitted": + return { + heading: "Sending the activation", + body: "The activation is on its way to the network. This should only take a moment. Closing this window will not stop it.", + hash: status.hash, + } + case "confirmed": + return { + heading: "Wind-down active", + body: "New requests are closed. Rounds can now distribute the free reserve.", + hash: status.hash, + } + case "failed": + return { + ...describeFailure( + status.failure, + activateWindDownContractErrorReason, + OPERATOR_FOLLOW_UP, + ), + hash: status.hash, + } + } +} + +export const describeFinalizeRoundStatus = ( + status: FinalizeWindDownRoundStatus, + round: string, +): { heading: string; body: string; hash?: string } | undefined => { + switch (status.status) { + case "idle": + return undefined + case "preparing": + return { + heading: `Preparing round ${round}`, + body: "We are getting the round ready. Your wallet will ask you to approve it next.", + } + case "awaiting-signature": + return { + heading: "Confirm in your wallet", + body: `Signing distributes the free reserve to holders as round ${round}.`, + } + case "submitted": + return { + heading: "Sending the round", + body: "The round is on its way to the network. This should only take a moment. Closing this window will not stop it.", + hash: status.hash, + } + case "confirmed": + return { + heading: `Round ${round} finalized`, + body: `${formatExact(status.credited, AMOUNT_DECIMALS)} is claimable by holders.`, + hash: status.hash, + } + case "failed": + return { + ...describeFailure( + status.failure, + finalizeRoundContractErrorReason, + OPERATOR_FOLLOW_UP, + ), + hash: status.hash, + } + } +} diff --git a/app/src/components/vault/transactionModal/emergency.ts b/app/src/components/vault/transactionModal/emergency.ts new file mode 100644 index 00000000..53be01f9 --- /dev/null +++ b/app/src/components/vault/transactionModal/emergency.ts @@ -0,0 +1,59 @@ +import { type AdminCopy } from "./adminCopy" +import { type EmergencyModalAction } from "./types" + +export const EMERGENCY_FOLLOW_UP = "check the Emergency tab before trying again" +export const EMERGENCY_COPY: Record = { + "pause-vault": { + refuser: "vault", + verb: "pause the vault", + signing: "Signing stops new deposits, pricing and upgrades.", + done: "Vault paused", + doneBody: + "New deposits, pricing and upgrades are stopped. Redemptions, claims, cancels and closing epochs stay open.", + }, + "unpause-vault": { + refuser: "vault", + verb: "unpause the vault", + signing: "Signing resumes deposits, pricing and upgrades.", + done: "Vault unpaused", + doneBody: "Deposits, pricing and upgrades are open again.", + }, + "pause-nav": { + refuser: "oracle", + verb: "pause the NAV", + signing: "Signing stops pricing until the NAV is unpaused.", + done: "NAV paused", + doneBody: + "Pricing is stopped. No epoch can be fulfilled until the NAV is unpaused.", + }, + "unpause-nav": { + refuser: "oracle", + verb: "unpause the NAV", + signing: "Signing resumes pricing.", + done: "NAV unpaused", + doneBody: "Epochs can be fulfilled again while the price is valid.", + }, + "clear-latest": { + refuser: "oracle", + verb: "clear the latest price", + signing: "Signing removes the recorded price.", + done: "Latest price cleared", + doneBody: + "The next attestation is not limited by its distance from the last. Pricing stays stopped until the NAV is unpaused.", + }, + "pause-token": { + refuser: "share token", + verb: "pause the share token", + signing: "Signing stops holder transfers only.", + done: "Share token paused", + doneBody: + "Holder transfers are stopped. Minting, burning, the vault's escrow moves and legal transfers continue.", + }, + "unpause-token": { + refuser: "share token", + verb: "unpause the share token", + signing: "Signing resumes holder transfers.", + done: "Share token unpaused", + doneBody: "Holder transfers are open again.", + }, +} diff --git a/app/src/components/vault/transactionModal/governance.ts b/app/src/components/vault/transactionModal/governance.ts new file mode 100644 index 00000000..b16822be --- /dev/null +++ b/app/src/components/vault/transactionModal/governance.ts @@ -0,0 +1,196 @@ +import { + type AdminCopy, + type TimelockRefuser, + WAITS, + proposeCopy, +} from "./adminCopy" +import { type GovernanceModalAction } from "./types" + +export const GOVERNANCE_FOLLOW_UP = + "check the Governance tab before trying again" +const grantCopy = + (refuser: AdminCopy["refuser"]) => + (value: string): AdminCopy => ({ + refuser, + verb: "grant a role", + signing: `Signing grants ${value}.`, + done: "Role granted", + doneBody: `Granted ${value}.`, + }) + +const revokeCopy = + (refuser: AdminCopy["refuser"]) => + (value: string): AdminCopy => ({ + refuser, + verb: "revoke a role", + signing: `Signing revokes ${value}.`, + done: "Role revoked", + doneBody: `Revoked ${value}.`, + }) + +const handOverCopy = + (refuser: AdminCopy["refuser"]) => + (value: string): AdminCopy => ({ + refuser, + verb: "hand over the admin", + signing: "Signing proposes the handover.", + done: "Handover proposed", + doneBody: value, + }) + +const cancelHandoverCopy = + (refuser: AdminCopy["refuser"]) => + (value: string): AdminCopy => ({ + refuser, + verb: "cancel the handover", + signing: `Signing withdraws the offer to ${value}.`, + done: "Handover cancelled", + doneBody: `${value} can no longer accept.`, + }) + +const ACCEPT_WIND_DOWN_CODES: Record = { + vault: 6046, + oracle: 3018, + "share token": 7008, +} + +const acceptAdminCopy = (refuser: TimelockRefuser) => (): AdminCopy => ({ + refuser, + reasons: { + [ACCEPT_WIND_DOWN_CODES[refuser]]: + "The vault is winding down, so the admin role can no longer change hands.", + }, + verb: "accept the admin role", + signing: `Signing makes this wallet the ${refuser} admin.`, + done: "Admin accepted", + doneBody: `This wallet is now the ${refuser} admin.`, +}) + +const proposalCopy = + (refuser: TimelockRefuser) => + (value: string): AdminCopy => ({ + refuser, + verb: "propose an upgrade", + signing: `Signing queues ${value}.`, + done: "Upgrade proposed", + doneBody: `${value} can apply once ${WAITS[refuser]} passes.`, + }) + +const VAULT_APPLY_REASONS = { + 1000: "The vault is paused; unpause it first.", + 6046: "The vault is winding down, so no change can apply.", +} + +const applyChangeCopy = + (refuser: TimelockRefuser) => + (value: string): AdminCopy => ({ + refuser, + ...(refuser === "vault" ? { reasons: VAULT_APPLY_REASONS } : {}), + verb: "apply the change", + signing: `Signing applies ${value}.`, + done: "Change applied", + doneBody: `Applied ${value}.`, + }) + +const cancelChangeCopy = + (refuser: TimelockRefuser) => + (value: string): AdminCopy => ({ + refuser, + verb: "cancel the change", + signing: `Signing withdraws ${value}.`, + done: "Change cancelled", + doneBody: `Withdrew ${value}.`, + }) + +const proposeWindDownCopy = (value: string): AdminCopy => ({ + refuser: "vault", + verb: "propose a wind-down", + signing: "Signing starts the wind-down countdown.", + done: "Wind-down proposed", + doneBody: value, +}) + +const cancelWindDownCopy = (): AdminCopy => ({ + refuser: "vault", + verb: "cancel the wind-down", + signing: "Signing withdraws the wind-down.", + done: "Wind-down cancelled", + doneBody: "The vault stays open.", +}) + +export const GOVERNANCE_COPY: Record< + GovernanceModalAction, + (value: string) => AdminCopy +> = { + "grant-vault-role": grantCopy("vault"), + "revoke-vault-role": revokeCopy("vault"), + "grant-oracle-role": grantCopy("oracle"), + "revoke-oracle-role": revokeCopy("oracle"), + "grant-token-role": grantCopy("share token"), + "revoke-token-role": revokeCopy("share token"), + "hand-over-vault-admin": handOverCopy("vault"), + "cancel-vault-handover": cancelHandoverCopy("vault"), + "accept-vault-admin": acceptAdminCopy("vault"), + "hand-over-oracle-admin": handOverCopy("oracle"), + "cancel-oracle-handover": cancelHandoverCopy("oracle"), + "accept-oracle-admin": acceptAdminCopy("oracle"), + "hand-over-token-admin": handOverCopy("share token"), + "cancel-token-handover": cancelHandoverCopy("share token"), + "accept-token-admin": acceptAdminCopy("share token"), + "propose-grant-role": proposeCopy( + "vault", + "propose a role grant", + "the grant of", + ), + "propose-revoke-role": proposeCopy( + "vault", + "propose a role revoke", + "the revoke of", + ), + "propose-admin": proposeCopy( + "vault", + "propose the hand-over", + "the hand-over to", + ), + "propose-token-grant-role": proposeCopy( + "share token", + "propose a role grant", + "the grant of", + ), + "propose-token-revoke-role": proposeCopy( + "share token", + "propose a role revoke", + "the revoke of", + ), + "propose-token-admin": proposeCopy( + "share token", + "propose the hand-over", + "the hand-over to", + ), + "propose-oracle-grant-role": proposeCopy( + "oracle", + "propose a role grant", + "the grant of", + ), + "propose-oracle-revoke-role": proposeCopy( + "oracle", + "propose a role revoke", + "the revoke of", + ), + "propose-oracle-admin": proposeCopy( + "oracle", + "propose the hand-over", + "the hand-over to", + ), + "propose-code-upgrade": proposalCopy("vault"), + "propose-upgrade-delay": proposalCopy("vault"), + "propose-oracle-upgrade": proposalCopy("oracle"), + "apply-change": applyChangeCopy("vault"), + "cancel-change": cancelChangeCopy("vault"), + "apply-token-change": applyChangeCopy("share token"), + "cancel-token-change": cancelChangeCopy("share token"), + "apply-oracle-change": applyChangeCopy("oracle"), + "cancel-oracle-change": cancelChangeCopy("oracle"), + "propose-wind-down": proposeWindDownCopy, + "cancel-wind-down": cancelWindDownCopy, +} diff --git a/app/src/components/vault/transactionModal/investor.ts b/app/src/components/vault/transactionModal/investor.ts new file mode 100644 index 00000000..c8ff18b6 --- /dev/null +++ b/app/src/components/vault/transactionModal/investor.ts @@ -0,0 +1,495 @@ +import { AMOUNT_DECIMALS, formatExact } from "@stellar-scaffold/app-lib" +import { type CancelDepositStatus } from "../../../hooks/useCancelDeposit" +import { type CancelRedeemStatus } from "../../../hooks/useCancelRedeem" +import { type ClaimDepositStatus } from "../../../hooks/useClaimDeposit" +import { type ClaimRedeemStatus } from "../../../hooks/useClaimRedeem" +import { type ClaimWindDownStatus } from "../../../hooks/useClaimWindDown" +import { type RequestDepositStatus } from "../../../hooks/useRequestDeposit" +import { type RequestRedeemStatus } from "../../../hooks/useRequestRedeem" +import { GENERIC_REFUSAL, describeFailure } from "./outcome" + +export const NOT_ALLOWLISTED = "Your address is not allowlisted." + +const SHARES_NOT_RETURNABLE = + "Your address cannot receive these shares right now, so they stay with the vault." + +const FROZEN = "Your address is frozen." + +export const HELD_WHILE_FROZEN = `${FROZEN} This request is held until the freeze is lifted.` + +const subscribeContractErrorReason = (code: number): string => { + switch (code) { + case 6007: + return "Enter an amount greater than zero." + case 6009: + return "You already have a subscription request open." + case 6014: + return "That amount is too large for the vault to accept." + case 6046: + return "The vault is winding down and is not accepting new subscriptions." + case 6064: + return "The vault's setup is still open. Subscriptions start once it closes." + case 6044: + return "The vault's price feed is not valid right now, so subscriptions are paused until it is." + case 6076: + return "The vault does not hold enough to cover its pending deposits right now, so it is not taking new subscriptions until it is funded again." + case 6068: + return `${NOT_ALLOWLISTED} Only allowlisted addresses can subscribe.` + case 6071: + return `${FROZEN} It cannot subscribe until the freeze is lifted.` + default: + return GENERIC_REFUSAL + } +} + +const cancelContractErrorReason = (code: number): string => { + switch (code) { + case 6001: + return "This request no longer exists to cancel." + case 6029: + return "This request could not be found." + case 6039: + return "This request has already been priced. Claim your shares instead of cancelling." + case 6041: + return "A price is available, so this request can no longer be cancelled. It will be priced shortly, and your shares are claimable once it is." + case 6076: + return "The vault does not hold enough to return this deposit right now. You can cancel once it is funded again." + case 6071: + return HELD_WHILE_FROZEN + default: + return GENERIC_REFUSAL + } +} + +const cancelRedeemContractErrorReason = ( + code: number, + assetTicker: string, +): string => { + const claimInstead = `Once priced, claim the ${assetTicker} it owes you instead.` + switch (code) { + case 6068: + return `${NOT_ALLOWLISTED} These shares cannot come back to you. ${claimInstead}` + case 6073: + return `${SHARES_NOT_RETURNABLE} ${claimInstead}` + default: + return cancelContractErrorReason(code) + } +} + +const claimContractErrorReason = (code: number): string => { + switch (code) { + case 6001: + return "You have no request to claim." + case 6014: + return "The share conversion for this claim is too large to complete." + case 6029: + return "This request could not be found." + case 6031: + return "The vault has not published a valid price for this request yet." + case 6035: + return "This request has already been claimed." + case 6037: + return "The vault does not yet hold enough in reserve to cover this claim. Try again shortly." + case 6071: + return HELD_WHILE_FROZEN + default: + return GENERIC_REFUSAL + } +} + +const redeemContractErrorReason = (code: number): string => { + switch (code) { + case 6007: + return "Enter an amount greater than zero." + case 6009: + return "You already have a redemption request open." + case 6014: + return "That amount is too large for the vault to accept." + case 6029: + return "This request could not be found." + case 6046: + return "The vault is winding down and is not accepting new redemptions." + case 6064: + return "The vault's setup is still open. Redemptions start once it closes." + case 6071: + return "Your address or some of these shares are frozen. Redeem only your free shares, or wait until the freeze is lifted." + default: + return GENERIC_REFUSAL + } +} + +const claimRedeemContractErrorReason = ( + code: number, + ticker: string, + uncovered?: string, +): string => { + switch (code) { + case 6034: + return "This request has not priced yet. Check back after the vault's next update." + case 6037: + return uncovered !== undefined + ? `The vault is ${uncovered} short of covering every priced claim. Try again once the reserve catches up.` + : `The vault does not yet hold enough ${ticker} in reserve to cover this claim. Try again shortly.` + case 6073: + return `The price left nothing to pay out, so the vault tried to return your shares. ${SHARES_NOT_RETURNABLE}` + default: + return claimContractErrorReason(code) + } +} + +const windDownClaimContractErrorReason = (code: number): string => { + switch (code) { + case 6051: + return "The wind-down has not made its first payout yet. Check back once it does." + case 6054: + return "You have nothing to claim from the wind-down right now." + case 6071: + return HELD_WHILE_FROZEN + default: + return GENERIC_REFUSAL + } +} + +export const describeSubscribeStatus = ( + status: RequestDepositStatus, + amount: string, + ticker: string, +): { heading: string; body: string; hash?: string } | undefined => { + switch (status.status) { + case "idle": + return undefined + case "preparing": + return { + heading: "Preparing your request", + body: "We are getting your request ready. Your wallet will ask you to approve it next.", + } + case "awaiting-signature": + return { + heading: "Confirm in your wallet", + body: `Your shares are set once the vault prices your request, not today. Signing locks ${amount} ${ticker} in escrow until then.`, + } + case "submitted": + return { + heading: "Sending your request", + body: "Your request is on its way to the network. This should only take a moment, while pricing comes later and takes longer. Closing this window will not cancel it.", + hash: status.hash, + } + case "confirmed": + return { + heading: "Request submitted", + body: `${amount} ${ticker} prices at the vault's next update.`, + hash: status.hash, + } + case "failed": + return { + ...describeFailure(status.failure, subscribeContractErrorReason), + hash: status.hash, + } + } +} + +export const describeCancelStatus = ( + status: CancelDepositStatus, + amount: string, + ticker: string, +): { heading: string; body: string; hash?: string } | undefined => { + switch (status.status) { + case "idle": + return undefined + case "preparing": + return { + heading: "Preparing your cancellation", + body: "We are getting your cancellation ready. Your wallet will ask you to approve it next.", + } + case "awaiting-signature": + return { + heading: "Confirm in your wallet", + body: `Your request is withdrawn, not priced. Signing returns ${amount} ${ticker} from escrow to your wallet.`, + } + case "submitted": + return { + heading: "Sending your cancellation", + body: "Your cancellation is on its way to the network. This should only take a moment. Closing this window will not stop it.", + hash: status.hash, + } + case "confirmed": + return { + heading: "Request cancelled", + body: `${formatExact(status.refundedAmount, AMOUNT_DECIMALS)} ${ticker} has been returned to your wallet.`, + hash: status.hash, + } + case "failed": + return { + ...describeFailure(status.failure, cancelContractErrorReason), + hash: status.hash, + } + } +} + +export const describeCancelRedeemStatus = ( + status: CancelRedeemStatus, + amount: string, + ticker: string, + assetTicker: string, + windingDown: boolean, +): { heading: string; body: string; hash?: string } | undefined => { + switch (status.status) { + case "idle": + return undefined + case "preparing": + return { + heading: "Preparing your cancellation", + body: "We are getting your cancellation ready. Your wallet will ask you to approve it next.", + } + case "awaiting-signature": + return { + heading: "Confirm in your wallet", + body: windingDown + ? `Your request is withdrawn, not priced. The vault is winding down, so signing moves ${amount} ${ticker} into your wind-down claim instead of your wallet.` + : `Your request is withdrawn, not priced. Signing returns ${amount} ${ticker} from escrow to your wallet.`, + } + case "submitted": + return { + heading: "Sending your cancellation", + body: "Your cancellation is on its way to the network. This should only take a moment. Closing this window will not stop it.", + hash: status.hash, + } + case "confirmed": + return status.surrendered + ? { + heading: MOVED_TO_WIND_DOWN, + body: "The vault is winding down, so these shares were added to your wind-down claim instead of coming back to your wallet.", + hash: status.hash, + } + : { + heading: "Request cancelled", + body: `${formatExact(status.returnedShares, AMOUNT_DECIMALS)} ${ticker} has been returned to your wallet.`, + hash: status.hash, + } + case "failed": + return { + ...describeFailure(status.failure, (code) => + cancelRedeemContractErrorReason(code, assetTicker), + ), + hash: status.hash, + } + } +} + +const MOVED_TO_WIND_DOWN = "Moved to your wind-down claim" + +const convertedOutcome = ( + queuedInto: bigint, + ticker: string, +): { heading: string; body: string } => + queuedInto === 0n + ? { + heading: MOVED_TO_WIND_DOWN, + body: `${NOT_ALLOWLISTED} Your shares were added to your wind-down claim instead.`, + } + : { + heading: "Queued as a redemption", + body: `${NOT_ALLOWLISTED} Your shares were queued as a redemption instead, and pay out in ${ticker} at the next price.`, + } + +export const describeClaimStatus = ( + status: ClaimDepositStatus, + amount: string, + ticker: string, + shareTicker: string, +): { heading: string; body: string; hash?: string } | undefined => { + switch (status.status) { + case "idle": + return undefined + case "preparing": + return { + heading: "Preparing your claim", + body: "We are getting your claim ready. Your wallet will ask you to approve it next.", + } + case "awaiting-signature": + return { + heading: "Confirm in your wallet", + body: `You receive what you're owed. If the price leaves no shares to claim, ${amount} ${ticker} returns to your wallet instead.`, + } + case "submitted": + return { + heading: "Sending your claim", + body: "Your claim is on its way to the network. This should only take a moment. Closing this window will not stop it.", + hash: status.hash, + } + case "confirmed": + if (status.queuedInto !== undefined) + return { + ...convertedOutcome(status.queuedInto, ticker), + hash: status.hash, + } + return status.sharesMinted > 0n + ? { + heading: "Shares claimed", + body: `${formatExact(status.sharesMinted, AMOUNT_DECIMALS)} ${shareTicker} has been added to your wallet.`, + hash: status.hash, + } + : { + heading: "Deposit returned", + body: `The price left no shares to claim, so ${amount} ${ticker} has been returned to your wallet instead.`, + hash: status.hash, + } + case "failed": + return { + ...describeFailure(status.failure, claimContractErrorReason), + hash: status.hash, + } + } +} + +export const describeClaimWindDownStatus = ( + status: ClaimWindDownStatus, + amount: string, + ticker: string, + shareTicker: string, +): { heading: string; body: string; hash?: string } | undefined => { + switch (status.status) { + case "idle": + return undefined + case "preparing": + return { + heading: "Preparing your claim", + body: "We are getting your claim ready. Your wallet will ask you to approve it next.", + } + case "awaiting-signature": + return { + heading: "Confirm in your wallet", + body: `You receive ${amount} ${ticker}, your share of the wind-down so far. Signing also turns in your unfrozen ${shareTicker}, so later rounds pay you for them.`, + } + case "submitted": + return { + heading: "Sending your claim", + body: "Your claim is on its way to the network. This should only take a moment. Closing this window will not stop it.", + hash: status.hash, + } + case "confirmed": + return { + ...windDownClaimOutcome(status, ticker, shareTicker), + hash: status.hash, + } + case "failed": + return { + ...describeFailure(status.failure, windDownClaimContractErrorReason), + hash: status.hash, + } + } +} + +const windDownClaimOutcome = ( + { + assetsClaimed, + sharesSurrendered, + }: Extract, + ticker: string, + shareTicker: string, +): { heading: string; body: string } => { + const turnedIn = + sharesSurrendered > 0n + ? `${formatExact(sharesSurrendered, AMOUNT_DECIMALS)} ${shareTicker} were turned in, and later rounds pay you for them.` + : "Later rounds pay what you are owed." + return assetsClaimed > 0n + ? { + heading: `${ticker} claimed`, + body: `${formatExact(assetsClaimed, AMOUNT_DECIMALS)} ${ticker} has been added to your wallet. ${turnedIn}`, + } + : { + heading: "Shares turned in", + body: `Nothing was paid this time. ${turnedIn}`, + } +} + +export const describeRedeemStatus = ( + status: RequestRedeemStatus, + amount: string, + ticker: string, +): { heading: string; body: string; hash?: string } | undefined => { + switch (status.status) { + case "idle": + return undefined + case "preparing": + return { + heading: "Preparing your redemption request", + body: "We are getting your redemption request ready. Your wallet will ask you to approve it next.", + } + case "awaiting-signature": + return { + heading: "Confirm in your wallet", + body: `What you're owed is set once the vault prices your request, not today. Signing locks ${amount} ${ticker} in escrow until then.`, + } + case "submitted": + return { + heading: "Sending your redemption request", + body: "Your redemption request is on its way to the network. This should only take a moment, while pricing comes later and takes longer. Closing this window will not cancel it.", + hash: status.hash, + } + case "confirmed": + return { + heading: "Redemption request submitted", + body: `${amount} ${ticker} prices at the vault's next update.`, + hash: status.hash, + } + case "failed": + return { + ...describeFailure(status.failure, redeemContractErrorReason), + hash: status.hash, + } + } +} + +export const describeClaimRedeemStatus = ( + status: ClaimRedeemStatus, + amount: string, + ticker: string, + uncovered?: string, +): { heading: string; body: string; hash?: string } | undefined => { + switch (status.status) { + case "idle": + return undefined + case "preparing": + return { + heading: "Preparing your claim", + body: "We are getting your claim ready. Your wallet will ask you to approve it next.", + } + case "awaiting-signature": + return { + heading: "Confirm in your wallet", + body: `You receive what you're owed. Signing sends ${amount} ${ticker} to your wallet.`, + } + case "submitted": + return { + heading: "Sending your claim", + body: "Your claim is on its way to the network. This should only take a moment. Closing this window will not stop it.", + hash: status.hash, + } + case "confirmed": + return status.assetsClaimed > 0n + ? { + heading: `${ticker} claimed`, + body: `${formatExact(status.assetsClaimed, AMOUNT_DECIMALS)} ${ticker} has been added to your wallet.`, + hash: status.hash, + } + : status.sharesReturned + ? { + heading: "Shares returned", + body: "The price left nothing to claim, so your shares have been returned to your wallet instead.", + hash: status.hash, + } + : { + heading: "Nothing to pay out", + body: "The price left nothing to pay out, and the vault is winding down, so these shares were burned.", + hash: status.hash, + } + case "failed": + return { + ...describeFailure(status.failure, (code) => + claimRedeemContractErrorReason(code, ticker, uncovered), + ), + hash: status.hash, + } + } +} diff --git a/app/src/components/vault/transactionModal/outcome.ts b/app/src/components/vault/transactionModal/outcome.ts new file mode 100644 index 00000000..e61dc77f --- /dev/null +++ b/app/src/components/vault/transactionModal/outcome.ts @@ -0,0 +1,76 @@ +import { formatDate, type SubmitFailure } from "@stellar-scaffold/app-lib" +import { type TransactionFailure } from "../../../hooks/useContractTransaction" + +export const GENERIC_REFUSAL = + "The vault refused this request. Try again, and contact support if it keeps happening." + +const INVESTOR_FOLLOW_UP = "check your requests before doing anything else" +const NETWORK_REASONS: Record, string> = { + expired: "The transaction expired before it was submitted.", + "sequence-consumed": + "The account moved on; this transaction is no longer valid.", + "bad-auth": "The signatures do not match the account's threshold.", + fee: "The account cannot pay the fee.", + "fee-too-low": "The fee is below the network minimum. Propose it again.", + "not-accepted": "The network did not accept it. Try again.", +} + +const unknownOutcome = (followUp: string) => ({ + heading: "Something went wrong", + body: `We could not confirm whether this reached the network. We don't yet know if it went through, so ${followUp}.`, +}) + +export const describeFailure = ( + failure: TransactionFailure, + contractErrorReason: (code: number) => string, + followUp = INVESTOR_FOLLOW_UP, + refuser = "vault", +): { heading: string; body: string } => { + switch (failure.kind) { + case "network": + if (failure.reason === "unknown") return unknownOutcome(followUp) + return { + heading: "The network rejected it", + body: NETWORK_REASONS[failure.reason], + } + case "declined": + return { + heading: "You declined the request", + body: "You chose not to sign, so nothing was sent. Try again when you're ready.", + } + case "unsigned": + return { + heading: "The wallet did not sign", + body: "Nothing was sent. Try again when you're ready.", + } + case "cannot-authorize": + return { + heading: "This wallet cannot authorize", + body: "This wallet cannot sign authorizations. Use Freighter, Hana or WalletConnect.", + } + case "contract-error": + return { + heading: `The ${refuser} refused this request`, + body: contractErrorReason(failure.code), + } + case "interrupted": + return { + heading: "We couldn't reach the vault", + body: "Nothing was requested from your wallet. Try again when you're ready.", + } + case "stale": + return { + heading: "The transaction went stale", + body: "The account moved on since it was prepared, so nothing was sent. Try again.", + } + case "unknown": + return unknownOutcome(followUp) + } +} + +export const describeShared = (expiresAt: Date | null) => { + const invitation = "Share the link with the next signer." + if (expiresAt === null) return invitation + const seconds = BigInt(Math.floor(expiresAt.getTime() / 1000)) + return `${invitation} Valid until ${formatDate(seconds)}.` +} diff --git a/app/src/components/vault/transactionModal/steps.ts b/app/src/components/vault/transactionModal/steps.ts new file mode 100644 index 00000000..8c7acb53 --- /dev/null +++ b/app/src/components/vault/transactionModal/steps.ts @@ -0,0 +1,88 @@ +import { type ActivateWindDownStatus } from "../../../hooks/useActivateWindDown" +import { type AttestStatus } from "../../../hooks/useAttest" +import { type CancelDepositStatus } from "../../../hooks/useCancelDeposit" +import { type CancelRedeemStatus } from "../../../hooks/useCancelRedeem" +import { type ClaimDepositStatus } from "../../../hooks/useClaimDeposit" +import { type ClaimRedeemStatus } from "../../../hooks/useClaimRedeem" +import { type CloseEpochStatus } from "../../../hooks/useCloseEpoch" +import { type TransactionStatus } from "../../../hooks/useContractTransaction" +import { type DeployToCustodianStatus } from "../../../hooks/useDeployToCustodian" +import { type FinalizeWindDownRoundStatus } from "../../../hooks/useFinalizeWindDownRound" +import { type FulfillEpochStatus } from "../../../hooks/useFulfillEpoch" +import { type FundStatus } from "../../../hooks/useFund" +import { type RequestDepositStatus } from "../../../hooks/useRequestDeposit" +import { type RequestRedeemStatus } from "../../../hooks/useRequestRedeem" +import styles from "../TransactionModal.module.css" + +type StepId = "signature" | "network" | "recorded" +type StepState = "done" | "current" | "upcoming" | "failed" + +export const STEP_ORDER: StepId[] = ["signature", "network", "recorded"] + +export const stepLabel: Record = { + signature: "Approved in your wallet", + network: "Sent to the network", + recorded: "Recorded", +} + +export const computeSteps = ( + status: + | RequestDepositStatus + | CancelDepositStatus + | CancelRedeemStatus + | ClaimDepositStatus + | RequestRedeemStatus + | ClaimRedeemStatus + | CloseEpochStatus + | FulfillEpochStatus + | AttestStatus + | DeployToCustodianStatus + | FundStatus + | ActivateWindDownStatus + | FinalizeWindDownRoundStatus + | TransactionStatus, +): Record | undefined => { + switch (status.status) { + case "idle": + return undefined + case "preparing": + return { + signature: "upcoming", + network: "upcoming", + recorded: "upcoming", + } + case "awaiting-signature": + return { signature: "current", network: "upcoming", recorded: "upcoming" } + case "submitted": + return { signature: "done", network: "current", recorded: "upcoming" } + case "shared": + return { signature: "done", network: "upcoming", recorded: "upcoming" } + case "confirmed": + return { signature: "done", network: "done", recorded: "done" } + case "failed": + if (status.failure.kind === "unknown" && status.hash !== undefined) { + return { signature: "done", network: "done", recorded: "failed" } + } + if ( + status.failure.kind === "unknown" || + status.failure.kind === "network" || + status.failure.kind === "stale" + ) { + return { signature: "done", network: "failed", recorded: "upcoming" } + } + return { signature: "failed", network: "upcoming", recorded: "upcoming" } + } +} + +export const stepStateClassName: Record = { + done: styles.stepDone, + current: styles.stepCurrent, + failed: styles.stepFailed, + upcoming: styles.stepUpcoming, +} + +export const stepAnnouncement: Partial> = { + done: "done", + current: "in progress", + failed: "failed", +} diff --git a/app/src/components/vault/transactionModal/types.ts b/app/src/components/vault/transactionModal/types.ts new file mode 100644 index 00000000..edc78882 --- /dev/null +++ b/app/src/components/vault/transactionModal/types.ts @@ -0,0 +1,180 @@ +import { type ActivateWindDownStatus } from "../../../hooks/useActivateWindDown" +import { type AttestStatus } from "../../../hooks/useAttest" +import { type CancelDepositStatus } from "../../../hooks/useCancelDeposit" +import { type CancelRedeemStatus } from "../../../hooks/useCancelRedeem" +import { type ClaimDepositStatus } from "../../../hooks/useClaimDeposit" +import { type ClaimRedeemStatus } from "../../../hooks/useClaimRedeem" +import { type ClaimWindDownStatus } from "../../../hooks/useClaimWindDown" +import { type CloseEpochStatus } from "../../../hooks/useCloseEpoch" +import { type TransactionStatus } from "../../../hooks/useContractTransaction" +import { type DeployToCustodianStatus } from "../../../hooks/useDeployToCustodian" +import { type FinalizeWindDownRoundStatus } from "../../../hooks/useFinalizeWindDownRound" +import { type FulfillEpochStatus } from "../../../hooks/useFulfillEpoch" +import { type FundStatus } from "../../../hooks/useFund" +import { type RequestDepositStatus } from "../../../hooks/useRequestDeposit" +import { type RequestRedeemStatus } from "../../../hooks/useRequestRedeem" +import { type ComplianceActionId } from "../../../pages/compliance" +import { type GovernanceActionId } from "../../../pages/governance" + +export type EmergencyModalAction = + | "pause-vault" + | "unpause-vault" + | "pause-nav" + | "unpause-nav" + | "clear-latest" + | "pause-token" + | "unpause-token" + +export type ConfigurationModalAction = + | "set-deposit-cap" + | "set-custodian" + | "set-notice" + | "set-wind-down-delay" + | "set-min-epoch-duration" + | "propose-custodian" + | "propose-notice" + | "propose-wind-down-delay" + | "propose-min-epoch-duration" + | "set-oracle-config" + | "propose-oracle-config" + | "set-compliance" + | "propose-compliance" + | "set-identity-verifier" + | "propose-identity-verifier" + +export type GovernanceModalAction = + GovernanceActionId | "propose-oracle-revoke-role" + +export type ComplianceModalAction = ComplianceActionId + +export type AdminModalAction = + | EmergencyModalAction + | ConfigurationModalAction + | GovernanceModalAction + | ComplianceModalAction + +export type TransactionModalProps = + | { + action: EmergencyModalAction + status: TransactionStatus + onClose: () => void + onRetry: () => void + } + | { + action: + ConfigurationModalAction | GovernanceModalAction | ComplianceModalAction + status: TransactionStatus + value: string + onClose: () => void + onRetry: () => void + } + | { + action: "subscribe" + status: RequestDepositStatus + amount: string + ticker: string + onClose: () => void + onRetry: () => void + } + | { + action: "cancel" + status: CancelDepositStatus + amount: string + ticker: string + onClose: () => void + onRetry: () => void + } + | { + action: "cancel-redeem" + status: CancelRedeemStatus + amount: string + ticker: string + assetTicker: string + windingDown?: boolean + onClose: () => void + onRetry: () => void + } + | { + action: "claim" + status: ClaimDepositStatus + amount: string + ticker: string + shareTicker: string + onClose: () => void + onRetry: () => void + } + | { + action: "redeem" + status: RequestRedeemStatus + amount: string + ticker: string + onClose: () => void + onRetry: () => void + } + | { + action: "claim-redeem" + status: ClaimRedeemStatus + amount: string + ticker: string + uncovered?: string + onClose: () => void + onRetry: () => void + } + | { + action: "claim-wind-down" + status: ClaimWindDownStatus + amount: string + ticker: string + shareTicker: string + onClose: () => void + onRetry: () => void + } + | { + action: "close-epoch" + status: CloseEpochStatus + epoch: string + onClose: () => void + onRetry: () => void + } + | { + action: "fulfill-epoch" + status: FulfillEpochStatus + epoch: string + onClose: () => void + onRetry: () => void + } + | { + action: "attest" + status: AttestStatus + price: string + validFor: string | null + onClose: () => void + onRetry: () => void + } + | { + action: "deploy" + status: DeployToCustodianStatus + amount: string + onClose: () => void + onRetry: () => void + } + | { + action: "fund" + status: FundStatus + amount: string + onClose: () => void + onRetry: () => void + } + | { + action: "activate-wind-down" + status: ActivateWindDownStatus + onClose: () => void + onRetry: () => void + } + | { + action: "finalize-round" + status: FinalizeWindDownRoundStatus + round: string + onClose: () => void + onRetry: () => void + } From d1d371b2987cc25f5e4fc73d50cf1f468c6e6767 Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Mon, 2 Nov 2026 13:27:32 -0300 Subject: [PATCH 07/18] test(app): split the TransactionModal tests by section The 3,300-line test file now follows the component: the modal's own behaviour stays in `TransactionModal.test.tsx`, and each section's describe blocks move to a test file beside its module. The render helpers move to one shared module. Test names and assertions moved unchanged; each file mocks the same app-lib helpers the original did. Refs #420 --- .../vault/TransactionModal.test.tsx | 2897 +---------------- .../transactionModal/compliance.test.tsx | 139 + .../transactionModal/configuration.test.tsx | 259 ++ .../vault/transactionModal/cycle.test.tsx | 670 ++++ .../vault/transactionModal/emergency.test.tsx | 263 ++ .../transactionModal/governance.test.tsx | 333 ++ .../vault/transactionModal/investor.test.tsx | 807 +++++ .../investorOutcomes.test.tsx | 231 ++ .../vault/transactionModal/renderModal.tsx | 303 ++ 9 files changed, 3008 insertions(+), 2894 deletions(-) create mode 100644 app/src/components/vault/transactionModal/compliance.test.tsx create mode 100644 app/src/components/vault/transactionModal/configuration.test.tsx create mode 100644 app/src/components/vault/transactionModal/cycle.test.tsx create mode 100644 app/src/components/vault/transactionModal/emergency.test.tsx create mode 100644 app/src/components/vault/transactionModal/governance.test.tsx create mode 100644 app/src/components/vault/transactionModal/investor.test.tsx create mode 100644 app/src/components/vault/transactionModal/investorOutcomes.test.tsx create mode 100644 app/src/components/vault/transactionModal/renderModal.tsx diff --git a/app/src/components/vault/TransactionModal.test.tsx b/app/src/components/vault/TransactionModal.test.tsx index 99b5357e..70b2ff88 100644 --- a/app/src/components/vault/TransactionModal.test.tsx +++ b/app/src/components/vault/TransactionModal.test.tsx @@ -1,34 +1,10 @@ -import { - type Amount, - formatDate, - type Price, - type SubmitFailure, -} from "@stellar-scaffold/app-lib" import type * as AppLib from "@stellar-scaffold/app-lib" -import { act, fireEvent, render, screen, within } from "@testing-library/react" +import { fireEvent, render, screen, within } from "@testing-library/react" import { StrictMode } from "react" import { describe, expect, it, vi } from "vitest" -import { type ActivateWindDownStatus } from "../../hooks/useActivateWindDown" -import { type AttestStatus } from "../../hooks/useAttest" -import { type CancelDepositStatus } from "../../hooks/useCancelDeposit" -import { type CancelRedeemStatus } from "../../hooks/useCancelRedeem" -import { type ClaimDepositStatus } from "../../hooks/useClaimDeposit" -import { type ClaimRedeemStatus } from "../../hooks/useClaimRedeem" -import { type ClaimWindDownStatus } from "../../hooks/useClaimWindDown" -import { type CloseEpochStatus } from "../../hooks/useCloseEpoch" -import { type TransactionStatus } from "../../hooks/useContractTransaction" -import { type DeployToCustodianStatus } from "../../hooks/useDeployToCustodian" -import { type FinalizeWindDownRoundStatus } from "../../hooks/useFinalizeWindDownRound" -import { type FulfillEpochStatus } from "../../hooks/useFulfillEpoch" -import { type FundStatus } from "../../hooks/useFund" import { type RequestDepositStatus } from "../../hooks/useRequestDeposit" -import { type RequestRedeemStatus } from "../../hooks/useRequestRedeem" -import TransactionModal, { - type ComplianceModalAction, - type ConfigurationModalAction, - type EmergencyModalAction, - type GovernanceModalAction, -} from "./TransactionModal" +import TransactionModal from "./TransactionModal" +import { renderModal } from "./transactionModal/renderModal" const { explorerTransactionMock } = vi.hoisted(() => ({ explorerTransactionMock: vi.fn(() => null as string | null), @@ -41,212 +17,6 @@ vi.mock("@stellar-scaffold/app-lib", async (importOriginal) => ({ explorerTransaction: explorerTransactionMock, })) -const renderModal = (status: RequestDepositStatus) => { - const onClose = vi.fn() - const onRetry = vi.fn() - const view = render( - , - ) - return { ...view, onClose, onRetry } -} - -const renderCancelModal = (status: CancelDepositStatus) => { - const onClose = vi.fn() - const onRetry = vi.fn() - const view = render( - , - ) - return { ...view, onClose, onRetry } -} - -const renderCancelRedeemModal = (status: CancelRedeemStatus) => { - const onClose = vi.fn() - const onRetry = vi.fn() - const view = render( - , - ) - return { ...view, onClose, onRetry } -} - -const renderClaimModal = (status: ClaimDepositStatus) => { - const onClose = vi.fn() - const onRetry = vi.fn() - const view = render( - , - ) - return { ...view, onClose, onRetry } -} - -const renderRedeemModal = (status: RequestRedeemStatus) => { - const onClose = vi.fn() - const onRetry = vi.fn() - const view = render( - , - ) - return { ...view, onClose, onRetry } -} - -const renderClaimRedeemModal = (status: ClaimRedeemStatus) => { - const onClose = vi.fn() - const onRetry = vi.fn() - const view = render( - , - ) - return { ...view, onClose, onRetry } -} - -const renderCloseEpochModal = (status: CloseEpochStatus, epoch = "5") => { - const onClose = vi.fn() - const onRetry = vi.fn() - const view = render( - , - ) - return { ...view, onClose, onRetry } -} - -const renderFulfillEpochModal = (status: FulfillEpochStatus) => { - const onClose = vi.fn() - const onRetry = vi.fn() - const view = render( - , - ) - return { ...view, onClose, onRetry } -} - -const renderAttestModal = ( - status: AttestStatus, - validFor: string | null = "1d", -) => { - const onClose = vi.fn() - const onRetry = vi.fn() - const view = render( - , - ) - return { ...view, onClose, onRetry } -} - -const renderDeployModal = (status: DeployToCustodianStatus) => { - const onClose = vi.fn() - const onRetry = vi.fn() - const view = render( - , - ) - return { ...view, onClose, onRetry } -} - -const renderFundModal = (status: FundStatus) => { - const onClose = vi.fn() - const onRetry = vi.fn() - const view = render( - , - ) - return { ...view, onClose, onRetry } -} - -const renderActivateModal = (status: ActivateWindDownStatus) => { - const onClose = vi.fn() - const onRetry = vi.fn() - const view = render( - , - ) - return { ...view, onClose, onRetry } -} - -const renderFinalizeModal = (status: FinalizeWindDownRoundStatus) => { - const onClose = vi.fn() - const onRetry = vi.fn() - const view = render( - , - ) - return { ...view, onClose, onRetry } -} - describe("TransactionModal", () => { it("renders nothing while idle", () => { renderModal({ status: "idle" }) @@ -637,2664 +407,3 @@ describe("TransactionModal", () => { consoleError.mockRestore() }) }) - -describe("TransactionModal, cancelling", () => { - it("opens with a preparing state naming the cancellation, not the request", () => { - renderCancelModal({ status: "preparing" }) - - expect( - screen.getByRole("heading", { name: "Preparing your cancellation" }), - ).toBeTruthy() - }) - - it("tells the investor their deposit returns to their wallet, honestly, without escrow or pricing language", () => { - renderCancelModal({ status: "awaiting-signature" }) - - expect( - screen.getByRole("heading", { name: "Confirm in your wallet" }), - ).toBeTruthy() - expect(screen.getByText(/150\.00 USDC/)).toBeTruthy() - expect(screen.getByText(/from escrow/)).toBeTruthy() - expect(screen.queryByText(/into escrow/)).toBeNull() - expect(screen.getByText(/withdrawn, not priced/)).toBeTruthy() - }) - - it("tells the investor the cancellation is on its way, distinct from the subscribe copy", () => { - renderCancelModal({ status: "submitted", hash: "a".repeat(64) }) - - expect( - screen.getByRole("heading", { name: "Sending your cancellation" }), - ).toBeTruthy() - expect(screen.getByText(/on its way to the network/)).toBeTruthy() - }) - - it("shows the returned amount once confirmed, with no batch or pricing claim", () => { - renderCancelModal({ - status: "confirmed", - refundedAmount: 150_0000000n as Amount, - hash: "b".repeat(64), - }) - - expect( - screen.getByRole("heading", { name: "Request cancelled" }), - ).toBeTruthy() - expect(screen.getByText(/150\.00 USDC/)).toBeTruthy() - expect(screen.queryByText(/Batch/)).toBeNull() - expect(screen.queryByText(/attestation/)).toBeNull() - expect(screen.queryByText(/Epoch/)).toBeNull() - }) - - it("reports the amount the vault returned, not the one captured when the investor pressed cancel", () => { - renderCancelModal({ - status: "confirmed", - refundedAmount: 275_5000000n as Amount, - hash: "b".repeat(64), - }) - - expect(screen.getByText(/275\.50 USDC/)).toBeTruthy() - expect(screen.queryByText(/150\.00/)).toBeNull() - }) - - it("renders the refunded amount at full precision, not rounded to two fraction digits", () => { - renderCancelModal({ - status: "confirmed", - refundedAmount: 952380952n as Amount, - hash: "b".repeat(64), - }) - - expect(screen.getByText(/95\.2380952 USDC/)).toBeTruthy() - }) - - it("names the vault's own reason for a cancel-specific contract refusal, distinct from subscribe's codes", () => { - renderCancelModal({ - status: "failed", - failure: { kind: "contract-error", code: 6039 }, - }) - - expect( - screen.getByRole("heading", { name: "The vault refused this request" }), - ).toBeTruthy() - expect( - screen.getByText(/already been priced. Claim your shares instead/), - ).toBeTruthy() - }) - - it("names PriceAvailable distinctly from AlreadyPriced, without sending the investor to claim yet", () => { - renderCancelModal({ - status: "failed", - failure: { kind: "contract-error", code: 6041 }, - }) - - expect(screen.getByText(/A price is available/)).toBeTruthy() - expect(screen.getByText(/claimable once it is/)).toBeTruthy() - expect(screen.queryByText(/Claim your shares instead/)).toBeNull() - }) - - it("names RequestNotFound for a cancellation of a request that no longer exists", () => { - renderCancelModal({ - status: "failed", - failure: { kind: "contract-error", code: 6001 }, - }) - - expect(screen.getByText(/no longer exists to cancel/)).toBeTruthy() - }) - - it("names EpochNotFound as a request that could not be found", () => { - renderCancelModal({ - status: "failed", - failure: { kind: "contract-error", code: 6029 }, - }) - - expect(screen.getByText(/This request could not be found/)).toBeTruthy() - }) - - it("names EscrowNotCovered as a deposit the vault cannot return until it is funded again", () => { - renderCancelModal({ - status: "failed", - failure: { kind: "contract-error", code: 6076 }, - }) - - expect( - screen.getByRole("heading", { name: "The vault refused this request" }), - ).toBeTruthy() - expect( - screen.getByText(/You can cancel once it is funded again/), - ).toBeTruthy() - }) - - it("falls back to a generic refusal for a cancel error it does not recognize, without the raw code", () => { - renderCancelModal({ - status: "failed", - failure: { kind: "contract-error", code: 9999 }, - }) - - expect(screen.getByText(/contact support/)).toBeTruthy() - expect(screen.queryByText(/9999/)).toBeNull() - }) - - it("falls back to a generic refusal for 304 on a deposit cancellation, since only the share token's identity check can raise it", () => { - renderCancelModal({ - status: "failed", - failure: { kind: "contract-error", code: 304 }, - }) - - expect(screen.getByText(/contact support/)).toBeTruthy() - expect(screen.queryByText(/304/)).toBeNull() - expect(screen.queryByText(/shares cannot be returned/)).toBeNull() - }) - - it("reads a declined cancellation as a choice, not a failure, and offers to try again", () => { - const { onRetry } = renderCancelModal({ - status: "failed", - failure: { kind: "declined" }, - }) - - expect( - screen.getByRole("heading", { name: "You declined the request" }), - ).toBeTruthy() - - fireEvent.click(screen.getByRole("button", { name: "Try again" })) - expect(onRetry).toHaveBeenCalledTimes(1) - }) - - it("shows the same step progress machinery for a cancellation in flight", () => { - renderCancelModal({ status: "awaiting-signature" }) - - const steps = screen.getAllByRole("listitem") - expect(steps.map((step) => step.textContent)).toEqual([ - "Approved in your wallet, in progress", - "Sent to the network", - "Recorded", - ]) - }) - - it("is reachable as a dialog and dismissible by its close control", () => { - const { onClose } = renderCancelModal({ status: "awaiting-signature" }) - - fireEvent.click(screen.getByRole("button", { name: "Close" })) - expect(onClose).toHaveBeenCalledTimes(1) - }) -}) - -describe("TransactionModal, cancelling a redemption", () => { - it("opens with a preparing state naming the cancellation", () => { - renderCancelRedeemModal({ status: "preparing" }) - - expect( - screen.getByRole("heading", { name: "Preparing your cancellation" }), - ).toBeTruthy() - }) - - it("tells the investor their shares return to their wallet, honestly, without escrow or pricing language", () => { - renderCancelRedeemModal({ status: "awaiting-signature" }) - - expect( - screen.getByRole("heading", { name: "Confirm in your wallet" }), - ).toBeTruthy() - expect(screen.getByText(/100\.00 vUSDC/)).toBeTruthy() - expect(screen.getByText(/from escrow/)).toBeTruthy() - expect(screen.queryByText(/into escrow/)).toBeNull() - expect(screen.getByText(/withdrawn, not priced/)).toBeTruthy() - }) - - it("shows the returned shares, in the share ticker, once confirmed", () => { - renderCancelRedeemModal({ - status: "confirmed", - returnedShares: 100_0000000n as Amount, - surrendered: false, - hash: "b".repeat(64), - }) - - expect( - screen.getByRole("heading", { name: "Request cancelled" }), - ).toBeTruthy() - expect(screen.getByText(/100\.00 vUSDC/)).toBeTruthy() - expect(screen.queryByText(/Batch/)).toBeNull() - }) - - it("renders the returned shares at full precision, not rounded to two fraction digits", () => { - renderCancelRedeemModal({ - status: "confirmed", - returnedShares: 952380952n as Amount, - surrendered: false, - hash: "b".repeat(64), - }) - - expect(screen.getByText(/95\.2380952 vUSDC/)).toBeTruthy() - }) - - it("names the vault's own reason for a cancel-specific contract refusal, reusing the same table as cancelling a deposit", () => { - renderCancelRedeemModal({ - status: "failed", - failure: { kind: "contract-error", code: 6039 }, - }) - - expect( - screen.getByText(/already been priced. Claim your shares instead/), - ).toBeTruthy() - }) - - it("names NotAllowlisted as a delisted holder whose shares stay queued, pointing to the cash claim instead", () => { - renderCancelRedeemModal({ - status: "failed", - failure: { kind: "contract-error", code: 6068 }, - }) - - expect(screen.getByText(/not allowlisted/)).toBeTruthy() - expect(screen.getByText(/claim the USDC/i)).toBeTruthy() - }) - - it("names HolderNotEligible as shares the address cannot receive, pointing to the cash claim instead", () => { - renderCancelRedeemModal({ - status: "failed", - failure: { kind: "contract-error", code: 6073 }, - }) - - expect( - screen.getByText(/cannot receive these shares right now/), - ).toBeTruthy() - expect(screen.getByText(/claim the USDC/i)).toBeTruthy() - }) - - it("falls back to a generic refusal for 304 on a withdrawal cancellation, since the vault checks the holder itself", () => { - renderCancelRedeemModal({ - status: "failed", - failure: { kind: "contract-error", code: 304 }, - }) - - expect(screen.getByText(/contact support/)).toBeTruthy() - expect(screen.queryByText(/allowlisted/)).toBeNull() - }) - - it("falls back to a generic refusal for a cancel-redeem error it does not recognize, without the raw code", () => { - renderCancelRedeemModal({ - status: "failed", - failure: { kind: "contract-error", code: 9999 }, - }) - - expect(screen.getByText(/contact support/)).toBeTruthy() - expect(screen.queryByText(/9999/)).toBeNull() - }) - - it("is reachable as a dialog and dismissible by its close control", () => { - const { onClose } = renderCancelRedeemModal({ - status: "awaiting-signature", - }) - - fireEvent.click(screen.getByRole("button", { name: "Close" })) - expect(onClose).toHaveBeenCalledTimes(1) - }) -}) - -describe("TransactionModal, claiming", () => { - it("opens with a preparing state naming the claim", () => { - renderClaimModal({ status: "preparing" }) - - expect( - screen.getByRole("heading", { name: "Preparing your claim" }), - ).toBeTruthy() - }) - - it("tells the investor what claiming means before they sign, including the refund path, without naming an epoch", () => { - renderClaimModal({ status: "awaiting-signature" }) - - expect( - screen.getByRole("heading", { name: "Confirm in your wallet" }), - ).toBeTruthy() - expect( - screen.getByText( - "You receive what you're owed. If the price leaves no shares to claim, 150.00 USDC returns to your wallet instead.", - ), - ).toBeTruthy() - expect(screen.queryByText(/Epoch/)).toBeNull() - expect(screen.queryByText(/epoch/)).toBeNull() - }) - - it("tells the investor the claim is on its way, distinct from subscribe and cancel copy", () => { - renderClaimModal({ status: "submitted", hash: "a".repeat(64) }) - - expect( - screen.getByRole("heading", { name: "Sending your claim" }), - ).toBeTruthy() - expect(screen.getByText(/on its way to the network/)).toBeTruthy() - }) - - it("shows the shares minted in the share token, not the deposit token, once confirmed", () => { - renderClaimModal({ - status: "confirmed", - sharesMinted: 100_0000000n as Amount, - hash: "b".repeat(64), - }) - - expect(screen.getByRole("heading", { name: "Shares claimed" })).toBeTruthy() - expect(screen.getByText(/100\.00 vUSDC/)).toBeTruthy() - expect(screen.queryByText(/150\.00 USDC/)).toBeNull() - }) - - it("renders the shares minted at full precision, not rounded to two fraction digits", () => { - renderClaimModal({ - status: "confirmed", - sharesMinted: 952380952n as Amount, - hash: "b".repeat(64), - }) - - expect(screen.getByText(/95\.2380952 vUSDC/)).toBeTruthy() - }) - - it("tells the investor their deposit came back, not that shares were issued, on a zero result", () => { - renderClaimModal({ - status: "confirmed", - sharesMinted: 0n as Amount, - hash: "b".repeat(64), - }) - - expect( - screen.getByRole("heading", { name: "Deposit returned" }), - ).toBeTruthy() - expect(screen.getByText(/150\.00 USDC/)).toBeTruthy() - expect(screen.queryByText(/vUSDC/)).toBeNull() - }) - - it("names RequestNotFound for a claim with no matching request", () => { - renderClaimModal({ - status: "failed", - failure: { kind: "contract-error", code: 6001 }, - }) - - expect(screen.getByText(/no request to claim/)).toBeTruthy() - }) - - it("names EpochNotFound as a request that could not be found", () => { - renderClaimModal({ - status: "failed", - failure: { kind: "contract-error", code: 6029 }, - }) - - expect(screen.getByText(/This request could not be found/)).toBeTruthy() - }) - - it("names InvalidSharePrice as no valid price published yet", () => { - renderClaimModal({ - status: "failed", - failure: { kind: "contract-error", code: 6031 }, - }) - - expect(screen.getByText(/not published a valid price/)).toBeTruthy() - }) - - it("names AlreadyClaimed distinctly", () => { - renderClaimModal({ - status: "failed", - failure: { kind: "contract-error", code: 6035 }, - }) - - expect(screen.getByText(/already been claimed/)).toBeTruthy() - }) - - it("names ClaimNotCovered as the reserve not yet covering this claim", () => { - renderClaimModal({ - status: "failed", - failure: { kind: "contract-error", code: 6037 }, - }) - - expect(screen.getByText(/not yet hold enough in reserve/)).toBeTruthy() - }) - - it("names AmountTooLarge as the conversion overflowing", () => { - renderClaimModal({ - status: "failed", - failure: { kind: "contract-error", code: 6014 }, - }) - - expect(screen.getByText(/too large to complete/)).toBeTruthy() - }) - - it("falls back to a generic refusal for 304 on a share claim, since a delisted holder's shares join an exit instead", () => { - renderClaimModal({ - status: "failed", - failure: { kind: "contract-error", code: 304 }, - }) - - expect(screen.getByText(/contact support/)).toBeTruthy() - expect(screen.queryByText(/allowlisted/)).toBeNull() - }) - - it("falls back to a generic refusal for a claim error it does not recognize, without the raw code", () => { - renderClaimModal({ - status: "failed", - failure: { kind: "contract-error", code: 9999 }, - }) - - expect(screen.getByText(/contact support/)).toBeTruthy() - expect(screen.queryByText(/9999/)).toBeNull() - }) - - it("reads a declined claim as a choice, not a failure, and offers to try again", () => { - const { onRetry } = renderClaimModal({ - status: "failed", - failure: { kind: "declined" }, - }) - - expect( - screen.getByRole("heading", { name: "You declined the request" }), - ).toBeTruthy() - - fireEvent.click(screen.getByRole("button", { name: "Try again" })) - expect(onRetry).toHaveBeenCalledTimes(1) - }) - - it("shows the same step progress machinery for a claim in flight", () => { - renderClaimModal({ status: "awaiting-signature" }) - - const steps = screen.getAllByRole("listitem") - expect(steps.map((step) => step.textContent)).toEqual([ - "Approved in your wallet, in progress", - "Sent to the network", - "Recorded", - ]) - }) - - it("is reachable as a dialog and dismissible by its close control", () => { - const { onClose } = renderClaimModal({ status: "awaiting-signature" }) - - fireEvent.click(screen.getByRole("button", { name: "Close" })) - expect(onClose).toHaveBeenCalledTimes(1) - }) -}) - -describe("TransactionModal, redeeming", () => { - it("opens with a preparing state naming the redemption request", () => { - renderRedeemModal({ status: "preparing" }) - - expect( - screen.getByRole("heading", { - name: "Preparing your redemption request", - }), - ).toBeTruthy() - }) - - it("tells the investor their shares are locked in escrow, in share terms, without naming an epoch", () => { - renderRedeemModal({ status: "awaiting-signature" }) - - expect( - screen.getByRole("heading", { name: "Confirm in your wallet" }), - ).toBeTruthy() - expect(screen.getByText(/100\.00 vUSDC/)).toBeTruthy() - expect(screen.getByText(/in escrow/)).toBeTruthy() - expect(screen.getByText(/not today/)).toBeTruthy() - expect(screen.queryByText(/Epoch/)).toBeNull() - expect(screen.queryByText(/epoch/)).toBeNull() - expect(screen.queryByText(/batch/i)).toBeNull() - }) - - it("tells the investor the redemption request is on its way, distinct from subscribe copy", () => { - renderRedeemModal({ status: "submitted", hash: "a".repeat(64) }) - - expect( - screen.getByRole("heading", { name: "Sending your redemption request" }), - ).toBeTruthy() - expect(screen.getByText(/on its way to the network/)).toBeTruthy() - }) - - it("shows the confirmed shares and transaction hash once confirmed", () => { - renderRedeemModal({ - status: "confirmed", - hash: "b".repeat(64), - }) - - expect( - screen.getByRole("heading", { name: "Redemption request submitted" }), - ).toBeTruthy() - expect(screen.getByText(/100\.00 vUSDC/)).toBeTruthy() - expect(screen.queryByText(/Batch/)).toBeNull() - expect(screen.getByText(/bbbb\.\.\.bbbb/)).toBeTruthy() - }) - - it("never mentions locking or attestation once confirmed", () => { - renderRedeemModal({ status: "confirmed", hash: "b".repeat(64) }) - - expect(screen.queryByText(/locked/i)).toBeNull() - expect(screen.queryByText(/attestation/i)).toBeNull() - }) - - it("names InvalidAmount for a non-positive share amount", () => { - renderRedeemModal({ - status: "failed", - failure: { kind: "contract-error", code: 6007 }, - }) - - expect(screen.getByText(/amount greater than zero/)).toBeTruthy() - }) - - it("names RequestOutstanding as a redemption request already open, distinct from subscribe's wording", () => { - renderRedeemModal({ - status: "failed", - failure: { kind: "contract-error", code: 6009 }, - }) - - expect( - screen.getByText(/already have a redemption request open/), - ).toBeTruthy() - }) - - it("names AmountTooLarge as too large for the vault to accept", () => { - renderRedeemModal({ - status: "failed", - failure: { kind: "contract-error", code: 6014 }, - }) - - expect(screen.getByText(/too large for the vault to accept/)).toBeTruthy() - }) - - it("names EpochNotFound as a request that could not be found", () => { - renderRedeemModal({ - status: "failed", - failure: { kind: "contract-error", code: 6029 }, - }) - - expect(screen.getByText(/This request could not be found/)).toBeTruthy() - }) - - it("names WindDownActive as the vault not accepting new redemptions", () => { - renderRedeemModal({ - status: "failed", - failure: { kind: "contract-error", code: 6046 }, - }) - - expect(screen.getByText(/not accepting new redemptions/)).toBeTruthy() - }) - - it("names SetupOpen as redemptions starting once setup closes", () => { - renderRedeemModal({ - status: "failed", - failure: { kind: "contract-error", code: 6064 }, - }) - - expect(screen.getByText(/setup is still open/)).toBeTruthy() - expect(screen.getByText(/Redemptions start once it closes/)).toBeTruthy() - }) - - it("falls back to a generic refusal for a redeem error it does not recognize, without the raw code", () => { - renderRedeemModal({ - status: "failed", - failure: { kind: "contract-error", code: 9999 }, - }) - - expect(screen.getByText(/contact support/)).toBeTruthy() - expect(screen.queryByText(/9999/)).toBeNull() - }) - - it("reads a declined redemption request as a choice, not a failure, and offers to try again", () => { - const { onRetry } = renderRedeemModal({ - status: "failed", - failure: { kind: "declined" }, - }) - - expect( - screen.getByRole("heading", { name: "You declined the request" }), - ).toBeTruthy() - - fireEvent.click(screen.getByRole("button", { name: "Try again" })) - expect(onRetry).toHaveBeenCalledTimes(1) - }) - - it("shows the same step progress machinery for a redemption request in flight", () => { - renderRedeemModal({ status: "awaiting-signature" }) - - const steps = screen.getAllByRole("listitem") - expect(steps.map((step) => step.textContent)).toEqual([ - "Approved in your wallet, in progress", - "Sent to the network", - "Recorded", - ]) - }) - - it("is reachable as a dialog and dismissible by its close control", () => { - const { onClose } = renderRedeemModal({ status: "awaiting-signature" }) - - fireEvent.click(screen.getByRole("button", { name: "Close" })) - expect(onClose).toHaveBeenCalledTimes(1) - }) -}) - -describe("TransactionModal, claiming a redemption", () => { - it("opens with a preparing state naming the claim", () => { - renderClaimRedeemModal({ status: "preparing" }) - - expect( - screen.getByRole("heading", { name: "Preparing your claim" }), - ).toBeTruthy() - }) - - it("tells the investor what claiming sends them, without naming an epoch", () => { - renderClaimRedeemModal({ status: "awaiting-signature" }) - - expect( - screen.getByRole("heading", { name: "Confirm in your wallet" }), - ).toBeTruthy() - expect( - screen.getByText( - "You receive what you're owed. Signing sends 100.00 USDC to your wallet.", - ), - ).toBeTruthy() - expect(screen.queryByText(/Epoch/)).toBeNull() - expect(screen.queryByText(/epoch/)).toBeNull() - }) - - it("tells the investor the claim is on its way to the network", () => { - renderClaimRedeemModal({ status: "submitted", hash: "a".repeat(64) }) - - expect( - screen.getByRole("heading", { name: "Sending your claim" }), - ).toBeTruthy() - expect(screen.getByText(/on its way to the network/)).toBeTruthy() - }) - - it("shows the claimed asset, in its own ticker, once confirmed", () => { - renderClaimRedeemModal({ - status: "confirmed", - assetsClaimed: 150_0000000n as Amount, - sharesReturned: false, - hash: "b".repeat(64), - }) - - expect(screen.getByRole("heading", { name: "USDC claimed" })).toBeTruthy() - expect(screen.getByText(/150\.00 USDC/)).toBeTruthy() - }) - - it("renders the claimed asset amount at full precision, not rounded to two fraction digits", () => { - renderClaimRedeemModal({ - status: "confirmed", - assetsClaimed: 952380952n as Amount, - sharesReturned: false, - hash: "b".repeat(64), - }) - - expect(screen.getByText(/95\.2380952 USDC/)).toBeTruthy() - }) - - it("tells the investor their shares came back, not that an asset was claimed, on a zero result", () => { - renderClaimRedeemModal({ - status: "confirmed", - assetsClaimed: 0n as Amount, - sharesReturned: true, - hash: "b".repeat(64), - }) - - expect( - screen.getByRole("heading", { name: "Shares returned" }), - ).toBeTruthy() - expect( - screen.getByText( - "The price left nothing to claim, so your shares have been returned to your wallet instead.", - ), - ).toBeTruthy() - expect(screen.queryByText(/USDC claimed/)).toBeNull() - }) - - it("names EpochNotFulfilled as not yet priced", () => { - renderClaimRedeemModal({ - status: "failed", - failure: { kind: "contract-error", code: 6034 }, - }) - - expect(screen.getByText(/has not priced yet/)).toBeTruthy() - }) - - it("names ClaimNotCovered with the deposit asset's ticker, distinct from the untickered claim wording for the same code", () => { - renderClaimRedeemModal({ - status: "failed", - failure: { kind: "contract-error", code: 6037 }, - }) - - expect(screen.getByText(/enough USDC in reserve/)).toBeTruthy() - }) - - it("names HolderNotEligible as the zero-price return the address cannot receive, in the same words as a withdrawal cancellation", () => { - renderClaimRedeemModal({ - status: "failed", - failure: { kind: "contract-error", code: 6073 }, - }) - - expect(screen.getByText(/tried to return your shares/)).toBeTruthy() - expect( - screen.getByText(/cannot receive these shares right now/), - ).toBeTruthy() - expect(screen.queryByText(/claim the USDC/i)).toBeNull() - }) - - it("falls back to a generic refusal for 304 on a withdrawal claim, since the vault checks the holder itself", () => { - renderClaimRedeemModal({ - status: "failed", - failure: { kind: "contract-error", code: 304 }, - }) - - expect(screen.getByText(/contact support/)).toBeTruthy() - expect(screen.queryByText(/allowlisted/)).toBeNull() - }) - - it("names AlreadyClaimed by delegating to the shared claim wording", () => { - renderClaimRedeemModal({ - status: "failed", - failure: { kind: "contract-error", code: 6035 }, - }) - - expect(screen.getByText(/already been claimed/)).toBeTruthy() - }) - - it("falls back to a generic refusal for a claim-redeem error it does not recognize, without the raw code", () => { - renderClaimRedeemModal({ - status: "failed", - failure: { kind: "contract-error", code: 9999 }, - }) - - expect(screen.getByText(/contact support/)).toBeTruthy() - expect(screen.queryByText(/9999/)).toBeNull() - }) - - it("reads a declined claim as a choice, not a failure, and offers to try again", () => { - const { onRetry } = renderClaimRedeemModal({ - status: "failed", - failure: { kind: "declined" }, - }) - - expect( - screen.getByRole("heading", { name: "You declined the request" }), - ).toBeTruthy() - - fireEvent.click(screen.getByRole("button", { name: "Try again" })) - expect(onRetry).toHaveBeenCalledTimes(1) - }) - - it("shows the same step progress machinery for a claim in flight", () => { - renderClaimRedeemModal({ status: "awaiting-signature" }) - - const steps = screen.getAllByRole("listitem") - expect(steps.map((step) => step.textContent)).toEqual([ - "Approved in your wallet, in progress", - "Sent to the network", - "Recorded", - ]) - }) - - it("is reachable as a dialog and dismissible by its close control", () => { - const { onClose } = renderClaimRedeemModal({ - status: "awaiting-signature", - }) - - fireEvent.click(screen.getByRole("button", { name: "Close" })) - expect(onClose).toHaveBeenCalledTimes(1) - }) -}) - -describe("TransactionModal, closing an epoch", () => { - it("opens with a preparing state naming the epoch being closed", () => { - renderCloseEpochModal({ status: "preparing" }) - - expect( - screen.getByRole("heading", { name: "Preparing to close epoch 5" }), - ).toBeTruthy() - }) - - it("tells the manager what signing seals and what it opens", () => { - renderCloseEpochModal({ status: "awaiting-signature" }) - - expect( - screen.getByRole("heading", { name: "Confirm in your wallet" }), - ).toBeTruthy() - expect( - screen.getByText( - "Signing seals epoch 5 and opens the next one. Requests in epoch 5 wait for a price.", - ), - ).toBeTruthy() - }) - - it.each(["", "unknown"])( - "renders the signing prompt without throwing when the label is %j", - (epoch) => { - expect(() => - renderCloseEpochModal({ status: "awaiting-signature" }, epoch), - ).not.toThrow() - expect( - screen.getByRole("heading", { name: "Confirm in your wallet" }), - ).toBeTruthy() - }, - ) - - it("tells the manager the close is on its way", () => { - renderCloseEpochModal({ status: "submitted", hash: "a".repeat(64) }) - - expect( - screen.getByRole("heading", { name: "Sending the close" }), - ).toBeTruthy() - expect(screen.getByText(/on its way to the network/)).toBeTruthy() - }) - - it("names the epoch the vault sealed once confirmed, taking the id from the result rather than the label", () => { - renderCloseEpochModal({ - status: "confirmed", - sealedEpoch: 7n, - hash: "b".repeat(64), - }) - - expect(screen.getByRole("heading", { name: "Epoch 7 sealed" })).toBeTruthy() - expect( - screen.getByText( - "Epoch 8 is open and accepting requests. Epoch 7 prices once the notice elapses and a valid price is attested.", - ), - ).toBeTruthy() - }) - - it.each<[number, RegExp]>([ - [6046, /winding down/], - [6029, /current epoch could not be found/], - [6032, /not open/], - [2000, /not authorized to close/], - [2007, /does not hold the manager role/], - [6077, /Only the manager can seal this epoch/], - [6064, /setup is still open, so no epoch can be sealed/], - ])("names the vault's reason for code %i", (code, sentence) => { - renderCloseEpochModal({ - status: "failed", - failure: { kind: "contract-error", code }, - }) - - expect( - screen.getByRole("heading", { name: "The vault refused this request" }), - ).toBeTruthy() - expect(screen.getByText(sentence)).toBeTruthy() - }) - - it("falls back to a generic refusal for a close error it does not recognize, without the raw code", () => { - renderCloseEpochModal({ - status: "failed", - failure: { kind: "contract-error", code: 6001 }, - }) - - expect( - screen.getByText(/The vault refused this request\. Try again/), - ).toBeTruthy() - expect(screen.queryByText(/6001/)).toBeNull() - }) - - it("sends an unknown outcome to the Cycle card, not to the investor's requests", () => { - renderCloseEpochModal({ - status: "failed", - failure: { kind: "unknown" }, - }) - - expect( - screen.getByRole("heading", { name: "Something went wrong" }), - ).toBeTruthy() - expect( - screen.getByText( - "We could not confirm whether this reached the network. We don't yet know if it went through, so check the Cycle card before trying again.", - ), - ).toBeTruthy() - expect(screen.queryByText(/your requests/)).toBeNull() - }) - - it("reads a declined signature as a choice and offers to try again", () => { - const { onRetry } = renderCloseEpochModal({ - status: "failed", - failure: { kind: "declined" }, - }) - - fireEvent.click(screen.getByRole("button", { name: "Try again" })) - - expect(onRetry).toHaveBeenCalledTimes(1) - }) -}) - -describe("TransactionModal, pricing an epoch", () => { - it("opens with a preparing state naming the epoch being priced", () => { - renderFulfillEpochModal({ status: "preparing" }) - - expect( - screen.getByRole("heading", { name: "Preparing to price epoch 4" }), - ).toBeTruthy() - }) - - it("tells the operator what signing prices and settles", () => { - renderFulfillEpochModal({ status: "awaiting-signature" }) - - expect( - screen.getByRole("heading", { name: "Confirm in your wallet" }), - ).toBeTruthy() - expect( - screen.getByText( - "Signing prices epoch 4 at the oracle's current price and settles its requests.", - ), - ).toBeTruthy() - }) - - it("tells the operator the settlement is on its way", () => { - renderFulfillEpochModal({ status: "submitted", hash: "a".repeat(64) }) - - expect( - screen.getByRole("heading", { name: "Sending the settlement" }), - ).toBeTruthy() - expect(screen.getByText(/on its way to the network/)).toBeTruthy() - }) - - it("shows the share price the vault settled at once confirmed, to four places", () => { - renderFulfillEpochModal({ - status: "confirmed", - sharePrice: 1_250_000_000_000_000_000n as Price, - hash: "b".repeat(64), - }) - - expect(screen.getByRole("heading", { name: "Epoch 4 priced" })).toBeTruthy() - expect( - screen.getByText( - "Settled at 1.2500 per share. Share claims are ready; redemption claims wait until the reserve covers them.", - ), - ).toBeTruthy() - }) - - it.each<[number, RegExp]>([ - [6046, /winding down/], - [6029, /epoch could not be found/], - [6038, /not sealed/], - [6042, /notice period has not elapsed/], - [6044, /price is not valid/], - [6043, /attested before this epoch was sealed/], - [6031, /could not resolve a valid share price/], - [6014, /too large/], - [1000, /paused/], - [6064, /setup is still open, so no epoch can be priced/], - ])("names the vault's reason for code %i", (code, sentence) => { - renderFulfillEpochModal({ - status: "failed", - failure: { kind: "contract-error", code }, - }) - - expect(screen.getByText(sentence)).toBeTruthy() - }) - - it("falls back to a generic refusal for a settlement error it does not recognize, without the raw code", () => { - renderFulfillEpochModal({ - status: "failed", - failure: { kind: "contract-error", code: 6035 }, - }) - - expect( - screen.getByText(/The vault refused this request\. Try again/), - ).toBeTruthy() - expect(screen.queryByText(/6035/)).toBeNull() - }) - - it("sends an unknown outcome to the Cycle card, not to the investor's requests", () => { - renderFulfillEpochModal({ - status: "failed", - failure: { kind: "unknown" }, - hash: "c".repeat(64), - }) - - expect( - screen.getByText(/check the Cycle card before trying again/), - ).toBeTruthy() - expect(screen.queryByText(/your requests/)).toBeNull() - expect(screen.getByText(/cccc\.\.\.cccc/)).toBeTruthy() - }) - - it("is reachable as a dialog and dismissible by its close control", () => { - const { onClose } = renderFulfillEpochModal({ status: "preparing" }) - - expect(screen.getByRole("dialog")).toBeTruthy() - fireEvent.click(screen.getByRole("button", { name: "Close" })) - expect(onClose).toHaveBeenCalledTimes(1) - }) -}) - -describe("TransactionModal, attesting a price", () => { - it("opens with a preparing state naming the price being attested", () => { - renderAttestModal({ status: "preparing" }) - - expect( - screen.getByRole("heading", { name: "Preparing to attest 1.0400" }), - ).toBeTruthy() - }) - - it("tells the attester what signing records", () => { - renderAttestModal({ status: "awaiting-signature" }) - - expect( - screen.getByRole("heading", { name: "Confirm in your wallet" }), - ).toBeTruthy() - expect( - screen.getByText( - "Signing records 1.0400 as the share price, with its proof. Sealed epochs settle at it once their notice elapses.", - ), - ).toBeTruthy() - }) - - it("tells the attester the attestation is on its way", () => { - renderAttestModal({ status: "submitted", hash: "a".repeat(64) }) - - expect( - screen.getByRole("heading", { name: "Sending the attestation" }), - ).toBeTruthy() - expect(screen.getByText(/on its way to the network/)).toBeTruthy() - }) - - it("says how long the price stays valid once confirmed", () => { - renderAttestModal({ status: "confirmed", hash: "b".repeat(64) }) - - expect(screen.getByRole("heading", { name: "Price attested" })).toBeTruthy() - expect( - screen.getByText("1.0400 is the share price. Valid for 1d."), - ).toBeTruthy() - }) - - it("leaves the validity out when the freshness window is not known", () => { - renderAttestModal({ status: "confirmed" }, null) - - expect(screen.getByText("1.0400 is the share price.")).toBeTruthy() - }) - - it.each<[number, RegExp]>([ - [3002, /outside the band/], - [3005, /already have expired/], - [3003, /cooldown .* has not elapsed/], - [3004, /moves further from the last attested price/], - [3010, /^Unpause the NAV first\.$/], - [3023, /^The proof hash cannot be all zeros\.$/], - [3024, /^The proof URI is longer than 256 bytes\.$/], - ])("names the oracle's reason for code %i", (code, sentence) => { - renderAttestModal({ - status: "failed", - failure: { kind: "contract-error", code }, - }) - - expect( - screen.getByRole("heading", { name: "The vault refused this request" }), - ).toBeTruthy() - expect(screen.getByText(sentence)).toBeTruthy() - }) - - it.each([2000, 2007])( - "falls back to a generic refusal for code %i, without the raw code", - (code) => { - renderAttestModal({ - status: "failed", - failure: { kind: "contract-error", code }, - }) - - expect( - screen.getByText(/The vault refused this request\. Try again/), - ).toBeTruthy() - expect(screen.queryByText(new RegExp(String(code)))).toBeNull() - }, - ) - - it("sends an unknown outcome to the Cycle card, not to the investor's requests", () => { - renderAttestModal({ status: "failed", failure: { kind: "unknown" } }) - - expect( - screen.getByText(/check the Cycle card before trying again/), - ).toBeTruthy() - expect(screen.queryByText(/your requests/)).toBeNull() - }) - - it("is reachable as a dialog and dismissible by its close control", () => { - const { onClose } = renderAttestModal({ status: "preparing" }) - - expect(screen.getByRole("dialog")).toBeTruthy() - fireEvent.click(screen.getByRole("button", { name: "Close" })) - expect(onClose).toHaveBeenCalledTimes(1) - }) -}) - -describe("TransactionModal, deploying to the custodian", () => { - it("opens with a preparing state naming the amount being deployed", () => { - renderDeployModal({ status: "preparing" }) - - expect( - screen.getByRole("heading", { name: "Preparing to deploy 250.00" }), - ).toBeTruthy() - }) - - it("tells the treasury what signing sends and where", () => { - renderDeployModal({ status: "awaiting-signature" }) - - expect( - screen.getByRole("heading", { name: "Confirm in your wallet" }), - ).toBeTruthy() - expect( - screen.getByText( - "Signing sends 250.00 from the vault's reserve to the custodian.", - ), - ).toBeTruthy() - }) - - it("tells the treasury the deployment is on its way", () => { - renderDeployModal({ status: "submitted", hash: "a".repeat(64) }) - - expect( - screen.getByRole("heading", { name: "Sending the deployment" }), - ).toBeTruthy() - expect(screen.getByText(/on its way to the network/)).toBeTruthy() - }) - - it("says what left the vault once confirmed", () => { - renderDeployModal({ status: "confirmed", hash: "b".repeat(64) }) - - expect(screen.getByRole("heading", { name: "Deployed" })).toBeTruthy() - expect( - screen.getByText("250.00 left the vault for the custodian."), - ).toBeTruthy() - }) - - it.each<[number, RegExp]>([ - [6046, /winding down/], - [6007, /greater than zero/], - [6012, /No custodian is set/], - [6005, /free reserve/], - [6014, /too large/], - [2000, /not authorized/], - [2007, /treasury role/], - ])("names the vault's reason for code %i", (code, sentence) => { - renderDeployModal({ - status: "failed", - failure: { kind: "contract-error", code }, - }) - - expect( - screen.getByRole("heading", { name: "The vault refused this request" }), - ).toBeTruthy() - expect(screen.getByText(sentence)).toBeTruthy() - }) - - it("falls back to a generic refusal for a code it does not recognize, without the raw code", () => { - renderDeployModal({ - status: "failed", - failure: { kind: "contract-error", code: 6029 }, - }) - - expect( - screen.getByText(/The vault refused this request\. Try again/), - ).toBeTruthy() - expect(screen.queryByText(/6029/)).toBeNull() - }) - - it("sends an unknown outcome to the Cycle card, not to the investor's requests", () => { - renderDeployModal({ status: "failed", failure: { kind: "unknown" } }) - - expect( - screen.getByText(/check the Cycle card before trying again/), - ).toBeTruthy() - expect(screen.queryByText(/your requests/)).toBeNull() - }) - - it("is reachable as a dialog and dismissible by its close control", () => { - const { onClose } = renderDeployModal({ status: "preparing" }) - - expect(screen.getByRole("dialog")).toBeTruthy() - fireEvent.click(screen.getByRole("button", { name: "Close" })) - expect(onClose).toHaveBeenCalledTimes(1) - }) -}) - -describe("TransactionModal, funding the reserve", () => { - it("opens with a preparing state naming the amount being funded", () => { - renderFundModal({ status: "preparing" }) - - expect( - screen.getByRole("heading", { name: "Preparing to fund 100.00" }), - ).toBeTruthy() - }) - - it("tells the funder what signing moves out of their wallet", () => { - renderFundModal({ status: "awaiting-signature" }) - - expect( - screen.getByRole("heading", { name: "Confirm in your wallet" }), - ).toBeTruthy() - expect( - screen.getByText( - "Signing moves 100.00 from your wallet into the vault's reserve.", - ), - ).toBeTruthy() - }) - - it("tells the funder the funding is on its way", () => { - renderFundModal({ status: "submitted", hash: "a".repeat(64) }) - - expect( - screen.getByRole("heading", { name: "Sending the funding" }), - ).toBeTruthy() - expect(screen.getByText(/on its way to the network/)).toBeTruthy() - }) - - it("says what joined the reserve once confirmed", () => { - renderFundModal({ status: "confirmed", hash: "b".repeat(64) }) - - expect(screen.getByRole("heading", { name: "Funded" })).toBeTruthy() - expect(screen.getByText("100.00 joined the reserve.")).toBeTruthy() - }) - - it.each<[number, RegExp]>([ - [6007, /greater than zero/], - [6014, /too large/], - ])("names the vault's reason for code %i", (code, sentence) => { - renderFundModal({ - status: "failed", - failure: { kind: "contract-error", code }, - }) - - expect( - screen.getByRole("heading", { name: "The vault refused this request" }), - ).toBeTruthy() - expect(screen.getByText(sentence)).toBeTruthy() - }) - - it("points the funder at their balance and trustline when the asset transfer refuses, without the raw code", () => { - renderFundModal({ - status: "failed", - failure: { kind: "contract-error", code: 10 }, - }) - - expect( - screen.getByText( - "Your wallet could not cover the transfer. Check the balance and the trustline.", - ), - ).toBeTruthy() - expect(screen.queryByText(/\b10\b/)).toBeNull() - }) - - it("sends an unknown outcome to the Cycle card, not to the investor's requests", () => { - renderFundModal({ status: "failed", failure: { kind: "unknown" } }) - - expect( - screen.getByText(/check the Cycle card before trying again/), - ).toBeTruthy() - expect(screen.queryByText(/your requests/)).toBeNull() - }) - - it("is reachable as a dialog and dismissible by its close control", () => { - const { onClose } = renderFundModal({ status: "preparing" }) - - expect(screen.getByRole("dialog")).toBeTruthy() - fireEvent.click(screen.getByRole("button", { name: "Close" })) - expect(onClose).toHaveBeenCalledTimes(1) - }) -}) - -describe("TransactionModal, activating the wind-down", () => { - it("opens with a preparing state", () => { - renderActivateModal({ status: "preparing" }) - - expect( - screen.getByRole("heading", { - name: "Preparing to activate the wind-down", - }), - ).toBeTruthy() - }) - - it("tells the signer what the activation stops and opens", () => { - renderActivateModal({ status: "awaiting-signature" }) - - expect( - screen.getByRole("heading", { name: "Confirm in your wallet" }), - ).toBeTruthy() - expect( - screen.getByText( - "Signing stops new requests and opens distribution rounds.", - ), - ).toBeTruthy() - }) - - it("tells the signer the activation is on its way", () => { - renderActivateModal({ status: "submitted", hash: "a".repeat(64) }) - - expect( - screen.getByRole("heading", { name: "Sending the activation" }), - ).toBeTruthy() - expect(screen.getByText(/on its way to the network/)).toBeTruthy() - }) - - it("says requests are closed and rounds can distribute once confirmed", () => { - renderActivateModal({ status: "confirmed", hash: "b".repeat(64) }) - - expect( - screen.getByRole("heading", { name: "Wind-down active" }), - ).toBeTruthy() - expect( - screen.getByText( - "New requests are closed. Rounds can now distribute the free reserve.", - ), - ).toBeTruthy() - }) - - it.each<[number, RegExp]>([ - [6047, /No wind-down has been proposed/], - [6046, /already active/], - [6049, /delay has not elapsed/], - ])("names the vault's reason for code %i", (code, sentence) => { - renderActivateModal({ - status: "failed", - failure: { kind: "contract-error", code }, - }) - - expect( - screen.getByRole("heading", { name: "The vault refused this request" }), - ).toBeTruthy() - expect(screen.getByText(sentence)).toBeTruthy() - }) - - it("falls back to a generic refusal for a code it does not recognize, without the raw code", () => { - renderActivateModal({ - status: "failed", - failure: { kind: "contract-error", code: 6050 }, - }) - - expect( - screen.getByText(/The vault refused this request\. Try again/), - ).toBeTruthy() - expect(screen.queryByText(/6050/)).toBeNull() - }) - - it("sends an unknown outcome to the Cycle card, not to the investor's requests", () => { - renderActivateModal({ status: "failed", failure: { kind: "unknown" } }) - - expect( - screen.getByText(/check the Cycle card before trying again/), - ).toBeTruthy() - expect(screen.queryByText(/your requests/)).toBeNull() - }) -}) - -describe("TransactionModal, finalizing a wind-down round", () => { - it("opens with a preparing state naming the round", () => { - renderFinalizeModal({ status: "preparing" }) - - expect( - screen.getByRole("heading", { name: "Preparing round 3" }), - ).toBeTruthy() - }) - - it("tells the signer what the round distributes", () => { - renderFinalizeModal({ status: "awaiting-signature" }) - - expect( - screen.getByRole("heading", { name: "Confirm in your wallet" }), - ).toBeTruthy() - expect( - screen.getByText( - "Signing distributes the free reserve to holders as round 3.", - ), - ).toBeTruthy() - }) - - it("tells the signer the round is on its way", () => { - renderFinalizeModal({ status: "submitted", hash: "a".repeat(64) }) - - expect( - screen.getByRole("heading", { name: "Sending the round" }), - ).toBeTruthy() - expect(screen.getByText(/on its way to the network/)).toBeTruthy() - }) - - it("says what holders can claim once confirmed, to the last digit", () => { - renderFinalizeModal({ - status: "confirmed", - hash: "b".repeat(64), - credited: 9_000_000_001n as Amount, - }) - - expect( - screen.getByRole("heading", { name: "Round 3 finalized" }), - ).toBeTruthy() - expect( - screen.getByText("900.0000001 is claimable by holders."), - ).toBeTruthy() - }) - - it.each<[number, RegExp]>([ - [6050, /not active/], - [6052, /nothing to distribute/], - [6014, /too large/], - ])("names the vault's reason for code %i", (code, sentence) => { - renderFinalizeModal({ - status: "failed", - failure: { kind: "contract-error", code }, - }) - - expect( - screen.getByRole("heading", { name: "The vault refused this request" }), - ).toBeTruthy() - expect(screen.getByText(sentence)).toBeTruthy() - }) - - it("sends an unknown outcome to the Cycle card, not to the investor's requests", () => { - renderFinalizeModal({ status: "failed", failure: { kind: "unknown" } }) - - expect( - screen.getByText(/check the Cycle card before trying again/), - ).toBeTruthy() - expect(screen.queryByText(/your requests/)).toBeNull() - }) -}) - -const renderEmergencyModal = ( - action: EmergencyModalAction, - status: TransactionStatus, -) => - render( - , - ) - -describe("TransactionModal, emergency actions", () => { - it("shows the signatures collected, the expiry and the signing link to copy", async () => { - const writeText = vi.fn().mockResolvedValue(undefined) - Object.defineProperty(navigator, "clipboard", { - value: { writeText }, - configurable: true, - }) - const expiresAt = new Date(Date.UTC(2026, 9, 22)) - renderEmergencyModal("pause-vault", { - status: "shared", - xdr: "AAAAXDR", - standing: { needs: 2 }, - expiresAt, - }) - const link = `${window.location.origin}/sign#AAAAXDR` - - expect( - screen.getByRole("heading", { name: "1 of 2 signatures" }), - ).toBeTruthy() - expect( - screen.getByText( - `Share the link with the next signer. Valid until ${formatDate(BigInt(expiresAt.getTime() / 1000))}.`, - ), - ).toBeTruthy() - expect( - screen.getByRole("textbox", { name: "Signing link" }) - .value, - ).toBe(link) - expect(screen.getByText("Approved in your wallet").textContent).toMatch( - /done/, - ) - - fireEvent.click(screen.getByRole("button", { name: "Copy link" })) - - expect(writeText).toHaveBeenCalledWith(link) - expect(await screen.findByRole("button", { name: "Copied" })).toBeTruthy() - }) - - it("names the weight a weighted signer adds against the threshold", () => { - renderEmergencyModal("pause-vault", { - status: "shared", - xdr: "AAAAXDR", - standing: { weight: 1, threshold: 3 }, - expiresAt: null, - }) - - expect( - screen.getByRole("heading", { name: "weight 1 of threshold 3" }), - ).toBeTruthy() - }) - - it("keeps offering the copy when the clipboard refuses, leaving the link to select", async () => { - const writeText = vi.fn().mockRejectedValue(new Error("denied")) - Object.defineProperty(navigator, "clipboard", { - value: { writeText }, - configurable: true, - }) - renderEmergencyModal("pause-vault", { - status: "shared", - xdr: "AAAAXDR", - standing: { needs: 2 }, - expiresAt: null, - }) - - fireEvent.click(screen.getByRole("button", { name: "Copy link" })) - await act(() => Promise.resolve()) - - expect(writeText).toHaveBeenCalledTimes(1) - expect(screen.getByRole("button", { name: "Copy link" })).toBeTruthy() - expect(screen.queryByRole("button", { name: "Copied" })).toBeNull() - }) - - it.each<[SubmitFailure, string]>([ - ["expired", "The transaction expired before it was submitted."], - [ - "sequence-consumed", - "The account moved on; this transaction is no longer valid.", - ], - ["bad-auth", "The signatures do not match the account's threshold."], - ["fee", "The account cannot pay the fee."], - ["fee-too-low", "The fee is below the network minimum. Propose it again."], - ["not-accepted", "The network did not accept it. Try again."], - ])("explains a %s rejection from the network", (reason, sentence) => { - renderEmergencyModal("pause-vault", { - status: "failed", - failure: { kind: "network", reason }, - hash: "d".repeat(64), - }) - - expect( - screen.getByRole("heading", { name: "The network rejected it" }), - ).toBeTruthy() - expect(screen.getByText(sentence)).toBeTruthy() - expect(screen.getByText("Sent to the network").textContent).toMatch( - /failed/, - ) - }) - - it("asks to try again when a prepared transaction went stale", () => { - const { onRetry } = renderModal({ - status: "failed", - failure: { kind: "stale" }, - }) - - expect( - screen.getByRole("heading", { name: "The transaction went stale" }), - ).toBeTruthy() - expect( - screen.getByText( - "The account moved on since it was prepared, so nothing was sent. Try again.", - ), - ).toBeTruthy() - fireEvent.click(screen.getByRole("button", { name: "Try again" })) - expect(onRetry).toHaveBeenCalledTimes(1) - }) - - it("treats an unexplained network rejection as an unknown outcome", () => { - renderEmergencyModal("pause-vault", { - status: "failed", - failure: { kind: "network", reason: "unknown" }, - }) - - expect( - screen.getByRole("heading", { name: "Something went wrong" }), - ).toBeTruthy() - expect( - screen.getByText(/check the Emergency tab before trying again/), - ).toBeTruthy() - }) - - it.each<[EmergencyModalAction, string, string]>([ - ["pause-vault", "Preparing to pause the vault", "Vault paused"], - ["unpause-vault", "Preparing to unpause the vault", "Vault unpaused"], - ["pause-nav", "Preparing to pause the NAV", "NAV paused"], - ["unpause-nav", "Preparing to unpause the NAV", "NAV unpaused"], - [ - "clear-latest", - "Preparing to clear the latest price", - "Latest price cleared", - ], - ["pause-token", "Preparing to pause the share token", "Share token paused"], - [ - "unpause-token", - "Preparing to unpause the share token", - "Share token unpaused", - ], - ])("walks %s from preparing to confirmed", (action, preparing, confirmed) => { - const { rerender } = renderEmergencyModal(action, { status: "preparing" }) - expect(screen.getByRole("heading", { name: preparing })).toBeTruthy() - - rerender( - , - ) - expect(screen.getByRole("heading", { name: confirmed })).toBeTruthy() - }) - - it("tells the signer what pausing the vault stops and what stays open", () => { - renderEmergencyModal("pause-vault", { status: "awaiting-signature" }) - - expect( - screen.getByText("Signing stops new deposits, pricing and upgrades."), - ).toBeTruthy() - }) - - it.each<[EmergencyModalAction, string, string]>([ - [ - "pause-token", - "Signing stops holder transfers only.", - "Holder transfers are stopped. Minting, burning, the vault's escrow moves and legal transfers continue.", - ], - [ - "unpause-token", - "Signing resumes holder transfers.", - "Holder transfers are open again.", - ], - ])( - "words %s after what the share token enforces", - (action, signing, done) => { - const { rerender } = renderEmergencyModal(action, { - status: "awaiting-signature", - }) - expect(screen.getByText(signing)).toBeTruthy() - - rerender( - , - ) - expect(screen.getByText(done)).toBeTruthy() - }, - ) - - it.each<[EmergencyModalAction, number, string, RegExp]>([ - ["pause-vault", 1000, "vault", /The vault is already paused/], - ["unpause-vault", 1001, "vault", /The vault is not paused/], - ["pause-token", 1000, "share token", /The share token is already paused/], - ["unpause-token", 1001, "share token", /The share token is not paused/], - ["pause-nav", 2000, "oracle", /does not hold the role/], - ["clear-latest", 3008, "oracle", /The NAV is not paused/], - ])( - "names the reason %s was refused with code %i", - (action, code, refuser, sentence) => { - renderEmergencyModal(action, { - status: "failed", - failure: { kind: "contract-error", code }, - }) - - expect( - screen.getByRole("heading", { - name: `The ${refuser} refused this request`, - }), - ).toBeTruthy() - expect(screen.getByText(sentence)).toBeTruthy() - }, - ) - - it("falls back to a generic refusal from the right contract without the raw code", () => { - renderEmergencyModal("pause-token", { - status: "failed", - failure: { kind: "contract-error", code: 9999 }, - }) - - expect( - screen.getByText(/The share token refused this request\. Try again/), - ).toBeTruthy() - expect(screen.queryByText(/9999/)).toBeNull() - }) - - it("sends an unknown outcome to the Emergency tab", () => { - renderEmergencyModal("pause-nav", { - status: "failed", - failure: { kind: "unknown" }, - }) - - expect( - screen.getByText(/check the Emergency tab before trying again/), - ).toBeTruthy() - }) -}) - -const renderConfigurationModal = ( - action: ConfigurationModalAction, - status: TransactionStatus, - value = "", -) => - render( - , - ) - -describe("TransactionModal, configuration actions", () => { - it.each<[ConfigurationModalAction, string, string]>([ - [ - "set-deposit-cap", - "Preparing to set the deposit cap", - "Deposit cap updated", - ], - ["set-custodian", "Preparing to set the custodian", "Custodian updated"], - ["set-notice", "Preparing to set the notice", "Notice updated"], - [ - "set-wind-down-delay", - "Preparing to set the wind-down delay", - "Wind-down delay updated", - ], - [ - "set-min-epoch-duration", - "Preparing to set the minimum epoch duration", - "Minimum epoch duration updated", - ], - [ - "set-oracle-config", - "Preparing to set the oracle limits", - "Oracle limits updated", - ], - [ - "set-compliance", - "Preparing to set the compliance contract", - "Compliance contract updated", - ], - [ - "set-identity-verifier", - "Preparing to set the identity verifier", - "Identity verifier updated", - ], - ])("walks %s from preparing to confirmed", (action, preparing, confirmed) => { - const { rerender } = renderConfigurationModal(action, { - status: "preparing", - }) - expect(screen.getByRole("heading", { name: preparing })).toBeTruthy() - - rerender( - , - ) - expect(screen.getByRole("heading", { name: confirmed })).toBeTruthy() - }) - - it("states the new value before signing and once recorded", () => { - const { rerender } = renderConfigurationModal( - "set-deposit-cap", - { status: "awaiting-signature" }, - "5,000.00", - ) - expect(screen.getByText("New deposit cap: 5,000.00.")).toBeTruthy() - - rerender( - , - ) - expect(screen.getByText("Deposit cap: 5,000.00.")).toBeTruthy() - }) - - it.each<[ConfigurationModalAction, string]>([ - ["set-custodian", "Treasury deploys to the new address from now on."], - ["set-notice", "Applies to epochs closed from now on."], - ["set-wind-down-delay", "A proposed wind-down keeps its activation time."], - [ - "set-min-epoch-duration", - "Anyone may seal an epoch once it has been open this long.", - ], - ])("names the side effect of %s once recorded", (action, effect) => { - renderConfigurationModal(action, { status: "confirmed" }, "3d") - - expect(screen.getByText(effect)).toBeTruthy() - }) - - it.each<[ConfigurationModalAction, string, string, string, string]>([ - [ - "propose-custodian", - "GABC...WXYZ", - "Preparing to propose a custodian", - "Signing queues custodian GABC...WXYZ.", - "Custodian GABC...WXYZ applies once the upgrade delay passes.", - ], - [ - "propose-notice", - "12h", - "Preparing to propose a notice", - "Signing queues notice 12h.", - "Notice 12h applies once the upgrade delay passes.", - ], - [ - "propose-wind-down-delay", - "8d", - "Preparing to propose a wind-down delay", - "Signing queues wind-down delay 8d.", - "Wind-down delay 8d applies once the upgrade delay passes.", - ], - [ - "propose-min-epoch-duration", - "7d", - "Preparing to propose a minimum epoch duration", - "Signing queues minimum epoch duration 7d.", - "Minimum epoch duration 7d applies once the upgrade delay passes.", - ], - [ - "propose-compliance", - "CABC...WXYZ", - "Preparing to propose a compliance contract", - "Signing queues compliance contract CABC...WXYZ.", - "Compliance contract CABC...WXYZ applies once the vault's upgrade delay passes.", - ], - [ - "propose-identity-verifier", - "CABC...WXYZ", - "Preparing to propose an identity verifier", - "Signing queues identity verifier CABC...WXYZ.", - "Identity verifier CABC...WXYZ applies once the vault's upgrade delay passes.", - ], - [ - "propose-oracle-config", - "", - "Preparing to propose oracle limits", - "Signing queues every new oracle limit.", - "The new oracle limits apply once the vault's upgrade delay passes.", - ], - ])( - "walks %s from preparing to proposed", - (action, value, preparing, signing, doneBody) => { - const { rerender } = renderConfigurationModal( - action, - { status: "preparing" }, - value, - ) - expect(screen.getByRole("heading", { name: preparing })).toBeTruthy() - - rerender( - , - ) - expect(screen.getByText(signing)).toBeTruthy() - - rerender( - , - ) - expect( - screen.getByRole("heading", { name: "Change proposed" }), - ).toBeTruthy() - expect(screen.getByText(doneBody)).toBeTruthy() - }, - ) - - it.each<[ConfigurationModalAction, number, string, RegExp]>([ - ["set-deposit-cap", 6007, "vault", /The deposit cap cannot be negative/], - ["set-notice", 6045, "vault", /The notice cannot exceed 30 days/], - ["set-notice", 6061, "vault", /The notice cannot exceed the upgrade delay/], - [ - "set-wind-down-delay", - 6053, - "vault", - /The wind-down delay cannot exceed 90 days/, - ], - [ - "set-wind-down-delay", - 6069, - "vault", - /The wind-down delay cannot be under 7 days/, - ], - [ - "set-min-epoch-duration", - 6007, - "vault", - /The minimum epoch duration must be 1 to 30 days/, - ], - [ - "propose-min-epoch-duration", - 6007, - "vault", - /The minimum epoch duration must be 1 to 30 days/, - ], - ["propose-custodian", 6075, "vault", /custodian cannot be the vault/], - ["propose-notice", 6064, "vault", /Setup is still open/], - ["set-notice", 6065, "vault", /goes through the timelock/], - ["propose-custodian", 6063, "vault", /16 pending changes/], - [ - "set-oracle-config", - 3001, - "oracle", - /One of the oracle limits is out of range/, - ], - ["set-compliance", 2000, "share token", /does not hold the role/], - ["propose-compliance", 7003, "share token", /16 pending changes/], - ["propose-identity-verifier", 7004, "share token", /Setup is still open/], - ["set-compliance", 7005, "share token", /goes through the timelock/], - ["propose-compliance", 7008, "share token", /vault is winding down/], - ])( - "names the reason %s was refused with code %i", - (action, code, refuser, sentence) => { - renderConfigurationModal(action, { - status: "failed", - failure: { kind: "contract-error", code }, - }) - - expect( - screen.getByRole("heading", { - name: `The ${refuser} refused this request`, - }), - ).toBeTruthy() - expect(screen.getByText(sentence)).toBeTruthy() - }, - ) - - it("sends an unknown outcome to the Configuration tab", () => { - renderConfigurationModal("set-notice", { - status: "failed", - failure: { kind: "unknown" }, - }) - - expect( - screen.getByText(/check the Configuration tab before trying again/), - ).toBeTruthy() - }) -}) - -const renderGovernanceModal = ( - action: GovernanceModalAction, - status: TransactionStatus, - value = "Manager to GABC...WXYZ", -) => - render( - , - ) - -describe("TransactionModal, governance actions", () => { - it.each<[GovernanceModalAction, string, string, string, string]>([ - [ - "grant-vault-role", - "Manager to GABC...WXYZ", - "Signing grants Manager to GABC...WXYZ.", - "Role granted", - "Granted Manager to GABC...WXYZ.", - ], - [ - "revoke-token-role", - "Manager from CVAU...LTID", - "Signing revokes Manager from CVAU...LTID.", - "Role revoked", - "Revoked Manager from CVAU...LTID.", - ], - ])( - "names the role change of %s", - (action, value, signing, done, doneBody) => { - const { unmount } = renderGovernanceModal( - action, - { status: "awaiting-signature" }, - value, - ) - expect(screen.getByText(signing)).toBeTruthy() - unmount() - - renderGovernanceModal(action, { status: "confirmed" }, value) - expect(screen.getByRole("heading", { name: done })).toBeTruthy() - expect(screen.getByText(doneBody)).toBeTruthy() - }, - ) - - it.each<[GovernanceModalAction, number, string, RegExp]>([ - ["grant-vault-role", 2000, "vault", /does not hold the role/], - ["revoke-oracle-role", 2002, "oracle", /holders changed/], - ["revoke-token-role", 2007, "share token", /does not hold this role/], - [ - "grant-oracle-role", - 2010, - "oracle", - /The oracle already has the maximum number of roles/, - ], - ["cancel-vault-handover", 2200, "vault", /no pending handover/], - ["hand-over-oracle-admin", 2201, "oracle", /expiry is outside/], - ["cancel-token-handover", 2202, "share token", /different address/], - ["accept-vault-admin", 2203, "vault", /expired before it was accepted/], - [ - "accept-vault-admin", - 6046, - "vault", - /admin role can no longer change hands/, - ], - [ - "accept-oracle-admin", - 3018, - "oracle", - /admin role can no longer change hands/, - ], - [ - "accept-token-admin", - 7008, - "share token", - /admin role can no longer change hands/, - ], - ["apply-change", 1000, "vault", /The vault is paused/], - ["apply-change", 6046, "vault", /winding down/], - ["propose-wind-down", 6046, "vault", /wind-down is already active/], - ["cancel-wind-down", 6047, "vault", /No wind-down is proposed/], - ["propose-wind-down", 6048, "vault", /wind-down is already proposed/], - ["propose-code-upgrade", 6055, "vault", /code change is already pending/], - ["cancel-change", 6056, "vault", /no pending change with that id/], - ["apply-change", 6057, "vault", /delay has not passed yet/], - ["propose-grant-role", 6063, "vault", /16 pending changes/], - ["propose-admin", 6064, "vault", /Setup is still open/], - ["grant-vault-role", 6065, "vault", /goes through the timelock/], - ["propose-upgrade-delay", 6058, "vault", /cannot be under 7 days/], - ["propose-upgrade-delay", 6059, "vault", /cannot be under the notice/], - ["propose-upgrade-delay", 6060, "vault", /cannot exceed 90 days/], - [ - "cancel-token-change", - 7001, - "share token", - /no pending change with that id/, - ], - ["apply-token-change", 7002, "share token", /delay has not passed yet/], - ["propose-token-grant-role", 7003, "share token", /16 pending changes/], - ["propose-token-admin", 7004, "share token", /Setup is still open/], - ["grant-token-role", 7005, "share token", /goes through the timelock/], - ["apply-token-change", 7008, "share token", /vault is winding down/], - ["apply-token-change", 7009, "share token", /vault is paused/], - ["apply-token-change", 7011, "share token", /upgrade is already pending/], - [ - "hand-over-token-admin", - 7016, - "share token", - /hand-over is still pending/, - ], - ["cancel-oracle-change", 3011, "oracle", /no pending change with that id/], - ["apply-oracle-change", 3012, "oracle", /delay has not passed yet/], - ["propose-oracle-grant-role", 3013, "oracle", /16 pending changes/], - ["propose-oracle-admin", 3014, "oracle", /Setup is still open/], - ["grant-oracle-role", 3015, "oracle", /goes through the timelock/], - ["apply-oracle-change", 3016, "oracle", /setup has already finished/], - ["apply-oracle-change", 3017, "oracle", /not bound to a vault/], - ["apply-oracle-change", 3018, "oracle", /vault is winding down/], - ["apply-oracle-change", 3019, "oracle", /holds every oracle change/], - ["apply-oracle-change", 3020, "oracle", /shorter than the vault's notice/], - ["propose-oracle-upgrade", 3021, "oracle", /upgrade is already pending/], - ])( - "names the reason %s was refused with code %i", - (action, code, refuser, sentence) => { - renderGovernanceModal(action, { - status: "failed", - failure: { kind: "contract-error", code }, - }) - - expect( - screen.getByRole("heading", { - name: `The ${refuser} refused this request`, - }), - ).toBeTruthy() - expect(screen.getByText(sentence)).toBeTruthy() - }, - ) - - it.each<[GovernanceModalAction, string, string, string, string]>([ - [ - "hand-over-vault-admin", - "GABC...WXYZ becomes admin once it accepts before 21 Nov, 22:13 UTC.", - "Signing proposes the handover.", - "Handover proposed", - "GABC...WXYZ becomes admin once it accepts before 21 Nov, 22:13 UTC.", - ], - [ - "cancel-oracle-handover", - "GABC...WXYZ", - "Signing withdraws the offer to GABC...WXYZ.", - "Handover cancelled", - "GABC...WXYZ can no longer accept.", - ], - [ - "accept-token-admin", - "share token", - "Signing makes this wallet the share token admin.", - "Admin accepted", - "This wallet is now the share token admin.", - ], - ])( - "names the handover step of %s", - (action, value, signing, done, doneBody) => { - const { unmount } = renderGovernanceModal( - action, - { status: "awaiting-signature" }, - value, - ) - expect(screen.getByText(signing)).toBeTruthy() - unmount() - - renderGovernanceModal(action, { status: "confirmed" }, value) - expect(screen.getByRole("heading", { name: done })).toBeTruthy() - expect(screen.getByText(doneBody)).toBeTruthy() - }, - ) - - it.each<[GovernanceModalAction, string, string, string, string]>([ - [ - "propose-code-upgrade", - "Code abab...abab", - "Signing queues Code abab...abab.", - "Upgrade proposed", - "Code abab...abab can apply once the upgrade delay passes.", - ], - [ - "apply-change", - "vault change #3: Upgrade delay 14d", - "Signing applies vault change #3: Upgrade delay 14d.", - "Change applied", - "Applied vault change #3: Upgrade delay 14d.", - ], - [ - "cancel-change", - "vault change #2: Code upgrade abab...abab", - "Signing withdraws vault change #2: Code upgrade abab...abab.", - "Change cancelled", - "Withdrew vault change #2: Code upgrade abab...abab.", - ], - [ - "propose-grant-role", - "Manager to GABC...WXYZ", - "Signing queues the grant of Manager to GABC...WXYZ.", - "Change proposed", - "The grant of Manager to GABC...WXYZ applies once the upgrade delay passes.", - ], - [ - "propose-revoke-role", - "Manager from GABC...WXYZ", - "Signing queues the revoke of Manager from GABC...WXYZ.", - "Change proposed", - "The revoke of Manager from GABC...WXYZ applies once the upgrade delay passes.", - ], - [ - "propose-admin", - "GABC...WXYZ", - "Signing queues the hand-over to GABC...WXYZ.", - "Change proposed", - "The hand-over to GABC...WXYZ applies once the upgrade delay passes.", - ], - [ - "propose-token-grant-role", - "Manager to GABC...WXYZ", - "Signing queues the grant of Manager to GABC...WXYZ.", - "Change proposed", - "The grant of Manager to GABC...WXYZ applies once the vault's upgrade delay passes.", - ], - [ - "propose-token-revoke-role", - "Manager from GABC...WXYZ", - "Signing queues the revoke of Manager from GABC...WXYZ.", - "Change proposed", - "The revoke of Manager from GABC...WXYZ applies once the vault's upgrade delay passes.", - ], - [ - "propose-token-admin", - "GABC...WXYZ", - "Signing queues the hand-over to GABC...WXYZ.", - "Change proposed", - "The hand-over to GABC...WXYZ applies once the vault's upgrade delay passes.", - ], - [ - "apply-token-change", - "share token change #1: Compliance contract CABC...WXYZ", - "Signing applies share token change #1: Compliance contract CABC...WXYZ.", - "Change applied", - "Applied share token change #1: Compliance contract CABC...WXYZ.", - ], - [ - "cancel-token-change", - "share token change #1: Compliance contract CABC...WXYZ", - "Signing withdraws share token change #1: Compliance contract CABC...WXYZ.", - "Change cancelled", - "Withdrew share token change #1: Compliance contract CABC...WXYZ.", - ], - [ - "propose-oracle-grant-role", - "Attester to GABC...WXYZ", - "Signing queues the grant of Attester to GABC...WXYZ.", - "Change proposed", - "The grant of Attester to GABC...WXYZ applies once the vault's upgrade delay passes.", - ], - [ - "propose-oracle-admin", - "GABC...WXYZ", - "Signing queues the hand-over to GABC...WXYZ.", - "Change proposed", - "The hand-over to GABC...WXYZ applies once the vault's upgrade delay passes.", - ], - [ - "propose-oracle-upgrade", - "Code abab...abab", - "Signing queues Code abab...abab.", - "Upgrade proposed", - "Code abab...abab can apply once the vault's upgrade delay passes.", - ], - [ - "apply-oracle-change", - "oracle change #1: Admin hand-over GABC...WXYZ", - "Signing applies oracle change #1: Admin hand-over GABC...WXYZ.", - "Change applied", - "Applied oracle change #1: Admin hand-over GABC...WXYZ.", - ], - [ - "cancel-oracle-change", - "oracle change #1: Admin hand-over GABC...WXYZ", - "Signing withdraws oracle change #1: Admin hand-over GABC...WXYZ.", - "Change cancelled", - "Withdrew oracle change #1: Admin hand-over GABC...WXYZ.", - ], - [ - "propose-wind-down", - "Activates 21 Nov, 22:13 UTC.", - "Signing starts the wind-down countdown.", - "Wind-down proposed", - "Activates 21 Nov, 22:13 UTC.", - ], - [ - "cancel-wind-down", - "", - "Signing withdraws the wind-down.", - "Wind-down cancelled", - "The vault stays open.", - ], - ])( - "names the timelock step of %s", - (action, value, signing, done, doneBody) => { - const { unmount } = renderGovernanceModal( - action, - { status: "awaiting-signature" }, - value, - ) - expect(screen.getByText(signing)).toBeTruthy() - unmount() - - renderGovernanceModal(action, { status: "confirmed" }, value) - expect(screen.getByRole("heading", { name: done })).toBeTruthy() - expect(screen.getByText(doneBody)).toBeTruthy() - }, - ) - - it("sends an unknown outcome to the Governance tab", () => { - renderGovernanceModal("grant-token-role", { - status: "failed", - failure: { kind: "unknown" }, - }) - - expect( - screen.getByText(/check the Governance tab before trying again/), - ).toBeTruthy() - }) -}) - -const renderComplianceModal = ( - action: ComplianceModalAction, - status: TransactionStatus, - value = "GABC...WXYZ", -) => - render( - , - ) - -describe("TransactionModal, compliance actions", () => { - it.each<[ComplianceModalAction, string, string, string, string]>([ - [ - "allow-address", - "GABC...WXYZ", - "Signing adds GABC...WXYZ to the allowlist.", - "Address allowed", - "GABC...WXYZ is on the allowlist.", - ], - [ - "freeze-shares", - "5.00 shares of GABC...WXYZ", - "Signing freezes 5.00 shares of GABC...WXYZ.", - "Shares frozen", - "Froze 5.00 shares of GABC...WXYZ.", - ], - [ - "legal-transfer", - "5.00 shares from GABC...WXYZ to GDEF...UVWX", - "Signing approves moving 5.00 shares from GABC...WXYZ to GDEF...UVWX.", - "Legal transfer made", - "Moved 5.00 shares from GABC...WXYZ to GDEF...UVWX.", - ], - ])("names what %s changes", (action, value, signing, done, doneBody) => { - const { unmount } = renderComplianceModal( - action, - { status: "awaiting-signature" }, - value, - ) - expect(screen.getByText(signing)).toBeTruthy() - unmount() - - renderComplianceModal(action, { status: "confirmed" }, value) - expect(screen.getByRole("heading", { name: done })).toBeTruthy() - expect(screen.getByText(doneBody)).toBeTruthy() - }) - - it.each<[ComplianceModalAction, number, string, RegExp]>([ - ["freeze-shares", 300, "share token", /does not hold enough shares/], - ["unfreeze-shares", 301, "share token", /cannot be negative/], - ["unfreeze-shares", 303, "share token", /that many frozen shares/], - ["freeze-address", 2000, "share token", /does not hold the role/], - ["allow-address", 2000, "identity verifier", /does not hold the role/], - ["disallow-address", 2001, "identity verifier", /verifier has no admin/], - [ - "legal-transfer", - 7013, - "share token", - /compliance account and the token admin must be two different accounts/, - ], - ["legal-transfer", 7015, "share token", /reference of 1 to 128 bytes/], - [ - "legal-transfer", - 7012, - "share token", - /never moves shares into or out of the vault/, - ], - ["legal-transfer", 304, "share token", /destination is not allowlisted/], - ["legal-transfer", 300, "share token", /does not hold enough shares/], - ])( - "names the reason %s was refused with code %i", - (action, code, refuser, sentence) => { - renderComplianceModal(action, { - status: "failed", - failure: { kind: "contract-error", code }, - }) - - expect( - screen.getByRole("heading", { - name: `The ${refuser} refused this request`, - }), - ).toBeTruthy() - expect(screen.getByText(sentence)).toBeTruthy() - }, - ) - - it("asks for the token admin's authorization before compliance signs", () => { - renderComplianceModal("legal-transfer", { - status: "shared", - xdr: "AAAAXDR", - standing: "admin-authorization", - expiresAt: null, - }) - - expect( - screen.getByRole("heading", { - name: "Needs the token admin's authorization", - }), - ).toBeTruthy() - expect( - screen.getByText( - "Share the link with the token admin. Once they authorize it, compliance's signers sign it.", - ), - ).toBeTruthy() - expect( - screen.getByRole("textbox", { name: "Signing link" }) - .value, - ).toBe(`${window.location.origin}/sign#AAAAXDR`) - }) - - it("names the wallets that can sign an authorization", () => { - renderComplianceModal("legal-transfer", { - status: "failed", - failure: { kind: "cannot-authorize" }, - }) - - expect( - screen.getByText( - "This wallet cannot sign authorizations. Use Freighter, Hana or WalletConnect.", - ), - ).toBeTruthy() - expect(screen.queryByText("You declined the request")).toBeNull() - expect(screen.queryByRole("button", { name: "Try again" })).toBeNull() - }) - - it("sends an unknown outcome to the Compliance tab", () => { - renderComplianceModal("unfreeze-address", { - status: "failed", - failure: { kind: "unknown" }, - }) - - expect( - screen.getByText(/check the Compliance tab before trying again/), - ).toBeTruthy() - }) -}) - -describe("TransactionModal, allowlist and freeze refusals", () => { - const contractError = (code: number) => ({ - status: "failed" as const, - failure: { kind: "contract-error" as const, code }, - }) - - it("names NotAllowlisted on a subscription as an address that cannot subscribe", () => { - renderModal(contractError(6068)) - - expect(screen.getByText(/not allowlisted/)).toBeTruthy() - expect(screen.getByText(/can subscribe/)).toBeTruthy() - }) - - it("names Frozen on a subscription as a frozen address", () => { - renderModal(contractError(6071)) - - expect(screen.getByText(/frozen/)).toBeTruthy() - expect(screen.getByText(/cannot subscribe/)).toBeTruthy() - }) - - it("names Frozen on a redemption request as a frozen address or frozen shares", () => { - renderRedeemModal(contractError(6071)) - - expect(screen.getByText(/shares are frozen/)).toBeTruthy() - expect(screen.getByText(/free shares/)).toBeTruthy() - }) - - it.each<[string, () => void]>([ - ["cancelling a subscription", () => renderCancelModal(contractError(6071))], - [ - "cancelling a redemption", - () => renderCancelRedeemModal(contractError(6071)), - ], - ["claiming shares", () => renderClaimModal(contractError(6071))], - [ - "claiming a redemption", - () => renderClaimRedeemModal(contractError(6071)), - ], - ])("names Frozen on %s as held until the freeze lifts", (_name, render) => { - render() - - expect(screen.getByText(/held until the freeze is lifted/)).toBeTruthy() - }) - - it("falls back to a generic refusal for NotAllowlisted on a subscription cancellation, since a delisted holder may still cancel", () => { - renderCancelModal(contractError(6068)) - - expect(screen.getByText(/contact support/)).toBeTruthy() - expect(screen.queryByText(/allowlisted/)).toBeNull() - }) -}) - -describe("TransactionModal, conversion and surrender outcomes", () => { - it("tells a delisted investor their claimed shares were queued as a redemption", () => { - renderClaimModal({ - status: "confirmed", - sharesMinted: 0n as Amount, - queuedInto: 5n, - }) - - expect( - screen.getByRole("heading", { name: "Queued as a redemption" }), - ).toBeTruthy() - expect(screen.getByText(/not allowlisted/)).toBeTruthy() - expect(screen.getByText(/pay out in USDC at the next price/)).toBeTruthy() - expect(screen.queryByText(/returned to your wallet/)).toBeNull() - }) - - it("tells a delisted investor in a wind-down their claimed shares joined the wind-down claim", () => { - renderClaimModal({ - status: "confirmed", - sharesMinted: 0n as Amount, - queuedInto: 0n, - }) - - expect( - screen.getByRole("heading", { name: "Moved to your wind-down claim" }), - ).toBeTruthy() - expect(screen.getByText(/not allowlisted/)).toBeTruthy() - }) - - it("still reads a zero-share claim without a conversion as a returned deposit", () => { - renderClaimModal({ status: "confirmed", sharesMinted: 0n as Amount }) - - expect( - screen.getByRole("heading", { name: "Deposit returned" }), - ).toBeTruthy() - }) - - it("tells the investor a cancelled redemption moved into the wind-down claim", () => { - renderCancelRedeemModal({ - status: "confirmed", - returnedShares: 100_0000000n as Amount, - surrendered: true, - }) - - expect( - screen.getByRole("heading", { name: "Moved to your wind-down claim" }), - ).toBeTruthy() - expect(screen.getByText(/winding down/)).toBeTruthy() - expect(screen.queryByText(/returned to your wallet/)).toBeNull() - }) - - it("still reads a returned redemption as cancelled", () => { - renderCancelRedeemModal({ - status: "confirmed", - returnedShares: 100_0000000n as Amount, - surrendered: false, - }) - - expect( - screen.getByRole("heading", { name: "Request cancelled" }), - ).toBeTruthy() - }) -}) - -describe("TransactionModal, wind-down outcomes", () => { - it("tells the investor a zero-price claim burned the shares during a wind-down, never that they came back", () => { - renderClaimRedeemModal({ - status: "confirmed", - assetsClaimed: 0n as Amount, - sharesReturned: false, - }) - - expect( - screen.getByRole("heading", { name: "Nothing to pay out" }), - ).toBeTruthy() - expect(screen.getByText(/winding down/)).toBeTruthy() - expect(screen.getByText(/burned/)).toBeTruthy() - expect(screen.queryByText(/returned to your wallet/)).toBeNull() - }) -}) - -describe("TransactionModal, cancelling a redemption in a wind-down", () => { - it("tells the signer the shares move into the wind-down claim, never that they return", () => { - render( - , - ) - - expect(screen.getByText(/wind-down claim/)).toBeTruthy() - expect(screen.queryByText(/returns/)).toBeNull() - }) -}) - -const renderClaimWindDownModal = (status: ClaimWindDownStatus) => - render( - , - ) - -describe("TransactionModal, claiming from the wind-down", () => { - it("tells the signer what they receive and that their shares are turned in", () => { - renderClaimWindDownModal({ status: "awaiting-signature" }) - - expect(screen.getByText(/25\.00 USDC/)).toBeTruthy() - expect(screen.getByText(/turns in your unfrozen vUSDC/)).toBeTruthy() - }) - - it("shows the assets paid and the shares turned in once confirmed", () => { - renderClaimWindDownModal({ - status: "confirmed", - assetsClaimed: 25_0000000n as Amount, - sharesSurrendered: 40_0000000n as Amount, - }) - - expect(screen.getByRole("heading", { name: "USDC claimed" })).toBeTruthy() - expect(screen.getByText(/25\.00 USDC has been added/)).toBeTruthy() - expect(screen.getByText(/40\.00 vUSDC/)).toBeTruthy() - }) - - it("reads a claim that paid nothing as shares turned in for later rounds", () => { - renderClaimWindDownModal({ - status: "confirmed", - assetsClaimed: 0n as Amount, - sharesSurrendered: 40_0000000n as Amount, - }) - - expect( - screen.getByRole("heading", { name: "Shares turned in" }), - ).toBeTruthy() - expect(screen.getByText(/later rounds/)).toBeTruthy() - }) - - it.each<[number, RegExp]>([ - [6051, /first payout/], - [6054, /nothing to claim from the wind-down/], - [6071, /held until the freeze is lifted/], - ])("names refusal %i in the dapp's words", (code, text) => { - renderClaimWindDownModal({ - status: "failed", - failure: { kind: "contract-error", code }, - }) - - expect(screen.getByText(text)).toBeTruthy() - }) - - it("falls back to a generic refusal for a wind-down claim error it does not recognize", () => { - renderClaimWindDownModal({ - status: "failed", - failure: { kind: "contract-error", code: 9999 }, - }) - - expect(screen.getByText(/contact support/)).toBeTruthy() - expect(screen.queryByText(/9999/)).toBeNull() - }) -}) diff --git a/app/src/components/vault/transactionModal/compliance.test.tsx b/app/src/components/vault/transactionModal/compliance.test.tsx new file mode 100644 index 00000000..045438a2 --- /dev/null +++ b/app/src/components/vault/transactionModal/compliance.test.tsx @@ -0,0 +1,139 @@ +import type * as AppLib from "@stellar-scaffold/app-lib" +import { screen } from "@testing-library/react" +import { describe, expect, it, vi } from "vitest" +import { type ComplianceModalAction } from "../TransactionModal" +import { renderComplianceModal } from "./renderModal" + +vi.mock("@stellar-scaffold/app-lib", async (importOriginal) => ({ + ...(await importOriginal()), + shortAddress: (address: string) => + `${address.slice(0, 4)}...${address.slice(-4)}`, + explorerTransaction: () => null, +})) + +describe("TransactionModal, compliance actions", () => { + it.each<[ComplianceModalAction, string, string, string, string]>([ + [ + "allow-address", + "GABC...WXYZ", + "Signing adds GABC...WXYZ to the allowlist.", + "Address allowed", + "GABC...WXYZ is on the allowlist.", + ], + [ + "freeze-shares", + "5.00 shares of GABC...WXYZ", + "Signing freezes 5.00 shares of GABC...WXYZ.", + "Shares frozen", + "Froze 5.00 shares of GABC...WXYZ.", + ], + [ + "legal-transfer", + "5.00 shares from GABC...WXYZ to GDEF...UVWX", + "Signing approves moving 5.00 shares from GABC...WXYZ to GDEF...UVWX.", + "Legal transfer made", + "Moved 5.00 shares from GABC...WXYZ to GDEF...UVWX.", + ], + ])("names what %s changes", (action, value, signing, done, doneBody) => { + const { unmount } = renderComplianceModal( + action, + { status: "awaiting-signature" }, + value, + ) + expect(screen.getByText(signing)).toBeTruthy() + unmount() + + renderComplianceModal(action, { status: "confirmed" }, value) + expect(screen.getByRole("heading", { name: done })).toBeTruthy() + expect(screen.getByText(doneBody)).toBeTruthy() + }) + + it.each<[ComplianceModalAction, number, string, RegExp]>([ + ["freeze-shares", 300, "share token", /does not hold enough shares/], + ["unfreeze-shares", 301, "share token", /cannot be negative/], + ["unfreeze-shares", 303, "share token", /that many frozen shares/], + ["freeze-address", 2000, "share token", /does not hold the role/], + ["allow-address", 2000, "identity verifier", /does not hold the role/], + ["disallow-address", 2001, "identity verifier", /verifier has no admin/], + [ + "legal-transfer", + 7013, + "share token", + /compliance account and the token admin must be two different accounts/, + ], + ["legal-transfer", 7015, "share token", /reference of 1 to 128 bytes/], + [ + "legal-transfer", + 7012, + "share token", + /never moves shares into or out of the vault/, + ], + ["legal-transfer", 304, "share token", /destination is not allowlisted/], + ["legal-transfer", 300, "share token", /does not hold enough shares/], + ])( + "names the reason %s was refused with code %i", + (action, code, refuser, sentence) => { + renderComplianceModal(action, { + status: "failed", + failure: { kind: "contract-error", code }, + }) + + expect( + screen.getByRole("heading", { + name: `The ${refuser} refused this request`, + }), + ).toBeTruthy() + expect(screen.getByText(sentence)).toBeTruthy() + }, + ) + + it("asks for the token admin's authorization before compliance signs", () => { + renderComplianceModal("legal-transfer", { + status: "shared", + xdr: "AAAAXDR", + standing: "admin-authorization", + expiresAt: null, + }) + + expect( + screen.getByRole("heading", { + name: "Needs the token admin's authorization", + }), + ).toBeTruthy() + expect( + screen.getByText( + "Share the link with the token admin. Once they authorize it, compliance's signers sign it.", + ), + ).toBeTruthy() + expect( + screen.getByRole("textbox", { name: "Signing link" }) + .value, + ).toBe(`${window.location.origin}/sign#AAAAXDR`) + }) + + it("names the wallets that can sign an authorization", () => { + renderComplianceModal("legal-transfer", { + status: "failed", + failure: { kind: "cannot-authorize" }, + }) + + expect( + screen.getByText( + "This wallet cannot sign authorizations. Use Freighter, Hana or WalletConnect.", + ), + ).toBeTruthy() + expect(screen.queryByText("You declined the request")).toBeNull() + expect(screen.queryByRole("button", { name: "Try again" })).toBeNull() + }) + + it("sends an unknown outcome to the Compliance tab", () => { + renderComplianceModal("unfreeze-address", { + status: "failed", + failure: { kind: "unknown" }, + }) + + expect( + screen.getByText(/check the Compliance tab before trying again/), + ).toBeTruthy() + }) +}) diff --git a/app/src/components/vault/transactionModal/configuration.test.tsx b/app/src/components/vault/transactionModal/configuration.test.tsx new file mode 100644 index 00000000..3ea01c87 --- /dev/null +++ b/app/src/components/vault/transactionModal/configuration.test.tsx @@ -0,0 +1,259 @@ +import type * as AppLib from "@stellar-scaffold/app-lib" +import { screen } from "@testing-library/react" +import { describe, expect, it, vi } from "vitest" +import TransactionModal, { + type ConfigurationModalAction, +} from "../TransactionModal" +import { renderConfigurationModal } from "./renderModal" + +vi.mock("@stellar-scaffold/app-lib", async (importOriginal) => ({ + ...(await importOriginal()), + shortAddress: (address: string) => + `${address.slice(0, 4)}...${address.slice(-4)}`, + explorerTransaction: () => null, +})) + +describe("TransactionModal, configuration actions", () => { + it.each<[ConfigurationModalAction, string, string]>([ + [ + "set-deposit-cap", + "Preparing to set the deposit cap", + "Deposit cap updated", + ], + ["set-custodian", "Preparing to set the custodian", "Custodian updated"], + ["set-notice", "Preparing to set the notice", "Notice updated"], + [ + "set-wind-down-delay", + "Preparing to set the wind-down delay", + "Wind-down delay updated", + ], + [ + "set-min-epoch-duration", + "Preparing to set the minimum epoch duration", + "Minimum epoch duration updated", + ], + [ + "set-oracle-config", + "Preparing to set the oracle limits", + "Oracle limits updated", + ], + [ + "set-compliance", + "Preparing to set the compliance contract", + "Compliance contract updated", + ], + [ + "set-identity-verifier", + "Preparing to set the identity verifier", + "Identity verifier updated", + ], + ])("walks %s from preparing to confirmed", (action, preparing, confirmed) => { + const { rerender } = renderConfigurationModal(action, { + status: "preparing", + }) + expect(screen.getByRole("heading", { name: preparing })).toBeTruthy() + + rerender( + , + ) + expect(screen.getByRole("heading", { name: confirmed })).toBeTruthy() + }) + + it("states the new value before signing and once recorded", () => { + const { rerender } = renderConfigurationModal( + "set-deposit-cap", + { status: "awaiting-signature" }, + "5,000.00", + ) + expect(screen.getByText("New deposit cap: 5,000.00.")).toBeTruthy() + + rerender( + , + ) + expect(screen.getByText("Deposit cap: 5,000.00.")).toBeTruthy() + }) + + it.each<[ConfigurationModalAction, string]>([ + ["set-custodian", "Treasury deploys to the new address from now on."], + ["set-notice", "Applies to epochs closed from now on."], + ["set-wind-down-delay", "A proposed wind-down keeps its activation time."], + [ + "set-min-epoch-duration", + "Anyone may seal an epoch once it has been open this long.", + ], + ])("names the side effect of %s once recorded", (action, effect) => { + renderConfigurationModal(action, { status: "confirmed" }, "3d") + + expect(screen.getByText(effect)).toBeTruthy() + }) + + it.each<[ConfigurationModalAction, string, string, string, string]>([ + [ + "propose-custodian", + "GABC...WXYZ", + "Preparing to propose a custodian", + "Signing queues custodian GABC...WXYZ.", + "Custodian GABC...WXYZ applies once the upgrade delay passes.", + ], + [ + "propose-notice", + "12h", + "Preparing to propose a notice", + "Signing queues notice 12h.", + "Notice 12h applies once the upgrade delay passes.", + ], + [ + "propose-wind-down-delay", + "8d", + "Preparing to propose a wind-down delay", + "Signing queues wind-down delay 8d.", + "Wind-down delay 8d applies once the upgrade delay passes.", + ], + [ + "propose-min-epoch-duration", + "7d", + "Preparing to propose a minimum epoch duration", + "Signing queues minimum epoch duration 7d.", + "Minimum epoch duration 7d applies once the upgrade delay passes.", + ], + [ + "propose-compliance", + "CABC...WXYZ", + "Preparing to propose a compliance contract", + "Signing queues compliance contract CABC...WXYZ.", + "Compliance contract CABC...WXYZ applies once the vault's upgrade delay passes.", + ], + [ + "propose-identity-verifier", + "CABC...WXYZ", + "Preparing to propose an identity verifier", + "Signing queues identity verifier CABC...WXYZ.", + "Identity verifier CABC...WXYZ applies once the vault's upgrade delay passes.", + ], + [ + "propose-oracle-config", + "", + "Preparing to propose oracle limits", + "Signing queues every new oracle limit.", + "The new oracle limits apply once the vault's upgrade delay passes.", + ], + ])( + "walks %s from preparing to proposed", + (action, value, preparing, signing, doneBody) => { + const { rerender } = renderConfigurationModal( + action, + { status: "preparing" }, + value, + ) + expect(screen.getByRole("heading", { name: preparing })).toBeTruthy() + + rerender( + , + ) + expect(screen.getByText(signing)).toBeTruthy() + + rerender( + , + ) + expect( + screen.getByRole("heading", { name: "Change proposed" }), + ).toBeTruthy() + expect(screen.getByText(doneBody)).toBeTruthy() + }, + ) + + it.each<[ConfigurationModalAction, number, string, RegExp]>([ + ["set-deposit-cap", 6007, "vault", /The deposit cap cannot be negative/], + ["set-notice", 6045, "vault", /The notice cannot exceed 30 days/], + ["set-notice", 6061, "vault", /The notice cannot exceed the upgrade delay/], + [ + "set-wind-down-delay", + 6053, + "vault", + /The wind-down delay cannot exceed 90 days/, + ], + [ + "set-wind-down-delay", + 6069, + "vault", + /The wind-down delay cannot be under 7 days/, + ], + [ + "set-min-epoch-duration", + 6007, + "vault", + /The minimum epoch duration must be 1 to 30 days/, + ], + [ + "propose-min-epoch-duration", + 6007, + "vault", + /The minimum epoch duration must be 1 to 30 days/, + ], + ["propose-custodian", 6075, "vault", /custodian cannot be the vault/], + ["propose-notice", 6064, "vault", /Setup is still open/], + ["set-notice", 6065, "vault", /goes through the timelock/], + ["propose-custodian", 6063, "vault", /16 pending changes/], + [ + "set-oracle-config", + 3001, + "oracle", + /One of the oracle limits is out of range/, + ], + ["set-compliance", 2000, "share token", /does not hold the role/], + ["propose-compliance", 7003, "share token", /16 pending changes/], + ["propose-identity-verifier", 7004, "share token", /Setup is still open/], + ["set-compliance", 7005, "share token", /goes through the timelock/], + ["propose-compliance", 7008, "share token", /vault is winding down/], + ])( + "names the reason %s was refused with code %i", + (action, code, refuser, sentence) => { + renderConfigurationModal(action, { + status: "failed", + failure: { kind: "contract-error", code }, + }) + + expect( + screen.getByRole("heading", { + name: `The ${refuser} refused this request`, + }), + ).toBeTruthy() + expect(screen.getByText(sentence)).toBeTruthy() + }, + ) + + it("sends an unknown outcome to the Configuration tab", () => { + renderConfigurationModal("set-notice", { + status: "failed", + failure: { kind: "unknown" }, + }) + + expect( + screen.getByText(/check the Configuration tab before trying again/), + ).toBeTruthy() + }) +}) diff --git a/app/src/components/vault/transactionModal/cycle.test.tsx b/app/src/components/vault/transactionModal/cycle.test.tsx new file mode 100644 index 00000000..02ba0ac8 --- /dev/null +++ b/app/src/components/vault/transactionModal/cycle.test.tsx @@ -0,0 +1,670 @@ +import { type Amount, type Price } from "@stellar-scaffold/app-lib" +import type * as AppLib from "@stellar-scaffold/app-lib" +import { fireEvent, screen } from "@testing-library/react" +import { describe, expect, it, vi } from "vitest" +import { + renderCloseEpochModal, + renderFulfillEpochModal, + renderAttestModal, + renderDeployModal, + renderFundModal, + renderActivateModal, + renderFinalizeModal, +} from "./renderModal" + +vi.mock("@stellar-scaffold/app-lib", async (importOriginal) => ({ + ...(await importOriginal()), + shortAddress: (address: string) => + `${address.slice(0, 4)}...${address.slice(-4)}`, + explorerTransaction: () => null, +})) + +describe("TransactionModal, closing an epoch", () => { + it("opens with a preparing state naming the epoch being closed", () => { + renderCloseEpochModal({ status: "preparing" }) + + expect( + screen.getByRole("heading", { name: "Preparing to close epoch 5" }), + ).toBeTruthy() + }) + + it("tells the manager what signing seals and what it opens", () => { + renderCloseEpochModal({ status: "awaiting-signature" }) + + expect( + screen.getByRole("heading", { name: "Confirm in your wallet" }), + ).toBeTruthy() + expect( + screen.getByText( + "Signing seals epoch 5 and opens the next one. Requests in epoch 5 wait for a price.", + ), + ).toBeTruthy() + }) + + it.each(["", "unknown"])( + "renders the signing prompt without throwing when the label is %j", + (epoch) => { + expect(() => + renderCloseEpochModal({ status: "awaiting-signature" }, epoch), + ).not.toThrow() + expect( + screen.getByRole("heading", { name: "Confirm in your wallet" }), + ).toBeTruthy() + }, + ) + + it("tells the manager the close is on its way", () => { + renderCloseEpochModal({ status: "submitted", hash: "a".repeat(64) }) + + expect( + screen.getByRole("heading", { name: "Sending the close" }), + ).toBeTruthy() + expect(screen.getByText(/on its way to the network/)).toBeTruthy() + }) + + it("names the epoch the vault sealed once confirmed, taking the id from the result rather than the label", () => { + renderCloseEpochModal({ + status: "confirmed", + sealedEpoch: 7n, + hash: "b".repeat(64), + }) + + expect(screen.getByRole("heading", { name: "Epoch 7 sealed" })).toBeTruthy() + expect( + screen.getByText( + "Epoch 8 is open and accepting requests. Epoch 7 prices once the notice elapses and a valid price is attested.", + ), + ).toBeTruthy() + }) + + it.each<[number, RegExp]>([ + [6046, /winding down/], + [6029, /current epoch could not be found/], + [6032, /not open/], + [2000, /not authorized to close/], + [2007, /does not hold the manager role/], + [6077, /Only the manager can seal this epoch/], + [6064, /setup is still open, so no epoch can be sealed/], + ])("names the vault's reason for code %i", (code, sentence) => { + renderCloseEpochModal({ + status: "failed", + failure: { kind: "contract-error", code }, + }) + + expect( + screen.getByRole("heading", { name: "The vault refused this request" }), + ).toBeTruthy() + expect(screen.getByText(sentence)).toBeTruthy() + }) + + it("falls back to a generic refusal for a close error it does not recognize, without the raw code", () => { + renderCloseEpochModal({ + status: "failed", + failure: { kind: "contract-error", code: 6001 }, + }) + + expect( + screen.getByText(/The vault refused this request\. Try again/), + ).toBeTruthy() + expect(screen.queryByText(/6001/)).toBeNull() + }) + + it("sends an unknown outcome to the Cycle card, not to the investor's requests", () => { + renderCloseEpochModal({ + status: "failed", + failure: { kind: "unknown" }, + }) + + expect( + screen.getByRole("heading", { name: "Something went wrong" }), + ).toBeTruthy() + expect( + screen.getByText( + "We could not confirm whether this reached the network. We don't yet know if it went through, so check the Cycle card before trying again.", + ), + ).toBeTruthy() + expect(screen.queryByText(/your requests/)).toBeNull() + }) + + it("reads a declined signature as a choice and offers to try again", () => { + const { onRetry } = renderCloseEpochModal({ + status: "failed", + failure: { kind: "declined" }, + }) + + fireEvent.click(screen.getByRole("button", { name: "Try again" })) + + expect(onRetry).toHaveBeenCalledTimes(1) + }) +}) + +describe("TransactionModal, pricing an epoch", () => { + it("opens with a preparing state naming the epoch being priced", () => { + renderFulfillEpochModal({ status: "preparing" }) + + expect( + screen.getByRole("heading", { name: "Preparing to price epoch 4" }), + ).toBeTruthy() + }) + + it("tells the operator what signing prices and settles", () => { + renderFulfillEpochModal({ status: "awaiting-signature" }) + + expect( + screen.getByRole("heading", { name: "Confirm in your wallet" }), + ).toBeTruthy() + expect( + screen.getByText( + "Signing prices epoch 4 at the oracle's current price and settles its requests.", + ), + ).toBeTruthy() + }) + + it("tells the operator the settlement is on its way", () => { + renderFulfillEpochModal({ status: "submitted", hash: "a".repeat(64) }) + + expect( + screen.getByRole("heading", { name: "Sending the settlement" }), + ).toBeTruthy() + expect(screen.getByText(/on its way to the network/)).toBeTruthy() + }) + + it("shows the share price the vault settled at once confirmed, to four places", () => { + renderFulfillEpochModal({ + status: "confirmed", + sharePrice: 1_250_000_000_000_000_000n as Price, + hash: "b".repeat(64), + }) + + expect(screen.getByRole("heading", { name: "Epoch 4 priced" })).toBeTruthy() + expect( + screen.getByText( + "Settled at 1.2500 per share. Share claims are ready; redemption claims wait until the reserve covers them.", + ), + ).toBeTruthy() + }) + + it.each<[number, RegExp]>([ + [6046, /winding down/], + [6029, /epoch could not be found/], + [6038, /not sealed/], + [6042, /notice period has not elapsed/], + [6044, /price is not valid/], + [6043, /attested before this epoch was sealed/], + [6031, /could not resolve a valid share price/], + [6014, /too large/], + [1000, /paused/], + [6064, /setup is still open, so no epoch can be priced/], + ])("names the vault's reason for code %i", (code, sentence) => { + renderFulfillEpochModal({ + status: "failed", + failure: { kind: "contract-error", code }, + }) + + expect(screen.getByText(sentence)).toBeTruthy() + }) + + it("falls back to a generic refusal for a settlement error it does not recognize, without the raw code", () => { + renderFulfillEpochModal({ + status: "failed", + failure: { kind: "contract-error", code: 6035 }, + }) + + expect( + screen.getByText(/The vault refused this request\. Try again/), + ).toBeTruthy() + expect(screen.queryByText(/6035/)).toBeNull() + }) + + it("sends an unknown outcome to the Cycle card, not to the investor's requests", () => { + renderFulfillEpochModal({ + status: "failed", + failure: { kind: "unknown" }, + hash: "c".repeat(64), + }) + + expect( + screen.getByText(/check the Cycle card before trying again/), + ).toBeTruthy() + expect(screen.queryByText(/your requests/)).toBeNull() + expect(screen.getByText(/cccc\.\.\.cccc/)).toBeTruthy() + }) + + it("is reachable as a dialog and dismissible by its close control", () => { + const { onClose } = renderFulfillEpochModal({ status: "preparing" }) + + expect(screen.getByRole("dialog")).toBeTruthy() + fireEvent.click(screen.getByRole("button", { name: "Close" })) + expect(onClose).toHaveBeenCalledTimes(1) + }) +}) + +describe("TransactionModal, attesting a price", () => { + it("opens with a preparing state naming the price being attested", () => { + renderAttestModal({ status: "preparing" }) + + expect( + screen.getByRole("heading", { name: "Preparing to attest 1.0400" }), + ).toBeTruthy() + }) + + it("tells the attester what signing records", () => { + renderAttestModal({ status: "awaiting-signature" }) + + expect( + screen.getByRole("heading", { name: "Confirm in your wallet" }), + ).toBeTruthy() + expect( + screen.getByText( + "Signing records 1.0400 as the share price, with its proof. Sealed epochs settle at it once their notice elapses.", + ), + ).toBeTruthy() + }) + + it("tells the attester the attestation is on its way", () => { + renderAttestModal({ status: "submitted", hash: "a".repeat(64) }) + + expect( + screen.getByRole("heading", { name: "Sending the attestation" }), + ).toBeTruthy() + expect(screen.getByText(/on its way to the network/)).toBeTruthy() + }) + + it("says how long the price stays valid once confirmed", () => { + renderAttestModal({ status: "confirmed", hash: "b".repeat(64) }) + + expect(screen.getByRole("heading", { name: "Price attested" })).toBeTruthy() + expect( + screen.getByText("1.0400 is the share price. Valid for 1d."), + ).toBeTruthy() + }) + + it("leaves the validity out when the freshness window is not known", () => { + renderAttestModal({ status: "confirmed" }, null) + + expect(screen.getByText("1.0400 is the share price.")).toBeTruthy() + }) + + it.each<[number, RegExp]>([ + [3002, /outside the band/], + [3005, /already have expired/], + [3003, /cooldown .* has not elapsed/], + [3004, /moves further from the last attested price/], + [3010, /^Unpause the NAV first\.$/], + [3023, /^The proof hash cannot be all zeros\.$/], + [3024, /^The proof URI is longer than 256 bytes\.$/], + ])("names the oracle's reason for code %i", (code, sentence) => { + renderAttestModal({ + status: "failed", + failure: { kind: "contract-error", code }, + }) + + expect( + screen.getByRole("heading", { name: "The vault refused this request" }), + ).toBeTruthy() + expect(screen.getByText(sentence)).toBeTruthy() + }) + + it.each([2000, 2007])( + "falls back to a generic refusal for code %i, without the raw code", + (code) => { + renderAttestModal({ + status: "failed", + failure: { kind: "contract-error", code }, + }) + + expect( + screen.getByText(/The vault refused this request\. Try again/), + ).toBeTruthy() + expect(screen.queryByText(new RegExp(String(code)))).toBeNull() + }, + ) + + it("sends an unknown outcome to the Cycle card, not to the investor's requests", () => { + renderAttestModal({ status: "failed", failure: { kind: "unknown" } }) + + expect( + screen.getByText(/check the Cycle card before trying again/), + ).toBeTruthy() + expect(screen.queryByText(/your requests/)).toBeNull() + }) + + it("is reachable as a dialog and dismissible by its close control", () => { + const { onClose } = renderAttestModal({ status: "preparing" }) + + expect(screen.getByRole("dialog")).toBeTruthy() + fireEvent.click(screen.getByRole("button", { name: "Close" })) + expect(onClose).toHaveBeenCalledTimes(1) + }) +}) + +describe("TransactionModal, deploying to the custodian", () => { + it("opens with a preparing state naming the amount being deployed", () => { + renderDeployModal({ status: "preparing" }) + + expect( + screen.getByRole("heading", { name: "Preparing to deploy 250.00" }), + ).toBeTruthy() + }) + + it("tells the treasury what signing sends and where", () => { + renderDeployModal({ status: "awaiting-signature" }) + + expect( + screen.getByRole("heading", { name: "Confirm in your wallet" }), + ).toBeTruthy() + expect( + screen.getByText( + "Signing sends 250.00 from the vault's reserve to the custodian.", + ), + ).toBeTruthy() + }) + + it("tells the treasury the deployment is on its way", () => { + renderDeployModal({ status: "submitted", hash: "a".repeat(64) }) + + expect( + screen.getByRole("heading", { name: "Sending the deployment" }), + ).toBeTruthy() + expect(screen.getByText(/on its way to the network/)).toBeTruthy() + }) + + it("says what left the vault once confirmed", () => { + renderDeployModal({ status: "confirmed", hash: "b".repeat(64) }) + + expect(screen.getByRole("heading", { name: "Deployed" })).toBeTruthy() + expect( + screen.getByText("250.00 left the vault for the custodian."), + ).toBeTruthy() + }) + + it.each<[number, RegExp]>([ + [6046, /winding down/], + [6007, /greater than zero/], + [6012, /No custodian is set/], + [6005, /free reserve/], + [6014, /too large/], + [2000, /not authorized/], + [2007, /treasury role/], + ])("names the vault's reason for code %i", (code, sentence) => { + renderDeployModal({ + status: "failed", + failure: { kind: "contract-error", code }, + }) + + expect( + screen.getByRole("heading", { name: "The vault refused this request" }), + ).toBeTruthy() + expect(screen.getByText(sentence)).toBeTruthy() + }) + + it("falls back to a generic refusal for a code it does not recognize, without the raw code", () => { + renderDeployModal({ + status: "failed", + failure: { kind: "contract-error", code: 6029 }, + }) + + expect( + screen.getByText(/The vault refused this request\. Try again/), + ).toBeTruthy() + expect(screen.queryByText(/6029/)).toBeNull() + }) + + it("sends an unknown outcome to the Cycle card, not to the investor's requests", () => { + renderDeployModal({ status: "failed", failure: { kind: "unknown" } }) + + expect( + screen.getByText(/check the Cycle card before trying again/), + ).toBeTruthy() + expect(screen.queryByText(/your requests/)).toBeNull() + }) + + it("is reachable as a dialog and dismissible by its close control", () => { + const { onClose } = renderDeployModal({ status: "preparing" }) + + expect(screen.getByRole("dialog")).toBeTruthy() + fireEvent.click(screen.getByRole("button", { name: "Close" })) + expect(onClose).toHaveBeenCalledTimes(1) + }) +}) + +describe("TransactionModal, funding the reserve", () => { + it("opens with a preparing state naming the amount being funded", () => { + renderFundModal({ status: "preparing" }) + + expect( + screen.getByRole("heading", { name: "Preparing to fund 100.00" }), + ).toBeTruthy() + }) + + it("tells the funder what signing moves out of their wallet", () => { + renderFundModal({ status: "awaiting-signature" }) + + expect( + screen.getByRole("heading", { name: "Confirm in your wallet" }), + ).toBeTruthy() + expect( + screen.getByText( + "Signing moves 100.00 from your wallet into the vault's reserve.", + ), + ).toBeTruthy() + }) + + it("tells the funder the funding is on its way", () => { + renderFundModal({ status: "submitted", hash: "a".repeat(64) }) + + expect( + screen.getByRole("heading", { name: "Sending the funding" }), + ).toBeTruthy() + expect(screen.getByText(/on its way to the network/)).toBeTruthy() + }) + + it("says what joined the reserve once confirmed", () => { + renderFundModal({ status: "confirmed", hash: "b".repeat(64) }) + + expect(screen.getByRole("heading", { name: "Funded" })).toBeTruthy() + expect(screen.getByText("100.00 joined the reserve.")).toBeTruthy() + }) + + it.each<[number, RegExp]>([ + [6007, /greater than zero/], + [6014, /too large/], + ])("names the vault's reason for code %i", (code, sentence) => { + renderFundModal({ + status: "failed", + failure: { kind: "contract-error", code }, + }) + + expect( + screen.getByRole("heading", { name: "The vault refused this request" }), + ).toBeTruthy() + expect(screen.getByText(sentence)).toBeTruthy() + }) + + it("points the funder at their balance and trustline when the asset transfer refuses, without the raw code", () => { + renderFundModal({ + status: "failed", + failure: { kind: "contract-error", code: 10 }, + }) + + expect( + screen.getByText( + "Your wallet could not cover the transfer. Check the balance and the trustline.", + ), + ).toBeTruthy() + expect(screen.queryByText(/\b10\b/)).toBeNull() + }) + + it("sends an unknown outcome to the Cycle card, not to the investor's requests", () => { + renderFundModal({ status: "failed", failure: { kind: "unknown" } }) + + expect( + screen.getByText(/check the Cycle card before trying again/), + ).toBeTruthy() + expect(screen.queryByText(/your requests/)).toBeNull() + }) + + it("is reachable as a dialog and dismissible by its close control", () => { + const { onClose } = renderFundModal({ status: "preparing" }) + + expect(screen.getByRole("dialog")).toBeTruthy() + fireEvent.click(screen.getByRole("button", { name: "Close" })) + expect(onClose).toHaveBeenCalledTimes(1) + }) +}) + +describe("TransactionModal, activating the wind-down", () => { + it("opens with a preparing state", () => { + renderActivateModal({ status: "preparing" }) + + expect( + screen.getByRole("heading", { + name: "Preparing to activate the wind-down", + }), + ).toBeTruthy() + }) + + it("tells the signer what the activation stops and opens", () => { + renderActivateModal({ status: "awaiting-signature" }) + + expect( + screen.getByRole("heading", { name: "Confirm in your wallet" }), + ).toBeTruthy() + expect( + screen.getByText( + "Signing stops new requests and opens distribution rounds.", + ), + ).toBeTruthy() + }) + + it("tells the signer the activation is on its way", () => { + renderActivateModal({ status: "submitted", hash: "a".repeat(64) }) + + expect( + screen.getByRole("heading", { name: "Sending the activation" }), + ).toBeTruthy() + expect(screen.getByText(/on its way to the network/)).toBeTruthy() + }) + + it("says requests are closed and rounds can distribute once confirmed", () => { + renderActivateModal({ status: "confirmed", hash: "b".repeat(64) }) + + expect( + screen.getByRole("heading", { name: "Wind-down active" }), + ).toBeTruthy() + expect( + screen.getByText( + "New requests are closed. Rounds can now distribute the free reserve.", + ), + ).toBeTruthy() + }) + + it.each<[number, RegExp]>([ + [6047, /No wind-down has been proposed/], + [6046, /already active/], + [6049, /delay has not elapsed/], + ])("names the vault's reason for code %i", (code, sentence) => { + renderActivateModal({ + status: "failed", + failure: { kind: "contract-error", code }, + }) + + expect( + screen.getByRole("heading", { name: "The vault refused this request" }), + ).toBeTruthy() + expect(screen.getByText(sentence)).toBeTruthy() + }) + + it("falls back to a generic refusal for a code it does not recognize, without the raw code", () => { + renderActivateModal({ + status: "failed", + failure: { kind: "contract-error", code: 6050 }, + }) + + expect( + screen.getByText(/The vault refused this request\. Try again/), + ).toBeTruthy() + expect(screen.queryByText(/6050/)).toBeNull() + }) + + it("sends an unknown outcome to the Cycle card, not to the investor's requests", () => { + renderActivateModal({ status: "failed", failure: { kind: "unknown" } }) + + expect( + screen.getByText(/check the Cycle card before trying again/), + ).toBeTruthy() + expect(screen.queryByText(/your requests/)).toBeNull() + }) +}) + +describe("TransactionModal, finalizing a wind-down round", () => { + it("opens with a preparing state naming the round", () => { + renderFinalizeModal({ status: "preparing" }) + + expect( + screen.getByRole("heading", { name: "Preparing round 3" }), + ).toBeTruthy() + }) + + it("tells the signer what the round distributes", () => { + renderFinalizeModal({ status: "awaiting-signature" }) + + expect( + screen.getByRole("heading", { name: "Confirm in your wallet" }), + ).toBeTruthy() + expect( + screen.getByText( + "Signing distributes the free reserve to holders as round 3.", + ), + ).toBeTruthy() + }) + + it("tells the signer the round is on its way", () => { + renderFinalizeModal({ status: "submitted", hash: "a".repeat(64) }) + + expect( + screen.getByRole("heading", { name: "Sending the round" }), + ).toBeTruthy() + expect(screen.getByText(/on its way to the network/)).toBeTruthy() + }) + + it("says what holders can claim once confirmed, to the last digit", () => { + renderFinalizeModal({ + status: "confirmed", + hash: "b".repeat(64), + credited: 9_000_000_001n as Amount, + }) + + expect( + screen.getByRole("heading", { name: "Round 3 finalized" }), + ).toBeTruthy() + expect( + screen.getByText("900.0000001 is claimable by holders."), + ).toBeTruthy() + }) + + it.each<[number, RegExp]>([ + [6050, /not active/], + [6052, /nothing to distribute/], + [6014, /too large/], + ])("names the vault's reason for code %i", (code, sentence) => { + renderFinalizeModal({ + status: "failed", + failure: { kind: "contract-error", code }, + }) + + expect( + screen.getByRole("heading", { name: "The vault refused this request" }), + ).toBeTruthy() + expect(screen.getByText(sentence)).toBeTruthy() + }) + + it("sends an unknown outcome to the Cycle card, not to the investor's requests", () => { + renderFinalizeModal({ status: "failed", failure: { kind: "unknown" } }) + + expect( + screen.getByText(/check the Cycle card before trying again/), + ).toBeTruthy() + expect(screen.queryByText(/your requests/)).toBeNull() + }) +}) diff --git a/app/src/components/vault/transactionModal/emergency.test.tsx b/app/src/components/vault/transactionModal/emergency.test.tsx new file mode 100644 index 00000000..78195831 --- /dev/null +++ b/app/src/components/vault/transactionModal/emergency.test.tsx @@ -0,0 +1,263 @@ +import { formatDate, type SubmitFailure } from "@stellar-scaffold/app-lib" +import type * as AppLib from "@stellar-scaffold/app-lib" +import { act, fireEvent, screen } from "@testing-library/react" +import { describe, expect, it, vi } from "vitest" +import TransactionModal, { + type EmergencyModalAction, +} from "../TransactionModal" +import { renderModal, renderEmergencyModal } from "./renderModal" + +vi.mock("@stellar-scaffold/app-lib", async (importOriginal) => ({ + ...(await importOriginal()), + shortAddress: (address: string) => + `${address.slice(0, 4)}...${address.slice(-4)}`, + explorerTransaction: () => null, +})) + +describe("TransactionModal, emergency actions", () => { + it("shows the signatures collected, the expiry and the signing link to copy", async () => { + const writeText = vi.fn().mockResolvedValue(undefined) + Object.defineProperty(navigator, "clipboard", { + value: { writeText }, + configurable: true, + }) + const expiresAt = new Date(Date.UTC(2026, 9, 22)) + renderEmergencyModal("pause-vault", { + status: "shared", + xdr: "AAAAXDR", + standing: { needs: 2 }, + expiresAt, + }) + const link = `${window.location.origin}/sign#AAAAXDR` + + expect( + screen.getByRole("heading", { name: "1 of 2 signatures" }), + ).toBeTruthy() + expect( + screen.getByText( + `Share the link with the next signer. Valid until ${formatDate(BigInt(expiresAt.getTime() / 1000))}.`, + ), + ).toBeTruthy() + expect( + screen.getByRole("textbox", { name: "Signing link" }) + .value, + ).toBe(link) + expect(screen.getByText("Approved in your wallet").textContent).toMatch( + /done/, + ) + + fireEvent.click(screen.getByRole("button", { name: "Copy link" })) + + expect(writeText).toHaveBeenCalledWith(link) + expect(await screen.findByRole("button", { name: "Copied" })).toBeTruthy() + }) + + it("names the weight a weighted signer adds against the threshold", () => { + renderEmergencyModal("pause-vault", { + status: "shared", + xdr: "AAAAXDR", + standing: { weight: 1, threshold: 3 }, + expiresAt: null, + }) + + expect( + screen.getByRole("heading", { name: "weight 1 of threshold 3" }), + ).toBeTruthy() + }) + + it("keeps offering the copy when the clipboard refuses, leaving the link to select", async () => { + const writeText = vi.fn().mockRejectedValue(new Error("denied")) + Object.defineProperty(navigator, "clipboard", { + value: { writeText }, + configurable: true, + }) + renderEmergencyModal("pause-vault", { + status: "shared", + xdr: "AAAAXDR", + standing: { needs: 2 }, + expiresAt: null, + }) + + fireEvent.click(screen.getByRole("button", { name: "Copy link" })) + await act(() => Promise.resolve()) + + expect(writeText).toHaveBeenCalledTimes(1) + expect(screen.getByRole("button", { name: "Copy link" })).toBeTruthy() + expect(screen.queryByRole("button", { name: "Copied" })).toBeNull() + }) + + it.each<[SubmitFailure, string]>([ + ["expired", "The transaction expired before it was submitted."], + [ + "sequence-consumed", + "The account moved on; this transaction is no longer valid.", + ], + ["bad-auth", "The signatures do not match the account's threshold."], + ["fee", "The account cannot pay the fee."], + ["fee-too-low", "The fee is below the network minimum. Propose it again."], + ["not-accepted", "The network did not accept it. Try again."], + ])("explains a %s rejection from the network", (reason, sentence) => { + renderEmergencyModal("pause-vault", { + status: "failed", + failure: { kind: "network", reason }, + hash: "d".repeat(64), + }) + + expect( + screen.getByRole("heading", { name: "The network rejected it" }), + ).toBeTruthy() + expect(screen.getByText(sentence)).toBeTruthy() + expect(screen.getByText("Sent to the network").textContent).toMatch( + /failed/, + ) + }) + + it("asks to try again when a prepared transaction went stale", () => { + const { onRetry } = renderModal({ + status: "failed", + failure: { kind: "stale" }, + }) + + expect( + screen.getByRole("heading", { name: "The transaction went stale" }), + ).toBeTruthy() + expect( + screen.getByText( + "The account moved on since it was prepared, so nothing was sent. Try again.", + ), + ).toBeTruthy() + fireEvent.click(screen.getByRole("button", { name: "Try again" })) + expect(onRetry).toHaveBeenCalledTimes(1) + }) + + it("treats an unexplained network rejection as an unknown outcome", () => { + renderEmergencyModal("pause-vault", { + status: "failed", + failure: { kind: "network", reason: "unknown" }, + }) + + expect( + screen.getByRole("heading", { name: "Something went wrong" }), + ).toBeTruthy() + expect( + screen.getByText(/check the Emergency tab before trying again/), + ).toBeTruthy() + }) + + it.each<[EmergencyModalAction, string, string]>([ + ["pause-vault", "Preparing to pause the vault", "Vault paused"], + ["unpause-vault", "Preparing to unpause the vault", "Vault unpaused"], + ["pause-nav", "Preparing to pause the NAV", "NAV paused"], + ["unpause-nav", "Preparing to unpause the NAV", "NAV unpaused"], + [ + "clear-latest", + "Preparing to clear the latest price", + "Latest price cleared", + ], + ["pause-token", "Preparing to pause the share token", "Share token paused"], + [ + "unpause-token", + "Preparing to unpause the share token", + "Share token unpaused", + ], + ])("walks %s from preparing to confirmed", (action, preparing, confirmed) => { + const { rerender } = renderEmergencyModal(action, { status: "preparing" }) + expect(screen.getByRole("heading", { name: preparing })).toBeTruthy() + + rerender( + , + ) + expect(screen.getByRole("heading", { name: confirmed })).toBeTruthy() + }) + + it("tells the signer what pausing the vault stops and what stays open", () => { + renderEmergencyModal("pause-vault", { status: "awaiting-signature" }) + + expect( + screen.getByText("Signing stops new deposits, pricing and upgrades."), + ).toBeTruthy() + }) + + it.each<[EmergencyModalAction, string, string]>([ + [ + "pause-token", + "Signing stops holder transfers only.", + "Holder transfers are stopped. Minting, burning, the vault's escrow moves and legal transfers continue.", + ], + [ + "unpause-token", + "Signing resumes holder transfers.", + "Holder transfers are open again.", + ], + ])( + "words %s after what the share token enforces", + (action, signing, done) => { + const { rerender } = renderEmergencyModal(action, { + status: "awaiting-signature", + }) + expect(screen.getByText(signing)).toBeTruthy() + + rerender( + , + ) + expect(screen.getByText(done)).toBeTruthy() + }, + ) + + it.each<[EmergencyModalAction, number, string, RegExp]>([ + ["pause-vault", 1000, "vault", /The vault is already paused/], + ["unpause-vault", 1001, "vault", /The vault is not paused/], + ["pause-token", 1000, "share token", /The share token is already paused/], + ["unpause-token", 1001, "share token", /The share token is not paused/], + ["pause-nav", 2000, "oracle", /does not hold the role/], + ["clear-latest", 3008, "oracle", /The NAV is not paused/], + ])( + "names the reason %s was refused with code %i", + (action, code, refuser, sentence) => { + renderEmergencyModal(action, { + status: "failed", + failure: { kind: "contract-error", code }, + }) + + expect( + screen.getByRole("heading", { + name: `The ${refuser} refused this request`, + }), + ).toBeTruthy() + expect(screen.getByText(sentence)).toBeTruthy() + }, + ) + + it("falls back to a generic refusal from the right contract without the raw code", () => { + renderEmergencyModal("pause-token", { + status: "failed", + failure: { kind: "contract-error", code: 9999 }, + }) + + expect( + screen.getByText(/The share token refused this request\. Try again/), + ).toBeTruthy() + expect(screen.queryByText(/9999/)).toBeNull() + }) + + it("sends an unknown outcome to the Emergency tab", () => { + renderEmergencyModal("pause-nav", { + status: "failed", + failure: { kind: "unknown" }, + }) + + expect( + screen.getByText(/check the Emergency tab before trying again/), + ).toBeTruthy() + }) +}) diff --git a/app/src/components/vault/transactionModal/governance.test.tsx b/app/src/components/vault/transactionModal/governance.test.tsx new file mode 100644 index 00000000..f3068ea6 --- /dev/null +++ b/app/src/components/vault/transactionModal/governance.test.tsx @@ -0,0 +1,333 @@ +import type * as AppLib from "@stellar-scaffold/app-lib" +import { screen } from "@testing-library/react" +import { describe, expect, it, vi } from "vitest" +import { type GovernanceModalAction } from "../TransactionModal" +import { renderGovernanceModal } from "./renderModal" + +vi.mock("@stellar-scaffold/app-lib", async (importOriginal) => ({ + ...(await importOriginal()), + shortAddress: (address: string) => + `${address.slice(0, 4)}...${address.slice(-4)}`, + explorerTransaction: () => null, +})) + +describe("TransactionModal, governance actions", () => { + it.each<[GovernanceModalAction, string, string, string, string]>([ + [ + "grant-vault-role", + "Manager to GABC...WXYZ", + "Signing grants Manager to GABC...WXYZ.", + "Role granted", + "Granted Manager to GABC...WXYZ.", + ], + [ + "revoke-token-role", + "Manager from CVAU...LTID", + "Signing revokes Manager from CVAU...LTID.", + "Role revoked", + "Revoked Manager from CVAU...LTID.", + ], + ])( + "names the role change of %s", + (action, value, signing, done, doneBody) => { + const { unmount } = renderGovernanceModal( + action, + { status: "awaiting-signature" }, + value, + ) + expect(screen.getByText(signing)).toBeTruthy() + unmount() + + renderGovernanceModal(action, { status: "confirmed" }, value) + expect(screen.getByRole("heading", { name: done })).toBeTruthy() + expect(screen.getByText(doneBody)).toBeTruthy() + }, + ) + + it.each<[GovernanceModalAction, number, string, RegExp]>([ + ["grant-vault-role", 2000, "vault", /does not hold the role/], + ["revoke-oracle-role", 2002, "oracle", /holders changed/], + ["revoke-token-role", 2007, "share token", /does not hold this role/], + [ + "grant-oracle-role", + 2010, + "oracle", + /The oracle already has the maximum number of roles/, + ], + ["cancel-vault-handover", 2200, "vault", /no pending handover/], + ["hand-over-oracle-admin", 2201, "oracle", /expiry is outside/], + ["cancel-token-handover", 2202, "share token", /different address/], + ["accept-vault-admin", 2203, "vault", /expired before it was accepted/], + [ + "accept-vault-admin", + 6046, + "vault", + /admin role can no longer change hands/, + ], + [ + "accept-oracle-admin", + 3018, + "oracle", + /admin role can no longer change hands/, + ], + [ + "accept-token-admin", + 7008, + "share token", + /admin role can no longer change hands/, + ], + ["apply-change", 1000, "vault", /The vault is paused/], + ["apply-change", 6046, "vault", /winding down/], + ["propose-wind-down", 6046, "vault", /wind-down is already active/], + ["cancel-wind-down", 6047, "vault", /No wind-down is proposed/], + ["propose-wind-down", 6048, "vault", /wind-down is already proposed/], + ["propose-code-upgrade", 6055, "vault", /code change is already pending/], + ["cancel-change", 6056, "vault", /no pending change with that id/], + ["apply-change", 6057, "vault", /delay has not passed yet/], + ["propose-grant-role", 6063, "vault", /16 pending changes/], + ["propose-admin", 6064, "vault", /Setup is still open/], + ["grant-vault-role", 6065, "vault", /goes through the timelock/], + ["propose-upgrade-delay", 6058, "vault", /cannot be under 7 days/], + ["propose-upgrade-delay", 6059, "vault", /cannot be under the notice/], + ["propose-upgrade-delay", 6060, "vault", /cannot exceed 90 days/], + [ + "cancel-token-change", + 7001, + "share token", + /no pending change with that id/, + ], + ["apply-token-change", 7002, "share token", /delay has not passed yet/], + ["propose-token-grant-role", 7003, "share token", /16 pending changes/], + ["propose-token-admin", 7004, "share token", /Setup is still open/], + ["grant-token-role", 7005, "share token", /goes through the timelock/], + ["apply-token-change", 7008, "share token", /vault is winding down/], + ["apply-token-change", 7009, "share token", /vault is paused/], + ["apply-token-change", 7011, "share token", /upgrade is already pending/], + [ + "hand-over-token-admin", + 7016, + "share token", + /hand-over is still pending/, + ], + ["cancel-oracle-change", 3011, "oracle", /no pending change with that id/], + ["apply-oracle-change", 3012, "oracle", /delay has not passed yet/], + ["propose-oracle-grant-role", 3013, "oracle", /16 pending changes/], + ["propose-oracle-admin", 3014, "oracle", /Setup is still open/], + ["grant-oracle-role", 3015, "oracle", /goes through the timelock/], + ["apply-oracle-change", 3016, "oracle", /setup has already finished/], + ["apply-oracle-change", 3017, "oracle", /not bound to a vault/], + ["apply-oracle-change", 3018, "oracle", /vault is winding down/], + ["apply-oracle-change", 3019, "oracle", /holds every oracle change/], + ["apply-oracle-change", 3020, "oracle", /shorter than the vault's notice/], + ["propose-oracle-upgrade", 3021, "oracle", /upgrade is already pending/], + ])( + "names the reason %s was refused with code %i", + (action, code, refuser, sentence) => { + renderGovernanceModal(action, { + status: "failed", + failure: { kind: "contract-error", code }, + }) + + expect( + screen.getByRole("heading", { + name: `The ${refuser} refused this request`, + }), + ).toBeTruthy() + expect(screen.getByText(sentence)).toBeTruthy() + }, + ) + + it.each<[GovernanceModalAction, string, string, string, string]>([ + [ + "hand-over-vault-admin", + "GABC...WXYZ becomes admin once it accepts before 21 Nov, 22:13 UTC.", + "Signing proposes the handover.", + "Handover proposed", + "GABC...WXYZ becomes admin once it accepts before 21 Nov, 22:13 UTC.", + ], + [ + "cancel-oracle-handover", + "GABC...WXYZ", + "Signing withdraws the offer to GABC...WXYZ.", + "Handover cancelled", + "GABC...WXYZ can no longer accept.", + ], + [ + "accept-token-admin", + "share token", + "Signing makes this wallet the share token admin.", + "Admin accepted", + "This wallet is now the share token admin.", + ], + ])( + "names the handover step of %s", + (action, value, signing, done, doneBody) => { + const { unmount } = renderGovernanceModal( + action, + { status: "awaiting-signature" }, + value, + ) + expect(screen.getByText(signing)).toBeTruthy() + unmount() + + renderGovernanceModal(action, { status: "confirmed" }, value) + expect(screen.getByRole("heading", { name: done })).toBeTruthy() + expect(screen.getByText(doneBody)).toBeTruthy() + }, + ) + + it.each<[GovernanceModalAction, string, string, string, string]>([ + [ + "propose-code-upgrade", + "Code abab...abab", + "Signing queues Code abab...abab.", + "Upgrade proposed", + "Code abab...abab can apply once the upgrade delay passes.", + ], + [ + "apply-change", + "vault change #3: Upgrade delay 14d", + "Signing applies vault change #3: Upgrade delay 14d.", + "Change applied", + "Applied vault change #3: Upgrade delay 14d.", + ], + [ + "cancel-change", + "vault change #2: Code upgrade abab...abab", + "Signing withdraws vault change #2: Code upgrade abab...abab.", + "Change cancelled", + "Withdrew vault change #2: Code upgrade abab...abab.", + ], + [ + "propose-grant-role", + "Manager to GABC...WXYZ", + "Signing queues the grant of Manager to GABC...WXYZ.", + "Change proposed", + "The grant of Manager to GABC...WXYZ applies once the upgrade delay passes.", + ], + [ + "propose-revoke-role", + "Manager from GABC...WXYZ", + "Signing queues the revoke of Manager from GABC...WXYZ.", + "Change proposed", + "The revoke of Manager from GABC...WXYZ applies once the upgrade delay passes.", + ], + [ + "propose-admin", + "GABC...WXYZ", + "Signing queues the hand-over to GABC...WXYZ.", + "Change proposed", + "The hand-over to GABC...WXYZ applies once the upgrade delay passes.", + ], + [ + "propose-token-grant-role", + "Manager to GABC...WXYZ", + "Signing queues the grant of Manager to GABC...WXYZ.", + "Change proposed", + "The grant of Manager to GABC...WXYZ applies once the vault's upgrade delay passes.", + ], + [ + "propose-token-revoke-role", + "Manager from GABC...WXYZ", + "Signing queues the revoke of Manager from GABC...WXYZ.", + "Change proposed", + "The revoke of Manager from GABC...WXYZ applies once the vault's upgrade delay passes.", + ], + [ + "propose-token-admin", + "GABC...WXYZ", + "Signing queues the hand-over to GABC...WXYZ.", + "Change proposed", + "The hand-over to GABC...WXYZ applies once the vault's upgrade delay passes.", + ], + [ + "apply-token-change", + "share token change #1: Compliance contract CABC...WXYZ", + "Signing applies share token change #1: Compliance contract CABC...WXYZ.", + "Change applied", + "Applied share token change #1: Compliance contract CABC...WXYZ.", + ], + [ + "cancel-token-change", + "share token change #1: Compliance contract CABC...WXYZ", + "Signing withdraws share token change #1: Compliance contract CABC...WXYZ.", + "Change cancelled", + "Withdrew share token change #1: Compliance contract CABC...WXYZ.", + ], + [ + "propose-oracle-grant-role", + "Attester to GABC...WXYZ", + "Signing queues the grant of Attester to GABC...WXYZ.", + "Change proposed", + "The grant of Attester to GABC...WXYZ applies once the vault's upgrade delay passes.", + ], + [ + "propose-oracle-admin", + "GABC...WXYZ", + "Signing queues the hand-over to GABC...WXYZ.", + "Change proposed", + "The hand-over to GABC...WXYZ applies once the vault's upgrade delay passes.", + ], + [ + "propose-oracle-upgrade", + "Code abab...abab", + "Signing queues Code abab...abab.", + "Upgrade proposed", + "Code abab...abab can apply once the vault's upgrade delay passes.", + ], + [ + "apply-oracle-change", + "oracle change #1: Admin hand-over GABC...WXYZ", + "Signing applies oracle change #1: Admin hand-over GABC...WXYZ.", + "Change applied", + "Applied oracle change #1: Admin hand-over GABC...WXYZ.", + ], + [ + "cancel-oracle-change", + "oracle change #1: Admin hand-over GABC...WXYZ", + "Signing withdraws oracle change #1: Admin hand-over GABC...WXYZ.", + "Change cancelled", + "Withdrew oracle change #1: Admin hand-over GABC...WXYZ.", + ], + [ + "propose-wind-down", + "Activates 21 Nov, 22:13 UTC.", + "Signing starts the wind-down countdown.", + "Wind-down proposed", + "Activates 21 Nov, 22:13 UTC.", + ], + [ + "cancel-wind-down", + "", + "Signing withdraws the wind-down.", + "Wind-down cancelled", + "The vault stays open.", + ], + ])( + "names the timelock step of %s", + (action, value, signing, done, doneBody) => { + const { unmount } = renderGovernanceModal( + action, + { status: "awaiting-signature" }, + value, + ) + expect(screen.getByText(signing)).toBeTruthy() + unmount() + + renderGovernanceModal(action, { status: "confirmed" }, value) + expect(screen.getByRole("heading", { name: done })).toBeTruthy() + expect(screen.getByText(doneBody)).toBeTruthy() + }, + ) + + it("sends an unknown outcome to the Governance tab", () => { + renderGovernanceModal("grant-token-role", { + status: "failed", + failure: { kind: "unknown" }, + }) + + expect( + screen.getByText(/check the Governance tab before trying again/), + ).toBeTruthy() + }) +}) diff --git a/app/src/components/vault/transactionModal/investor.test.tsx b/app/src/components/vault/transactionModal/investor.test.tsx new file mode 100644 index 00000000..ce67042f --- /dev/null +++ b/app/src/components/vault/transactionModal/investor.test.tsx @@ -0,0 +1,807 @@ +import { type Amount } from "@stellar-scaffold/app-lib" +import type * as AppLib from "@stellar-scaffold/app-lib" +import { fireEvent, screen } from "@testing-library/react" +import { describe, expect, it, vi } from "vitest" +import { + renderCancelModal, + renderCancelRedeemModal, + renderClaimModal, + renderRedeemModal, + renderClaimRedeemModal, +} from "./renderModal" + +vi.mock("@stellar-scaffold/app-lib", async (importOriginal) => ({ + ...(await importOriginal()), + shortAddress: (address: string) => + `${address.slice(0, 4)}...${address.slice(-4)}`, + explorerTransaction: () => null, +})) + +describe("TransactionModal, cancelling", () => { + it("opens with a preparing state naming the cancellation, not the request", () => { + renderCancelModal({ status: "preparing" }) + + expect( + screen.getByRole("heading", { name: "Preparing your cancellation" }), + ).toBeTruthy() + }) + + it("tells the investor their deposit returns to their wallet, honestly, without escrow or pricing language", () => { + renderCancelModal({ status: "awaiting-signature" }) + + expect( + screen.getByRole("heading", { name: "Confirm in your wallet" }), + ).toBeTruthy() + expect(screen.getByText(/150\.00 USDC/)).toBeTruthy() + expect(screen.getByText(/from escrow/)).toBeTruthy() + expect(screen.queryByText(/into escrow/)).toBeNull() + expect(screen.getByText(/withdrawn, not priced/)).toBeTruthy() + }) + + it("tells the investor the cancellation is on its way, distinct from the subscribe copy", () => { + renderCancelModal({ status: "submitted", hash: "a".repeat(64) }) + + expect( + screen.getByRole("heading", { name: "Sending your cancellation" }), + ).toBeTruthy() + expect(screen.getByText(/on its way to the network/)).toBeTruthy() + }) + + it("shows the returned amount once confirmed, with no batch or pricing claim", () => { + renderCancelModal({ + status: "confirmed", + refundedAmount: 150_0000000n as Amount, + hash: "b".repeat(64), + }) + + expect( + screen.getByRole("heading", { name: "Request cancelled" }), + ).toBeTruthy() + expect(screen.getByText(/150\.00 USDC/)).toBeTruthy() + expect(screen.queryByText(/Batch/)).toBeNull() + expect(screen.queryByText(/attestation/)).toBeNull() + expect(screen.queryByText(/Epoch/)).toBeNull() + }) + + it("reports the amount the vault returned, not the one captured when the investor pressed cancel", () => { + renderCancelModal({ + status: "confirmed", + refundedAmount: 275_5000000n as Amount, + hash: "b".repeat(64), + }) + + expect(screen.getByText(/275\.50 USDC/)).toBeTruthy() + expect(screen.queryByText(/150\.00/)).toBeNull() + }) + + it("renders the refunded amount at full precision, not rounded to two fraction digits", () => { + renderCancelModal({ + status: "confirmed", + refundedAmount: 952380952n as Amount, + hash: "b".repeat(64), + }) + + expect(screen.getByText(/95\.2380952 USDC/)).toBeTruthy() + }) + + it("names the vault's own reason for a cancel-specific contract refusal, distinct from subscribe's codes", () => { + renderCancelModal({ + status: "failed", + failure: { kind: "contract-error", code: 6039 }, + }) + + expect( + screen.getByRole("heading", { name: "The vault refused this request" }), + ).toBeTruthy() + expect( + screen.getByText(/already been priced. Claim your shares instead/), + ).toBeTruthy() + }) + + it("names PriceAvailable distinctly from AlreadyPriced, without sending the investor to claim yet", () => { + renderCancelModal({ + status: "failed", + failure: { kind: "contract-error", code: 6041 }, + }) + + expect(screen.getByText(/A price is available/)).toBeTruthy() + expect(screen.getByText(/claimable once it is/)).toBeTruthy() + expect(screen.queryByText(/Claim your shares instead/)).toBeNull() + }) + + it("names RequestNotFound for a cancellation of a request that no longer exists", () => { + renderCancelModal({ + status: "failed", + failure: { kind: "contract-error", code: 6001 }, + }) + + expect(screen.getByText(/no longer exists to cancel/)).toBeTruthy() + }) + + it("names EpochNotFound as a request that could not be found", () => { + renderCancelModal({ + status: "failed", + failure: { kind: "contract-error", code: 6029 }, + }) + + expect(screen.getByText(/This request could not be found/)).toBeTruthy() + }) + + it("names EscrowNotCovered as a deposit the vault cannot return until it is funded again", () => { + renderCancelModal({ + status: "failed", + failure: { kind: "contract-error", code: 6076 }, + }) + + expect( + screen.getByRole("heading", { name: "The vault refused this request" }), + ).toBeTruthy() + expect( + screen.getByText(/You can cancel once it is funded again/), + ).toBeTruthy() + }) + + it("falls back to a generic refusal for a cancel error it does not recognize, without the raw code", () => { + renderCancelModal({ + status: "failed", + failure: { kind: "contract-error", code: 9999 }, + }) + + expect(screen.getByText(/contact support/)).toBeTruthy() + expect(screen.queryByText(/9999/)).toBeNull() + }) + + it("falls back to a generic refusal for 304 on a deposit cancellation, since only the share token's identity check can raise it", () => { + renderCancelModal({ + status: "failed", + failure: { kind: "contract-error", code: 304 }, + }) + + expect(screen.getByText(/contact support/)).toBeTruthy() + expect(screen.queryByText(/304/)).toBeNull() + expect(screen.queryByText(/shares cannot be returned/)).toBeNull() + }) + + it("reads a declined cancellation as a choice, not a failure, and offers to try again", () => { + const { onRetry } = renderCancelModal({ + status: "failed", + failure: { kind: "declined" }, + }) + + expect( + screen.getByRole("heading", { name: "You declined the request" }), + ).toBeTruthy() + + fireEvent.click(screen.getByRole("button", { name: "Try again" })) + expect(onRetry).toHaveBeenCalledTimes(1) + }) + + it("shows the same step progress machinery for a cancellation in flight", () => { + renderCancelModal({ status: "awaiting-signature" }) + + const steps = screen.getAllByRole("listitem") + expect(steps.map((step) => step.textContent)).toEqual([ + "Approved in your wallet, in progress", + "Sent to the network", + "Recorded", + ]) + }) + + it("is reachable as a dialog and dismissible by its close control", () => { + const { onClose } = renderCancelModal({ status: "awaiting-signature" }) + + fireEvent.click(screen.getByRole("button", { name: "Close" })) + expect(onClose).toHaveBeenCalledTimes(1) + }) +}) + +describe("TransactionModal, cancelling a redemption", () => { + it("opens with a preparing state naming the cancellation", () => { + renderCancelRedeemModal({ status: "preparing" }) + + expect( + screen.getByRole("heading", { name: "Preparing your cancellation" }), + ).toBeTruthy() + }) + + it("tells the investor their shares return to their wallet, honestly, without escrow or pricing language", () => { + renderCancelRedeemModal({ status: "awaiting-signature" }) + + expect( + screen.getByRole("heading", { name: "Confirm in your wallet" }), + ).toBeTruthy() + expect(screen.getByText(/100\.00 vUSDC/)).toBeTruthy() + expect(screen.getByText(/from escrow/)).toBeTruthy() + expect(screen.queryByText(/into escrow/)).toBeNull() + expect(screen.getByText(/withdrawn, not priced/)).toBeTruthy() + }) + + it("shows the returned shares, in the share ticker, once confirmed", () => { + renderCancelRedeemModal({ + status: "confirmed", + returnedShares: 100_0000000n as Amount, + surrendered: false, + hash: "b".repeat(64), + }) + + expect( + screen.getByRole("heading", { name: "Request cancelled" }), + ).toBeTruthy() + expect(screen.getByText(/100\.00 vUSDC/)).toBeTruthy() + expect(screen.queryByText(/Batch/)).toBeNull() + }) + + it("renders the returned shares at full precision, not rounded to two fraction digits", () => { + renderCancelRedeemModal({ + status: "confirmed", + returnedShares: 952380952n as Amount, + surrendered: false, + hash: "b".repeat(64), + }) + + expect(screen.getByText(/95\.2380952 vUSDC/)).toBeTruthy() + }) + + it("names the vault's own reason for a cancel-specific contract refusal, reusing the same table as cancelling a deposit", () => { + renderCancelRedeemModal({ + status: "failed", + failure: { kind: "contract-error", code: 6039 }, + }) + + expect( + screen.getByText(/already been priced. Claim your shares instead/), + ).toBeTruthy() + }) + + it("names NotAllowlisted as a delisted holder whose shares stay queued, pointing to the cash claim instead", () => { + renderCancelRedeemModal({ + status: "failed", + failure: { kind: "contract-error", code: 6068 }, + }) + + expect(screen.getByText(/not allowlisted/)).toBeTruthy() + expect(screen.getByText(/claim the USDC/i)).toBeTruthy() + }) + + it("names HolderNotEligible as shares the address cannot receive, pointing to the cash claim instead", () => { + renderCancelRedeemModal({ + status: "failed", + failure: { kind: "contract-error", code: 6073 }, + }) + + expect( + screen.getByText(/cannot receive these shares right now/), + ).toBeTruthy() + expect(screen.getByText(/claim the USDC/i)).toBeTruthy() + }) + + it("falls back to a generic refusal for 304 on a withdrawal cancellation, since the vault checks the holder itself", () => { + renderCancelRedeemModal({ + status: "failed", + failure: { kind: "contract-error", code: 304 }, + }) + + expect(screen.getByText(/contact support/)).toBeTruthy() + expect(screen.queryByText(/allowlisted/)).toBeNull() + }) + + it("falls back to a generic refusal for a cancel-redeem error it does not recognize, without the raw code", () => { + renderCancelRedeemModal({ + status: "failed", + failure: { kind: "contract-error", code: 9999 }, + }) + + expect(screen.getByText(/contact support/)).toBeTruthy() + expect(screen.queryByText(/9999/)).toBeNull() + }) + + it("is reachable as a dialog and dismissible by its close control", () => { + const { onClose } = renderCancelRedeemModal({ + status: "awaiting-signature", + }) + + fireEvent.click(screen.getByRole("button", { name: "Close" })) + expect(onClose).toHaveBeenCalledTimes(1) + }) +}) + +describe("TransactionModal, claiming", () => { + it("opens with a preparing state naming the claim", () => { + renderClaimModal({ status: "preparing" }) + + expect( + screen.getByRole("heading", { name: "Preparing your claim" }), + ).toBeTruthy() + }) + + it("tells the investor what claiming means before they sign, including the refund path, without naming an epoch", () => { + renderClaimModal({ status: "awaiting-signature" }) + + expect( + screen.getByRole("heading", { name: "Confirm in your wallet" }), + ).toBeTruthy() + expect( + screen.getByText( + "You receive what you're owed. If the price leaves no shares to claim, 150.00 USDC returns to your wallet instead.", + ), + ).toBeTruthy() + expect(screen.queryByText(/Epoch/)).toBeNull() + expect(screen.queryByText(/epoch/)).toBeNull() + }) + + it("tells the investor the claim is on its way, distinct from subscribe and cancel copy", () => { + renderClaimModal({ status: "submitted", hash: "a".repeat(64) }) + + expect( + screen.getByRole("heading", { name: "Sending your claim" }), + ).toBeTruthy() + expect(screen.getByText(/on its way to the network/)).toBeTruthy() + }) + + it("shows the shares minted in the share token, not the deposit token, once confirmed", () => { + renderClaimModal({ + status: "confirmed", + sharesMinted: 100_0000000n as Amount, + hash: "b".repeat(64), + }) + + expect(screen.getByRole("heading", { name: "Shares claimed" })).toBeTruthy() + expect(screen.getByText(/100\.00 vUSDC/)).toBeTruthy() + expect(screen.queryByText(/150\.00 USDC/)).toBeNull() + }) + + it("renders the shares minted at full precision, not rounded to two fraction digits", () => { + renderClaimModal({ + status: "confirmed", + sharesMinted: 952380952n as Amount, + hash: "b".repeat(64), + }) + + expect(screen.getByText(/95\.2380952 vUSDC/)).toBeTruthy() + }) + + it("tells the investor their deposit came back, not that shares were issued, on a zero result", () => { + renderClaimModal({ + status: "confirmed", + sharesMinted: 0n as Amount, + hash: "b".repeat(64), + }) + + expect( + screen.getByRole("heading", { name: "Deposit returned" }), + ).toBeTruthy() + expect(screen.getByText(/150\.00 USDC/)).toBeTruthy() + expect(screen.queryByText(/vUSDC/)).toBeNull() + }) + + it("names RequestNotFound for a claim with no matching request", () => { + renderClaimModal({ + status: "failed", + failure: { kind: "contract-error", code: 6001 }, + }) + + expect(screen.getByText(/no request to claim/)).toBeTruthy() + }) + + it("names EpochNotFound as a request that could not be found", () => { + renderClaimModal({ + status: "failed", + failure: { kind: "contract-error", code: 6029 }, + }) + + expect(screen.getByText(/This request could not be found/)).toBeTruthy() + }) + + it("names InvalidSharePrice as no valid price published yet", () => { + renderClaimModal({ + status: "failed", + failure: { kind: "contract-error", code: 6031 }, + }) + + expect(screen.getByText(/not published a valid price/)).toBeTruthy() + }) + + it("names AlreadyClaimed distinctly", () => { + renderClaimModal({ + status: "failed", + failure: { kind: "contract-error", code: 6035 }, + }) + + expect(screen.getByText(/already been claimed/)).toBeTruthy() + }) + + it("names ClaimNotCovered as the reserve not yet covering this claim", () => { + renderClaimModal({ + status: "failed", + failure: { kind: "contract-error", code: 6037 }, + }) + + expect(screen.getByText(/not yet hold enough in reserve/)).toBeTruthy() + }) + + it("names AmountTooLarge as the conversion overflowing", () => { + renderClaimModal({ + status: "failed", + failure: { kind: "contract-error", code: 6014 }, + }) + + expect(screen.getByText(/too large to complete/)).toBeTruthy() + }) + + it("falls back to a generic refusal for 304 on a share claim, since a delisted holder's shares join an exit instead", () => { + renderClaimModal({ + status: "failed", + failure: { kind: "contract-error", code: 304 }, + }) + + expect(screen.getByText(/contact support/)).toBeTruthy() + expect(screen.queryByText(/allowlisted/)).toBeNull() + }) + + it("falls back to a generic refusal for a claim error it does not recognize, without the raw code", () => { + renderClaimModal({ + status: "failed", + failure: { kind: "contract-error", code: 9999 }, + }) + + expect(screen.getByText(/contact support/)).toBeTruthy() + expect(screen.queryByText(/9999/)).toBeNull() + }) + + it("reads a declined claim as a choice, not a failure, and offers to try again", () => { + const { onRetry } = renderClaimModal({ + status: "failed", + failure: { kind: "declined" }, + }) + + expect( + screen.getByRole("heading", { name: "You declined the request" }), + ).toBeTruthy() + + fireEvent.click(screen.getByRole("button", { name: "Try again" })) + expect(onRetry).toHaveBeenCalledTimes(1) + }) + + it("shows the same step progress machinery for a claim in flight", () => { + renderClaimModal({ status: "awaiting-signature" }) + + const steps = screen.getAllByRole("listitem") + expect(steps.map((step) => step.textContent)).toEqual([ + "Approved in your wallet, in progress", + "Sent to the network", + "Recorded", + ]) + }) + + it("is reachable as a dialog and dismissible by its close control", () => { + const { onClose } = renderClaimModal({ status: "awaiting-signature" }) + + fireEvent.click(screen.getByRole("button", { name: "Close" })) + expect(onClose).toHaveBeenCalledTimes(1) + }) +}) + +describe("TransactionModal, redeeming", () => { + it("opens with a preparing state naming the redemption request", () => { + renderRedeemModal({ status: "preparing" }) + + expect( + screen.getByRole("heading", { + name: "Preparing your redemption request", + }), + ).toBeTruthy() + }) + + it("tells the investor their shares are locked in escrow, in share terms, without naming an epoch", () => { + renderRedeemModal({ status: "awaiting-signature" }) + + expect( + screen.getByRole("heading", { name: "Confirm in your wallet" }), + ).toBeTruthy() + expect(screen.getByText(/100\.00 vUSDC/)).toBeTruthy() + expect(screen.getByText(/in escrow/)).toBeTruthy() + expect(screen.getByText(/not today/)).toBeTruthy() + expect(screen.queryByText(/Epoch/)).toBeNull() + expect(screen.queryByText(/epoch/)).toBeNull() + expect(screen.queryByText(/batch/i)).toBeNull() + }) + + it("tells the investor the redemption request is on its way, distinct from subscribe copy", () => { + renderRedeemModal({ status: "submitted", hash: "a".repeat(64) }) + + expect( + screen.getByRole("heading", { name: "Sending your redemption request" }), + ).toBeTruthy() + expect(screen.getByText(/on its way to the network/)).toBeTruthy() + }) + + it("shows the confirmed shares and transaction hash once confirmed", () => { + renderRedeemModal({ + status: "confirmed", + hash: "b".repeat(64), + }) + + expect( + screen.getByRole("heading", { name: "Redemption request submitted" }), + ).toBeTruthy() + expect(screen.getByText(/100\.00 vUSDC/)).toBeTruthy() + expect(screen.queryByText(/Batch/)).toBeNull() + expect(screen.getByText(/bbbb\.\.\.bbbb/)).toBeTruthy() + }) + + it("never mentions locking or attestation once confirmed", () => { + renderRedeemModal({ status: "confirmed", hash: "b".repeat(64) }) + + expect(screen.queryByText(/locked/i)).toBeNull() + expect(screen.queryByText(/attestation/i)).toBeNull() + }) + + it("names InvalidAmount for a non-positive share amount", () => { + renderRedeemModal({ + status: "failed", + failure: { kind: "contract-error", code: 6007 }, + }) + + expect(screen.getByText(/amount greater than zero/)).toBeTruthy() + }) + + it("names RequestOutstanding as a redemption request already open, distinct from subscribe's wording", () => { + renderRedeemModal({ + status: "failed", + failure: { kind: "contract-error", code: 6009 }, + }) + + expect( + screen.getByText(/already have a redemption request open/), + ).toBeTruthy() + }) + + it("names AmountTooLarge as too large for the vault to accept", () => { + renderRedeemModal({ + status: "failed", + failure: { kind: "contract-error", code: 6014 }, + }) + + expect(screen.getByText(/too large for the vault to accept/)).toBeTruthy() + }) + + it("names EpochNotFound as a request that could not be found", () => { + renderRedeemModal({ + status: "failed", + failure: { kind: "contract-error", code: 6029 }, + }) + + expect(screen.getByText(/This request could not be found/)).toBeTruthy() + }) + + it("names WindDownActive as the vault not accepting new redemptions", () => { + renderRedeemModal({ + status: "failed", + failure: { kind: "contract-error", code: 6046 }, + }) + + expect(screen.getByText(/not accepting new redemptions/)).toBeTruthy() + }) + + it("names SetupOpen as redemptions starting once setup closes", () => { + renderRedeemModal({ + status: "failed", + failure: { kind: "contract-error", code: 6064 }, + }) + + expect(screen.getByText(/setup is still open/)).toBeTruthy() + expect(screen.getByText(/Redemptions start once it closes/)).toBeTruthy() + }) + + it("falls back to a generic refusal for a redeem error it does not recognize, without the raw code", () => { + renderRedeemModal({ + status: "failed", + failure: { kind: "contract-error", code: 9999 }, + }) + + expect(screen.getByText(/contact support/)).toBeTruthy() + expect(screen.queryByText(/9999/)).toBeNull() + }) + + it("reads a declined redemption request as a choice, not a failure, and offers to try again", () => { + const { onRetry } = renderRedeemModal({ + status: "failed", + failure: { kind: "declined" }, + }) + + expect( + screen.getByRole("heading", { name: "You declined the request" }), + ).toBeTruthy() + + fireEvent.click(screen.getByRole("button", { name: "Try again" })) + expect(onRetry).toHaveBeenCalledTimes(1) + }) + + it("shows the same step progress machinery for a redemption request in flight", () => { + renderRedeemModal({ status: "awaiting-signature" }) + + const steps = screen.getAllByRole("listitem") + expect(steps.map((step) => step.textContent)).toEqual([ + "Approved in your wallet, in progress", + "Sent to the network", + "Recorded", + ]) + }) + + it("is reachable as a dialog and dismissible by its close control", () => { + const { onClose } = renderRedeemModal({ status: "awaiting-signature" }) + + fireEvent.click(screen.getByRole("button", { name: "Close" })) + expect(onClose).toHaveBeenCalledTimes(1) + }) +}) + +describe("TransactionModal, claiming a redemption", () => { + it("opens with a preparing state naming the claim", () => { + renderClaimRedeemModal({ status: "preparing" }) + + expect( + screen.getByRole("heading", { name: "Preparing your claim" }), + ).toBeTruthy() + }) + + it("tells the investor what claiming sends them, without naming an epoch", () => { + renderClaimRedeemModal({ status: "awaiting-signature" }) + + expect( + screen.getByRole("heading", { name: "Confirm in your wallet" }), + ).toBeTruthy() + expect( + screen.getByText( + "You receive what you're owed. Signing sends 100.00 USDC to your wallet.", + ), + ).toBeTruthy() + expect(screen.queryByText(/Epoch/)).toBeNull() + expect(screen.queryByText(/epoch/)).toBeNull() + }) + + it("tells the investor the claim is on its way to the network", () => { + renderClaimRedeemModal({ status: "submitted", hash: "a".repeat(64) }) + + expect( + screen.getByRole("heading", { name: "Sending your claim" }), + ).toBeTruthy() + expect(screen.getByText(/on its way to the network/)).toBeTruthy() + }) + + it("shows the claimed asset, in its own ticker, once confirmed", () => { + renderClaimRedeemModal({ + status: "confirmed", + assetsClaimed: 150_0000000n as Amount, + sharesReturned: false, + hash: "b".repeat(64), + }) + + expect(screen.getByRole("heading", { name: "USDC claimed" })).toBeTruthy() + expect(screen.getByText(/150\.00 USDC/)).toBeTruthy() + }) + + it("renders the claimed asset amount at full precision, not rounded to two fraction digits", () => { + renderClaimRedeemModal({ + status: "confirmed", + assetsClaimed: 952380952n as Amount, + sharesReturned: false, + hash: "b".repeat(64), + }) + + expect(screen.getByText(/95\.2380952 USDC/)).toBeTruthy() + }) + + it("tells the investor their shares came back, not that an asset was claimed, on a zero result", () => { + renderClaimRedeemModal({ + status: "confirmed", + assetsClaimed: 0n as Amount, + sharesReturned: true, + hash: "b".repeat(64), + }) + + expect( + screen.getByRole("heading", { name: "Shares returned" }), + ).toBeTruthy() + expect( + screen.getByText( + "The price left nothing to claim, so your shares have been returned to your wallet instead.", + ), + ).toBeTruthy() + expect(screen.queryByText(/USDC claimed/)).toBeNull() + }) + + it("names EpochNotFulfilled as not yet priced", () => { + renderClaimRedeemModal({ + status: "failed", + failure: { kind: "contract-error", code: 6034 }, + }) + + expect(screen.getByText(/has not priced yet/)).toBeTruthy() + }) + + it("names ClaimNotCovered with the deposit asset's ticker, distinct from the untickered claim wording for the same code", () => { + renderClaimRedeemModal({ + status: "failed", + failure: { kind: "contract-error", code: 6037 }, + }) + + expect(screen.getByText(/enough USDC in reserve/)).toBeTruthy() + }) + + it("names HolderNotEligible as the zero-price return the address cannot receive, in the same words as a withdrawal cancellation", () => { + renderClaimRedeemModal({ + status: "failed", + failure: { kind: "contract-error", code: 6073 }, + }) + + expect(screen.getByText(/tried to return your shares/)).toBeTruthy() + expect( + screen.getByText(/cannot receive these shares right now/), + ).toBeTruthy() + expect(screen.queryByText(/claim the USDC/i)).toBeNull() + }) + + it("falls back to a generic refusal for 304 on a withdrawal claim, since the vault checks the holder itself", () => { + renderClaimRedeemModal({ + status: "failed", + failure: { kind: "contract-error", code: 304 }, + }) + + expect(screen.getByText(/contact support/)).toBeTruthy() + expect(screen.queryByText(/allowlisted/)).toBeNull() + }) + + it("names AlreadyClaimed by delegating to the shared claim wording", () => { + renderClaimRedeemModal({ + status: "failed", + failure: { kind: "contract-error", code: 6035 }, + }) + + expect(screen.getByText(/already been claimed/)).toBeTruthy() + }) + + it("falls back to a generic refusal for a claim-redeem error it does not recognize, without the raw code", () => { + renderClaimRedeemModal({ + status: "failed", + failure: { kind: "contract-error", code: 9999 }, + }) + + expect(screen.getByText(/contact support/)).toBeTruthy() + expect(screen.queryByText(/9999/)).toBeNull() + }) + + it("reads a declined claim as a choice, not a failure, and offers to try again", () => { + const { onRetry } = renderClaimRedeemModal({ + status: "failed", + failure: { kind: "declined" }, + }) + + expect( + screen.getByRole("heading", { name: "You declined the request" }), + ).toBeTruthy() + + fireEvent.click(screen.getByRole("button", { name: "Try again" })) + expect(onRetry).toHaveBeenCalledTimes(1) + }) + + it("shows the same step progress machinery for a claim in flight", () => { + renderClaimRedeemModal({ status: "awaiting-signature" }) + + const steps = screen.getAllByRole("listitem") + expect(steps.map((step) => step.textContent)).toEqual([ + "Approved in your wallet, in progress", + "Sent to the network", + "Recorded", + ]) + }) + + it("is reachable as a dialog and dismissible by its close control", () => { + const { onClose } = renderClaimRedeemModal({ + status: "awaiting-signature", + }) + + fireEvent.click(screen.getByRole("button", { name: "Close" })) + expect(onClose).toHaveBeenCalledTimes(1) + }) +}) diff --git a/app/src/components/vault/transactionModal/investorOutcomes.test.tsx b/app/src/components/vault/transactionModal/investorOutcomes.test.tsx new file mode 100644 index 00000000..2bf129a7 --- /dev/null +++ b/app/src/components/vault/transactionModal/investorOutcomes.test.tsx @@ -0,0 +1,231 @@ +import { type Amount } from "@stellar-scaffold/app-lib" +import type * as AppLib from "@stellar-scaffold/app-lib" +import { render, screen } from "@testing-library/react" +import { describe, expect, it, vi } from "vitest" +import TransactionModal from "../TransactionModal" +import { + renderModal, + renderCancelModal, + renderCancelRedeemModal, + renderClaimModal, + renderRedeemModal, + renderClaimRedeemModal, + renderClaimWindDownModal, +} from "./renderModal" + +vi.mock("@stellar-scaffold/app-lib", async (importOriginal) => ({ + ...(await importOriginal()), + shortAddress: (address: string) => + `${address.slice(0, 4)}...${address.slice(-4)}`, + explorerTransaction: () => null, +})) + +describe("TransactionModal, allowlist and freeze refusals", () => { + const contractError = (code: number) => ({ + status: "failed" as const, + failure: { kind: "contract-error" as const, code }, + }) + + it("names NotAllowlisted on a subscription as an address that cannot subscribe", () => { + renderModal(contractError(6068)) + + expect(screen.getByText(/not allowlisted/)).toBeTruthy() + expect(screen.getByText(/can subscribe/)).toBeTruthy() + }) + + it("names Frozen on a subscription as a frozen address", () => { + renderModal(contractError(6071)) + + expect(screen.getByText(/frozen/)).toBeTruthy() + expect(screen.getByText(/cannot subscribe/)).toBeTruthy() + }) + + it("names Frozen on a redemption request as a frozen address or frozen shares", () => { + renderRedeemModal(contractError(6071)) + + expect(screen.getByText(/shares are frozen/)).toBeTruthy() + expect(screen.getByText(/free shares/)).toBeTruthy() + }) + + it.each<[string, () => void]>([ + ["cancelling a subscription", () => renderCancelModal(contractError(6071))], + [ + "cancelling a redemption", + () => renderCancelRedeemModal(contractError(6071)), + ], + ["claiming shares", () => renderClaimModal(contractError(6071))], + [ + "claiming a redemption", + () => renderClaimRedeemModal(contractError(6071)), + ], + ])("names Frozen on %s as held until the freeze lifts", (_name, render) => { + render() + + expect(screen.getByText(/held until the freeze is lifted/)).toBeTruthy() + }) + + it("falls back to a generic refusal for NotAllowlisted on a subscription cancellation, since a delisted holder may still cancel", () => { + renderCancelModal(contractError(6068)) + + expect(screen.getByText(/contact support/)).toBeTruthy() + expect(screen.queryByText(/allowlisted/)).toBeNull() + }) +}) + +describe("TransactionModal, conversion and surrender outcomes", () => { + it("tells a delisted investor their claimed shares were queued as a redemption", () => { + renderClaimModal({ + status: "confirmed", + sharesMinted: 0n as Amount, + queuedInto: 5n, + }) + + expect( + screen.getByRole("heading", { name: "Queued as a redemption" }), + ).toBeTruthy() + expect(screen.getByText(/not allowlisted/)).toBeTruthy() + expect(screen.getByText(/pay out in USDC at the next price/)).toBeTruthy() + expect(screen.queryByText(/returned to your wallet/)).toBeNull() + }) + + it("tells a delisted investor in a wind-down their claimed shares joined the wind-down claim", () => { + renderClaimModal({ + status: "confirmed", + sharesMinted: 0n as Amount, + queuedInto: 0n, + }) + + expect( + screen.getByRole("heading", { name: "Moved to your wind-down claim" }), + ).toBeTruthy() + expect(screen.getByText(/not allowlisted/)).toBeTruthy() + }) + + it("still reads a zero-share claim without a conversion as a returned deposit", () => { + renderClaimModal({ status: "confirmed", sharesMinted: 0n as Amount }) + + expect( + screen.getByRole("heading", { name: "Deposit returned" }), + ).toBeTruthy() + }) + + it("tells the investor a cancelled redemption moved into the wind-down claim", () => { + renderCancelRedeemModal({ + status: "confirmed", + returnedShares: 100_0000000n as Amount, + surrendered: true, + }) + + expect( + screen.getByRole("heading", { name: "Moved to your wind-down claim" }), + ).toBeTruthy() + expect(screen.getByText(/winding down/)).toBeTruthy() + expect(screen.queryByText(/returned to your wallet/)).toBeNull() + }) + + it("still reads a returned redemption as cancelled", () => { + renderCancelRedeemModal({ + status: "confirmed", + returnedShares: 100_0000000n as Amount, + surrendered: false, + }) + + expect( + screen.getByRole("heading", { name: "Request cancelled" }), + ).toBeTruthy() + }) +}) + +describe("TransactionModal, wind-down outcomes", () => { + it("tells the investor a zero-price claim burned the shares during a wind-down, never that they came back", () => { + renderClaimRedeemModal({ + status: "confirmed", + assetsClaimed: 0n as Amount, + sharesReturned: false, + }) + + expect( + screen.getByRole("heading", { name: "Nothing to pay out" }), + ).toBeTruthy() + expect(screen.getByText(/winding down/)).toBeTruthy() + expect(screen.getByText(/burned/)).toBeTruthy() + expect(screen.queryByText(/returned to your wallet/)).toBeNull() + }) +}) + +describe("TransactionModal, cancelling a redemption in a wind-down", () => { + it("tells the signer the shares move into the wind-down claim, never that they return", () => { + render( + , + ) + + expect(screen.getByText(/wind-down claim/)).toBeTruthy() + expect(screen.queryByText(/returns/)).toBeNull() + }) +}) + +describe("TransactionModal, claiming from the wind-down", () => { + it("tells the signer what they receive and that their shares are turned in", () => { + renderClaimWindDownModal({ status: "awaiting-signature" }) + + expect(screen.getByText(/25\.00 USDC/)).toBeTruthy() + expect(screen.getByText(/turns in your unfrozen vUSDC/)).toBeTruthy() + }) + + it("shows the assets paid and the shares turned in once confirmed", () => { + renderClaimWindDownModal({ + status: "confirmed", + assetsClaimed: 25_0000000n as Amount, + sharesSurrendered: 40_0000000n as Amount, + }) + + expect(screen.getByRole("heading", { name: "USDC claimed" })).toBeTruthy() + expect(screen.getByText(/25\.00 USDC has been added/)).toBeTruthy() + expect(screen.getByText(/40\.00 vUSDC/)).toBeTruthy() + }) + + it("reads a claim that paid nothing as shares turned in for later rounds", () => { + renderClaimWindDownModal({ + status: "confirmed", + assetsClaimed: 0n as Amount, + sharesSurrendered: 40_0000000n as Amount, + }) + + expect( + screen.getByRole("heading", { name: "Shares turned in" }), + ).toBeTruthy() + expect(screen.getByText(/later rounds/)).toBeTruthy() + }) + + it.each<[number, RegExp]>([ + [6051, /first payout/], + [6054, /nothing to claim from the wind-down/], + [6071, /held until the freeze is lifted/], + ])("names refusal %i in the dapp's words", (code, text) => { + renderClaimWindDownModal({ + status: "failed", + failure: { kind: "contract-error", code }, + }) + + expect(screen.getByText(text)).toBeTruthy() + }) + + it("falls back to a generic refusal for a wind-down claim error it does not recognize", () => { + renderClaimWindDownModal({ + status: "failed", + failure: { kind: "contract-error", code: 9999 }, + }) + + expect(screen.getByText(/contact support/)).toBeTruthy() + expect(screen.queryByText(/9999/)).toBeNull() + }) +}) diff --git a/app/src/components/vault/transactionModal/renderModal.tsx b/app/src/components/vault/transactionModal/renderModal.tsx new file mode 100644 index 00000000..f23ac334 --- /dev/null +++ b/app/src/components/vault/transactionModal/renderModal.tsx @@ -0,0 +1,303 @@ +import { render } from "@testing-library/react" +import { vi } from "vitest" +import { type ActivateWindDownStatus } from "../../../hooks/useActivateWindDown" +import { type AttestStatus } from "../../../hooks/useAttest" +import { type CancelDepositStatus } from "../../../hooks/useCancelDeposit" +import { type CancelRedeemStatus } from "../../../hooks/useCancelRedeem" +import { type ClaimDepositStatus } from "../../../hooks/useClaimDeposit" +import { type ClaimRedeemStatus } from "../../../hooks/useClaimRedeem" +import { type ClaimWindDownStatus } from "../../../hooks/useClaimWindDown" +import { type CloseEpochStatus } from "../../../hooks/useCloseEpoch" +import { type TransactionStatus } from "../../../hooks/useContractTransaction" +import { type DeployToCustodianStatus } from "../../../hooks/useDeployToCustodian" +import { type FinalizeWindDownRoundStatus } from "../../../hooks/useFinalizeWindDownRound" +import { type FulfillEpochStatus } from "../../../hooks/useFulfillEpoch" +import { type FundStatus } from "../../../hooks/useFund" +import { type RequestDepositStatus } from "../../../hooks/useRequestDeposit" +import { type RequestRedeemStatus } from "../../../hooks/useRequestRedeem" +import TransactionModal, { + type ComplianceModalAction, + type ConfigurationModalAction, + type EmergencyModalAction, + type GovernanceModalAction, +} from "../TransactionModal" + +export const renderModal = (status: RequestDepositStatus) => { + const onClose = vi.fn() + const onRetry = vi.fn() + const view = render( + , + ) + return { ...view, onClose, onRetry } +} + +export const renderCancelModal = (status: CancelDepositStatus) => { + const onClose = vi.fn() + const onRetry = vi.fn() + const view = render( + , + ) + return { ...view, onClose, onRetry } +} + +export const renderCancelRedeemModal = (status: CancelRedeemStatus) => { + const onClose = vi.fn() + const onRetry = vi.fn() + const view = render( + , + ) + return { ...view, onClose, onRetry } +} + +export const renderClaimModal = (status: ClaimDepositStatus) => { + const onClose = vi.fn() + const onRetry = vi.fn() + const view = render( + , + ) + return { ...view, onClose, onRetry } +} + +export const renderRedeemModal = (status: RequestRedeemStatus) => { + const onClose = vi.fn() + const onRetry = vi.fn() + const view = render( + , + ) + return { ...view, onClose, onRetry } +} + +export const renderClaimRedeemModal = (status: ClaimRedeemStatus) => { + const onClose = vi.fn() + const onRetry = vi.fn() + const view = render( + , + ) + return { ...view, onClose, onRetry } +} + +export const renderCloseEpochModal = ( + status: CloseEpochStatus, + epoch = "5", +) => { + const onClose = vi.fn() + const onRetry = vi.fn() + const view = render( + , + ) + return { ...view, onClose, onRetry } +} + +export const renderFulfillEpochModal = (status: FulfillEpochStatus) => { + const onClose = vi.fn() + const onRetry = vi.fn() + const view = render( + , + ) + return { ...view, onClose, onRetry } +} + +export const renderAttestModal = ( + status: AttestStatus, + validFor: string | null = "1d", +) => { + const onClose = vi.fn() + const onRetry = vi.fn() + const view = render( + , + ) + return { ...view, onClose, onRetry } +} + +export const renderDeployModal = (status: DeployToCustodianStatus) => { + const onClose = vi.fn() + const onRetry = vi.fn() + const view = render( + , + ) + return { ...view, onClose, onRetry } +} + +export const renderFundModal = (status: FundStatus) => { + const onClose = vi.fn() + const onRetry = vi.fn() + const view = render( + , + ) + return { ...view, onClose, onRetry } +} + +export const renderActivateModal = (status: ActivateWindDownStatus) => { + const onClose = vi.fn() + const onRetry = vi.fn() + const view = render( + , + ) + return { ...view, onClose, onRetry } +} + +export const renderFinalizeModal = (status: FinalizeWindDownRoundStatus) => { + const onClose = vi.fn() + const onRetry = vi.fn() + const view = render( + , + ) + return { ...view, onClose, onRetry } +} + +export const renderEmergencyModal = ( + action: EmergencyModalAction, + status: TransactionStatus, +) => + render( + , + ) + +export const renderConfigurationModal = ( + action: ConfigurationModalAction, + status: TransactionStatus, + value = "", +) => + render( + , + ) + +export const renderGovernanceModal = ( + action: GovernanceModalAction, + status: TransactionStatus, + value = "Manager to GABC...WXYZ", +) => + render( + , + ) + +export const renderComplianceModal = ( + action: ComplianceModalAction, + status: TransactionStatus, + value = "GABC...WXYZ", +) => + render( + , + ) + +export const renderClaimWindDownModal = (status: ClaimWindDownStatus) => + render( + , + ) From cb280b74fa83422189662b42ae2d4964ef15a3fe Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Mon, 2 Nov 2026 14:51:39 -0300 Subject: [PATCH 08/18] build(harness): write every client with the SDK's generator `build:clients` and the harness deploy now write each client's sources with the SDK's generator, pin its stellar-sdk, and compile them to the `dist/` the harness imports. The stellar CLI's bindings, which typed an empty `Option` differently, are no longer generated. The guard before tests and the type check also requires each contract client's `dist/`. Refs #420 --- AGENTS.md | 16 +++---- .../renderTransactionModal.tsx} | 0 scripts/clients-guard.mjs | 5 +- scripts/generate-clients.mjs | 2 +- scripts/harness/build-clients.ts | 27 +++++------ scripts/harness/deploy.ts | 7 ++- scripts/harness/lib/asset-client.ts | 3 +- scripts/harness/lib/cli.test.ts | 28 ++++++++++- scripts/harness/lib/cli.ts | 48 +++++++++++-------- scripts/lib/clients.d.mts | 4 +- scripts/lib/clients.mjs | 29 ++++++----- scripts/lib/clients.test.mjs | 42 +++++++++++----- 12 files changed, 130 insertions(+), 81 deletions(-) rename app/src/{components/vault/transactionModal/renderModal.tsx => test/renderTransactionModal.tsx} (100%) diff --git a/AGENTS.md b/AGENTS.md index ce1088fe..32389266 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -104,14 +104,14 @@ apply as the corresponding code lands here. A fresh clone builds `app` with no cargo, no stellar CLI and no local network, but needs RPC access to the selected network at build time. A local scaffold build writes the same directory from the WASM. -- Two generators write `app-lib/clients/`, and they type an empty `Option` - differently: the SDK's as `null`, the stellar CLI's as `undefined`. The app - is written against the SDK's, which its build uses. `npm run build:clients` - writes both from the local Wasm, with no network: `src/` from the SDK's - generator for the app and its tests, and `dist/` compiled from the CLI's - bindings for the harness. The scaffold's `--build-clients` writes the CLI's - in both places, so run `npm run build:clients` again before type-checking the - app. +- The clients come from one generator, the SDK's `BindingGenerator`, which + types an empty `Option` as `null`. `npm run build:clients` and the harness + deploy write each client's `src/` from the local Wasm, with no network, and + compile it to the `dist/` the harness imports. A workspace build + (`npm run build --workspaces`) recompiles `dist/` from `src/`. The scaffold's + `--build-clients` writes the stellar CLI's bindings instead, which type an + empty `Option` as `undefined`, so run `npm run build:clients` again after it + before type-checking. - When `cargo` itself builds a contract for the Wasm target, rather than `stellar contract build` (a size check with `cargo build --release --target wasm32v1-none`, for example), set diff --git a/app/src/components/vault/transactionModal/renderModal.tsx b/app/src/test/renderTransactionModal.tsx similarity index 100% rename from app/src/components/vault/transactionModal/renderModal.tsx rename to app/src/test/renderTransactionModal.tsx diff --git a/scripts/clients-guard.mjs b/scripts/clients-guard.mjs index c48de5b8..9dca2a5b 100644 --- a/scripts/clients-guard.mjs +++ b/scripts/clients-guard.mjs @@ -2,8 +2,7 @@ import { dirname, resolve } from "node:path" import { fileURLToPath } from "node:url" import { missingClients } from "./lib/clients.mjs" -// Runs before the root `test` and `typecheck`. Both import the generated -// contract clients, so without them they fail on modules they cannot resolve. +// Runs before the root `test` and `typecheck`, which import the generated clients. const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..") const missing = missingClients(repoRoot) @@ -13,7 +12,7 @@ if (missing.length > 0) { "", "The generated contract clients are missing:", "", - ...missing.map((name) => ` - app-lib/clients/${name}`), + ...missing.map((path) => ` - ${path}`), "", "Build the contracts and generate the clients first:", "", diff --git a/scripts/generate-clients.mjs b/scripts/generate-clients.mjs index 539fe937..baa8c892 100644 --- a/scripts/generate-clients.mjs +++ b/scripts/generate-clients.mjs @@ -27,7 +27,7 @@ const generate = async ({ name, id }) => { rpcUrl, networkPassphrase, }) - writeClient(repoRoot, name, files) + writeClient(resolve(repoRoot, "app-lib/clients", name), files) } await Promise.all(contracts.map(generate)) diff --git a/scripts/harness/build-clients.ts b/scripts/harness/build-clients.ts index b3106285..83637b71 100644 --- a/scripts/harness/build-clients.ts +++ b/scripts/harness/build-clients.ts @@ -1,11 +1,8 @@ -import { existsSync, readFileSync } from "node:fs" +import { existsSync } from "node:fs" import { join } from "node:path" -import { BindingGenerator } from "@stellar/stellar-sdk" - -import { writeClient } from "../lib/clients.mjs" import { stellarAssetGenerator } from "./lib/asset-client.js" -import { generateClient, rootSdkVersion } from "./lib/cli.js" +import { generateClient, rootSdkVersion, wasmGenerator } from "./lib/cli.js" import { CONTRACT_NAMES, REPO_ROOT, @@ -15,7 +12,7 @@ import { const wasmOf = (name: ContractName): string => join(REPO_ROOT, "target", "wasm32v1-none", "release", `${name}.wasm`) -const clientDir = (name: ContractName): string => +const clientDir = (name: ContractName | "asset"): string => join(REPO_ROOT, "app-lib", "clients", name) async function main(): Promise { @@ -26,17 +23,19 @@ async function main(): Promise { ) } - // The harness imports each client's `dist/`, compiled from the CLI's - // bindings. The app imports `src/`, written by the SDK's generator as the - // app's own build does; the two type an empty `Option` differently. const sdkVersion = rootSdkVersion() for (const name of CONTRACT_NAMES) { - await generateClient(wasmOf(name), clientDir(name), sdkVersion) - const generator = BindingGenerator.fromWasm(readFileSync(wasmOf(name))) - writeClient(REPO_ROOT, name, generator.generate({ contractName: name })) + await generateClient( + clientDir(name), + wasmGenerator(wasmOf(name)), + sdkVersion, + ) } - const asset = await stellarAssetGenerator() - writeClient(REPO_ROOT, "asset", asset.generate({ contractName: "asset" })) + await generateClient( + clientDir("asset"), + await stellarAssetGenerator(), + sdkVersion, + ) console.log("clients generated") } diff --git a/scripts/harness/deploy.ts b/scripts/harness/deploy.ts index 0bd3a475..4fbd370f 100644 --- a/scripts/harness/deploy.ts +++ b/scripts/harness/deploy.ts @@ -18,6 +18,7 @@ import { ensureAssetContract, generateClient, rootSdkVersion, + wasmGenerator, } from "./lib/cli.js" import { clearIncomplete, @@ -438,7 +439,11 @@ async function main(): Promise { ) for (const name of CONTRACT_NAMES) { - await generateClient(wasmOf(name), clientDir(name), sdkVersion) + await generateClient( + clientDir(name), + wasmGenerator(wasmOf(name)), + sdkVersion, + ) } console.log("clients generated") diff --git a/scripts/harness/lib/asset-client.ts b/scripts/harness/lib/asset-client.ts index 2d1ff900..ff3894fe 100644 --- a/scripts/harness/lib/asset-client.ts +++ b/scripts/harness/lib/asset-client.ts @@ -1,7 +1,6 @@ import { BindingGenerator, StrKey, xdr, type rpc } from "@stellar/stellar-sdk" -// The SDK carries the Stellar Asset Contract's spec but hands it out only for a -// contract it looks up, so this answers that one lookup as a Stellar asset. +// The SDK gives out its Stellar Asset Contract spec only after a lookup, which this answers. const stellarAssetLookup = { getLedgerEntries(key: xdr.LedgerKey) { const instance = new xdr.ScContractInstance({ diff --git a/scripts/harness/lib/cli.test.ts b/scripts/harness/lib/cli.test.ts index a7c87ad7..2e74acd0 100644 --- a/scripts/harness/lib/cli.test.ts +++ b/scripts/harness/lib/cli.test.ts @@ -1,4 +1,5 @@ -import { readFileSync } from "node:fs" +import { existsSync, mkdtempSync, readFileSync } from "node:fs" +import { tmpdir } from "node:os" import { join } from "node:path" import { @@ -11,7 +12,13 @@ import { } from "@stellar/stellar-sdk" import { describe, expect, it } from "vitest" -import { ensureAssetContract, pinClientSdk, rootSdkVersion } from "./cli.js" +import { stellarAssetGenerator } from "./asset-client.js" +import { + ensureAssetContract, + pinClientSdk, + rootSdkVersion, + writeClientSources, +} from "./cli.js" import { REPO_ROOT } from "./deployment.js" import { type Network } from "./network.js" @@ -75,3 +82,20 @@ describe("rootSdkVersion", () => { expect(rootSdkVersion()).toBe(rootPkg.overrides?.["@stellar/stellar-sdk"]) }) }) + +describe("writeClientSources", () => { + it("writes the SDK's bindings named after the client, with its stellar-sdk pinned", async () => { + const outDir = join(mkdtempSync(join(tmpdir(), "client-")), "asset") + + writeClientSources(outDir, await stellarAssetGenerator(), "16.4.1") + + const pkg = JSON.parse( + readFileSync(join(outDir, "package.json"), "utf8"), + ) as { name: string; dependencies: Record } + expect(pkg.name).toBe("asset") + expect(pkg.dependencies["@stellar/stellar-sdk"]).toBe("16.4.1") + for (const file of ["index.ts", "types.ts", "client.ts"]) { + expect(existsSync(join(outDir, "src", file))).toBe(true) + } + }) +}) diff --git a/scripts/harness/lib/cli.ts b/scripts/harness/lib/cli.ts index 227b631e..3936528e 100644 --- a/scripts/harness/lib/cli.ts +++ b/scripts/harness/lib/cli.ts @@ -1,10 +1,11 @@ import { execFile } from "node:child_process" -import { existsSync, readFileSync, writeFileSync } from "node:fs" +import { readFileSync, writeFileSync } from "node:fs" import { createRequire } from "node:module" import { basename, join } from "node:path" import { promisify } from "node:util" import { + BindingGenerator, Contract, StrKey, type Asset, @@ -12,6 +13,7 @@ import { type rpc, } from "@stellar/stellar-sdk" +import { writeClient } from "../../lib/clients.mjs" import { REPO_ROOT } from "./deployment.js" import { type Network } from "./network.js" @@ -163,35 +165,39 @@ export function rootSdkVersion(): string { return version } -export async function generateClient( - wasm: string, +export const wasmGenerator = (wasm: string): BindingGenerator => + BindingGenerator.fromWasm(readFileSync(wasm)) + +/** Writes a client's SDK bindings to `outDir`, named after it, with its stellar-sdk pinned. */ +export function writeClientSources( outDir: string, + generator: BindingGenerator, sdkVersion: string, -): Promise { - await stellar([ - "contract", - "bindings", - "typescript", - "--wasm", - wasm, - "--output-dir", - outDir, - "--overwrite", - ]) +): void { + writeClient(outDir, generator.generate({ contractName: basename(outDir) })) const pkgPath = join(outDir, "package.json") - if (existsSync(pkgPath)) { - const pkg = JSON.parse(readFileSync(pkgPath, "utf8")) as ClientPackageJson - const pinned = pinClientSdk(pkg, sdkVersion) - if (pinned !== pkg) { - writeFileSync(pkgPath, JSON.stringify(pinned, null, 2) + "\n") - } + const pkg = JSON.parse(readFileSync(pkgPath, "utf8")) as ClientPackageJson + const pinned = pinClientSdk(pkg, sdkVersion) + if (pinned !== pkg) { + writeFileSync(pkgPath, JSON.stringify(pinned, null, 2) + "\n") } - // TypeScript 6 rejects the CLI-generated tsconfig without an explicit rootDir. +} + +/** Writes a client's sources and compiles its `dist/`, which the harness imports. */ +export async function generateClient( + outDir: string, + generator: BindingGenerator, + sdkVersion: string, +): Promise { + writeClientSources(outDir, generator, sdkVersion) + // TypeScript 6 needs the rootDir and the node types the generated tsconfig leaves to defaults. await exec(process.execPath, [ tsc, "-p", outDir, "--rootDir", join(outDir, "src"), + "--types", + "node", ]) } diff --git a/scripts/lib/clients.d.mts b/scripts/lib/clients.d.mts index 8834f21b..e721099f 100644 --- a/scripts/lib/clients.d.mts +++ b/scripts/lib/clients.d.mts @@ -1,9 +1,9 @@ +// Types for clients.mjs, which the harness imports from TypeScript. import type { BindingGenerator } from "@stellar/stellar-sdk" export function missingClients(root: string): string[] export function writeClient( - root: string, - name: string, + dir: string, files: ReturnType, ): void diff --git a/scripts/lib/clients.mjs b/scripts/lib/clients.mjs index 27ea245c..ff0ceb65 100644 --- a/scripts/lib/clients.mjs +++ b/scripts/lib/clients.mjs @@ -1,25 +1,24 @@ import { existsSync, mkdirSync, readdirSync, writeFileSync } from "node:fs" -import { join } from "node:path" +import { basename, join } from "node:path" -const clientDir = (root, name) => join(root, "app-lib", "clients", name) - -// One client per contract crate, plus the Stellar Asset Contract the vault holds. -const expectedClients = (root) => [ - ...readdirSync(join(root, "contracts"), { withFileTypes: true }) +const crateClients = (root) => + readdirSync(join(root, "contracts"), { withFileTypes: true }) .filter((entry) => entry.isDirectory()) .map((entry) => entry.name.replaceAll("-", "_")) - .sort(), - "asset", -] + .sort() +// The app imports each client's `src/`, the harness a contract client's `dist/`. export const missingClients = (root) => - expectedClients(root).filter( - (name) => !existsSync(join(clientDir(root, name), "src", "index.ts")), - ) + [ + ...crateClients(root).flatMap((name) => [ + `app-lib/clients/${name}/src/index.ts`, + `app-lib/clients/${name}/dist/index.js`, + ]), + "app-lib/clients/asset/src/index.ts", + ].filter((path) => !existsSync(join(root, path))) -// Writes the SDK generator's bindings where `app-lib/contracts.ts` imports them. -export const writeClient = (root, name, files) => { - const dir = clientDir(root, name) +export const writeClient = (dir, files) => { + const name = basename(dir) mkdirSync(join(dir, "src"), { recursive: true }) const sources = { "src/index.ts": files.index, diff --git a/scripts/lib/clients.test.mjs b/scripts/lib/clients.test.mjs index d350a5f3..4f60bc57 100644 --- a/scripts/lib/clients.test.mjs +++ b/scripts/lib/clients.test.mjs @@ -5,19 +5,25 @@ import { join } from "node:path" import { describe, expect, it } from "vitest" import { missingClients, writeClient } from "./clients.mjs" -const repo = (contracts, clients) => { +const repo = (contracts, clients, { unbuilt = [] } = {}) => { const root = mkdtempSync(join(tmpdir(), "clients-")) for (const name of contracts) { mkdirSync(join(root, "contracts", name), { recursive: true }) } for (const name of clients) { - const src = join(root, "app-lib", "clients", name, "src") - mkdirSync(src, { recursive: true }) - writeFileSync(join(src, "index.ts"), "export {}\n") + const client = join(root, "app-lib", "clients", name) + mkdirSync(join(client, "src"), { recursive: true }) + writeFileSync(join(client, "src", "index.ts"), "export {}\n") + if (unbuilt.includes(name)) continue + mkdirSync(join(client, "dist"), { recursive: true }) + writeFileSync(join(client, "dist", "index.js"), "export {}\n") } return root } +const source = (name) => `app-lib/clients/${name}/src/index.ts` +const compiled = (name) => `app-lib/clients/${name}/dist/index.js` + describe("missingClients", () => { it("finds none when every contract and the asset have a client", () => { const root = repo( @@ -31,13 +37,24 @@ describe("missingClients", () => { it("names each contract without a client, in its underscored form", () => { const root = repo(["async-vault", "nav-oracle"], ["async_vault", "asset"]) - expect(missingClients(root)).toEqual(["nav_oracle"]) + expect(missingClients(root)).toEqual([ + source("nav_oracle"), + compiled("nav_oracle"), + ]) }) it("needs a client for the asset, which has no crate", () => { const root = repo(["async-vault"], ["async_vault"]) - expect(missingClients(root)).toEqual(["asset"]) + expect(missingClients(root)).toEqual([source("asset")]) + }) + + it("needs each contract client compiled, since the harness imports its dist", () => { + const root = repo(["async-vault"], ["async_vault", "asset"], { + unbuilt: ["async_vault", "asset"], + }) + + expect(missingClients(root)).toEqual([compiled("async_vault")]) }) it("names them all when the clients folder is empty", () => { @@ -45,9 +62,11 @@ describe("missingClients", () => { mkdirSync(join(root, "app-lib", "clients"), { recursive: true }) expect(missingClients(root)).toEqual([ - "async_vault", - "share_token", - "asset", + source("async_vault"), + compiled("async_vault"), + source("share_token"), + compiled("share_token"), + source("asset"), ]) }) }) @@ -64,19 +83,18 @@ describe("writeClient", () => { it("writes the generated sources where the app imports them", () => { const root = repo(["async-vault"], []) - writeClient(root, "async_vault", files) + writeClient(join(root, "app-lib", "clients", "async_vault"), files) const src = join(root, "app-lib", "clients", "async_vault", "src") expect(readFileSync(join(src, "index.ts"), "utf8")).toBe(files.index) expect(readFileSync(join(src, "types.ts"), "utf8")).toBe(files.types) expect(readFileSync(join(src, "client.ts"), "utf8")).toBe(files.client) - expect(missingClients(root)).toEqual(["asset"]) }) it("names the package after the client", () => { const root = repo([], []) - writeClient(root, "nav_oracle", files) + writeClient(join(root, "app-lib", "clients", "nav_oracle"), files) const pkg = JSON.parse( readFileSync( From d3d900fcf717c593550924d68bebcadd1735ca04 Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Mon, 2 Nov 2026 14:51:44 -0300 Subject: [PATCH 09/18] fix(app-lib): leave the clients' compiled output out of the type check A client compiled by an older TypeScript can declare a reference to the node types, which then clash with app-lib's own `Buffer` type. Refs #420 --- app-lib/tsconfig.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app-lib/tsconfig.json b/app-lib/tsconfig.json index e9bd1553..a3cfa899 100644 --- a/app-lib/tsconfig.json +++ b/app-lib/tsconfig.json @@ -1,4 +1,5 @@ { "extends": "@theahaco/ts-config/typescript.app", - "include": ["."] + "include": ["."], + "exclude": ["clients/*/dist", "node_modules"] } From 908a78b376454fb74f0c092665530dadd1519008 Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Mon, 2 Nov 2026 14:51:48 -0300 Subject: [PATCH 10/18] test(app): make a faked read carry its result A faked call now resolves either to a result of the contract's type or to a transaction a write can sign, so an empty object or a missing result no longer type-checks. Three vault roles faked as `undefined` now use the `null` the client returns, and the test title says what it fakes. Refs #420 --- app/src/hooks/useAdminAuthority.test.ts | 8 +++++--- app/src/hooks/useClaimWindDown.test.ts | 1 + app/src/pages/VaultPreview.test.tsx | 10 +++++----- app/src/test/fakeClients.test.ts | 6 ++++++ app/src/test/fakeClients.ts | 10 +++++++--- 5 files changed, 24 insertions(+), 11 deletions(-) diff --git a/app/src/hooks/useAdminAuthority.test.ts b/app/src/hooks/useAdminAuthority.test.ts index fc8fc7f8..93df6069 100644 --- a/app/src/hooks/useAdminAuthority.test.ts +++ b/app/src/hooks/useAdminAuthority.test.ts @@ -383,9 +383,11 @@ const vaultRoles = (byRole: Record) => { }), ) vaultMock.get_role_member.mockImplementation( - async ({ role, index }: { role: string; index: number }) => ({ - result: byRole[role]?.[index], - }), + async ({ role, index }: { role: string; index: number }) => { + const member = byRole[role]?.[index] + if (member === undefined) throw new Error(`no ${role} at ${index}`) + return { result: member } + }, ) } diff --git a/app/src/hooks/useClaimWindDown.test.ts b/app/src/hooks/useClaimWindDown.test.ts index dff9e533..455e7feb 100644 --- a/app/src/hooks/useClaimWindDown.test.ts +++ b/app/src/hooks/useClaimWindDown.test.ts @@ -89,6 +89,7 @@ describe("useClaimWindDown", () => { it("maps a refusal at simulation to its contract code", async () => { vaultMock.claim_wind_down.mockResolvedValue({ simulation: { error: "HostError: Error(Contract, #6071)" }, + signAndSend: vi.fn(), }) const { result } = renderWithWallet(useClaimWindDown) diff --git a/app/src/pages/VaultPreview.test.tsx b/app/src/pages/VaultPreview.test.tsx index 5d08d113..2255fd08 100644 --- a/app/src/pages/VaultPreview.test.tsx +++ b/app/src/pages/VaultPreview.test.tsx @@ -323,7 +323,7 @@ vi.mock("../config/contracts", () => { vi.mock("../config/clients", () => import("../test/fakeClients")) const figure = (result: bigint) => async () => ({ result }) -const address = (result: string | null | undefined) => async () => ({ +const address = (result: string | null) => async () => ({ result, }) const vault = fakeAsyncVault( @@ -358,9 +358,9 @@ vault.uncovered.mockImplementation(async () => { vault.total_economic_supply.mockImplementation(figure(100000000000000n)) vault.net_deployed.mockImplementation(figure(50000000000n)) vault.governance.mockImplementation(address(mockGovernanceAddress)) -vault.manager.mockImplementation(address(undefined)) -vault.treasury.mockImplementation(address(undefined)) -vault.guardian.mockImplementation(address(undefined)) +vault.manager.mockImplementation(address(null)) +vault.treasury.mockImplementation(address(null)) +vault.guardian.mockImplementation(address(null)) vault.custodian.mockImplementation(address(null)) vault.current_epoch.mockImplementation(async () => { if (!mockRequests.readable) throw new Error("boom") @@ -901,7 +901,7 @@ describe("VaultPreview", () => { expect(screen.getByText("5,000.00")).toBeTruthy() }) - it("renders the authority addresses the vault reports, unavailable when unreadable and the custodian as not set", async () => { + it("renders the authority addresses the vault reports, unavailable for a role with no holder and the custodian as not set", async () => { renderVaultPreview() expect( diff --git a/app/src/test/fakeClients.test.ts b/app/src/test/fakeClients.test.ts index e766db89..3492ea6d 100644 --- a/app/src/test/fakeClients.test.ts +++ b/app/src/test/fakeClients.test.ts @@ -73,6 +73,12 @@ describe("fake clients", () => { vault.current_epoch.mockResolvedValue({ result: "3" }) // @ts-expect-error not a member of an assembled transaction vault.current_epoch.mockResolvedValue({ results: 3n }) + // @ts-expect-error a read carries its result + vault.current_epoch.mockResolvedValue({}) + // @ts-expect-error current_epoch never returns undefined + vault.current_epoch.mockResolvedValue({ result: undefined }) + fakeNavOracle("get_admin").get_admin.mockResolvedValue({ result: null }) + vault.cancel_deposit.mockResolvedValue({ signAndSend: vi.fn() }) // @ts-expect-error the call was not declared void vault.paused }) diff --git a/app/src/test/fakeClients.ts b/app/src/test/fakeClients.ts index 6d7d0f3e..01ddf698 100644 --- a/app/src/test/fakeClients.ts +++ b/app/src/test/fakeClients.ts @@ -17,12 +17,16 @@ type CallName = { : never }[keyof Client] -// What a faked call resolves to. Only `result` takes its type from the -// contract; the other members are SDK internals that tests stand in for loosely. -export type FakeTransaction = { result?: Result } & { +type SdkMembers = { [Member in Exclude, "result">]?: unknown } +// A faked read carries its result, typed by the contract; a faked write carries +// what the signing path calls. Other SDK members stay loose. +export type FakeTransaction = + | ({ result: Result } & SdkMembers) + | ({ signAndSend: unknown } & SdkMembers) + type FakeCall = Call extends ( ...args: infer Args ) => Promise> From bb9b2a316f9a74b107646729dc9199c98700beee Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Mon, 2 Nov 2026 14:51:53 -0300 Subject: [PATCH 11/18] ci: pass only the clients between jobs Nothing downstream reads the Wasm, so the artifact carries the generated clients alone. The harness report upload uses the same upload-artifact release as the clients. Refs #420 --- .github/workflows/build.yml | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f156ef11..01a6db37 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -79,12 +79,11 @@ jobs: - run: npm ci - name: Generate the TypeScript clients from the Wasm run: npm run build:clients + # One path, so the artifact's root is the clients folder itself. - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: - name: contracts - path: | - target/wasm32v1-none/release/*.wasm - app-lib/clients/ + name: clients + path: app-lib/clients/ if-no-files-found: error retention-days: 7 @@ -101,10 +100,11 @@ jobs: - run: npm i -g npm@11.7.0 - run: npm --version - run: npm ci - - name: Download the Wasm and the generated clients + - name: Download the generated clients uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: - name: contracts + name: clients + path: app-lib/clients - run: npm run lint - run: npm run format:check - run: npm run typecheck @@ -345,7 +345,7 @@ jobs: HARNESS_NETWORK: local - name: Keep the harness report when it fails if: failure() - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: harness-runs path: scripts/harness/runs/ From df89ed1c27c589419aabf0c4b8a3e9fcc7ad6299 Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Mon, 2 Nov 2026 14:51:58 -0300 Subject: [PATCH 12/18] test(app): move the modal render helpers beside the other test helpers Refs #420 --- .../vault/TransactionModal.test.tsx | 2 +- .../transactionModal/compliance.test.tsx | 2 +- .../transactionModal/configuration.test.tsx | 2 +- .../vault/transactionModal/cycle.test.tsx | 2 +- .../vault/transactionModal/emergency.test.tsx | 5 ++- .../transactionModal/governance.test.tsx | 2 +- .../vault/transactionModal/investor.test.tsx | 2 +- .../investorOutcomes.test.tsx | 4 +-- app/src/test/renderTransactionModal.tsx | 32 +++++++++---------- 9 files changed, 28 insertions(+), 25 deletions(-) diff --git a/app/src/components/vault/TransactionModal.test.tsx b/app/src/components/vault/TransactionModal.test.tsx index 70b2ff88..a0ccd635 100644 --- a/app/src/components/vault/TransactionModal.test.tsx +++ b/app/src/components/vault/TransactionModal.test.tsx @@ -3,8 +3,8 @@ import { fireEvent, render, screen, within } from "@testing-library/react" import { StrictMode } from "react" import { describe, expect, it, vi } from "vitest" import { type RequestDepositStatus } from "../../hooks/useRequestDeposit" +import { renderModal } from "../../test/renderTransactionModal" import TransactionModal from "./TransactionModal" -import { renderModal } from "./transactionModal/renderModal" const { explorerTransactionMock } = vi.hoisted(() => ({ explorerTransactionMock: vi.fn(() => null as string | null), diff --git a/app/src/components/vault/transactionModal/compliance.test.tsx b/app/src/components/vault/transactionModal/compliance.test.tsx index 045438a2..774b2d04 100644 --- a/app/src/components/vault/transactionModal/compliance.test.tsx +++ b/app/src/components/vault/transactionModal/compliance.test.tsx @@ -1,8 +1,8 @@ import type * as AppLib from "@stellar-scaffold/app-lib" import { screen } from "@testing-library/react" import { describe, expect, it, vi } from "vitest" +import { renderComplianceModal } from "../../../test/renderTransactionModal" import { type ComplianceModalAction } from "../TransactionModal" -import { renderComplianceModal } from "./renderModal" vi.mock("@stellar-scaffold/app-lib", async (importOriginal) => ({ ...(await importOriginal()), diff --git a/app/src/components/vault/transactionModal/configuration.test.tsx b/app/src/components/vault/transactionModal/configuration.test.tsx index 3ea01c87..076fff85 100644 --- a/app/src/components/vault/transactionModal/configuration.test.tsx +++ b/app/src/components/vault/transactionModal/configuration.test.tsx @@ -1,10 +1,10 @@ import type * as AppLib from "@stellar-scaffold/app-lib" import { screen } from "@testing-library/react" import { describe, expect, it, vi } from "vitest" +import { renderConfigurationModal } from "../../../test/renderTransactionModal" import TransactionModal, { type ConfigurationModalAction, } from "../TransactionModal" -import { renderConfigurationModal } from "./renderModal" vi.mock("@stellar-scaffold/app-lib", async (importOriginal) => ({ ...(await importOriginal()), diff --git a/app/src/components/vault/transactionModal/cycle.test.tsx b/app/src/components/vault/transactionModal/cycle.test.tsx index 02ba0ac8..be01635b 100644 --- a/app/src/components/vault/transactionModal/cycle.test.tsx +++ b/app/src/components/vault/transactionModal/cycle.test.tsx @@ -10,7 +10,7 @@ import { renderFundModal, renderActivateModal, renderFinalizeModal, -} from "./renderModal" +} from "../../../test/renderTransactionModal" vi.mock("@stellar-scaffold/app-lib", async (importOriginal) => ({ ...(await importOriginal()), diff --git a/app/src/components/vault/transactionModal/emergency.test.tsx b/app/src/components/vault/transactionModal/emergency.test.tsx index 78195831..6e72fbe9 100644 --- a/app/src/components/vault/transactionModal/emergency.test.tsx +++ b/app/src/components/vault/transactionModal/emergency.test.tsx @@ -2,10 +2,13 @@ import { formatDate, type SubmitFailure } from "@stellar-scaffold/app-lib" import type * as AppLib from "@stellar-scaffold/app-lib" import { act, fireEvent, screen } from "@testing-library/react" import { describe, expect, it, vi } from "vitest" +import { + renderModal, + renderEmergencyModal, +} from "../../../test/renderTransactionModal" import TransactionModal, { type EmergencyModalAction, } from "../TransactionModal" -import { renderModal, renderEmergencyModal } from "./renderModal" vi.mock("@stellar-scaffold/app-lib", async (importOriginal) => ({ ...(await importOriginal()), diff --git a/app/src/components/vault/transactionModal/governance.test.tsx b/app/src/components/vault/transactionModal/governance.test.tsx index f3068ea6..f7eb73f4 100644 --- a/app/src/components/vault/transactionModal/governance.test.tsx +++ b/app/src/components/vault/transactionModal/governance.test.tsx @@ -1,8 +1,8 @@ import type * as AppLib from "@stellar-scaffold/app-lib" import { screen } from "@testing-library/react" import { describe, expect, it, vi } from "vitest" +import { renderGovernanceModal } from "../../../test/renderTransactionModal" import { type GovernanceModalAction } from "../TransactionModal" -import { renderGovernanceModal } from "./renderModal" vi.mock("@stellar-scaffold/app-lib", async (importOriginal) => ({ ...(await importOriginal()), diff --git a/app/src/components/vault/transactionModal/investor.test.tsx b/app/src/components/vault/transactionModal/investor.test.tsx index ce67042f..412d9cd7 100644 --- a/app/src/components/vault/transactionModal/investor.test.tsx +++ b/app/src/components/vault/transactionModal/investor.test.tsx @@ -8,7 +8,7 @@ import { renderClaimModal, renderRedeemModal, renderClaimRedeemModal, -} from "./renderModal" +} from "../../../test/renderTransactionModal" vi.mock("@stellar-scaffold/app-lib", async (importOriginal) => ({ ...(await importOriginal()), diff --git a/app/src/components/vault/transactionModal/investorOutcomes.test.tsx b/app/src/components/vault/transactionModal/investorOutcomes.test.tsx index 2bf129a7..8a07dd93 100644 --- a/app/src/components/vault/transactionModal/investorOutcomes.test.tsx +++ b/app/src/components/vault/transactionModal/investorOutcomes.test.tsx @@ -2,7 +2,6 @@ import { type Amount } from "@stellar-scaffold/app-lib" import type * as AppLib from "@stellar-scaffold/app-lib" import { render, screen } from "@testing-library/react" import { describe, expect, it, vi } from "vitest" -import TransactionModal from "../TransactionModal" import { renderModal, renderCancelModal, @@ -11,7 +10,8 @@ import { renderRedeemModal, renderClaimRedeemModal, renderClaimWindDownModal, -} from "./renderModal" +} from "../../../test/renderTransactionModal" +import TransactionModal from "../TransactionModal" vi.mock("@stellar-scaffold/app-lib", async (importOriginal) => ({ ...(await importOriginal()), diff --git a/app/src/test/renderTransactionModal.tsx b/app/src/test/renderTransactionModal.tsx index f23ac334..63c384dd 100644 --- a/app/src/test/renderTransactionModal.tsx +++ b/app/src/test/renderTransactionModal.tsx @@ -1,26 +1,26 @@ import { render } from "@testing-library/react" import { vi } from "vitest" -import { type ActivateWindDownStatus } from "../../../hooks/useActivateWindDown" -import { type AttestStatus } from "../../../hooks/useAttest" -import { type CancelDepositStatus } from "../../../hooks/useCancelDeposit" -import { type CancelRedeemStatus } from "../../../hooks/useCancelRedeem" -import { type ClaimDepositStatus } from "../../../hooks/useClaimDeposit" -import { type ClaimRedeemStatus } from "../../../hooks/useClaimRedeem" -import { type ClaimWindDownStatus } from "../../../hooks/useClaimWindDown" -import { type CloseEpochStatus } from "../../../hooks/useCloseEpoch" -import { type TransactionStatus } from "../../../hooks/useContractTransaction" -import { type DeployToCustodianStatus } from "../../../hooks/useDeployToCustodian" -import { type FinalizeWindDownRoundStatus } from "../../../hooks/useFinalizeWindDownRound" -import { type FulfillEpochStatus } from "../../../hooks/useFulfillEpoch" -import { type FundStatus } from "../../../hooks/useFund" -import { type RequestDepositStatus } from "../../../hooks/useRequestDeposit" -import { type RequestRedeemStatus } from "../../../hooks/useRequestRedeem" import TransactionModal, { type ComplianceModalAction, type ConfigurationModalAction, type EmergencyModalAction, type GovernanceModalAction, -} from "../TransactionModal" +} from "../components/vault/TransactionModal" +import { type ActivateWindDownStatus } from "../hooks/useActivateWindDown" +import { type AttestStatus } from "../hooks/useAttest" +import { type CancelDepositStatus } from "../hooks/useCancelDeposit" +import { type CancelRedeemStatus } from "../hooks/useCancelRedeem" +import { type ClaimDepositStatus } from "../hooks/useClaimDeposit" +import { type ClaimRedeemStatus } from "../hooks/useClaimRedeem" +import { type ClaimWindDownStatus } from "../hooks/useClaimWindDown" +import { type CloseEpochStatus } from "../hooks/useCloseEpoch" +import { type TransactionStatus } from "../hooks/useContractTransaction" +import { type DeployToCustodianStatus } from "../hooks/useDeployToCustodian" +import { type FinalizeWindDownRoundStatus } from "../hooks/useFinalizeWindDownRound" +import { type FulfillEpochStatus } from "../hooks/useFulfillEpoch" +import { type FundStatus } from "../hooks/useFund" +import { type RequestDepositStatus } from "../hooks/useRequestDeposit" +import { type RequestRedeemStatus } from "../hooks/useRequestRedeem" export const renderModal = (status: RequestDepositStatus) => { const onClose = vi.fn() From 5fe9919c7cf85a70dcfdc8f3086c592e0b5fe6f2 Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Mon, 2 Nov 2026 15:41:01 -0300 Subject: [PATCH 13/18] revert: test(app): move the modal render helpers beside the other test helpers This reverts commit df89ed1. The TransactionModal split moves to its own issue. Refs #420 --- .../vault/TransactionModal.test.tsx | 2 +- .../transactionModal/compliance.test.tsx | 2 +- .../transactionModal/configuration.test.tsx | 2 +- .../vault/transactionModal/cycle.test.tsx | 2 +- .../vault/transactionModal/emergency.test.tsx | 5 +-- .../transactionModal/governance.test.tsx | 2 +- .../vault/transactionModal/investor.test.tsx | 2 +- .../investorOutcomes.test.tsx | 4 +-- app/src/test/renderTransactionModal.tsx | 32 +++++++++---------- 9 files changed, 25 insertions(+), 28 deletions(-) diff --git a/app/src/components/vault/TransactionModal.test.tsx b/app/src/components/vault/TransactionModal.test.tsx index a0ccd635..70b2ff88 100644 --- a/app/src/components/vault/TransactionModal.test.tsx +++ b/app/src/components/vault/TransactionModal.test.tsx @@ -3,8 +3,8 @@ import { fireEvent, render, screen, within } from "@testing-library/react" import { StrictMode } from "react" import { describe, expect, it, vi } from "vitest" import { type RequestDepositStatus } from "../../hooks/useRequestDeposit" -import { renderModal } from "../../test/renderTransactionModal" import TransactionModal from "./TransactionModal" +import { renderModal } from "./transactionModal/renderModal" const { explorerTransactionMock } = vi.hoisted(() => ({ explorerTransactionMock: vi.fn(() => null as string | null), diff --git a/app/src/components/vault/transactionModal/compliance.test.tsx b/app/src/components/vault/transactionModal/compliance.test.tsx index 774b2d04..045438a2 100644 --- a/app/src/components/vault/transactionModal/compliance.test.tsx +++ b/app/src/components/vault/transactionModal/compliance.test.tsx @@ -1,8 +1,8 @@ import type * as AppLib from "@stellar-scaffold/app-lib" import { screen } from "@testing-library/react" import { describe, expect, it, vi } from "vitest" -import { renderComplianceModal } from "../../../test/renderTransactionModal" import { type ComplianceModalAction } from "../TransactionModal" +import { renderComplianceModal } from "./renderModal" vi.mock("@stellar-scaffold/app-lib", async (importOriginal) => ({ ...(await importOriginal()), diff --git a/app/src/components/vault/transactionModal/configuration.test.tsx b/app/src/components/vault/transactionModal/configuration.test.tsx index 076fff85..3ea01c87 100644 --- a/app/src/components/vault/transactionModal/configuration.test.tsx +++ b/app/src/components/vault/transactionModal/configuration.test.tsx @@ -1,10 +1,10 @@ import type * as AppLib from "@stellar-scaffold/app-lib" import { screen } from "@testing-library/react" import { describe, expect, it, vi } from "vitest" -import { renderConfigurationModal } from "../../../test/renderTransactionModal" import TransactionModal, { type ConfigurationModalAction, } from "../TransactionModal" +import { renderConfigurationModal } from "./renderModal" vi.mock("@stellar-scaffold/app-lib", async (importOriginal) => ({ ...(await importOriginal()), diff --git a/app/src/components/vault/transactionModal/cycle.test.tsx b/app/src/components/vault/transactionModal/cycle.test.tsx index be01635b..02ba0ac8 100644 --- a/app/src/components/vault/transactionModal/cycle.test.tsx +++ b/app/src/components/vault/transactionModal/cycle.test.tsx @@ -10,7 +10,7 @@ import { renderFundModal, renderActivateModal, renderFinalizeModal, -} from "../../../test/renderTransactionModal" +} from "./renderModal" vi.mock("@stellar-scaffold/app-lib", async (importOriginal) => ({ ...(await importOriginal()), diff --git a/app/src/components/vault/transactionModal/emergency.test.tsx b/app/src/components/vault/transactionModal/emergency.test.tsx index 6e72fbe9..78195831 100644 --- a/app/src/components/vault/transactionModal/emergency.test.tsx +++ b/app/src/components/vault/transactionModal/emergency.test.tsx @@ -2,13 +2,10 @@ import { formatDate, type SubmitFailure } from "@stellar-scaffold/app-lib" import type * as AppLib from "@stellar-scaffold/app-lib" import { act, fireEvent, screen } from "@testing-library/react" import { describe, expect, it, vi } from "vitest" -import { - renderModal, - renderEmergencyModal, -} from "../../../test/renderTransactionModal" import TransactionModal, { type EmergencyModalAction, } from "../TransactionModal" +import { renderModal, renderEmergencyModal } from "./renderModal" vi.mock("@stellar-scaffold/app-lib", async (importOriginal) => ({ ...(await importOriginal()), diff --git a/app/src/components/vault/transactionModal/governance.test.tsx b/app/src/components/vault/transactionModal/governance.test.tsx index f7eb73f4..f3068ea6 100644 --- a/app/src/components/vault/transactionModal/governance.test.tsx +++ b/app/src/components/vault/transactionModal/governance.test.tsx @@ -1,8 +1,8 @@ import type * as AppLib from "@stellar-scaffold/app-lib" import { screen } from "@testing-library/react" import { describe, expect, it, vi } from "vitest" -import { renderGovernanceModal } from "../../../test/renderTransactionModal" import { type GovernanceModalAction } from "../TransactionModal" +import { renderGovernanceModal } from "./renderModal" vi.mock("@stellar-scaffold/app-lib", async (importOriginal) => ({ ...(await importOriginal()), diff --git a/app/src/components/vault/transactionModal/investor.test.tsx b/app/src/components/vault/transactionModal/investor.test.tsx index 412d9cd7..ce67042f 100644 --- a/app/src/components/vault/transactionModal/investor.test.tsx +++ b/app/src/components/vault/transactionModal/investor.test.tsx @@ -8,7 +8,7 @@ import { renderClaimModal, renderRedeemModal, renderClaimRedeemModal, -} from "../../../test/renderTransactionModal" +} from "./renderModal" vi.mock("@stellar-scaffold/app-lib", async (importOriginal) => ({ ...(await importOriginal()), diff --git a/app/src/components/vault/transactionModal/investorOutcomes.test.tsx b/app/src/components/vault/transactionModal/investorOutcomes.test.tsx index 8a07dd93..2bf129a7 100644 --- a/app/src/components/vault/transactionModal/investorOutcomes.test.tsx +++ b/app/src/components/vault/transactionModal/investorOutcomes.test.tsx @@ -2,6 +2,7 @@ import { type Amount } from "@stellar-scaffold/app-lib" import type * as AppLib from "@stellar-scaffold/app-lib" import { render, screen } from "@testing-library/react" import { describe, expect, it, vi } from "vitest" +import TransactionModal from "../TransactionModal" import { renderModal, renderCancelModal, @@ -10,8 +11,7 @@ import { renderRedeemModal, renderClaimRedeemModal, renderClaimWindDownModal, -} from "../../../test/renderTransactionModal" -import TransactionModal from "../TransactionModal" +} from "./renderModal" vi.mock("@stellar-scaffold/app-lib", async (importOriginal) => ({ ...(await importOriginal()), diff --git a/app/src/test/renderTransactionModal.tsx b/app/src/test/renderTransactionModal.tsx index 63c384dd..f23ac334 100644 --- a/app/src/test/renderTransactionModal.tsx +++ b/app/src/test/renderTransactionModal.tsx @@ -1,26 +1,26 @@ import { render } from "@testing-library/react" import { vi } from "vitest" +import { type ActivateWindDownStatus } from "../../../hooks/useActivateWindDown" +import { type AttestStatus } from "../../../hooks/useAttest" +import { type CancelDepositStatus } from "../../../hooks/useCancelDeposit" +import { type CancelRedeemStatus } from "../../../hooks/useCancelRedeem" +import { type ClaimDepositStatus } from "../../../hooks/useClaimDeposit" +import { type ClaimRedeemStatus } from "../../../hooks/useClaimRedeem" +import { type ClaimWindDownStatus } from "../../../hooks/useClaimWindDown" +import { type CloseEpochStatus } from "../../../hooks/useCloseEpoch" +import { type TransactionStatus } from "../../../hooks/useContractTransaction" +import { type DeployToCustodianStatus } from "../../../hooks/useDeployToCustodian" +import { type FinalizeWindDownRoundStatus } from "../../../hooks/useFinalizeWindDownRound" +import { type FulfillEpochStatus } from "../../../hooks/useFulfillEpoch" +import { type FundStatus } from "../../../hooks/useFund" +import { type RequestDepositStatus } from "../../../hooks/useRequestDeposit" +import { type RequestRedeemStatus } from "../../../hooks/useRequestRedeem" import TransactionModal, { type ComplianceModalAction, type ConfigurationModalAction, type EmergencyModalAction, type GovernanceModalAction, -} from "../components/vault/TransactionModal" -import { type ActivateWindDownStatus } from "../hooks/useActivateWindDown" -import { type AttestStatus } from "../hooks/useAttest" -import { type CancelDepositStatus } from "../hooks/useCancelDeposit" -import { type CancelRedeemStatus } from "../hooks/useCancelRedeem" -import { type ClaimDepositStatus } from "../hooks/useClaimDeposit" -import { type ClaimRedeemStatus } from "../hooks/useClaimRedeem" -import { type ClaimWindDownStatus } from "../hooks/useClaimWindDown" -import { type CloseEpochStatus } from "../hooks/useCloseEpoch" -import { type TransactionStatus } from "../hooks/useContractTransaction" -import { type DeployToCustodianStatus } from "../hooks/useDeployToCustodian" -import { type FinalizeWindDownRoundStatus } from "../hooks/useFinalizeWindDownRound" -import { type FulfillEpochStatus } from "../hooks/useFulfillEpoch" -import { type FundStatus } from "../hooks/useFund" -import { type RequestDepositStatus } from "../hooks/useRequestDeposit" -import { type RequestRedeemStatus } from "../hooks/useRequestRedeem" +} from "../TransactionModal" export const renderModal = (status: RequestDepositStatus) => { const onClose = vi.fn() From 536f7af6b2246417cf19c7b49fd3852f97132f5d Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Mon, 2 Nov 2026 15:41:06 -0300 Subject: [PATCH 14/18] revert: test(app): make a faked read carry its result This reverts commit 908a78b. The shared typed fake moves to its own issue. Refs #420 --- app/src/hooks/useAdminAuthority.test.ts | 8 +++----- app/src/hooks/useClaimWindDown.test.ts | 1 - app/src/pages/VaultPreview.test.tsx | 10 +++++----- app/src/test/fakeClients.test.ts | 6 ------ app/src/test/fakeClients.ts | 10 +++------- 5 files changed, 11 insertions(+), 24 deletions(-) diff --git a/app/src/hooks/useAdminAuthority.test.ts b/app/src/hooks/useAdminAuthority.test.ts index 93df6069..fc8fc7f8 100644 --- a/app/src/hooks/useAdminAuthority.test.ts +++ b/app/src/hooks/useAdminAuthority.test.ts @@ -383,11 +383,9 @@ const vaultRoles = (byRole: Record) => { }), ) vaultMock.get_role_member.mockImplementation( - async ({ role, index }: { role: string; index: number }) => { - const member = byRole[role]?.[index] - if (member === undefined) throw new Error(`no ${role} at ${index}`) - return { result: member } - }, + async ({ role, index }: { role: string; index: number }) => ({ + result: byRole[role]?.[index], + }), ) } diff --git a/app/src/hooks/useClaimWindDown.test.ts b/app/src/hooks/useClaimWindDown.test.ts index 455e7feb..dff9e533 100644 --- a/app/src/hooks/useClaimWindDown.test.ts +++ b/app/src/hooks/useClaimWindDown.test.ts @@ -89,7 +89,6 @@ describe("useClaimWindDown", () => { it("maps a refusal at simulation to its contract code", async () => { vaultMock.claim_wind_down.mockResolvedValue({ simulation: { error: "HostError: Error(Contract, #6071)" }, - signAndSend: vi.fn(), }) const { result } = renderWithWallet(useClaimWindDown) diff --git a/app/src/pages/VaultPreview.test.tsx b/app/src/pages/VaultPreview.test.tsx index 2255fd08..5d08d113 100644 --- a/app/src/pages/VaultPreview.test.tsx +++ b/app/src/pages/VaultPreview.test.tsx @@ -323,7 +323,7 @@ vi.mock("../config/contracts", () => { vi.mock("../config/clients", () => import("../test/fakeClients")) const figure = (result: bigint) => async () => ({ result }) -const address = (result: string | null) => async () => ({ +const address = (result: string | null | undefined) => async () => ({ result, }) const vault = fakeAsyncVault( @@ -358,9 +358,9 @@ vault.uncovered.mockImplementation(async () => { vault.total_economic_supply.mockImplementation(figure(100000000000000n)) vault.net_deployed.mockImplementation(figure(50000000000n)) vault.governance.mockImplementation(address(mockGovernanceAddress)) -vault.manager.mockImplementation(address(null)) -vault.treasury.mockImplementation(address(null)) -vault.guardian.mockImplementation(address(null)) +vault.manager.mockImplementation(address(undefined)) +vault.treasury.mockImplementation(address(undefined)) +vault.guardian.mockImplementation(address(undefined)) vault.custodian.mockImplementation(address(null)) vault.current_epoch.mockImplementation(async () => { if (!mockRequests.readable) throw new Error("boom") @@ -901,7 +901,7 @@ describe("VaultPreview", () => { expect(screen.getByText("5,000.00")).toBeTruthy() }) - it("renders the authority addresses the vault reports, unavailable for a role with no holder and the custodian as not set", async () => { + it("renders the authority addresses the vault reports, unavailable when unreadable and the custodian as not set", async () => { renderVaultPreview() expect( diff --git a/app/src/test/fakeClients.test.ts b/app/src/test/fakeClients.test.ts index 3492ea6d..e766db89 100644 --- a/app/src/test/fakeClients.test.ts +++ b/app/src/test/fakeClients.test.ts @@ -73,12 +73,6 @@ describe("fake clients", () => { vault.current_epoch.mockResolvedValue({ result: "3" }) // @ts-expect-error not a member of an assembled transaction vault.current_epoch.mockResolvedValue({ results: 3n }) - // @ts-expect-error a read carries its result - vault.current_epoch.mockResolvedValue({}) - // @ts-expect-error current_epoch never returns undefined - vault.current_epoch.mockResolvedValue({ result: undefined }) - fakeNavOracle("get_admin").get_admin.mockResolvedValue({ result: null }) - vault.cancel_deposit.mockResolvedValue({ signAndSend: vi.fn() }) // @ts-expect-error the call was not declared void vault.paused }) diff --git a/app/src/test/fakeClients.ts b/app/src/test/fakeClients.ts index 01ddf698..6d7d0f3e 100644 --- a/app/src/test/fakeClients.ts +++ b/app/src/test/fakeClients.ts @@ -17,16 +17,12 @@ type CallName = { : never }[keyof Client] -type SdkMembers = { +// What a faked call resolves to. Only `result` takes its type from the +// contract; the other members are SDK internals that tests stand in for loosely. +export type FakeTransaction = { result?: Result } & { [Member in Exclude, "result">]?: unknown } -// A faked read carries its result, typed by the contract; a faked write carries -// what the signing path calls. Other SDK members stay loose. -export type FakeTransaction = - | ({ result: Result } & SdkMembers) - | ({ signAndSend: unknown } & SdkMembers) - type FakeCall = Call extends ( ...args: infer Args ) => Promise> From 378ddb8eca6f6a5339b0316280123abaec078f8c Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Mon, 2 Nov 2026 15:41:38 -0300 Subject: [PATCH 15/18] revert: test(app): split the TransactionModal tests by section This reverts commit d1d371b, and removes its render helpers at the path they were later moved to. The split moves to its own issue. Refs #420 --- .../vault/TransactionModal.test.tsx | 2897 ++++++++++++++++- .../transactionModal/compliance.test.tsx | 139 - .../transactionModal/configuration.test.tsx | 259 -- .../vault/transactionModal/cycle.test.tsx | 670 ---- .../vault/transactionModal/emergency.test.tsx | 263 -- .../transactionModal/governance.test.tsx | 333 -- .../vault/transactionModal/investor.test.tsx | 807 ----- .../investorOutcomes.test.tsx | 231 -- app/src/test/renderTransactionModal.tsx | 303 -- 9 files changed, 2894 insertions(+), 3008 deletions(-) delete mode 100644 app/src/components/vault/transactionModal/compliance.test.tsx delete mode 100644 app/src/components/vault/transactionModal/configuration.test.tsx delete mode 100644 app/src/components/vault/transactionModal/cycle.test.tsx delete mode 100644 app/src/components/vault/transactionModal/emergency.test.tsx delete mode 100644 app/src/components/vault/transactionModal/governance.test.tsx delete mode 100644 app/src/components/vault/transactionModal/investor.test.tsx delete mode 100644 app/src/components/vault/transactionModal/investorOutcomes.test.tsx delete mode 100644 app/src/test/renderTransactionModal.tsx diff --git a/app/src/components/vault/TransactionModal.test.tsx b/app/src/components/vault/TransactionModal.test.tsx index 70b2ff88..99b5357e 100644 --- a/app/src/components/vault/TransactionModal.test.tsx +++ b/app/src/components/vault/TransactionModal.test.tsx @@ -1,10 +1,34 @@ +import { + type Amount, + formatDate, + type Price, + type SubmitFailure, +} from "@stellar-scaffold/app-lib" import type * as AppLib from "@stellar-scaffold/app-lib" -import { fireEvent, render, screen, within } from "@testing-library/react" +import { act, fireEvent, render, screen, within } from "@testing-library/react" import { StrictMode } from "react" import { describe, expect, it, vi } from "vitest" +import { type ActivateWindDownStatus } from "../../hooks/useActivateWindDown" +import { type AttestStatus } from "../../hooks/useAttest" +import { type CancelDepositStatus } from "../../hooks/useCancelDeposit" +import { type CancelRedeemStatus } from "../../hooks/useCancelRedeem" +import { type ClaimDepositStatus } from "../../hooks/useClaimDeposit" +import { type ClaimRedeemStatus } from "../../hooks/useClaimRedeem" +import { type ClaimWindDownStatus } from "../../hooks/useClaimWindDown" +import { type CloseEpochStatus } from "../../hooks/useCloseEpoch" +import { type TransactionStatus } from "../../hooks/useContractTransaction" +import { type DeployToCustodianStatus } from "../../hooks/useDeployToCustodian" +import { type FinalizeWindDownRoundStatus } from "../../hooks/useFinalizeWindDownRound" +import { type FulfillEpochStatus } from "../../hooks/useFulfillEpoch" +import { type FundStatus } from "../../hooks/useFund" import { type RequestDepositStatus } from "../../hooks/useRequestDeposit" -import TransactionModal from "./TransactionModal" -import { renderModal } from "./transactionModal/renderModal" +import { type RequestRedeemStatus } from "../../hooks/useRequestRedeem" +import TransactionModal, { + type ComplianceModalAction, + type ConfigurationModalAction, + type EmergencyModalAction, + type GovernanceModalAction, +} from "./TransactionModal" const { explorerTransactionMock } = vi.hoisted(() => ({ explorerTransactionMock: vi.fn(() => null as string | null), @@ -17,6 +41,212 @@ vi.mock("@stellar-scaffold/app-lib", async (importOriginal) => ({ explorerTransaction: explorerTransactionMock, })) +const renderModal = (status: RequestDepositStatus) => { + const onClose = vi.fn() + const onRetry = vi.fn() + const view = render( + , + ) + return { ...view, onClose, onRetry } +} + +const renderCancelModal = (status: CancelDepositStatus) => { + const onClose = vi.fn() + const onRetry = vi.fn() + const view = render( + , + ) + return { ...view, onClose, onRetry } +} + +const renderCancelRedeemModal = (status: CancelRedeemStatus) => { + const onClose = vi.fn() + const onRetry = vi.fn() + const view = render( + , + ) + return { ...view, onClose, onRetry } +} + +const renderClaimModal = (status: ClaimDepositStatus) => { + const onClose = vi.fn() + const onRetry = vi.fn() + const view = render( + , + ) + return { ...view, onClose, onRetry } +} + +const renderRedeemModal = (status: RequestRedeemStatus) => { + const onClose = vi.fn() + const onRetry = vi.fn() + const view = render( + , + ) + return { ...view, onClose, onRetry } +} + +const renderClaimRedeemModal = (status: ClaimRedeemStatus) => { + const onClose = vi.fn() + const onRetry = vi.fn() + const view = render( + , + ) + return { ...view, onClose, onRetry } +} + +const renderCloseEpochModal = (status: CloseEpochStatus, epoch = "5") => { + const onClose = vi.fn() + const onRetry = vi.fn() + const view = render( + , + ) + return { ...view, onClose, onRetry } +} + +const renderFulfillEpochModal = (status: FulfillEpochStatus) => { + const onClose = vi.fn() + const onRetry = vi.fn() + const view = render( + , + ) + return { ...view, onClose, onRetry } +} + +const renderAttestModal = ( + status: AttestStatus, + validFor: string | null = "1d", +) => { + const onClose = vi.fn() + const onRetry = vi.fn() + const view = render( + , + ) + return { ...view, onClose, onRetry } +} + +const renderDeployModal = (status: DeployToCustodianStatus) => { + const onClose = vi.fn() + const onRetry = vi.fn() + const view = render( + , + ) + return { ...view, onClose, onRetry } +} + +const renderFundModal = (status: FundStatus) => { + const onClose = vi.fn() + const onRetry = vi.fn() + const view = render( + , + ) + return { ...view, onClose, onRetry } +} + +const renderActivateModal = (status: ActivateWindDownStatus) => { + const onClose = vi.fn() + const onRetry = vi.fn() + const view = render( + , + ) + return { ...view, onClose, onRetry } +} + +const renderFinalizeModal = (status: FinalizeWindDownRoundStatus) => { + const onClose = vi.fn() + const onRetry = vi.fn() + const view = render( + , + ) + return { ...view, onClose, onRetry } +} + describe("TransactionModal", () => { it("renders nothing while idle", () => { renderModal({ status: "idle" }) @@ -407,3 +637,2664 @@ describe("TransactionModal", () => { consoleError.mockRestore() }) }) + +describe("TransactionModal, cancelling", () => { + it("opens with a preparing state naming the cancellation, not the request", () => { + renderCancelModal({ status: "preparing" }) + + expect( + screen.getByRole("heading", { name: "Preparing your cancellation" }), + ).toBeTruthy() + }) + + it("tells the investor their deposit returns to their wallet, honestly, without escrow or pricing language", () => { + renderCancelModal({ status: "awaiting-signature" }) + + expect( + screen.getByRole("heading", { name: "Confirm in your wallet" }), + ).toBeTruthy() + expect(screen.getByText(/150\.00 USDC/)).toBeTruthy() + expect(screen.getByText(/from escrow/)).toBeTruthy() + expect(screen.queryByText(/into escrow/)).toBeNull() + expect(screen.getByText(/withdrawn, not priced/)).toBeTruthy() + }) + + it("tells the investor the cancellation is on its way, distinct from the subscribe copy", () => { + renderCancelModal({ status: "submitted", hash: "a".repeat(64) }) + + expect( + screen.getByRole("heading", { name: "Sending your cancellation" }), + ).toBeTruthy() + expect(screen.getByText(/on its way to the network/)).toBeTruthy() + }) + + it("shows the returned amount once confirmed, with no batch or pricing claim", () => { + renderCancelModal({ + status: "confirmed", + refundedAmount: 150_0000000n as Amount, + hash: "b".repeat(64), + }) + + expect( + screen.getByRole("heading", { name: "Request cancelled" }), + ).toBeTruthy() + expect(screen.getByText(/150\.00 USDC/)).toBeTruthy() + expect(screen.queryByText(/Batch/)).toBeNull() + expect(screen.queryByText(/attestation/)).toBeNull() + expect(screen.queryByText(/Epoch/)).toBeNull() + }) + + it("reports the amount the vault returned, not the one captured when the investor pressed cancel", () => { + renderCancelModal({ + status: "confirmed", + refundedAmount: 275_5000000n as Amount, + hash: "b".repeat(64), + }) + + expect(screen.getByText(/275\.50 USDC/)).toBeTruthy() + expect(screen.queryByText(/150\.00/)).toBeNull() + }) + + it("renders the refunded amount at full precision, not rounded to two fraction digits", () => { + renderCancelModal({ + status: "confirmed", + refundedAmount: 952380952n as Amount, + hash: "b".repeat(64), + }) + + expect(screen.getByText(/95\.2380952 USDC/)).toBeTruthy() + }) + + it("names the vault's own reason for a cancel-specific contract refusal, distinct from subscribe's codes", () => { + renderCancelModal({ + status: "failed", + failure: { kind: "contract-error", code: 6039 }, + }) + + expect( + screen.getByRole("heading", { name: "The vault refused this request" }), + ).toBeTruthy() + expect( + screen.getByText(/already been priced. Claim your shares instead/), + ).toBeTruthy() + }) + + it("names PriceAvailable distinctly from AlreadyPriced, without sending the investor to claim yet", () => { + renderCancelModal({ + status: "failed", + failure: { kind: "contract-error", code: 6041 }, + }) + + expect(screen.getByText(/A price is available/)).toBeTruthy() + expect(screen.getByText(/claimable once it is/)).toBeTruthy() + expect(screen.queryByText(/Claim your shares instead/)).toBeNull() + }) + + it("names RequestNotFound for a cancellation of a request that no longer exists", () => { + renderCancelModal({ + status: "failed", + failure: { kind: "contract-error", code: 6001 }, + }) + + expect(screen.getByText(/no longer exists to cancel/)).toBeTruthy() + }) + + it("names EpochNotFound as a request that could not be found", () => { + renderCancelModal({ + status: "failed", + failure: { kind: "contract-error", code: 6029 }, + }) + + expect(screen.getByText(/This request could not be found/)).toBeTruthy() + }) + + it("names EscrowNotCovered as a deposit the vault cannot return until it is funded again", () => { + renderCancelModal({ + status: "failed", + failure: { kind: "contract-error", code: 6076 }, + }) + + expect( + screen.getByRole("heading", { name: "The vault refused this request" }), + ).toBeTruthy() + expect( + screen.getByText(/You can cancel once it is funded again/), + ).toBeTruthy() + }) + + it("falls back to a generic refusal for a cancel error it does not recognize, without the raw code", () => { + renderCancelModal({ + status: "failed", + failure: { kind: "contract-error", code: 9999 }, + }) + + expect(screen.getByText(/contact support/)).toBeTruthy() + expect(screen.queryByText(/9999/)).toBeNull() + }) + + it("falls back to a generic refusal for 304 on a deposit cancellation, since only the share token's identity check can raise it", () => { + renderCancelModal({ + status: "failed", + failure: { kind: "contract-error", code: 304 }, + }) + + expect(screen.getByText(/contact support/)).toBeTruthy() + expect(screen.queryByText(/304/)).toBeNull() + expect(screen.queryByText(/shares cannot be returned/)).toBeNull() + }) + + it("reads a declined cancellation as a choice, not a failure, and offers to try again", () => { + const { onRetry } = renderCancelModal({ + status: "failed", + failure: { kind: "declined" }, + }) + + expect( + screen.getByRole("heading", { name: "You declined the request" }), + ).toBeTruthy() + + fireEvent.click(screen.getByRole("button", { name: "Try again" })) + expect(onRetry).toHaveBeenCalledTimes(1) + }) + + it("shows the same step progress machinery for a cancellation in flight", () => { + renderCancelModal({ status: "awaiting-signature" }) + + const steps = screen.getAllByRole("listitem") + expect(steps.map((step) => step.textContent)).toEqual([ + "Approved in your wallet, in progress", + "Sent to the network", + "Recorded", + ]) + }) + + it("is reachable as a dialog and dismissible by its close control", () => { + const { onClose } = renderCancelModal({ status: "awaiting-signature" }) + + fireEvent.click(screen.getByRole("button", { name: "Close" })) + expect(onClose).toHaveBeenCalledTimes(1) + }) +}) + +describe("TransactionModal, cancelling a redemption", () => { + it("opens with a preparing state naming the cancellation", () => { + renderCancelRedeemModal({ status: "preparing" }) + + expect( + screen.getByRole("heading", { name: "Preparing your cancellation" }), + ).toBeTruthy() + }) + + it("tells the investor their shares return to their wallet, honestly, without escrow or pricing language", () => { + renderCancelRedeemModal({ status: "awaiting-signature" }) + + expect( + screen.getByRole("heading", { name: "Confirm in your wallet" }), + ).toBeTruthy() + expect(screen.getByText(/100\.00 vUSDC/)).toBeTruthy() + expect(screen.getByText(/from escrow/)).toBeTruthy() + expect(screen.queryByText(/into escrow/)).toBeNull() + expect(screen.getByText(/withdrawn, not priced/)).toBeTruthy() + }) + + it("shows the returned shares, in the share ticker, once confirmed", () => { + renderCancelRedeemModal({ + status: "confirmed", + returnedShares: 100_0000000n as Amount, + surrendered: false, + hash: "b".repeat(64), + }) + + expect( + screen.getByRole("heading", { name: "Request cancelled" }), + ).toBeTruthy() + expect(screen.getByText(/100\.00 vUSDC/)).toBeTruthy() + expect(screen.queryByText(/Batch/)).toBeNull() + }) + + it("renders the returned shares at full precision, not rounded to two fraction digits", () => { + renderCancelRedeemModal({ + status: "confirmed", + returnedShares: 952380952n as Amount, + surrendered: false, + hash: "b".repeat(64), + }) + + expect(screen.getByText(/95\.2380952 vUSDC/)).toBeTruthy() + }) + + it("names the vault's own reason for a cancel-specific contract refusal, reusing the same table as cancelling a deposit", () => { + renderCancelRedeemModal({ + status: "failed", + failure: { kind: "contract-error", code: 6039 }, + }) + + expect( + screen.getByText(/already been priced. Claim your shares instead/), + ).toBeTruthy() + }) + + it("names NotAllowlisted as a delisted holder whose shares stay queued, pointing to the cash claim instead", () => { + renderCancelRedeemModal({ + status: "failed", + failure: { kind: "contract-error", code: 6068 }, + }) + + expect(screen.getByText(/not allowlisted/)).toBeTruthy() + expect(screen.getByText(/claim the USDC/i)).toBeTruthy() + }) + + it("names HolderNotEligible as shares the address cannot receive, pointing to the cash claim instead", () => { + renderCancelRedeemModal({ + status: "failed", + failure: { kind: "contract-error", code: 6073 }, + }) + + expect( + screen.getByText(/cannot receive these shares right now/), + ).toBeTruthy() + expect(screen.getByText(/claim the USDC/i)).toBeTruthy() + }) + + it("falls back to a generic refusal for 304 on a withdrawal cancellation, since the vault checks the holder itself", () => { + renderCancelRedeemModal({ + status: "failed", + failure: { kind: "contract-error", code: 304 }, + }) + + expect(screen.getByText(/contact support/)).toBeTruthy() + expect(screen.queryByText(/allowlisted/)).toBeNull() + }) + + it("falls back to a generic refusal for a cancel-redeem error it does not recognize, without the raw code", () => { + renderCancelRedeemModal({ + status: "failed", + failure: { kind: "contract-error", code: 9999 }, + }) + + expect(screen.getByText(/contact support/)).toBeTruthy() + expect(screen.queryByText(/9999/)).toBeNull() + }) + + it("is reachable as a dialog and dismissible by its close control", () => { + const { onClose } = renderCancelRedeemModal({ + status: "awaiting-signature", + }) + + fireEvent.click(screen.getByRole("button", { name: "Close" })) + expect(onClose).toHaveBeenCalledTimes(1) + }) +}) + +describe("TransactionModal, claiming", () => { + it("opens with a preparing state naming the claim", () => { + renderClaimModal({ status: "preparing" }) + + expect( + screen.getByRole("heading", { name: "Preparing your claim" }), + ).toBeTruthy() + }) + + it("tells the investor what claiming means before they sign, including the refund path, without naming an epoch", () => { + renderClaimModal({ status: "awaiting-signature" }) + + expect( + screen.getByRole("heading", { name: "Confirm in your wallet" }), + ).toBeTruthy() + expect( + screen.getByText( + "You receive what you're owed. If the price leaves no shares to claim, 150.00 USDC returns to your wallet instead.", + ), + ).toBeTruthy() + expect(screen.queryByText(/Epoch/)).toBeNull() + expect(screen.queryByText(/epoch/)).toBeNull() + }) + + it("tells the investor the claim is on its way, distinct from subscribe and cancel copy", () => { + renderClaimModal({ status: "submitted", hash: "a".repeat(64) }) + + expect( + screen.getByRole("heading", { name: "Sending your claim" }), + ).toBeTruthy() + expect(screen.getByText(/on its way to the network/)).toBeTruthy() + }) + + it("shows the shares minted in the share token, not the deposit token, once confirmed", () => { + renderClaimModal({ + status: "confirmed", + sharesMinted: 100_0000000n as Amount, + hash: "b".repeat(64), + }) + + expect(screen.getByRole("heading", { name: "Shares claimed" })).toBeTruthy() + expect(screen.getByText(/100\.00 vUSDC/)).toBeTruthy() + expect(screen.queryByText(/150\.00 USDC/)).toBeNull() + }) + + it("renders the shares minted at full precision, not rounded to two fraction digits", () => { + renderClaimModal({ + status: "confirmed", + sharesMinted: 952380952n as Amount, + hash: "b".repeat(64), + }) + + expect(screen.getByText(/95\.2380952 vUSDC/)).toBeTruthy() + }) + + it("tells the investor their deposit came back, not that shares were issued, on a zero result", () => { + renderClaimModal({ + status: "confirmed", + sharesMinted: 0n as Amount, + hash: "b".repeat(64), + }) + + expect( + screen.getByRole("heading", { name: "Deposit returned" }), + ).toBeTruthy() + expect(screen.getByText(/150\.00 USDC/)).toBeTruthy() + expect(screen.queryByText(/vUSDC/)).toBeNull() + }) + + it("names RequestNotFound for a claim with no matching request", () => { + renderClaimModal({ + status: "failed", + failure: { kind: "contract-error", code: 6001 }, + }) + + expect(screen.getByText(/no request to claim/)).toBeTruthy() + }) + + it("names EpochNotFound as a request that could not be found", () => { + renderClaimModal({ + status: "failed", + failure: { kind: "contract-error", code: 6029 }, + }) + + expect(screen.getByText(/This request could not be found/)).toBeTruthy() + }) + + it("names InvalidSharePrice as no valid price published yet", () => { + renderClaimModal({ + status: "failed", + failure: { kind: "contract-error", code: 6031 }, + }) + + expect(screen.getByText(/not published a valid price/)).toBeTruthy() + }) + + it("names AlreadyClaimed distinctly", () => { + renderClaimModal({ + status: "failed", + failure: { kind: "contract-error", code: 6035 }, + }) + + expect(screen.getByText(/already been claimed/)).toBeTruthy() + }) + + it("names ClaimNotCovered as the reserve not yet covering this claim", () => { + renderClaimModal({ + status: "failed", + failure: { kind: "contract-error", code: 6037 }, + }) + + expect(screen.getByText(/not yet hold enough in reserve/)).toBeTruthy() + }) + + it("names AmountTooLarge as the conversion overflowing", () => { + renderClaimModal({ + status: "failed", + failure: { kind: "contract-error", code: 6014 }, + }) + + expect(screen.getByText(/too large to complete/)).toBeTruthy() + }) + + it("falls back to a generic refusal for 304 on a share claim, since a delisted holder's shares join an exit instead", () => { + renderClaimModal({ + status: "failed", + failure: { kind: "contract-error", code: 304 }, + }) + + expect(screen.getByText(/contact support/)).toBeTruthy() + expect(screen.queryByText(/allowlisted/)).toBeNull() + }) + + it("falls back to a generic refusal for a claim error it does not recognize, without the raw code", () => { + renderClaimModal({ + status: "failed", + failure: { kind: "contract-error", code: 9999 }, + }) + + expect(screen.getByText(/contact support/)).toBeTruthy() + expect(screen.queryByText(/9999/)).toBeNull() + }) + + it("reads a declined claim as a choice, not a failure, and offers to try again", () => { + const { onRetry } = renderClaimModal({ + status: "failed", + failure: { kind: "declined" }, + }) + + expect( + screen.getByRole("heading", { name: "You declined the request" }), + ).toBeTruthy() + + fireEvent.click(screen.getByRole("button", { name: "Try again" })) + expect(onRetry).toHaveBeenCalledTimes(1) + }) + + it("shows the same step progress machinery for a claim in flight", () => { + renderClaimModal({ status: "awaiting-signature" }) + + const steps = screen.getAllByRole("listitem") + expect(steps.map((step) => step.textContent)).toEqual([ + "Approved in your wallet, in progress", + "Sent to the network", + "Recorded", + ]) + }) + + it("is reachable as a dialog and dismissible by its close control", () => { + const { onClose } = renderClaimModal({ status: "awaiting-signature" }) + + fireEvent.click(screen.getByRole("button", { name: "Close" })) + expect(onClose).toHaveBeenCalledTimes(1) + }) +}) + +describe("TransactionModal, redeeming", () => { + it("opens with a preparing state naming the redemption request", () => { + renderRedeemModal({ status: "preparing" }) + + expect( + screen.getByRole("heading", { + name: "Preparing your redemption request", + }), + ).toBeTruthy() + }) + + it("tells the investor their shares are locked in escrow, in share terms, without naming an epoch", () => { + renderRedeemModal({ status: "awaiting-signature" }) + + expect( + screen.getByRole("heading", { name: "Confirm in your wallet" }), + ).toBeTruthy() + expect(screen.getByText(/100\.00 vUSDC/)).toBeTruthy() + expect(screen.getByText(/in escrow/)).toBeTruthy() + expect(screen.getByText(/not today/)).toBeTruthy() + expect(screen.queryByText(/Epoch/)).toBeNull() + expect(screen.queryByText(/epoch/)).toBeNull() + expect(screen.queryByText(/batch/i)).toBeNull() + }) + + it("tells the investor the redemption request is on its way, distinct from subscribe copy", () => { + renderRedeemModal({ status: "submitted", hash: "a".repeat(64) }) + + expect( + screen.getByRole("heading", { name: "Sending your redemption request" }), + ).toBeTruthy() + expect(screen.getByText(/on its way to the network/)).toBeTruthy() + }) + + it("shows the confirmed shares and transaction hash once confirmed", () => { + renderRedeemModal({ + status: "confirmed", + hash: "b".repeat(64), + }) + + expect( + screen.getByRole("heading", { name: "Redemption request submitted" }), + ).toBeTruthy() + expect(screen.getByText(/100\.00 vUSDC/)).toBeTruthy() + expect(screen.queryByText(/Batch/)).toBeNull() + expect(screen.getByText(/bbbb\.\.\.bbbb/)).toBeTruthy() + }) + + it("never mentions locking or attestation once confirmed", () => { + renderRedeemModal({ status: "confirmed", hash: "b".repeat(64) }) + + expect(screen.queryByText(/locked/i)).toBeNull() + expect(screen.queryByText(/attestation/i)).toBeNull() + }) + + it("names InvalidAmount for a non-positive share amount", () => { + renderRedeemModal({ + status: "failed", + failure: { kind: "contract-error", code: 6007 }, + }) + + expect(screen.getByText(/amount greater than zero/)).toBeTruthy() + }) + + it("names RequestOutstanding as a redemption request already open, distinct from subscribe's wording", () => { + renderRedeemModal({ + status: "failed", + failure: { kind: "contract-error", code: 6009 }, + }) + + expect( + screen.getByText(/already have a redemption request open/), + ).toBeTruthy() + }) + + it("names AmountTooLarge as too large for the vault to accept", () => { + renderRedeemModal({ + status: "failed", + failure: { kind: "contract-error", code: 6014 }, + }) + + expect(screen.getByText(/too large for the vault to accept/)).toBeTruthy() + }) + + it("names EpochNotFound as a request that could not be found", () => { + renderRedeemModal({ + status: "failed", + failure: { kind: "contract-error", code: 6029 }, + }) + + expect(screen.getByText(/This request could not be found/)).toBeTruthy() + }) + + it("names WindDownActive as the vault not accepting new redemptions", () => { + renderRedeemModal({ + status: "failed", + failure: { kind: "contract-error", code: 6046 }, + }) + + expect(screen.getByText(/not accepting new redemptions/)).toBeTruthy() + }) + + it("names SetupOpen as redemptions starting once setup closes", () => { + renderRedeemModal({ + status: "failed", + failure: { kind: "contract-error", code: 6064 }, + }) + + expect(screen.getByText(/setup is still open/)).toBeTruthy() + expect(screen.getByText(/Redemptions start once it closes/)).toBeTruthy() + }) + + it("falls back to a generic refusal for a redeem error it does not recognize, without the raw code", () => { + renderRedeemModal({ + status: "failed", + failure: { kind: "contract-error", code: 9999 }, + }) + + expect(screen.getByText(/contact support/)).toBeTruthy() + expect(screen.queryByText(/9999/)).toBeNull() + }) + + it("reads a declined redemption request as a choice, not a failure, and offers to try again", () => { + const { onRetry } = renderRedeemModal({ + status: "failed", + failure: { kind: "declined" }, + }) + + expect( + screen.getByRole("heading", { name: "You declined the request" }), + ).toBeTruthy() + + fireEvent.click(screen.getByRole("button", { name: "Try again" })) + expect(onRetry).toHaveBeenCalledTimes(1) + }) + + it("shows the same step progress machinery for a redemption request in flight", () => { + renderRedeemModal({ status: "awaiting-signature" }) + + const steps = screen.getAllByRole("listitem") + expect(steps.map((step) => step.textContent)).toEqual([ + "Approved in your wallet, in progress", + "Sent to the network", + "Recorded", + ]) + }) + + it("is reachable as a dialog and dismissible by its close control", () => { + const { onClose } = renderRedeemModal({ status: "awaiting-signature" }) + + fireEvent.click(screen.getByRole("button", { name: "Close" })) + expect(onClose).toHaveBeenCalledTimes(1) + }) +}) + +describe("TransactionModal, claiming a redemption", () => { + it("opens with a preparing state naming the claim", () => { + renderClaimRedeemModal({ status: "preparing" }) + + expect( + screen.getByRole("heading", { name: "Preparing your claim" }), + ).toBeTruthy() + }) + + it("tells the investor what claiming sends them, without naming an epoch", () => { + renderClaimRedeemModal({ status: "awaiting-signature" }) + + expect( + screen.getByRole("heading", { name: "Confirm in your wallet" }), + ).toBeTruthy() + expect( + screen.getByText( + "You receive what you're owed. Signing sends 100.00 USDC to your wallet.", + ), + ).toBeTruthy() + expect(screen.queryByText(/Epoch/)).toBeNull() + expect(screen.queryByText(/epoch/)).toBeNull() + }) + + it("tells the investor the claim is on its way to the network", () => { + renderClaimRedeemModal({ status: "submitted", hash: "a".repeat(64) }) + + expect( + screen.getByRole("heading", { name: "Sending your claim" }), + ).toBeTruthy() + expect(screen.getByText(/on its way to the network/)).toBeTruthy() + }) + + it("shows the claimed asset, in its own ticker, once confirmed", () => { + renderClaimRedeemModal({ + status: "confirmed", + assetsClaimed: 150_0000000n as Amount, + sharesReturned: false, + hash: "b".repeat(64), + }) + + expect(screen.getByRole("heading", { name: "USDC claimed" })).toBeTruthy() + expect(screen.getByText(/150\.00 USDC/)).toBeTruthy() + }) + + it("renders the claimed asset amount at full precision, not rounded to two fraction digits", () => { + renderClaimRedeemModal({ + status: "confirmed", + assetsClaimed: 952380952n as Amount, + sharesReturned: false, + hash: "b".repeat(64), + }) + + expect(screen.getByText(/95\.2380952 USDC/)).toBeTruthy() + }) + + it("tells the investor their shares came back, not that an asset was claimed, on a zero result", () => { + renderClaimRedeemModal({ + status: "confirmed", + assetsClaimed: 0n as Amount, + sharesReturned: true, + hash: "b".repeat(64), + }) + + expect( + screen.getByRole("heading", { name: "Shares returned" }), + ).toBeTruthy() + expect( + screen.getByText( + "The price left nothing to claim, so your shares have been returned to your wallet instead.", + ), + ).toBeTruthy() + expect(screen.queryByText(/USDC claimed/)).toBeNull() + }) + + it("names EpochNotFulfilled as not yet priced", () => { + renderClaimRedeemModal({ + status: "failed", + failure: { kind: "contract-error", code: 6034 }, + }) + + expect(screen.getByText(/has not priced yet/)).toBeTruthy() + }) + + it("names ClaimNotCovered with the deposit asset's ticker, distinct from the untickered claim wording for the same code", () => { + renderClaimRedeemModal({ + status: "failed", + failure: { kind: "contract-error", code: 6037 }, + }) + + expect(screen.getByText(/enough USDC in reserve/)).toBeTruthy() + }) + + it("names HolderNotEligible as the zero-price return the address cannot receive, in the same words as a withdrawal cancellation", () => { + renderClaimRedeemModal({ + status: "failed", + failure: { kind: "contract-error", code: 6073 }, + }) + + expect(screen.getByText(/tried to return your shares/)).toBeTruthy() + expect( + screen.getByText(/cannot receive these shares right now/), + ).toBeTruthy() + expect(screen.queryByText(/claim the USDC/i)).toBeNull() + }) + + it("falls back to a generic refusal for 304 on a withdrawal claim, since the vault checks the holder itself", () => { + renderClaimRedeemModal({ + status: "failed", + failure: { kind: "contract-error", code: 304 }, + }) + + expect(screen.getByText(/contact support/)).toBeTruthy() + expect(screen.queryByText(/allowlisted/)).toBeNull() + }) + + it("names AlreadyClaimed by delegating to the shared claim wording", () => { + renderClaimRedeemModal({ + status: "failed", + failure: { kind: "contract-error", code: 6035 }, + }) + + expect(screen.getByText(/already been claimed/)).toBeTruthy() + }) + + it("falls back to a generic refusal for a claim-redeem error it does not recognize, without the raw code", () => { + renderClaimRedeemModal({ + status: "failed", + failure: { kind: "contract-error", code: 9999 }, + }) + + expect(screen.getByText(/contact support/)).toBeTruthy() + expect(screen.queryByText(/9999/)).toBeNull() + }) + + it("reads a declined claim as a choice, not a failure, and offers to try again", () => { + const { onRetry } = renderClaimRedeemModal({ + status: "failed", + failure: { kind: "declined" }, + }) + + expect( + screen.getByRole("heading", { name: "You declined the request" }), + ).toBeTruthy() + + fireEvent.click(screen.getByRole("button", { name: "Try again" })) + expect(onRetry).toHaveBeenCalledTimes(1) + }) + + it("shows the same step progress machinery for a claim in flight", () => { + renderClaimRedeemModal({ status: "awaiting-signature" }) + + const steps = screen.getAllByRole("listitem") + expect(steps.map((step) => step.textContent)).toEqual([ + "Approved in your wallet, in progress", + "Sent to the network", + "Recorded", + ]) + }) + + it("is reachable as a dialog and dismissible by its close control", () => { + const { onClose } = renderClaimRedeemModal({ + status: "awaiting-signature", + }) + + fireEvent.click(screen.getByRole("button", { name: "Close" })) + expect(onClose).toHaveBeenCalledTimes(1) + }) +}) + +describe("TransactionModal, closing an epoch", () => { + it("opens with a preparing state naming the epoch being closed", () => { + renderCloseEpochModal({ status: "preparing" }) + + expect( + screen.getByRole("heading", { name: "Preparing to close epoch 5" }), + ).toBeTruthy() + }) + + it("tells the manager what signing seals and what it opens", () => { + renderCloseEpochModal({ status: "awaiting-signature" }) + + expect( + screen.getByRole("heading", { name: "Confirm in your wallet" }), + ).toBeTruthy() + expect( + screen.getByText( + "Signing seals epoch 5 and opens the next one. Requests in epoch 5 wait for a price.", + ), + ).toBeTruthy() + }) + + it.each(["", "unknown"])( + "renders the signing prompt without throwing when the label is %j", + (epoch) => { + expect(() => + renderCloseEpochModal({ status: "awaiting-signature" }, epoch), + ).not.toThrow() + expect( + screen.getByRole("heading", { name: "Confirm in your wallet" }), + ).toBeTruthy() + }, + ) + + it("tells the manager the close is on its way", () => { + renderCloseEpochModal({ status: "submitted", hash: "a".repeat(64) }) + + expect( + screen.getByRole("heading", { name: "Sending the close" }), + ).toBeTruthy() + expect(screen.getByText(/on its way to the network/)).toBeTruthy() + }) + + it("names the epoch the vault sealed once confirmed, taking the id from the result rather than the label", () => { + renderCloseEpochModal({ + status: "confirmed", + sealedEpoch: 7n, + hash: "b".repeat(64), + }) + + expect(screen.getByRole("heading", { name: "Epoch 7 sealed" })).toBeTruthy() + expect( + screen.getByText( + "Epoch 8 is open and accepting requests. Epoch 7 prices once the notice elapses and a valid price is attested.", + ), + ).toBeTruthy() + }) + + it.each<[number, RegExp]>([ + [6046, /winding down/], + [6029, /current epoch could not be found/], + [6032, /not open/], + [2000, /not authorized to close/], + [2007, /does not hold the manager role/], + [6077, /Only the manager can seal this epoch/], + [6064, /setup is still open, so no epoch can be sealed/], + ])("names the vault's reason for code %i", (code, sentence) => { + renderCloseEpochModal({ + status: "failed", + failure: { kind: "contract-error", code }, + }) + + expect( + screen.getByRole("heading", { name: "The vault refused this request" }), + ).toBeTruthy() + expect(screen.getByText(sentence)).toBeTruthy() + }) + + it("falls back to a generic refusal for a close error it does not recognize, without the raw code", () => { + renderCloseEpochModal({ + status: "failed", + failure: { kind: "contract-error", code: 6001 }, + }) + + expect( + screen.getByText(/The vault refused this request\. Try again/), + ).toBeTruthy() + expect(screen.queryByText(/6001/)).toBeNull() + }) + + it("sends an unknown outcome to the Cycle card, not to the investor's requests", () => { + renderCloseEpochModal({ + status: "failed", + failure: { kind: "unknown" }, + }) + + expect( + screen.getByRole("heading", { name: "Something went wrong" }), + ).toBeTruthy() + expect( + screen.getByText( + "We could not confirm whether this reached the network. We don't yet know if it went through, so check the Cycle card before trying again.", + ), + ).toBeTruthy() + expect(screen.queryByText(/your requests/)).toBeNull() + }) + + it("reads a declined signature as a choice and offers to try again", () => { + const { onRetry } = renderCloseEpochModal({ + status: "failed", + failure: { kind: "declined" }, + }) + + fireEvent.click(screen.getByRole("button", { name: "Try again" })) + + expect(onRetry).toHaveBeenCalledTimes(1) + }) +}) + +describe("TransactionModal, pricing an epoch", () => { + it("opens with a preparing state naming the epoch being priced", () => { + renderFulfillEpochModal({ status: "preparing" }) + + expect( + screen.getByRole("heading", { name: "Preparing to price epoch 4" }), + ).toBeTruthy() + }) + + it("tells the operator what signing prices and settles", () => { + renderFulfillEpochModal({ status: "awaiting-signature" }) + + expect( + screen.getByRole("heading", { name: "Confirm in your wallet" }), + ).toBeTruthy() + expect( + screen.getByText( + "Signing prices epoch 4 at the oracle's current price and settles its requests.", + ), + ).toBeTruthy() + }) + + it("tells the operator the settlement is on its way", () => { + renderFulfillEpochModal({ status: "submitted", hash: "a".repeat(64) }) + + expect( + screen.getByRole("heading", { name: "Sending the settlement" }), + ).toBeTruthy() + expect(screen.getByText(/on its way to the network/)).toBeTruthy() + }) + + it("shows the share price the vault settled at once confirmed, to four places", () => { + renderFulfillEpochModal({ + status: "confirmed", + sharePrice: 1_250_000_000_000_000_000n as Price, + hash: "b".repeat(64), + }) + + expect(screen.getByRole("heading", { name: "Epoch 4 priced" })).toBeTruthy() + expect( + screen.getByText( + "Settled at 1.2500 per share. Share claims are ready; redemption claims wait until the reserve covers them.", + ), + ).toBeTruthy() + }) + + it.each<[number, RegExp]>([ + [6046, /winding down/], + [6029, /epoch could not be found/], + [6038, /not sealed/], + [6042, /notice period has not elapsed/], + [6044, /price is not valid/], + [6043, /attested before this epoch was sealed/], + [6031, /could not resolve a valid share price/], + [6014, /too large/], + [1000, /paused/], + [6064, /setup is still open, so no epoch can be priced/], + ])("names the vault's reason for code %i", (code, sentence) => { + renderFulfillEpochModal({ + status: "failed", + failure: { kind: "contract-error", code }, + }) + + expect(screen.getByText(sentence)).toBeTruthy() + }) + + it("falls back to a generic refusal for a settlement error it does not recognize, without the raw code", () => { + renderFulfillEpochModal({ + status: "failed", + failure: { kind: "contract-error", code: 6035 }, + }) + + expect( + screen.getByText(/The vault refused this request\. Try again/), + ).toBeTruthy() + expect(screen.queryByText(/6035/)).toBeNull() + }) + + it("sends an unknown outcome to the Cycle card, not to the investor's requests", () => { + renderFulfillEpochModal({ + status: "failed", + failure: { kind: "unknown" }, + hash: "c".repeat(64), + }) + + expect( + screen.getByText(/check the Cycle card before trying again/), + ).toBeTruthy() + expect(screen.queryByText(/your requests/)).toBeNull() + expect(screen.getByText(/cccc\.\.\.cccc/)).toBeTruthy() + }) + + it("is reachable as a dialog and dismissible by its close control", () => { + const { onClose } = renderFulfillEpochModal({ status: "preparing" }) + + expect(screen.getByRole("dialog")).toBeTruthy() + fireEvent.click(screen.getByRole("button", { name: "Close" })) + expect(onClose).toHaveBeenCalledTimes(1) + }) +}) + +describe("TransactionModal, attesting a price", () => { + it("opens with a preparing state naming the price being attested", () => { + renderAttestModal({ status: "preparing" }) + + expect( + screen.getByRole("heading", { name: "Preparing to attest 1.0400" }), + ).toBeTruthy() + }) + + it("tells the attester what signing records", () => { + renderAttestModal({ status: "awaiting-signature" }) + + expect( + screen.getByRole("heading", { name: "Confirm in your wallet" }), + ).toBeTruthy() + expect( + screen.getByText( + "Signing records 1.0400 as the share price, with its proof. Sealed epochs settle at it once their notice elapses.", + ), + ).toBeTruthy() + }) + + it("tells the attester the attestation is on its way", () => { + renderAttestModal({ status: "submitted", hash: "a".repeat(64) }) + + expect( + screen.getByRole("heading", { name: "Sending the attestation" }), + ).toBeTruthy() + expect(screen.getByText(/on its way to the network/)).toBeTruthy() + }) + + it("says how long the price stays valid once confirmed", () => { + renderAttestModal({ status: "confirmed", hash: "b".repeat(64) }) + + expect(screen.getByRole("heading", { name: "Price attested" })).toBeTruthy() + expect( + screen.getByText("1.0400 is the share price. Valid for 1d."), + ).toBeTruthy() + }) + + it("leaves the validity out when the freshness window is not known", () => { + renderAttestModal({ status: "confirmed" }, null) + + expect(screen.getByText("1.0400 is the share price.")).toBeTruthy() + }) + + it.each<[number, RegExp]>([ + [3002, /outside the band/], + [3005, /already have expired/], + [3003, /cooldown .* has not elapsed/], + [3004, /moves further from the last attested price/], + [3010, /^Unpause the NAV first\.$/], + [3023, /^The proof hash cannot be all zeros\.$/], + [3024, /^The proof URI is longer than 256 bytes\.$/], + ])("names the oracle's reason for code %i", (code, sentence) => { + renderAttestModal({ + status: "failed", + failure: { kind: "contract-error", code }, + }) + + expect( + screen.getByRole("heading", { name: "The vault refused this request" }), + ).toBeTruthy() + expect(screen.getByText(sentence)).toBeTruthy() + }) + + it.each([2000, 2007])( + "falls back to a generic refusal for code %i, without the raw code", + (code) => { + renderAttestModal({ + status: "failed", + failure: { kind: "contract-error", code }, + }) + + expect( + screen.getByText(/The vault refused this request\. Try again/), + ).toBeTruthy() + expect(screen.queryByText(new RegExp(String(code)))).toBeNull() + }, + ) + + it("sends an unknown outcome to the Cycle card, not to the investor's requests", () => { + renderAttestModal({ status: "failed", failure: { kind: "unknown" } }) + + expect( + screen.getByText(/check the Cycle card before trying again/), + ).toBeTruthy() + expect(screen.queryByText(/your requests/)).toBeNull() + }) + + it("is reachable as a dialog and dismissible by its close control", () => { + const { onClose } = renderAttestModal({ status: "preparing" }) + + expect(screen.getByRole("dialog")).toBeTruthy() + fireEvent.click(screen.getByRole("button", { name: "Close" })) + expect(onClose).toHaveBeenCalledTimes(1) + }) +}) + +describe("TransactionModal, deploying to the custodian", () => { + it("opens with a preparing state naming the amount being deployed", () => { + renderDeployModal({ status: "preparing" }) + + expect( + screen.getByRole("heading", { name: "Preparing to deploy 250.00" }), + ).toBeTruthy() + }) + + it("tells the treasury what signing sends and where", () => { + renderDeployModal({ status: "awaiting-signature" }) + + expect( + screen.getByRole("heading", { name: "Confirm in your wallet" }), + ).toBeTruthy() + expect( + screen.getByText( + "Signing sends 250.00 from the vault's reserve to the custodian.", + ), + ).toBeTruthy() + }) + + it("tells the treasury the deployment is on its way", () => { + renderDeployModal({ status: "submitted", hash: "a".repeat(64) }) + + expect( + screen.getByRole("heading", { name: "Sending the deployment" }), + ).toBeTruthy() + expect(screen.getByText(/on its way to the network/)).toBeTruthy() + }) + + it("says what left the vault once confirmed", () => { + renderDeployModal({ status: "confirmed", hash: "b".repeat(64) }) + + expect(screen.getByRole("heading", { name: "Deployed" })).toBeTruthy() + expect( + screen.getByText("250.00 left the vault for the custodian."), + ).toBeTruthy() + }) + + it.each<[number, RegExp]>([ + [6046, /winding down/], + [6007, /greater than zero/], + [6012, /No custodian is set/], + [6005, /free reserve/], + [6014, /too large/], + [2000, /not authorized/], + [2007, /treasury role/], + ])("names the vault's reason for code %i", (code, sentence) => { + renderDeployModal({ + status: "failed", + failure: { kind: "contract-error", code }, + }) + + expect( + screen.getByRole("heading", { name: "The vault refused this request" }), + ).toBeTruthy() + expect(screen.getByText(sentence)).toBeTruthy() + }) + + it("falls back to a generic refusal for a code it does not recognize, without the raw code", () => { + renderDeployModal({ + status: "failed", + failure: { kind: "contract-error", code: 6029 }, + }) + + expect( + screen.getByText(/The vault refused this request\. Try again/), + ).toBeTruthy() + expect(screen.queryByText(/6029/)).toBeNull() + }) + + it("sends an unknown outcome to the Cycle card, not to the investor's requests", () => { + renderDeployModal({ status: "failed", failure: { kind: "unknown" } }) + + expect( + screen.getByText(/check the Cycle card before trying again/), + ).toBeTruthy() + expect(screen.queryByText(/your requests/)).toBeNull() + }) + + it("is reachable as a dialog and dismissible by its close control", () => { + const { onClose } = renderDeployModal({ status: "preparing" }) + + expect(screen.getByRole("dialog")).toBeTruthy() + fireEvent.click(screen.getByRole("button", { name: "Close" })) + expect(onClose).toHaveBeenCalledTimes(1) + }) +}) + +describe("TransactionModal, funding the reserve", () => { + it("opens with a preparing state naming the amount being funded", () => { + renderFundModal({ status: "preparing" }) + + expect( + screen.getByRole("heading", { name: "Preparing to fund 100.00" }), + ).toBeTruthy() + }) + + it("tells the funder what signing moves out of their wallet", () => { + renderFundModal({ status: "awaiting-signature" }) + + expect( + screen.getByRole("heading", { name: "Confirm in your wallet" }), + ).toBeTruthy() + expect( + screen.getByText( + "Signing moves 100.00 from your wallet into the vault's reserve.", + ), + ).toBeTruthy() + }) + + it("tells the funder the funding is on its way", () => { + renderFundModal({ status: "submitted", hash: "a".repeat(64) }) + + expect( + screen.getByRole("heading", { name: "Sending the funding" }), + ).toBeTruthy() + expect(screen.getByText(/on its way to the network/)).toBeTruthy() + }) + + it("says what joined the reserve once confirmed", () => { + renderFundModal({ status: "confirmed", hash: "b".repeat(64) }) + + expect(screen.getByRole("heading", { name: "Funded" })).toBeTruthy() + expect(screen.getByText("100.00 joined the reserve.")).toBeTruthy() + }) + + it.each<[number, RegExp]>([ + [6007, /greater than zero/], + [6014, /too large/], + ])("names the vault's reason for code %i", (code, sentence) => { + renderFundModal({ + status: "failed", + failure: { kind: "contract-error", code }, + }) + + expect( + screen.getByRole("heading", { name: "The vault refused this request" }), + ).toBeTruthy() + expect(screen.getByText(sentence)).toBeTruthy() + }) + + it("points the funder at their balance and trustline when the asset transfer refuses, without the raw code", () => { + renderFundModal({ + status: "failed", + failure: { kind: "contract-error", code: 10 }, + }) + + expect( + screen.getByText( + "Your wallet could not cover the transfer. Check the balance and the trustline.", + ), + ).toBeTruthy() + expect(screen.queryByText(/\b10\b/)).toBeNull() + }) + + it("sends an unknown outcome to the Cycle card, not to the investor's requests", () => { + renderFundModal({ status: "failed", failure: { kind: "unknown" } }) + + expect( + screen.getByText(/check the Cycle card before trying again/), + ).toBeTruthy() + expect(screen.queryByText(/your requests/)).toBeNull() + }) + + it("is reachable as a dialog and dismissible by its close control", () => { + const { onClose } = renderFundModal({ status: "preparing" }) + + expect(screen.getByRole("dialog")).toBeTruthy() + fireEvent.click(screen.getByRole("button", { name: "Close" })) + expect(onClose).toHaveBeenCalledTimes(1) + }) +}) + +describe("TransactionModal, activating the wind-down", () => { + it("opens with a preparing state", () => { + renderActivateModal({ status: "preparing" }) + + expect( + screen.getByRole("heading", { + name: "Preparing to activate the wind-down", + }), + ).toBeTruthy() + }) + + it("tells the signer what the activation stops and opens", () => { + renderActivateModal({ status: "awaiting-signature" }) + + expect( + screen.getByRole("heading", { name: "Confirm in your wallet" }), + ).toBeTruthy() + expect( + screen.getByText( + "Signing stops new requests and opens distribution rounds.", + ), + ).toBeTruthy() + }) + + it("tells the signer the activation is on its way", () => { + renderActivateModal({ status: "submitted", hash: "a".repeat(64) }) + + expect( + screen.getByRole("heading", { name: "Sending the activation" }), + ).toBeTruthy() + expect(screen.getByText(/on its way to the network/)).toBeTruthy() + }) + + it("says requests are closed and rounds can distribute once confirmed", () => { + renderActivateModal({ status: "confirmed", hash: "b".repeat(64) }) + + expect( + screen.getByRole("heading", { name: "Wind-down active" }), + ).toBeTruthy() + expect( + screen.getByText( + "New requests are closed. Rounds can now distribute the free reserve.", + ), + ).toBeTruthy() + }) + + it.each<[number, RegExp]>([ + [6047, /No wind-down has been proposed/], + [6046, /already active/], + [6049, /delay has not elapsed/], + ])("names the vault's reason for code %i", (code, sentence) => { + renderActivateModal({ + status: "failed", + failure: { kind: "contract-error", code }, + }) + + expect( + screen.getByRole("heading", { name: "The vault refused this request" }), + ).toBeTruthy() + expect(screen.getByText(sentence)).toBeTruthy() + }) + + it("falls back to a generic refusal for a code it does not recognize, without the raw code", () => { + renderActivateModal({ + status: "failed", + failure: { kind: "contract-error", code: 6050 }, + }) + + expect( + screen.getByText(/The vault refused this request\. Try again/), + ).toBeTruthy() + expect(screen.queryByText(/6050/)).toBeNull() + }) + + it("sends an unknown outcome to the Cycle card, not to the investor's requests", () => { + renderActivateModal({ status: "failed", failure: { kind: "unknown" } }) + + expect( + screen.getByText(/check the Cycle card before trying again/), + ).toBeTruthy() + expect(screen.queryByText(/your requests/)).toBeNull() + }) +}) + +describe("TransactionModal, finalizing a wind-down round", () => { + it("opens with a preparing state naming the round", () => { + renderFinalizeModal({ status: "preparing" }) + + expect( + screen.getByRole("heading", { name: "Preparing round 3" }), + ).toBeTruthy() + }) + + it("tells the signer what the round distributes", () => { + renderFinalizeModal({ status: "awaiting-signature" }) + + expect( + screen.getByRole("heading", { name: "Confirm in your wallet" }), + ).toBeTruthy() + expect( + screen.getByText( + "Signing distributes the free reserve to holders as round 3.", + ), + ).toBeTruthy() + }) + + it("tells the signer the round is on its way", () => { + renderFinalizeModal({ status: "submitted", hash: "a".repeat(64) }) + + expect( + screen.getByRole("heading", { name: "Sending the round" }), + ).toBeTruthy() + expect(screen.getByText(/on its way to the network/)).toBeTruthy() + }) + + it("says what holders can claim once confirmed, to the last digit", () => { + renderFinalizeModal({ + status: "confirmed", + hash: "b".repeat(64), + credited: 9_000_000_001n as Amount, + }) + + expect( + screen.getByRole("heading", { name: "Round 3 finalized" }), + ).toBeTruthy() + expect( + screen.getByText("900.0000001 is claimable by holders."), + ).toBeTruthy() + }) + + it.each<[number, RegExp]>([ + [6050, /not active/], + [6052, /nothing to distribute/], + [6014, /too large/], + ])("names the vault's reason for code %i", (code, sentence) => { + renderFinalizeModal({ + status: "failed", + failure: { kind: "contract-error", code }, + }) + + expect( + screen.getByRole("heading", { name: "The vault refused this request" }), + ).toBeTruthy() + expect(screen.getByText(sentence)).toBeTruthy() + }) + + it("sends an unknown outcome to the Cycle card, not to the investor's requests", () => { + renderFinalizeModal({ status: "failed", failure: { kind: "unknown" } }) + + expect( + screen.getByText(/check the Cycle card before trying again/), + ).toBeTruthy() + expect(screen.queryByText(/your requests/)).toBeNull() + }) +}) + +const renderEmergencyModal = ( + action: EmergencyModalAction, + status: TransactionStatus, +) => + render( + , + ) + +describe("TransactionModal, emergency actions", () => { + it("shows the signatures collected, the expiry and the signing link to copy", async () => { + const writeText = vi.fn().mockResolvedValue(undefined) + Object.defineProperty(navigator, "clipboard", { + value: { writeText }, + configurable: true, + }) + const expiresAt = new Date(Date.UTC(2026, 9, 22)) + renderEmergencyModal("pause-vault", { + status: "shared", + xdr: "AAAAXDR", + standing: { needs: 2 }, + expiresAt, + }) + const link = `${window.location.origin}/sign#AAAAXDR` + + expect( + screen.getByRole("heading", { name: "1 of 2 signatures" }), + ).toBeTruthy() + expect( + screen.getByText( + `Share the link with the next signer. Valid until ${formatDate(BigInt(expiresAt.getTime() / 1000))}.`, + ), + ).toBeTruthy() + expect( + screen.getByRole("textbox", { name: "Signing link" }) + .value, + ).toBe(link) + expect(screen.getByText("Approved in your wallet").textContent).toMatch( + /done/, + ) + + fireEvent.click(screen.getByRole("button", { name: "Copy link" })) + + expect(writeText).toHaveBeenCalledWith(link) + expect(await screen.findByRole("button", { name: "Copied" })).toBeTruthy() + }) + + it("names the weight a weighted signer adds against the threshold", () => { + renderEmergencyModal("pause-vault", { + status: "shared", + xdr: "AAAAXDR", + standing: { weight: 1, threshold: 3 }, + expiresAt: null, + }) + + expect( + screen.getByRole("heading", { name: "weight 1 of threshold 3" }), + ).toBeTruthy() + }) + + it("keeps offering the copy when the clipboard refuses, leaving the link to select", async () => { + const writeText = vi.fn().mockRejectedValue(new Error("denied")) + Object.defineProperty(navigator, "clipboard", { + value: { writeText }, + configurable: true, + }) + renderEmergencyModal("pause-vault", { + status: "shared", + xdr: "AAAAXDR", + standing: { needs: 2 }, + expiresAt: null, + }) + + fireEvent.click(screen.getByRole("button", { name: "Copy link" })) + await act(() => Promise.resolve()) + + expect(writeText).toHaveBeenCalledTimes(1) + expect(screen.getByRole("button", { name: "Copy link" })).toBeTruthy() + expect(screen.queryByRole("button", { name: "Copied" })).toBeNull() + }) + + it.each<[SubmitFailure, string]>([ + ["expired", "The transaction expired before it was submitted."], + [ + "sequence-consumed", + "The account moved on; this transaction is no longer valid.", + ], + ["bad-auth", "The signatures do not match the account's threshold."], + ["fee", "The account cannot pay the fee."], + ["fee-too-low", "The fee is below the network minimum. Propose it again."], + ["not-accepted", "The network did not accept it. Try again."], + ])("explains a %s rejection from the network", (reason, sentence) => { + renderEmergencyModal("pause-vault", { + status: "failed", + failure: { kind: "network", reason }, + hash: "d".repeat(64), + }) + + expect( + screen.getByRole("heading", { name: "The network rejected it" }), + ).toBeTruthy() + expect(screen.getByText(sentence)).toBeTruthy() + expect(screen.getByText("Sent to the network").textContent).toMatch( + /failed/, + ) + }) + + it("asks to try again when a prepared transaction went stale", () => { + const { onRetry } = renderModal({ + status: "failed", + failure: { kind: "stale" }, + }) + + expect( + screen.getByRole("heading", { name: "The transaction went stale" }), + ).toBeTruthy() + expect( + screen.getByText( + "The account moved on since it was prepared, so nothing was sent. Try again.", + ), + ).toBeTruthy() + fireEvent.click(screen.getByRole("button", { name: "Try again" })) + expect(onRetry).toHaveBeenCalledTimes(1) + }) + + it("treats an unexplained network rejection as an unknown outcome", () => { + renderEmergencyModal("pause-vault", { + status: "failed", + failure: { kind: "network", reason: "unknown" }, + }) + + expect( + screen.getByRole("heading", { name: "Something went wrong" }), + ).toBeTruthy() + expect( + screen.getByText(/check the Emergency tab before trying again/), + ).toBeTruthy() + }) + + it.each<[EmergencyModalAction, string, string]>([ + ["pause-vault", "Preparing to pause the vault", "Vault paused"], + ["unpause-vault", "Preparing to unpause the vault", "Vault unpaused"], + ["pause-nav", "Preparing to pause the NAV", "NAV paused"], + ["unpause-nav", "Preparing to unpause the NAV", "NAV unpaused"], + [ + "clear-latest", + "Preparing to clear the latest price", + "Latest price cleared", + ], + ["pause-token", "Preparing to pause the share token", "Share token paused"], + [ + "unpause-token", + "Preparing to unpause the share token", + "Share token unpaused", + ], + ])("walks %s from preparing to confirmed", (action, preparing, confirmed) => { + const { rerender } = renderEmergencyModal(action, { status: "preparing" }) + expect(screen.getByRole("heading", { name: preparing })).toBeTruthy() + + rerender( + , + ) + expect(screen.getByRole("heading", { name: confirmed })).toBeTruthy() + }) + + it("tells the signer what pausing the vault stops and what stays open", () => { + renderEmergencyModal("pause-vault", { status: "awaiting-signature" }) + + expect( + screen.getByText("Signing stops new deposits, pricing and upgrades."), + ).toBeTruthy() + }) + + it.each<[EmergencyModalAction, string, string]>([ + [ + "pause-token", + "Signing stops holder transfers only.", + "Holder transfers are stopped. Minting, burning, the vault's escrow moves and legal transfers continue.", + ], + [ + "unpause-token", + "Signing resumes holder transfers.", + "Holder transfers are open again.", + ], + ])( + "words %s after what the share token enforces", + (action, signing, done) => { + const { rerender } = renderEmergencyModal(action, { + status: "awaiting-signature", + }) + expect(screen.getByText(signing)).toBeTruthy() + + rerender( + , + ) + expect(screen.getByText(done)).toBeTruthy() + }, + ) + + it.each<[EmergencyModalAction, number, string, RegExp]>([ + ["pause-vault", 1000, "vault", /The vault is already paused/], + ["unpause-vault", 1001, "vault", /The vault is not paused/], + ["pause-token", 1000, "share token", /The share token is already paused/], + ["unpause-token", 1001, "share token", /The share token is not paused/], + ["pause-nav", 2000, "oracle", /does not hold the role/], + ["clear-latest", 3008, "oracle", /The NAV is not paused/], + ])( + "names the reason %s was refused with code %i", + (action, code, refuser, sentence) => { + renderEmergencyModal(action, { + status: "failed", + failure: { kind: "contract-error", code }, + }) + + expect( + screen.getByRole("heading", { + name: `The ${refuser} refused this request`, + }), + ).toBeTruthy() + expect(screen.getByText(sentence)).toBeTruthy() + }, + ) + + it("falls back to a generic refusal from the right contract without the raw code", () => { + renderEmergencyModal("pause-token", { + status: "failed", + failure: { kind: "contract-error", code: 9999 }, + }) + + expect( + screen.getByText(/The share token refused this request\. Try again/), + ).toBeTruthy() + expect(screen.queryByText(/9999/)).toBeNull() + }) + + it("sends an unknown outcome to the Emergency tab", () => { + renderEmergencyModal("pause-nav", { + status: "failed", + failure: { kind: "unknown" }, + }) + + expect( + screen.getByText(/check the Emergency tab before trying again/), + ).toBeTruthy() + }) +}) + +const renderConfigurationModal = ( + action: ConfigurationModalAction, + status: TransactionStatus, + value = "", +) => + render( + , + ) + +describe("TransactionModal, configuration actions", () => { + it.each<[ConfigurationModalAction, string, string]>([ + [ + "set-deposit-cap", + "Preparing to set the deposit cap", + "Deposit cap updated", + ], + ["set-custodian", "Preparing to set the custodian", "Custodian updated"], + ["set-notice", "Preparing to set the notice", "Notice updated"], + [ + "set-wind-down-delay", + "Preparing to set the wind-down delay", + "Wind-down delay updated", + ], + [ + "set-min-epoch-duration", + "Preparing to set the minimum epoch duration", + "Minimum epoch duration updated", + ], + [ + "set-oracle-config", + "Preparing to set the oracle limits", + "Oracle limits updated", + ], + [ + "set-compliance", + "Preparing to set the compliance contract", + "Compliance contract updated", + ], + [ + "set-identity-verifier", + "Preparing to set the identity verifier", + "Identity verifier updated", + ], + ])("walks %s from preparing to confirmed", (action, preparing, confirmed) => { + const { rerender } = renderConfigurationModal(action, { + status: "preparing", + }) + expect(screen.getByRole("heading", { name: preparing })).toBeTruthy() + + rerender( + , + ) + expect(screen.getByRole("heading", { name: confirmed })).toBeTruthy() + }) + + it("states the new value before signing and once recorded", () => { + const { rerender } = renderConfigurationModal( + "set-deposit-cap", + { status: "awaiting-signature" }, + "5,000.00", + ) + expect(screen.getByText("New deposit cap: 5,000.00.")).toBeTruthy() + + rerender( + , + ) + expect(screen.getByText("Deposit cap: 5,000.00.")).toBeTruthy() + }) + + it.each<[ConfigurationModalAction, string]>([ + ["set-custodian", "Treasury deploys to the new address from now on."], + ["set-notice", "Applies to epochs closed from now on."], + ["set-wind-down-delay", "A proposed wind-down keeps its activation time."], + [ + "set-min-epoch-duration", + "Anyone may seal an epoch once it has been open this long.", + ], + ])("names the side effect of %s once recorded", (action, effect) => { + renderConfigurationModal(action, { status: "confirmed" }, "3d") + + expect(screen.getByText(effect)).toBeTruthy() + }) + + it.each<[ConfigurationModalAction, string, string, string, string]>([ + [ + "propose-custodian", + "GABC...WXYZ", + "Preparing to propose a custodian", + "Signing queues custodian GABC...WXYZ.", + "Custodian GABC...WXYZ applies once the upgrade delay passes.", + ], + [ + "propose-notice", + "12h", + "Preparing to propose a notice", + "Signing queues notice 12h.", + "Notice 12h applies once the upgrade delay passes.", + ], + [ + "propose-wind-down-delay", + "8d", + "Preparing to propose a wind-down delay", + "Signing queues wind-down delay 8d.", + "Wind-down delay 8d applies once the upgrade delay passes.", + ], + [ + "propose-min-epoch-duration", + "7d", + "Preparing to propose a minimum epoch duration", + "Signing queues minimum epoch duration 7d.", + "Minimum epoch duration 7d applies once the upgrade delay passes.", + ], + [ + "propose-compliance", + "CABC...WXYZ", + "Preparing to propose a compliance contract", + "Signing queues compliance contract CABC...WXYZ.", + "Compliance contract CABC...WXYZ applies once the vault's upgrade delay passes.", + ], + [ + "propose-identity-verifier", + "CABC...WXYZ", + "Preparing to propose an identity verifier", + "Signing queues identity verifier CABC...WXYZ.", + "Identity verifier CABC...WXYZ applies once the vault's upgrade delay passes.", + ], + [ + "propose-oracle-config", + "", + "Preparing to propose oracle limits", + "Signing queues every new oracle limit.", + "The new oracle limits apply once the vault's upgrade delay passes.", + ], + ])( + "walks %s from preparing to proposed", + (action, value, preparing, signing, doneBody) => { + const { rerender } = renderConfigurationModal( + action, + { status: "preparing" }, + value, + ) + expect(screen.getByRole("heading", { name: preparing })).toBeTruthy() + + rerender( + , + ) + expect(screen.getByText(signing)).toBeTruthy() + + rerender( + , + ) + expect( + screen.getByRole("heading", { name: "Change proposed" }), + ).toBeTruthy() + expect(screen.getByText(doneBody)).toBeTruthy() + }, + ) + + it.each<[ConfigurationModalAction, number, string, RegExp]>([ + ["set-deposit-cap", 6007, "vault", /The deposit cap cannot be negative/], + ["set-notice", 6045, "vault", /The notice cannot exceed 30 days/], + ["set-notice", 6061, "vault", /The notice cannot exceed the upgrade delay/], + [ + "set-wind-down-delay", + 6053, + "vault", + /The wind-down delay cannot exceed 90 days/, + ], + [ + "set-wind-down-delay", + 6069, + "vault", + /The wind-down delay cannot be under 7 days/, + ], + [ + "set-min-epoch-duration", + 6007, + "vault", + /The minimum epoch duration must be 1 to 30 days/, + ], + [ + "propose-min-epoch-duration", + 6007, + "vault", + /The minimum epoch duration must be 1 to 30 days/, + ], + ["propose-custodian", 6075, "vault", /custodian cannot be the vault/], + ["propose-notice", 6064, "vault", /Setup is still open/], + ["set-notice", 6065, "vault", /goes through the timelock/], + ["propose-custodian", 6063, "vault", /16 pending changes/], + [ + "set-oracle-config", + 3001, + "oracle", + /One of the oracle limits is out of range/, + ], + ["set-compliance", 2000, "share token", /does not hold the role/], + ["propose-compliance", 7003, "share token", /16 pending changes/], + ["propose-identity-verifier", 7004, "share token", /Setup is still open/], + ["set-compliance", 7005, "share token", /goes through the timelock/], + ["propose-compliance", 7008, "share token", /vault is winding down/], + ])( + "names the reason %s was refused with code %i", + (action, code, refuser, sentence) => { + renderConfigurationModal(action, { + status: "failed", + failure: { kind: "contract-error", code }, + }) + + expect( + screen.getByRole("heading", { + name: `The ${refuser} refused this request`, + }), + ).toBeTruthy() + expect(screen.getByText(sentence)).toBeTruthy() + }, + ) + + it("sends an unknown outcome to the Configuration tab", () => { + renderConfigurationModal("set-notice", { + status: "failed", + failure: { kind: "unknown" }, + }) + + expect( + screen.getByText(/check the Configuration tab before trying again/), + ).toBeTruthy() + }) +}) + +const renderGovernanceModal = ( + action: GovernanceModalAction, + status: TransactionStatus, + value = "Manager to GABC...WXYZ", +) => + render( + , + ) + +describe("TransactionModal, governance actions", () => { + it.each<[GovernanceModalAction, string, string, string, string]>([ + [ + "grant-vault-role", + "Manager to GABC...WXYZ", + "Signing grants Manager to GABC...WXYZ.", + "Role granted", + "Granted Manager to GABC...WXYZ.", + ], + [ + "revoke-token-role", + "Manager from CVAU...LTID", + "Signing revokes Manager from CVAU...LTID.", + "Role revoked", + "Revoked Manager from CVAU...LTID.", + ], + ])( + "names the role change of %s", + (action, value, signing, done, doneBody) => { + const { unmount } = renderGovernanceModal( + action, + { status: "awaiting-signature" }, + value, + ) + expect(screen.getByText(signing)).toBeTruthy() + unmount() + + renderGovernanceModal(action, { status: "confirmed" }, value) + expect(screen.getByRole("heading", { name: done })).toBeTruthy() + expect(screen.getByText(doneBody)).toBeTruthy() + }, + ) + + it.each<[GovernanceModalAction, number, string, RegExp]>([ + ["grant-vault-role", 2000, "vault", /does not hold the role/], + ["revoke-oracle-role", 2002, "oracle", /holders changed/], + ["revoke-token-role", 2007, "share token", /does not hold this role/], + [ + "grant-oracle-role", + 2010, + "oracle", + /The oracle already has the maximum number of roles/, + ], + ["cancel-vault-handover", 2200, "vault", /no pending handover/], + ["hand-over-oracle-admin", 2201, "oracle", /expiry is outside/], + ["cancel-token-handover", 2202, "share token", /different address/], + ["accept-vault-admin", 2203, "vault", /expired before it was accepted/], + [ + "accept-vault-admin", + 6046, + "vault", + /admin role can no longer change hands/, + ], + [ + "accept-oracle-admin", + 3018, + "oracle", + /admin role can no longer change hands/, + ], + [ + "accept-token-admin", + 7008, + "share token", + /admin role can no longer change hands/, + ], + ["apply-change", 1000, "vault", /The vault is paused/], + ["apply-change", 6046, "vault", /winding down/], + ["propose-wind-down", 6046, "vault", /wind-down is already active/], + ["cancel-wind-down", 6047, "vault", /No wind-down is proposed/], + ["propose-wind-down", 6048, "vault", /wind-down is already proposed/], + ["propose-code-upgrade", 6055, "vault", /code change is already pending/], + ["cancel-change", 6056, "vault", /no pending change with that id/], + ["apply-change", 6057, "vault", /delay has not passed yet/], + ["propose-grant-role", 6063, "vault", /16 pending changes/], + ["propose-admin", 6064, "vault", /Setup is still open/], + ["grant-vault-role", 6065, "vault", /goes through the timelock/], + ["propose-upgrade-delay", 6058, "vault", /cannot be under 7 days/], + ["propose-upgrade-delay", 6059, "vault", /cannot be under the notice/], + ["propose-upgrade-delay", 6060, "vault", /cannot exceed 90 days/], + [ + "cancel-token-change", + 7001, + "share token", + /no pending change with that id/, + ], + ["apply-token-change", 7002, "share token", /delay has not passed yet/], + ["propose-token-grant-role", 7003, "share token", /16 pending changes/], + ["propose-token-admin", 7004, "share token", /Setup is still open/], + ["grant-token-role", 7005, "share token", /goes through the timelock/], + ["apply-token-change", 7008, "share token", /vault is winding down/], + ["apply-token-change", 7009, "share token", /vault is paused/], + ["apply-token-change", 7011, "share token", /upgrade is already pending/], + [ + "hand-over-token-admin", + 7016, + "share token", + /hand-over is still pending/, + ], + ["cancel-oracle-change", 3011, "oracle", /no pending change with that id/], + ["apply-oracle-change", 3012, "oracle", /delay has not passed yet/], + ["propose-oracle-grant-role", 3013, "oracle", /16 pending changes/], + ["propose-oracle-admin", 3014, "oracle", /Setup is still open/], + ["grant-oracle-role", 3015, "oracle", /goes through the timelock/], + ["apply-oracle-change", 3016, "oracle", /setup has already finished/], + ["apply-oracle-change", 3017, "oracle", /not bound to a vault/], + ["apply-oracle-change", 3018, "oracle", /vault is winding down/], + ["apply-oracle-change", 3019, "oracle", /holds every oracle change/], + ["apply-oracle-change", 3020, "oracle", /shorter than the vault's notice/], + ["propose-oracle-upgrade", 3021, "oracle", /upgrade is already pending/], + ])( + "names the reason %s was refused with code %i", + (action, code, refuser, sentence) => { + renderGovernanceModal(action, { + status: "failed", + failure: { kind: "contract-error", code }, + }) + + expect( + screen.getByRole("heading", { + name: `The ${refuser} refused this request`, + }), + ).toBeTruthy() + expect(screen.getByText(sentence)).toBeTruthy() + }, + ) + + it.each<[GovernanceModalAction, string, string, string, string]>([ + [ + "hand-over-vault-admin", + "GABC...WXYZ becomes admin once it accepts before 21 Nov, 22:13 UTC.", + "Signing proposes the handover.", + "Handover proposed", + "GABC...WXYZ becomes admin once it accepts before 21 Nov, 22:13 UTC.", + ], + [ + "cancel-oracle-handover", + "GABC...WXYZ", + "Signing withdraws the offer to GABC...WXYZ.", + "Handover cancelled", + "GABC...WXYZ can no longer accept.", + ], + [ + "accept-token-admin", + "share token", + "Signing makes this wallet the share token admin.", + "Admin accepted", + "This wallet is now the share token admin.", + ], + ])( + "names the handover step of %s", + (action, value, signing, done, doneBody) => { + const { unmount } = renderGovernanceModal( + action, + { status: "awaiting-signature" }, + value, + ) + expect(screen.getByText(signing)).toBeTruthy() + unmount() + + renderGovernanceModal(action, { status: "confirmed" }, value) + expect(screen.getByRole("heading", { name: done })).toBeTruthy() + expect(screen.getByText(doneBody)).toBeTruthy() + }, + ) + + it.each<[GovernanceModalAction, string, string, string, string]>([ + [ + "propose-code-upgrade", + "Code abab...abab", + "Signing queues Code abab...abab.", + "Upgrade proposed", + "Code abab...abab can apply once the upgrade delay passes.", + ], + [ + "apply-change", + "vault change #3: Upgrade delay 14d", + "Signing applies vault change #3: Upgrade delay 14d.", + "Change applied", + "Applied vault change #3: Upgrade delay 14d.", + ], + [ + "cancel-change", + "vault change #2: Code upgrade abab...abab", + "Signing withdraws vault change #2: Code upgrade abab...abab.", + "Change cancelled", + "Withdrew vault change #2: Code upgrade abab...abab.", + ], + [ + "propose-grant-role", + "Manager to GABC...WXYZ", + "Signing queues the grant of Manager to GABC...WXYZ.", + "Change proposed", + "The grant of Manager to GABC...WXYZ applies once the upgrade delay passes.", + ], + [ + "propose-revoke-role", + "Manager from GABC...WXYZ", + "Signing queues the revoke of Manager from GABC...WXYZ.", + "Change proposed", + "The revoke of Manager from GABC...WXYZ applies once the upgrade delay passes.", + ], + [ + "propose-admin", + "GABC...WXYZ", + "Signing queues the hand-over to GABC...WXYZ.", + "Change proposed", + "The hand-over to GABC...WXYZ applies once the upgrade delay passes.", + ], + [ + "propose-token-grant-role", + "Manager to GABC...WXYZ", + "Signing queues the grant of Manager to GABC...WXYZ.", + "Change proposed", + "The grant of Manager to GABC...WXYZ applies once the vault's upgrade delay passes.", + ], + [ + "propose-token-revoke-role", + "Manager from GABC...WXYZ", + "Signing queues the revoke of Manager from GABC...WXYZ.", + "Change proposed", + "The revoke of Manager from GABC...WXYZ applies once the vault's upgrade delay passes.", + ], + [ + "propose-token-admin", + "GABC...WXYZ", + "Signing queues the hand-over to GABC...WXYZ.", + "Change proposed", + "The hand-over to GABC...WXYZ applies once the vault's upgrade delay passes.", + ], + [ + "apply-token-change", + "share token change #1: Compliance contract CABC...WXYZ", + "Signing applies share token change #1: Compliance contract CABC...WXYZ.", + "Change applied", + "Applied share token change #1: Compliance contract CABC...WXYZ.", + ], + [ + "cancel-token-change", + "share token change #1: Compliance contract CABC...WXYZ", + "Signing withdraws share token change #1: Compliance contract CABC...WXYZ.", + "Change cancelled", + "Withdrew share token change #1: Compliance contract CABC...WXYZ.", + ], + [ + "propose-oracle-grant-role", + "Attester to GABC...WXYZ", + "Signing queues the grant of Attester to GABC...WXYZ.", + "Change proposed", + "The grant of Attester to GABC...WXYZ applies once the vault's upgrade delay passes.", + ], + [ + "propose-oracle-admin", + "GABC...WXYZ", + "Signing queues the hand-over to GABC...WXYZ.", + "Change proposed", + "The hand-over to GABC...WXYZ applies once the vault's upgrade delay passes.", + ], + [ + "propose-oracle-upgrade", + "Code abab...abab", + "Signing queues Code abab...abab.", + "Upgrade proposed", + "Code abab...abab can apply once the vault's upgrade delay passes.", + ], + [ + "apply-oracle-change", + "oracle change #1: Admin hand-over GABC...WXYZ", + "Signing applies oracle change #1: Admin hand-over GABC...WXYZ.", + "Change applied", + "Applied oracle change #1: Admin hand-over GABC...WXYZ.", + ], + [ + "cancel-oracle-change", + "oracle change #1: Admin hand-over GABC...WXYZ", + "Signing withdraws oracle change #1: Admin hand-over GABC...WXYZ.", + "Change cancelled", + "Withdrew oracle change #1: Admin hand-over GABC...WXYZ.", + ], + [ + "propose-wind-down", + "Activates 21 Nov, 22:13 UTC.", + "Signing starts the wind-down countdown.", + "Wind-down proposed", + "Activates 21 Nov, 22:13 UTC.", + ], + [ + "cancel-wind-down", + "", + "Signing withdraws the wind-down.", + "Wind-down cancelled", + "The vault stays open.", + ], + ])( + "names the timelock step of %s", + (action, value, signing, done, doneBody) => { + const { unmount } = renderGovernanceModal( + action, + { status: "awaiting-signature" }, + value, + ) + expect(screen.getByText(signing)).toBeTruthy() + unmount() + + renderGovernanceModal(action, { status: "confirmed" }, value) + expect(screen.getByRole("heading", { name: done })).toBeTruthy() + expect(screen.getByText(doneBody)).toBeTruthy() + }, + ) + + it("sends an unknown outcome to the Governance tab", () => { + renderGovernanceModal("grant-token-role", { + status: "failed", + failure: { kind: "unknown" }, + }) + + expect( + screen.getByText(/check the Governance tab before trying again/), + ).toBeTruthy() + }) +}) + +const renderComplianceModal = ( + action: ComplianceModalAction, + status: TransactionStatus, + value = "GABC...WXYZ", +) => + render( + , + ) + +describe("TransactionModal, compliance actions", () => { + it.each<[ComplianceModalAction, string, string, string, string]>([ + [ + "allow-address", + "GABC...WXYZ", + "Signing adds GABC...WXYZ to the allowlist.", + "Address allowed", + "GABC...WXYZ is on the allowlist.", + ], + [ + "freeze-shares", + "5.00 shares of GABC...WXYZ", + "Signing freezes 5.00 shares of GABC...WXYZ.", + "Shares frozen", + "Froze 5.00 shares of GABC...WXYZ.", + ], + [ + "legal-transfer", + "5.00 shares from GABC...WXYZ to GDEF...UVWX", + "Signing approves moving 5.00 shares from GABC...WXYZ to GDEF...UVWX.", + "Legal transfer made", + "Moved 5.00 shares from GABC...WXYZ to GDEF...UVWX.", + ], + ])("names what %s changes", (action, value, signing, done, doneBody) => { + const { unmount } = renderComplianceModal( + action, + { status: "awaiting-signature" }, + value, + ) + expect(screen.getByText(signing)).toBeTruthy() + unmount() + + renderComplianceModal(action, { status: "confirmed" }, value) + expect(screen.getByRole("heading", { name: done })).toBeTruthy() + expect(screen.getByText(doneBody)).toBeTruthy() + }) + + it.each<[ComplianceModalAction, number, string, RegExp]>([ + ["freeze-shares", 300, "share token", /does not hold enough shares/], + ["unfreeze-shares", 301, "share token", /cannot be negative/], + ["unfreeze-shares", 303, "share token", /that many frozen shares/], + ["freeze-address", 2000, "share token", /does not hold the role/], + ["allow-address", 2000, "identity verifier", /does not hold the role/], + ["disallow-address", 2001, "identity verifier", /verifier has no admin/], + [ + "legal-transfer", + 7013, + "share token", + /compliance account and the token admin must be two different accounts/, + ], + ["legal-transfer", 7015, "share token", /reference of 1 to 128 bytes/], + [ + "legal-transfer", + 7012, + "share token", + /never moves shares into or out of the vault/, + ], + ["legal-transfer", 304, "share token", /destination is not allowlisted/], + ["legal-transfer", 300, "share token", /does not hold enough shares/], + ])( + "names the reason %s was refused with code %i", + (action, code, refuser, sentence) => { + renderComplianceModal(action, { + status: "failed", + failure: { kind: "contract-error", code }, + }) + + expect( + screen.getByRole("heading", { + name: `The ${refuser} refused this request`, + }), + ).toBeTruthy() + expect(screen.getByText(sentence)).toBeTruthy() + }, + ) + + it("asks for the token admin's authorization before compliance signs", () => { + renderComplianceModal("legal-transfer", { + status: "shared", + xdr: "AAAAXDR", + standing: "admin-authorization", + expiresAt: null, + }) + + expect( + screen.getByRole("heading", { + name: "Needs the token admin's authorization", + }), + ).toBeTruthy() + expect( + screen.getByText( + "Share the link with the token admin. Once they authorize it, compliance's signers sign it.", + ), + ).toBeTruthy() + expect( + screen.getByRole("textbox", { name: "Signing link" }) + .value, + ).toBe(`${window.location.origin}/sign#AAAAXDR`) + }) + + it("names the wallets that can sign an authorization", () => { + renderComplianceModal("legal-transfer", { + status: "failed", + failure: { kind: "cannot-authorize" }, + }) + + expect( + screen.getByText( + "This wallet cannot sign authorizations. Use Freighter, Hana or WalletConnect.", + ), + ).toBeTruthy() + expect(screen.queryByText("You declined the request")).toBeNull() + expect(screen.queryByRole("button", { name: "Try again" })).toBeNull() + }) + + it("sends an unknown outcome to the Compliance tab", () => { + renderComplianceModal("unfreeze-address", { + status: "failed", + failure: { kind: "unknown" }, + }) + + expect( + screen.getByText(/check the Compliance tab before trying again/), + ).toBeTruthy() + }) +}) + +describe("TransactionModal, allowlist and freeze refusals", () => { + const contractError = (code: number) => ({ + status: "failed" as const, + failure: { kind: "contract-error" as const, code }, + }) + + it("names NotAllowlisted on a subscription as an address that cannot subscribe", () => { + renderModal(contractError(6068)) + + expect(screen.getByText(/not allowlisted/)).toBeTruthy() + expect(screen.getByText(/can subscribe/)).toBeTruthy() + }) + + it("names Frozen on a subscription as a frozen address", () => { + renderModal(contractError(6071)) + + expect(screen.getByText(/frozen/)).toBeTruthy() + expect(screen.getByText(/cannot subscribe/)).toBeTruthy() + }) + + it("names Frozen on a redemption request as a frozen address or frozen shares", () => { + renderRedeemModal(contractError(6071)) + + expect(screen.getByText(/shares are frozen/)).toBeTruthy() + expect(screen.getByText(/free shares/)).toBeTruthy() + }) + + it.each<[string, () => void]>([ + ["cancelling a subscription", () => renderCancelModal(contractError(6071))], + [ + "cancelling a redemption", + () => renderCancelRedeemModal(contractError(6071)), + ], + ["claiming shares", () => renderClaimModal(contractError(6071))], + [ + "claiming a redemption", + () => renderClaimRedeemModal(contractError(6071)), + ], + ])("names Frozen on %s as held until the freeze lifts", (_name, render) => { + render() + + expect(screen.getByText(/held until the freeze is lifted/)).toBeTruthy() + }) + + it("falls back to a generic refusal for NotAllowlisted on a subscription cancellation, since a delisted holder may still cancel", () => { + renderCancelModal(contractError(6068)) + + expect(screen.getByText(/contact support/)).toBeTruthy() + expect(screen.queryByText(/allowlisted/)).toBeNull() + }) +}) + +describe("TransactionModal, conversion and surrender outcomes", () => { + it("tells a delisted investor their claimed shares were queued as a redemption", () => { + renderClaimModal({ + status: "confirmed", + sharesMinted: 0n as Amount, + queuedInto: 5n, + }) + + expect( + screen.getByRole("heading", { name: "Queued as a redemption" }), + ).toBeTruthy() + expect(screen.getByText(/not allowlisted/)).toBeTruthy() + expect(screen.getByText(/pay out in USDC at the next price/)).toBeTruthy() + expect(screen.queryByText(/returned to your wallet/)).toBeNull() + }) + + it("tells a delisted investor in a wind-down their claimed shares joined the wind-down claim", () => { + renderClaimModal({ + status: "confirmed", + sharesMinted: 0n as Amount, + queuedInto: 0n, + }) + + expect( + screen.getByRole("heading", { name: "Moved to your wind-down claim" }), + ).toBeTruthy() + expect(screen.getByText(/not allowlisted/)).toBeTruthy() + }) + + it("still reads a zero-share claim without a conversion as a returned deposit", () => { + renderClaimModal({ status: "confirmed", sharesMinted: 0n as Amount }) + + expect( + screen.getByRole("heading", { name: "Deposit returned" }), + ).toBeTruthy() + }) + + it("tells the investor a cancelled redemption moved into the wind-down claim", () => { + renderCancelRedeemModal({ + status: "confirmed", + returnedShares: 100_0000000n as Amount, + surrendered: true, + }) + + expect( + screen.getByRole("heading", { name: "Moved to your wind-down claim" }), + ).toBeTruthy() + expect(screen.getByText(/winding down/)).toBeTruthy() + expect(screen.queryByText(/returned to your wallet/)).toBeNull() + }) + + it("still reads a returned redemption as cancelled", () => { + renderCancelRedeemModal({ + status: "confirmed", + returnedShares: 100_0000000n as Amount, + surrendered: false, + }) + + expect( + screen.getByRole("heading", { name: "Request cancelled" }), + ).toBeTruthy() + }) +}) + +describe("TransactionModal, wind-down outcomes", () => { + it("tells the investor a zero-price claim burned the shares during a wind-down, never that they came back", () => { + renderClaimRedeemModal({ + status: "confirmed", + assetsClaimed: 0n as Amount, + sharesReturned: false, + }) + + expect( + screen.getByRole("heading", { name: "Nothing to pay out" }), + ).toBeTruthy() + expect(screen.getByText(/winding down/)).toBeTruthy() + expect(screen.getByText(/burned/)).toBeTruthy() + expect(screen.queryByText(/returned to your wallet/)).toBeNull() + }) +}) + +describe("TransactionModal, cancelling a redemption in a wind-down", () => { + it("tells the signer the shares move into the wind-down claim, never that they return", () => { + render( + , + ) + + expect(screen.getByText(/wind-down claim/)).toBeTruthy() + expect(screen.queryByText(/returns/)).toBeNull() + }) +}) + +const renderClaimWindDownModal = (status: ClaimWindDownStatus) => + render( + , + ) + +describe("TransactionModal, claiming from the wind-down", () => { + it("tells the signer what they receive and that their shares are turned in", () => { + renderClaimWindDownModal({ status: "awaiting-signature" }) + + expect(screen.getByText(/25\.00 USDC/)).toBeTruthy() + expect(screen.getByText(/turns in your unfrozen vUSDC/)).toBeTruthy() + }) + + it("shows the assets paid and the shares turned in once confirmed", () => { + renderClaimWindDownModal({ + status: "confirmed", + assetsClaimed: 25_0000000n as Amount, + sharesSurrendered: 40_0000000n as Amount, + }) + + expect(screen.getByRole("heading", { name: "USDC claimed" })).toBeTruthy() + expect(screen.getByText(/25\.00 USDC has been added/)).toBeTruthy() + expect(screen.getByText(/40\.00 vUSDC/)).toBeTruthy() + }) + + it("reads a claim that paid nothing as shares turned in for later rounds", () => { + renderClaimWindDownModal({ + status: "confirmed", + assetsClaimed: 0n as Amount, + sharesSurrendered: 40_0000000n as Amount, + }) + + expect( + screen.getByRole("heading", { name: "Shares turned in" }), + ).toBeTruthy() + expect(screen.getByText(/later rounds/)).toBeTruthy() + }) + + it.each<[number, RegExp]>([ + [6051, /first payout/], + [6054, /nothing to claim from the wind-down/], + [6071, /held until the freeze is lifted/], + ])("names refusal %i in the dapp's words", (code, text) => { + renderClaimWindDownModal({ + status: "failed", + failure: { kind: "contract-error", code }, + }) + + expect(screen.getByText(text)).toBeTruthy() + }) + + it("falls back to a generic refusal for a wind-down claim error it does not recognize", () => { + renderClaimWindDownModal({ + status: "failed", + failure: { kind: "contract-error", code: 9999 }, + }) + + expect(screen.getByText(/contact support/)).toBeTruthy() + expect(screen.queryByText(/9999/)).toBeNull() + }) +}) diff --git a/app/src/components/vault/transactionModal/compliance.test.tsx b/app/src/components/vault/transactionModal/compliance.test.tsx deleted file mode 100644 index 045438a2..00000000 --- a/app/src/components/vault/transactionModal/compliance.test.tsx +++ /dev/null @@ -1,139 +0,0 @@ -import type * as AppLib from "@stellar-scaffold/app-lib" -import { screen } from "@testing-library/react" -import { describe, expect, it, vi } from "vitest" -import { type ComplianceModalAction } from "../TransactionModal" -import { renderComplianceModal } from "./renderModal" - -vi.mock("@stellar-scaffold/app-lib", async (importOriginal) => ({ - ...(await importOriginal()), - shortAddress: (address: string) => - `${address.slice(0, 4)}...${address.slice(-4)}`, - explorerTransaction: () => null, -})) - -describe("TransactionModal, compliance actions", () => { - it.each<[ComplianceModalAction, string, string, string, string]>([ - [ - "allow-address", - "GABC...WXYZ", - "Signing adds GABC...WXYZ to the allowlist.", - "Address allowed", - "GABC...WXYZ is on the allowlist.", - ], - [ - "freeze-shares", - "5.00 shares of GABC...WXYZ", - "Signing freezes 5.00 shares of GABC...WXYZ.", - "Shares frozen", - "Froze 5.00 shares of GABC...WXYZ.", - ], - [ - "legal-transfer", - "5.00 shares from GABC...WXYZ to GDEF...UVWX", - "Signing approves moving 5.00 shares from GABC...WXYZ to GDEF...UVWX.", - "Legal transfer made", - "Moved 5.00 shares from GABC...WXYZ to GDEF...UVWX.", - ], - ])("names what %s changes", (action, value, signing, done, doneBody) => { - const { unmount } = renderComplianceModal( - action, - { status: "awaiting-signature" }, - value, - ) - expect(screen.getByText(signing)).toBeTruthy() - unmount() - - renderComplianceModal(action, { status: "confirmed" }, value) - expect(screen.getByRole("heading", { name: done })).toBeTruthy() - expect(screen.getByText(doneBody)).toBeTruthy() - }) - - it.each<[ComplianceModalAction, number, string, RegExp]>([ - ["freeze-shares", 300, "share token", /does not hold enough shares/], - ["unfreeze-shares", 301, "share token", /cannot be negative/], - ["unfreeze-shares", 303, "share token", /that many frozen shares/], - ["freeze-address", 2000, "share token", /does not hold the role/], - ["allow-address", 2000, "identity verifier", /does not hold the role/], - ["disallow-address", 2001, "identity verifier", /verifier has no admin/], - [ - "legal-transfer", - 7013, - "share token", - /compliance account and the token admin must be two different accounts/, - ], - ["legal-transfer", 7015, "share token", /reference of 1 to 128 bytes/], - [ - "legal-transfer", - 7012, - "share token", - /never moves shares into or out of the vault/, - ], - ["legal-transfer", 304, "share token", /destination is not allowlisted/], - ["legal-transfer", 300, "share token", /does not hold enough shares/], - ])( - "names the reason %s was refused with code %i", - (action, code, refuser, sentence) => { - renderComplianceModal(action, { - status: "failed", - failure: { kind: "contract-error", code }, - }) - - expect( - screen.getByRole("heading", { - name: `The ${refuser} refused this request`, - }), - ).toBeTruthy() - expect(screen.getByText(sentence)).toBeTruthy() - }, - ) - - it("asks for the token admin's authorization before compliance signs", () => { - renderComplianceModal("legal-transfer", { - status: "shared", - xdr: "AAAAXDR", - standing: "admin-authorization", - expiresAt: null, - }) - - expect( - screen.getByRole("heading", { - name: "Needs the token admin's authorization", - }), - ).toBeTruthy() - expect( - screen.getByText( - "Share the link with the token admin. Once they authorize it, compliance's signers sign it.", - ), - ).toBeTruthy() - expect( - screen.getByRole("textbox", { name: "Signing link" }) - .value, - ).toBe(`${window.location.origin}/sign#AAAAXDR`) - }) - - it("names the wallets that can sign an authorization", () => { - renderComplianceModal("legal-transfer", { - status: "failed", - failure: { kind: "cannot-authorize" }, - }) - - expect( - screen.getByText( - "This wallet cannot sign authorizations. Use Freighter, Hana or WalletConnect.", - ), - ).toBeTruthy() - expect(screen.queryByText("You declined the request")).toBeNull() - expect(screen.queryByRole("button", { name: "Try again" })).toBeNull() - }) - - it("sends an unknown outcome to the Compliance tab", () => { - renderComplianceModal("unfreeze-address", { - status: "failed", - failure: { kind: "unknown" }, - }) - - expect( - screen.getByText(/check the Compliance tab before trying again/), - ).toBeTruthy() - }) -}) diff --git a/app/src/components/vault/transactionModal/configuration.test.tsx b/app/src/components/vault/transactionModal/configuration.test.tsx deleted file mode 100644 index 3ea01c87..00000000 --- a/app/src/components/vault/transactionModal/configuration.test.tsx +++ /dev/null @@ -1,259 +0,0 @@ -import type * as AppLib from "@stellar-scaffold/app-lib" -import { screen } from "@testing-library/react" -import { describe, expect, it, vi } from "vitest" -import TransactionModal, { - type ConfigurationModalAction, -} from "../TransactionModal" -import { renderConfigurationModal } from "./renderModal" - -vi.mock("@stellar-scaffold/app-lib", async (importOriginal) => ({ - ...(await importOriginal()), - shortAddress: (address: string) => - `${address.slice(0, 4)}...${address.slice(-4)}`, - explorerTransaction: () => null, -})) - -describe("TransactionModal, configuration actions", () => { - it.each<[ConfigurationModalAction, string, string]>([ - [ - "set-deposit-cap", - "Preparing to set the deposit cap", - "Deposit cap updated", - ], - ["set-custodian", "Preparing to set the custodian", "Custodian updated"], - ["set-notice", "Preparing to set the notice", "Notice updated"], - [ - "set-wind-down-delay", - "Preparing to set the wind-down delay", - "Wind-down delay updated", - ], - [ - "set-min-epoch-duration", - "Preparing to set the minimum epoch duration", - "Minimum epoch duration updated", - ], - [ - "set-oracle-config", - "Preparing to set the oracle limits", - "Oracle limits updated", - ], - [ - "set-compliance", - "Preparing to set the compliance contract", - "Compliance contract updated", - ], - [ - "set-identity-verifier", - "Preparing to set the identity verifier", - "Identity verifier updated", - ], - ])("walks %s from preparing to confirmed", (action, preparing, confirmed) => { - const { rerender } = renderConfigurationModal(action, { - status: "preparing", - }) - expect(screen.getByRole("heading", { name: preparing })).toBeTruthy() - - rerender( - , - ) - expect(screen.getByRole("heading", { name: confirmed })).toBeTruthy() - }) - - it("states the new value before signing and once recorded", () => { - const { rerender } = renderConfigurationModal( - "set-deposit-cap", - { status: "awaiting-signature" }, - "5,000.00", - ) - expect(screen.getByText("New deposit cap: 5,000.00.")).toBeTruthy() - - rerender( - , - ) - expect(screen.getByText("Deposit cap: 5,000.00.")).toBeTruthy() - }) - - it.each<[ConfigurationModalAction, string]>([ - ["set-custodian", "Treasury deploys to the new address from now on."], - ["set-notice", "Applies to epochs closed from now on."], - ["set-wind-down-delay", "A proposed wind-down keeps its activation time."], - [ - "set-min-epoch-duration", - "Anyone may seal an epoch once it has been open this long.", - ], - ])("names the side effect of %s once recorded", (action, effect) => { - renderConfigurationModal(action, { status: "confirmed" }, "3d") - - expect(screen.getByText(effect)).toBeTruthy() - }) - - it.each<[ConfigurationModalAction, string, string, string, string]>([ - [ - "propose-custodian", - "GABC...WXYZ", - "Preparing to propose a custodian", - "Signing queues custodian GABC...WXYZ.", - "Custodian GABC...WXYZ applies once the upgrade delay passes.", - ], - [ - "propose-notice", - "12h", - "Preparing to propose a notice", - "Signing queues notice 12h.", - "Notice 12h applies once the upgrade delay passes.", - ], - [ - "propose-wind-down-delay", - "8d", - "Preparing to propose a wind-down delay", - "Signing queues wind-down delay 8d.", - "Wind-down delay 8d applies once the upgrade delay passes.", - ], - [ - "propose-min-epoch-duration", - "7d", - "Preparing to propose a minimum epoch duration", - "Signing queues minimum epoch duration 7d.", - "Minimum epoch duration 7d applies once the upgrade delay passes.", - ], - [ - "propose-compliance", - "CABC...WXYZ", - "Preparing to propose a compliance contract", - "Signing queues compliance contract CABC...WXYZ.", - "Compliance contract CABC...WXYZ applies once the vault's upgrade delay passes.", - ], - [ - "propose-identity-verifier", - "CABC...WXYZ", - "Preparing to propose an identity verifier", - "Signing queues identity verifier CABC...WXYZ.", - "Identity verifier CABC...WXYZ applies once the vault's upgrade delay passes.", - ], - [ - "propose-oracle-config", - "", - "Preparing to propose oracle limits", - "Signing queues every new oracle limit.", - "The new oracle limits apply once the vault's upgrade delay passes.", - ], - ])( - "walks %s from preparing to proposed", - (action, value, preparing, signing, doneBody) => { - const { rerender } = renderConfigurationModal( - action, - { status: "preparing" }, - value, - ) - expect(screen.getByRole("heading", { name: preparing })).toBeTruthy() - - rerender( - , - ) - expect(screen.getByText(signing)).toBeTruthy() - - rerender( - , - ) - expect( - screen.getByRole("heading", { name: "Change proposed" }), - ).toBeTruthy() - expect(screen.getByText(doneBody)).toBeTruthy() - }, - ) - - it.each<[ConfigurationModalAction, number, string, RegExp]>([ - ["set-deposit-cap", 6007, "vault", /The deposit cap cannot be negative/], - ["set-notice", 6045, "vault", /The notice cannot exceed 30 days/], - ["set-notice", 6061, "vault", /The notice cannot exceed the upgrade delay/], - [ - "set-wind-down-delay", - 6053, - "vault", - /The wind-down delay cannot exceed 90 days/, - ], - [ - "set-wind-down-delay", - 6069, - "vault", - /The wind-down delay cannot be under 7 days/, - ], - [ - "set-min-epoch-duration", - 6007, - "vault", - /The minimum epoch duration must be 1 to 30 days/, - ], - [ - "propose-min-epoch-duration", - 6007, - "vault", - /The minimum epoch duration must be 1 to 30 days/, - ], - ["propose-custodian", 6075, "vault", /custodian cannot be the vault/], - ["propose-notice", 6064, "vault", /Setup is still open/], - ["set-notice", 6065, "vault", /goes through the timelock/], - ["propose-custodian", 6063, "vault", /16 pending changes/], - [ - "set-oracle-config", - 3001, - "oracle", - /One of the oracle limits is out of range/, - ], - ["set-compliance", 2000, "share token", /does not hold the role/], - ["propose-compliance", 7003, "share token", /16 pending changes/], - ["propose-identity-verifier", 7004, "share token", /Setup is still open/], - ["set-compliance", 7005, "share token", /goes through the timelock/], - ["propose-compliance", 7008, "share token", /vault is winding down/], - ])( - "names the reason %s was refused with code %i", - (action, code, refuser, sentence) => { - renderConfigurationModal(action, { - status: "failed", - failure: { kind: "contract-error", code }, - }) - - expect( - screen.getByRole("heading", { - name: `The ${refuser} refused this request`, - }), - ).toBeTruthy() - expect(screen.getByText(sentence)).toBeTruthy() - }, - ) - - it("sends an unknown outcome to the Configuration tab", () => { - renderConfigurationModal("set-notice", { - status: "failed", - failure: { kind: "unknown" }, - }) - - expect( - screen.getByText(/check the Configuration tab before trying again/), - ).toBeTruthy() - }) -}) diff --git a/app/src/components/vault/transactionModal/cycle.test.tsx b/app/src/components/vault/transactionModal/cycle.test.tsx deleted file mode 100644 index 02ba0ac8..00000000 --- a/app/src/components/vault/transactionModal/cycle.test.tsx +++ /dev/null @@ -1,670 +0,0 @@ -import { type Amount, type Price } from "@stellar-scaffold/app-lib" -import type * as AppLib from "@stellar-scaffold/app-lib" -import { fireEvent, screen } from "@testing-library/react" -import { describe, expect, it, vi } from "vitest" -import { - renderCloseEpochModal, - renderFulfillEpochModal, - renderAttestModal, - renderDeployModal, - renderFundModal, - renderActivateModal, - renderFinalizeModal, -} from "./renderModal" - -vi.mock("@stellar-scaffold/app-lib", async (importOriginal) => ({ - ...(await importOriginal()), - shortAddress: (address: string) => - `${address.slice(0, 4)}...${address.slice(-4)}`, - explorerTransaction: () => null, -})) - -describe("TransactionModal, closing an epoch", () => { - it("opens with a preparing state naming the epoch being closed", () => { - renderCloseEpochModal({ status: "preparing" }) - - expect( - screen.getByRole("heading", { name: "Preparing to close epoch 5" }), - ).toBeTruthy() - }) - - it("tells the manager what signing seals and what it opens", () => { - renderCloseEpochModal({ status: "awaiting-signature" }) - - expect( - screen.getByRole("heading", { name: "Confirm in your wallet" }), - ).toBeTruthy() - expect( - screen.getByText( - "Signing seals epoch 5 and opens the next one. Requests in epoch 5 wait for a price.", - ), - ).toBeTruthy() - }) - - it.each(["", "unknown"])( - "renders the signing prompt without throwing when the label is %j", - (epoch) => { - expect(() => - renderCloseEpochModal({ status: "awaiting-signature" }, epoch), - ).not.toThrow() - expect( - screen.getByRole("heading", { name: "Confirm in your wallet" }), - ).toBeTruthy() - }, - ) - - it("tells the manager the close is on its way", () => { - renderCloseEpochModal({ status: "submitted", hash: "a".repeat(64) }) - - expect( - screen.getByRole("heading", { name: "Sending the close" }), - ).toBeTruthy() - expect(screen.getByText(/on its way to the network/)).toBeTruthy() - }) - - it("names the epoch the vault sealed once confirmed, taking the id from the result rather than the label", () => { - renderCloseEpochModal({ - status: "confirmed", - sealedEpoch: 7n, - hash: "b".repeat(64), - }) - - expect(screen.getByRole("heading", { name: "Epoch 7 sealed" })).toBeTruthy() - expect( - screen.getByText( - "Epoch 8 is open and accepting requests. Epoch 7 prices once the notice elapses and a valid price is attested.", - ), - ).toBeTruthy() - }) - - it.each<[number, RegExp]>([ - [6046, /winding down/], - [6029, /current epoch could not be found/], - [6032, /not open/], - [2000, /not authorized to close/], - [2007, /does not hold the manager role/], - [6077, /Only the manager can seal this epoch/], - [6064, /setup is still open, so no epoch can be sealed/], - ])("names the vault's reason for code %i", (code, sentence) => { - renderCloseEpochModal({ - status: "failed", - failure: { kind: "contract-error", code }, - }) - - expect( - screen.getByRole("heading", { name: "The vault refused this request" }), - ).toBeTruthy() - expect(screen.getByText(sentence)).toBeTruthy() - }) - - it("falls back to a generic refusal for a close error it does not recognize, without the raw code", () => { - renderCloseEpochModal({ - status: "failed", - failure: { kind: "contract-error", code: 6001 }, - }) - - expect( - screen.getByText(/The vault refused this request\. Try again/), - ).toBeTruthy() - expect(screen.queryByText(/6001/)).toBeNull() - }) - - it("sends an unknown outcome to the Cycle card, not to the investor's requests", () => { - renderCloseEpochModal({ - status: "failed", - failure: { kind: "unknown" }, - }) - - expect( - screen.getByRole("heading", { name: "Something went wrong" }), - ).toBeTruthy() - expect( - screen.getByText( - "We could not confirm whether this reached the network. We don't yet know if it went through, so check the Cycle card before trying again.", - ), - ).toBeTruthy() - expect(screen.queryByText(/your requests/)).toBeNull() - }) - - it("reads a declined signature as a choice and offers to try again", () => { - const { onRetry } = renderCloseEpochModal({ - status: "failed", - failure: { kind: "declined" }, - }) - - fireEvent.click(screen.getByRole("button", { name: "Try again" })) - - expect(onRetry).toHaveBeenCalledTimes(1) - }) -}) - -describe("TransactionModal, pricing an epoch", () => { - it("opens with a preparing state naming the epoch being priced", () => { - renderFulfillEpochModal({ status: "preparing" }) - - expect( - screen.getByRole("heading", { name: "Preparing to price epoch 4" }), - ).toBeTruthy() - }) - - it("tells the operator what signing prices and settles", () => { - renderFulfillEpochModal({ status: "awaiting-signature" }) - - expect( - screen.getByRole("heading", { name: "Confirm in your wallet" }), - ).toBeTruthy() - expect( - screen.getByText( - "Signing prices epoch 4 at the oracle's current price and settles its requests.", - ), - ).toBeTruthy() - }) - - it("tells the operator the settlement is on its way", () => { - renderFulfillEpochModal({ status: "submitted", hash: "a".repeat(64) }) - - expect( - screen.getByRole("heading", { name: "Sending the settlement" }), - ).toBeTruthy() - expect(screen.getByText(/on its way to the network/)).toBeTruthy() - }) - - it("shows the share price the vault settled at once confirmed, to four places", () => { - renderFulfillEpochModal({ - status: "confirmed", - sharePrice: 1_250_000_000_000_000_000n as Price, - hash: "b".repeat(64), - }) - - expect(screen.getByRole("heading", { name: "Epoch 4 priced" })).toBeTruthy() - expect( - screen.getByText( - "Settled at 1.2500 per share. Share claims are ready; redemption claims wait until the reserve covers them.", - ), - ).toBeTruthy() - }) - - it.each<[number, RegExp]>([ - [6046, /winding down/], - [6029, /epoch could not be found/], - [6038, /not sealed/], - [6042, /notice period has not elapsed/], - [6044, /price is not valid/], - [6043, /attested before this epoch was sealed/], - [6031, /could not resolve a valid share price/], - [6014, /too large/], - [1000, /paused/], - [6064, /setup is still open, so no epoch can be priced/], - ])("names the vault's reason for code %i", (code, sentence) => { - renderFulfillEpochModal({ - status: "failed", - failure: { kind: "contract-error", code }, - }) - - expect(screen.getByText(sentence)).toBeTruthy() - }) - - it("falls back to a generic refusal for a settlement error it does not recognize, without the raw code", () => { - renderFulfillEpochModal({ - status: "failed", - failure: { kind: "contract-error", code: 6035 }, - }) - - expect( - screen.getByText(/The vault refused this request\. Try again/), - ).toBeTruthy() - expect(screen.queryByText(/6035/)).toBeNull() - }) - - it("sends an unknown outcome to the Cycle card, not to the investor's requests", () => { - renderFulfillEpochModal({ - status: "failed", - failure: { kind: "unknown" }, - hash: "c".repeat(64), - }) - - expect( - screen.getByText(/check the Cycle card before trying again/), - ).toBeTruthy() - expect(screen.queryByText(/your requests/)).toBeNull() - expect(screen.getByText(/cccc\.\.\.cccc/)).toBeTruthy() - }) - - it("is reachable as a dialog and dismissible by its close control", () => { - const { onClose } = renderFulfillEpochModal({ status: "preparing" }) - - expect(screen.getByRole("dialog")).toBeTruthy() - fireEvent.click(screen.getByRole("button", { name: "Close" })) - expect(onClose).toHaveBeenCalledTimes(1) - }) -}) - -describe("TransactionModal, attesting a price", () => { - it("opens with a preparing state naming the price being attested", () => { - renderAttestModal({ status: "preparing" }) - - expect( - screen.getByRole("heading", { name: "Preparing to attest 1.0400" }), - ).toBeTruthy() - }) - - it("tells the attester what signing records", () => { - renderAttestModal({ status: "awaiting-signature" }) - - expect( - screen.getByRole("heading", { name: "Confirm in your wallet" }), - ).toBeTruthy() - expect( - screen.getByText( - "Signing records 1.0400 as the share price, with its proof. Sealed epochs settle at it once their notice elapses.", - ), - ).toBeTruthy() - }) - - it("tells the attester the attestation is on its way", () => { - renderAttestModal({ status: "submitted", hash: "a".repeat(64) }) - - expect( - screen.getByRole("heading", { name: "Sending the attestation" }), - ).toBeTruthy() - expect(screen.getByText(/on its way to the network/)).toBeTruthy() - }) - - it("says how long the price stays valid once confirmed", () => { - renderAttestModal({ status: "confirmed", hash: "b".repeat(64) }) - - expect(screen.getByRole("heading", { name: "Price attested" })).toBeTruthy() - expect( - screen.getByText("1.0400 is the share price. Valid for 1d."), - ).toBeTruthy() - }) - - it("leaves the validity out when the freshness window is not known", () => { - renderAttestModal({ status: "confirmed" }, null) - - expect(screen.getByText("1.0400 is the share price.")).toBeTruthy() - }) - - it.each<[number, RegExp]>([ - [3002, /outside the band/], - [3005, /already have expired/], - [3003, /cooldown .* has not elapsed/], - [3004, /moves further from the last attested price/], - [3010, /^Unpause the NAV first\.$/], - [3023, /^The proof hash cannot be all zeros\.$/], - [3024, /^The proof URI is longer than 256 bytes\.$/], - ])("names the oracle's reason for code %i", (code, sentence) => { - renderAttestModal({ - status: "failed", - failure: { kind: "contract-error", code }, - }) - - expect( - screen.getByRole("heading", { name: "The vault refused this request" }), - ).toBeTruthy() - expect(screen.getByText(sentence)).toBeTruthy() - }) - - it.each([2000, 2007])( - "falls back to a generic refusal for code %i, without the raw code", - (code) => { - renderAttestModal({ - status: "failed", - failure: { kind: "contract-error", code }, - }) - - expect( - screen.getByText(/The vault refused this request\. Try again/), - ).toBeTruthy() - expect(screen.queryByText(new RegExp(String(code)))).toBeNull() - }, - ) - - it("sends an unknown outcome to the Cycle card, not to the investor's requests", () => { - renderAttestModal({ status: "failed", failure: { kind: "unknown" } }) - - expect( - screen.getByText(/check the Cycle card before trying again/), - ).toBeTruthy() - expect(screen.queryByText(/your requests/)).toBeNull() - }) - - it("is reachable as a dialog and dismissible by its close control", () => { - const { onClose } = renderAttestModal({ status: "preparing" }) - - expect(screen.getByRole("dialog")).toBeTruthy() - fireEvent.click(screen.getByRole("button", { name: "Close" })) - expect(onClose).toHaveBeenCalledTimes(1) - }) -}) - -describe("TransactionModal, deploying to the custodian", () => { - it("opens with a preparing state naming the amount being deployed", () => { - renderDeployModal({ status: "preparing" }) - - expect( - screen.getByRole("heading", { name: "Preparing to deploy 250.00" }), - ).toBeTruthy() - }) - - it("tells the treasury what signing sends and where", () => { - renderDeployModal({ status: "awaiting-signature" }) - - expect( - screen.getByRole("heading", { name: "Confirm in your wallet" }), - ).toBeTruthy() - expect( - screen.getByText( - "Signing sends 250.00 from the vault's reserve to the custodian.", - ), - ).toBeTruthy() - }) - - it("tells the treasury the deployment is on its way", () => { - renderDeployModal({ status: "submitted", hash: "a".repeat(64) }) - - expect( - screen.getByRole("heading", { name: "Sending the deployment" }), - ).toBeTruthy() - expect(screen.getByText(/on its way to the network/)).toBeTruthy() - }) - - it("says what left the vault once confirmed", () => { - renderDeployModal({ status: "confirmed", hash: "b".repeat(64) }) - - expect(screen.getByRole("heading", { name: "Deployed" })).toBeTruthy() - expect( - screen.getByText("250.00 left the vault for the custodian."), - ).toBeTruthy() - }) - - it.each<[number, RegExp]>([ - [6046, /winding down/], - [6007, /greater than zero/], - [6012, /No custodian is set/], - [6005, /free reserve/], - [6014, /too large/], - [2000, /not authorized/], - [2007, /treasury role/], - ])("names the vault's reason for code %i", (code, sentence) => { - renderDeployModal({ - status: "failed", - failure: { kind: "contract-error", code }, - }) - - expect( - screen.getByRole("heading", { name: "The vault refused this request" }), - ).toBeTruthy() - expect(screen.getByText(sentence)).toBeTruthy() - }) - - it("falls back to a generic refusal for a code it does not recognize, without the raw code", () => { - renderDeployModal({ - status: "failed", - failure: { kind: "contract-error", code: 6029 }, - }) - - expect( - screen.getByText(/The vault refused this request\. Try again/), - ).toBeTruthy() - expect(screen.queryByText(/6029/)).toBeNull() - }) - - it("sends an unknown outcome to the Cycle card, not to the investor's requests", () => { - renderDeployModal({ status: "failed", failure: { kind: "unknown" } }) - - expect( - screen.getByText(/check the Cycle card before trying again/), - ).toBeTruthy() - expect(screen.queryByText(/your requests/)).toBeNull() - }) - - it("is reachable as a dialog and dismissible by its close control", () => { - const { onClose } = renderDeployModal({ status: "preparing" }) - - expect(screen.getByRole("dialog")).toBeTruthy() - fireEvent.click(screen.getByRole("button", { name: "Close" })) - expect(onClose).toHaveBeenCalledTimes(1) - }) -}) - -describe("TransactionModal, funding the reserve", () => { - it("opens with a preparing state naming the amount being funded", () => { - renderFundModal({ status: "preparing" }) - - expect( - screen.getByRole("heading", { name: "Preparing to fund 100.00" }), - ).toBeTruthy() - }) - - it("tells the funder what signing moves out of their wallet", () => { - renderFundModal({ status: "awaiting-signature" }) - - expect( - screen.getByRole("heading", { name: "Confirm in your wallet" }), - ).toBeTruthy() - expect( - screen.getByText( - "Signing moves 100.00 from your wallet into the vault's reserve.", - ), - ).toBeTruthy() - }) - - it("tells the funder the funding is on its way", () => { - renderFundModal({ status: "submitted", hash: "a".repeat(64) }) - - expect( - screen.getByRole("heading", { name: "Sending the funding" }), - ).toBeTruthy() - expect(screen.getByText(/on its way to the network/)).toBeTruthy() - }) - - it("says what joined the reserve once confirmed", () => { - renderFundModal({ status: "confirmed", hash: "b".repeat(64) }) - - expect(screen.getByRole("heading", { name: "Funded" })).toBeTruthy() - expect(screen.getByText("100.00 joined the reserve.")).toBeTruthy() - }) - - it.each<[number, RegExp]>([ - [6007, /greater than zero/], - [6014, /too large/], - ])("names the vault's reason for code %i", (code, sentence) => { - renderFundModal({ - status: "failed", - failure: { kind: "contract-error", code }, - }) - - expect( - screen.getByRole("heading", { name: "The vault refused this request" }), - ).toBeTruthy() - expect(screen.getByText(sentence)).toBeTruthy() - }) - - it("points the funder at their balance and trustline when the asset transfer refuses, without the raw code", () => { - renderFundModal({ - status: "failed", - failure: { kind: "contract-error", code: 10 }, - }) - - expect( - screen.getByText( - "Your wallet could not cover the transfer. Check the balance and the trustline.", - ), - ).toBeTruthy() - expect(screen.queryByText(/\b10\b/)).toBeNull() - }) - - it("sends an unknown outcome to the Cycle card, not to the investor's requests", () => { - renderFundModal({ status: "failed", failure: { kind: "unknown" } }) - - expect( - screen.getByText(/check the Cycle card before trying again/), - ).toBeTruthy() - expect(screen.queryByText(/your requests/)).toBeNull() - }) - - it("is reachable as a dialog and dismissible by its close control", () => { - const { onClose } = renderFundModal({ status: "preparing" }) - - expect(screen.getByRole("dialog")).toBeTruthy() - fireEvent.click(screen.getByRole("button", { name: "Close" })) - expect(onClose).toHaveBeenCalledTimes(1) - }) -}) - -describe("TransactionModal, activating the wind-down", () => { - it("opens with a preparing state", () => { - renderActivateModal({ status: "preparing" }) - - expect( - screen.getByRole("heading", { - name: "Preparing to activate the wind-down", - }), - ).toBeTruthy() - }) - - it("tells the signer what the activation stops and opens", () => { - renderActivateModal({ status: "awaiting-signature" }) - - expect( - screen.getByRole("heading", { name: "Confirm in your wallet" }), - ).toBeTruthy() - expect( - screen.getByText( - "Signing stops new requests and opens distribution rounds.", - ), - ).toBeTruthy() - }) - - it("tells the signer the activation is on its way", () => { - renderActivateModal({ status: "submitted", hash: "a".repeat(64) }) - - expect( - screen.getByRole("heading", { name: "Sending the activation" }), - ).toBeTruthy() - expect(screen.getByText(/on its way to the network/)).toBeTruthy() - }) - - it("says requests are closed and rounds can distribute once confirmed", () => { - renderActivateModal({ status: "confirmed", hash: "b".repeat(64) }) - - expect( - screen.getByRole("heading", { name: "Wind-down active" }), - ).toBeTruthy() - expect( - screen.getByText( - "New requests are closed. Rounds can now distribute the free reserve.", - ), - ).toBeTruthy() - }) - - it.each<[number, RegExp]>([ - [6047, /No wind-down has been proposed/], - [6046, /already active/], - [6049, /delay has not elapsed/], - ])("names the vault's reason for code %i", (code, sentence) => { - renderActivateModal({ - status: "failed", - failure: { kind: "contract-error", code }, - }) - - expect( - screen.getByRole("heading", { name: "The vault refused this request" }), - ).toBeTruthy() - expect(screen.getByText(sentence)).toBeTruthy() - }) - - it("falls back to a generic refusal for a code it does not recognize, without the raw code", () => { - renderActivateModal({ - status: "failed", - failure: { kind: "contract-error", code: 6050 }, - }) - - expect( - screen.getByText(/The vault refused this request\. Try again/), - ).toBeTruthy() - expect(screen.queryByText(/6050/)).toBeNull() - }) - - it("sends an unknown outcome to the Cycle card, not to the investor's requests", () => { - renderActivateModal({ status: "failed", failure: { kind: "unknown" } }) - - expect( - screen.getByText(/check the Cycle card before trying again/), - ).toBeTruthy() - expect(screen.queryByText(/your requests/)).toBeNull() - }) -}) - -describe("TransactionModal, finalizing a wind-down round", () => { - it("opens with a preparing state naming the round", () => { - renderFinalizeModal({ status: "preparing" }) - - expect( - screen.getByRole("heading", { name: "Preparing round 3" }), - ).toBeTruthy() - }) - - it("tells the signer what the round distributes", () => { - renderFinalizeModal({ status: "awaiting-signature" }) - - expect( - screen.getByRole("heading", { name: "Confirm in your wallet" }), - ).toBeTruthy() - expect( - screen.getByText( - "Signing distributes the free reserve to holders as round 3.", - ), - ).toBeTruthy() - }) - - it("tells the signer the round is on its way", () => { - renderFinalizeModal({ status: "submitted", hash: "a".repeat(64) }) - - expect( - screen.getByRole("heading", { name: "Sending the round" }), - ).toBeTruthy() - expect(screen.getByText(/on its way to the network/)).toBeTruthy() - }) - - it("says what holders can claim once confirmed, to the last digit", () => { - renderFinalizeModal({ - status: "confirmed", - hash: "b".repeat(64), - credited: 9_000_000_001n as Amount, - }) - - expect( - screen.getByRole("heading", { name: "Round 3 finalized" }), - ).toBeTruthy() - expect( - screen.getByText("900.0000001 is claimable by holders."), - ).toBeTruthy() - }) - - it.each<[number, RegExp]>([ - [6050, /not active/], - [6052, /nothing to distribute/], - [6014, /too large/], - ])("names the vault's reason for code %i", (code, sentence) => { - renderFinalizeModal({ - status: "failed", - failure: { kind: "contract-error", code }, - }) - - expect( - screen.getByRole("heading", { name: "The vault refused this request" }), - ).toBeTruthy() - expect(screen.getByText(sentence)).toBeTruthy() - }) - - it("sends an unknown outcome to the Cycle card, not to the investor's requests", () => { - renderFinalizeModal({ status: "failed", failure: { kind: "unknown" } }) - - expect( - screen.getByText(/check the Cycle card before trying again/), - ).toBeTruthy() - expect(screen.queryByText(/your requests/)).toBeNull() - }) -}) diff --git a/app/src/components/vault/transactionModal/emergency.test.tsx b/app/src/components/vault/transactionModal/emergency.test.tsx deleted file mode 100644 index 78195831..00000000 --- a/app/src/components/vault/transactionModal/emergency.test.tsx +++ /dev/null @@ -1,263 +0,0 @@ -import { formatDate, type SubmitFailure } from "@stellar-scaffold/app-lib" -import type * as AppLib from "@stellar-scaffold/app-lib" -import { act, fireEvent, screen } from "@testing-library/react" -import { describe, expect, it, vi } from "vitest" -import TransactionModal, { - type EmergencyModalAction, -} from "../TransactionModal" -import { renderModal, renderEmergencyModal } from "./renderModal" - -vi.mock("@stellar-scaffold/app-lib", async (importOriginal) => ({ - ...(await importOriginal()), - shortAddress: (address: string) => - `${address.slice(0, 4)}...${address.slice(-4)}`, - explorerTransaction: () => null, -})) - -describe("TransactionModal, emergency actions", () => { - it("shows the signatures collected, the expiry and the signing link to copy", async () => { - const writeText = vi.fn().mockResolvedValue(undefined) - Object.defineProperty(navigator, "clipboard", { - value: { writeText }, - configurable: true, - }) - const expiresAt = new Date(Date.UTC(2026, 9, 22)) - renderEmergencyModal("pause-vault", { - status: "shared", - xdr: "AAAAXDR", - standing: { needs: 2 }, - expiresAt, - }) - const link = `${window.location.origin}/sign#AAAAXDR` - - expect( - screen.getByRole("heading", { name: "1 of 2 signatures" }), - ).toBeTruthy() - expect( - screen.getByText( - `Share the link with the next signer. Valid until ${formatDate(BigInt(expiresAt.getTime() / 1000))}.`, - ), - ).toBeTruthy() - expect( - screen.getByRole("textbox", { name: "Signing link" }) - .value, - ).toBe(link) - expect(screen.getByText("Approved in your wallet").textContent).toMatch( - /done/, - ) - - fireEvent.click(screen.getByRole("button", { name: "Copy link" })) - - expect(writeText).toHaveBeenCalledWith(link) - expect(await screen.findByRole("button", { name: "Copied" })).toBeTruthy() - }) - - it("names the weight a weighted signer adds against the threshold", () => { - renderEmergencyModal("pause-vault", { - status: "shared", - xdr: "AAAAXDR", - standing: { weight: 1, threshold: 3 }, - expiresAt: null, - }) - - expect( - screen.getByRole("heading", { name: "weight 1 of threshold 3" }), - ).toBeTruthy() - }) - - it("keeps offering the copy when the clipboard refuses, leaving the link to select", async () => { - const writeText = vi.fn().mockRejectedValue(new Error("denied")) - Object.defineProperty(navigator, "clipboard", { - value: { writeText }, - configurable: true, - }) - renderEmergencyModal("pause-vault", { - status: "shared", - xdr: "AAAAXDR", - standing: { needs: 2 }, - expiresAt: null, - }) - - fireEvent.click(screen.getByRole("button", { name: "Copy link" })) - await act(() => Promise.resolve()) - - expect(writeText).toHaveBeenCalledTimes(1) - expect(screen.getByRole("button", { name: "Copy link" })).toBeTruthy() - expect(screen.queryByRole("button", { name: "Copied" })).toBeNull() - }) - - it.each<[SubmitFailure, string]>([ - ["expired", "The transaction expired before it was submitted."], - [ - "sequence-consumed", - "The account moved on; this transaction is no longer valid.", - ], - ["bad-auth", "The signatures do not match the account's threshold."], - ["fee", "The account cannot pay the fee."], - ["fee-too-low", "The fee is below the network minimum. Propose it again."], - ["not-accepted", "The network did not accept it. Try again."], - ])("explains a %s rejection from the network", (reason, sentence) => { - renderEmergencyModal("pause-vault", { - status: "failed", - failure: { kind: "network", reason }, - hash: "d".repeat(64), - }) - - expect( - screen.getByRole("heading", { name: "The network rejected it" }), - ).toBeTruthy() - expect(screen.getByText(sentence)).toBeTruthy() - expect(screen.getByText("Sent to the network").textContent).toMatch( - /failed/, - ) - }) - - it("asks to try again when a prepared transaction went stale", () => { - const { onRetry } = renderModal({ - status: "failed", - failure: { kind: "stale" }, - }) - - expect( - screen.getByRole("heading", { name: "The transaction went stale" }), - ).toBeTruthy() - expect( - screen.getByText( - "The account moved on since it was prepared, so nothing was sent. Try again.", - ), - ).toBeTruthy() - fireEvent.click(screen.getByRole("button", { name: "Try again" })) - expect(onRetry).toHaveBeenCalledTimes(1) - }) - - it("treats an unexplained network rejection as an unknown outcome", () => { - renderEmergencyModal("pause-vault", { - status: "failed", - failure: { kind: "network", reason: "unknown" }, - }) - - expect( - screen.getByRole("heading", { name: "Something went wrong" }), - ).toBeTruthy() - expect( - screen.getByText(/check the Emergency tab before trying again/), - ).toBeTruthy() - }) - - it.each<[EmergencyModalAction, string, string]>([ - ["pause-vault", "Preparing to pause the vault", "Vault paused"], - ["unpause-vault", "Preparing to unpause the vault", "Vault unpaused"], - ["pause-nav", "Preparing to pause the NAV", "NAV paused"], - ["unpause-nav", "Preparing to unpause the NAV", "NAV unpaused"], - [ - "clear-latest", - "Preparing to clear the latest price", - "Latest price cleared", - ], - ["pause-token", "Preparing to pause the share token", "Share token paused"], - [ - "unpause-token", - "Preparing to unpause the share token", - "Share token unpaused", - ], - ])("walks %s from preparing to confirmed", (action, preparing, confirmed) => { - const { rerender } = renderEmergencyModal(action, { status: "preparing" }) - expect(screen.getByRole("heading", { name: preparing })).toBeTruthy() - - rerender( - , - ) - expect(screen.getByRole("heading", { name: confirmed })).toBeTruthy() - }) - - it("tells the signer what pausing the vault stops and what stays open", () => { - renderEmergencyModal("pause-vault", { status: "awaiting-signature" }) - - expect( - screen.getByText("Signing stops new deposits, pricing and upgrades."), - ).toBeTruthy() - }) - - it.each<[EmergencyModalAction, string, string]>([ - [ - "pause-token", - "Signing stops holder transfers only.", - "Holder transfers are stopped. Minting, burning, the vault's escrow moves and legal transfers continue.", - ], - [ - "unpause-token", - "Signing resumes holder transfers.", - "Holder transfers are open again.", - ], - ])( - "words %s after what the share token enforces", - (action, signing, done) => { - const { rerender } = renderEmergencyModal(action, { - status: "awaiting-signature", - }) - expect(screen.getByText(signing)).toBeTruthy() - - rerender( - , - ) - expect(screen.getByText(done)).toBeTruthy() - }, - ) - - it.each<[EmergencyModalAction, number, string, RegExp]>([ - ["pause-vault", 1000, "vault", /The vault is already paused/], - ["unpause-vault", 1001, "vault", /The vault is not paused/], - ["pause-token", 1000, "share token", /The share token is already paused/], - ["unpause-token", 1001, "share token", /The share token is not paused/], - ["pause-nav", 2000, "oracle", /does not hold the role/], - ["clear-latest", 3008, "oracle", /The NAV is not paused/], - ])( - "names the reason %s was refused with code %i", - (action, code, refuser, sentence) => { - renderEmergencyModal(action, { - status: "failed", - failure: { kind: "contract-error", code }, - }) - - expect( - screen.getByRole("heading", { - name: `The ${refuser} refused this request`, - }), - ).toBeTruthy() - expect(screen.getByText(sentence)).toBeTruthy() - }, - ) - - it("falls back to a generic refusal from the right contract without the raw code", () => { - renderEmergencyModal("pause-token", { - status: "failed", - failure: { kind: "contract-error", code: 9999 }, - }) - - expect( - screen.getByText(/The share token refused this request\. Try again/), - ).toBeTruthy() - expect(screen.queryByText(/9999/)).toBeNull() - }) - - it("sends an unknown outcome to the Emergency tab", () => { - renderEmergencyModal("pause-nav", { - status: "failed", - failure: { kind: "unknown" }, - }) - - expect( - screen.getByText(/check the Emergency tab before trying again/), - ).toBeTruthy() - }) -}) diff --git a/app/src/components/vault/transactionModal/governance.test.tsx b/app/src/components/vault/transactionModal/governance.test.tsx deleted file mode 100644 index f3068ea6..00000000 --- a/app/src/components/vault/transactionModal/governance.test.tsx +++ /dev/null @@ -1,333 +0,0 @@ -import type * as AppLib from "@stellar-scaffold/app-lib" -import { screen } from "@testing-library/react" -import { describe, expect, it, vi } from "vitest" -import { type GovernanceModalAction } from "../TransactionModal" -import { renderGovernanceModal } from "./renderModal" - -vi.mock("@stellar-scaffold/app-lib", async (importOriginal) => ({ - ...(await importOriginal()), - shortAddress: (address: string) => - `${address.slice(0, 4)}...${address.slice(-4)}`, - explorerTransaction: () => null, -})) - -describe("TransactionModal, governance actions", () => { - it.each<[GovernanceModalAction, string, string, string, string]>([ - [ - "grant-vault-role", - "Manager to GABC...WXYZ", - "Signing grants Manager to GABC...WXYZ.", - "Role granted", - "Granted Manager to GABC...WXYZ.", - ], - [ - "revoke-token-role", - "Manager from CVAU...LTID", - "Signing revokes Manager from CVAU...LTID.", - "Role revoked", - "Revoked Manager from CVAU...LTID.", - ], - ])( - "names the role change of %s", - (action, value, signing, done, doneBody) => { - const { unmount } = renderGovernanceModal( - action, - { status: "awaiting-signature" }, - value, - ) - expect(screen.getByText(signing)).toBeTruthy() - unmount() - - renderGovernanceModal(action, { status: "confirmed" }, value) - expect(screen.getByRole("heading", { name: done })).toBeTruthy() - expect(screen.getByText(doneBody)).toBeTruthy() - }, - ) - - it.each<[GovernanceModalAction, number, string, RegExp]>([ - ["grant-vault-role", 2000, "vault", /does not hold the role/], - ["revoke-oracle-role", 2002, "oracle", /holders changed/], - ["revoke-token-role", 2007, "share token", /does not hold this role/], - [ - "grant-oracle-role", - 2010, - "oracle", - /The oracle already has the maximum number of roles/, - ], - ["cancel-vault-handover", 2200, "vault", /no pending handover/], - ["hand-over-oracle-admin", 2201, "oracle", /expiry is outside/], - ["cancel-token-handover", 2202, "share token", /different address/], - ["accept-vault-admin", 2203, "vault", /expired before it was accepted/], - [ - "accept-vault-admin", - 6046, - "vault", - /admin role can no longer change hands/, - ], - [ - "accept-oracle-admin", - 3018, - "oracle", - /admin role can no longer change hands/, - ], - [ - "accept-token-admin", - 7008, - "share token", - /admin role can no longer change hands/, - ], - ["apply-change", 1000, "vault", /The vault is paused/], - ["apply-change", 6046, "vault", /winding down/], - ["propose-wind-down", 6046, "vault", /wind-down is already active/], - ["cancel-wind-down", 6047, "vault", /No wind-down is proposed/], - ["propose-wind-down", 6048, "vault", /wind-down is already proposed/], - ["propose-code-upgrade", 6055, "vault", /code change is already pending/], - ["cancel-change", 6056, "vault", /no pending change with that id/], - ["apply-change", 6057, "vault", /delay has not passed yet/], - ["propose-grant-role", 6063, "vault", /16 pending changes/], - ["propose-admin", 6064, "vault", /Setup is still open/], - ["grant-vault-role", 6065, "vault", /goes through the timelock/], - ["propose-upgrade-delay", 6058, "vault", /cannot be under 7 days/], - ["propose-upgrade-delay", 6059, "vault", /cannot be under the notice/], - ["propose-upgrade-delay", 6060, "vault", /cannot exceed 90 days/], - [ - "cancel-token-change", - 7001, - "share token", - /no pending change with that id/, - ], - ["apply-token-change", 7002, "share token", /delay has not passed yet/], - ["propose-token-grant-role", 7003, "share token", /16 pending changes/], - ["propose-token-admin", 7004, "share token", /Setup is still open/], - ["grant-token-role", 7005, "share token", /goes through the timelock/], - ["apply-token-change", 7008, "share token", /vault is winding down/], - ["apply-token-change", 7009, "share token", /vault is paused/], - ["apply-token-change", 7011, "share token", /upgrade is already pending/], - [ - "hand-over-token-admin", - 7016, - "share token", - /hand-over is still pending/, - ], - ["cancel-oracle-change", 3011, "oracle", /no pending change with that id/], - ["apply-oracle-change", 3012, "oracle", /delay has not passed yet/], - ["propose-oracle-grant-role", 3013, "oracle", /16 pending changes/], - ["propose-oracle-admin", 3014, "oracle", /Setup is still open/], - ["grant-oracle-role", 3015, "oracle", /goes through the timelock/], - ["apply-oracle-change", 3016, "oracle", /setup has already finished/], - ["apply-oracle-change", 3017, "oracle", /not bound to a vault/], - ["apply-oracle-change", 3018, "oracle", /vault is winding down/], - ["apply-oracle-change", 3019, "oracle", /holds every oracle change/], - ["apply-oracle-change", 3020, "oracle", /shorter than the vault's notice/], - ["propose-oracle-upgrade", 3021, "oracle", /upgrade is already pending/], - ])( - "names the reason %s was refused with code %i", - (action, code, refuser, sentence) => { - renderGovernanceModal(action, { - status: "failed", - failure: { kind: "contract-error", code }, - }) - - expect( - screen.getByRole("heading", { - name: `The ${refuser} refused this request`, - }), - ).toBeTruthy() - expect(screen.getByText(sentence)).toBeTruthy() - }, - ) - - it.each<[GovernanceModalAction, string, string, string, string]>([ - [ - "hand-over-vault-admin", - "GABC...WXYZ becomes admin once it accepts before 21 Nov, 22:13 UTC.", - "Signing proposes the handover.", - "Handover proposed", - "GABC...WXYZ becomes admin once it accepts before 21 Nov, 22:13 UTC.", - ], - [ - "cancel-oracle-handover", - "GABC...WXYZ", - "Signing withdraws the offer to GABC...WXYZ.", - "Handover cancelled", - "GABC...WXYZ can no longer accept.", - ], - [ - "accept-token-admin", - "share token", - "Signing makes this wallet the share token admin.", - "Admin accepted", - "This wallet is now the share token admin.", - ], - ])( - "names the handover step of %s", - (action, value, signing, done, doneBody) => { - const { unmount } = renderGovernanceModal( - action, - { status: "awaiting-signature" }, - value, - ) - expect(screen.getByText(signing)).toBeTruthy() - unmount() - - renderGovernanceModal(action, { status: "confirmed" }, value) - expect(screen.getByRole("heading", { name: done })).toBeTruthy() - expect(screen.getByText(doneBody)).toBeTruthy() - }, - ) - - it.each<[GovernanceModalAction, string, string, string, string]>([ - [ - "propose-code-upgrade", - "Code abab...abab", - "Signing queues Code abab...abab.", - "Upgrade proposed", - "Code abab...abab can apply once the upgrade delay passes.", - ], - [ - "apply-change", - "vault change #3: Upgrade delay 14d", - "Signing applies vault change #3: Upgrade delay 14d.", - "Change applied", - "Applied vault change #3: Upgrade delay 14d.", - ], - [ - "cancel-change", - "vault change #2: Code upgrade abab...abab", - "Signing withdraws vault change #2: Code upgrade abab...abab.", - "Change cancelled", - "Withdrew vault change #2: Code upgrade abab...abab.", - ], - [ - "propose-grant-role", - "Manager to GABC...WXYZ", - "Signing queues the grant of Manager to GABC...WXYZ.", - "Change proposed", - "The grant of Manager to GABC...WXYZ applies once the upgrade delay passes.", - ], - [ - "propose-revoke-role", - "Manager from GABC...WXYZ", - "Signing queues the revoke of Manager from GABC...WXYZ.", - "Change proposed", - "The revoke of Manager from GABC...WXYZ applies once the upgrade delay passes.", - ], - [ - "propose-admin", - "GABC...WXYZ", - "Signing queues the hand-over to GABC...WXYZ.", - "Change proposed", - "The hand-over to GABC...WXYZ applies once the upgrade delay passes.", - ], - [ - "propose-token-grant-role", - "Manager to GABC...WXYZ", - "Signing queues the grant of Manager to GABC...WXYZ.", - "Change proposed", - "The grant of Manager to GABC...WXYZ applies once the vault's upgrade delay passes.", - ], - [ - "propose-token-revoke-role", - "Manager from GABC...WXYZ", - "Signing queues the revoke of Manager from GABC...WXYZ.", - "Change proposed", - "The revoke of Manager from GABC...WXYZ applies once the vault's upgrade delay passes.", - ], - [ - "propose-token-admin", - "GABC...WXYZ", - "Signing queues the hand-over to GABC...WXYZ.", - "Change proposed", - "The hand-over to GABC...WXYZ applies once the vault's upgrade delay passes.", - ], - [ - "apply-token-change", - "share token change #1: Compliance contract CABC...WXYZ", - "Signing applies share token change #1: Compliance contract CABC...WXYZ.", - "Change applied", - "Applied share token change #1: Compliance contract CABC...WXYZ.", - ], - [ - "cancel-token-change", - "share token change #1: Compliance contract CABC...WXYZ", - "Signing withdraws share token change #1: Compliance contract CABC...WXYZ.", - "Change cancelled", - "Withdrew share token change #1: Compliance contract CABC...WXYZ.", - ], - [ - "propose-oracle-grant-role", - "Attester to GABC...WXYZ", - "Signing queues the grant of Attester to GABC...WXYZ.", - "Change proposed", - "The grant of Attester to GABC...WXYZ applies once the vault's upgrade delay passes.", - ], - [ - "propose-oracle-admin", - "GABC...WXYZ", - "Signing queues the hand-over to GABC...WXYZ.", - "Change proposed", - "The hand-over to GABC...WXYZ applies once the vault's upgrade delay passes.", - ], - [ - "propose-oracle-upgrade", - "Code abab...abab", - "Signing queues Code abab...abab.", - "Upgrade proposed", - "Code abab...abab can apply once the vault's upgrade delay passes.", - ], - [ - "apply-oracle-change", - "oracle change #1: Admin hand-over GABC...WXYZ", - "Signing applies oracle change #1: Admin hand-over GABC...WXYZ.", - "Change applied", - "Applied oracle change #1: Admin hand-over GABC...WXYZ.", - ], - [ - "cancel-oracle-change", - "oracle change #1: Admin hand-over GABC...WXYZ", - "Signing withdraws oracle change #1: Admin hand-over GABC...WXYZ.", - "Change cancelled", - "Withdrew oracle change #1: Admin hand-over GABC...WXYZ.", - ], - [ - "propose-wind-down", - "Activates 21 Nov, 22:13 UTC.", - "Signing starts the wind-down countdown.", - "Wind-down proposed", - "Activates 21 Nov, 22:13 UTC.", - ], - [ - "cancel-wind-down", - "", - "Signing withdraws the wind-down.", - "Wind-down cancelled", - "The vault stays open.", - ], - ])( - "names the timelock step of %s", - (action, value, signing, done, doneBody) => { - const { unmount } = renderGovernanceModal( - action, - { status: "awaiting-signature" }, - value, - ) - expect(screen.getByText(signing)).toBeTruthy() - unmount() - - renderGovernanceModal(action, { status: "confirmed" }, value) - expect(screen.getByRole("heading", { name: done })).toBeTruthy() - expect(screen.getByText(doneBody)).toBeTruthy() - }, - ) - - it("sends an unknown outcome to the Governance tab", () => { - renderGovernanceModal("grant-token-role", { - status: "failed", - failure: { kind: "unknown" }, - }) - - expect( - screen.getByText(/check the Governance tab before trying again/), - ).toBeTruthy() - }) -}) diff --git a/app/src/components/vault/transactionModal/investor.test.tsx b/app/src/components/vault/transactionModal/investor.test.tsx deleted file mode 100644 index ce67042f..00000000 --- a/app/src/components/vault/transactionModal/investor.test.tsx +++ /dev/null @@ -1,807 +0,0 @@ -import { type Amount } from "@stellar-scaffold/app-lib" -import type * as AppLib from "@stellar-scaffold/app-lib" -import { fireEvent, screen } from "@testing-library/react" -import { describe, expect, it, vi } from "vitest" -import { - renderCancelModal, - renderCancelRedeemModal, - renderClaimModal, - renderRedeemModal, - renderClaimRedeemModal, -} from "./renderModal" - -vi.mock("@stellar-scaffold/app-lib", async (importOriginal) => ({ - ...(await importOriginal()), - shortAddress: (address: string) => - `${address.slice(0, 4)}...${address.slice(-4)}`, - explorerTransaction: () => null, -})) - -describe("TransactionModal, cancelling", () => { - it("opens with a preparing state naming the cancellation, not the request", () => { - renderCancelModal({ status: "preparing" }) - - expect( - screen.getByRole("heading", { name: "Preparing your cancellation" }), - ).toBeTruthy() - }) - - it("tells the investor their deposit returns to their wallet, honestly, without escrow or pricing language", () => { - renderCancelModal({ status: "awaiting-signature" }) - - expect( - screen.getByRole("heading", { name: "Confirm in your wallet" }), - ).toBeTruthy() - expect(screen.getByText(/150\.00 USDC/)).toBeTruthy() - expect(screen.getByText(/from escrow/)).toBeTruthy() - expect(screen.queryByText(/into escrow/)).toBeNull() - expect(screen.getByText(/withdrawn, not priced/)).toBeTruthy() - }) - - it("tells the investor the cancellation is on its way, distinct from the subscribe copy", () => { - renderCancelModal({ status: "submitted", hash: "a".repeat(64) }) - - expect( - screen.getByRole("heading", { name: "Sending your cancellation" }), - ).toBeTruthy() - expect(screen.getByText(/on its way to the network/)).toBeTruthy() - }) - - it("shows the returned amount once confirmed, with no batch or pricing claim", () => { - renderCancelModal({ - status: "confirmed", - refundedAmount: 150_0000000n as Amount, - hash: "b".repeat(64), - }) - - expect( - screen.getByRole("heading", { name: "Request cancelled" }), - ).toBeTruthy() - expect(screen.getByText(/150\.00 USDC/)).toBeTruthy() - expect(screen.queryByText(/Batch/)).toBeNull() - expect(screen.queryByText(/attestation/)).toBeNull() - expect(screen.queryByText(/Epoch/)).toBeNull() - }) - - it("reports the amount the vault returned, not the one captured when the investor pressed cancel", () => { - renderCancelModal({ - status: "confirmed", - refundedAmount: 275_5000000n as Amount, - hash: "b".repeat(64), - }) - - expect(screen.getByText(/275\.50 USDC/)).toBeTruthy() - expect(screen.queryByText(/150\.00/)).toBeNull() - }) - - it("renders the refunded amount at full precision, not rounded to two fraction digits", () => { - renderCancelModal({ - status: "confirmed", - refundedAmount: 952380952n as Amount, - hash: "b".repeat(64), - }) - - expect(screen.getByText(/95\.2380952 USDC/)).toBeTruthy() - }) - - it("names the vault's own reason for a cancel-specific contract refusal, distinct from subscribe's codes", () => { - renderCancelModal({ - status: "failed", - failure: { kind: "contract-error", code: 6039 }, - }) - - expect( - screen.getByRole("heading", { name: "The vault refused this request" }), - ).toBeTruthy() - expect( - screen.getByText(/already been priced. Claim your shares instead/), - ).toBeTruthy() - }) - - it("names PriceAvailable distinctly from AlreadyPriced, without sending the investor to claim yet", () => { - renderCancelModal({ - status: "failed", - failure: { kind: "contract-error", code: 6041 }, - }) - - expect(screen.getByText(/A price is available/)).toBeTruthy() - expect(screen.getByText(/claimable once it is/)).toBeTruthy() - expect(screen.queryByText(/Claim your shares instead/)).toBeNull() - }) - - it("names RequestNotFound for a cancellation of a request that no longer exists", () => { - renderCancelModal({ - status: "failed", - failure: { kind: "contract-error", code: 6001 }, - }) - - expect(screen.getByText(/no longer exists to cancel/)).toBeTruthy() - }) - - it("names EpochNotFound as a request that could not be found", () => { - renderCancelModal({ - status: "failed", - failure: { kind: "contract-error", code: 6029 }, - }) - - expect(screen.getByText(/This request could not be found/)).toBeTruthy() - }) - - it("names EscrowNotCovered as a deposit the vault cannot return until it is funded again", () => { - renderCancelModal({ - status: "failed", - failure: { kind: "contract-error", code: 6076 }, - }) - - expect( - screen.getByRole("heading", { name: "The vault refused this request" }), - ).toBeTruthy() - expect( - screen.getByText(/You can cancel once it is funded again/), - ).toBeTruthy() - }) - - it("falls back to a generic refusal for a cancel error it does not recognize, without the raw code", () => { - renderCancelModal({ - status: "failed", - failure: { kind: "contract-error", code: 9999 }, - }) - - expect(screen.getByText(/contact support/)).toBeTruthy() - expect(screen.queryByText(/9999/)).toBeNull() - }) - - it("falls back to a generic refusal for 304 on a deposit cancellation, since only the share token's identity check can raise it", () => { - renderCancelModal({ - status: "failed", - failure: { kind: "contract-error", code: 304 }, - }) - - expect(screen.getByText(/contact support/)).toBeTruthy() - expect(screen.queryByText(/304/)).toBeNull() - expect(screen.queryByText(/shares cannot be returned/)).toBeNull() - }) - - it("reads a declined cancellation as a choice, not a failure, and offers to try again", () => { - const { onRetry } = renderCancelModal({ - status: "failed", - failure: { kind: "declined" }, - }) - - expect( - screen.getByRole("heading", { name: "You declined the request" }), - ).toBeTruthy() - - fireEvent.click(screen.getByRole("button", { name: "Try again" })) - expect(onRetry).toHaveBeenCalledTimes(1) - }) - - it("shows the same step progress machinery for a cancellation in flight", () => { - renderCancelModal({ status: "awaiting-signature" }) - - const steps = screen.getAllByRole("listitem") - expect(steps.map((step) => step.textContent)).toEqual([ - "Approved in your wallet, in progress", - "Sent to the network", - "Recorded", - ]) - }) - - it("is reachable as a dialog and dismissible by its close control", () => { - const { onClose } = renderCancelModal({ status: "awaiting-signature" }) - - fireEvent.click(screen.getByRole("button", { name: "Close" })) - expect(onClose).toHaveBeenCalledTimes(1) - }) -}) - -describe("TransactionModal, cancelling a redemption", () => { - it("opens with a preparing state naming the cancellation", () => { - renderCancelRedeemModal({ status: "preparing" }) - - expect( - screen.getByRole("heading", { name: "Preparing your cancellation" }), - ).toBeTruthy() - }) - - it("tells the investor their shares return to their wallet, honestly, without escrow or pricing language", () => { - renderCancelRedeemModal({ status: "awaiting-signature" }) - - expect( - screen.getByRole("heading", { name: "Confirm in your wallet" }), - ).toBeTruthy() - expect(screen.getByText(/100\.00 vUSDC/)).toBeTruthy() - expect(screen.getByText(/from escrow/)).toBeTruthy() - expect(screen.queryByText(/into escrow/)).toBeNull() - expect(screen.getByText(/withdrawn, not priced/)).toBeTruthy() - }) - - it("shows the returned shares, in the share ticker, once confirmed", () => { - renderCancelRedeemModal({ - status: "confirmed", - returnedShares: 100_0000000n as Amount, - surrendered: false, - hash: "b".repeat(64), - }) - - expect( - screen.getByRole("heading", { name: "Request cancelled" }), - ).toBeTruthy() - expect(screen.getByText(/100\.00 vUSDC/)).toBeTruthy() - expect(screen.queryByText(/Batch/)).toBeNull() - }) - - it("renders the returned shares at full precision, not rounded to two fraction digits", () => { - renderCancelRedeemModal({ - status: "confirmed", - returnedShares: 952380952n as Amount, - surrendered: false, - hash: "b".repeat(64), - }) - - expect(screen.getByText(/95\.2380952 vUSDC/)).toBeTruthy() - }) - - it("names the vault's own reason for a cancel-specific contract refusal, reusing the same table as cancelling a deposit", () => { - renderCancelRedeemModal({ - status: "failed", - failure: { kind: "contract-error", code: 6039 }, - }) - - expect( - screen.getByText(/already been priced. Claim your shares instead/), - ).toBeTruthy() - }) - - it("names NotAllowlisted as a delisted holder whose shares stay queued, pointing to the cash claim instead", () => { - renderCancelRedeemModal({ - status: "failed", - failure: { kind: "contract-error", code: 6068 }, - }) - - expect(screen.getByText(/not allowlisted/)).toBeTruthy() - expect(screen.getByText(/claim the USDC/i)).toBeTruthy() - }) - - it("names HolderNotEligible as shares the address cannot receive, pointing to the cash claim instead", () => { - renderCancelRedeemModal({ - status: "failed", - failure: { kind: "contract-error", code: 6073 }, - }) - - expect( - screen.getByText(/cannot receive these shares right now/), - ).toBeTruthy() - expect(screen.getByText(/claim the USDC/i)).toBeTruthy() - }) - - it("falls back to a generic refusal for 304 on a withdrawal cancellation, since the vault checks the holder itself", () => { - renderCancelRedeemModal({ - status: "failed", - failure: { kind: "contract-error", code: 304 }, - }) - - expect(screen.getByText(/contact support/)).toBeTruthy() - expect(screen.queryByText(/allowlisted/)).toBeNull() - }) - - it("falls back to a generic refusal for a cancel-redeem error it does not recognize, without the raw code", () => { - renderCancelRedeemModal({ - status: "failed", - failure: { kind: "contract-error", code: 9999 }, - }) - - expect(screen.getByText(/contact support/)).toBeTruthy() - expect(screen.queryByText(/9999/)).toBeNull() - }) - - it("is reachable as a dialog and dismissible by its close control", () => { - const { onClose } = renderCancelRedeemModal({ - status: "awaiting-signature", - }) - - fireEvent.click(screen.getByRole("button", { name: "Close" })) - expect(onClose).toHaveBeenCalledTimes(1) - }) -}) - -describe("TransactionModal, claiming", () => { - it("opens with a preparing state naming the claim", () => { - renderClaimModal({ status: "preparing" }) - - expect( - screen.getByRole("heading", { name: "Preparing your claim" }), - ).toBeTruthy() - }) - - it("tells the investor what claiming means before they sign, including the refund path, without naming an epoch", () => { - renderClaimModal({ status: "awaiting-signature" }) - - expect( - screen.getByRole("heading", { name: "Confirm in your wallet" }), - ).toBeTruthy() - expect( - screen.getByText( - "You receive what you're owed. If the price leaves no shares to claim, 150.00 USDC returns to your wallet instead.", - ), - ).toBeTruthy() - expect(screen.queryByText(/Epoch/)).toBeNull() - expect(screen.queryByText(/epoch/)).toBeNull() - }) - - it("tells the investor the claim is on its way, distinct from subscribe and cancel copy", () => { - renderClaimModal({ status: "submitted", hash: "a".repeat(64) }) - - expect( - screen.getByRole("heading", { name: "Sending your claim" }), - ).toBeTruthy() - expect(screen.getByText(/on its way to the network/)).toBeTruthy() - }) - - it("shows the shares minted in the share token, not the deposit token, once confirmed", () => { - renderClaimModal({ - status: "confirmed", - sharesMinted: 100_0000000n as Amount, - hash: "b".repeat(64), - }) - - expect(screen.getByRole("heading", { name: "Shares claimed" })).toBeTruthy() - expect(screen.getByText(/100\.00 vUSDC/)).toBeTruthy() - expect(screen.queryByText(/150\.00 USDC/)).toBeNull() - }) - - it("renders the shares minted at full precision, not rounded to two fraction digits", () => { - renderClaimModal({ - status: "confirmed", - sharesMinted: 952380952n as Amount, - hash: "b".repeat(64), - }) - - expect(screen.getByText(/95\.2380952 vUSDC/)).toBeTruthy() - }) - - it("tells the investor their deposit came back, not that shares were issued, on a zero result", () => { - renderClaimModal({ - status: "confirmed", - sharesMinted: 0n as Amount, - hash: "b".repeat(64), - }) - - expect( - screen.getByRole("heading", { name: "Deposit returned" }), - ).toBeTruthy() - expect(screen.getByText(/150\.00 USDC/)).toBeTruthy() - expect(screen.queryByText(/vUSDC/)).toBeNull() - }) - - it("names RequestNotFound for a claim with no matching request", () => { - renderClaimModal({ - status: "failed", - failure: { kind: "contract-error", code: 6001 }, - }) - - expect(screen.getByText(/no request to claim/)).toBeTruthy() - }) - - it("names EpochNotFound as a request that could not be found", () => { - renderClaimModal({ - status: "failed", - failure: { kind: "contract-error", code: 6029 }, - }) - - expect(screen.getByText(/This request could not be found/)).toBeTruthy() - }) - - it("names InvalidSharePrice as no valid price published yet", () => { - renderClaimModal({ - status: "failed", - failure: { kind: "contract-error", code: 6031 }, - }) - - expect(screen.getByText(/not published a valid price/)).toBeTruthy() - }) - - it("names AlreadyClaimed distinctly", () => { - renderClaimModal({ - status: "failed", - failure: { kind: "contract-error", code: 6035 }, - }) - - expect(screen.getByText(/already been claimed/)).toBeTruthy() - }) - - it("names ClaimNotCovered as the reserve not yet covering this claim", () => { - renderClaimModal({ - status: "failed", - failure: { kind: "contract-error", code: 6037 }, - }) - - expect(screen.getByText(/not yet hold enough in reserve/)).toBeTruthy() - }) - - it("names AmountTooLarge as the conversion overflowing", () => { - renderClaimModal({ - status: "failed", - failure: { kind: "contract-error", code: 6014 }, - }) - - expect(screen.getByText(/too large to complete/)).toBeTruthy() - }) - - it("falls back to a generic refusal for 304 on a share claim, since a delisted holder's shares join an exit instead", () => { - renderClaimModal({ - status: "failed", - failure: { kind: "contract-error", code: 304 }, - }) - - expect(screen.getByText(/contact support/)).toBeTruthy() - expect(screen.queryByText(/allowlisted/)).toBeNull() - }) - - it("falls back to a generic refusal for a claim error it does not recognize, without the raw code", () => { - renderClaimModal({ - status: "failed", - failure: { kind: "contract-error", code: 9999 }, - }) - - expect(screen.getByText(/contact support/)).toBeTruthy() - expect(screen.queryByText(/9999/)).toBeNull() - }) - - it("reads a declined claim as a choice, not a failure, and offers to try again", () => { - const { onRetry } = renderClaimModal({ - status: "failed", - failure: { kind: "declined" }, - }) - - expect( - screen.getByRole("heading", { name: "You declined the request" }), - ).toBeTruthy() - - fireEvent.click(screen.getByRole("button", { name: "Try again" })) - expect(onRetry).toHaveBeenCalledTimes(1) - }) - - it("shows the same step progress machinery for a claim in flight", () => { - renderClaimModal({ status: "awaiting-signature" }) - - const steps = screen.getAllByRole("listitem") - expect(steps.map((step) => step.textContent)).toEqual([ - "Approved in your wallet, in progress", - "Sent to the network", - "Recorded", - ]) - }) - - it("is reachable as a dialog and dismissible by its close control", () => { - const { onClose } = renderClaimModal({ status: "awaiting-signature" }) - - fireEvent.click(screen.getByRole("button", { name: "Close" })) - expect(onClose).toHaveBeenCalledTimes(1) - }) -}) - -describe("TransactionModal, redeeming", () => { - it("opens with a preparing state naming the redemption request", () => { - renderRedeemModal({ status: "preparing" }) - - expect( - screen.getByRole("heading", { - name: "Preparing your redemption request", - }), - ).toBeTruthy() - }) - - it("tells the investor their shares are locked in escrow, in share terms, without naming an epoch", () => { - renderRedeemModal({ status: "awaiting-signature" }) - - expect( - screen.getByRole("heading", { name: "Confirm in your wallet" }), - ).toBeTruthy() - expect(screen.getByText(/100\.00 vUSDC/)).toBeTruthy() - expect(screen.getByText(/in escrow/)).toBeTruthy() - expect(screen.getByText(/not today/)).toBeTruthy() - expect(screen.queryByText(/Epoch/)).toBeNull() - expect(screen.queryByText(/epoch/)).toBeNull() - expect(screen.queryByText(/batch/i)).toBeNull() - }) - - it("tells the investor the redemption request is on its way, distinct from subscribe copy", () => { - renderRedeemModal({ status: "submitted", hash: "a".repeat(64) }) - - expect( - screen.getByRole("heading", { name: "Sending your redemption request" }), - ).toBeTruthy() - expect(screen.getByText(/on its way to the network/)).toBeTruthy() - }) - - it("shows the confirmed shares and transaction hash once confirmed", () => { - renderRedeemModal({ - status: "confirmed", - hash: "b".repeat(64), - }) - - expect( - screen.getByRole("heading", { name: "Redemption request submitted" }), - ).toBeTruthy() - expect(screen.getByText(/100\.00 vUSDC/)).toBeTruthy() - expect(screen.queryByText(/Batch/)).toBeNull() - expect(screen.getByText(/bbbb\.\.\.bbbb/)).toBeTruthy() - }) - - it("never mentions locking or attestation once confirmed", () => { - renderRedeemModal({ status: "confirmed", hash: "b".repeat(64) }) - - expect(screen.queryByText(/locked/i)).toBeNull() - expect(screen.queryByText(/attestation/i)).toBeNull() - }) - - it("names InvalidAmount for a non-positive share amount", () => { - renderRedeemModal({ - status: "failed", - failure: { kind: "contract-error", code: 6007 }, - }) - - expect(screen.getByText(/amount greater than zero/)).toBeTruthy() - }) - - it("names RequestOutstanding as a redemption request already open, distinct from subscribe's wording", () => { - renderRedeemModal({ - status: "failed", - failure: { kind: "contract-error", code: 6009 }, - }) - - expect( - screen.getByText(/already have a redemption request open/), - ).toBeTruthy() - }) - - it("names AmountTooLarge as too large for the vault to accept", () => { - renderRedeemModal({ - status: "failed", - failure: { kind: "contract-error", code: 6014 }, - }) - - expect(screen.getByText(/too large for the vault to accept/)).toBeTruthy() - }) - - it("names EpochNotFound as a request that could not be found", () => { - renderRedeemModal({ - status: "failed", - failure: { kind: "contract-error", code: 6029 }, - }) - - expect(screen.getByText(/This request could not be found/)).toBeTruthy() - }) - - it("names WindDownActive as the vault not accepting new redemptions", () => { - renderRedeemModal({ - status: "failed", - failure: { kind: "contract-error", code: 6046 }, - }) - - expect(screen.getByText(/not accepting new redemptions/)).toBeTruthy() - }) - - it("names SetupOpen as redemptions starting once setup closes", () => { - renderRedeemModal({ - status: "failed", - failure: { kind: "contract-error", code: 6064 }, - }) - - expect(screen.getByText(/setup is still open/)).toBeTruthy() - expect(screen.getByText(/Redemptions start once it closes/)).toBeTruthy() - }) - - it("falls back to a generic refusal for a redeem error it does not recognize, without the raw code", () => { - renderRedeemModal({ - status: "failed", - failure: { kind: "contract-error", code: 9999 }, - }) - - expect(screen.getByText(/contact support/)).toBeTruthy() - expect(screen.queryByText(/9999/)).toBeNull() - }) - - it("reads a declined redemption request as a choice, not a failure, and offers to try again", () => { - const { onRetry } = renderRedeemModal({ - status: "failed", - failure: { kind: "declined" }, - }) - - expect( - screen.getByRole("heading", { name: "You declined the request" }), - ).toBeTruthy() - - fireEvent.click(screen.getByRole("button", { name: "Try again" })) - expect(onRetry).toHaveBeenCalledTimes(1) - }) - - it("shows the same step progress machinery for a redemption request in flight", () => { - renderRedeemModal({ status: "awaiting-signature" }) - - const steps = screen.getAllByRole("listitem") - expect(steps.map((step) => step.textContent)).toEqual([ - "Approved in your wallet, in progress", - "Sent to the network", - "Recorded", - ]) - }) - - it("is reachable as a dialog and dismissible by its close control", () => { - const { onClose } = renderRedeemModal({ status: "awaiting-signature" }) - - fireEvent.click(screen.getByRole("button", { name: "Close" })) - expect(onClose).toHaveBeenCalledTimes(1) - }) -}) - -describe("TransactionModal, claiming a redemption", () => { - it("opens with a preparing state naming the claim", () => { - renderClaimRedeemModal({ status: "preparing" }) - - expect( - screen.getByRole("heading", { name: "Preparing your claim" }), - ).toBeTruthy() - }) - - it("tells the investor what claiming sends them, without naming an epoch", () => { - renderClaimRedeemModal({ status: "awaiting-signature" }) - - expect( - screen.getByRole("heading", { name: "Confirm in your wallet" }), - ).toBeTruthy() - expect( - screen.getByText( - "You receive what you're owed. Signing sends 100.00 USDC to your wallet.", - ), - ).toBeTruthy() - expect(screen.queryByText(/Epoch/)).toBeNull() - expect(screen.queryByText(/epoch/)).toBeNull() - }) - - it("tells the investor the claim is on its way to the network", () => { - renderClaimRedeemModal({ status: "submitted", hash: "a".repeat(64) }) - - expect( - screen.getByRole("heading", { name: "Sending your claim" }), - ).toBeTruthy() - expect(screen.getByText(/on its way to the network/)).toBeTruthy() - }) - - it("shows the claimed asset, in its own ticker, once confirmed", () => { - renderClaimRedeemModal({ - status: "confirmed", - assetsClaimed: 150_0000000n as Amount, - sharesReturned: false, - hash: "b".repeat(64), - }) - - expect(screen.getByRole("heading", { name: "USDC claimed" })).toBeTruthy() - expect(screen.getByText(/150\.00 USDC/)).toBeTruthy() - }) - - it("renders the claimed asset amount at full precision, not rounded to two fraction digits", () => { - renderClaimRedeemModal({ - status: "confirmed", - assetsClaimed: 952380952n as Amount, - sharesReturned: false, - hash: "b".repeat(64), - }) - - expect(screen.getByText(/95\.2380952 USDC/)).toBeTruthy() - }) - - it("tells the investor their shares came back, not that an asset was claimed, on a zero result", () => { - renderClaimRedeemModal({ - status: "confirmed", - assetsClaimed: 0n as Amount, - sharesReturned: true, - hash: "b".repeat(64), - }) - - expect( - screen.getByRole("heading", { name: "Shares returned" }), - ).toBeTruthy() - expect( - screen.getByText( - "The price left nothing to claim, so your shares have been returned to your wallet instead.", - ), - ).toBeTruthy() - expect(screen.queryByText(/USDC claimed/)).toBeNull() - }) - - it("names EpochNotFulfilled as not yet priced", () => { - renderClaimRedeemModal({ - status: "failed", - failure: { kind: "contract-error", code: 6034 }, - }) - - expect(screen.getByText(/has not priced yet/)).toBeTruthy() - }) - - it("names ClaimNotCovered with the deposit asset's ticker, distinct from the untickered claim wording for the same code", () => { - renderClaimRedeemModal({ - status: "failed", - failure: { kind: "contract-error", code: 6037 }, - }) - - expect(screen.getByText(/enough USDC in reserve/)).toBeTruthy() - }) - - it("names HolderNotEligible as the zero-price return the address cannot receive, in the same words as a withdrawal cancellation", () => { - renderClaimRedeemModal({ - status: "failed", - failure: { kind: "contract-error", code: 6073 }, - }) - - expect(screen.getByText(/tried to return your shares/)).toBeTruthy() - expect( - screen.getByText(/cannot receive these shares right now/), - ).toBeTruthy() - expect(screen.queryByText(/claim the USDC/i)).toBeNull() - }) - - it("falls back to a generic refusal for 304 on a withdrawal claim, since the vault checks the holder itself", () => { - renderClaimRedeemModal({ - status: "failed", - failure: { kind: "contract-error", code: 304 }, - }) - - expect(screen.getByText(/contact support/)).toBeTruthy() - expect(screen.queryByText(/allowlisted/)).toBeNull() - }) - - it("names AlreadyClaimed by delegating to the shared claim wording", () => { - renderClaimRedeemModal({ - status: "failed", - failure: { kind: "contract-error", code: 6035 }, - }) - - expect(screen.getByText(/already been claimed/)).toBeTruthy() - }) - - it("falls back to a generic refusal for a claim-redeem error it does not recognize, without the raw code", () => { - renderClaimRedeemModal({ - status: "failed", - failure: { kind: "contract-error", code: 9999 }, - }) - - expect(screen.getByText(/contact support/)).toBeTruthy() - expect(screen.queryByText(/9999/)).toBeNull() - }) - - it("reads a declined claim as a choice, not a failure, and offers to try again", () => { - const { onRetry } = renderClaimRedeemModal({ - status: "failed", - failure: { kind: "declined" }, - }) - - expect( - screen.getByRole("heading", { name: "You declined the request" }), - ).toBeTruthy() - - fireEvent.click(screen.getByRole("button", { name: "Try again" })) - expect(onRetry).toHaveBeenCalledTimes(1) - }) - - it("shows the same step progress machinery for a claim in flight", () => { - renderClaimRedeemModal({ status: "awaiting-signature" }) - - const steps = screen.getAllByRole("listitem") - expect(steps.map((step) => step.textContent)).toEqual([ - "Approved in your wallet, in progress", - "Sent to the network", - "Recorded", - ]) - }) - - it("is reachable as a dialog and dismissible by its close control", () => { - const { onClose } = renderClaimRedeemModal({ - status: "awaiting-signature", - }) - - fireEvent.click(screen.getByRole("button", { name: "Close" })) - expect(onClose).toHaveBeenCalledTimes(1) - }) -}) diff --git a/app/src/components/vault/transactionModal/investorOutcomes.test.tsx b/app/src/components/vault/transactionModal/investorOutcomes.test.tsx deleted file mode 100644 index 2bf129a7..00000000 --- a/app/src/components/vault/transactionModal/investorOutcomes.test.tsx +++ /dev/null @@ -1,231 +0,0 @@ -import { type Amount } from "@stellar-scaffold/app-lib" -import type * as AppLib from "@stellar-scaffold/app-lib" -import { render, screen } from "@testing-library/react" -import { describe, expect, it, vi } from "vitest" -import TransactionModal from "../TransactionModal" -import { - renderModal, - renderCancelModal, - renderCancelRedeemModal, - renderClaimModal, - renderRedeemModal, - renderClaimRedeemModal, - renderClaimWindDownModal, -} from "./renderModal" - -vi.mock("@stellar-scaffold/app-lib", async (importOriginal) => ({ - ...(await importOriginal()), - shortAddress: (address: string) => - `${address.slice(0, 4)}...${address.slice(-4)}`, - explorerTransaction: () => null, -})) - -describe("TransactionModal, allowlist and freeze refusals", () => { - const contractError = (code: number) => ({ - status: "failed" as const, - failure: { kind: "contract-error" as const, code }, - }) - - it("names NotAllowlisted on a subscription as an address that cannot subscribe", () => { - renderModal(contractError(6068)) - - expect(screen.getByText(/not allowlisted/)).toBeTruthy() - expect(screen.getByText(/can subscribe/)).toBeTruthy() - }) - - it("names Frozen on a subscription as a frozen address", () => { - renderModal(contractError(6071)) - - expect(screen.getByText(/frozen/)).toBeTruthy() - expect(screen.getByText(/cannot subscribe/)).toBeTruthy() - }) - - it("names Frozen on a redemption request as a frozen address or frozen shares", () => { - renderRedeemModal(contractError(6071)) - - expect(screen.getByText(/shares are frozen/)).toBeTruthy() - expect(screen.getByText(/free shares/)).toBeTruthy() - }) - - it.each<[string, () => void]>([ - ["cancelling a subscription", () => renderCancelModal(contractError(6071))], - [ - "cancelling a redemption", - () => renderCancelRedeemModal(contractError(6071)), - ], - ["claiming shares", () => renderClaimModal(contractError(6071))], - [ - "claiming a redemption", - () => renderClaimRedeemModal(contractError(6071)), - ], - ])("names Frozen on %s as held until the freeze lifts", (_name, render) => { - render() - - expect(screen.getByText(/held until the freeze is lifted/)).toBeTruthy() - }) - - it("falls back to a generic refusal for NotAllowlisted on a subscription cancellation, since a delisted holder may still cancel", () => { - renderCancelModal(contractError(6068)) - - expect(screen.getByText(/contact support/)).toBeTruthy() - expect(screen.queryByText(/allowlisted/)).toBeNull() - }) -}) - -describe("TransactionModal, conversion and surrender outcomes", () => { - it("tells a delisted investor their claimed shares were queued as a redemption", () => { - renderClaimModal({ - status: "confirmed", - sharesMinted: 0n as Amount, - queuedInto: 5n, - }) - - expect( - screen.getByRole("heading", { name: "Queued as a redemption" }), - ).toBeTruthy() - expect(screen.getByText(/not allowlisted/)).toBeTruthy() - expect(screen.getByText(/pay out in USDC at the next price/)).toBeTruthy() - expect(screen.queryByText(/returned to your wallet/)).toBeNull() - }) - - it("tells a delisted investor in a wind-down their claimed shares joined the wind-down claim", () => { - renderClaimModal({ - status: "confirmed", - sharesMinted: 0n as Amount, - queuedInto: 0n, - }) - - expect( - screen.getByRole("heading", { name: "Moved to your wind-down claim" }), - ).toBeTruthy() - expect(screen.getByText(/not allowlisted/)).toBeTruthy() - }) - - it("still reads a zero-share claim without a conversion as a returned deposit", () => { - renderClaimModal({ status: "confirmed", sharesMinted: 0n as Amount }) - - expect( - screen.getByRole("heading", { name: "Deposit returned" }), - ).toBeTruthy() - }) - - it("tells the investor a cancelled redemption moved into the wind-down claim", () => { - renderCancelRedeemModal({ - status: "confirmed", - returnedShares: 100_0000000n as Amount, - surrendered: true, - }) - - expect( - screen.getByRole("heading", { name: "Moved to your wind-down claim" }), - ).toBeTruthy() - expect(screen.getByText(/winding down/)).toBeTruthy() - expect(screen.queryByText(/returned to your wallet/)).toBeNull() - }) - - it("still reads a returned redemption as cancelled", () => { - renderCancelRedeemModal({ - status: "confirmed", - returnedShares: 100_0000000n as Amount, - surrendered: false, - }) - - expect( - screen.getByRole("heading", { name: "Request cancelled" }), - ).toBeTruthy() - }) -}) - -describe("TransactionModal, wind-down outcomes", () => { - it("tells the investor a zero-price claim burned the shares during a wind-down, never that they came back", () => { - renderClaimRedeemModal({ - status: "confirmed", - assetsClaimed: 0n as Amount, - sharesReturned: false, - }) - - expect( - screen.getByRole("heading", { name: "Nothing to pay out" }), - ).toBeTruthy() - expect(screen.getByText(/winding down/)).toBeTruthy() - expect(screen.getByText(/burned/)).toBeTruthy() - expect(screen.queryByText(/returned to your wallet/)).toBeNull() - }) -}) - -describe("TransactionModal, cancelling a redemption in a wind-down", () => { - it("tells the signer the shares move into the wind-down claim, never that they return", () => { - render( - , - ) - - expect(screen.getByText(/wind-down claim/)).toBeTruthy() - expect(screen.queryByText(/returns/)).toBeNull() - }) -}) - -describe("TransactionModal, claiming from the wind-down", () => { - it("tells the signer what they receive and that their shares are turned in", () => { - renderClaimWindDownModal({ status: "awaiting-signature" }) - - expect(screen.getByText(/25\.00 USDC/)).toBeTruthy() - expect(screen.getByText(/turns in your unfrozen vUSDC/)).toBeTruthy() - }) - - it("shows the assets paid and the shares turned in once confirmed", () => { - renderClaimWindDownModal({ - status: "confirmed", - assetsClaimed: 25_0000000n as Amount, - sharesSurrendered: 40_0000000n as Amount, - }) - - expect(screen.getByRole("heading", { name: "USDC claimed" })).toBeTruthy() - expect(screen.getByText(/25\.00 USDC has been added/)).toBeTruthy() - expect(screen.getByText(/40\.00 vUSDC/)).toBeTruthy() - }) - - it("reads a claim that paid nothing as shares turned in for later rounds", () => { - renderClaimWindDownModal({ - status: "confirmed", - assetsClaimed: 0n as Amount, - sharesSurrendered: 40_0000000n as Amount, - }) - - expect( - screen.getByRole("heading", { name: "Shares turned in" }), - ).toBeTruthy() - expect(screen.getByText(/later rounds/)).toBeTruthy() - }) - - it.each<[number, RegExp]>([ - [6051, /first payout/], - [6054, /nothing to claim from the wind-down/], - [6071, /held until the freeze is lifted/], - ])("names refusal %i in the dapp's words", (code, text) => { - renderClaimWindDownModal({ - status: "failed", - failure: { kind: "contract-error", code }, - }) - - expect(screen.getByText(text)).toBeTruthy() - }) - - it("falls back to a generic refusal for a wind-down claim error it does not recognize", () => { - renderClaimWindDownModal({ - status: "failed", - failure: { kind: "contract-error", code: 9999 }, - }) - - expect(screen.getByText(/contact support/)).toBeTruthy() - expect(screen.queryByText(/9999/)).toBeNull() - }) -}) diff --git a/app/src/test/renderTransactionModal.tsx b/app/src/test/renderTransactionModal.tsx deleted file mode 100644 index f23ac334..00000000 --- a/app/src/test/renderTransactionModal.tsx +++ /dev/null @@ -1,303 +0,0 @@ -import { render } from "@testing-library/react" -import { vi } from "vitest" -import { type ActivateWindDownStatus } from "../../../hooks/useActivateWindDown" -import { type AttestStatus } from "../../../hooks/useAttest" -import { type CancelDepositStatus } from "../../../hooks/useCancelDeposit" -import { type CancelRedeemStatus } from "../../../hooks/useCancelRedeem" -import { type ClaimDepositStatus } from "../../../hooks/useClaimDeposit" -import { type ClaimRedeemStatus } from "../../../hooks/useClaimRedeem" -import { type ClaimWindDownStatus } from "../../../hooks/useClaimWindDown" -import { type CloseEpochStatus } from "../../../hooks/useCloseEpoch" -import { type TransactionStatus } from "../../../hooks/useContractTransaction" -import { type DeployToCustodianStatus } from "../../../hooks/useDeployToCustodian" -import { type FinalizeWindDownRoundStatus } from "../../../hooks/useFinalizeWindDownRound" -import { type FulfillEpochStatus } from "../../../hooks/useFulfillEpoch" -import { type FundStatus } from "../../../hooks/useFund" -import { type RequestDepositStatus } from "../../../hooks/useRequestDeposit" -import { type RequestRedeemStatus } from "../../../hooks/useRequestRedeem" -import TransactionModal, { - type ComplianceModalAction, - type ConfigurationModalAction, - type EmergencyModalAction, - type GovernanceModalAction, -} from "../TransactionModal" - -export const renderModal = (status: RequestDepositStatus) => { - const onClose = vi.fn() - const onRetry = vi.fn() - const view = render( - , - ) - return { ...view, onClose, onRetry } -} - -export const renderCancelModal = (status: CancelDepositStatus) => { - const onClose = vi.fn() - const onRetry = vi.fn() - const view = render( - , - ) - return { ...view, onClose, onRetry } -} - -export const renderCancelRedeemModal = (status: CancelRedeemStatus) => { - const onClose = vi.fn() - const onRetry = vi.fn() - const view = render( - , - ) - return { ...view, onClose, onRetry } -} - -export const renderClaimModal = (status: ClaimDepositStatus) => { - const onClose = vi.fn() - const onRetry = vi.fn() - const view = render( - , - ) - return { ...view, onClose, onRetry } -} - -export const renderRedeemModal = (status: RequestRedeemStatus) => { - const onClose = vi.fn() - const onRetry = vi.fn() - const view = render( - , - ) - return { ...view, onClose, onRetry } -} - -export const renderClaimRedeemModal = (status: ClaimRedeemStatus) => { - const onClose = vi.fn() - const onRetry = vi.fn() - const view = render( - , - ) - return { ...view, onClose, onRetry } -} - -export const renderCloseEpochModal = ( - status: CloseEpochStatus, - epoch = "5", -) => { - const onClose = vi.fn() - const onRetry = vi.fn() - const view = render( - , - ) - return { ...view, onClose, onRetry } -} - -export const renderFulfillEpochModal = (status: FulfillEpochStatus) => { - const onClose = vi.fn() - const onRetry = vi.fn() - const view = render( - , - ) - return { ...view, onClose, onRetry } -} - -export const renderAttestModal = ( - status: AttestStatus, - validFor: string | null = "1d", -) => { - const onClose = vi.fn() - const onRetry = vi.fn() - const view = render( - , - ) - return { ...view, onClose, onRetry } -} - -export const renderDeployModal = (status: DeployToCustodianStatus) => { - const onClose = vi.fn() - const onRetry = vi.fn() - const view = render( - , - ) - return { ...view, onClose, onRetry } -} - -export const renderFundModal = (status: FundStatus) => { - const onClose = vi.fn() - const onRetry = vi.fn() - const view = render( - , - ) - return { ...view, onClose, onRetry } -} - -export const renderActivateModal = (status: ActivateWindDownStatus) => { - const onClose = vi.fn() - const onRetry = vi.fn() - const view = render( - , - ) - return { ...view, onClose, onRetry } -} - -export const renderFinalizeModal = (status: FinalizeWindDownRoundStatus) => { - const onClose = vi.fn() - const onRetry = vi.fn() - const view = render( - , - ) - return { ...view, onClose, onRetry } -} - -export const renderEmergencyModal = ( - action: EmergencyModalAction, - status: TransactionStatus, -) => - render( - , - ) - -export const renderConfigurationModal = ( - action: ConfigurationModalAction, - status: TransactionStatus, - value = "", -) => - render( - , - ) - -export const renderGovernanceModal = ( - action: GovernanceModalAction, - status: TransactionStatus, - value = "Manager to GABC...WXYZ", -) => - render( - , - ) - -export const renderComplianceModal = ( - action: ComplianceModalAction, - status: TransactionStatus, - value = "GABC...WXYZ", -) => - render( - , - ) - -export const renderClaimWindDownModal = (status: ClaimWindDownStatus) => - render( - , - ) From f34e539730b2e7017b19a5a64d1e52c75bdf8218 Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Mon, 2 Nov 2026 15:41:44 -0300 Subject: [PATCH 16/18] revert: refactor(app): split TransactionModal into section modules This reverts commit 80acc8e. The split moves to its own issue. Refs #420 --- app/src/components/vault/TransactionModal.tsx | 2006 ++++++++++++++++- .../vault/transactionModal/adminCopy.ts | 201 -- .../vault/transactionModal/compliance.ts | 68 - .../vault/transactionModal/configuration.ts | 82 - .../vault/transactionModal/content.ts | 180 -- .../vault/transactionModal/cycle.ts | 434 ---- .../vault/transactionModal/emergency.ts | 59 - .../vault/transactionModal/governance.ts | 196 -- .../vault/transactionModal/investor.ts | 495 ---- .../vault/transactionModal/outcome.ts | 76 - .../vault/transactionModal/steps.ts | 88 - .../vault/transactionModal/types.ts | 180 -- 12 files changed, 1988 insertions(+), 2077 deletions(-) delete mode 100644 app/src/components/vault/transactionModal/adminCopy.ts delete mode 100644 app/src/components/vault/transactionModal/compliance.ts delete mode 100644 app/src/components/vault/transactionModal/configuration.ts delete mode 100644 app/src/components/vault/transactionModal/content.ts delete mode 100644 app/src/components/vault/transactionModal/cycle.ts delete mode 100644 app/src/components/vault/transactionModal/emergency.ts delete mode 100644 app/src/components/vault/transactionModal/governance.ts delete mode 100644 app/src/components/vault/transactionModal/investor.ts delete mode 100644 app/src/components/vault/transactionModal/outcome.ts delete mode 100644 app/src/components/vault/transactionModal/steps.ts delete mode 100644 app/src/components/vault/transactionModal/types.ts diff --git a/app/src/components/vault/TransactionModal.tsx b/app/src/components/vault/TransactionModal.tsx index dd82933c..e017090d 100644 --- a/app/src/components/vault/TransactionModal.tsx +++ b/app/src/components/vault/TransactionModal.tsx @@ -1,29 +1,1999 @@ -import { explorerTransaction, shortAddress } from "@stellar-scaffold/app-lib" +import { + AMOUNT_DECIMALS, + explorerTransaction, + formatDate, + formatExact, + formatScaled, + PRICE_DECIMALS, + shortAddress, + type SubmitFailure, +} from "@stellar-scaffold/app-lib" import React from "react" +import { type ActivateWindDownStatus } from "../../hooks/useActivateWindDown" +import { type AttestStatus } from "../../hooks/useAttest" +import { type CancelDepositStatus } from "../../hooks/useCancelDeposit" +import { type CancelRedeemStatus } from "../../hooks/useCancelRedeem" +import { type ClaimDepositStatus } from "../../hooks/useClaimDeposit" +import { type ClaimRedeemStatus } from "../../hooks/useClaimRedeem" +import { type ClaimWindDownStatus } from "../../hooks/useClaimWindDown" +import { type CloseEpochStatus } from "../../hooks/useCloseEpoch" +import { + type TransactionFailure, + type TransactionStatus, +} from "../../hooks/useContractTransaction" +import { type DeployToCustodianStatus } from "../../hooks/useDeployToCustodian" +import { type FinalizeWindDownRoundStatus } from "../../hooks/useFinalizeWindDownRound" +import { type FulfillEpochStatus } from "../../hooks/useFulfillEpoch" +import { type FundStatus } from "../../hooks/useFund" +import { type RequestDepositStatus } from "../../hooks/useRequestDeposit" +import { type RequestRedeemStatus } from "../../hooks/useRequestRedeem" +import { describeStanding } from "../../pages/actions" +import { type ComplianceActionId } from "../../pages/compliance" +import { EFFECTS } from "../../pages/configuration" +import { type GovernanceActionId } from "../../pages/governance" import typeStyles from "../../styles/type.module.css" import Close from "../icons/Close" import ExternalLink from "../icons/ExternalLink" -import { describeStatus } from "./transactionModal/content" -import { - STEP_ORDER, - stepLabel, - computeSteps, - stepStateClassName, - stepAnnouncement, -} from "./transactionModal/steps" -import { type TransactionModalProps } from "./transactionModal/types" import styles from "./TransactionModal.module.css" -export type { - AdminModalAction, - ComplianceModalAction, +export type EmergencyModalAction = + | "pause-vault" + | "unpause-vault" + | "pause-nav" + | "unpause-nav" + | "clear-latest" + | "pause-token" + | "unpause-token" + +export type ConfigurationModalAction = + | "set-deposit-cap" + | "set-custodian" + | "set-notice" + | "set-wind-down-delay" + | "set-min-epoch-duration" + | "propose-custodian" + | "propose-notice" + | "propose-wind-down-delay" + | "propose-min-epoch-duration" + | "set-oracle-config" + | "propose-oracle-config" + | "set-compliance" + | "propose-compliance" + | "set-identity-verifier" + | "propose-identity-verifier" + +export type GovernanceModalAction = + GovernanceActionId | "propose-oracle-revoke-role" + +export type ComplianceModalAction = ComplianceActionId + +export type AdminModalAction = + | EmergencyModalAction + | ConfigurationModalAction + | GovernanceModalAction + | ComplianceModalAction + +export type TransactionModalProps = + | { + action: EmergencyModalAction + status: TransactionStatus + onClose: () => void + onRetry: () => void + } + | { + action: + ConfigurationModalAction | GovernanceModalAction | ComplianceModalAction + status: TransactionStatus + value: string + onClose: () => void + onRetry: () => void + } + | { + action: "subscribe" + status: RequestDepositStatus + amount: string + ticker: string + onClose: () => void + onRetry: () => void + } + | { + action: "cancel" + status: CancelDepositStatus + amount: string + ticker: string + onClose: () => void + onRetry: () => void + } + | { + action: "cancel-redeem" + status: CancelRedeemStatus + amount: string + ticker: string + assetTicker: string + windingDown?: boolean + onClose: () => void + onRetry: () => void + } + | { + action: "claim" + status: ClaimDepositStatus + amount: string + ticker: string + shareTicker: string + onClose: () => void + onRetry: () => void + } + | { + action: "redeem" + status: RequestRedeemStatus + amount: string + ticker: string + onClose: () => void + onRetry: () => void + } + | { + action: "claim-redeem" + status: ClaimRedeemStatus + amount: string + ticker: string + uncovered?: string + onClose: () => void + onRetry: () => void + } + | { + action: "claim-wind-down" + status: ClaimWindDownStatus + amount: string + ticker: string + shareTicker: string + onClose: () => void + onRetry: () => void + } + | { + action: "close-epoch" + status: CloseEpochStatus + epoch: string + onClose: () => void + onRetry: () => void + } + | { + action: "fulfill-epoch" + status: FulfillEpochStatus + epoch: string + onClose: () => void + onRetry: () => void + } + | { + action: "attest" + status: AttestStatus + price: string + validFor: string | null + onClose: () => void + onRetry: () => void + } + | { + action: "deploy" + status: DeployToCustodianStatus + amount: string + onClose: () => void + onRetry: () => void + } + | { + action: "fund" + status: FundStatus + amount: string + onClose: () => void + onRetry: () => void + } + | { + action: "activate-wind-down" + status: ActivateWindDownStatus + onClose: () => void + onRetry: () => void + } + | { + action: "finalize-round" + status: FinalizeWindDownRoundStatus + round: string + onClose: () => void + onRetry: () => void + } + +const GENERIC_REFUSAL = + "The vault refused this request. Try again, and contact support if it keeps happening." + +export const NOT_ALLOWLISTED = "Your address is not allowlisted." + +const SHARES_NOT_RETURNABLE = + "Your address cannot receive these shares right now, so they stay with the vault." + +const FROZEN = "Your address is frozen." + +export const HELD_WHILE_FROZEN = `${FROZEN} This request is held until the freeze is lifted.` + +const subscribeContractErrorReason = (code: number): string => { + switch (code) { + case 6007: + return "Enter an amount greater than zero." + case 6009: + return "You already have a subscription request open." + case 6014: + return "That amount is too large for the vault to accept." + case 6046: + return "The vault is winding down and is not accepting new subscriptions." + case 6064: + return "The vault's setup is still open. Subscriptions start once it closes." + case 6044: + return "The vault's price feed is not valid right now, so subscriptions are paused until it is." + case 6076: + return "The vault does not hold enough to cover its pending deposits right now, so it is not taking new subscriptions until it is funded again." + case 6068: + return `${NOT_ALLOWLISTED} Only allowlisted addresses can subscribe.` + case 6071: + return `${FROZEN} It cannot subscribe until the freeze is lifted.` + default: + return GENERIC_REFUSAL + } +} + +const cancelContractErrorReason = (code: number): string => { + switch (code) { + case 6001: + return "This request no longer exists to cancel." + case 6029: + return "This request could not be found." + case 6039: + return "This request has already been priced. Claim your shares instead of cancelling." + case 6041: + return "A price is available, so this request can no longer be cancelled. It will be priced shortly, and your shares are claimable once it is." + case 6076: + return "The vault does not hold enough to return this deposit right now. You can cancel once it is funded again." + case 6071: + return HELD_WHILE_FROZEN + default: + return GENERIC_REFUSAL + } +} + +const cancelRedeemContractErrorReason = ( + code: number, + assetTicker: string, +): string => { + const claimInstead = `Once priced, claim the ${assetTicker} it owes you instead.` + switch (code) { + case 6068: + return `${NOT_ALLOWLISTED} These shares cannot come back to you. ${claimInstead}` + case 6073: + return `${SHARES_NOT_RETURNABLE} ${claimInstead}` + default: + return cancelContractErrorReason(code) + } +} + +const claimContractErrorReason = (code: number): string => { + switch (code) { + case 6001: + return "You have no request to claim." + case 6014: + return "The share conversion for this claim is too large to complete." + case 6029: + return "This request could not be found." + case 6031: + return "The vault has not published a valid price for this request yet." + case 6035: + return "This request has already been claimed." + case 6037: + return "The vault does not yet hold enough in reserve to cover this claim. Try again shortly." + case 6071: + return HELD_WHILE_FROZEN + default: + return GENERIC_REFUSAL + } +} + +const redeemContractErrorReason = (code: number): string => { + switch (code) { + case 6007: + return "Enter an amount greater than zero." + case 6009: + return "You already have a redemption request open." + case 6014: + return "That amount is too large for the vault to accept." + case 6029: + return "This request could not be found." + case 6046: + return "The vault is winding down and is not accepting new redemptions." + case 6064: + return "The vault's setup is still open. Redemptions start once it closes." + case 6071: + return "Your address or some of these shares are frozen. Redeem only your free shares, or wait until the freeze is lifted." + default: + return GENERIC_REFUSAL + } +} + +const claimRedeemContractErrorReason = ( + code: number, + ticker: string, + uncovered?: string, +): string => { + switch (code) { + case 6034: + return "This request has not priced yet. Check back after the vault's next update." + case 6037: + return uncovered !== undefined + ? `The vault is ${uncovered} short of covering every priced claim. Try again once the reserve catches up.` + : `The vault does not yet hold enough ${ticker} in reserve to cover this claim. Try again shortly.` + case 6073: + return `The price left nothing to pay out, so the vault tried to return your shares. ${SHARES_NOT_RETURNABLE}` + default: + return claimContractErrorReason(code) + } +} + +const windDownClaimContractErrorReason = (code: number): string => { + switch (code) { + case 6051: + return "The wind-down has not made its first payout yet. Check back once it does." + case 6054: + return "You have nothing to claim from the wind-down right now." + case 6071: + return HELD_WHILE_FROZEN + default: + return GENERIC_REFUSAL + } +} + +const closeEpochContractErrorReason = (code: number): string => { + switch (code) { + case 6046: + return "The vault is winding down, so no more epochs can be sealed." + case 6029: + return "The current epoch could not be found." + case 6032: + return "The current epoch is not open, so there is nothing to seal." + case 2000: + return "This wallet is not authorized to close epochs." + case 2007: + return "This wallet does not hold the manager role." + case 6077: + return "Only the manager can seal this epoch until it has been open for the minimum duration." + case 6064: + return "The vault's setup is still open, so no epoch can be sealed yet." + default: + return GENERIC_REFUSAL + } +} + +const fulfillEpochContractErrorReason = (code: number): string => { + switch (code) { + case 6046: + return "The vault is winding down, so epochs can no longer be priced." + case 6029: + return "This epoch could not be found." + case 6038: + return "This epoch is not sealed, so it cannot be priced." + case 6042: + return "The notice period has not elapsed yet." + case 6044: + return "The oracle price is not valid right now." + case 6043: + return "The latest price was attested before this epoch was sealed. A fresh attestation is needed." + case 6031: + return "The vault could not resolve a valid share price." + case 6014: + return "The settlement total is too large for the vault to record." + case 1000: + return "The vault is paused, so epochs cannot be priced." + case 6064: + return "The vault's setup is still open, so no epoch can be priced yet." + default: + return GENERIC_REFUSAL + } +} + +const attestContractErrorReason = (code: number): string => { + switch (code) { + case 3002: + return "That price is outside the band the oracle accepts." + case 3005: + return "The attestation would already have expired when recorded." + case 3003: + return "The cooldown since the last attestation has not elapsed yet." + case 3004: + return "That price moves further from the last attested price than the oracle allows." + case 3010: + return "Unpause the NAV first." + case 3023: + return "The proof hash cannot be all zeros." + case 3024: + return "The proof URI is longer than 256 bytes." + default: + return GENERIC_REFUSAL + } +} + +const deployContractErrorReason = (code: number): string => { + switch (code) { + case 6046: + return "The vault is winding down, so nothing can leave for the custodian." + case 6007: + return "Enter an amount greater than zero." + case 6012: + return "No custodian is set, so there is nowhere to deploy to." + case 6005: + return "That amount exceeds the free reserve. The rest is committed to priced exits." + case 6014: + return "That amount is too large for the vault to record." + case 2000: + return "This wallet is not authorized to deploy to the custodian." + case 2007: + return "This wallet does not hold the treasury role." + default: + return GENERIC_REFUSAL + } +} + +const fundContractErrorReason = (code: number): string => { + switch (code) { + case 6007: + return "Enter an amount greater than zero." + case 6014: + return "That amount is too large for the vault to record." + default: + return code < 6000 + ? "Your wallet could not cover the transfer. Check the balance and the trustline." + : GENERIC_REFUSAL + } +} + +const activateWindDownContractErrorReason = (code: number): string => { + switch (code) { + case 6047: + return "No wind-down has been proposed, so there is nothing to activate." + case 6046: + return "The wind-down is already active." + case 6049: + return "The wind-down delay has not elapsed yet." + default: + return GENERIC_REFUSAL + } +} + +const finalizeRoundContractErrorReason = (code: number): string => { + switch (code) { + case 6050: + return "The wind-down is not active, so no round can be finalized." + case 6052: + return "There is nothing to distribute: the free reserve is empty or too small to split across holders." + case 6014: + return "The round is too large for the vault to record." + default: + return GENERIC_REFUSAL + } +} + +const INVESTOR_FOLLOW_UP = "check your requests before doing anything else" +const OPERATOR_FOLLOW_UP = "check the Cycle card before trying again" +const EMERGENCY_FOLLOW_UP = "check the Emergency tab before trying again" +const CONFIGURATION_FOLLOW_UP = + "check the Configuration tab before trying again" +const GOVERNANCE_FOLLOW_UP = "check the Governance tab before trying again" +const COMPLIANCE_FOLLOW_UP = "check the Compliance tab before trying again" + +const NETWORK_REASONS: Record, string> = { + expired: "The transaction expired before it was submitted.", + "sequence-consumed": + "The account moved on; this transaction is no longer valid.", + "bad-auth": "The signatures do not match the account's threshold.", + fee: "The account cannot pay the fee.", + "fee-too-low": "The fee is below the network minimum. Propose it again.", + "not-accepted": "The network did not accept it. Try again.", +} + +const unknownOutcome = (followUp: string) => ({ + heading: "Something went wrong", + body: `We could not confirm whether this reached the network. We don't yet know if it went through, so ${followUp}.`, +}) + +const describeFailure = ( + failure: TransactionFailure, + contractErrorReason: (code: number) => string, + followUp = INVESTOR_FOLLOW_UP, + refuser = "vault", +): { heading: string; body: string } => { + switch (failure.kind) { + case "network": + if (failure.reason === "unknown") return unknownOutcome(followUp) + return { + heading: "The network rejected it", + body: NETWORK_REASONS[failure.reason], + } + case "declined": + return { + heading: "You declined the request", + body: "You chose not to sign, so nothing was sent. Try again when you're ready.", + } + case "unsigned": + return { + heading: "The wallet did not sign", + body: "Nothing was sent. Try again when you're ready.", + } + case "cannot-authorize": + return { + heading: "This wallet cannot authorize", + body: "This wallet cannot sign authorizations. Use Freighter, Hana or WalletConnect.", + } + case "contract-error": + return { + heading: `The ${refuser} refused this request`, + body: contractErrorReason(failure.code), + } + case "interrupted": + return { + heading: "We couldn't reach the vault", + body: "Nothing was requested from your wallet. Try again when you're ready.", + } + case "stale": + return { + heading: "The transaction went stale", + body: "The account moved on since it was prepared, so nothing was sent. Try again.", + } + case "unknown": + return unknownOutcome(followUp) + } +} + +const describeShared = (expiresAt: Date | null) => { + const invitation = "Share the link with the next signer." + if (expiresAt === null) return invitation + const seconds = BigInt(Math.floor(expiresAt.getTime() / 1000)) + return `${invitation} Valid until ${formatDate(seconds)}.` +} + +const describeSubscribeStatus = ( + status: RequestDepositStatus, + amount: string, + ticker: string, +): { heading: string; body: string; hash?: string } | undefined => { + switch (status.status) { + case "idle": + return undefined + case "preparing": + return { + heading: "Preparing your request", + body: "We are getting your request ready. Your wallet will ask you to approve it next.", + } + case "awaiting-signature": + return { + heading: "Confirm in your wallet", + body: `Your shares are set once the vault prices your request, not today. Signing locks ${amount} ${ticker} in escrow until then.`, + } + case "submitted": + return { + heading: "Sending your request", + body: "Your request is on its way to the network. This should only take a moment, while pricing comes later and takes longer. Closing this window will not cancel it.", + hash: status.hash, + } + case "confirmed": + return { + heading: "Request submitted", + body: `${amount} ${ticker} prices at the vault's next update.`, + hash: status.hash, + } + case "failed": + return { + ...describeFailure(status.failure, subscribeContractErrorReason), + hash: status.hash, + } + } +} + +const describeCancelStatus = ( + status: CancelDepositStatus, + amount: string, + ticker: string, +): { heading: string; body: string; hash?: string } | undefined => { + switch (status.status) { + case "idle": + return undefined + case "preparing": + return { + heading: "Preparing your cancellation", + body: "We are getting your cancellation ready. Your wallet will ask you to approve it next.", + } + case "awaiting-signature": + return { + heading: "Confirm in your wallet", + body: `Your request is withdrawn, not priced. Signing returns ${amount} ${ticker} from escrow to your wallet.`, + } + case "submitted": + return { + heading: "Sending your cancellation", + body: "Your cancellation is on its way to the network. This should only take a moment. Closing this window will not stop it.", + hash: status.hash, + } + case "confirmed": + return { + heading: "Request cancelled", + body: `${formatExact(status.refundedAmount, AMOUNT_DECIMALS)} ${ticker} has been returned to your wallet.`, + hash: status.hash, + } + case "failed": + return { + ...describeFailure(status.failure, cancelContractErrorReason), + hash: status.hash, + } + } +} + +const describeCancelRedeemStatus = ( + status: CancelRedeemStatus, + amount: string, + ticker: string, + assetTicker: string, + windingDown: boolean, +): { heading: string; body: string; hash?: string } | undefined => { + switch (status.status) { + case "idle": + return undefined + case "preparing": + return { + heading: "Preparing your cancellation", + body: "We are getting your cancellation ready. Your wallet will ask you to approve it next.", + } + case "awaiting-signature": + return { + heading: "Confirm in your wallet", + body: windingDown + ? `Your request is withdrawn, not priced. The vault is winding down, so signing moves ${amount} ${ticker} into your wind-down claim instead of your wallet.` + : `Your request is withdrawn, not priced. Signing returns ${amount} ${ticker} from escrow to your wallet.`, + } + case "submitted": + return { + heading: "Sending your cancellation", + body: "Your cancellation is on its way to the network. This should only take a moment. Closing this window will not stop it.", + hash: status.hash, + } + case "confirmed": + return status.surrendered + ? { + heading: MOVED_TO_WIND_DOWN, + body: "The vault is winding down, so these shares were added to your wind-down claim instead of coming back to your wallet.", + hash: status.hash, + } + : { + heading: "Request cancelled", + body: `${formatExact(status.returnedShares, AMOUNT_DECIMALS)} ${ticker} has been returned to your wallet.`, + hash: status.hash, + } + case "failed": + return { + ...describeFailure(status.failure, (code) => + cancelRedeemContractErrorReason(code, assetTicker), + ), + hash: status.hash, + } + } +} + +const MOVED_TO_WIND_DOWN = "Moved to your wind-down claim" + +const convertedOutcome = ( + queuedInto: bigint, + ticker: string, +): { heading: string; body: string } => + queuedInto === 0n + ? { + heading: MOVED_TO_WIND_DOWN, + body: `${NOT_ALLOWLISTED} Your shares were added to your wind-down claim instead.`, + } + : { + heading: "Queued as a redemption", + body: `${NOT_ALLOWLISTED} Your shares were queued as a redemption instead, and pay out in ${ticker} at the next price.`, + } + +const describeClaimStatus = ( + status: ClaimDepositStatus, + amount: string, + ticker: string, + shareTicker: string, +): { heading: string; body: string; hash?: string } | undefined => { + switch (status.status) { + case "idle": + return undefined + case "preparing": + return { + heading: "Preparing your claim", + body: "We are getting your claim ready. Your wallet will ask you to approve it next.", + } + case "awaiting-signature": + return { + heading: "Confirm in your wallet", + body: `You receive what you're owed. If the price leaves no shares to claim, ${amount} ${ticker} returns to your wallet instead.`, + } + case "submitted": + return { + heading: "Sending your claim", + body: "Your claim is on its way to the network. This should only take a moment. Closing this window will not stop it.", + hash: status.hash, + } + case "confirmed": + if (status.queuedInto !== undefined) + return { + ...convertedOutcome(status.queuedInto, ticker), + hash: status.hash, + } + return status.sharesMinted > 0n + ? { + heading: "Shares claimed", + body: `${formatExact(status.sharesMinted, AMOUNT_DECIMALS)} ${shareTicker} has been added to your wallet.`, + hash: status.hash, + } + : { + heading: "Deposit returned", + body: `The price left no shares to claim, so ${amount} ${ticker} has been returned to your wallet instead.`, + hash: status.hash, + } + case "failed": + return { + ...describeFailure(status.failure, claimContractErrorReason), + hash: status.hash, + } + } +} + +const describeClaimWindDownStatus = ( + status: ClaimWindDownStatus, + amount: string, + ticker: string, + shareTicker: string, +): { heading: string; body: string; hash?: string } | undefined => { + switch (status.status) { + case "idle": + return undefined + case "preparing": + return { + heading: "Preparing your claim", + body: "We are getting your claim ready. Your wallet will ask you to approve it next.", + } + case "awaiting-signature": + return { + heading: "Confirm in your wallet", + body: `You receive ${amount} ${ticker}, your share of the wind-down so far. Signing also turns in your unfrozen ${shareTicker}, so later rounds pay you for them.`, + } + case "submitted": + return { + heading: "Sending your claim", + body: "Your claim is on its way to the network. This should only take a moment. Closing this window will not stop it.", + hash: status.hash, + } + case "confirmed": + return { + ...windDownClaimOutcome(status, ticker, shareTicker), + hash: status.hash, + } + case "failed": + return { + ...describeFailure(status.failure, windDownClaimContractErrorReason), + hash: status.hash, + } + } +} + +const windDownClaimOutcome = ( + { + assetsClaimed, + sharesSurrendered, + }: Extract, + ticker: string, + shareTicker: string, +): { heading: string; body: string } => { + const turnedIn = + sharesSurrendered > 0n + ? `${formatExact(sharesSurrendered, AMOUNT_DECIMALS)} ${shareTicker} were turned in, and later rounds pay you for them.` + : "Later rounds pay what you are owed." + return assetsClaimed > 0n + ? { + heading: `${ticker} claimed`, + body: `${formatExact(assetsClaimed, AMOUNT_DECIMALS)} ${ticker} has been added to your wallet. ${turnedIn}`, + } + : { + heading: "Shares turned in", + body: `Nothing was paid this time. ${turnedIn}`, + } +} + +const describeRedeemStatus = ( + status: RequestRedeemStatus, + amount: string, + ticker: string, +): { heading: string; body: string; hash?: string } | undefined => { + switch (status.status) { + case "idle": + return undefined + case "preparing": + return { + heading: "Preparing your redemption request", + body: "We are getting your redemption request ready. Your wallet will ask you to approve it next.", + } + case "awaiting-signature": + return { + heading: "Confirm in your wallet", + body: `What you're owed is set once the vault prices your request, not today. Signing locks ${amount} ${ticker} in escrow until then.`, + } + case "submitted": + return { + heading: "Sending your redemption request", + body: "Your redemption request is on its way to the network. This should only take a moment, while pricing comes later and takes longer. Closing this window will not cancel it.", + hash: status.hash, + } + case "confirmed": + return { + heading: "Redemption request submitted", + body: `${amount} ${ticker} prices at the vault's next update.`, + hash: status.hash, + } + case "failed": + return { + ...describeFailure(status.failure, redeemContractErrorReason), + hash: status.hash, + } + } +} + +const describeClaimRedeemStatus = ( + status: ClaimRedeemStatus, + amount: string, + ticker: string, + uncovered?: string, +): { heading: string; body: string; hash?: string } | undefined => { + switch (status.status) { + case "idle": + return undefined + case "preparing": + return { + heading: "Preparing your claim", + body: "We are getting your claim ready. Your wallet will ask you to approve it next.", + } + case "awaiting-signature": + return { + heading: "Confirm in your wallet", + body: `You receive what you're owed. Signing sends ${amount} ${ticker} to your wallet.`, + } + case "submitted": + return { + heading: "Sending your claim", + body: "Your claim is on its way to the network. This should only take a moment. Closing this window will not stop it.", + hash: status.hash, + } + case "confirmed": + return status.assetsClaimed > 0n + ? { + heading: `${ticker} claimed`, + body: `${formatExact(status.assetsClaimed, AMOUNT_DECIMALS)} ${ticker} has been added to your wallet.`, + hash: status.hash, + } + : status.sharesReturned + ? { + heading: "Shares returned", + body: "The price left nothing to claim, so your shares have been returned to your wallet instead.", + hash: status.hash, + } + : { + heading: "Nothing to pay out", + body: "The price left nothing to pay out, and the vault is winding down, so these shares were burned.", + hash: status.hash, + } + case "failed": + return { + ...describeFailure(status.failure, (code) => + claimRedeemContractErrorReason(code, ticker, uncovered), + ), + hash: status.hash, + } + } +} + +const describeCloseEpochStatus = ( + status: CloseEpochStatus, + epoch: string, +): { heading: string; body: string; hash?: string } | undefined => { + switch (status.status) { + case "idle": + return undefined + case "preparing": + return { + heading: `Preparing to close epoch ${epoch}`, + body: "We are getting the close ready. Your wallet will ask you to approve it next.", + } + case "awaiting-signature": + return { + heading: "Confirm in your wallet", + body: `Signing seals epoch ${epoch} and opens the next one. Requests in epoch ${epoch} wait for a price.`, + } + case "submitted": + return { + heading: "Sending the close", + body: "The close is on its way to the network. This should only take a moment. Closing this window will not stop it.", + hash: status.hash, + } + case "confirmed": + return { + heading: `Epoch ${status.sealedEpoch} sealed`, + body: `Epoch ${status.sealedEpoch + 1n} is open and accepting requests. Epoch ${status.sealedEpoch} prices once the notice elapses and a valid price is attested.`, + hash: status.hash, + } + case "failed": + return { + ...describeFailure( + status.failure, + closeEpochContractErrorReason, + OPERATOR_FOLLOW_UP, + ), + hash: status.hash, + } + } +} + +const describeFulfillEpochStatus = ( + status: FulfillEpochStatus, + epoch: string, +): { heading: string; body: string; hash?: string } | undefined => { + switch (status.status) { + case "idle": + return undefined + case "preparing": + return { + heading: `Preparing to price epoch ${epoch}`, + body: "We are getting the settlement ready. Your wallet will ask you to approve it next.", + } + case "awaiting-signature": + return { + heading: "Confirm in your wallet", + body: `Signing prices epoch ${epoch} at the oracle's current price and settles its requests.`, + } + case "submitted": + return { + heading: "Sending the settlement", + body: "The settlement is on its way to the network. This should only take a moment. Closing this window will not stop it.", + hash: status.hash, + } + case "confirmed": + return { + heading: `Epoch ${epoch} priced`, + body: `Settled at ${formatScaled(status.sharePrice, PRICE_DECIMALS, 4)} per share. Share claims are ready; redemption claims wait until the reserve covers them.`, + hash: status.hash, + } + case "failed": + return { + ...describeFailure( + status.failure, + fulfillEpochContractErrorReason, + OPERATOR_FOLLOW_UP, + ), + hash: status.hash, + } + } +} + +const describeAttestStatus = ( + status: AttestStatus, + price: string, + validFor: string | null, +): { heading: string; body: string; hash?: string } | undefined => { + switch (status.status) { + case "idle": + return undefined + case "preparing": + return { + heading: `Preparing to attest ${price}`, + body: "We are getting the attestation ready. Your wallet will ask you to approve it next.", + } + case "awaiting-signature": + return { + heading: "Confirm in your wallet", + body: `Signing records ${price} as the share price, with its proof. Sealed epochs settle at it once their notice elapses.`, + } + case "submitted": + return { + heading: "Sending the attestation", + body: "The attestation is on its way to the network. This should only take a moment. Closing this window will not stop it.", + hash: status.hash, + } + case "confirmed": + return { + heading: "Price attested", + body: + validFor === null + ? `${price} is the share price.` + : `${price} is the share price. Valid for ${validFor}.`, + hash: status.hash, + } + case "failed": + return { + ...describeFailure( + status.failure, + attestContractErrorReason, + OPERATOR_FOLLOW_UP, + ), + hash: status.hash, + } + } +} + +const describeDeployStatus = ( + status: DeployToCustodianStatus, + amount: string, +): { heading: string; body: string; hash?: string } | undefined => { + switch (status.status) { + case "idle": + return undefined + case "preparing": + return { + heading: `Preparing to deploy ${amount}`, + body: "We are getting the deployment ready. Your wallet will ask you to approve it next.", + } + case "awaiting-signature": + return { + heading: "Confirm in your wallet", + body: `Signing sends ${amount} from the vault's reserve to the custodian.`, + } + case "submitted": + return { + heading: "Sending the deployment", + body: "The deployment is on its way to the network. This should only take a moment. Closing this window will not stop it.", + hash: status.hash, + } + case "confirmed": + return { + heading: "Deployed", + body: `${amount} left the vault for the custodian.`, + hash: status.hash, + } + case "failed": + return { + ...describeFailure( + status.failure, + deployContractErrorReason, + OPERATOR_FOLLOW_UP, + ), + hash: status.hash, + } + } +} + +const describeFundStatus = ( + status: FundStatus, + amount: string, +): { heading: string; body: string; hash?: string } | undefined => { + switch (status.status) { + case "idle": + return undefined + case "preparing": + return { + heading: `Preparing to fund ${amount}`, + body: "We are getting the funding ready. Your wallet will ask you to approve it next.", + } + case "awaiting-signature": + return { + heading: "Confirm in your wallet", + body: `Signing moves ${amount} from your wallet into the vault's reserve.`, + } + case "submitted": + return { + heading: "Sending the funding", + body: "The funding is on its way to the network. This should only take a moment. Closing this window will not stop it.", + hash: status.hash, + } + case "confirmed": + return { + heading: "Funded", + body: `${amount} joined the reserve.`, + hash: status.hash, + } + case "failed": + return { + ...describeFailure( + status.failure, + fundContractErrorReason, + OPERATOR_FOLLOW_UP, + ), + hash: status.hash, + } + } +} + +const describeActivateWindDownStatus = ( + status: ActivateWindDownStatus, +): { heading: string; body: string; hash?: string } | undefined => { + switch (status.status) { + case "idle": + return undefined + case "preparing": + return { + heading: "Preparing to activate the wind-down", + body: "We are getting the activation ready. Your wallet will ask you to approve it next.", + } + case "awaiting-signature": + return { + heading: "Confirm in your wallet", + body: "Signing stops new requests and opens distribution rounds.", + } + case "submitted": + return { + heading: "Sending the activation", + body: "The activation is on its way to the network. This should only take a moment. Closing this window will not stop it.", + hash: status.hash, + } + case "confirmed": + return { + heading: "Wind-down active", + body: "New requests are closed. Rounds can now distribute the free reserve.", + hash: status.hash, + } + case "failed": + return { + ...describeFailure( + status.failure, + activateWindDownContractErrorReason, + OPERATOR_FOLLOW_UP, + ), + hash: status.hash, + } + } +} + +const describeFinalizeRoundStatus = ( + status: FinalizeWindDownRoundStatus, + round: string, +): { heading: string; body: string; hash?: string } | undefined => { + switch (status.status) { + case "idle": + return undefined + case "preparing": + return { + heading: `Preparing round ${round}`, + body: "We are getting the round ready. Your wallet will ask you to approve it next.", + } + case "awaiting-signature": + return { + heading: "Confirm in your wallet", + body: `Signing distributes the free reserve to holders as round ${round}.`, + } + case "submitted": + return { + heading: "Sending the round", + body: "The round is on its way to the network. This should only take a moment. Closing this window will not stop it.", + hash: status.hash, + } + case "confirmed": + return { + heading: `Round ${round} finalized`, + body: `${formatExact(status.credited, AMOUNT_DECIMALS)} is claimable by holders.`, + hash: status.hash, + } + case "failed": + return { + ...describeFailure( + status.failure, + finalizeRoundContractErrorReason, + OPERATOR_FOLLOW_UP, + ), + hash: status.hash, + } + } +} + +type AdminCopy = { + refuser: "vault" | "share token" | "oracle" | "identity verifier" + reasons?: Partial> + verb: string + signing: string + done: string + doneBody: string +} + +const EMERGENCY_COPY: Record = { + "pause-vault": { + refuser: "vault", + verb: "pause the vault", + signing: "Signing stops new deposits, pricing and upgrades.", + done: "Vault paused", + doneBody: + "New deposits, pricing and upgrades are stopped. Redemptions, claims, cancels and closing epochs stay open.", + }, + "unpause-vault": { + refuser: "vault", + verb: "unpause the vault", + signing: "Signing resumes deposits, pricing and upgrades.", + done: "Vault unpaused", + doneBody: "Deposits, pricing and upgrades are open again.", + }, + "pause-nav": { + refuser: "oracle", + verb: "pause the NAV", + signing: "Signing stops pricing until the NAV is unpaused.", + done: "NAV paused", + doneBody: + "Pricing is stopped. No epoch can be fulfilled until the NAV is unpaused.", + }, + "unpause-nav": { + refuser: "oracle", + verb: "unpause the NAV", + signing: "Signing resumes pricing.", + done: "NAV unpaused", + doneBody: "Epochs can be fulfilled again while the price is valid.", + }, + "clear-latest": { + refuser: "oracle", + verb: "clear the latest price", + signing: "Signing removes the recorded price.", + done: "Latest price cleared", + doneBody: + "The next attestation is not limited by its distance from the last. Pricing stays stopped until the NAV is unpaused.", + }, + "pause-token": { + refuser: "share token", + verb: "pause the share token", + signing: "Signing stops holder transfers only.", + done: "Share token paused", + doneBody: + "Holder transfers are stopped. Minting, burning, the vault's escrow moves and legal transfers continue.", + }, + "unpause-token": { + refuser: "share token", + verb: "unpause the share token", + signing: "Signing resumes holder transfers.", + done: "Share token unpaused", + doneBody: "Holder transfers are open again.", + }, +} + +const setterCopy = + (refuser: AdminCopy["refuser"], subject: string, effect?: string) => + (value: string): AdminCopy => ({ + refuser, + verb: `set the ${subject.toLowerCase()}`, + signing: `New ${subject.toLowerCase()}: ${value}.`, + done: `${subject} updated`, + doneBody: effect ?? `${subject}: ${value}.`, + }) + +const capitalize = (text: string): string => + `${text.charAt(0).toUpperCase()}${text.slice(1)}` + +type TimelockRefuser = Extract< + AdminCopy["refuser"], + "vault" | "share token" | "oracle" +> + +const WAITS: Record = { + vault: "the upgrade delay", + "share token": "the vault's upgrade delay", + oracle: "the vault's upgrade delay", +} + +const proposeCopy = + (refuser: TimelockRefuser, verb: string, subject: string) => + (value: string): AdminCopy => ({ + refuser, + verb, + signing: `Signing queues ${subject} ${value}.`, + done: "Change proposed", + doneBody: `${capitalize(subject)} ${value} applies once ${WAITS[refuser]} passes.`, + }) + +const MIN_EPOCH_DURATION_REASONS = { + 6007: "The minimum epoch duration must be 1 to 30 days.", +} + +const CONFIGURATION_COPY: Record< ConfigurationModalAction, - EmergencyModalAction, + (value: string) => AdminCopy +> = { + "set-deposit-cap": setterCopy("vault", "Deposit cap"), + "set-custodian": setterCopy("vault", "Custodian", EFFECTS.custodian), + "set-notice": setterCopy("vault", "Notice", EFFECTS.notice), + "set-wind-down-delay": setterCopy( + "vault", + "Wind-down delay", + EFFECTS.windDownDelay, + ), + "set-min-epoch-duration": (value) => ({ + ...setterCopy( + "vault", + "Minimum epoch duration", + EFFECTS.minEpochDuration, + )(value), + reasons: MIN_EPOCH_DURATION_REASONS, + }), + "propose-custodian": proposeCopy("vault", "propose a custodian", "custodian"), + "propose-notice": proposeCopy("vault", "propose a notice", "notice"), + "propose-wind-down-delay": proposeCopy( + "vault", + "propose a wind-down delay", + "wind-down delay", + ), + "propose-min-epoch-duration": (value) => ({ + ...proposeCopy( + "vault", + "propose a minimum epoch duration", + "minimum epoch duration", + )(value), + reasons: MIN_EPOCH_DURATION_REASONS, + }), + "set-oracle-config": () => ({ + refuser: "oracle", + verb: "set the oracle limits", + signing: "Signing replaces every oracle limit.", + done: "Oracle limits updated", + doneBody: "The new limits are recorded.", + }), + "propose-oracle-config": () => ({ + refuser: "oracle", + verb: "propose oracle limits", + signing: "Signing queues every new oracle limit.", + done: "Change proposed", + doneBody: `The new oracle limits apply once ${WAITS.oracle} passes.`, + }), + "set-compliance": setterCopy("share token", "Compliance contract"), + "propose-compliance": proposeCopy( + "share token", + "propose a compliance contract", + "compliance contract", + ), + "set-identity-verifier": setterCopy("share token", "Identity verifier"), + "propose-identity-verifier": proposeCopy( + "share token", + "propose an identity verifier", + "identity verifier", + ), +} + +const grantCopy = + (refuser: AdminCopy["refuser"]) => + (value: string): AdminCopy => ({ + refuser, + verb: "grant a role", + signing: `Signing grants ${value}.`, + done: "Role granted", + doneBody: `Granted ${value}.`, + }) + +const revokeCopy = + (refuser: AdminCopy["refuser"]) => + (value: string): AdminCopy => ({ + refuser, + verb: "revoke a role", + signing: `Signing revokes ${value}.`, + done: "Role revoked", + doneBody: `Revoked ${value}.`, + }) + +const handOverCopy = + (refuser: AdminCopy["refuser"]) => + (value: string): AdminCopy => ({ + refuser, + verb: "hand over the admin", + signing: "Signing proposes the handover.", + done: "Handover proposed", + doneBody: value, + }) + +const cancelHandoverCopy = + (refuser: AdminCopy["refuser"]) => + (value: string): AdminCopy => ({ + refuser, + verb: "cancel the handover", + signing: `Signing withdraws the offer to ${value}.`, + done: "Handover cancelled", + doneBody: `${value} can no longer accept.`, + }) + +const ACCEPT_WIND_DOWN_CODES: Record = { + vault: 6046, + oracle: 3018, + "share token": 7008, +} + +const acceptAdminCopy = (refuser: TimelockRefuser) => (): AdminCopy => ({ + refuser, + reasons: { + [ACCEPT_WIND_DOWN_CODES[refuser]]: + "The vault is winding down, so the admin role can no longer change hands.", + }, + verb: "accept the admin role", + signing: `Signing makes this wallet the ${refuser} admin.`, + done: "Admin accepted", + doneBody: `This wallet is now the ${refuser} admin.`, +}) + +const proposalCopy = + (refuser: TimelockRefuser) => + (value: string): AdminCopy => ({ + refuser, + verb: "propose an upgrade", + signing: `Signing queues ${value}.`, + done: "Upgrade proposed", + doneBody: `${value} can apply once ${WAITS[refuser]} passes.`, + }) + +const VAULT_APPLY_REASONS = { + 1000: "The vault is paused; unpause it first.", + 6046: "The vault is winding down, so no change can apply.", +} + +const applyChangeCopy = + (refuser: TimelockRefuser) => + (value: string): AdminCopy => ({ + refuser, + ...(refuser === "vault" ? { reasons: VAULT_APPLY_REASONS } : {}), + verb: "apply the change", + signing: `Signing applies ${value}.`, + done: "Change applied", + doneBody: `Applied ${value}.`, + }) + +const cancelChangeCopy = + (refuser: TimelockRefuser) => + (value: string): AdminCopy => ({ + refuser, + verb: "cancel the change", + signing: `Signing withdraws ${value}.`, + done: "Change cancelled", + doneBody: `Withdrew ${value}.`, + }) + +const proposeWindDownCopy = (value: string): AdminCopy => ({ + refuser: "vault", + verb: "propose a wind-down", + signing: "Signing starts the wind-down countdown.", + done: "Wind-down proposed", + doneBody: value, +}) + +const cancelWindDownCopy = (): AdminCopy => ({ + refuser: "vault", + verb: "cancel the wind-down", + signing: "Signing withdraws the wind-down.", + done: "Wind-down cancelled", + doneBody: "The vault stays open.", +}) + +const GOVERNANCE_COPY: Record< GovernanceModalAction, - TransactionModalProps, -} from "./transactionModal/types" -export { ADMIN_VERBS } from "./transactionModal/content" -export { HELD_WHILE_FROZEN, NOT_ALLOWLISTED } from "./transactionModal/investor" + (value: string) => AdminCopy +> = { + "grant-vault-role": grantCopy("vault"), + "revoke-vault-role": revokeCopy("vault"), + "grant-oracle-role": grantCopy("oracle"), + "revoke-oracle-role": revokeCopy("oracle"), + "grant-token-role": grantCopy("share token"), + "revoke-token-role": revokeCopy("share token"), + "hand-over-vault-admin": handOverCopy("vault"), + "cancel-vault-handover": cancelHandoverCopy("vault"), + "accept-vault-admin": acceptAdminCopy("vault"), + "hand-over-oracle-admin": handOverCopy("oracle"), + "cancel-oracle-handover": cancelHandoverCopy("oracle"), + "accept-oracle-admin": acceptAdminCopy("oracle"), + "hand-over-token-admin": handOverCopy("share token"), + "cancel-token-handover": cancelHandoverCopy("share token"), + "accept-token-admin": acceptAdminCopy("share token"), + "propose-grant-role": proposeCopy( + "vault", + "propose a role grant", + "the grant of", + ), + "propose-revoke-role": proposeCopy( + "vault", + "propose a role revoke", + "the revoke of", + ), + "propose-admin": proposeCopy( + "vault", + "propose the hand-over", + "the hand-over to", + ), + "propose-token-grant-role": proposeCopy( + "share token", + "propose a role grant", + "the grant of", + ), + "propose-token-revoke-role": proposeCopy( + "share token", + "propose a role revoke", + "the revoke of", + ), + "propose-token-admin": proposeCopy( + "share token", + "propose the hand-over", + "the hand-over to", + ), + "propose-oracle-grant-role": proposeCopy( + "oracle", + "propose a role grant", + "the grant of", + ), + "propose-oracle-revoke-role": proposeCopy( + "oracle", + "propose a role revoke", + "the revoke of", + ), + "propose-oracle-admin": proposeCopy( + "oracle", + "propose the hand-over", + "the hand-over to", + ), + "propose-code-upgrade": proposalCopy("vault"), + "propose-upgrade-delay": proposalCopy("vault"), + "propose-oracle-upgrade": proposalCopy("oracle"), + "apply-change": applyChangeCopy("vault"), + "cancel-change": cancelChangeCopy("vault"), + "apply-token-change": applyChangeCopy("share token"), + "cancel-token-change": cancelChangeCopy("share token"), + "apply-oracle-change": applyChangeCopy("oracle"), + "cancel-oracle-change": cancelChangeCopy("oracle"), + "propose-wind-down": proposeWindDownCopy, + "cancel-wind-down": cancelWindDownCopy, +} + +const LEGAL_TRANSFER_REASONS: Partial> = { + 7012: "A legal transfer never moves shares into or out of the vault: they are other investors' escrow.", + 7013: "The compliance account and the token admin must be two different accounts.", + 7015: "Enter a reference of 1 to 128 bytes.", + 304: "The destination is not allowlisted, so it cannot receive shares.", +} + +const COMPLIANCE_COPY: Record< + ComplianceModalAction, + (value: string) => AdminCopy +> = { + "allow-address": (value) => ({ + refuser: "identity verifier", + verb: "allow the address", + signing: `Signing adds ${value} to the allowlist.`, + done: "Address allowed", + doneBody: `${value} is on the allowlist.`, + }), + "disallow-address": (value) => ({ + refuser: "identity verifier", + verb: "remove the address from the allowlist", + signing: `Signing removes ${value} from the allowlist.`, + done: "Removed from the allowlist", + doneBody: `${value} is off the allowlist.`, + }), + "freeze-address": (value) => ({ + refuser: "share token", + verb: "freeze the address", + signing: `Signing freezes ${value}.`, + done: "Address frozen", + doneBody: `${value} is frozen.`, + }), + "unfreeze-address": (value) => ({ + refuser: "share token", + verb: "unfreeze the address", + signing: `Signing unfreezes ${value}.`, + done: "Address unfrozen", + doneBody: `${value} is no longer frozen.`, + }), + "freeze-shares": (value) => ({ + refuser: "share token", + verb: "freeze shares", + signing: `Signing freezes ${value}.`, + done: "Shares frozen", + doneBody: `Froze ${value}.`, + }), + "unfreeze-shares": (value) => ({ + refuser: "share token", + verb: "unfreeze shares", + signing: `Signing unfreezes ${value}.`, + done: "Shares unfrozen", + doneBody: `Unfroze ${value}.`, + }), + "legal-transfer": (value) => ({ + refuser: "share token", + reasons: LEGAL_TRANSFER_REASONS, + verb: "make a legal transfer", + signing: `Signing approves moving ${value}.`, + done: "Legal transfer made", + doneBody: `Moved ${value}.`, + }), +} + +const verbsOf = ( + table: Record AdminCopy)>, +): Record => + Object.fromEntries( + Object.entries AdminCopy)>(table).map( + ([id, copy]) => [id, (typeof copy === "function" ? copy("") : copy).verb], + ), + ) as Record + +export const ADMIN_VERBS: Record = { + ...verbsOf(EMERGENCY_COPY), + ...verbsOf(CONFIGURATION_COPY), + ...verbsOf(GOVERNANCE_COPY), + ...verbsOf(COMPLIANCE_COPY), +} + +const adminContractErrorReason = + (refuser: AdminCopy["refuser"]) => + (code: number): string => { + switch (code) { + case 1000: + return `The ${refuser} is already paused.` + case 1001: + return `The ${refuser} is not paused, so there is nothing to unpause.` + case 300: + return "The address does not hold enough shares." + case 301: + return "The amount cannot be negative." + case 303: + return "The address does not have that many frozen shares." + case 2000: + return "This wallet does not hold the role this action needs." + case 2001: + return `The ${refuser} has no admin.` + case 2002: + return "The role's holders changed before this was recorded." + case 2007: + return "The account does not hold this role." + case 2010: + return `The ${refuser} already has the maximum number of roles.` + case 2200: + return "There is no pending handover." + case 2201: + return "The expiry is outside what the network allows." + case 2202: + return "The pending handover is to a different address." + case 2203: + return "The handover expired before it was accepted." + case 6046: + return "The wind-down is already active." + case 6047: + return "No wind-down is proposed." + case 6048: + return "A wind-down is already proposed." + case 6055: + return "A code change is already pending; one at a time." + case 6056: + return "There is no pending change with that id." + case 6057: + return "The change's delay has not passed yet." + case 6058: + return "The upgrade delay cannot be under 7 days." + case 6059: + return "The upgrade delay cannot be under the notice." + case 6060: + return "The upgrade delay cannot exceed 90 days." + case 6063: + return "There are already 16 pending changes; apply or cancel one first." + case 6064: + return "Setup is still open, so no change can be proposed yet." + case 6065: + return "After setup this change goes through the timelock." + case 6069: + return "The wind-down delay cannot be under 7 days." + case 6075: + return "The custodian cannot be the vault itself." + case 3001: + return "One of the oracle limits is out of range." + case 3008: + return "The NAV is not paused, so the latest price cannot be cleared." + case 3011: + return "There is no pending change with that id." + case 3012: + return "The change's delay has not passed yet." + case 3013: + return "There are already 16 pending changes; apply or cancel one first." + case 3014: + return "Setup is still open, so no change can be proposed yet." + case 3015: + return "After setup this change goes through the timelock." + case 3016: + return "The oracle's setup has already finished." + case 3017: + return "The oracle is not bound to a vault yet." + case 3018: + return "The vault is winding down, so no change can be proposed or applied." + case 3019: + return "The vault is paused, which holds every oracle change; unpause it first." + case 3020: + return "The change's delay is shorter than the vault's notice now, so it cannot apply." + case 3021: + return "A code upgrade is already pending; cancel it first." + case 6007: + return "The deposit cap cannot be negative." + case 6045: + return "The notice cannot exceed 30 days." + case 6053: + return "The wind-down delay cannot exceed 90 days." + case 6061: + return "The notice cannot exceed the upgrade delay." + case 7001: + return "There is no pending change with that id." + case 7002: + return "The change's delay has not passed yet." + case 7003: + return "There are already 16 pending changes; apply or cancel one first." + case 7004: + return "Setup is still open, so no change can be proposed yet." + case 7005: + return "After setup this change goes through the timelock." + case 7008: + return "The vault is winding down, so no change can be proposed or applied." + case 7009: + return "The vault is paused, which holds every share-token change; unpause it first." + case 7011: + return "A code upgrade is already pending; cancel it first." + case 7016: + return "An admin hand-over is still pending; cancel it first." + default: + return `The ${refuser} refused this request. Try again, and contact support if it keeps happening.` + } + } + +const describeAdminStatus = ( + copy: AdminCopy, + status: TransactionStatus, + followUp: string, +): { heading: string; body: string; hash?: string } | undefined => { + switch (status.status) { + case "idle": + return undefined + case "preparing": + return { + heading: `Preparing to ${copy.verb}`, + body: "We are getting it ready. Your wallet will ask you to approve it next.", + } + case "awaiting-signature": + return { heading: "Confirm in your wallet", body: copy.signing } + case "submitted": + return { + heading: "Sending", + body: "It is on its way to the network. This should only take a moment. Closing this window will not stop it.", + hash: status.hash, + } + case "shared": + if (status.standing === "admin-authorization") + return { + heading: "Needs the token admin's authorization", + body: "Share the link with the token admin. Once they authorize it, compliance's signers sign it.", + } + return { + heading: describeStanding(status.standing), + body: describeShared(status.expiresAt), + } + case "confirmed": + return { heading: copy.done, body: copy.doneBody, hash: status.hash } + case "failed": + return { + ...describeFailure( + status.failure, + (code) => + copy.reasons?.[code] ?? + adminContractErrorReason(copy.refuser)(code), + followUp, + copy.refuser, + ), + hash: status.hash, + } + } +} + +const describeStatus = ( + props: TransactionModalProps, +): { heading: string; body: string; hash?: string } | undefined => { + switch (props.action) { + case "subscribe": + return describeSubscribeStatus(props.status, props.amount, props.ticker) + case "cancel": + return describeCancelStatus(props.status, props.amount, props.ticker) + case "cancel-redeem": + return describeCancelRedeemStatus( + props.status, + props.amount, + props.ticker, + props.assetTicker, + props.windingDown === true, + ) + case "claim": + return describeClaimStatus( + props.status, + props.amount, + props.ticker, + props.shareTicker, + ) + case "redeem": + return describeRedeemStatus(props.status, props.amount, props.ticker) + case "claim-redeem": + return describeClaimRedeemStatus( + props.status, + props.amount, + props.ticker, + props.uncovered, + ) + case "claim-wind-down": + return describeClaimWindDownStatus( + props.status, + props.amount, + props.ticker, + props.shareTicker, + ) + case "close-epoch": + return describeCloseEpochStatus(props.status, props.epoch) + case "fulfill-epoch": + return describeFulfillEpochStatus(props.status, props.epoch) + case "attest": + return describeAttestStatus(props.status, props.price, props.validFor) + case "deploy": + return describeDeployStatus(props.status, props.amount) + case "fund": + return describeFundStatus(props.status, props.amount) + case "activate-wind-down": + return describeActivateWindDownStatus(props.status) + case "finalize-round": + return describeFinalizeRoundStatus(props.status, props.round) + case "pause-vault": + case "unpause-vault": + case "pause-nav": + case "unpause-nav": + case "clear-latest": + case "pause-token": + case "unpause-token": + return describeAdminStatus( + EMERGENCY_COPY[props.action], + props.status, + EMERGENCY_FOLLOW_UP, + ) + case "set-deposit-cap": + case "set-custodian": + case "set-notice": + case "set-wind-down-delay": + case "set-min-epoch-duration": + case "propose-custodian": + case "propose-notice": + case "propose-wind-down-delay": + case "propose-min-epoch-duration": + case "set-oracle-config": + case "propose-oracle-config": + case "set-compliance": + case "propose-compliance": + case "set-identity-verifier": + case "propose-identity-verifier": + return describeAdminStatus( + CONFIGURATION_COPY[props.action](props.value), + props.status, + CONFIGURATION_FOLLOW_UP, + ) + case "grant-vault-role": + case "revoke-vault-role": + case "grant-oracle-role": + case "revoke-oracle-role": + case "grant-token-role": + case "revoke-token-role": + case "hand-over-vault-admin": + case "cancel-vault-handover": + case "accept-vault-admin": + case "hand-over-oracle-admin": + case "cancel-oracle-handover": + case "accept-oracle-admin": + case "hand-over-token-admin": + case "cancel-token-handover": + case "accept-token-admin": + case "propose-grant-role": + case "propose-revoke-role": + case "propose-admin": + case "propose-token-grant-role": + case "propose-token-revoke-role": + case "propose-token-admin": + case "propose-code-upgrade": + case "propose-upgrade-delay": + case "apply-change": + case "cancel-change": + case "apply-token-change": + case "cancel-token-change": + case "propose-oracle-grant-role": + case "propose-oracle-revoke-role": + case "propose-oracle-admin": + case "propose-oracle-upgrade": + case "apply-oracle-change": + case "cancel-oracle-change": + case "propose-wind-down": + case "cancel-wind-down": + return describeAdminStatus( + GOVERNANCE_COPY[props.action](props.value), + props.status, + GOVERNANCE_FOLLOW_UP, + ) + case "allow-address": + case "disallow-address": + case "freeze-address": + case "unfreeze-address": + case "freeze-shares": + case "unfreeze-shares": + case "legal-transfer": + return describeAdminStatus( + COMPLIANCE_COPY[props.action](props.value), + props.status, + COMPLIANCE_FOLLOW_UP, + ) + } +} + +type StepId = "signature" | "network" | "recorded" +type StepState = "done" | "current" | "upcoming" | "failed" + +const STEP_ORDER: StepId[] = ["signature", "network", "recorded"] + +const stepLabel: Record = { + signature: "Approved in your wallet", + network: "Sent to the network", + recorded: "Recorded", +} + +const computeSteps = ( + status: + | RequestDepositStatus + | CancelDepositStatus + | CancelRedeemStatus + | ClaimDepositStatus + | RequestRedeemStatus + | ClaimRedeemStatus + | CloseEpochStatus + | FulfillEpochStatus + | AttestStatus + | DeployToCustodianStatus + | FundStatus + | ActivateWindDownStatus + | FinalizeWindDownRoundStatus + | TransactionStatus, +): Record | undefined => { + switch (status.status) { + case "idle": + return undefined + case "preparing": + return { + signature: "upcoming", + network: "upcoming", + recorded: "upcoming", + } + case "awaiting-signature": + return { signature: "current", network: "upcoming", recorded: "upcoming" } + case "submitted": + return { signature: "done", network: "current", recorded: "upcoming" } + case "shared": + return { signature: "done", network: "upcoming", recorded: "upcoming" } + case "confirmed": + return { signature: "done", network: "done", recorded: "done" } + case "failed": + if (status.failure.kind === "unknown" && status.hash !== undefined) { + return { signature: "done", network: "done", recorded: "failed" } + } + if ( + status.failure.kind === "unknown" || + status.failure.kind === "network" || + status.failure.kind === "stale" + ) { + return { signature: "done", network: "failed", recorded: "upcoming" } + } + return { signature: "failed", network: "upcoming", recorded: "upcoming" } + } +} + +const stepStateClassName: Record = { + done: styles.stepDone, + current: styles.stepCurrent, + failed: styles.stepFailed, + upcoming: styles.stepUpcoming, +} + +const stepAnnouncement: Partial> = { + done: "done", + current: "in progress", + failed: "failed", +} export const ShareLink: React.FC<{ xdr: string }> = ({ xdr }) => { const [copied, setCopied] = React.useState(false) diff --git a/app/src/components/vault/transactionModal/adminCopy.ts b/app/src/components/vault/transactionModal/adminCopy.ts deleted file mode 100644 index abef1cc0..00000000 --- a/app/src/components/vault/transactionModal/adminCopy.ts +++ /dev/null @@ -1,201 +0,0 @@ -import { type TransactionStatus } from "../../../hooks/useContractTransaction" -import { describeStanding } from "../../../pages/actions" -import { describeFailure, describeShared } from "./outcome" - -export type AdminCopy = { - refuser: "vault" | "share token" | "oracle" | "identity verifier" - reasons?: Partial> - verb: string - signing: string - done: string - doneBody: string -} - -const capitalize = (text: string): string => - `${text.charAt(0).toUpperCase()}${text.slice(1)}` - -export type TimelockRefuser = Extract< - AdminCopy["refuser"], - "vault" | "share token" | "oracle" -> - -export const WAITS: Record = { - vault: "the upgrade delay", - "share token": "the vault's upgrade delay", - oracle: "the vault's upgrade delay", -} - -export const proposeCopy = - (refuser: TimelockRefuser, verb: string, subject: string) => - (value: string): AdminCopy => ({ - refuser, - verb, - signing: `Signing queues ${subject} ${value}.`, - done: "Change proposed", - doneBody: `${capitalize(subject)} ${value} applies once ${WAITS[refuser]} passes.`, - }) - -const adminContractErrorReason = - (refuser: AdminCopy["refuser"]) => - (code: number): string => { - switch (code) { - case 1000: - return `The ${refuser} is already paused.` - case 1001: - return `The ${refuser} is not paused, so there is nothing to unpause.` - case 300: - return "The address does not hold enough shares." - case 301: - return "The amount cannot be negative." - case 303: - return "The address does not have that many frozen shares." - case 2000: - return "This wallet does not hold the role this action needs." - case 2001: - return `The ${refuser} has no admin.` - case 2002: - return "The role's holders changed before this was recorded." - case 2007: - return "The account does not hold this role." - case 2010: - return `The ${refuser} already has the maximum number of roles.` - case 2200: - return "There is no pending handover." - case 2201: - return "The expiry is outside what the network allows." - case 2202: - return "The pending handover is to a different address." - case 2203: - return "The handover expired before it was accepted." - case 6046: - return "The wind-down is already active." - case 6047: - return "No wind-down is proposed." - case 6048: - return "A wind-down is already proposed." - case 6055: - return "A code change is already pending; one at a time." - case 6056: - return "There is no pending change with that id." - case 6057: - return "The change's delay has not passed yet." - case 6058: - return "The upgrade delay cannot be under 7 days." - case 6059: - return "The upgrade delay cannot be under the notice." - case 6060: - return "The upgrade delay cannot exceed 90 days." - case 6063: - return "There are already 16 pending changes; apply or cancel one first." - case 6064: - return "Setup is still open, so no change can be proposed yet." - case 6065: - return "After setup this change goes through the timelock." - case 6069: - return "The wind-down delay cannot be under 7 days." - case 6075: - return "The custodian cannot be the vault itself." - case 3001: - return "One of the oracle limits is out of range." - case 3008: - return "The NAV is not paused, so the latest price cannot be cleared." - case 3011: - return "There is no pending change with that id." - case 3012: - return "The change's delay has not passed yet." - case 3013: - return "There are already 16 pending changes; apply or cancel one first." - case 3014: - return "Setup is still open, so no change can be proposed yet." - case 3015: - return "After setup this change goes through the timelock." - case 3016: - return "The oracle's setup has already finished." - case 3017: - return "The oracle is not bound to a vault yet." - case 3018: - return "The vault is winding down, so no change can be proposed or applied." - case 3019: - return "The vault is paused, which holds every oracle change; unpause it first." - case 3020: - return "The change's delay is shorter than the vault's notice now, so it cannot apply." - case 3021: - return "A code upgrade is already pending; cancel it first." - case 6007: - return "The deposit cap cannot be negative." - case 6045: - return "The notice cannot exceed 30 days." - case 6053: - return "The wind-down delay cannot exceed 90 days." - case 6061: - return "The notice cannot exceed the upgrade delay." - case 7001: - return "There is no pending change with that id." - case 7002: - return "The change's delay has not passed yet." - case 7003: - return "There are already 16 pending changes; apply or cancel one first." - case 7004: - return "Setup is still open, so no change can be proposed yet." - case 7005: - return "After setup this change goes through the timelock." - case 7008: - return "The vault is winding down, so no change can be proposed or applied." - case 7009: - return "The vault is paused, which holds every share-token change; unpause it first." - case 7011: - return "A code upgrade is already pending; cancel it first." - case 7016: - return "An admin hand-over is still pending; cancel it first." - default: - return `The ${refuser} refused this request. Try again, and contact support if it keeps happening.` - } - } - -export const describeAdminStatus = ( - copy: AdminCopy, - status: TransactionStatus, - followUp: string, -): { heading: string; body: string; hash?: string } | undefined => { - switch (status.status) { - case "idle": - return undefined - case "preparing": - return { - heading: `Preparing to ${copy.verb}`, - body: "We are getting it ready. Your wallet will ask you to approve it next.", - } - case "awaiting-signature": - return { heading: "Confirm in your wallet", body: copy.signing } - case "submitted": - return { - heading: "Sending", - body: "It is on its way to the network. This should only take a moment. Closing this window will not stop it.", - hash: status.hash, - } - case "shared": - if (status.standing === "admin-authorization") - return { - heading: "Needs the token admin's authorization", - body: "Share the link with the token admin. Once they authorize it, compliance's signers sign it.", - } - return { - heading: describeStanding(status.standing), - body: describeShared(status.expiresAt), - } - case "confirmed": - return { heading: copy.done, body: copy.doneBody, hash: status.hash } - case "failed": - return { - ...describeFailure( - status.failure, - (code) => - copy.reasons?.[code] ?? - adminContractErrorReason(copy.refuser)(code), - followUp, - copy.refuser, - ), - hash: status.hash, - } - } -} diff --git a/app/src/components/vault/transactionModal/compliance.ts b/app/src/components/vault/transactionModal/compliance.ts deleted file mode 100644 index 24444535..00000000 --- a/app/src/components/vault/transactionModal/compliance.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { type AdminCopy } from "./adminCopy" -import { type ComplianceModalAction } from "./types" - -export const COMPLIANCE_FOLLOW_UP = - "check the Compliance tab before trying again" - -const LEGAL_TRANSFER_REASONS: Partial> = { - 7012: "A legal transfer never moves shares into or out of the vault: they are other investors' escrow.", - 7013: "The compliance account and the token admin must be two different accounts.", - 7015: "Enter a reference of 1 to 128 bytes.", - 304: "The destination is not allowlisted, so it cannot receive shares.", -} - -export const COMPLIANCE_COPY: Record< - ComplianceModalAction, - (value: string) => AdminCopy -> = { - "allow-address": (value) => ({ - refuser: "identity verifier", - verb: "allow the address", - signing: `Signing adds ${value} to the allowlist.`, - done: "Address allowed", - doneBody: `${value} is on the allowlist.`, - }), - "disallow-address": (value) => ({ - refuser: "identity verifier", - verb: "remove the address from the allowlist", - signing: `Signing removes ${value} from the allowlist.`, - done: "Removed from the allowlist", - doneBody: `${value} is off the allowlist.`, - }), - "freeze-address": (value) => ({ - refuser: "share token", - verb: "freeze the address", - signing: `Signing freezes ${value}.`, - done: "Address frozen", - doneBody: `${value} is frozen.`, - }), - "unfreeze-address": (value) => ({ - refuser: "share token", - verb: "unfreeze the address", - signing: `Signing unfreezes ${value}.`, - done: "Address unfrozen", - doneBody: `${value} is no longer frozen.`, - }), - "freeze-shares": (value) => ({ - refuser: "share token", - verb: "freeze shares", - signing: `Signing freezes ${value}.`, - done: "Shares frozen", - doneBody: `Froze ${value}.`, - }), - "unfreeze-shares": (value) => ({ - refuser: "share token", - verb: "unfreeze shares", - signing: `Signing unfreezes ${value}.`, - done: "Shares unfrozen", - doneBody: `Unfroze ${value}.`, - }), - "legal-transfer": (value) => ({ - refuser: "share token", - reasons: LEGAL_TRANSFER_REASONS, - verb: "make a legal transfer", - signing: `Signing approves moving ${value}.`, - done: "Legal transfer made", - doneBody: `Moved ${value}.`, - }), -} diff --git a/app/src/components/vault/transactionModal/configuration.ts b/app/src/components/vault/transactionModal/configuration.ts deleted file mode 100644 index 98d37e37..00000000 --- a/app/src/components/vault/transactionModal/configuration.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { EFFECTS } from "../../../pages/configuration" -import { type AdminCopy, WAITS, proposeCopy } from "./adminCopy" -import { type ConfigurationModalAction } from "./types" - -export const CONFIGURATION_FOLLOW_UP = - "check the Configuration tab before trying again" -const setterCopy = - (refuser: AdminCopy["refuser"], subject: string, effect?: string) => - (value: string): AdminCopy => ({ - refuser, - verb: `set the ${subject.toLowerCase()}`, - signing: `New ${subject.toLowerCase()}: ${value}.`, - done: `${subject} updated`, - doneBody: effect ?? `${subject}: ${value}.`, - }) - -const MIN_EPOCH_DURATION_REASONS = { - 6007: "The minimum epoch duration must be 1 to 30 days.", -} - -export const CONFIGURATION_COPY: Record< - ConfigurationModalAction, - (value: string) => AdminCopy -> = { - "set-deposit-cap": setterCopy("vault", "Deposit cap"), - "set-custodian": setterCopy("vault", "Custodian", EFFECTS.custodian), - "set-notice": setterCopy("vault", "Notice", EFFECTS.notice), - "set-wind-down-delay": setterCopy( - "vault", - "Wind-down delay", - EFFECTS.windDownDelay, - ), - "set-min-epoch-duration": (value) => ({ - ...setterCopy( - "vault", - "Minimum epoch duration", - EFFECTS.minEpochDuration, - )(value), - reasons: MIN_EPOCH_DURATION_REASONS, - }), - "propose-custodian": proposeCopy("vault", "propose a custodian", "custodian"), - "propose-notice": proposeCopy("vault", "propose a notice", "notice"), - "propose-wind-down-delay": proposeCopy( - "vault", - "propose a wind-down delay", - "wind-down delay", - ), - "propose-min-epoch-duration": (value) => ({ - ...proposeCopy( - "vault", - "propose a minimum epoch duration", - "minimum epoch duration", - )(value), - reasons: MIN_EPOCH_DURATION_REASONS, - }), - "set-oracle-config": () => ({ - refuser: "oracle", - verb: "set the oracle limits", - signing: "Signing replaces every oracle limit.", - done: "Oracle limits updated", - doneBody: "The new limits are recorded.", - }), - "propose-oracle-config": () => ({ - refuser: "oracle", - verb: "propose oracle limits", - signing: "Signing queues every new oracle limit.", - done: "Change proposed", - doneBody: `The new oracle limits apply once ${WAITS.oracle} passes.`, - }), - "set-compliance": setterCopy("share token", "Compliance contract"), - "propose-compliance": proposeCopy( - "share token", - "propose a compliance contract", - "compliance contract", - ), - "set-identity-verifier": setterCopy("share token", "Identity verifier"), - "propose-identity-verifier": proposeCopy( - "share token", - "propose an identity verifier", - "identity verifier", - ), -} diff --git a/app/src/components/vault/transactionModal/content.ts b/app/src/components/vault/transactionModal/content.ts deleted file mode 100644 index 93bafda3..00000000 --- a/app/src/components/vault/transactionModal/content.ts +++ /dev/null @@ -1,180 +0,0 @@ -import { type AdminCopy, describeAdminStatus } from "./adminCopy" -import { COMPLIANCE_FOLLOW_UP, COMPLIANCE_COPY } from "./compliance" -import { CONFIGURATION_FOLLOW_UP, CONFIGURATION_COPY } from "./configuration" -import { - describeCloseEpochStatus, - describeFulfillEpochStatus, - describeAttestStatus, - describeDeployStatus, - describeFundStatus, - describeActivateWindDownStatus, - describeFinalizeRoundStatus, -} from "./cycle" -import { EMERGENCY_FOLLOW_UP, EMERGENCY_COPY } from "./emergency" -import { GOVERNANCE_FOLLOW_UP, GOVERNANCE_COPY } from "./governance" -import { - describeSubscribeStatus, - describeCancelStatus, - describeCancelRedeemStatus, - describeClaimStatus, - describeClaimWindDownStatus, - describeRedeemStatus, - describeClaimRedeemStatus, -} from "./investor" -import { type AdminModalAction, type TransactionModalProps } from "./types" - -const verbsOf = ( - table: Record AdminCopy)>, -): Record => - Object.fromEntries( - Object.entries AdminCopy)>(table).map( - ([id, copy]) => [id, (typeof copy === "function" ? copy("") : copy).verb], - ), - ) as Record - -export const ADMIN_VERBS: Record = { - ...verbsOf(EMERGENCY_COPY), - ...verbsOf(CONFIGURATION_COPY), - ...verbsOf(GOVERNANCE_COPY), - ...verbsOf(COMPLIANCE_COPY), -} - -export const describeStatus = ( - props: TransactionModalProps, -): { heading: string; body: string; hash?: string } | undefined => { - switch (props.action) { - case "subscribe": - return describeSubscribeStatus(props.status, props.amount, props.ticker) - case "cancel": - return describeCancelStatus(props.status, props.amount, props.ticker) - case "cancel-redeem": - return describeCancelRedeemStatus( - props.status, - props.amount, - props.ticker, - props.assetTicker, - props.windingDown === true, - ) - case "claim": - return describeClaimStatus( - props.status, - props.amount, - props.ticker, - props.shareTicker, - ) - case "redeem": - return describeRedeemStatus(props.status, props.amount, props.ticker) - case "claim-redeem": - return describeClaimRedeemStatus( - props.status, - props.amount, - props.ticker, - props.uncovered, - ) - case "claim-wind-down": - return describeClaimWindDownStatus( - props.status, - props.amount, - props.ticker, - props.shareTicker, - ) - case "close-epoch": - return describeCloseEpochStatus(props.status, props.epoch) - case "fulfill-epoch": - return describeFulfillEpochStatus(props.status, props.epoch) - case "attest": - return describeAttestStatus(props.status, props.price, props.validFor) - case "deploy": - return describeDeployStatus(props.status, props.amount) - case "fund": - return describeFundStatus(props.status, props.amount) - case "activate-wind-down": - return describeActivateWindDownStatus(props.status) - case "finalize-round": - return describeFinalizeRoundStatus(props.status, props.round) - case "pause-vault": - case "unpause-vault": - case "pause-nav": - case "unpause-nav": - case "clear-latest": - case "pause-token": - case "unpause-token": - return describeAdminStatus( - EMERGENCY_COPY[props.action], - props.status, - EMERGENCY_FOLLOW_UP, - ) - case "set-deposit-cap": - case "set-custodian": - case "set-notice": - case "set-wind-down-delay": - case "set-min-epoch-duration": - case "propose-custodian": - case "propose-notice": - case "propose-wind-down-delay": - case "propose-min-epoch-duration": - case "set-oracle-config": - case "propose-oracle-config": - case "set-compliance": - case "propose-compliance": - case "set-identity-verifier": - case "propose-identity-verifier": - return describeAdminStatus( - CONFIGURATION_COPY[props.action](props.value), - props.status, - CONFIGURATION_FOLLOW_UP, - ) - case "grant-vault-role": - case "revoke-vault-role": - case "grant-oracle-role": - case "revoke-oracle-role": - case "grant-token-role": - case "revoke-token-role": - case "hand-over-vault-admin": - case "cancel-vault-handover": - case "accept-vault-admin": - case "hand-over-oracle-admin": - case "cancel-oracle-handover": - case "accept-oracle-admin": - case "hand-over-token-admin": - case "cancel-token-handover": - case "accept-token-admin": - case "propose-grant-role": - case "propose-revoke-role": - case "propose-admin": - case "propose-token-grant-role": - case "propose-token-revoke-role": - case "propose-token-admin": - case "propose-code-upgrade": - case "propose-upgrade-delay": - case "apply-change": - case "cancel-change": - case "apply-token-change": - case "cancel-token-change": - case "propose-oracle-grant-role": - case "propose-oracle-revoke-role": - case "propose-oracle-admin": - case "propose-oracle-upgrade": - case "apply-oracle-change": - case "cancel-oracle-change": - case "propose-wind-down": - case "cancel-wind-down": - return describeAdminStatus( - GOVERNANCE_COPY[props.action](props.value), - props.status, - GOVERNANCE_FOLLOW_UP, - ) - case "allow-address": - case "disallow-address": - case "freeze-address": - case "unfreeze-address": - case "freeze-shares": - case "unfreeze-shares": - case "legal-transfer": - return describeAdminStatus( - COMPLIANCE_COPY[props.action](props.value), - props.status, - COMPLIANCE_FOLLOW_UP, - ) - } -} diff --git a/app/src/components/vault/transactionModal/cycle.ts b/app/src/components/vault/transactionModal/cycle.ts deleted file mode 100644 index a5db32f9..00000000 --- a/app/src/components/vault/transactionModal/cycle.ts +++ /dev/null @@ -1,434 +0,0 @@ -import { - AMOUNT_DECIMALS, - formatExact, - formatScaled, - PRICE_DECIMALS, -} from "@stellar-scaffold/app-lib" -import { type ActivateWindDownStatus } from "../../../hooks/useActivateWindDown" -import { type AttestStatus } from "../../../hooks/useAttest" -import { type CloseEpochStatus } from "../../../hooks/useCloseEpoch" -import { type DeployToCustodianStatus } from "../../../hooks/useDeployToCustodian" -import { type FinalizeWindDownRoundStatus } from "../../../hooks/useFinalizeWindDownRound" -import { type FulfillEpochStatus } from "../../../hooks/useFulfillEpoch" -import { type FundStatus } from "../../../hooks/useFund" -import { GENERIC_REFUSAL, describeFailure } from "./outcome" - -const closeEpochContractErrorReason = (code: number): string => { - switch (code) { - case 6046: - return "The vault is winding down, so no more epochs can be sealed." - case 6029: - return "The current epoch could not be found." - case 6032: - return "The current epoch is not open, so there is nothing to seal." - case 2000: - return "This wallet is not authorized to close epochs." - case 2007: - return "This wallet does not hold the manager role." - case 6077: - return "Only the manager can seal this epoch until it has been open for the minimum duration." - case 6064: - return "The vault's setup is still open, so no epoch can be sealed yet." - default: - return GENERIC_REFUSAL - } -} - -const fulfillEpochContractErrorReason = (code: number): string => { - switch (code) { - case 6046: - return "The vault is winding down, so epochs can no longer be priced." - case 6029: - return "This epoch could not be found." - case 6038: - return "This epoch is not sealed, so it cannot be priced." - case 6042: - return "The notice period has not elapsed yet." - case 6044: - return "The oracle price is not valid right now." - case 6043: - return "The latest price was attested before this epoch was sealed. A fresh attestation is needed." - case 6031: - return "The vault could not resolve a valid share price." - case 6014: - return "The settlement total is too large for the vault to record." - case 1000: - return "The vault is paused, so epochs cannot be priced." - case 6064: - return "The vault's setup is still open, so no epoch can be priced yet." - default: - return GENERIC_REFUSAL - } -} - -const attestContractErrorReason = (code: number): string => { - switch (code) { - case 3002: - return "That price is outside the band the oracle accepts." - case 3005: - return "The attestation would already have expired when recorded." - case 3003: - return "The cooldown since the last attestation has not elapsed yet." - case 3004: - return "That price moves further from the last attested price than the oracle allows." - case 3010: - return "Unpause the NAV first." - case 3023: - return "The proof hash cannot be all zeros." - case 3024: - return "The proof URI is longer than 256 bytes." - default: - return GENERIC_REFUSAL - } -} - -const deployContractErrorReason = (code: number): string => { - switch (code) { - case 6046: - return "The vault is winding down, so nothing can leave for the custodian." - case 6007: - return "Enter an amount greater than zero." - case 6012: - return "No custodian is set, so there is nowhere to deploy to." - case 6005: - return "That amount exceeds the free reserve. The rest is committed to priced exits." - case 6014: - return "That amount is too large for the vault to record." - case 2000: - return "This wallet is not authorized to deploy to the custodian." - case 2007: - return "This wallet does not hold the treasury role." - default: - return GENERIC_REFUSAL - } -} - -const fundContractErrorReason = (code: number): string => { - switch (code) { - case 6007: - return "Enter an amount greater than zero." - case 6014: - return "That amount is too large for the vault to record." - default: - return code < 6000 - ? "Your wallet could not cover the transfer. Check the balance and the trustline." - : GENERIC_REFUSAL - } -} - -const activateWindDownContractErrorReason = (code: number): string => { - switch (code) { - case 6047: - return "No wind-down has been proposed, so there is nothing to activate." - case 6046: - return "The wind-down is already active." - case 6049: - return "The wind-down delay has not elapsed yet." - default: - return GENERIC_REFUSAL - } -} - -const finalizeRoundContractErrorReason = (code: number): string => { - switch (code) { - case 6050: - return "The wind-down is not active, so no round can be finalized." - case 6052: - return "There is nothing to distribute: the free reserve is empty or too small to split across holders." - case 6014: - return "The round is too large for the vault to record." - default: - return GENERIC_REFUSAL - } -} - -const OPERATOR_FOLLOW_UP = "check the Cycle card before trying again" -export const describeCloseEpochStatus = ( - status: CloseEpochStatus, - epoch: string, -): { heading: string; body: string; hash?: string } | undefined => { - switch (status.status) { - case "idle": - return undefined - case "preparing": - return { - heading: `Preparing to close epoch ${epoch}`, - body: "We are getting the close ready. Your wallet will ask you to approve it next.", - } - case "awaiting-signature": - return { - heading: "Confirm in your wallet", - body: `Signing seals epoch ${epoch} and opens the next one. Requests in epoch ${epoch} wait for a price.`, - } - case "submitted": - return { - heading: "Sending the close", - body: "The close is on its way to the network. This should only take a moment. Closing this window will not stop it.", - hash: status.hash, - } - case "confirmed": - return { - heading: `Epoch ${status.sealedEpoch} sealed`, - body: `Epoch ${status.sealedEpoch + 1n} is open and accepting requests. Epoch ${status.sealedEpoch} prices once the notice elapses and a valid price is attested.`, - hash: status.hash, - } - case "failed": - return { - ...describeFailure( - status.failure, - closeEpochContractErrorReason, - OPERATOR_FOLLOW_UP, - ), - hash: status.hash, - } - } -} - -export const describeFulfillEpochStatus = ( - status: FulfillEpochStatus, - epoch: string, -): { heading: string; body: string; hash?: string } | undefined => { - switch (status.status) { - case "idle": - return undefined - case "preparing": - return { - heading: `Preparing to price epoch ${epoch}`, - body: "We are getting the settlement ready. Your wallet will ask you to approve it next.", - } - case "awaiting-signature": - return { - heading: "Confirm in your wallet", - body: `Signing prices epoch ${epoch} at the oracle's current price and settles its requests.`, - } - case "submitted": - return { - heading: "Sending the settlement", - body: "The settlement is on its way to the network. This should only take a moment. Closing this window will not stop it.", - hash: status.hash, - } - case "confirmed": - return { - heading: `Epoch ${epoch} priced`, - body: `Settled at ${formatScaled(status.sharePrice, PRICE_DECIMALS, 4)} per share. Share claims are ready; redemption claims wait until the reserve covers them.`, - hash: status.hash, - } - case "failed": - return { - ...describeFailure( - status.failure, - fulfillEpochContractErrorReason, - OPERATOR_FOLLOW_UP, - ), - hash: status.hash, - } - } -} - -export const describeAttestStatus = ( - status: AttestStatus, - price: string, - validFor: string | null, -): { heading: string; body: string; hash?: string } | undefined => { - switch (status.status) { - case "idle": - return undefined - case "preparing": - return { - heading: `Preparing to attest ${price}`, - body: "We are getting the attestation ready. Your wallet will ask you to approve it next.", - } - case "awaiting-signature": - return { - heading: "Confirm in your wallet", - body: `Signing records ${price} as the share price, with its proof. Sealed epochs settle at it once their notice elapses.`, - } - case "submitted": - return { - heading: "Sending the attestation", - body: "The attestation is on its way to the network. This should only take a moment. Closing this window will not stop it.", - hash: status.hash, - } - case "confirmed": - return { - heading: "Price attested", - body: - validFor === null - ? `${price} is the share price.` - : `${price} is the share price. Valid for ${validFor}.`, - hash: status.hash, - } - case "failed": - return { - ...describeFailure( - status.failure, - attestContractErrorReason, - OPERATOR_FOLLOW_UP, - ), - hash: status.hash, - } - } -} - -export const describeDeployStatus = ( - status: DeployToCustodianStatus, - amount: string, -): { heading: string; body: string; hash?: string } | undefined => { - switch (status.status) { - case "idle": - return undefined - case "preparing": - return { - heading: `Preparing to deploy ${amount}`, - body: "We are getting the deployment ready. Your wallet will ask you to approve it next.", - } - case "awaiting-signature": - return { - heading: "Confirm in your wallet", - body: `Signing sends ${amount} from the vault's reserve to the custodian.`, - } - case "submitted": - return { - heading: "Sending the deployment", - body: "The deployment is on its way to the network. This should only take a moment. Closing this window will not stop it.", - hash: status.hash, - } - case "confirmed": - return { - heading: "Deployed", - body: `${amount} left the vault for the custodian.`, - hash: status.hash, - } - case "failed": - return { - ...describeFailure( - status.failure, - deployContractErrorReason, - OPERATOR_FOLLOW_UP, - ), - hash: status.hash, - } - } -} - -export const describeFundStatus = ( - status: FundStatus, - amount: string, -): { heading: string; body: string; hash?: string } | undefined => { - switch (status.status) { - case "idle": - return undefined - case "preparing": - return { - heading: `Preparing to fund ${amount}`, - body: "We are getting the funding ready. Your wallet will ask you to approve it next.", - } - case "awaiting-signature": - return { - heading: "Confirm in your wallet", - body: `Signing moves ${amount} from your wallet into the vault's reserve.`, - } - case "submitted": - return { - heading: "Sending the funding", - body: "The funding is on its way to the network. This should only take a moment. Closing this window will not stop it.", - hash: status.hash, - } - case "confirmed": - return { - heading: "Funded", - body: `${amount} joined the reserve.`, - hash: status.hash, - } - case "failed": - return { - ...describeFailure( - status.failure, - fundContractErrorReason, - OPERATOR_FOLLOW_UP, - ), - hash: status.hash, - } - } -} - -export const describeActivateWindDownStatus = ( - status: ActivateWindDownStatus, -): { heading: string; body: string; hash?: string } | undefined => { - switch (status.status) { - case "idle": - return undefined - case "preparing": - return { - heading: "Preparing to activate the wind-down", - body: "We are getting the activation ready. Your wallet will ask you to approve it next.", - } - case "awaiting-signature": - return { - heading: "Confirm in your wallet", - body: "Signing stops new requests and opens distribution rounds.", - } - case "submitted": - return { - heading: "Sending the activation", - body: "The activation is on its way to the network. This should only take a moment. Closing this window will not stop it.", - hash: status.hash, - } - case "confirmed": - return { - heading: "Wind-down active", - body: "New requests are closed. Rounds can now distribute the free reserve.", - hash: status.hash, - } - case "failed": - return { - ...describeFailure( - status.failure, - activateWindDownContractErrorReason, - OPERATOR_FOLLOW_UP, - ), - hash: status.hash, - } - } -} - -export const describeFinalizeRoundStatus = ( - status: FinalizeWindDownRoundStatus, - round: string, -): { heading: string; body: string; hash?: string } | undefined => { - switch (status.status) { - case "idle": - return undefined - case "preparing": - return { - heading: `Preparing round ${round}`, - body: "We are getting the round ready. Your wallet will ask you to approve it next.", - } - case "awaiting-signature": - return { - heading: "Confirm in your wallet", - body: `Signing distributes the free reserve to holders as round ${round}.`, - } - case "submitted": - return { - heading: "Sending the round", - body: "The round is on its way to the network. This should only take a moment. Closing this window will not stop it.", - hash: status.hash, - } - case "confirmed": - return { - heading: `Round ${round} finalized`, - body: `${formatExact(status.credited, AMOUNT_DECIMALS)} is claimable by holders.`, - hash: status.hash, - } - case "failed": - return { - ...describeFailure( - status.failure, - finalizeRoundContractErrorReason, - OPERATOR_FOLLOW_UP, - ), - hash: status.hash, - } - } -} diff --git a/app/src/components/vault/transactionModal/emergency.ts b/app/src/components/vault/transactionModal/emergency.ts deleted file mode 100644 index 53be01f9..00000000 --- a/app/src/components/vault/transactionModal/emergency.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { type AdminCopy } from "./adminCopy" -import { type EmergencyModalAction } from "./types" - -export const EMERGENCY_FOLLOW_UP = "check the Emergency tab before trying again" -export const EMERGENCY_COPY: Record = { - "pause-vault": { - refuser: "vault", - verb: "pause the vault", - signing: "Signing stops new deposits, pricing and upgrades.", - done: "Vault paused", - doneBody: - "New deposits, pricing and upgrades are stopped. Redemptions, claims, cancels and closing epochs stay open.", - }, - "unpause-vault": { - refuser: "vault", - verb: "unpause the vault", - signing: "Signing resumes deposits, pricing and upgrades.", - done: "Vault unpaused", - doneBody: "Deposits, pricing and upgrades are open again.", - }, - "pause-nav": { - refuser: "oracle", - verb: "pause the NAV", - signing: "Signing stops pricing until the NAV is unpaused.", - done: "NAV paused", - doneBody: - "Pricing is stopped. No epoch can be fulfilled until the NAV is unpaused.", - }, - "unpause-nav": { - refuser: "oracle", - verb: "unpause the NAV", - signing: "Signing resumes pricing.", - done: "NAV unpaused", - doneBody: "Epochs can be fulfilled again while the price is valid.", - }, - "clear-latest": { - refuser: "oracle", - verb: "clear the latest price", - signing: "Signing removes the recorded price.", - done: "Latest price cleared", - doneBody: - "The next attestation is not limited by its distance from the last. Pricing stays stopped until the NAV is unpaused.", - }, - "pause-token": { - refuser: "share token", - verb: "pause the share token", - signing: "Signing stops holder transfers only.", - done: "Share token paused", - doneBody: - "Holder transfers are stopped. Minting, burning, the vault's escrow moves and legal transfers continue.", - }, - "unpause-token": { - refuser: "share token", - verb: "unpause the share token", - signing: "Signing resumes holder transfers.", - done: "Share token unpaused", - doneBody: "Holder transfers are open again.", - }, -} diff --git a/app/src/components/vault/transactionModal/governance.ts b/app/src/components/vault/transactionModal/governance.ts deleted file mode 100644 index b16822be..00000000 --- a/app/src/components/vault/transactionModal/governance.ts +++ /dev/null @@ -1,196 +0,0 @@ -import { - type AdminCopy, - type TimelockRefuser, - WAITS, - proposeCopy, -} from "./adminCopy" -import { type GovernanceModalAction } from "./types" - -export const GOVERNANCE_FOLLOW_UP = - "check the Governance tab before trying again" -const grantCopy = - (refuser: AdminCopy["refuser"]) => - (value: string): AdminCopy => ({ - refuser, - verb: "grant a role", - signing: `Signing grants ${value}.`, - done: "Role granted", - doneBody: `Granted ${value}.`, - }) - -const revokeCopy = - (refuser: AdminCopy["refuser"]) => - (value: string): AdminCopy => ({ - refuser, - verb: "revoke a role", - signing: `Signing revokes ${value}.`, - done: "Role revoked", - doneBody: `Revoked ${value}.`, - }) - -const handOverCopy = - (refuser: AdminCopy["refuser"]) => - (value: string): AdminCopy => ({ - refuser, - verb: "hand over the admin", - signing: "Signing proposes the handover.", - done: "Handover proposed", - doneBody: value, - }) - -const cancelHandoverCopy = - (refuser: AdminCopy["refuser"]) => - (value: string): AdminCopy => ({ - refuser, - verb: "cancel the handover", - signing: `Signing withdraws the offer to ${value}.`, - done: "Handover cancelled", - doneBody: `${value} can no longer accept.`, - }) - -const ACCEPT_WIND_DOWN_CODES: Record = { - vault: 6046, - oracle: 3018, - "share token": 7008, -} - -const acceptAdminCopy = (refuser: TimelockRefuser) => (): AdminCopy => ({ - refuser, - reasons: { - [ACCEPT_WIND_DOWN_CODES[refuser]]: - "The vault is winding down, so the admin role can no longer change hands.", - }, - verb: "accept the admin role", - signing: `Signing makes this wallet the ${refuser} admin.`, - done: "Admin accepted", - doneBody: `This wallet is now the ${refuser} admin.`, -}) - -const proposalCopy = - (refuser: TimelockRefuser) => - (value: string): AdminCopy => ({ - refuser, - verb: "propose an upgrade", - signing: `Signing queues ${value}.`, - done: "Upgrade proposed", - doneBody: `${value} can apply once ${WAITS[refuser]} passes.`, - }) - -const VAULT_APPLY_REASONS = { - 1000: "The vault is paused; unpause it first.", - 6046: "The vault is winding down, so no change can apply.", -} - -const applyChangeCopy = - (refuser: TimelockRefuser) => - (value: string): AdminCopy => ({ - refuser, - ...(refuser === "vault" ? { reasons: VAULT_APPLY_REASONS } : {}), - verb: "apply the change", - signing: `Signing applies ${value}.`, - done: "Change applied", - doneBody: `Applied ${value}.`, - }) - -const cancelChangeCopy = - (refuser: TimelockRefuser) => - (value: string): AdminCopy => ({ - refuser, - verb: "cancel the change", - signing: `Signing withdraws ${value}.`, - done: "Change cancelled", - doneBody: `Withdrew ${value}.`, - }) - -const proposeWindDownCopy = (value: string): AdminCopy => ({ - refuser: "vault", - verb: "propose a wind-down", - signing: "Signing starts the wind-down countdown.", - done: "Wind-down proposed", - doneBody: value, -}) - -const cancelWindDownCopy = (): AdminCopy => ({ - refuser: "vault", - verb: "cancel the wind-down", - signing: "Signing withdraws the wind-down.", - done: "Wind-down cancelled", - doneBody: "The vault stays open.", -}) - -export const GOVERNANCE_COPY: Record< - GovernanceModalAction, - (value: string) => AdminCopy -> = { - "grant-vault-role": grantCopy("vault"), - "revoke-vault-role": revokeCopy("vault"), - "grant-oracle-role": grantCopy("oracle"), - "revoke-oracle-role": revokeCopy("oracle"), - "grant-token-role": grantCopy("share token"), - "revoke-token-role": revokeCopy("share token"), - "hand-over-vault-admin": handOverCopy("vault"), - "cancel-vault-handover": cancelHandoverCopy("vault"), - "accept-vault-admin": acceptAdminCopy("vault"), - "hand-over-oracle-admin": handOverCopy("oracle"), - "cancel-oracle-handover": cancelHandoverCopy("oracle"), - "accept-oracle-admin": acceptAdminCopy("oracle"), - "hand-over-token-admin": handOverCopy("share token"), - "cancel-token-handover": cancelHandoverCopy("share token"), - "accept-token-admin": acceptAdminCopy("share token"), - "propose-grant-role": proposeCopy( - "vault", - "propose a role grant", - "the grant of", - ), - "propose-revoke-role": proposeCopy( - "vault", - "propose a role revoke", - "the revoke of", - ), - "propose-admin": proposeCopy( - "vault", - "propose the hand-over", - "the hand-over to", - ), - "propose-token-grant-role": proposeCopy( - "share token", - "propose a role grant", - "the grant of", - ), - "propose-token-revoke-role": proposeCopy( - "share token", - "propose a role revoke", - "the revoke of", - ), - "propose-token-admin": proposeCopy( - "share token", - "propose the hand-over", - "the hand-over to", - ), - "propose-oracle-grant-role": proposeCopy( - "oracle", - "propose a role grant", - "the grant of", - ), - "propose-oracle-revoke-role": proposeCopy( - "oracle", - "propose a role revoke", - "the revoke of", - ), - "propose-oracle-admin": proposeCopy( - "oracle", - "propose the hand-over", - "the hand-over to", - ), - "propose-code-upgrade": proposalCopy("vault"), - "propose-upgrade-delay": proposalCopy("vault"), - "propose-oracle-upgrade": proposalCopy("oracle"), - "apply-change": applyChangeCopy("vault"), - "cancel-change": cancelChangeCopy("vault"), - "apply-token-change": applyChangeCopy("share token"), - "cancel-token-change": cancelChangeCopy("share token"), - "apply-oracle-change": applyChangeCopy("oracle"), - "cancel-oracle-change": cancelChangeCopy("oracle"), - "propose-wind-down": proposeWindDownCopy, - "cancel-wind-down": cancelWindDownCopy, -} diff --git a/app/src/components/vault/transactionModal/investor.ts b/app/src/components/vault/transactionModal/investor.ts deleted file mode 100644 index c8ff18b6..00000000 --- a/app/src/components/vault/transactionModal/investor.ts +++ /dev/null @@ -1,495 +0,0 @@ -import { AMOUNT_DECIMALS, formatExact } from "@stellar-scaffold/app-lib" -import { type CancelDepositStatus } from "../../../hooks/useCancelDeposit" -import { type CancelRedeemStatus } from "../../../hooks/useCancelRedeem" -import { type ClaimDepositStatus } from "../../../hooks/useClaimDeposit" -import { type ClaimRedeemStatus } from "../../../hooks/useClaimRedeem" -import { type ClaimWindDownStatus } from "../../../hooks/useClaimWindDown" -import { type RequestDepositStatus } from "../../../hooks/useRequestDeposit" -import { type RequestRedeemStatus } from "../../../hooks/useRequestRedeem" -import { GENERIC_REFUSAL, describeFailure } from "./outcome" - -export const NOT_ALLOWLISTED = "Your address is not allowlisted." - -const SHARES_NOT_RETURNABLE = - "Your address cannot receive these shares right now, so they stay with the vault." - -const FROZEN = "Your address is frozen." - -export const HELD_WHILE_FROZEN = `${FROZEN} This request is held until the freeze is lifted.` - -const subscribeContractErrorReason = (code: number): string => { - switch (code) { - case 6007: - return "Enter an amount greater than zero." - case 6009: - return "You already have a subscription request open." - case 6014: - return "That amount is too large for the vault to accept." - case 6046: - return "The vault is winding down and is not accepting new subscriptions." - case 6064: - return "The vault's setup is still open. Subscriptions start once it closes." - case 6044: - return "The vault's price feed is not valid right now, so subscriptions are paused until it is." - case 6076: - return "The vault does not hold enough to cover its pending deposits right now, so it is not taking new subscriptions until it is funded again." - case 6068: - return `${NOT_ALLOWLISTED} Only allowlisted addresses can subscribe.` - case 6071: - return `${FROZEN} It cannot subscribe until the freeze is lifted.` - default: - return GENERIC_REFUSAL - } -} - -const cancelContractErrorReason = (code: number): string => { - switch (code) { - case 6001: - return "This request no longer exists to cancel." - case 6029: - return "This request could not be found." - case 6039: - return "This request has already been priced. Claim your shares instead of cancelling." - case 6041: - return "A price is available, so this request can no longer be cancelled. It will be priced shortly, and your shares are claimable once it is." - case 6076: - return "The vault does not hold enough to return this deposit right now. You can cancel once it is funded again." - case 6071: - return HELD_WHILE_FROZEN - default: - return GENERIC_REFUSAL - } -} - -const cancelRedeemContractErrorReason = ( - code: number, - assetTicker: string, -): string => { - const claimInstead = `Once priced, claim the ${assetTicker} it owes you instead.` - switch (code) { - case 6068: - return `${NOT_ALLOWLISTED} These shares cannot come back to you. ${claimInstead}` - case 6073: - return `${SHARES_NOT_RETURNABLE} ${claimInstead}` - default: - return cancelContractErrorReason(code) - } -} - -const claimContractErrorReason = (code: number): string => { - switch (code) { - case 6001: - return "You have no request to claim." - case 6014: - return "The share conversion for this claim is too large to complete." - case 6029: - return "This request could not be found." - case 6031: - return "The vault has not published a valid price for this request yet." - case 6035: - return "This request has already been claimed." - case 6037: - return "The vault does not yet hold enough in reserve to cover this claim. Try again shortly." - case 6071: - return HELD_WHILE_FROZEN - default: - return GENERIC_REFUSAL - } -} - -const redeemContractErrorReason = (code: number): string => { - switch (code) { - case 6007: - return "Enter an amount greater than zero." - case 6009: - return "You already have a redemption request open." - case 6014: - return "That amount is too large for the vault to accept." - case 6029: - return "This request could not be found." - case 6046: - return "The vault is winding down and is not accepting new redemptions." - case 6064: - return "The vault's setup is still open. Redemptions start once it closes." - case 6071: - return "Your address or some of these shares are frozen. Redeem only your free shares, or wait until the freeze is lifted." - default: - return GENERIC_REFUSAL - } -} - -const claimRedeemContractErrorReason = ( - code: number, - ticker: string, - uncovered?: string, -): string => { - switch (code) { - case 6034: - return "This request has not priced yet. Check back after the vault's next update." - case 6037: - return uncovered !== undefined - ? `The vault is ${uncovered} short of covering every priced claim. Try again once the reserve catches up.` - : `The vault does not yet hold enough ${ticker} in reserve to cover this claim. Try again shortly.` - case 6073: - return `The price left nothing to pay out, so the vault tried to return your shares. ${SHARES_NOT_RETURNABLE}` - default: - return claimContractErrorReason(code) - } -} - -const windDownClaimContractErrorReason = (code: number): string => { - switch (code) { - case 6051: - return "The wind-down has not made its first payout yet. Check back once it does." - case 6054: - return "You have nothing to claim from the wind-down right now." - case 6071: - return HELD_WHILE_FROZEN - default: - return GENERIC_REFUSAL - } -} - -export const describeSubscribeStatus = ( - status: RequestDepositStatus, - amount: string, - ticker: string, -): { heading: string; body: string; hash?: string } | undefined => { - switch (status.status) { - case "idle": - return undefined - case "preparing": - return { - heading: "Preparing your request", - body: "We are getting your request ready. Your wallet will ask you to approve it next.", - } - case "awaiting-signature": - return { - heading: "Confirm in your wallet", - body: `Your shares are set once the vault prices your request, not today. Signing locks ${amount} ${ticker} in escrow until then.`, - } - case "submitted": - return { - heading: "Sending your request", - body: "Your request is on its way to the network. This should only take a moment, while pricing comes later and takes longer. Closing this window will not cancel it.", - hash: status.hash, - } - case "confirmed": - return { - heading: "Request submitted", - body: `${amount} ${ticker} prices at the vault's next update.`, - hash: status.hash, - } - case "failed": - return { - ...describeFailure(status.failure, subscribeContractErrorReason), - hash: status.hash, - } - } -} - -export const describeCancelStatus = ( - status: CancelDepositStatus, - amount: string, - ticker: string, -): { heading: string; body: string; hash?: string } | undefined => { - switch (status.status) { - case "idle": - return undefined - case "preparing": - return { - heading: "Preparing your cancellation", - body: "We are getting your cancellation ready. Your wallet will ask you to approve it next.", - } - case "awaiting-signature": - return { - heading: "Confirm in your wallet", - body: `Your request is withdrawn, not priced. Signing returns ${amount} ${ticker} from escrow to your wallet.`, - } - case "submitted": - return { - heading: "Sending your cancellation", - body: "Your cancellation is on its way to the network. This should only take a moment. Closing this window will not stop it.", - hash: status.hash, - } - case "confirmed": - return { - heading: "Request cancelled", - body: `${formatExact(status.refundedAmount, AMOUNT_DECIMALS)} ${ticker} has been returned to your wallet.`, - hash: status.hash, - } - case "failed": - return { - ...describeFailure(status.failure, cancelContractErrorReason), - hash: status.hash, - } - } -} - -export const describeCancelRedeemStatus = ( - status: CancelRedeemStatus, - amount: string, - ticker: string, - assetTicker: string, - windingDown: boolean, -): { heading: string; body: string; hash?: string } | undefined => { - switch (status.status) { - case "idle": - return undefined - case "preparing": - return { - heading: "Preparing your cancellation", - body: "We are getting your cancellation ready. Your wallet will ask you to approve it next.", - } - case "awaiting-signature": - return { - heading: "Confirm in your wallet", - body: windingDown - ? `Your request is withdrawn, not priced. The vault is winding down, so signing moves ${amount} ${ticker} into your wind-down claim instead of your wallet.` - : `Your request is withdrawn, not priced. Signing returns ${amount} ${ticker} from escrow to your wallet.`, - } - case "submitted": - return { - heading: "Sending your cancellation", - body: "Your cancellation is on its way to the network. This should only take a moment. Closing this window will not stop it.", - hash: status.hash, - } - case "confirmed": - return status.surrendered - ? { - heading: MOVED_TO_WIND_DOWN, - body: "The vault is winding down, so these shares were added to your wind-down claim instead of coming back to your wallet.", - hash: status.hash, - } - : { - heading: "Request cancelled", - body: `${formatExact(status.returnedShares, AMOUNT_DECIMALS)} ${ticker} has been returned to your wallet.`, - hash: status.hash, - } - case "failed": - return { - ...describeFailure(status.failure, (code) => - cancelRedeemContractErrorReason(code, assetTicker), - ), - hash: status.hash, - } - } -} - -const MOVED_TO_WIND_DOWN = "Moved to your wind-down claim" - -const convertedOutcome = ( - queuedInto: bigint, - ticker: string, -): { heading: string; body: string } => - queuedInto === 0n - ? { - heading: MOVED_TO_WIND_DOWN, - body: `${NOT_ALLOWLISTED} Your shares were added to your wind-down claim instead.`, - } - : { - heading: "Queued as a redemption", - body: `${NOT_ALLOWLISTED} Your shares were queued as a redemption instead, and pay out in ${ticker} at the next price.`, - } - -export const describeClaimStatus = ( - status: ClaimDepositStatus, - amount: string, - ticker: string, - shareTicker: string, -): { heading: string; body: string; hash?: string } | undefined => { - switch (status.status) { - case "idle": - return undefined - case "preparing": - return { - heading: "Preparing your claim", - body: "We are getting your claim ready. Your wallet will ask you to approve it next.", - } - case "awaiting-signature": - return { - heading: "Confirm in your wallet", - body: `You receive what you're owed. If the price leaves no shares to claim, ${amount} ${ticker} returns to your wallet instead.`, - } - case "submitted": - return { - heading: "Sending your claim", - body: "Your claim is on its way to the network. This should only take a moment. Closing this window will not stop it.", - hash: status.hash, - } - case "confirmed": - if (status.queuedInto !== undefined) - return { - ...convertedOutcome(status.queuedInto, ticker), - hash: status.hash, - } - return status.sharesMinted > 0n - ? { - heading: "Shares claimed", - body: `${formatExact(status.sharesMinted, AMOUNT_DECIMALS)} ${shareTicker} has been added to your wallet.`, - hash: status.hash, - } - : { - heading: "Deposit returned", - body: `The price left no shares to claim, so ${amount} ${ticker} has been returned to your wallet instead.`, - hash: status.hash, - } - case "failed": - return { - ...describeFailure(status.failure, claimContractErrorReason), - hash: status.hash, - } - } -} - -export const describeClaimWindDownStatus = ( - status: ClaimWindDownStatus, - amount: string, - ticker: string, - shareTicker: string, -): { heading: string; body: string; hash?: string } | undefined => { - switch (status.status) { - case "idle": - return undefined - case "preparing": - return { - heading: "Preparing your claim", - body: "We are getting your claim ready. Your wallet will ask you to approve it next.", - } - case "awaiting-signature": - return { - heading: "Confirm in your wallet", - body: `You receive ${amount} ${ticker}, your share of the wind-down so far. Signing also turns in your unfrozen ${shareTicker}, so later rounds pay you for them.`, - } - case "submitted": - return { - heading: "Sending your claim", - body: "Your claim is on its way to the network. This should only take a moment. Closing this window will not stop it.", - hash: status.hash, - } - case "confirmed": - return { - ...windDownClaimOutcome(status, ticker, shareTicker), - hash: status.hash, - } - case "failed": - return { - ...describeFailure(status.failure, windDownClaimContractErrorReason), - hash: status.hash, - } - } -} - -const windDownClaimOutcome = ( - { - assetsClaimed, - sharesSurrendered, - }: Extract, - ticker: string, - shareTicker: string, -): { heading: string; body: string } => { - const turnedIn = - sharesSurrendered > 0n - ? `${formatExact(sharesSurrendered, AMOUNT_DECIMALS)} ${shareTicker} were turned in, and later rounds pay you for them.` - : "Later rounds pay what you are owed." - return assetsClaimed > 0n - ? { - heading: `${ticker} claimed`, - body: `${formatExact(assetsClaimed, AMOUNT_DECIMALS)} ${ticker} has been added to your wallet. ${turnedIn}`, - } - : { - heading: "Shares turned in", - body: `Nothing was paid this time. ${turnedIn}`, - } -} - -export const describeRedeemStatus = ( - status: RequestRedeemStatus, - amount: string, - ticker: string, -): { heading: string; body: string; hash?: string } | undefined => { - switch (status.status) { - case "idle": - return undefined - case "preparing": - return { - heading: "Preparing your redemption request", - body: "We are getting your redemption request ready. Your wallet will ask you to approve it next.", - } - case "awaiting-signature": - return { - heading: "Confirm in your wallet", - body: `What you're owed is set once the vault prices your request, not today. Signing locks ${amount} ${ticker} in escrow until then.`, - } - case "submitted": - return { - heading: "Sending your redemption request", - body: "Your redemption request is on its way to the network. This should only take a moment, while pricing comes later and takes longer. Closing this window will not cancel it.", - hash: status.hash, - } - case "confirmed": - return { - heading: "Redemption request submitted", - body: `${amount} ${ticker} prices at the vault's next update.`, - hash: status.hash, - } - case "failed": - return { - ...describeFailure(status.failure, redeemContractErrorReason), - hash: status.hash, - } - } -} - -export const describeClaimRedeemStatus = ( - status: ClaimRedeemStatus, - amount: string, - ticker: string, - uncovered?: string, -): { heading: string; body: string; hash?: string } | undefined => { - switch (status.status) { - case "idle": - return undefined - case "preparing": - return { - heading: "Preparing your claim", - body: "We are getting your claim ready. Your wallet will ask you to approve it next.", - } - case "awaiting-signature": - return { - heading: "Confirm in your wallet", - body: `You receive what you're owed. Signing sends ${amount} ${ticker} to your wallet.`, - } - case "submitted": - return { - heading: "Sending your claim", - body: "Your claim is on its way to the network. This should only take a moment. Closing this window will not stop it.", - hash: status.hash, - } - case "confirmed": - return status.assetsClaimed > 0n - ? { - heading: `${ticker} claimed`, - body: `${formatExact(status.assetsClaimed, AMOUNT_DECIMALS)} ${ticker} has been added to your wallet.`, - hash: status.hash, - } - : status.sharesReturned - ? { - heading: "Shares returned", - body: "The price left nothing to claim, so your shares have been returned to your wallet instead.", - hash: status.hash, - } - : { - heading: "Nothing to pay out", - body: "The price left nothing to pay out, and the vault is winding down, so these shares were burned.", - hash: status.hash, - } - case "failed": - return { - ...describeFailure(status.failure, (code) => - claimRedeemContractErrorReason(code, ticker, uncovered), - ), - hash: status.hash, - } - } -} diff --git a/app/src/components/vault/transactionModal/outcome.ts b/app/src/components/vault/transactionModal/outcome.ts deleted file mode 100644 index e61dc77f..00000000 --- a/app/src/components/vault/transactionModal/outcome.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { formatDate, type SubmitFailure } from "@stellar-scaffold/app-lib" -import { type TransactionFailure } from "../../../hooks/useContractTransaction" - -export const GENERIC_REFUSAL = - "The vault refused this request. Try again, and contact support if it keeps happening." - -const INVESTOR_FOLLOW_UP = "check your requests before doing anything else" -const NETWORK_REASONS: Record, string> = { - expired: "The transaction expired before it was submitted.", - "sequence-consumed": - "The account moved on; this transaction is no longer valid.", - "bad-auth": "The signatures do not match the account's threshold.", - fee: "The account cannot pay the fee.", - "fee-too-low": "The fee is below the network minimum. Propose it again.", - "not-accepted": "The network did not accept it. Try again.", -} - -const unknownOutcome = (followUp: string) => ({ - heading: "Something went wrong", - body: `We could not confirm whether this reached the network. We don't yet know if it went through, so ${followUp}.`, -}) - -export const describeFailure = ( - failure: TransactionFailure, - contractErrorReason: (code: number) => string, - followUp = INVESTOR_FOLLOW_UP, - refuser = "vault", -): { heading: string; body: string } => { - switch (failure.kind) { - case "network": - if (failure.reason === "unknown") return unknownOutcome(followUp) - return { - heading: "The network rejected it", - body: NETWORK_REASONS[failure.reason], - } - case "declined": - return { - heading: "You declined the request", - body: "You chose not to sign, so nothing was sent. Try again when you're ready.", - } - case "unsigned": - return { - heading: "The wallet did not sign", - body: "Nothing was sent. Try again when you're ready.", - } - case "cannot-authorize": - return { - heading: "This wallet cannot authorize", - body: "This wallet cannot sign authorizations. Use Freighter, Hana or WalletConnect.", - } - case "contract-error": - return { - heading: `The ${refuser} refused this request`, - body: contractErrorReason(failure.code), - } - case "interrupted": - return { - heading: "We couldn't reach the vault", - body: "Nothing was requested from your wallet. Try again when you're ready.", - } - case "stale": - return { - heading: "The transaction went stale", - body: "The account moved on since it was prepared, so nothing was sent. Try again.", - } - case "unknown": - return unknownOutcome(followUp) - } -} - -export const describeShared = (expiresAt: Date | null) => { - const invitation = "Share the link with the next signer." - if (expiresAt === null) return invitation - const seconds = BigInt(Math.floor(expiresAt.getTime() / 1000)) - return `${invitation} Valid until ${formatDate(seconds)}.` -} diff --git a/app/src/components/vault/transactionModal/steps.ts b/app/src/components/vault/transactionModal/steps.ts deleted file mode 100644 index 8c7acb53..00000000 --- a/app/src/components/vault/transactionModal/steps.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { type ActivateWindDownStatus } from "../../../hooks/useActivateWindDown" -import { type AttestStatus } from "../../../hooks/useAttest" -import { type CancelDepositStatus } from "../../../hooks/useCancelDeposit" -import { type CancelRedeemStatus } from "../../../hooks/useCancelRedeem" -import { type ClaimDepositStatus } from "../../../hooks/useClaimDeposit" -import { type ClaimRedeemStatus } from "../../../hooks/useClaimRedeem" -import { type CloseEpochStatus } from "../../../hooks/useCloseEpoch" -import { type TransactionStatus } from "../../../hooks/useContractTransaction" -import { type DeployToCustodianStatus } from "../../../hooks/useDeployToCustodian" -import { type FinalizeWindDownRoundStatus } from "../../../hooks/useFinalizeWindDownRound" -import { type FulfillEpochStatus } from "../../../hooks/useFulfillEpoch" -import { type FundStatus } from "../../../hooks/useFund" -import { type RequestDepositStatus } from "../../../hooks/useRequestDeposit" -import { type RequestRedeemStatus } from "../../../hooks/useRequestRedeem" -import styles from "../TransactionModal.module.css" - -type StepId = "signature" | "network" | "recorded" -type StepState = "done" | "current" | "upcoming" | "failed" - -export const STEP_ORDER: StepId[] = ["signature", "network", "recorded"] - -export const stepLabel: Record = { - signature: "Approved in your wallet", - network: "Sent to the network", - recorded: "Recorded", -} - -export const computeSteps = ( - status: - | RequestDepositStatus - | CancelDepositStatus - | CancelRedeemStatus - | ClaimDepositStatus - | RequestRedeemStatus - | ClaimRedeemStatus - | CloseEpochStatus - | FulfillEpochStatus - | AttestStatus - | DeployToCustodianStatus - | FundStatus - | ActivateWindDownStatus - | FinalizeWindDownRoundStatus - | TransactionStatus, -): Record | undefined => { - switch (status.status) { - case "idle": - return undefined - case "preparing": - return { - signature: "upcoming", - network: "upcoming", - recorded: "upcoming", - } - case "awaiting-signature": - return { signature: "current", network: "upcoming", recorded: "upcoming" } - case "submitted": - return { signature: "done", network: "current", recorded: "upcoming" } - case "shared": - return { signature: "done", network: "upcoming", recorded: "upcoming" } - case "confirmed": - return { signature: "done", network: "done", recorded: "done" } - case "failed": - if (status.failure.kind === "unknown" && status.hash !== undefined) { - return { signature: "done", network: "done", recorded: "failed" } - } - if ( - status.failure.kind === "unknown" || - status.failure.kind === "network" || - status.failure.kind === "stale" - ) { - return { signature: "done", network: "failed", recorded: "upcoming" } - } - return { signature: "failed", network: "upcoming", recorded: "upcoming" } - } -} - -export const stepStateClassName: Record = { - done: styles.stepDone, - current: styles.stepCurrent, - failed: styles.stepFailed, - upcoming: styles.stepUpcoming, -} - -export const stepAnnouncement: Partial> = { - done: "done", - current: "in progress", - failed: "failed", -} diff --git a/app/src/components/vault/transactionModal/types.ts b/app/src/components/vault/transactionModal/types.ts deleted file mode 100644 index edc78882..00000000 --- a/app/src/components/vault/transactionModal/types.ts +++ /dev/null @@ -1,180 +0,0 @@ -import { type ActivateWindDownStatus } from "../../../hooks/useActivateWindDown" -import { type AttestStatus } from "../../../hooks/useAttest" -import { type CancelDepositStatus } from "../../../hooks/useCancelDeposit" -import { type CancelRedeemStatus } from "../../../hooks/useCancelRedeem" -import { type ClaimDepositStatus } from "../../../hooks/useClaimDeposit" -import { type ClaimRedeemStatus } from "../../../hooks/useClaimRedeem" -import { type ClaimWindDownStatus } from "../../../hooks/useClaimWindDown" -import { type CloseEpochStatus } from "../../../hooks/useCloseEpoch" -import { type TransactionStatus } from "../../../hooks/useContractTransaction" -import { type DeployToCustodianStatus } from "../../../hooks/useDeployToCustodian" -import { type FinalizeWindDownRoundStatus } from "../../../hooks/useFinalizeWindDownRound" -import { type FulfillEpochStatus } from "../../../hooks/useFulfillEpoch" -import { type FundStatus } from "../../../hooks/useFund" -import { type RequestDepositStatus } from "../../../hooks/useRequestDeposit" -import { type RequestRedeemStatus } from "../../../hooks/useRequestRedeem" -import { type ComplianceActionId } from "../../../pages/compliance" -import { type GovernanceActionId } from "../../../pages/governance" - -export type EmergencyModalAction = - | "pause-vault" - | "unpause-vault" - | "pause-nav" - | "unpause-nav" - | "clear-latest" - | "pause-token" - | "unpause-token" - -export type ConfigurationModalAction = - | "set-deposit-cap" - | "set-custodian" - | "set-notice" - | "set-wind-down-delay" - | "set-min-epoch-duration" - | "propose-custodian" - | "propose-notice" - | "propose-wind-down-delay" - | "propose-min-epoch-duration" - | "set-oracle-config" - | "propose-oracle-config" - | "set-compliance" - | "propose-compliance" - | "set-identity-verifier" - | "propose-identity-verifier" - -export type GovernanceModalAction = - GovernanceActionId | "propose-oracle-revoke-role" - -export type ComplianceModalAction = ComplianceActionId - -export type AdminModalAction = - | EmergencyModalAction - | ConfigurationModalAction - | GovernanceModalAction - | ComplianceModalAction - -export type TransactionModalProps = - | { - action: EmergencyModalAction - status: TransactionStatus - onClose: () => void - onRetry: () => void - } - | { - action: - ConfigurationModalAction | GovernanceModalAction | ComplianceModalAction - status: TransactionStatus - value: string - onClose: () => void - onRetry: () => void - } - | { - action: "subscribe" - status: RequestDepositStatus - amount: string - ticker: string - onClose: () => void - onRetry: () => void - } - | { - action: "cancel" - status: CancelDepositStatus - amount: string - ticker: string - onClose: () => void - onRetry: () => void - } - | { - action: "cancel-redeem" - status: CancelRedeemStatus - amount: string - ticker: string - assetTicker: string - windingDown?: boolean - onClose: () => void - onRetry: () => void - } - | { - action: "claim" - status: ClaimDepositStatus - amount: string - ticker: string - shareTicker: string - onClose: () => void - onRetry: () => void - } - | { - action: "redeem" - status: RequestRedeemStatus - amount: string - ticker: string - onClose: () => void - onRetry: () => void - } - | { - action: "claim-redeem" - status: ClaimRedeemStatus - amount: string - ticker: string - uncovered?: string - onClose: () => void - onRetry: () => void - } - | { - action: "claim-wind-down" - status: ClaimWindDownStatus - amount: string - ticker: string - shareTicker: string - onClose: () => void - onRetry: () => void - } - | { - action: "close-epoch" - status: CloseEpochStatus - epoch: string - onClose: () => void - onRetry: () => void - } - | { - action: "fulfill-epoch" - status: FulfillEpochStatus - epoch: string - onClose: () => void - onRetry: () => void - } - | { - action: "attest" - status: AttestStatus - price: string - validFor: string | null - onClose: () => void - onRetry: () => void - } - | { - action: "deploy" - status: DeployToCustodianStatus - amount: string - onClose: () => void - onRetry: () => void - } - | { - action: "fund" - status: FundStatus - amount: string - onClose: () => void - onRetry: () => void - } - | { - action: "activate-wind-down" - status: ActivateWindDownStatus - onClose: () => void - onRetry: () => void - } - | { - action: "finalize-round" - status: FinalizeWindDownRoundStatus - round: string - onClose: () => void - onRetry: () => void - } From 76aeaa4dfa0587dc8fe66430a4438d8701d76429 Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Mon, 2 Nov 2026 15:41:49 -0300 Subject: [PATCH 17/18] revert: test(app): fake the contract clients with one typed helper This reverts commit c9c75ec. The shared typed fake moves to its own issue. Refs #420 --- app/src/hooks/useActivateWindDown.test.ts | 13 +- app/src/hooks/useAdminAuthority.test.ts | 57 ++--- app/src/hooks/useAdminTransaction.test.ts | 119 ++++----- app/src/hooks/useAttest.test.ts | 13 +- app/src/hooks/useCancelDeposit.test.ts | 14 +- app/src/hooks/useCancelRedeem.test.ts | 14 +- app/src/hooks/useClaimDeposit.test.ts | 14 +- app/src/hooks/useClaimRedeem.test.ts | 14 +- app/src/hooks/useClaimWindDown.test.ts | 9 +- app/src/hooks/useCloseEpoch.test.ts | 13 +- app/src/hooks/useComplianceState.test.ts | 29 ++- app/src/hooks/useConfigurationState.test.ts | 34 +-- app/src/hooks/useCycleState.test.ts | 71 +++--- app/src/hooks/useDeployToCustodian.test.ts | 13 +- app/src/hooks/useDepositBalance.test.ts | 6 +- app/src/hooks/useEpochHistory.test.ts | 10 +- .../hooks/useFinalizeWindDownRound.test.ts | 13 +- app/src/hooks/useFulfillEpoch.test.ts | 13 +- app/src/hooks/useFund.test.ts | 13 +- app/src/hooks/useGovernanceState.test.ts | 28 +-- app/src/hooks/useHolderFrozen.test.ts | 12 +- app/src/hooks/useInvestorRequests.test.ts | 17 +- app/src/hooks/useIsAllowed.test.ts | 17 +- app/src/hooks/useNavPrice.test.ts | 7 +- app/src/hooks/useOracleState.test.ts | 9 +- app/src/hooks/useRequestDeposit.test.ts | 14 +- app/src/hooks/useRequestRedeem.test.ts | 14 +- app/src/hooks/useSharePosition.test.ts | 6 +- app/src/hooks/useSignPending.test.ts | 29 ++- app/src/hooks/useTokenPaused.test.ts | 9 +- app/src/hooks/useTokenSymbols.test.ts | 8 +- app/src/hooks/useVaultAuthorities.test.ts | 19 +- app/src/hooks/useVaultPaused.test.ts | 10 +- app/src/hooks/useWindDownActive.test.ts | 12 +- app/src/hooks/useWindDownClaimable.test.ts | 14 +- app/src/pages/SignPage.test.tsx | 12 +- app/src/pages/VaultPreview.test.tsx | 228 ++++++++---------- app/src/test/fakeClients.test.ts | 79 ------ app/src/test/fakeClients.ts | 90 ------- 39 files changed, 503 insertions(+), 613 deletions(-) delete mode 100644 app/src/test/fakeClients.test.ts delete mode 100644 app/src/test/fakeClients.ts diff --git a/app/src/hooks/useActivateWindDown.test.ts b/app/src/hooks/useActivateWindDown.test.ts index d13af049..06ab2ecc 100644 --- a/app/src/hooks/useActivateWindDown.test.ts +++ b/app/src/hooks/useActivateWindDown.test.ts @@ -1,17 +1,18 @@ import { AssembledTransaction } from "@stellar/stellar-sdk/contract" import { act, waitFor } from "@testing-library/react" import { beforeEach, describe, expect, it, vi } from "vitest" -import { - asyncVaultWriter as asyncVaultWriterMock, - fakeAsyncVault, -} from "../test/fakeClients" import { deferred, renderWithWallet } from "./testSupport" import { useActivateWindDown } from "./useActivateWindDown" import { cycleWriteKeys } from "./useCycleState" -vi.mock("../config/clients", () => import("../test/fakeClients")) +const { vaultMock, asyncVaultWriterMock } = vi.hoisted(() => ({ + vaultMock: { activate_wind_down: vi.fn() }, + asyncVaultWriterMock: vi.fn(), +})) -const vaultMock = fakeAsyncVault("activate_wind_down") +vi.mock("../config/clients", () => ({ + asyncVaultWriter: asyncVaultWriterMock, +})) const renderActivateWindDown = () => renderWithWallet(useActivateWindDown) diff --git a/app/src/hooks/useAdminAuthority.test.ts b/app/src/hooks/useAdminAuthority.test.ts index fc8fc7f8..3901e0df 100644 --- a/app/src/hooks/useAdminAuthority.test.ts +++ b/app/src/hooks/useAdminAuthority.test.ts @@ -3,13 +3,6 @@ import { waitFor } from "@testing-library/react" import { beforeEach, describe, expect, it, vi } from "vitest" import { addresses } from "../config/addresses" import { vaultContractId } from "../config/contracts" -import { - fakeAsyncVault, - fakeNavOracle, - fakeShareToken, - navOracle as navOracleMock, - shareToken as shareTokenMock, -} from "../test/fakeClients" import { pendingAdminsQuery } from "./pendingAdmins" import { deferred, renderWithWallet, investorAddress } from "./testSupport" import { @@ -306,39 +299,49 @@ describe("toSurfaces", () => { }) const { + vaultMock, + oracleMock, + tokenMock, connectNavOracleMock, connectShareTokenMock, + navOracleMock, + shareTokenMock, accountSignersMock, readPendingAdminsMock, readContractAdminMock, } = vi.hoisted(() => ({ + vaultMock: { + governance: vi.fn(), + get_role_member_count: vi.fn(), + get_role_member: vi.fn(), + oracle: vi.fn(), + share_token: vi.fn(), + }, + oracleMock: { + get_role_member_count: vi.fn(), + get_role_member: vi.fn(), + get_admin: vi.fn(), + }, + tokenMock: { + get_role_member_count: vi.fn(), + get_role_member: vi.fn(), + get_admin: vi.fn(), + identity_verifier: vi.fn(), + }, connectNavOracleMock: vi.fn(), connectShareTokenMock: vi.fn(), + navOracleMock: vi.fn(), + shareTokenMock: vi.fn(), accountSignersMock: vi.fn(), readPendingAdminsMock: vi.fn(), readContractAdminMock: vi.fn(), })) -vi.mock("../config/clients", () => import("../test/fakeClients")) - -const vaultMock = fakeAsyncVault( - "governance", - "get_role_member_count", - "get_role_member", - "oracle", - "share_token", -) -const oracleMock = fakeNavOracle( - "get_role_member_count", - "get_role_member", - "get_admin", -) -const tokenMock = fakeShareToken( - "get_role_member_count", - "get_role_member", - "get_admin", - "identity_verifier", -) +vi.mock("../config/clients", () => ({ + asyncVault: async () => vaultMock, + navOracle: navOracleMock, + shareToken: shareTokenMock, +})) vi.mock("@stellar-scaffold/app-lib", async (importOriginal) => { const actual = await importOriginal() diff --git a/app/src/hooks/useAdminTransaction.test.ts b/app/src/hooks/useAdminTransaction.test.ts index 0dc1059a..68771092 100644 --- a/app/src/hooks/useAdminTransaction.test.ts +++ b/app/src/hooks/useAdminTransaction.test.ts @@ -4,14 +4,6 @@ import type * as AppLib from "@stellar-scaffold/app-lib" import { OPERATOR_TIMEOUT_SECONDS } from "@stellar-scaffold/app-lib" import { act, waitFor } from "@testing-library/react" import { beforeEach, describe, expect, it, vi } from "vitest" -import { - asyncVaultWriter as asyncVaultWriterMock, - fakeAsyncVault, - fakeIdentityVerifier, - fakeNavOracle, - fakeShareToken, - identityVerifierWriter as verifierWriterMock, -} from "../test/fakeClients" import { governanceReadsKey } from "./pendingAdmins" import { deferred, investorAddress, renderWithWallet } from "./testSupport" import { adminAuthorityKey, type Grant } from "./useAdminAuthority" @@ -88,9 +80,65 @@ import { tokenPausedKey } from "./useTokenPaused" import { vaultAuthoritiesKey } from "./useVaultAuthorities" import { vaultPausedKey } from "./useVaultPaused" -const { submitSignedMock, decodePendingMock } = vi.hoisted(() => ({ +const { + vault, + token, + oracle, + verifier, + verifierWriterMock, + asyncVaultWriterMock, + submitSignedMock, + decodePendingMock, +} = vi.hoisted(() => ({ + asyncVaultWriterMock: vi.fn(), submitSignedMock: vi.fn(), decodePendingMock: vi.fn(), + vault: { + pause: vi.fn(), + unpause: vi.fn(), + set_deposit_cap: vi.fn(), + set_custodian: vi.fn(), + set_notice: vi.fn(), + set_wind_down_delay: vi.fn(), + grant_role: vi.fn(), + revoke_role: vi.fn(), + transfer_admin_role: vi.fn(), + accept_admin_transfer: vi.fn(), + propose_change: vi.fn(), + apply_change: vi.fn(), + cancel_change: vi.fn(), + propose_wind_down: vi.fn(), + cancel_wind_down_proposal: vi.fn(), + }, + token: { + pause: vi.fn(), + unpause: vi.fn(), + set_compliance: vi.fn(), + set_identity_verifier: vi.fn(), + grant_role: vi.fn(), + revoke_role: vi.fn(), + transfer_admin_role: vi.fn(), + accept_admin_transfer: vi.fn(), + set_address_frozen: vi.fn(), + freeze_partial_tokens: vi.fn(), + unfreeze_partial_tokens: vi.fn(), + legal_transfer: vi.fn(), + propose_change: vi.fn(), + apply_change: vi.fn(), + cancel_change: vi.fn(), + }, + verifier: { allow: vi.fn() }, + verifierWriterMock: vi.fn(), + oracle: { + pause_nav: vi.fn(), + set_nav_paused: vi.fn(), + clear_latest: vi.fn(), + set_config: vi.fn(), + grant_role: vi.fn(), + revoke_role: vi.fn(), + transfer_admin_role: vi.fn(), + accept_admin_transfer: vi.fn(), + }, })) vi.mock("@stellar-scaffold/app-lib", async (importOriginal) => ({ @@ -99,53 +147,12 @@ vi.mock("@stellar-scaffold/app-lib", async (importOriginal) => ({ decodePending: decodePendingMock, })) -vi.mock("../config/clients", () => import("../test/fakeClients")) - -const vault = fakeAsyncVault( - "pause", - "unpause", - "set_deposit_cap", - "set_custodian", - "set_notice", - "set_wind_down_delay", - "grant_role", - "revoke_role", - "transfer_admin_role", - "accept_admin_transfer", - "propose_change", - "apply_change", - "cancel_change", - "propose_wind_down", - "cancel_wind_down_proposal", -) -const token = fakeShareToken( - "pause", - "unpause", - "set_compliance", - "set_identity_verifier", - "grant_role", - "revoke_role", - "transfer_admin_role", - "accept_admin_transfer", - "set_address_frozen", - "freeze_partial_tokens", - "unfreeze_partial_tokens", - "legal_transfer", - "propose_change", - "apply_change", - "cancel_change", -) -const verifier = fakeIdentityVerifier("allow") -const oracle = fakeNavOracle( - "pause_nav", - "set_nav_paused", - "clear_latest", - "set_config", - "grant_role", - "revoke_role", - "transfer_admin_role", - "accept_admin_transfer", -) +vi.mock("../config/clients", () => ({ + asyncVaultWriter: asyncVaultWriterMock, + shareTokenWriter: async () => token, + navOracleWriter: async () => oracle, + identityVerifierWriter: verifierWriterMock, +})) const CONTRACT = "CCONTRACTADDRESS1234567890" const ORACLE_CONFIG = { diff --git a/app/src/hooks/useAttest.test.ts b/app/src/hooks/useAttest.test.ts index 9a099e76..65cd994c 100644 --- a/app/src/hooks/useAttest.test.ts +++ b/app/src/hooks/useAttest.test.ts @@ -3,18 +3,19 @@ import { AssembledTransaction } from "@stellar/stellar-sdk/contract" import { type Price } from "@stellar-scaffold/app-lib" import { act, waitFor } from "@testing-library/react" import { beforeEach, describe, expect, it, vi } from "vitest" -import { - navOracleWriter as navOracleWriterMock, - fakeNavOracle, -} from "../test/fakeClients" import { deferred, investorAddress, renderWithWallet } from "./testSupport" import { attestWriteKeys, useAttest } from "./useAttest" import { investorRequestsKey } from "./useInvestorRequests" import { navLatestKey } from "./useNavPrice" -vi.mock("../config/clients", () => import("../test/fakeClients")) +const { oracleMock, navOracleWriterMock } = vi.hoisted(() => ({ + oracleMock: { attest: vi.fn() }, + navOracleWriterMock: vi.fn(), +})) -const oracleMock = fakeNavOracle("attest") +vi.mock("../config/clients", () => ({ + navOracleWriter: navOracleWriterMock, +})) const attestation = { price: 1_040_000_000_000_000_000n as Price, diff --git a/app/src/hooks/useCancelDeposit.test.ts b/app/src/hooks/useCancelDeposit.test.ts index 21f89a3e..8d2884f5 100644 --- a/app/src/hooks/useCancelDeposit.test.ts +++ b/app/src/hooks/useCancelDeposit.test.ts @@ -1,18 +1,20 @@ import { AssembledTransaction } from "@stellar/stellar-sdk/contract" import { act, waitFor } from "@testing-library/react" import { beforeEach, describe, expect, it, vi } from "vitest" -import { - asyncVaultWriter as asyncVaultWriterMock, - fakeAsyncVault, -} from "../test/fakeClients" import { deferred, investorAddress, renderWithWallet } from "./testSupport" import { useCancelDeposit } from "./useCancelDeposit" import { depositBalanceKey } from "./useDepositBalance" import { investorRequestsKey } from "./useInvestorRequests" -vi.mock("../config/clients", () => import("../test/fakeClients")) +const { vaultMock, asyncVaultWriterMock } = vi.hoisted(() => ({ + vaultMock: { cancel_deposit: vi.fn() }, + asyncVaultWriterMock: vi.fn(), +})) +asyncVaultWriterMock.mockResolvedValue(vaultMock) -const vaultMock = fakeAsyncVault("cancel_deposit") +vi.mock("../config/clients", () => ({ + asyncVaultWriter: asyncVaultWriterMock, +})) const epochId = 3n const otherEpochId = 4n diff --git a/app/src/hooks/useCancelRedeem.test.ts b/app/src/hooks/useCancelRedeem.test.ts index 5cd791b5..7b41d9d8 100644 --- a/app/src/hooks/useCancelRedeem.test.ts +++ b/app/src/hooks/useCancelRedeem.test.ts @@ -2,10 +2,6 @@ import { nativeToScVal } from "@stellar/stellar-sdk" import { AssembledTransaction } from "@stellar/stellar-sdk/contract" import { act, waitFor } from "@testing-library/react" import { beforeEach, describe, expect, it, vi } from "vitest" -import { - asyncVaultWriter as asyncVaultWriterMock, - fakeAsyncVault, -} from "../test/fakeClients" import { contractEvent, deferred, @@ -17,9 +13,15 @@ import { useCancelRedeem } from "./useCancelRedeem" import { investorRequestsKey } from "./useInvestorRequests" import { sharePositionKey } from "./useSharePosition" -vi.mock("../config/clients", () => import("../test/fakeClients")) +const { vaultMock, asyncVaultWriterMock } = vi.hoisted(() => ({ + vaultMock: { cancel_redeem: vi.fn() }, + asyncVaultWriterMock: vi.fn(), +})) +asyncVaultWriterMock.mockResolvedValue(vaultMock) -const vaultMock = fakeAsyncVault("cancel_redeem") +vi.mock("../config/clients", () => ({ + asyncVaultWriter: asyncVaultWriterMock, +})) const epochId = 3n const otherEpochId = 4n diff --git a/app/src/hooks/useClaimDeposit.test.ts b/app/src/hooks/useClaimDeposit.test.ts index 8b3b5250..f519f16f 100644 --- a/app/src/hooks/useClaimDeposit.test.ts +++ b/app/src/hooks/useClaimDeposit.test.ts @@ -2,10 +2,6 @@ import { nativeToScVal } from "@stellar/stellar-sdk" import { AssembledTransaction } from "@stellar/stellar-sdk/contract" import { act, waitFor } from "@testing-library/react" import { beforeEach, describe, expect, it, vi } from "vitest" -import { - asyncVaultWriter as asyncVaultWriterMock, - fakeAsyncVault, -} from "../test/fakeClients" import { contractEvent, deferred, @@ -18,9 +14,15 @@ import { depositBalanceKey } from "./useDepositBalance" import { investorRequestsKey } from "./useInvestorRequests" import { sharePositionKey } from "./useSharePosition" -vi.mock("../config/clients", () => import("../test/fakeClients")) +const { vaultMock, asyncVaultWriterMock } = vi.hoisted(() => ({ + vaultMock: { claim_deposit: vi.fn() }, + asyncVaultWriterMock: vi.fn(), +})) +asyncVaultWriterMock.mockResolvedValue(vaultMock) -const vaultMock = fakeAsyncVault("claim_deposit") +vi.mock("../config/clients", () => ({ + asyncVaultWriter: asyncVaultWriterMock, +})) const epochId = 3n diff --git a/app/src/hooks/useClaimRedeem.test.ts b/app/src/hooks/useClaimRedeem.test.ts index 4a6f6e6e..7e6f1b63 100644 --- a/app/src/hooks/useClaimRedeem.test.ts +++ b/app/src/hooks/useClaimRedeem.test.ts @@ -2,10 +2,6 @@ import { xdr } from "@stellar/stellar-sdk" import { AssembledTransaction } from "@stellar/stellar-sdk/contract" import { act, waitFor } from "@testing-library/react" import { beforeEach, describe, expect, it, vi } from "vitest" -import { - asyncVaultWriter as asyncVaultWriterMock, - fakeAsyncVault, -} from "../test/fakeClients" import { contractEvent, deferred, @@ -18,9 +14,15 @@ import { depositBalanceKey } from "./useDepositBalance" import { investorRequestsKey } from "./useInvestorRequests" import { sharePositionKey } from "./useSharePosition" -vi.mock("../config/clients", () => import("../test/fakeClients")) +const { vaultMock, asyncVaultWriterMock } = vi.hoisted(() => ({ + vaultMock: { claim_redeem: vi.fn() }, + asyncVaultWriterMock: vi.fn(), +})) +asyncVaultWriterMock.mockResolvedValue(vaultMock) -const vaultMock = fakeAsyncVault("claim_redeem") +vi.mock("../config/clients", () => ({ + asyncVaultWriter: asyncVaultWriterMock, +})) const epochId = 3n diff --git a/app/src/hooks/useClaimWindDown.test.ts b/app/src/hooks/useClaimWindDown.test.ts index dff9e533..4f94ed60 100644 --- a/app/src/hooks/useClaimWindDown.test.ts +++ b/app/src/hooks/useClaimWindDown.test.ts @@ -1,7 +1,6 @@ import { nativeToScVal } from "@stellar/stellar-sdk" import { act } from "@testing-library/react" import { beforeEach, describe, expect, it, vi } from "vitest" -import { fakeAsyncVault } from "../test/fakeClients" import { contractEvent, investorAddress, @@ -11,9 +10,13 @@ import { import { useClaimWindDown } from "./useClaimWindDown" import { windDownClaimableKey } from "./useWindDownClaimable" -vi.mock("../config/clients", () => import("../test/fakeClients")) +const { vaultMock } = vi.hoisted(() => ({ + vaultMock: { claim_wind_down: vi.fn() }, +})) -const vaultMock = fakeAsyncVault("claim_wind_down") +vi.mock("../config/clients", () => ({ + asyncVaultWriter: async () => vaultMock, +})) const claimed = (surrendered: bigint, assets: bigint) => contractEvent( diff --git a/app/src/hooks/useCloseEpoch.test.ts b/app/src/hooks/useCloseEpoch.test.ts index 253a067b..4691e904 100644 --- a/app/src/hooks/useCloseEpoch.test.ts +++ b/app/src/hooks/useCloseEpoch.test.ts @@ -1,18 +1,19 @@ import { AssembledTransaction } from "@stellar/stellar-sdk/contract" import { act, waitFor } from "@testing-library/react" import { beforeEach, describe, expect, it, vi } from "vitest" -import { - asyncVaultWriter as asyncVaultWriterMock, - fakeAsyncVault, -} from "../test/fakeClients" import { deferred, investorAddress, renderWithWallet } from "./testSupport" import { useCloseEpoch } from "./useCloseEpoch" import { cycleWriteKeys } from "./useCycleState" import { investorRequestsKey } from "./useInvestorRequests" -vi.mock("../config/clients", () => import("../test/fakeClients")) +const { vaultMock, asyncVaultWriterMock } = vi.hoisted(() => ({ + vaultMock: { close_epoch: vi.fn() }, + asyncVaultWriterMock: vi.fn(), +})) -const vaultMock = fakeAsyncVault("close_epoch") +vi.mock("../config/clients", () => ({ + asyncVaultWriter: asyncVaultWriterMock, +})) const renderCloseEpoch = () => renderWithWallet(useCloseEpoch) diff --git a/app/src/hooks/useComplianceState.test.ts b/app/src/hooks/useComplianceState.test.ts index c5891187..a341b774 100644 --- a/app/src/hooks/useComplianceState.test.ts +++ b/app/src/hooks/useComplianceState.test.ts @@ -1,17 +1,22 @@ import { waitFor } from "@testing-library/react" import { beforeEach, describe, expect, it, vi } from "vitest" -import { - fakeIdentityVerifier, - fakeShareToken, - identityVerifierAt as identityVerifierAtMock, -} from "../test/fakeClients" import { renderWithWallet } from "./testSupport" import { useComplianceState } from "./useComplianceState" -vi.mock("../config/clients", () => import("../test/fakeClients")) +const { tokenMock, verifierMock, identityVerifierAtMock } = vi.hoisted(() => ({ + tokenMock: { + is_frozen: vi.fn(), + balance: vi.fn(), + get_frozen_tokens: vi.fn(), + }, + verifierMock: { is_allowed: vi.fn() }, + identityVerifierAtMock: vi.fn(), +})) -const tokenMock = fakeShareToken("is_frozen", "balance", "get_frozen_tokens") -const verifierMock = fakeIdentityVerifier("is_allowed") +vi.mock("../config/clients", () => ({ + shareToken: async () => tokenMock, + identityVerifierAt: identityVerifierAtMock, +})) const VERIFIER = "CVERIFIERADDRESS" const TARGET = "GTARGETADDRESS1234" @@ -21,9 +26,11 @@ describe("useComplianceState", () => { beforeEach(() => { vi.clearAllMocks() identityVerifierAtMock.mockImplementation(async () => verifierMock) - verifierMock.is_allowed.mockImplementation(async ({ account }) => ({ - result: account === TARGET, - })) + verifierMock.is_allowed.mockImplementation( + async ({ account }: { account: string }) => ({ + result: account === TARGET, + }), + ) tokenMock.is_frozen.mockResolvedValue({ result: true }) tokenMock.balance.mockResolvedValue({ result: 100n }) tokenMock.get_frozen_tokens.mockResolvedValue({ result: 40n }) diff --git a/app/src/hooks/useConfigurationState.test.ts b/app/src/hooks/useConfigurationState.test.ts index 4a86d283..345305f2 100644 --- a/app/src/hooks/useConfigurationState.test.ts +++ b/app/src/hooks/useConfigurationState.test.ts @@ -1,27 +1,31 @@ import { waitFor } from "@testing-library/react" import { beforeEach, describe, expect, it, vi } from "vitest" -import { - fakeAsyncVault, - fakeNavOracle, - fakeShareToken, -} from "../test/fakeClients" import { renderWithWallet } from "./testSupport" import { useConfigurationState } from "./useConfigurationState" -const { useCycleReadsMock, useVaultAuthoritiesMock } = vi.hoisted(() => ({ +const { + vaultMock, + tokenMock, + oracleMock, + useCycleReadsMock, + useVaultAuthoritiesMock, +} = vi.hoisted(() => ({ + vaultMock: { upgrade_delay: vi.fn(), setup_open: vi.fn() }, + tokenMock: { + compliance: vi.fn(), + identity_verifier: vi.fn(), + setup_open: vi.fn(), + }, + oracleMock: { setup_open: vi.fn() }, useCycleReadsMock: vi.fn(), useVaultAuthoritiesMock: vi.fn(), })) -vi.mock("../config/clients", () => import("../test/fakeClients")) - -const vaultMock = fakeAsyncVault("upgrade_delay", "setup_open") -const tokenMock = fakeShareToken( - "compliance", - "identity_verifier", - "setup_open", -) -const oracleMock = fakeNavOracle("setup_open") +vi.mock("../config/clients", () => ({ + asyncVault: async () => vaultMock, + shareToken: async () => tokenMock, + navOracle: async () => oracleMock, +})) vi.mock("./useCycleState", async (importOriginal) => ({ ...(await importOriginal()), useCycleReads: useCycleReadsMock, diff --git a/app/src/hooks/useCycleState.test.ts b/app/src/hooks/useCycleState.test.ts index 1d463ec8..ed89cc20 100644 --- a/app/src/hooks/useCycleState.test.ts +++ b/app/src/hooks/useCycleState.test.ts @@ -11,7 +11,6 @@ import { import { waitFor } from "@testing-library/react" import { beforeEach, describe, expect, it, vi } from "vitest" import { addresses } from "../config/addresses" -import { fakeAsset, fakeAsyncVault, fakeNavOracle } from "../test/fakeClients" import { renderWithWallet } from "./testSupport" import { toEpoch, @@ -23,39 +22,43 @@ import { } from "./useCycleState" import { type EpochHistoryRead, type EpochRecord } from "./useEpochHistory" -const { readEpochsMock, readEventsMock } = vi.hoisted(() => ({ - readEpochsMock: vi.fn(), - readEventsMock: vi.fn(), +const { vaultMock, oracleMock, assetMock, readEpochsMock, readEventsMock } = + vi.hoisted(() => ({ + vaultMock: { + current_epoch: vi.fn(), + notice: vi.fn(), + free_reserve: vi.fn(), + deposit_cap: vi.fn(), + min_epoch_duration: vi.fn(), + epoch_opened_at: vi.fn(), + wind_down: vi.fn(), + wind_down_delay: vi.fn(), + wind_down_owed: vi.fn(), + wind_down_supply: vi.fn(), + liquid_reserve: vi.fn(), + committed: vi.fn(), + uncovered: vi.fn(), + total_economic_supply: vi.fn(), + net_deployed: vi.fn(), + paused: vi.fn(), + governance: vi.fn(), + manager: vi.fn(), + treasury: vi.fn(), + guardian: vi.fn(), + custodian: vi.fn(), + }, + oracleMock: { state: vi.fn(), latest: vi.fn(), config: vi.fn() }, + assetMock: { balance: vi.fn() }, + readEpochsMock: vi.fn(), + readEventsMock: vi.fn(), + })) + +vi.mock("../config/clients", () => ({ + asyncVault: async () => vaultMock, + navOracle: async () => oracleMock, + asset: async () => assetMock, })) -vi.mock("../config/clients", () => import("../test/fakeClients")) - -const vaultMock = fakeAsyncVault( - "current_epoch", - "notice", - "free_reserve", - "deposit_cap", - "min_epoch_duration", - "epoch_opened_at", - "wind_down", - "wind_down_delay", - "wind_down_owed", - "wind_down_supply", - "liquid_reserve", - "committed", - "uncovered", - "total_economic_supply", - "net_deployed", - "paused", - "governance", - "manager", - "treasury", - "guardian", - "custodian", -) -const oracleMock = fakeNavOracle("state", "latest", "config") -const assetMock = fakeAsset("balance") - vi.mock("@stellar-scaffold/app-lib", async (importOriginal) => ({ ...(await importOriginal()), readEpochs: readEpochsMock, @@ -296,9 +299,7 @@ const resolveVault = () => { vaultMock.treasury.mockResolvedValue({ result: "GTREASURY" }) vaultMock.guardian.mockResolvedValue({ result: "GGUARDIAN" }) vaultMock.custodian.mockResolvedValue({ result: "GCUSTODIAN" }) - oracleMock.state.mockResolvedValue({ - result: { tag: "Valid", values: undefined }, - }) + oracleMock.state.mockResolvedValue({ result: { tag: "Valid" } }) oracleMock.latest.mockResolvedValue({ result: report }) oracleMock.config.mockResolvedValue({ result: config }) assetMock.balance.mockResolvedValue({ result: 150n }) diff --git a/app/src/hooks/useDeployToCustodian.test.ts b/app/src/hooks/useDeployToCustodian.test.ts index f07bfbd6..762b23ec 100644 --- a/app/src/hooks/useDeployToCustodian.test.ts +++ b/app/src/hooks/useDeployToCustodian.test.ts @@ -2,17 +2,18 @@ import { AssembledTransaction } from "@stellar/stellar-sdk/contract" import { type Amount } from "@stellar-scaffold/app-lib" import { act, waitFor } from "@testing-library/react" import { beforeEach, describe, expect, it, vi } from "vitest" -import { - asyncVaultWriter as asyncVaultWriterMock, - fakeAsyncVault, -} from "../test/fakeClients" import { deferred, investorAddress, renderWithWallet } from "./testSupport" import { cycleWriteKeys } from "./useCycleState" import { useDeployToCustodian } from "./useDeployToCustodian" -vi.mock("../config/clients", () => import("../test/fakeClients")) +const { vaultMock, asyncVaultWriterMock } = vi.hoisted(() => ({ + vaultMock: { deploy_to_custodian: vi.fn() }, + asyncVaultWriterMock: vi.fn(), +})) -const vaultMock = fakeAsyncVault("deploy_to_custodian") +vi.mock("../config/clients", () => ({ + asyncVaultWriter: asyncVaultWriterMock, +})) const assets = 2_500_000_000n as Amount diff --git a/app/src/hooks/useDepositBalance.test.ts b/app/src/hooks/useDepositBalance.test.ts index d3082853..74033d22 100644 --- a/app/src/hooks/useDepositBalance.test.ts +++ b/app/src/hooks/useDepositBalance.test.ts @@ -2,7 +2,11 @@ import { type Amount, type ContractRead } from "@stellar-scaffold/app-lib" import { describe, expect, it, vi } from "vitest" import { classifyDepositBalance, depositBalanceKey } from "./useDepositBalance" -vi.mock("../config/clients", () => import("../test/fakeClients")) +vi.mock("../config/clients", () => ({ + asset: async () => ({ + balance: async () => ({}), + }), +})) const zero: ContractRead = { kind: "value", value: 0n } const held: ContractRead = { kind: "value", value: 500_0000000n } diff --git a/app/src/hooks/useEpochHistory.test.ts b/app/src/hooks/useEpochHistory.test.ts index 2b9bafef..b153d825 100644 --- a/app/src/hooks/useEpochHistory.test.ts +++ b/app/src/hooks/useEpochHistory.test.ts @@ -6,17 +6,17 @@ import { import type * as AppLib from "@stellar-scaffold/app-lib" import { waitFor } from "@testing-library/react" import { beforeEach, describe, expect, it, vi } from "vitest" -import { fakeAsyncVault } from "../test/fakeClients" import { renderWithWallet } from "./testSupport" import { fetchEpochHistory, useEpochHistory } from "./useEpochHistory" -const { readEpochsMock } = vi.hoisted(() => ({ +const { vaultMock, asyncVaultMock, readEpochsMock } = vi.hoisted(() => ({ + vaultMock: { current_epoch: vi.fn() }, + asyncVaultMock: vi.fn(), readEpochsMock: vi.fn(), })) +asyncVaultMock.mockResolvedValue(vaultMock) -vi.mock("../config/clients", () => import("../test/fakeClients")) - -const vaultMock = fakeAsyncVault("current_epoch") +vi.mock("../config/clients", () => ({ asyncVault: asyncVaultMock })) vi.mock("@stellar-scaffold/app-lib", async (importOriginal) => ({ ...(await importOriginal()), diff --git a/app/src/hooks/useFinalizeWindDownRound.test.ts b/app/src/hooks/useFinalizeWindDownRound.test.ts index 329c5f06..90e647fd 100644 --- a/app/src/hooks/useFinalizeWindDownRound.test.ts +++ b/app/src/hooks/useFinalizeWindDownRound.test.ts @@ -1,17 +1,18 @@ import { AssembledTransaction } from "@stellar/stellar-sdk/contract" import { act, waitFor } from "@testing-library/react" import { beforeEach, describe, expect, it, vi } from "vitest" -import { - asyncVaultWriter as asyncVaultWriterMock, - fakeAsyncVault, -} from "../test/fakeClients" import { deferred, renderWithWallet } from "./testSupport" import { cycleWriteKeys } from "./useCycleState" import { useFinalizeWindDownRound } from "./useFinalizeWindDownRound" -vi.mock("../config/clients", () => import("../test/fakeClients")) +const { vaultMock, asyncVaultWriterMock } = vi.hoisted(() => ({ + vaultMock: { finalize_wind_down_round: vi.fn() }, + asyncVaultWriterMock: vi.fn(), +})) -const vaultMock = fakeAsyncVault("finalize_wind_down_round") +vi.mock("../config/clients", () => ({ + asyncVaultWriter: asyncVaultWriterMock, +})) const credited = 9_000_000_000n diff --git a/app/src/hooks/useFulfillEpoch.test.ts b/app/src/hooks/useFulfillEpoch.test.ts index 92a1303c..c1cb42ce 100644 --- a/app/src/hooks/useFulfillEpoch.test.ts +++ b/app/src/hooks/useFulfillEpoch.test.ts @@ -1,17 +1,18 @@ import { AssembledTransaction } from "@stellar/stellar-sdk/contract" import { act, waitFor } from "@testing-library/react" import { beforeEach, describe, expect, it, vi } from "vitest" -import { - asyncVaultWriter as asyncVaultWriterMock, - fakeAsyncVault, -} from "../test/fakeClients" import { deferred, renderWithWallet } from "./testSupport" import { cycleWriteKeys } from "./useCycleState" import { useFulfillEpoch } from "./useFulfillEpoch" -vi.mock("../config/clients", () => import("../test/fakeClients")) +const { vaultMock, asyncVaultWriterMock } = vi.hoisted(() => ({ + vaultMock: { fulfill_epoch: vi.fn() }, + asyncVaultWriterMock: vi.fn(), +})) -const vaultMock = fakeAsyncVault("fulfill_epoch") +vi.mock("../config/clients", () => ({ + asyncVaultWriter: asyncVaultWriterMock, +})) const epochId = 4n const sharePrice = 1_250_000_000_000_000_000n diff --git a/app/src/hooks/useFund.test.ts b/app/src/hooks/useFund.test.ts index 3f365508..0b5f1861 100644 --- a/app/src/hooks/useFund.test.ts +++ b/app/src/hooks/useFund.test.ts @@ -2,10 +2,6 @@ import { AssembledTransaction } from "@stellar/stellar-sdk/contract" import { type Amount } from "@stellar-scaffold/app-lib" import { act, waitFor } from "@testing-library/react" import { beforeEach, describe, expect, it, vi } from "vitest" -import { - asyncVaultWriter as asyncVaultWriterMock, - fakeAsyncVault, -} from "../test/fakeClients" import { deferred, investorAddress, renderWithWallet } from "./testSupport" import type * as ContractTransactionModule from "./useContractTransaction" import { useContractTransaction } from "./useContractTransaction" @@ -13,9 +9,14 @@ import { cycleWriteKeys } from "./useCycleState" import { depositBalanceKey } from "./useDepositBalance" import { useFund } from "./useFund" -vi.mock("../config/clients", () => import("../test/fakeClients")) +const { vaultMock, asyncVaultWriterMock } = vi.hoisted(() => ({ + vaultMock: { fund: vi.fn() }, + asyncVaultWriterMock: vi.fn(), +})) -const vaultMock = fakeAsyncVault("fund") +vi.mock("../config/clients", () => ({ + asyncVaultWriter: asyncVaultWriterMock, +})) vi.mock("./useContractTransaction", async (importOriginal) => { const actual = await importOriginal() diff --git a/app/src/hooks/useGovernanceState.test.ts b/app/src/hooks/useGovernanceState.test.ts index 0e8c4914..86e135a1 100644 --- a/app/src/hooks/useGovernanceState.test.ts +++ b/app/src/hooks/useGovernanceState.test.ts @@ -2,33 +2,33 @@ import { Buffer } from "buffer" import type * as AppLib from "@stellar-scaffold/app-lib" import { waitFor } from "@testing-library/react" import { beforeEach, describe, expect, it, vi } from "vitest" -import { - fakeAsyncVault, - fakeNavOracle, - fakeShareToken, -} from "../test/fakeClients" import { governanceReadsKey } from "./pendingAdmins" import { renderWithWallet } from "./testSupport" import { useGovernanceState } from "./useGovernanceState" const { readPendingAdminsMock, + vaultMock, + tokenMock, + oracleMock, useCycleReadsMock, useConfigurationStateMock, useVaultPausedMock, } = vi.hoisted(() => ({ readPendingAdminsMock: vi.fn(), + vaultMock: { pending_changes: vi.fn() }, + tokenMock: { pending_changes: vi.fn() }, + oracleMock: { pending_changes: vi.fn() }, useCycleReadsMock: vi.fn(), useConfigurationStateMock: vi.fn(), useVaultPausedMock: vi.fn(), })) -vi.mock("../config/clients", () => import("../test/fakeClients")) - -const vaultMock = fakeAsyncVault("pending_changes") -const tokenMock = fakeShareToken("pending_changes") -const oracleMock = fakeNavOracle("pending_changes") - +vi.mock("../config/clients", () => ({ + asyncVault: async () => vaultMock, + shareToken: async () => tokenMock, + navOracle: async () => oracleMock, +})) vi.mock("./useCycleState", async (importOriginal) => ({ ...(await importOriginal()), useCycleReads: useCycleReadsMock, @@ -176,7 +176,7 @@ describe("useGovernanceState", () => { }) it("reads every pending change as the vault reports it", async () => { - const pending: AppLib.VaultPendingChange[] = [ + const pending = [ { id: 2, change: { tag: "Upgrade", values: [Buffer.alloc(32, 0xab)] }, @@ -215,7 +215,7 @@ describe("useGovernanceState", () => { }) it("reads the share token's own queue and setup state on the vault's clock", async () => { - const pending: AppLib.TokenPendingChange[] = [ + const pending = [ { id: 1, change: { tag: "Compliance", values: ["CCOMPLIANCE"] }, @@ -245,7 +245,7 @@ describe("useGovernanceState", () => { }) it("reads the oracle's own queue and setup state on the vault's clock", async () => { - const pending: AppLib.OraclePendingChange[] = [ + const pending = [ { id: 1, change: { tag: "Admin", values: ["GNEXT"] }, eta: 8n }, ] oracleMock.pending_changes.mockResolvedValue({ result: pending }) diff --git a/app/src/hooks/useHolderFrozen.test.ts b/app/src/hooks/useHolderFrozen.test.ts index 58b3b112..02dfaea6 100644 --- a/app/src/hooks/useHolderFrozen.test.ts +++ b/app/src/hooks/useHolderFrozen.test.ts @@ -1,13 +1,17 @@ import { waitFor } from "@testing-library/react" import { describe, expect, it, vi } from "vitest" -import { fakeShareToken } from "../test/fakeClients" import { investorAddress, renderWithWallet } from "./testSupport" import { useHolderFrozen } from "./useHolderFrozen" -vi.mock("../config/clients", () => import("../test/fakeClients")) +const { isFrozenMock } = vi.hoisted(() => ({ + isFrozenMock: vi.fn(async (_args: { user_address: string }) => ({ + result: true, + })), +})) -const { is_frozen: isFrozenMock } = fakeShareToken("is_frozen") -isFrozenMock.mockResolvedValue({ result: true }) +vi.mock("../config/clients", () => ({ + shareToken: async () => ({ is_frozen: isFrozenMock }), +})) describe("useHolderFrozen", () => { it("asks the share token whether the connected wallet is frozen", async () => { diff --git a/app/src/hooks/useInvestorRequests.test.ts b/app/src/hooks/useInvestorRequests.test.ts index 07885a1d..79adcb69 100644 --- a/app/src/hooks/useInvestorRequests.test.ts +++ b/app/src/hooks/useInvestorRequests.test.ts @@ -18,10 +18,6 @@ import { WalletContext, type WalletContextType, } from "../providers/WalletProvider" -import { - asyncVault as asyncVaultMock, - fakeAsyncVault, -} from "../test/fakeClients" import { classifyRequest, fetchInvestorRequests, @@ -29,13 +25,18 @@ import { useInvestorRequests, } from "./useInvestorRequests" -const { readEpochRequestsMock } = vi.hoisted(() => ({ +const { vaultMock, asyncVaultMock, readEpochRequestsMock } = vi.hoisted(() => ({ + vaultMock: { + current_epoch: vi.fn(), + }, + asyncVaultMock: vi.fn(), readEpochRequestsMock: vi.fn(), })) +asyncVaultMock.mockResolvedValue(vaultMock) -vi.mock("../config/clients", () => import("../test/fakeClients")) - -const vaultMock = fakeAsyncVault("current_epoch") +vi.mock("../config/clients", () => ({ + asyncVault: asyncVaultMock, +})) vi.mock("@stellar-scaffold/app-lib", async (importOriginal) => ({ ...(await importOriginal()), diff --git a/app/src/hooks/useIsAllowed.test.ts b/app/src/hooks/useIsAllowed.test.ts index b7953681..02740870 100644 --- a/app/src/hooks/useIsAllowed.test.ts +++ b/app/src/hooks/useIsAllowed.test.ts @@ -3,11 +3,6 @@ import { QueryClientProvider, useQuery } from "@tanstack/react-query" import { renderHook, waitFor } from "@testing-library/react" import { createElement, type ReactNode } from "react" import { beforeEach, describe, expect, it, vi } from "vitest" -import { - fakeIdentityVerifier, - fakeShareToken, - identityVerifierAt as identityVerifierAtMock, -} from "../test/fakeClients" import { investorAddress, renderWithWallet } from "./testSupport" import { classifyAllowance, @@ -15,10 +10,16 @@ import { useIsAllowed, } from "./useIsAllowed" -vi.mock("../config/clients", () => import("../test/fakeClients")) +const { tokenMock, verifierMock, identityVerifierAtMock } = vi.hoisted(() => ({ + tokenMock: { identity_verifier: vi.fn() }, + verifierMock: { is_allowed: vi.fn() }, + identityVerifierAtMock: vi.fn(), +})) -const tokenMock = fakeShareToken("identity_verifier") -const verifierMock = fakeIdentityVerifier("is_allowed") +vi.mock("../config/clients", () => ({ + shareToken: async () => tokenMock, + identityVerifierAt: identityVerifierAtMock, +})) const allowed: ContractRead = { kind: "value", value: true } const notAllowed: ContractRead = { kind: "value", value: false } diff --git a/app/src/hooks/useNavPrice.test.ts b/app/src/hooks/useNavPrice.test.ts index 34661f81..0afca360 100644 --- a/app/src/hooks/useNavPrice.test.ts +++ b/app/src/hooks/useNavPrice.test.ts @@ -7,7 +7,12 @@ import { import { describe, expect, it, vi } from "vitest" import { classifyNav, type NavClassification } from "./useNavPrice" -vi.mock("../config/clients", () => import("../test/fakeClients")) +vi.mock("../config/clients", () => ({ + navOracle: async () => ({ + state: async () => ({}), + latest: async () => ({}), + }), +})) const valid: ContractRead = { kind: "value", diff --git a/app/src/hooks/useOracleState.test.ts b/app/src/hooks/useOracleState.test.ts index 373db073..ecfbe479 100644 --- a/app/src/hooks/useOracleState.test.ts +++ b/app/src/hooks/useOracleState.test.ts @@ -1,13 +1,16 @@ import { type ContractRead, type OracleState } from "@stellar-scaffold/app-lib" import { waitFor } from "@testing-library/react" import { beforeEach, describe, expect, it, vi } from "vitest" -import { fakeNavOracle } from "../test/fakeClients" import { renderWithWallet } from "./testSupport" import { classifyOracle, useOracleState } from "./useOracleState" -vi.mock("../config/clients", () => import("../test/fakeClients")) +const { oracleMock } = vi.hoisted(() => ({ + oracleMock: { state: vi.fn() }, +})) -const oracleMock = fakeNavOracle("state") +vi.mock("../config/clients", () => ({ + navOracle: async () => oracleMock, +})) const tagged = (tag: OracleState["tag"]): ContractRead => ({ kind: "value", diff --git a/app/src/hooks/useRequestDeposit.test.ts b/app/src/hooks/useRequestDeposit.test.ts index 3ed75e6d..63865ea6 100644 --- a/app/src/hooks/useRequestDeposit.test.ts +++ b/app/src/hooks/useRequestDeposit.test.ts @@ -2,18 +2,20 @@ import { AssembledTransaction } from "@stellar/stellar-sdk/contract" import { AMOUNT_DECIMALS, type Amount } from "@stellar-scaffold/app-lib" import { act, waitFor } from "@testing-library/react" import { beforeEach, describe, expect, it, vi } from "vitest" -import { - asyncVaultWriter as asyncVaultWriterMock, - fakeAsyncVault, -} from "../test/fakeClients" import { deferred, investorAddress, renderWithWallet } from "./testSupport" import { depositBalanceKey } from "./useDepositBalance" import { investorRequestsKey } from "./useInvestorRequests" import { useRequestDeposit } from "./useRequestDeposit" -vi.mock("../config/clients", () => import("../test/fakeClients")) +const { vaultMock, asyncVaultWriterMock } = vi.hoisted(() => ({ + vaultMock: { request_deposit: vi.fn() }, + asyncVaultWriterMock: vi.fn(), +})) +asyncVaultWriterMock.mockResolvedValue(vaultMock) -const vaultMock = fakeAsyncVault("request_deposit") +vi.mock("../config/clients", () => ({ + asyncVaultWriter: asyncVaultWriterMock, +})) const amount = (100n * 10n ** BigInt(AMOUNT_DECIMALS)) as Amount diff --git a/app/src/hooks/useRequestRedeem.test.ts b/app/src/hooks/useRequestRedeem.test.ts index 24547da3..06ad9952 100644 --- a/app/src/hooks/useRequestRedeem.test.ts +++ b/app/src/hooks/useRequestRedeem.test.ts @@ -2,18 +2,20 @@ import { AssembledTransaction } from "@stellar/stellar-sdk/contract" import { AMOUNT_DECIMALS, type Amount } from "@stellar-scaffold/app-lib" import { act, waitFor } from "@testing-library/react" import { beforeEach, describe, expect, it, vi } from "vitest" -import { - asyncVaultWriter as asyncVaultWriterMock, - fakeAsyncVault, -} from "../test/fakeClients" import { deferred, investorAddress, renderWithWallet } from "./testSupport" import { investorRequestsKey } from "./useInvestorRequests" import { useRequestRedeem } from "./useRequestRedeem" import { sharePositionKey } from "./useSharePosition" -vi.mock("../config/clients", () => import("../test/fakeClients")) +const { vaultMock, asyncVaultWriterMock } = vi.hoisted(() => ({ + vaultMock: { request_redeem: vi.fn() }, + asyncVaultWriterMock: vi.fn(), +})) +asyncVaultWriterMock.mockResolvedValue(vaultMock) -const vaultMock = fakeAsyncVault("request_redeem") +vi.mock("../config/clients", () => ({ + asyncVaultWriter: asyncVaultWriterMock, +})) const shares = (100n * 10n ** BigInt(AMOUNT_DECIMALS)) as Amount diff --git a/app/src/hooks/useSharePosition.test.ts b/app/src/hooks/useSharePosition.test.ts index 0ea4f8e6..939f6d4e 100644 --- a/app/src/hooks/useSharePosition.test.ts +++ b/app/src/hooks/useSharePosition.test.ts @@ -2,7 +2,11 @@ import { type ContractRead, type Amount } from "@stellar-scaffold/app-lib" import { describe, expect, it, vi } from "vitest" import { classifyShares } from "./useSharePosition" -vi.mock("../config/clients", () => import("../test/fakeClients")) +vi.mock("../config/clients", () => ({ + shareToken: async () => ({ + balance: async () => ({}), + }), +})) const zero: ContractRead = { kind: "value", value: 0n } const held: ContractRead = { kind: "value", value: 500_0000000n } diff --git a/app/src/hooks/useSignPending.test.ts b/app/src/hooks/useSignPending.test.ts index 4307f97d..c2165c16 100644 --- a/app/src/hooks/useSignPending.test.ts +++ b/app/src/hooks/useSignPending.test.ts @@ -14,11 +14,6 @@ import { WalletContext, type WalletContextType, } from "../providers/WalletProvider" -import { - fakeAsyncVault, - fakeNavOracle, - fakeShareToken, -} from "../test/fakeClients" import { useSignPending } from "./useSignPending" const { @@ -28,6 +23,9 @@ const { authorizePendingMock, prepareCoSignedMock, latestLedgerMock, + tokenMock, + vaultMock, + oracleMock, linkTarget, } = vi.hoisted(() => ({ accountSignersMock: vi.fn(), @@ -36,17 +34,22 @@ const { authorizePendingMock: vi.fn(), prepareCoSignedMock: vi.fn(), latestLedgerMock: vi.fn(), + tokenMock: { + identity_verifier: vi.fn(), + pending_changes: vi.fn(), + get_admin: vi.fn(), + txFromXDR: vi.fn(), + }, + vaultMock: { pending_changes: vi.fn() }, + oracleMock: { pending_changes: vi.fn() }, linkTarget: { contractId: "", expiresAt: new Date(0) }, })) -vi.mock("../config/clients", () => import("../test/fakeClients")) - -const tokenMock = Object.assign( - fakeShareToken("identity_verifier", "pending_changes", "get_admin"), - { txFromXDR: vi.fn() }, -) -const vaultMock = fakeAsyncVault("pending_changes") -const oracleMock = fakeNavOracle("pending_changes") +vi.mock("../config/clients", () => ({ + shareToken: async () => tokenMock, + asyncVault: async () => vaultMock, + navOracle: async () => oracleMock, +})) type Marker = { signedBy: string[] } diff --git a/app/src/hooks/useTokenPaused.test.ts b/app/src/hooks/useTokenPaused.test.ts index c74db943..c872ac14 100644 --- a/app/src/hooks/useTokenPaused.test.ts +++ b/app/src/hooks/useTokenPaused.test.ts @@ -1,12 +1,15 @@ import { waitFor } from "@testing-library/react" import { beforeEach, describe, expect, it, vi } from "vitest" -import { fakeShareToken } from "../test/fakeClients" import { renderWithWallet } from "./testSupport" import { useTokenPaused } from "./useTokenPaused" -vi.mock("../config/clients", () => import("../test/fakeClients")) +const { tokenMock } = vi.hoisted(() => ({ + tokenMock: { paused: vi.fn() }, +})) -const tokenMock = fakeShareToken("paused") +vi.mock("../config/clients", () => ({ + shareToken: async () => tokenMock, +})) describe("useTokenPaused", () => { beforeEach(() => { diff --git a/app/src/hooks/useTokenSymbols.test.ts b/app/src/hooks/useTokenSymbols.test.ts index 3182061a..a12b55ee 100644 --- a/app/src/hooks/useTokenSymbols.test.ts +++ b/app/src/hooks/useTokenSymbols.test.ts @@ -2,7 +2,13 @@ import { type ContractRead } from "@stellar-scaffold/app-lib" import { describe, expect, it, vi } from "vitest" import { toSymbols } from "./useTokenSymbols" -vi.mock("../config/clients", () => import("../test/fakeClients")) +vi.mock("../config/clients", () => ({ + asset: async () => ({ symbol: async () => ({}) }), + shareToken: async () => ({ + symbol: async () => ({}), + name: async () => ({}), + }), +})) const value = (result: string): ContractRead => ({ kind: "value", diff --git a/app/src/hooks/useVaultAuthorities.test.ts b/app/src/hooks/useVaultAuthorities.test.ts index 0b95d0f7..98b70f32 100644 --- a/app/src/hooks/useVaultAuthorities.test.ts +++ b/app/src/hooks/useVaultAuthorities.test.ts @@ -1,18 +1,19 @@ import { waitFor } from "@testing-library/react" import { beforeEach, describe, expect, it, vi } from "vitest" -import { fakeAsyncVault } from "../test/fakeClients" import { renderWithWallet } from "./testSupport" import { useVaultAuthorities } from "./useVaultAuthorities" -vi.mock("../config/clients", () => import("../test/fakeClients")) +const { vaultMock } = vi.hoisted(() => ({ + vaultMock: { + governance: vi.fn(), + manager: vi.fn(), + treasury: vi.fn(), + guardian: vi.fn(), + custodian: vi.fn(), + }, +})) -const vaultMock = fakeAsyncVault( - "governance", - "manager", - "treasury", - "guardian", - "custodian", -) +vi.mock("../config/clients", () => ({ asyncVault: async () => vaultMock })) const settled = async () => { const rendered = renderWithWallet(useVaultAuthorities) diff --git a/app/src/hooks/useVaultPaused.test.ts b/app/src/hooks/useVaultPaused.test.ts index adeb4910..6b72e97f 100644 --- a/app/src/hooks/useVaultPaused.test.ts +++ b/app/src/hooks/useVaultPaused.test.ts @@ -3,17 +3,19 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query" import { act, renderHook, waitFor } from "@testing-library/react" import { createElement, type ReactNode } from "react" import { afterEach, describe, expect, it, vi } from "vitest" -import { fakeAsyncVault } from "../test/fakeClients" import { classifyPause, PAUSE_REFRESH_MS, useVaultPaused, } from "./useVaultPaused" -vi.mock("../config/clients", () => import("../test/fakeClients")) +const { pausedMock } = vi.hoisted(() => ({ + pausedMock: vi.fn(async () => ({ result: false })), +})) -const { paused: pausedMock } = fakeAsyncVault("paused") -pausedMock.mockResolvedValue({ result: false }) +vi.mock("../config/clients", () => ({ + asyncVault: async () => ({ paused: pausedMock }), +})) const setPageVisibility = (state: DocumentVisibilityState) => { Object.defineProperty(document, "visibilityState", { diff --git a/app/src/hooks/useWindDownActive.test.ts b/app/src/hooks/useWindDownActive.test.ts index a4fbd5db..4d999cef 100644 --- a/app/src/hooks/useWindDownActive.test.ts +++ b/app/src/hooks/useWindDownActive.test.ts @@ -1,14 +1,18 @@ import { type WindDownInfo } from "@stellar-scaffold/app-lib" import { waitFor } from "@testing-library/react" import { describe, expect, it, vi } from "vitest" -import { fakeAsyncVault } from "../test/fakeClients" import { renderWithWallet } from "./testSupport" import { useWindDownActive } from "./useWindDownActive" -vi.mock("../config/clients", () => import("../test/fakeClients")) +const { windDownMock } = vi.hoisted(() => ({ + windDownMock: vi.fn(async (): Promise<{ result: WindDownInfo | null }> => ({ + result: null, + })), +})) -const { wind_down: windDownMock } = fakeAsyncVault("wind_down") -windDownMock.mockResolvedValue({ result: null }) +vi.mock("../config/clients", () => ({ + asyncVault: async () => ({ wind_down: windDownMock }), +})) const info = (tag: "Proposed" | "Active"): WindDownInfo => ({ active_at: 1_700_000_000n, diff --git a/app/src/hooks/useWindDownClaimable.test.ts b/app/src/hooks/useWindDownClaimable.test.ts index 42acdc31..9f928149 100644 --- a/app/src/hooks/useWindDownClaimable.test.ts +++ b/app/src/hooks/useWindDownClaimable.test.ts @@ -1,15 +1,17 @@ import { waitFor } from "@testing-library/react" import { describe, expect, it, vi } from "vitest" -import { fakeAsyncVault } from "../test/fakeClients" import { investorAddress, renderWithWallet } from "./testSupport" import { useWindDownClaimable } from "./useWindDownClaimable" -vi.mock("../config/clients", () => import("../test/fakeClients")) +const { claimableMock } = vi.hoisted(() => ({ + claimableMock: vi.fn(async (_args: { holder: string }) => ({ + result: 25_0000000n, + })), +})) -const { wind_down_claimable: claimableMock } = fakeAsyncVault( - "wind_down_claimable", -) -claimableMock.mockResolvedValue({ result: 25_0000000n }) +vi.mock("../config/clients", () => ({ + asyncVault: async () => ({ wind_down_claimable: claimableMock }), +})) describe("useWindDownClaimable", () => { it("reads what the wind-down would pay the connected wallet now", async () => { diff --git a/app/src/pages/SignPage.test.tsx b/app/src/pages/SignPage.test.tsx index dd421574..b91e585a 100644 --- a/app/src/pages/SignPage.test.tsx +++ b/app/src/pages/SignPage.test.tsx @@ -20,7 +20,6 @@ import { WalletContext, type WalletContextType, } from "../providers/WalletProvider" -import { fakeShareToken } from "../test/fakeClients" import SignPage from "./SignPage" const { accountSignersMock, submitSignedMock, transactionStatusMock } = @@ -32,11 +31,12 @@ const { accountSignersMock, submitSignedMock, transactionStatusMock } = const tokenAdmin = "GTOKENADMINACCOUNT" -vi.mock("../config/clients", () => import("../test/fakeClients")) - -const token = fakeShareToken("identity_verifier", "get_admin") -token.identity_verifier.mockResolvedValue({ result: "CLIVEVERIFIER" }) -token.get_admin.mockResolvedValue({ result: tokenAdmin }) +vi.mock("../config/clients", () => ({ + shareToken: async () => ({ + identity_verifier: async () => ({ result: "CLIVEVERIFIER" }), + get_admin: async () => ({ result: tokenAdmin }), + }), +})) type Marker = { signedBy: string[] } diff --git a/app/src/pages/VaultPreview.test.tsx b/app/src/pages/VaultPreview.test.tsx index 5d08d113..d4d98648 100644 --- a/app/src/pages/VaultPreview.test.tsx +++ b/app/src/pages/VaultPreview.test.tsx @@ -1,5 +1,4 @@ import { Buffer } from "buffer" -import { type Address } from "@stellar/stellar-sdk" import { AssembledTransaction } from "@stellar/stellar-sdk/contract" import { formatDate, @@ -19,13 +18,6 @@ import { WalletContext, type WalletContextType, } from "../providers/WalletProvider" -import { - fakeAsset, - fakeAsyncVault, - fakeIdentityVerifier, - fakeNavOracle, - fakeShareToken, -} from "../test/fakeClients" import VaultPreview from "./VaultPreview" const { @@ -38,7 +30,14 @@ const { mockVault, mockFigures, mockIdentity, + requestDepositMock, + cancelDepositMock, + claimDepositMock, + requestRedeemMock, + cancelRedeemMock, + claimRedeemMock, readEpochRequestsMock, + claimWindDownMock, } = vi.hoisted(() => ({ mockVaultId: "CMOCKVAULTADDRESS1234567890", mockGovernanceAddress: "GGOVERNANCEADDRESS1234567890", @@ -87,7 +86,14 @@ const { { shares: bigint; claimed: boolean } | undefined >(), }, + requestDepositMock: vi.fn(), + cancelDepositMock: vi.fn(), + claimDepositMock: vi.fn(), + requestRedeemMock: vi.fn(), + cancelRedeemMock: vi.fn(), + claimRedeemMock: vi.fn(), readEpochRequestsMock: vi.fn(), + claimWindDownMock: vi.fn(), })) vi.mock("@stellar-scaffold/app-lib", async (importOriginal) => ({ @@ -98,7 +104,7 @@ vi.mock("@stellar-scaffold/app-lib", async (importOriginal) => ({ const defaultCancelDepositImpl = async ({ epoch_id, }: { - holder: string | Address + holder: string epoch_id: bigint }) => { const request = mockRequests.deposits.get(epoch_id) @@ -123,7 +129,7 @@ const defaultCancelDepositImpl = async ({ const defaultRequestDepositImpl = async ({ amount, }: { - holder: string | Address + holder: string amount: bigint }) => { const epochId = mockRequests.currentEpoch @@ -144,7 +150,7 @@ const defaultRequestDepositImpl = async ({ const defaultRequestRedeemImpl = async ({ shares, }: { - holder: string | Address + holder: string shares: bigint }) => { const epochId = mockRequests.currentEpoch @@ -165,7 +171,7 @@ const defaultRequestRedeemImpl = async ({ const defaultCancelRedeemImpl = async ({ epoch_id, }: { - holder: string | Address + holder: string epoch_id: bigint }) => { const request = mockRequests.redeems.get(epoch_id) @@ -192,7 +198,7 @@ const WAD_SCALE = 1_000000000000000000n const defaultClaimDepositImpl = async ({ epoch_id, }: { - holder: string | Address + holder: string epoch_id: bigint }) => { const request = mockRequests.deposits.get(epoch_id) @@ -221,7 +227,7 @@ const defaultClaimDepositImpl = async ({ const defaultClaimRedeemImpl = async ({ epoch_id, }: { - holder: string | Address + holder: string epoch_id: bigint }) => { const request = mockRequests.redeems.get(epoch_id) @@ -320,120 +326,86 @@ vi.mock("../config/contracts", () => { return { vaultContractId: mockVaultId, contractRows } }) -vi.mock("../config/clients", () => import("../test/fakeClients")) +vi.mock("../config/clients", () => { + const figure = (result: bigint) => async () => ({ result }) + const address = (result: string | null | undefined) => async () => ({ + result, + }) + const vault = { + liquid_reserve: figure(184000000000n), + committed: figure(62000000000n), + uncovered: async () => { + if (mockFigures.uncovered === null) throw new Error("boom") + return { result: mockFigures.uncovered } + }, + total_economic_supply: figure(100000000000000n), + net_deployed: figure(50000000000n), + governance: address(mockGovernanceAddress), + manager: address(undefined), + treasury: address(undefined), + guardian: address(undefined), + custodian: address(null), + current_epoch: async () => { + if (!mockRequests.readable) throw new Error("boom") + return { result: mockRequests.currentEpoch } + }, + paused: async () => ({ result: mockVault.paused }), + wind_down: async () => ({ + result: mockVault.windingDown + ? { + active_at: 1_700_000_000n, + round: 0, + status: { tag: "Active", values: undefined }, + } + : null, + }), + wind_down_claimable: async () => ({ + result: mockVault.windDownClaimable, + }), + claim_wind_down: claimWindDownMock, + request_deposit: requestDepositMock, + cancel_deposit: cancelDepositMock, + claim_deposit: claimDepositMock, + request_redeem: requestRedeemMock, + cancel_redeem: cancelRedeemMock, + claim_redeem: claimRedeemMock, + } + const oracle = { + state: async () => ({ result: { tag: "Valid", values: undefined } }), + latest: async () => ({ + result: { + nav_per_share: 1500000000000000000n, + expires_at: 1800000000n, + accepted_at: 1757900000n, + proof_hash: Buffer.alloc(32, 0xab), + proof_uri: "", + }, + }), + } + const identity = { + is_allowed: async () => ({ result: mockIdentity.allowed }), + } + const shares = { + balance: async () => ({ result: mockShares.balance }), + symbol: async () => ({ result: mockSymbols.shareToken }), + name: async () => ({ result: mockSymbols.vaultName }), + identity_verifier: async () => ({ result: "CVERIFIER" }), + is_frozen: async () => ({ result: mockIdentity.frozen }), + } + const depositAsset = { + symbol: async () => ({ result: mockSymbols.token }), + balance: async () => ({ result: mockDeposit.balance }), + } -const figure = (result: bigint) => async () => ({ result }) -const address = (result: string | null | undefined) => async () => ({ - result, -}) -const vault = fakeAsyncVault( - "liquid_reserve", - "committed", - "uncovered", - "total_economic_supply", - "net_deployed", - "governance", - "manager", - "treasury", - "guardian", - "custodian", - "current_epoch", - "paused", - "wind_down", - "wind_down_claimable", - "claim_wind_down", - "request_deposit", - "cancel_deposit", - "claim_deposit", - "request_redeem", - "cancel_redeem", - "claim_redeem", -) -vault.liquid_reserve.mockImplementation(figure(184000000000n)) -vault.committed.mockImplementation(figure(62000000000n)) -vault.uncovered.mockImplementation(async () => { - if (mockFigures.uncovered === null) throw new Error("boom") - return { result: mockFigures.uncovered } -}) -vault.total_economic_supply.mockImplementation(figure(100000000000000n)) -vault.net_deployed.mockImplementation(figure(50000000000n)) -vault.governance.mockImplementation(address(mockGovernanceAddress)) -vault.manager.mockImplementation(address(undefined)) -vault.treasury.mockImplementation(address(undefined)) -vault.guardian.mockImplementation(address(undefined)) -vault.custodian.mockImplementation(address(null)) -vault.current_epoch.mockImplementation(async () => { - if (!mockRequests.readable) throw new Error("boom") - return { result: mockRequests.currentEpoch } + return { + asyncVault: async () => vault, + asyncVaultWriter: async () => vault, + navOracle: async () => oracle, + identityVerifierAt: async () => identity, + shareToken: async () => shares, + asset: async () => depositAsset, + } }) -vault.paused.mockImplementation(async () => ({ result: mockVault.paused })) -vault.wind_down.mockImplementation(async () => ({ - result: mockVault.windingDown - ? { - active_at: 1_700_000_000n, - round: 0, - status: { tag: "Active", values: undefined }, - } - : null, -})) -vault.wind_down_claimable.mockImplementation(async () => ({ - result: mockVault.windDownClaimable, -})) -const { - claim_wind_down: claimWindDownMock, - request_deposit: requestDepositMock, - cancel_deposit: cancelDepositMock, - claim_deposit: claimDepositMock, - request_redeem: requestRedeemMock, - cancel_redeem: cancelRedeemMock, - claim_redeem: claimRedeemMock, -} = vault - -const oracle = fakeNavOracle("state", "latest") -oracle.state.mockImplementation(async () => ({ - result: { tag: "Valid", values: undefined }, -})) -oracle.latest.mockImplementation(async () => ({ - result: { - nav_per_share: 1500000000000000000n, - expires_at: 1800000000n, - accepted_at: 1757900000n, - proof_hash: Buffer.alloc(32, 0xab), - proof_uri: "", - }, -})) - -const identity = fakeIdentityVerifier("is_allowed") -identity.is_allowed.mockImplementation(async () => ({ - result: mockIdentity.allowed, -})) - -const shares = fakeShareToken( - "balance", - "symbol", - "name", - "identity_verifier", - "is_frozen", -) -shares.balance.mockImplementation(async () => ({ result: mockShares.balance })) -shares.symbol.mockImplementation(async () => ({ - result: mockSymbols.shareToken, -})) -shares.name.mockImplementation(async () => ({ result: mockSymbols.vaultName })) -shares.identity_verifier.mockImplementation(async () => ({ - result: "CVERIFIER", -})) -shares.is_frozen.mockImplementation(async () => ({ - result: mockIdentity.frozen, -})) - -const depositAsset = fakeAsset("symbol", "balance") -depositAsset.symbol.mockImplementation(async () => ({ - result: mockSymbols.token, -})) -depositAsset.balance.mockImplementation(async () => ({ - result: mockDeposit.balance, -})) const investorAddress = "GINVESTORADDRESS1234567890" @@ -1004,7 +976,7 @@ describe("VaultPreview", () => { resolveSend = resolve }) requestDepositMock.mockImplementationOnce( - async ({ amount }: { holder: string | Address; amount: bigint }) => ({ + async ({ amount }: { holder: string; amount: bigint }) => ({ simulation: undefined, signAndSend: async ({ watcher, @@ -1053,7 +1025,7 @@ describe("VaultPreview", () => { resolveSend = resolve }) requestDepositMock.mockImplementationOnce( - async ({ amount }: { holder: string | Address; amount: bigint }) => ({ + async ({ amount }: { holder: string; amount: bigint }) => ({ simulation: undefined, signAndSend: async ({ watcher, diff --git a/app/src/test/fakeClients.test.ts b/app/src/test/fakeClients.test.ts deleted file mode 100644 index e766db89..00000000 --- a/app/src/test/fakeClients.test.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { type Address } from "@stellar/stellar-sdk" -import { describe, expect, expectTypeOf, it, vi } from "vitest" -import type * as Clients from "../config/clients" -import * as fakeClients from "./fakeClients" -import { - asset, - asyncVault, - asyncVaultWriter, - fakeAsset, - fakeAsyncVault, - fakeIdentityVerifier, - fakeNavOracle, - fakeShareToken, - identityVerifierAt, - identityVerifierWriter, - navOracle, - navOracleWriter, - shareToken, - shareTokenWriter, -} from "./fakeClients" - -const signer = { publicKey: "GSIGNER", signTransaction: vi.fn() } - -describe("fake clients", () => { - it("fakes every client config/clients hands out", async () => { - const real = await vi.importActual("../config/clients") - - for (const name of Object.keys(real)) { - expect(fakeClients).toHaveProperty(name) - } - }) - - it("gives each named call its own mock and nothing else", () => { - const vault = fakeAsyncVault("current_epoch", "cancel_deposit") - - expect(Object.keys(vault)).toEqual(["current_epoch", "cancel_deposit"]) - expect(vi.isMockFunction(vault.current_epoch)).toBe(true) - expect(vault.current_epoch).not.toBe(vault.cancel_deposit) - }) - - it("hands the last fake of each contract to its reader and writer", async () => { - const vault = fakeAsyncVault("paused") - const token = fakeShareToken("paused") - const oracle = fakeNavOracle("state") - const usdc = fakeAsset("balance") - const verifier = fakeIdentityVerifier("is_allowed") - - expect(await asyncVault()).toBe(vault) - expect(await asyncVaultWriter(signer)).toBe(vault) - expect(await shareToken()).toBe(token) - expect(await shareTokenWriter(signer)).toBe(token) - expect(await navOracle()).toBe(oracle) - expect(await navOracleWriter(signer)).toBe(oracle) - expect(await asset()).toBe(usdc) - expect(await identityVerifierAt("CVERIFIER")).toBe(verifier) - expect(await identityVerifierWriter("CVERIFIER")(signer)).toBe(verifier) - }) - - it("types each call by the generated client", () => { - const vault = fakeAsyncVault("current_epoch", "cancel_deposit") - const token = fakeShareToken("identity_verifier") - - vault.current_epoch.mockResolvedValue({ result: 3n }) - token.identity_verifier.mockResolvedValue({ result: "CVERIFIER" }) - expectTypeOf(vault.cancel_deposit).parameter(0).toEqualTypeOf<{ - holder: string | Address - epoch_id: bigint - }>() - - // @ts-expect-error the vault has no such call - fakeAsyncVault("current_epochs") - // @ts-expect-error current_epoch returns a bigint - vault.current_epoch.mockResolvedValue({ result: "3" }) - // @ts-expect-error not a member of an assembled transaction - vault.current_epoch.mockResolvedValue({ results: 3n }) - // @ts-expect-error the call was not declared - void vault.paused - }) -}) diff --git a/app/src/test/fakeClients.ts b/app/src/test/fakeClients.ts deleted file mode 100644 index 6d7d0f3e..00000000 --- a/app/src/test/fakeClients.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { type AssembledTransaction } from "@stellar/stellar-sdk/contract" -import { - type AssetApi, - type AsyncVaultApi, - type IdentityVerifierApi, - type NavOracleApi, - type ShareTokenApi, -} from "@stellar-scaffold/app-lib" -import { vi, type Mock } from "vitest" -import { type WriterSigner as Signer } from "../config/clients" - -type CallName = { - [Name in keyof Client]: Client[Name] extends ( - ...args: never[] - ) => Promise> - ? Name - : never -}[keyof Client] - -// What a faked call resolves to. Only `result` takes its type from the -// contract; the other members are SDK internals that tests stand in for loosely. -export type FakeTransaction = { result?: Result } & { - [Member in Exclude, "result">]?: unknown -} - -type FakeCall = Call extends ( - ...args: infer Args -) => Promise> - ? Mock<(...args: Args) => Promise>> - : never - -export type FakeClient< - Client, - Name extends CallName = CallName, -> = { [N in Name]: FakeCall } - -type AnyFake = Partial> - -const fakes: { - vault: AnyFake - token: AnyFake - oracle: AnyFake - asset: AnyFake - verifier: AnyFake | undefined -} = { vault: {}, token: {}, oracle: {}, asset: {}, verifier: undefined } - -const fakeOf = - () => - >(...names: Name[]): FakeClient => - Object.fromEntries(names.map((name) => [name, vi.fn()])) as FakeClient< - Client, - Name - > - -// Each fake becomes the client `config/clients` hands out for its contract, -// to readers and writers alike, until the next fake of that contract. -export const fakeAsyncVault = >( - ...names: Name[] -) => (fakes.vault = fakeOf()(...names)) - -export const fakeShareToken = >( - ...names: Name[] -) => (fakes.token = fakeOf()(...names)) - -export const fakeNavOracle = >( - ...names: Name[] -) => (fakes.oracle = fakeOf()(...names)) - -export const fakeAsset = >(...names: Name[]) => - (fakes.asset = fakeOf()(...names)) - -export const fakeIdentityVerifier = < - Name extends CallName, ->( - ...names: Name[] -) => (fakes.verifier = fakeOf()(...names)) - -export const asyncVault = vi.fn(async () => fakes.vault) -export const asyncVaultWriter = vi.fn(async (_signer: Signer) => fakes.vault) -export const shareToken = vi.fn(async () => fakes.token) -export const shareTokenWriter = vi.fn(async (_signer: Signer) => fakes.token) -export const navOracle = vi.fn(async () => fakes.oracle) -export const navOracleWriter = vi.fn(async (_signer: Signer) => fakes.oracle) -export const asset = vi.fn(async () => fakes.asset) -export const identityVerifierAt = vi.fn( - async (_address: string) => fakes.verifier, -) -export const identityVerifierWriter = vi.fn( - (_address: string) => async (_signer: Signer) => fakes.verifier, -) From ae1aad69622d6331c5da8677265259235f733c06 Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Mon, 2 Nov 2026 17:27:30 -0300 Subject: [PATCH 18/18] fix(scripts): require the clients' dist only before the type check `npm run test` imports only each client's `src/`, which the app's own client generator also writes, so its guard checks only that. The guard before `npm run typecheck` also requires each contract client's `dist/`, which the harness imports. Both errors name `npm run build:clients`. Refs #420 --- package.json | 2 +- scripts/clients-guard.mjs | 22 ++++++--------------- scripts/lib/clients.d.mts | 7 ++++++- scripts/lib/clients.mjs | 19 +++++++++++++++--- scripts/lib/clients.test.mjs | 38 ++++++++++++++++++++++++++++-------- 5 files changed, 59 insertions(+), 29 deletions(-) diff --git a/package.json b/package.json index 6b99681c..5a174316 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "seed": "npm run seed -w scripts/harness --", "pretest": "node scripts/clients-guard.mjs && node scripts/generate-addresses.mjs", "test": "vitest run", - "pretypecheck": "node scripts/clients-guard.mjs && node scripts/generate-addresses.mjs", + "pretypecheck": "node scripts/clients-guard.mjs --compiled && node scripts/generate-addresses.mjs", "typecheck": "npm run typecheck -w app -w scripts/harness" }, "workspaces": [ diff --git a/scripts/clients-guard.mjs b/scripts/clients-guard.mjs index 9dca2a5b..7589205f 100644 --- a/scripts/clients-guard.mjs +++ b/scripts/clients-guard.mjs @@ -1,24 +1,14 @@ import { dirname, resolve } from "node:path" import { fileURLToPath } from "node:url" -import { missingClients } from "./lib/clients.mjs" +import { missingClients, missingClientsMessage } from "./lib/clients.mjs" -// Runs before the root `test` and `typecheck`, which import the generated clients. +// Runs before the root `test`, and with `--compiled` before `typecheck`. const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..") -const missing = missingClients(repoRoot) +const missing = missingClients(repoRoot, { + compiled: process.argv.includes("--compiled"), +}) if (missing.length > 0) { - console.error( - [ - "", - "The generated contract clients are missing:", - "", - ...missing.map((path) => ` - ${path}`), - "", - "Build the contracts and generate the clients first:", - "", - " stellar contract build && npm run build:clients", - "", - ].join("\n"), - ) + console.error(missingClientsMessage(missing)) process.exit(1) } diff --git a/scripts/lib/clients.d.mts b/scripts/lib/clients.d.mts index e721099f..fded30a1 100644 --- a/scripts/lib/clients.d.mts +++ b/scripts/lib/clients.d.mts @@ -1,7 +1,12 @@ // Types for clients.mjs, which the harness imports from TypeScript. import type { BindingGenerator } from "@stellar/stellar-sdk" -export function missingClients(root: string): string[] +export function missingClients( + root: string, + options?: { compiled?: boolean }, +): string[] + +export function missingClientsMessage(missing: string[]): string export function writeClient( dir: string, diff --git a/scripts/lib/clients.mjs b/scripts/lib/clients.mjs index ff0ceb65..753f6e72 100644 --- a/scripts/lib/clients.mjs +++ b/scripts/lib/clients.mjs @@ -7,16 +7,29 @@ const crateClients = (root) => .map((entry) => entry.name.replaceAll("-", "_")) .sort() -// The app imports each client's `src/`, the harness a contract client's `dist/`. -export const missingClients = (root) => +// Tests import `src/`; the type check also needs each contract client's `dist/`. +export const missingClients = (root, { compiled = false } = {}) => [ ...crateClients(root).flatMap((name) => [ `app-lib/clients/${name}/src/index.ts`, - `app-lib/clients/${name}/dist/index.js`, + ...(compiled ? [`app-lib/clients/${name}/dist/index.js`] : []), ]), "app-lib/clients/asset/src/index.ts", ].filter((path) => !existsSync(join(root, path))) +export const missingClientsMessage = (missing) => + [ + "", + "The generated contract clients are missing:", + "", + ...missing.map((path) => ` - ${path}`), + "", + "Build the contracts and generate the clients first:", + "", + " stellar contract build && npm run build:clients", + "", + ].join("\n") + export const writeClient = (dir, files) => { const name = basename(dir) mkdirSync(join(dir, "src"), { recursive: true }) diff --git a/scripts/lib/clients.test.mjs b/scripts/lib/clients.test.mjs index 4f60bc57..b1f35bfd 100644 --- a/scripts/lib/clients.test.mjs +++ b/scripts/lib/clients.test.mjs @@ -3,7 +3,11 @@ import { tmpdir } from "node:os" import { join } from "node:path" import { describe, expect, it } from "vitest" -import { missingClients, writeClient } from "./clients.mjs" +import { + missingClients, + missingClientsMessage, + writeClient, +} from "./clients.mjs" const repo = (contracts, clients, { unbuilt = [] } = {}) => { const root = mkdtempSync(join(tmpdir(), "clients-")) @@ -32,15 +36,13 @@ describe("missingClients", () => { ) expect(missingClients(root)).toEqual([]) + expect(missingClients(root, { compiled: true })).toEqual([]) }) it("names each contract without a client, in its underscored form", () => { const root = repo(["async-vault", "nav-oracle"], ["async_vault", "asset"]) - expect(missingClients(root)).toEqual([ - source("nav_oracle"), - compiled("nav_oracle"), - ]) + expect(missingClients(root)).toEqual([source("nav_oracle")]) }) it("needs a client for the asset, which has no crate", () => { @@ -49,19 +51,30 @@ describe("missingClients", () => { expect(missingClients(root)).toEqual([source("asset")]) }) - it("needs each contract client compiled, since the harness imports its dist", () => { + it("passes the test guard with sources alone, since the tests import src", () => { const root = repo(["async-vault"], ["async_vault", "asset"], { unbuilt: ["async_vault", "asset"], }) - expect(missingClients(root)).toEqual([compiled("async_vault")]) + expect(missingClients(root)).toEqual([]) + }) + + it("fails the typecheck guard without each contract client's dist, which the harness imports", () => { + const root = repo(["async-vault"], ["async_vault", "asset"], { + unbuilt: ["async_vault", "asset"], + }) + + const missing = missingClients(root, { compiled: true }) + + expect(missing).toEqual([compiled("async_vault")]) + expect(missingClientsMessage(missing)).toContain("npm run build:clients") }) it("names them all when the clients folder is empty", () => { const root = repo(["async-vault", "share-token"], []) mkdirSync(join(root, "app-lib", "clients"), { recursive: true }) - expect(missingClients(root)).toEqual([ + expect(missingClients(root, { compiled: true })).toEqual([ source("async_vault"), compiled("async_vault"), source("share_token"), @@ -71,6 +84,15 @@ describe("missingClients", () => { }) }) +describe("missingClientsMessage", () => { + it("lists each missing path and the command that generates them", () => { + const message = missingClientsMessage([source("asset")]) + + expect(message).toContain(source("asset")) + expect(message).toContain("stellar contract build && npm run build:clients") + }) +}) + describe("writeClient", () => { const files = { index: "export * from './client.js'\n",