From b9f112cfcfbc2337696c3d0e78f8107217643255 Mon Sep 17 00:00:00 2001 From: luchobonatti Date: Mon, 2 Nov 2026 16:29:34 -0300 Subject: [PATCH 1/4] feat(app): resolve contract errors by generated name Contract errors are resolved at runtime from the error cases the generated clients carry, so failures expose the error's name next to its code. Tests derive codes from each client's error table by name. Refs #415 --- app-lib/contractErrors.test.ts | 69 +++++++++++ app-lib/contractErrors.ts | 40 ++++++ app-lib/contracts.ts | 14 +++ app-lib/multisig.test.ts | 25 +++- app-lib/multisig.ts | 14 +-- app-lib/readContract.test.ts | 15 ++- app-lib/readContract.ts | 19 +-- app/src/hooks/useActivateWindDown.test.ts | 14 ++- app/src/hooks/useAdminTransaction.test.ts | 114 ++++++++++-------- app/src/hooks/useAttest.test.ts | 12 +- app/src/hooks/useCancelDeposit.test.ts | 12 +- app/src/hooks/useCancelRedeem.test.ts | 23 +++- app/src/hooks/useClaimDeposit.test.ts | 23 +++- app/src/hooks/useClaimRedeem.test.ts | 23 +++- app/src/hooks/useClaimWindDown.test.ts | 10 +- app/src/hooks/useCloseEpoch.test.ts | 12 +- app/src/hooks/useComplianceState.test.ts | 4 +- app/src/hooks/useContractTransaction.test.ts | 8 +- app/src/hooks/useContractTransaction.ts | 13 +- app/src/hooks/useCycleState.test.ts | 14 ++- app/src/hooks/useCycleState.ts | 4 +- app/src/hooks/useDeployToCustodian.test.ts | 14 ++- app/src/hooks/useDepositBalance.test.ts | 1 + .../hooks/useFinalizeWindDownRound.test.ts | 12 +- app/src/hooks/useFulfillEpoch.test.ts | 12 +- app/src/hooks/useFund.test.ts | 12 +- app/src/hooks/useIsAllowed.test.ts | 1 + app/src/hooks/useNavPrice.test.ts | 5 +- app/src/hooks/useNavPrice.ts | 2 +- app/src/hooks/useOracleState.test.ts | 6 +- app/src/hooks/useRequestDeposit.test.ts | 12 +- app/src/hooks/useRequestRedeem.test.ts | 12 +- app/src/hooks/useSharePosition.test.ts | 1 + app/src/hooks/useSignPending.test.ts | 26 +++- app/src/hooks/useSignPending.ts | 12 +- app/src/hooks/useTokenSymbols.test.ts | 6 +- app/src/hooks/useVaultPaused.test.ts | 1 + app/src/test/contractErrorCode.ts | 17 +++ 38 files changed, 491 insertions(+), 143 deletions(-) create mode 100644 app-lib/contractErrors.test.ts create mode 100644 app-lib/contractErrors.ts create mode 100644 app/src/test/contractErrorCode.ts diff --git a/app-lib/contractErrors.test.ts b/app-lib/contractErrors.test.ts new file mode 100644 index 00000000..4ef813c4 --- /dev/null +++ b/app-lib/contractErrors.test.ts @@ -0,0 +1,69 @@ +import { Buffer } from "buffer" +import { describe, expect, it, vi } from "vitest" +import { errorName, errorNames } from "./contractErrors" +import { generatedSpecs } from "./contracts" + +const errorCase = (name: string, value: number) => ({ + name: () => Buffer.from(name), + value: () => value, +}) + +const spec = (...cases: ReturnType[]) => ({ + errorCases: () => cases, +}) + +describe("errorNames", () => { + it("merges the error cases of every spec by code", () => { + const names = errorNames([ + spec(errorCase("InvalidAmount", 6007), errorCase("Paused", 1000)), + spec(errorCase("NoRecord", 3006), errorCase("Paused", 1000)), + ]) + + expect(names.get(6007)).toBe("InvalidAmount") + expect(names.get(3006)).toBe("NoRecord") + expect(names.get(1000)).toBe("Paused") + }) + + it("knows no name for a code no spec declares", () => { + expect(errorNames([spec(errorCase("NoRecord", 3006))]).get(9999)).toBe( + undefined, + ) + }) + + it("leaves a code two specs name differently unresolved and logs it", () => { + const logged = vi.spyOn(console, "error").mockImplementation(() => {}) + try { + const names = errorNames([ + spec(errorCase("WindDownActive", 6046), errorCase("NoRecord", 3006)), + spec(errorCase("AlreadyPriced", 6046), errorCase("Paused", 1000)), + ]) + + expect(names.get(6046)).toBe(undefined) + expect(names.get(3006)).toBe("NoRecord") + expect(names.get(1000)).toBe("Paused") + expect(logged).toHaveBeenCalledOnce() + expect(logged.mock.calls[0]?.[0]).toMatch( + /#6046.*WindDownActive.*AlreadyPriced.*regenerate the clients from the same contracts/, + ) + } finally { + logged.mockRestore() + } + }) +}) + +describe("errorName", () => { + it("names every error case of the generated clients, with no code named twice", () => { + const logged = vi.spyOn(console, "error").mockImplementation(() => {}) + try { + const cases = generatedSpecs().flatMap((spec) => spec.errorCases()) + + expect(cases.length).toBeGreaterThan(0) + for (const errorCase of cases) { + expect(errorName(errorCase.value())).toBe(errorCase.name().toString()) + } + expect(logged).not.toHaveBeenCalled() + } finally { + logged.mockRestore() + } + }) +}) diff --git a/app-lib/contractErrors.ts b/app-lib/contractErrors.ts new file mode 100644 index 00000000..97afef8f --- /dev/null +++ b/app-lib/contractErrors.ts @@ -0,0 +1,40 @@ +import { generatedSpecs } from "./contracts" + +interface ErrorCase { + name(): { toString(): string } + value(): number +} + +export interface ErrorSpec { + errorCases(): ErrorCase[] +} + +export function errorNames(specs: ErrorSpec[]): Map { + const names = new Map() + const conflicting = new Set() + for (const spec of specs) { + for (const errorCase of spec.errorCases()) { + const code = errorCase.value() + if (conflicting.has(code)) continue + const name = errorCase.name().toString() + const known = names.get(code) + if (known !== undefined && known !== name) { + console.error( + `Contract error #${code} is ${known} in one generated client and ${name} in another; regenerate the clients from the same contracts`, + ) + conflicting.add(code) + names.delete(code) + continue + } + names.set(code, name) + } + } + return names +} + +let generatedNames: Map | undefined + +export function errorName(code: number): string | undefined { + generatedNames ??= errorNames(generatedSpecs()) + return generatedNames.get(code) +} diff --git a/app-lib/contracts.ts b/app-lib/contracts.ts index e4a0f006..51a76a49 100644 --- a/app-lib/contracts.ts +++ b/app-lib/contracts.ts @@ -16,6 +16,7 @@ import { type VaultPendingChange, type WindDownInfo, } from "./clients/async_vault/src/index" +import { Client as ComplianceApi } from "./clients/compliance/src/index" import { Client as IdentityVerifierApi } from "./clients/identity_verifier/src/index" import { Client as NavOracleApi, @@ -80,6 +81,19 @@ export const connectAsyncVault = async ( ): Promise => new AsyncVaultApi(clientOptions(contractId, signer, options)) +// The spec is embedded in the generated client; building one calls no contract. +export const generatedSpecs = () => { + const options = clientOptions("") + return [ + new AssetApi(options).spec, + new AsyncVaultApi(options).spec, + new ComplianceApi(options).spec, + new IdentityVerifierApi(options).spec, + new NavOracleApi(options).spec, + new ShareTokenApi(options).spec, + ] +} + export const isUserRejection = (error: unknown): boolean => error instanceof AssembledTransaction.Errors.UserRejected diff --git a/app-lib/multisig.test.ts b/app-lib/multisig.test.ts index d4cf294b..9212c06a 100644 --- a/app-lib/multisig.test.ts +++ b/app-lib/multisig.test.ts @@ -23,7 +23,12 @@ import { } from "@stellar/stellar-sdk/contract" import { Api } from "@stellar/stellar-sdk/rpc" import { beforeEach, describe, expect, it, vi } from "vitest" +import { + contractErrorCode, + simulationError, +} from "../app/src/test/contractErrorCode" import type { AccountSigners } from "./accounts" +import { RWAError } from "./clients/share_token/src/index" import { connectAsyncVault } from "./contracts" import { networkPassphrase } from "./env" import { @@ -766,12 +771,12 @@ const simulated = (): Api.SimulateTransactionSuccessResponse => ({ result: { auth: [], retval: xdr.ScVal.scvVoid() }, }) -const refused = (code: number): Api.SimulateTransactionErrorResponse => ({ +const refused = (name: string): Api.SimulateTransactionErrorResponse => ({ id: "1", latestLedger: 1_000, events: [], _parsed: true, - error: `HostError: Error(Contract, #${code})`, + error: simulationError(RWAError, name), }) const adminWallet = vi.fn(async (preimage: string) => ({ @@ -815,12 +820,16 @@ describe("authorizePending", () => { }) it("reports the contract's refusal from the re-simulation", async () => { - simulateMock.mockResolvedValue(refused(300)) + simulateMock.mockResolvedValue(refused("InsufficientBalance")) const encoded = legalTransfer() await expect( authorizePending(assembled(encoded), decodePending(encoded), adminWallet), - ).resolves.toEqual({ kind: "refused", code: 300 }) + ).resolves.toEqual({ + kind: "refused", + code: contractErrorCode(RWAError, "InsufficientBalance"), + name: "InsufficientBalance", + }) }) it("refuses a transaction whose authorization is already signed", async () => { @@ -875,14 +884,18 @@ describe("prepareCoSigned", () => { it("reports the contract's refusal from the simulation", async () => { const pending = await authorizedTransfer() - simulateMock.mockResolvedValue(refused(304)) + simulateMock.mockResolvedValue(refused("IdentityVerificationFailed")) await expect( prepareCoSigned( assembled(pending.transaction.toEnvelope().toXDR("base64")), pending, ), - ).resolves.toEqual({ kind: "refused", code: 304 }) + ).resolves.toEqual({ + kind: "refused", + code: contractErrorCode(RWAError, "IdentityVerificationFailed"), + name: "IdentityVerificationFailed", + }) }) it("refuses a transaction the admin has not authorized", async () => { diff --git a/app-lib/multisig.ts b/app-lib/multisig.ts index fa4c93eb..f193d434 100644 --- a/app-lib/multisig.ts +++ b/app-lib/multisig.ts @@ -16,7 +16,7 @@ import { import { Api } from "@stellar/stellar-sdk/rpc" import type { AccountSigners } from "./accounts" import { networkPassphrase } from "./env" -import { parseErrorCode } from "./readContract" +import { type ContractError, parseContractError } from "./readContract" import { server } from "./rpc" export const OPERATOR_TIMEOUT_SECONDS = 7 * 24 * 60 * 60 @@ -56,7 +56,7 @@ export type SignAuthEntry = NonNullable export type CoSignStep = | { kind: "ready"; xdr: string; pending: PendingTransaction } - | { kind: "refused"; code: number } + | ({ kind: "refused" } & ContractError) export type SubmitFailure = | "sequence-consumed" @@ -218,10 +218,10 @@ const sameTimeBounds = (a: Transaction, b: Transaction): boolean => const simulated = async ( assembled: AssembledTransaction, -): Promise => { +): Promise => { await assembled.simulate() - const code = parseErrorCode(assembled.simulation) - if (code !== null) return code + const error = parseContractError(assembled.simulation) + if (error !== null) return error if ( assembled.simulation === undefined || !Api.isSimulationSuccess(assembled.simulation) || @@ -253,7 +253,7 @@ export async function authorizePending( expiration, }) const built = await simulated(assembled) - if (typeof built === "number") return { kind: "refused", code: built } + if ("code" in built) return { kind: "refused", ...built } const xdr = encodeEnvelope(built) const next = decodePending(xdr) if ( @@ -276,7 +276,7 @@ export async function prepareCoSigned( throw new InvalidPendingTransaction("authorization missing") } const built = await simulated(assembled) - if (typeof built === "number") return { kind: "refused", code: built } + if ("code" in built) return { kind: "refused", ...built } const retimed = TransactionBuilder.cloneFrom(built, { timebounds: undefined, sorobanData: assembled.simulationData.transactionData, diff --git a/app-lib/readContract.test.ts b/app-lib/readContract.test.ts index e6a06ae2..84e332b8 100644 --- a/app-lib/readContract.test.ts +++ b/app-lib/readContract.test.ts @@ -1,6 +1,11 @@ import { Address, nativeToScVal, xdr } from "@stellar/stellar-sdk" import type { Api } from "@stellar/stellar-sdk/rpc" import { beforeEach, describe, expect, it, vi } from "vitest" +import { + contractErrorCode, + simulationError, +} from "../app/src/test/contractErrorCode" +import { OracleError } from "./clients/nav_oracle/src/index" import type { DepositRequest, EpochInfo, RedeemRequest } from "./contracts" import { decodeTransactionEvents, @@ -45,7 +50,7 @@ const errorSimulation: Api.SimulateTransactionErrorResponse = { latestLedger: 100, events: [], _parsed: true, - error: "HostError: Error(Contract, #3006)", + error: simulationError(OracleError, "NoRecord"), } describe("readContract", () => { @@ -55,7 +60,7 @@ describe("readContract", () => { expect(read).toEqual({ kind: "value", value: 42n }) }) - it("parses the error code out of the simulation, never touching result", async () => { + it("parses the error code out of the simulation and names it, never touching result", async () => { const read = await readContract(() => Promise.resolve({ simulation: errorSimulation, @@ -65,7 +70,11 @@ describe("readContract", () => { }), ) - expect(read).toEqual({ kind: "contract-error", code: 3006 }) + expect(read).toEqual({ + kind: "contract-error", + code: contractErrorCode(OracleError, "NoRecord"), + name: "NoRecord", + }) }) it("is unreadable when the call itself rejects", async () => { diff --git a/app-lib/readContract.ts b/app-lib/readContract.ts index 88a9d188..5aabf3f3 100644 --- a/app-lib/readContract.ts +++ b/app-lib/readContract.ts @@ -1,5 +1,6 @@ import { Address, scValToNative, xdr } from "@stellar/stellar-sdk" import { Api } from "@stellar/stellar-sdk/rpc" +import { errorName } from "./contractErrors" import type { DepositRequest, EpochInfo, @@ -8,21 +9,25 @@ import type { } from "./contracts" import { server } from "./rpc" +export type ContractError = { code: number; name: string | undefined } + export type ContractRead = | { kind: "value"; value: T } - | { kind: "contract-error"; code: number } + | ({ kind: "contract-error" } & ContractError) | { kind: "archived" } | { kind: "unreadable" } const CONTRACT_ERROR = /Error\(Contract, #(\d+)\)/ -export function parseErrorCode( +export function parseContractError( simulation: Api.SimulateTransactionResponse | undefined, -): number | null { +): ContractError | null { if (simulation === undefined || !Api.isSimulationError(simulation)) return null const match = simulation.error.match(CONTRACT_ERROR) - return match ? Number(match[1]) : null + if (!match) return null + const code = Number(match[1]) + return { code, name: errorName(code) } } export async function readContract( @@ -36,9 +41,9 @@ export async function readContract( if (tx.simulation !== undefined && Api.isSimulationRestore(tx.simulation)) { return { kind: "archived" } } - const code = parseErrorCode(tx.simulation) - return code !== null - ? { kind: "contract-error", code } + const error = parseContractError(tx.simulation) + return error !== null + ? { kind: "contract-error", ...error } : { kind: "value", value: tx.result } } catch { return { kind: "unreadable" } diff --git a/app/src/hooks/useActivateWindDown.test.ts b/app/src/hooks/useActivateWindDown.test.ts index 06ab2ecc..f561e0df 100644 --- a/app/src/hooks/useActivateWindDown.test.ts +++ b/app/src/hooks/useActivateWindDown.test.ts @@ -1,6 +1,8 @@ import { AssembledTransaction } from "@stellar/stellar-sdk/contract" import { act, waitFor } from "@testing-library/react" import { beforeEach, describe, expect, it, vi } from "vitest" +import { VaultError } from "../../../app-lib/clients/async_vault/src/index" +import { contractErrorCode, simulationError } from "../test/contractErrorCode" import { deferred, renderWithWallet } from "./testSupport" import { useActivateWindDown } from "./useActivateWindDown" import { cycleWriteKeys } from "./useCycleState" @@ -72,7 +74,9 @@ describe("useActivateWindDown", () => { it("refuses before ever asking for a signature when the simulation carries the vault's reason", async () => { const signAndSend = vi.fn() vaultMock.activate_wind_down.mockResolvedValue({ - simulation: { error: "HostError: Error(Contract, #6049)" }, + simulation: { + error: simulationError(VaultError, "WindDownDelayNotElapsed"), + }, signAndSend, }) const { result } = renderActivateWindDown() @@ -81,7 +85,11 @@ describe("useActivateWindDown", () => { expect(result.current.status).toEqual({ status: "failed", - failure: { kind: "contract-error", code: 6049 }, + failure: { + kind: "contract-error", + code: contractErrorCode(VaultError, "WindDownDelayNotElapsed"), + name: "WindDownDelayNotElapsed", + }, }) expect(signAndSend).not.toHaveBeenCalled() }) @@ -147,7 +155,7 @@ describe("useActivateWindDown", () => { it("refreshes the cached cycle when the vault refuses at simulation", async () => { vaultMock.activate_wind_down.mockResolvedValue({ - simulation: { error: "HostError: Error(Contract, #6047)" }, + simulation: { error: simulationError(VaultError, "WindDownNotProposed") }, signAndSend: vi.fn(), }) const { result, queryClient } = renderActivateWindDown() diff --git a/app/src/hooks/useAdminTransaction.test.ts b/app/src/hooks/useAdminTransaction.test.ts index 68771092..59a1e30c 100644 --- a/app/src/hooks/useAdminTransaction.test.ts +++ b/app/src/hooks/useAdminTransaction.test.ts @@ -4,6 +4,11 @@ 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 * as VaultClient from "../../../app-lib/clients/async_vault/src/index" +import * as VerifierClient from "../../../app-lib/clients/identity_verifier/src/index" +import * as OracleClient from "../../../app-lib/clients/nav_oracle/src/index" +import * as ShareTokenClient from "../../../app-lib/clients/share_token/src/index" +import { contractErrorCode, type ErrorTable } from "../test/contractErrorCode" import { governanceReadsKey } from "./pendingAdmins" import { deferred, investorAddress, renderWithWallet } from "./testSupport" import { adminAuthorityKey, type Grant } from "./useAdminAuthority" @@ -174,9 +179,14 @@ type Case = { arg: unknown args?: object keys: readonly (readonly string[])[] - refusal: number + refusal: { code: number; name: string } } +const refused = (errors: ErrorTable, name: string) => ({ + code: contractErrorCode(errors, name), + name, +}) + const cases: Case[] = [ { name: "usePauseVault", @@ -185,7 +195,7 @@ const cases: Case[] = [ arg: undefined, args: { caller: investorAddress }, keys: [vaultPausedKey], - refusal: 1000, + refusal: refused(VaultClient.PausableError, "EnforcedPause"), }, { name: "useUnpauseVault", @@ -194,7 +204,7 @@ const cases: Case[] = [ arg: undefined, args: { caller: investorAddress }, keys: [vaultPausedKey], - refusal: 1001, + refusal: refused(VaultClient.PausableError, "ExpectedPause"), }, { name: "usePauseToken", @@ -203,7 +213,7 @@ const cases: Case[] = [ arg: undefined, args: { caller: investorAddress }, keys: [tokenPausedKey], - refusal: 1000, + refusal: refused(ShareTokenClient.PausableError, "EnforcedPause"), }, { name: "useUnpauseToken", @@ -212,7 +222,7 @@ const cases: Case[] = [ arg: undefined, args: { caller: investorAddress }, keys: [tokenPausedKey], - refusal: 1001, + refusal: refused(ShareTokenClient.PausableError, "ExpectedPause"), }, { name: "usePauseNav", @@ -221,7 +231,7 @@ const cases: Case[] = [ arg: undefined, args: { caller: investorAddress }, keys: oracleWriteKeys, - refusal: 2000, + refusal: refused(OracleClient.AccessControlError, "Unauthorized"), }, { name: "useClearLatest", @@ -229,7 +239,7 @@ const cases: Case[] = [ method: oracle.clear_latest, arg: undefined, keys: oracleWriteKeys, - refusal: 3008, + refusal: refused(OracleClient.OracleError, "NavNotPaused"), }, { name: "useSetNavPaused", @@ -238,7 +248,7 @@ const cases: Case[] = [ arg: false, args: { paused: false }, keys: oracleWriteKeys, - refusal: 2000, + refusal: refused(OracleClient.AccessControlError, "Unauthorized"), }, { name: "useSetDepositCap", @@ -247,7 +257,7 @@ const cases: Case[] = [ arg: null, args: { cap: null }, keys: [cycleReadsKey], - refusal: 6007, + refusal: refused(VaultClient.VaultError, "InvalidAmount"), }, { name: "useSetCustodian", @@ -256,7 +266,7 @@ const cases: Case[] = [ arg: CONTRACT, args: { custodian: CONTRACT }, keys: [vaultAuthoritiesKey], - refusal: 2000, + refusal: refused(VaultClient.AccessControlError, "Unauthorized"), }, { name: "useSetNotice", @@ -265,7 +275,7 @@ const cases: Case[] = [ arg: 86_400n, args: { secs: 86_400n }, keys: [cycleReadsKey], - refusal: 6061, + refusal: refused(VaultClient.VaultError, "NoticeAboveUpgradeDelay"), }, { name: "useSetWindDownDelay", @@ -274,7 +284,7 @@ const cases: Case[] = [ arg: 86_400n, args: { secs: 86_400n }, keys: [cycleReadsKey], - refusal: 6053, + refusal: refused(VaultClient.VaultError, "WindDownDelayTooLong"), }, { name: "useProposeCustodian", @@ -283,7 +293,7 @@ const cases: Case[] = [ arg: CONTRACT, args: { change: { tag: "Custodian", values: [CONTRACT] } }, keys: [pendingChangesKey("vault"), vaultAuthoritiesKey], - refusal: 6075, + refusal: refused(VaultClient.VaultError, "InvalidCustodian"), }, { name: "useProposeNotice", @@ -292,7 +302,7 @@ const cases: Case[] = [ arg: 43_200n, args: { change: { tag: "Notice", values: [43_200n] } }, keys: [pendingChangesKey("vault"), cycleReadsKey], - refusal: 6061, + refusal: refused(VaultClient.VaultError, "NoticeAboveUpgradeDelay"), }, { name: "useProposeWindDownDelay", @@ -301,7 +311,7 @@ const cases: Case[] = [ arg: 691_200n, args: { change: { tag: "WindDownDelay", values: [691_200n] } }, keys: [pendingChangesKey("vault"), cycleReadsKey], - refusal: 6069, + refusal: refused(VaultClient.VaultError, "WindDownDelayTooShort"), }, { name: "useSetOracleConfig", @@ -310,7 +320,7 @@ const cases: Case[] = [ arg: ORACLE_CONFIG, args: { config: ORACLE_CONFIG }, keys: oracleWriteKeys, - refusal: 3001, + refusal: refused(OracleClient.OracleError, "InvalidConfig"), }, { name: "useSetCompliance", @@ -319,7 +329,7 @@ const cases: Case[] = [ arg: CONTRACT, args: { compliance: CONTRACT, operator: investorAddress }, keys: [configurationReadsKey, isAllowedKey], - refusal: 2000, + refusal: refused(ShareTokenClient.AccessControlError, "Unauthorized"), }, { name: "useSetIdentityVerifier", @@ -328,7 +338,7 @@ const cases: Case[] = [ arg: CONTRACT, args: { identity_verifier: CONTRACT, operator: investorAddress }, keys: [configurationReadsKey, adminAuthorityKey, isAllowedKey], - refusal: 2000, + refusal: refused(ShareTokenClient.AccessControlError, "Unauthorized"), }, { name: "useProposeCompliance", @@ -337,7 +347,7 @@ const cases: Case[] = [ arg: CONTRACT, args: { change: { tag: "Compliance", values: [CONTRACT] } }, keys: [pendingChangesKey("token"), configurationReadsKey], - refusal: 7004, + refusal: refused(ShareTokenClient.ShareTokenError, "SetupOpen"), }, { name: "useProposeIdentityVerifier", @@ -346,7 +356,7 @@ const cases: Case[] = [ arg: CONTRACT, args: { change: { tag: "IdentityVerifier", values: [CONTRACT] } }, keys: [pendingChangesKey("token"), configurationReadsKey], - refusal: 7003, + refusal: refused(ShareTokenClient.ShareTokenError, "TooManyPendingChanges"), }, ] @@ -362,6 +372,7 @@ const governedContracts: { hooks: Record<"grant" | "revoke" | "handOver" | "cancel" | "accept", Hook> roleKeys: Case["keys"] handoverKeys: Case["keys"] + errors: Record<"AccessControlError" | "RoleTransferError", ErrorTable> }[] = [ { name: "Vault", @@ -375,6 +386,7 @@ const governedContracts: { }, roleKeys: [adminAuthorityKey, vaultAuthoritiesKey], handoverKeys: [governanceReadsKey, adminAuthorityKey, vaultAuthoritiesKey], + errors: VaultClient, }, { name: "Oracle", @@ -388,6 +400,7 @@ const governedContracts: { }, roleKeys: [adminAuthorityKey], handoverKeys: [governanceReadsKey, adminAuthorityKey], + errors: OracleClient, }, { name: "Token", @@ -401,6 +414,7 @@ const governedContracts: { }, roleKeys: [adminAuthorityKey], handoverKeys: [governanceReadsKey, adminAuthorityKey], + errors: ShareTokenClient, }, ] @@ -412,7 +426,7 @@ const roleArgs = { const governanceCases: Case[] = [ ...governedContracts.flatMap( - ({ name, client, hooks, roleKeys, handoverKeys }): Case[] => [ + ({ name, client, hooks, roleKeys, handoverKeys, errors }): Case[] => [ { name: `useGrant${name}Role`, useHook: hooks.grant, @@ -420,7 +434,7 @@ const governanceCases: Case[] = [ arg: ROLE_CHANGE, args: roleArgs, keys: roleKeys, - refusal: 2000, + refusal: refused(errors.AccessControlError, "Unauthorized"), }, { name: `useRevoke${name}Role`, @@ -429,7 +443,7 @@ const governanceCases: Case[] = [ arg: ROLE_CHANGE, args: roleArgs, keys: roleKeys, - refusal: 2007, + refusal: refused(errors.AccessControlError, "RoleNotHeld"), }, { name: `useHandOver${name}Admin`, @@ -438,7 +452,7 @@ const governanceCases: Case[] = [ arg: OFFER, args: { new_admin: CONTRACT, live_until_ledger: 1_234 }, keys: handoverKeys, - refusal: 2201, + refusal: refused(errors.RoleTransferError, "InvalidLiveUntilLedger"), }, { name: `useCancel${name}Handover`, @@ -447,7 +461,7 @@ const governanceCases: Case[] = [ arg: { ...OFFER, liveUntilLedger: 0 }, args: { new_admin: CONTRACT, live_until_ledger: 0 }, keys: handoverKeys, - refusal: 2200, + refusal: refused(errors.RoleTransferError, "NoPendingTransfer"), }, { name: `useAccept${name}Admin`, @@ -455,7 +469,7 @@ const governanceCases: Case[] = [ method: client.accept_admin_transfer, arg: undefined, keys: handoverKeys, - refusal: 2202, + refusal: refused(errors.RoleTransferError, "InvalidPendingAccount"), }, ], ), @@ -466,7 +480,7 @@ const governanceCases: Case[] = [ arg: WASM_HASH, args: { change: { tag: "Upgrade", values: [Buffer.alloc(32, 0xab)] } }, keys: [pendingChangesKey("vault"), configurationReadsKey], - refusal: 6055, + refusal: refused(VaultClient.VaultError, "UpgradeProposalExists"), }, { name: "useProposeUpgradeDelay", @@ -475,7 +489,7 @@ const governanceCases: Case[] = [ arg: 604_800n, args: { change: { tag: "UpgradeDelay", values: [604_800n] } }, keys: [pendingChangesKey("vault"), configurationReadsKey], - refusal: 6058, + refusal: refused(VaultClient.VaultError, "UpgradeDelayTooShort"), }, { name: "useProposeGrantRole", @@ -484,7 +498,7 @@ const governanceCases: Case[] = [ arg: ROLE_CHANGE, args: { change: { tag: "GrantRole", values: ["guardian", CONTRACT] } }, keys: [pendingChangesKey("vault")], - refusal: 6063, + refusal: refused(VaultClient.VaultError, "TooManyPendingChanges"), }, { name: "useProposeRevokeRole", @@ -493,7 +507,7 @@ const governanceCases: Case[] = [ arg: ROLE_CHANGE, args: { change: { tag: "RevokeRole", values: ["guardian", CONTRACT] } }, keys: [pendingChangesKey("vault")], - refusal: 6064, + refusal: refused(VaultClient.VaultError, "SetupOpen"), }, { name: "useProposeAdmin", @@ -502,7 +516,7 @@ const governanceCases: Case[] = [ arg: CONTRACT, args: { change: { tag: "Admin", values: [CONTRACT] } }, keys: [pendingChangesKey("vault")], - refusal: 6046, + refusal: refused(VaultClient.VaultError, "WindDownActive"), }, { name: "useApplyChange", @@ -518,7 +532,7 @@ const governanceCases: Case[] = [ adminAuthorityKey, vaultAuthoritiesKey, ], - refusal: 6057, + refusal: refused(VaultClient.VaultError, "ChangeNotReady"), }, { name: "useCancelChange", @@ -527,7 +541,7 @@ const governanceCases: Case[] = [ arg: 3, args: { id: 3 }, keys: [pendingChangesKey("vault")], - refusal: 6056, + refusal: refused(VaultClient.VaultError, "ChangeNotFound"), }, { name: "useProposeTokenGrantRole", @@ -536,7 +550,7 @@ const governanceCases: Case[] = [ arg: ROLE_CHANGE, args: { change: { tag: "GrantRole", values: ["guardian", CONTRACT] } }, keys: [pendingChangesKey("token")], - refusal: 7003, + refusal: refused(ShareTokenClient.ShareTokenError, "TooManyPendingChanges"), }, { name: "useProposeTokenRevokeRole", @@ -545,7 +559,7 @@ const governanceCases: Case[] = [ arg: ROLE_CHANGE, args: { change: { tag: "RevokeRole", values: ["guardian", CONTRACT] } }, keys: [pendingChangesKey("token")], - refusal: 7008, + refusal: refused(ShareTokenClient.ShareTokenError, "WindDownActive"), }, { name: "useProposeTokenAdmin", @@ -554,7 +568,7 @@ const governanceCases: Case[] = [ arg: CONTRACT, args: { change: { tag: "Admin", values: [CONTRACT] } }, keys: [pendingChangesKey("token")], - refusal: 7004, + refusal: refused(ShareTokenClient.ShareTokenError, "SetupOpen"), }, { name: "useApplyTokenChange", @@ -569,7 +583,7 @@ const governanceCases: Case[] = [ adminAuthorityKey, isAllowedKey, ], - refusal: 7009, + refusal: refused(ShareTokenClient.ShareTokenError, "VaultPaused"), }, { name: "useCancelTokenChange", @@ -578,7 +592,7 @@ const governanceCases: Case[] = [ arg: 3, args: { id: 3 }, keys: [pendingChangesKey("token")], - refusal: 7001, + refusal: refused(ShareTokenClient.ShareTokenError, "ChangeNotFound"), }, { name: "useProposeWindDown", @@ -586,7 +600,7 @@ const governanceCases: Case[] = [ method: vault.propose_wind_down, arg: undefined, keys: [cycleReadsKey], - refusal: 6046, + refusal: refused(VaultClient.VaultError, "WindDownActive"), }, { name: "useCancelWindDown", @@ -594,7 +608,7 @@ const governanceCases: Case[] = [ method: vault.cancel_wind_down_proposal, arg: undefined, keys: [cycleReadsKey], - refusal: 6047, + refusal: refused(VaultClient.VaultError, "WindDownNotProposed"), }, ] @@ -618,7 +632,7 @@ const complianceCases: Case[] = [ arg: { verifier: VERIFIER, account: TARGET }, args: { account: TARGET, allowed: true }, keys: [complianceReadsKey, isAllowedKey], - refusal: 2000, + refusal: refused(VerifierClient.AccessControlError, "Unauthorized"), }, { name: "useDisallowAddress", @@ -627,7 +641,7 @@ const complianceCases: Case[] = [ arg: { verifier: VERIFIER, account: TARGET }, args: { account: TARGET, allowed: false }, keys: [complianceReadsKey, isAllowedKey], - refusal: 2001, + refusal: refused(VerifierClient.AccessControlError, "AdminNotSet"), }, { name: "useFreezeAddress", @@ -636,7 +650,7 @@ const complianceCases: Case[] = [ arg: TARGET, args: { user_address: TARGET, freeze: true, operator: investorAddress }, keys: [complianceReadsKey], - refusal: 2000, + refusal: refused(ShareTokenClient.AccessControlError, "Unauthorized"), }, { name: "useUnfreezeAddress", @@ -645,7 +659,7 @@ const complianceCases: Case[] = [ arg: TARGET, args: { user_address: TARGET, freeze: false, operator: investorAddress }, keys: [complianceReadsKey], - refusal: 2000, + refusal: refused(ShareTokenClient.AccessControlError, "Unauthorized"), }, { name: "useFreezeShares", @@ -654,7 +668,7 @@ const complianceCases: Case[] = [ arg: { account: TARGET, amount: 5n }, args: { user_address: TARGET, amount: 5n, operator: investorAddress }, keys: [complianceReadsKey], - refusal: 300, + refusal: refused(ShareTokenClient.RWAError, "InsufficientBalance"), }, { name: "useUnfreezeShares", @@ -663,7 +677,7 @@ const complianceCases: Case[] = [ arg: { account: TARGET, amount: 5n }, args: { user_address: TARGET, amount: 5n, operator: investorAddress }, keys: [complianceReadsKey], - refusal: 303, + refusal: refused(ShareTokenClient.RWAError, "InsufficientFreeTokens"), }, { name: "useLegalTransfer", @@ -678,7 +692,7 @@ const complianceCases: Case[] = [ caller: investorAddress, }, keys: [complianceReadsKey, ["share", "balance"], legalTransfersKey], - refusal: 300, + refusal: refused(ShareTokenClient.RWAError, "InsufficientBalance"), }, ] @@ -728,7 +742,9 @@ describe.each([...cases, ...governanceCases, ...complianceCases])( it("refuses before asking for a signature and refreshes the state it read", async () => { const signAndSend = vi.fn() method.mockResolvedValue({ - simulation: { error: `HostError: Error(Contract, #${refusal})` }, + simulation: { + error: `HostError: Error(Contract, #${refusal.code})`, + }, signAndSend, }) const { result, queryClient } = renderWithWallet(useHook) @@ -738,7 +754,7 @@ describe.each([...cases, ...governanceCases, ...complianceCases])( expect(result.current.status).toEqual({ status: "failed", - failure: { kind: "contract-error", code: refusal }, + failure: { kind: "contract-error", ...refusal }, }) expect(signAndSend).not.toHaveBeenCalled() for (const queryKey of keys) diff --git a/app/src/hooks/useAttest.test.ts b/app/src/hooks/useAttest.test.ts index 65cd994c..b25a7161 100644 --- a/app/src/hooks/useAttest.test.ts +++ b/app/src/hooks/useAttest.test.ts @@ -3,6 +3,8 @@ 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 { OracleError } from "../../../app-lib/clients/nav_oracle/src/index" +import { contractErrorCode, simulationError } from "../test/contractErrorCode" import { deferred, investorAddress, renderWithWallet } from "./testSupport" import { attestWriteKeys, useAttest } from "./useAttest" import { investorRequestsKey } from "./useInvestorRequests" @@ -105,7 +107,7 @@ describe("useAttest", () => { it("refuses before ever asking for a signature when the simulation carries the oracle's reason", async () => { const signAndSend = vi.fn() oracleMock.attest.mockResolvedValue({ - simulation: { error: "HostError: Error(Contract, #3004)" }, + simulation: { error: simulationError(OracleError, "DeviationExceeded") }, signAndSend, }) const { result } = renderAttest() @@ -114,7 +116,11 @@ describe("useAttest", () => { expect(result.current.status).toEqual({ status: "failed", - failure: { kind: "contract-error", code: 3004 }, + failure: { + kind: "contract-error", + code: contractErrorCode(OracleError, "DeviationExceeded"), + name: "DeviationExceeded", + }, }) expect(signAndSend).not.toHaveBeenCalled() }) @@ -183,7 +189,7 @@ describe("useAttest", () => { it("refreshes the cached cycle when the oracle refuses at simulation", async () => { oracleMock.attest.mockResolvedValue({ - simulation: { error: "HostError: Error(Contract, #3002)" }, + simulation: { error: simulationError(OracleError, "NavOutOfBand") }, signAndSend: vi.fn(), }) const { result, queryClient } = renderAttest() diff --git a/app/src/hooks/useCancelDeposit.test.ts b/app/src/hooks/useCancelDeposit.test.ts index 8d2884f5..c4268a6c 100644 --- a/app/src/hooks/useCancelDeposit.test.ts +++ b/app/src/hooks/useCancelDeposit.test.ts @@ -1,6 +1,8 @@ import { AssembledTransaction } from "@stellar/stellar-sdk/contract" import { act, waitFor } from "@testing-library/react" import { beforeEach, describe, expect, it, vi } from "vitest" +import { VaultError } from "../../../app-lib/clients/async_vault/src/index" +import { contractErrorCode, simulationError } from "../test/contractErrorCode" import { deferred, investorAddress, renderWithWallet } from "./testSupport" import { useCancelDeposit } from "./useCancelDeposit" import { depositBalanceKey } from "./useDepositBalance" @@ -108,7 +110,7 @@ describe("useCancelDeposit", () => { it("does not touch cached requests or balance when the vault refuses at simulation", async () => { vaultMock.cancel_deposit.mockResolvedValue({ - simulation: { error: "HostError: Error(Contract, #6039)" }, + simulation: { error: simulationError(VaultError, "AlreadyPriced") }, signAndSend: vi.fn(), }) const { result, queryClient } = renderCancelDeposit() @@ -122,7 +124,7 @@ describe("useCancelDeposit", () => { it("refuses before ever asking for a signature when the simulation carries the vault's reason", async () => { const signAndSend = vi.fn() vaultMock.cancel_deposit.mockResolvedValue({ - simulation: { error: "HostError: Error(Contract, #6039)" }, + simulation: { error: simulationError(VaultError, "AlreadyPriced") }, signAndSend, }) const { result } = renderCancelDeposit() @@ -131,7 +133,11 @@ describe("useCancelDeposit", () => { expect(result.current.status).toEqual({ status: "failed", - failure: { kind: "contract-error", code: 6039 }, + failure: { + kind: "contract-error", + code: contractErrorCode(VaultError, "AlreadyPriced"), + name: "AlreadyPriced", + }, }) expect(signAndSend).not.toHaveBeenCalled() }) diff --git a/app/src/hooks/useCancelRedeem.test.ts b/app/src/hooks/useCancelRedeem.test.ts index 7b41d9d8..5a509170 100644 --- a/app/src/hooks/useCancelRedeem.test.ts +++ b/app/src/hooks/useCancelRedeem.test.ts @@ -2,6 +2,9 @@ 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 { VaultError } from "../../../app-lib/clients/async_vault/src/index" +import { RWAError } from "../../../app-lib/clients/identity_verifier/src/index" +import { contractErrorCode, simulationError } from "../test/contractErrorCode" import { contractEvent, deferred, @@ -165,7 +168,7 @@ describe("useCancelRedeem", () => { it("does not touch cached requests or share position when the vault refuses at simulation", async () => { vaultMock.cancel_redeem.mockResolvedValue({ - simulation: { error: "HostError: Error(Contract, #6039)" }, + simulation: { error: simulationError(VaultError, "AlreadyPriced") }, signAndSend: vi.fn(), }) const { result, queryClient } = renderCancelRedeem() @@ -179,7 +182,7 @@ describe("useCancelRedeem", () => { it("refuses before ever asking for a signature when the simulation carries the vault's reason", async () => { const signAndSend = vi.fn() vaultMock.cancel_redeem.mockResolvedValue({ - simulation: { error: "HostError: Error(Contract, #6039)" }, + simulation: { error: simulationError(VaultError, "AlreadyPriced") }, signAndSend, }) const { result } = renderCancelRedeem() @@ -188,7 +191,11 @@ describe("useCancelRedeem", () => { expect(result.current.status).toEqual({ status: "failed", - failure: { kind: "contract-error", code: 6039 }, + failure: { + kind: "contract-error", + code: contractErrorCode(VaultError, "AlreadyPriced"), + name: "AlreadyPriced", + }, }) expect(signAndSend).not.toHaveBeenCalled() }) @@ -196,7 +203,9 @@ describe("useCancelRedeem", () => { it("refuses at simulation when a delisted controller cannot receive its shares back", async () => { const signAndSend = vi.fn() vaultMock.cancel_redeem.mockResolvedValue({ - simulation: { error: "HostError: Error(Contract, #304)" }, + simulation: { + error: simulationError(RWAError, "IdentityVerificationFailed"), + }, signAndSend, }) const { result } = renderCancelRedeem() @@ -205,7 +214,11 @@ describe("useCancelRedeem", () => { expect(result.current.status).toEqual({ status: "failed", - failure: { kind: "contract-error", code: 304 }, + failure: { + kind: "contract-error", + code: contractErrorCode(RWAError, "IdentityVerificationFailed"), + name: "IdentityVerificationFailed", + }, }) expect(signAndSend).not.toHaveBeenCalled() }) diff --git a/app/src/hooks/useClaimDeposit.test.ts b/app/src/hooks/useClaimDeposit.test.ts index f519f16f..42f401ce 100644 --- a/app/src/hooks/useClaimDeposit.test.ts +++ b/app/src/hooks/useClaimDeposit.test.ts @@ -2,6 +2,9 @@ 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 { VaultError } from "../../../app-lib/clients/async_vault/src/index" +import { RWAError } from "../../../app-lib/clients/identity_verifier/src/index" +import { contractErrorCode, simulationError } from "../test/contractErrorCode" import { contractEvent, deferred, @@ -193,7 +196,7 @@ describe("useClaimDeposit", () => { it("does not touch cached requests, balance or share position when the vault refuses at simulation", async () => { vaultMock.claim_deposit.mockResolvedValue({ - simulation: { error: "HostError: Error(Contract, #6035)" }, + simulation: { error: simulationError(VaultError, "AlreadyClaimed") }, signAndSend: vi.fn(), }) const { result, queryClient } = renderClaimDeposit() @@ -207,7 +210,7 @@ describe("useClaimDeposit", () => { it("refuses before ever asking for a signature when the simulation carries the vault's reason", async () => { const signAndSend = vi.fn() vaultMock.claim_deposit.mockResolvedValue({ - simulation: { error: "HostError: Error(Contract, #6035)" }, + simulation: { error: simulationError(VaultError, "AlreadyClaimed") }, signAndSend, }) const { result } = renderClaimDeposit() @@ -216,14 +219,20 @@ describe("useClaimDeposit", () => { expect(result.current.status).toEqual({ status: "failed", - failure: { kind: "contract-error", code: 6035 }, + failure: { + kind: "contract-error", + code: contractErrorCode(VaultError, "AlreadyClaimed"), + name: "AlreadyClaimed", + }, }) expect(signAndSend).not.toHaveBeenCalled() }) it("refuses at simulation with the identity verifier's cross-contract code", async () => { vaultMock.claim_deposit.mockResolvedValue({ - simulation: { error: "HostError: Error(Contract, #304)" }, + simulation: { + error: simulationError(RWAError, "IdentityVerificationFailed"), + }, signAndSend: vi.fn(), }) const { result } = renderClaimDeposit() @@ -232,7 +241,11 @@ describe("useClaimDeposit", () => { expect(result.current.status).toEqual({ status: "failed", - failure: { kind: "contract-error", code: 304 }, + failure: { + kind: "contract-error", + code: contractErrorCode(RWAError, "IdentityVerificationFailed"), + name: "IdentityVerificationFailed", + }, }) }) diff --git a/app/src/hooks/useClaimRedeem.test.ts b/app/src/hooks/useClaimRedeem.test.ts index 7e6f1b63..7c007a8a 100644 --- a/app/src/hooks/useClaimRedeem.test.ts +++ b/app/src/hooks/useClaimRedeem.test.ts @@ -2,6 +2,9 @@ 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 { VaultError } from "../../../app-lib/clients/async_vault/src/index" +import { RWAError } from "../../../app-lib/clients/identity_verifier/src/index" +import { contractErrorCode, simulationError } from "../test/contractErrorCode" import { contractEvent, deferred, @@ -163,7 +166,7 @@ describe("useClaimRedeem", () => { it("does not touch cached requests, balance or share position when the vault refuses at simulation", async () => { vaultMock.claim_redeem.mockResolvedValue({ - simulation: { error: "HostError: Error(Contract, #6035)" }, + simulation: { error: simulationError(VaultError, "AlreadyClaimed") }, signAndSend: vi.fn(), }) const { result, queryClient } = renderClaimRedeem() @@ -177,7 +180,7 @@ describe("useClaimRedeem", () => { it("refuses before ever asking for a signature when the simulation carries the vault's reason", async () => { const signAndSend = vi.fn() vaultMock.claim_redeem.mockResolvedValue({ - simulation: { error: "HostError: Error(Contract, #6035)" }, + simulation: { error: simulationError(VaultError, "AlreadyClaimed") }, signAndSend, }) const { result } = renderClaimRedeem() @@ -186,14 +189,20 @@ describe("useClaimRedeem", () => { expect(result.current.status).toEqual({ status: "failed", - failure: { kind: "contract-error", code: 6035 }, + failure: { + kind: "contract-error", + code: contractErrorCode(VaultError, "AlreadyClaimed"), + name: "AlreadyClaimed", + }, }) expect(signAndSend).not.toHaveBeenCalled() }) it("refuses at simulation with the identity verifier's cross-contract code", async () => { vaultMock.claim_redeem.mockResolvedValue({ - simulation: { error: "HostError: Error(Contract, #304)" }, + simulation: { + error: simulationError(RWAError, "IdentityVerificationFailed"), + }, signAndSend: vi.fn(), }) const { result } = renderClaimRedeem() @@ -202,7 +211,11 @@ describe("useClaimRedeem", () => { expect(result.current.status).toEqual({ status: "failed", - failure: { kind: "contract-error", code: 304 }, + failure: { + kind: "contract-error", + code: contractErrorCode(RWAError, "IdentityVerificationFailed"), + name: "IdentityVerificationFailed", + }, }) }) diff --git a/app/src/hooks/useClaimWindDown.test.ts b/app/src/hooks/useClaimWindDown.test.ts index 4f94ed60..e353ce0f 100644 --- a/app/src/hooks/useClaimWindDown.test.ts +++ b/app/src/hooks/useClaimWindDown.test.ts @@ -1,6 +1,8 @@ import { nativeToScVal } from "@stellar/stellar-sdk" import { act } from "@testing-library/react" import { beforeEach, describe, expect, it, vi } from "vitest" +import { VaultError } from "../../../app-lib/clients/async_vault/src/index" +import { contractErrorCode, simulationError } from "../test/contractErrorCode" import { contractEvent, investorAddress, @@ -91,7 +93,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)" }, + simulation: { error: simulationError(VaultError, "Frozen") }, }) const { result } = renderWithWallet(useClaimWindDown) @@ -99,7 +101,11 @@ describe("useClaimWindDown", () => { expect(result.current.status).toEqual({ status: "failed", - failure: { kind: "contract-error", code: 6071 }, + failure: { + kind: "contract-error", + code: contractErrorCode(VaultError, "Frozen"), + name: "Frozen", + }, }) }) }) diff --git a/app/src/hooks/useCloseEpoch.test.ts b/app/src/hooks/useCloseEpoch.test.ts index 4691e904..d148b2f2 100644 --- a/app/src/hooks/useCloseEpoch.test.ts +++ b/app/src/hooks/useCloseEpoch.test.ts @@ -1,6 +1,8 @@ import { AssembledTransaction } from "@stellar/stellar-sdk/contract" import { act, waitFor } from "@testing-library/react" import { beforeEach, describe, expect, it, vi } from "vitest" +import { VaultError } from "../../../app-lib/clients/async_vault/src/index" +import { contractErrorCode, simulationError } from "../test/contractErrorCode" import { deferred, investorAddress, renderWithWallet } from "./testSupport" import { useCloseEpoch } from "./useCloseEpoch" import { cycleWriteKeys } from "./useCycleState" @@ -75,7 +77,7 @@ describe("useCloseEpoch", () => { it("refuses before ever asking for a signature when the simulation carries the vault's reason", async () => { const signAndSend = vi.fn() vaultMock.close_epoch.mockResolvedValue({ - simulation: { error: "HostError: Error(Contract, #6032)" }, + simulation: { error: simulationError(VaultError, "EpochNotOpen") }, signAndSend, }) const { result } = renderCloseEpoch() @@ -84,7 +86,11 @@ describe("useCloseEpoch", () => { expect(result.current.status).toEqual({ status: "failed", - failure: { kind: "contract-error", code: 6032 }, + failure: { + kind: "contract-error", + code: contractErrorCode(VaultError, "EpochNotOpen"), + name: "EpochNotOpen", + }, }) expect(signAndSend).not.toHaveBeenCalled() }) @@ -150,7 +156,7 @@ describe("useCloseEpoch", () => { it("refreshes the cached cycle when the vault refuses at simulation", async () => { vaultMock.close_epoch.mockResolvedValue({ - simulation: { error: "HostError: Error(Contract, #6046)" }, + simulation: { error: simulationError(VaultError, "WindDownActive") }, signAndSend: vi.fn(), }) const { result, queryClient } = renderCloseEpoch() diff --git a/app/src/hooks/useComplianceState.test.ts b/app/src/hooks/useComplianceState.test.ts index a341b774..7cd21d36 100644 --- a/app/src/hooks/useComplianceState.test.ts +++ b/app/src/hooks/useComplianceState.test.ts @@ -1,5 +1,7 @@ import { waitFor } from "@testing-library/react" import { beforeEach, describe, expect, it, vi } from "vitest" +import { RWAError } from "../../../app-lib/clients/share_token/src/index" +import { simulationError } from "../test/contractErrorCode" import { renderWithWallet } from "./testSupport" import { useComplianceState } from "./useComplianceState" @@ -80,7 +82,7 @@ describe("useComplianceState", () => { tokenMock.is_frozen.mockRejectedValue(new Error("rpc down")) tokenMock.balance.mockResolvedValue({ result: 0n, - simulation: { error: "HostError: Error(Contract, #300)" }, + simulation: { error: simulationError(RWAError, "InsufficientBalance") }, }) const { result } = renderWithWallet(() => useComplianceState({ diff --git a/app/src/hooks/useContractTransaction.test.ts b/app/src/hooks/useContractTransaction.test.ts index 36783aa0..8b645c8a 100644 --- a/app/src/hooks/useContractTransaction.test.ts +++ b/app/src/hooks/useContractTransaction.test.ts @@ -4,6 +4,8 @@ import type * as AppLib from "@stellar-scaffold/app-lib" import { type TransactionEvent } from "@stellar-scaffold/app-lib" import { act, waitFor } from "@testing-library/react" import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" +import { VaultError } from "../../../app-lib/clients/async_vault/src/index" +import { simulationError } from "../test/contractErrorCode" import { investorAddress, renderWithWallet } from "./testSupport" import { useContractTransaction } from "./useContractTransaction" import { investorRequestsKey } from "./useInvestorRequests" @@ -104,7 +106,7 @@ describe("useContractTransaction", () => { it("refreshes only the extra query keys when the contract refuses at simulation", async () => { const { result, queryClient } = renderTransaction( - "HostError: Error(Contract, #6046)", + simulationError(VaultError, "WindDownActive"), ) const invalidateQueries = vi.spyOn(queryClient, "invalidateQueries") @@ -399,7 +401,9 @@ describe("useContractTransaction, prepared simulations", () => { it("does not keep a background simulation the contract refused", async () => { const call = vi .fn() - .mockResolvedValueOnce(transaction("HostError: Error(Contract, #6046)")) + .mockResolvedValueOnce( + transaction(simulationError(VaultError, "WindDownActive")), + ) .mockResolvedValueOnce(transaction()) const { result } = renderPrepared(call) diff --git a/app/src/hooks/useContractTransaction.ts b/app/src/hooks/useContractTransaction.ts index f6eb4add..b1ccfc6f 100644 --- a/app/src/hooks/useContractTransaction.ts +++ b/app/src/hooks/useContractTransaction.ts @@ -5,7 +5,8 @@ import { decodeTransactionEvents, isUserRejection, OPERATOR_TIMEOUT_SECONDS, - parseErrorCode, + type ContractError, + parseContractError, type SubmitFailure, submitSigned, type TransactionEvent, @@ -34,7 +35,7 @@ export type TransactionFailure = | { kind: "declined" } | { kind: "unsigned" } | { kind: "cannot-authorize" } - | { kind: "contract-error"; code: number } + | ({ kind: "contract-error" } & ContractError) | { kind: "interrupted" } | { kind: "network"; reason: SubmitFailure } | { kind: "stale" } @@ -203,7 +204,7 @@ function usePrepared( (tx) => { if (!latest()) return simulating.current = null - if (parseErrorCode(tx.simulation) !== null) return + if (parseContractError(tx.simulation) !== null) return simulated.current = { key, tx, at } expiry.current = setTimeout( () => refresh.current(), @@ -413,11 +414,11 @@ export function useContractTransaction< try { const tx = reused ?? (await call(signerFor(owner, grant, signTransaction), arg)) - const code = parseErrorCode(tx.simulation) - if (code !== null) { + const error = parseContractError(tx.simulation) + if (error !== null) { applyStatus({ status: "failed", - failure: { kind: "contract-error", code }, + failure: { kind: "contract-error", ...error }, }) invalidate(extraKeys) return diff --git a/app/src/hooks/useCycleState.test.ts b/app/src/hooks/useCycleState.test.ts index ed89cc20..ca0dfdcf 100644 --- a/app/src/hooks/useCycleState.test.ts +++ b/app/src/hooks/useCycleState.test.ts @@ -10,7 +10,9 @@ import { } from "@stellar-scaffold/app-lib" import { waitFor } from "@testing-library/react" import { beforeEach, describe, expect, it, vi } from "vitest" +import { OracleError } from "../../../app-lib/clients/nav_oracle/src/index" import { addresses } from "../config/addresses" +import { contractErrorCode } from "../test/contractErrorCode" import { renderWithWallet } from "./testSupport" import { toEpoch, @@ -129,7 +131,11 @@ describe("toOracle", () => { it("has no price when nothing was ever attested", () => { const oracle = toOracle( state("Stale"), - { kind: "contract-error", code: 3006 }, + { + kind: "contract-error", + code: contractErrorCode(OracleError, "NoRecord"), + name: "NoRecord", + }, value(config), ) @@ -145,7 +151,11 @@ describe("toOracle", () => { it("knows there is no record even while the NAV is paused", () => { const oracle = toOracle( state("Paused"), - { kind: "contract-error", code: 3006 }, + { + kind: "contract-error", + code: contractErrorCode(OracleError, "NoRecord"), + name: "NoRecord", + }, value(config), ) diff --git a/app/src/hooks/useCycleState.ts b/app/src/hooks/useCycleState.ts index 6165932a..a47ba5fb 100644 --- a/app/src/hooks/useCycleState.ts +++ b/app/src/hooks/useCycleState.ts @@ -119,11 +119,9 @@ export type CycleState = const toAmount = (read: ContractRead): Amount | null => read.kind === "value" ? (read.value as Amount) : null -const NO_RECORD_ERROR = 3006 - const recordedOf = (latest: ContractRead): boolean | null => { if (latest.kind === "value") return true - return latest.kind === "contract-error" && latest.code === NO_RECORD_ERROR + return latest.kind === "contract-error" && latest.name === "NoRecord" ? false : null } diff --git a/app/src/hooks/useDeployToCustodian.test.ts b/app/src/hooks/useDeployToCustodian.test.ts index 762b23ec..0080a84c 100644 --- a/app/src/hooks/useDeployToCustodian.test.ts +++ b/app/src/hooks/useDeployToCustodian.test.ts @@ -2,6 +2,8 @@ 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 { VaultError } from "../../../app-lib/clients/async_vault/src/index" +import { contractErrorCode, simulationError } from "../test/contractErrorCode" import { deferred, investorAddress, renderWithWallet } from "./testSupport" import { cycleWriteKeys } from "./useCycleState" import { useDeployToCustodian } from "./useDeployToCustodian" @@ -78,7 +80,9 @@ describe("useDeployToCustodian", () => { it("refuses before ever asking for a signature when the simulation carries the vault's reason", async () => { const signAndSend = vi.fn() vaultMock.deploy_to_custodian.mockResolvedValue({ - simulation: { error: "HostError: Error(Contract, #6005)" }, + simulation: { + error: simulationError(VaultError, "ReserveCommittedToExits"), + }, signAndSend, }) const { result } = renderDeploy() @@ -87,7 +91,11 @@ describe("useDeployToCustodian", () => { expect(result.current.status).toEqual({ status: "failed", - failure: { kind: "contract-error", code: 6005 }, + failure: { + kind: "contract-error", + code: contractErrorCode(VaultError, "ReserveCommittedToExits"), + name: "ReserveCommittedToExits", + }, }) expect(signAndSend).not.toHaveBeenCalled() }) @@ -165,7 +173,7 @@ describe("useDeployToCustodian", () => { it("refreshes the cached cycle when the vault refuses at simulation", async () => { vaultMock.deploy_to_custodian.mockResolvedValue({ - simulation: { error: "HostError: Error(Contract, #6012)" }, + simulation: { error: simulationError(VaultError, "CustodianNotSet") }, signAndSend: vi.fn(), }) const { result, queryClient } = renderDeploy() diff --git a/app/src/hooks/useDepositBalance.test.ts b/app/src/hooks/useDepositBalance.test.ts index 74033d22..dae77e59 100644 --- a/app/src/hooks/useDepositBalance.test.ts +++ b/app/src/hooks/useDepositBalance.test.ts @@ -13,6 +13,7 @@ const held: ContractRead = { kind: "value", value: 500_0000000n } const contractError: ContractRead = { kind: "contract-error", code: 1, + name: undefined, } const unreadable: ContractRead = { kind: "unreadable" } diff --git a/app/src/hooks/useFinalizeWindDownRound.test.ts b/app/src/hooks/useFinalizeWindDownRound.test.ts index 90e647fd..b99a347e 100644 --- a/app/src/hooks/useFinalizeWindDownRound.test.ts +++ b/app/src/hooks/useFinalizeWindDownRound.test.ts @@ -1,6 +1,8 @@ import { AssembledTransaction } from "@stellar/stellar-sdk/contract" import { act, waitFor } from "@testing-library/react" import { beforeEach, describe, expect, it, vi } from "vitest" +import { VaultError } from "../../../app-lib/clients/async_vault/src/index" +import { contractErrorCode, simulationError } from "../test/contractErrorCode" import { deferred, renderWithWallet } from "./testSupport" import { cycleWriteKeys } from "./useCycleState" import { useFinalizeWindDownRound } from "./useFinalizeWindDownRound" @@ -78,7 +80,7 @@ describe("useFinalizeWindDownRound", () => { it("refuses before ever asking for a signature when the simulation carries the vault's reason", async () => { const signAndSend = vi.fn() vaultMock.finalize_wind_down_round.mockResolvedValue({ - simulation: { error: "HostError: Error(Contract, #6052)" }, + simulation: { error: simulationError(VaultError, "NothingToDistribute") }, signAndSend, }) const { result } = renderFinalizeWindDownRound() @@ -87,7 +89,11 @@ describe("useFinalizeWindDownRound", () => { expect(result.current.status).toEqual({ status: "failed", - failure: { kind: "contract-error", code: 6052 }, + failure: { + kind: "contract-error", + code: contractErrorCode(VaultError, "NothingToDistribute"), + name: "NothingToDistribute", + }, }) expect(signAndSend).not.toHaveBeenCalled() }) @@ -153,7 +159,7 @@ describe("useFinalizeWindDownRound", () => { it("refreshes the cached cycle when the vault refuses at simulation", async () => { vaultMock.finalize_wind_down_round.mockResolvedValue({ - simulation: { error: "HostError: Error(Contract, #6050)" }, + simulation: { error: simulationError(VaultError, "WindDownNotActive") }, signAndSend: vi.fn(), }) const { result, queryClient } = renderFinalizeWindDownRound() diff --git a/app/src/hooks/useFulfillEpoch.test.ts b/app/src/hooks/useFulfillEpoch.test.ts index c1cb42ce..4043fb8f 100644 --- a/app/src/hooks/useFulfillEpoch.test.ts +++ b/app/src/hooks/useFulfillEpoch.test.ts @@ -1,6 +1,8 @@ import { AssembledTransaction } from "@stellar/stellar-sdk/contract" import { act, waitFor } from "@testing-library/react" import { beforeEach, describe, expect, it, vi } from "vitest" +import { VaultError } from "../../../app-lib/clients/async_vault/src/index" +import { contractErrorCode, simulationError } from "../test/contractErrorCode" import { deferred, renderWithWallet } from "./testSupport" import { cycleWriteKeys } from "./useCycleState" import { useFulfillEpoch } from "./useFulfillEpoch" @@ -80,7 +82,7 @@ describe("useFulfillEpoch", () => { it("refuses before ever asking for a signature when the simulation carries the vault's reason", async () => { const signAndSend = vi.fn() vaultMock.fulfill_epoch.mockResolvedValue({ - simulation: { error: "HostError: Error(Contract, #6042)" }, + simulation: { error: simulationError(VaultError, "NoticeNotElapsed") }, signAndSend, }) const { result } = renderFulfillEpoch() @@ -89,7 +91,11 @@ describe("useFulfillEpoch", () => { expect(result.current.status).toEqual({ status: "failed", - failure: { kind: "contract-error", code: 6042 }, + failure: { + kind: "contract-error", + code: contractErrorCode(VaultError, "NoticeNotElapsed"), + name: "NoticeNotElapsed", + }, }) expect(signAndSend).not.toHaveBeenCalled() }) @@ -155,7 +161,7 @@ describe("useFulfillEpoch", () => { it("refreshes the cached cycle when the vault refuses at simulation", async () => { vaultMock.fulfill_epoch.mockResolvedValue({ - simulation: { error: "HostError: Error(Contract, #6044)" }, + simulation: { error: simulationError(VaultError, "FeedNotValid") }, signAndSend: vi.fn(), }) const { result, queryClient } = renderFulfillEpoch() diff --git a/app/src/hooks/useFund.test.ts b/app/src/hooks/useFund.test.ts index 0b5f1861..1a5e1b19 100644 --- a/app/src/hooks/useFund.test.ts +++ b/app/src/hooks/useFund.test.ts @@ -2,6 +2,8 @@ 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 { VaultError } from "../../../app-lib/clients/async_vault/src/index" +import { contractErrorCode, simulationError } from "../test/contractErrorCode" import { deferred, investorAddress, renderWithWallet } from "./testSupport" import type * as ContractTransactionModule from "./useContractTransaction" import { useContractTransaction } from "./useContractTransaction" @@ -86,7 +88,7 @@ describe("useFund", () => { it("refuses before ever asking for a signature when the simulation carries the vault's reason", async () => { const signAndSend = vi.fn() vaultMock.fund.mockResolvedValue({ - simulation: { error: "HostError: Error(Contract, #6007)" }, + simulation: { error: simulationError(VaultError, "InvalidAmount") }, signAndSend, }) const { result } = renderFund() @@ -95,7 +97,11 @@ describe("useFund", () => { expect(result.current.status).toEqual({ status: "failed", - failure: { kind: "contract-error", code: 6007 }, + failure: { + kind: "contract-error", + code: contractErrorCode(VaultError, "InvalidAmount"), + name: "InvalidAmount", + }, }) expect(signAndSend).not.toHaveBeenCalled() }) @@ -184,7 +190,7 @@ describe("useFund", () => { it("refreshes the cached cycle when the vault refuses at simulation", async () => { vaultMock.fund.mockResolvedValue({ - simulation: { error: "HostError: Error(Contract, #6014)" }, + simulation: { error: simulationError(VaultError, "AmountTooLarge") }, signAndSend: vi.fn(), }) const { result, queryClient } = renderFund() diff --git a/app/src/hooks/useIsAllowed.test.ts b/app/src/hooks/useIsAllowed.test.ts index 02740870..85e030fb 100644 --- a/app/src/hooks/useIsAllowed.test.ts +++ b/app/src/hooks/useIsAllowed.test.ts @@ -26,6 +26,7 @@ const notAllowed: ContractRead = { kind: "value", value: false } const contractError: ContractRead = { kind: "contract-error", code: 1, + name: undefined, } const unreadable: ContractRead = { kind: "unreadable" } diff --git a/app/src/hooks/useNavPrice.test.ts b/app/src/hooks/useNavPrice.test.ts index 0afca360..3d5f55c6 100644 --- a/app/src/hooks/useNavPrice.test.ts +++ b/app/src/hooks/useNavPrice.test.ts @@ -5,6 +5,8 @@ import { type OracleState, } from "@stellar-scaffold/app-lib" import { describe, expect, it, vi } from "vitest" +import { OracleError } from "../../../app-lib/clients/nav_oracle/src/index" +import { contractErrorCode } from "../test/contractErrorCode" import { classifyNav, type NavClassification } from "./useNavPrice" vi.mock("../config/clients", () => ({ @@ -38,7 +40,8 @@ const report: NavReport = { const value: ContractRead = { kind: "value", value: report } const notAttested: ContractRead = { kind: "contract-error", - code: 3006, + code: contractErrorCode(OracleError, "NoRecord"), + name: "NoRecord", } const unreadableLatest: ContractRead = { kind: "unreadable" } diff --git a/app/src/hooks/useNavPrice.ts b/app/src/hooks/useNavPrice.ts index 17fa20a5..1bbcabc6 100644 --- a/app/src/hooks/useNavPrice.ts +++ b/app/src/hooks/useNavPrice.ts @@ -23,7 +23,7 @@ export function classifyNav( if (state.value.tag === "Paused") return { status: "paused" } if (state.value.tag === "Stale") { - if (latest.kind === "contract-error" && latest.code === 3006) + if (latest.kind === "contract-error" && latest.name === "NoRecord") return { status: "never" } return latest.kind === "value" ? { status: "stale", expiresAt: latest.value.expires_at } diff --git a/app/src/hooks/useOracleState.test.ts b/app/src/hooks/useOracleState.test.ts index ecfbe479..dd2de12c 100644 --- a/app/src/hooks/useOracleState.test.ts +++ b/app/src/hooks/useOracleState.test.ts @@ -22,7 +22,11 @@ describe("classifyOracle", () => { ["Valid", tagged("Valid"), "valid"], ["Stale", tagged("Stale"), "stale"], ["Paused", tagged("Paused"), "paused"], - ["a contract error", { kind: "contract-error", code: 1 }, "unreadable"], + [ + "a contract error", + { kind: "contract-error", code: 1, name: undefined }, + "unreadable", + ], ["an unreadable read", { kind: "unreadable" }, "unreadable"], ])("classifies %s as %s", (_name, read, expected) => { expect(classifyOracle(read)).toBe(expected) diff --git a/app/src/hooks/useRequestDeposit.test.ts b/app/src/hooks/useRequestDeposit.test.ts index 63865ea6..80c47e6c 100644 --- a/app/src/hooks/useRequestDeposit.test.ts +++ b/app/src/hooks/useRequestDeposit.test.ts @@ -2,6 +2,8 @@ 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 { VaultError } from "../../../app-lib/clients/async_vault/src/index" +import { contractErrorCode, simulationError } from "../test/contractErrorCode" import { deferred, investorAddress, renderWithWallet } from "./testSupport" import { depositBalanceKey } from "./useDepositBalance" import { investorRequestsKey } from "./useInvestorRequests" @@ -103,7 +105,7 @@ describe("useRequestDeposit", () => { it("does not touch the investor's cached requests or balance on a failed deposit", async () => { vaultMock.request_deposit.mockResolvedValue({ - simulation: { error: "HostError: Error(Contract, #6009)" }, + simulation: { error: simulationError(VaultError, "RequestOutstanding") }, signAndSend: vi.fn(), }) const { result, queryClient } = renderRequestDeposit() @@ -117,7 +119,7 @@ describe("useRequestDeposit", () => { it("refuses before ever asking for a signature when the simulation carries the vault's reason", async () => { const signAndSend = vi.fn() vaultMock.request_deposit.mockResolvedValue({ - simulation: { error: "HostError: Error(Contract, #6009)" }, + simulation: { error: simulationError(VaultError, "RequestOutstanding") }, signAndSend, }) const { result } = renderRequestDeposit() @@ -126,7 +128,11 @@ describe("useRequestDeposit", () => { expect(result.current.status).toEqual({ status: "failed", - failure: { kind: "contract-error", code: 6009 }, + failure: { + kind: "contract-error", + code: contractErrorCode(VaultError, "RequestOutstanding"), + name: "RequestOutstanding", + }, }) expect(signAndSend).not.toHaveBeenCalled() }) diff --git a/app/src/hooks/useRequestRedeem.test.ts b/app/src/hooks/useRequestRedeem.test.ts index 06ad9952..3ce66688 100644 --- a/app/src/hooks/useRequestRedeem.test.ts +++ b/app/src/hooks/useRequestRedeem.test.ts @@ -2,6 +2,8 @@ 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 { VaultError } from "../../../app-lib/clients/async_vault/src/index" +import { contractErrorCode, simulationError } from "../test/contractErrorCode" import { deferred, investorAddress, renderWithWallet } from "./testSupport" import { investorRequestsKey } from "./useInvestorRequests" import { useRequestRedeem } from "./useRequestRedeem" @@ -121,7 +123,7 @@ describe("useRequestRedeem", () => { it("does not touch the investor's cached requests on a failed redemption request", async () => { vaultMock.request_redeem.mockResolvedValue({ - simulation: { error: "HostError: Error(Contract, #6009)" }, + simulation: { error: simulationError(VaultError, "RequestOutstanding") }, signAndSend: vi.fn(), }) const { result, queryClient } = renderRequestRedeem() @@ -135,7 +137,7 @@ describe("useRequestRedeem", () => { it("refuses before ever asking for a signature when the simulation carries the vault's reason", async () => { const signAndSend = vi.fn() vaultMock.request_redeem.mockResolvedValue({ - simulation: { error: "HostError: Error(Contract, #6009)" }, + simulation: { error: simulationError(VaultError, "RequestOutstanding") }, signAndSend, }) const { result } = renderRequestRedeem() @@ -144,7 +146,11 @@ describe("useRequestRedeem", () => { expect(result.current.status).toEqual({ status: "failed", - failure: { kind: "contract-error", code: 6009 }, + failure: { + kind: "contract-error", + code: contractErrorCode(VaultError, "RequestOutstanding"), + name: "RequestOutstanding", + }, }) expect(signAndSend).not.toHaveBeenCalled() }) diff --git a/app/src/hooks/useSharePosition.test.ts b/app/src/hooks/useSharePosition.test.ts index 939f6d4e..81c6cd81 100644 --- a/app/src/hooks/useSharePosition.test.ts +++ b/app/src/hooks/useSharePosition.test.ts @@ -13,6 +13,7 @@ const held: ContractRead = { kind: "value", value: 500_0000000n } const contractError: ContractRead = { kind: "contract-error", code: 1, + name: undefined, } const unreadable: ContractRead = { kind: "unreadable" } diff --git a/app/src/hooks/useSignPending.test.ts b/app/src/hooks/useSignPending.test.ts index c2165c16..ea2fd889 100644 --- a/app/src/hooks/useSignPending.test.ts +++ b/app/src/hooks/useSignPending.test.ts @@ -9,11 +9,13 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query" import { act, renderHook, waitFor } from "@testing-library/react" import { createElement, type ReactNode } from "react" import { beforeEach, describe, expect, it, vi } from "vitest" +import { RWAError } from "../../../app-lib/clients/share_token/src/index" import { addresses } from "../config/addresses" import { WalletContext, type WalletContextType, } from "../providers/WalletProvider" +import { contractErrorCode } from "../test/contractErrorCode" import { useSignPending } from "./useSignPending" const { @@ -699,7 +701,11 @@ describe("useSignPending", () => { ) it("reports the contract's refusal after the admin signs", async () => { - authorizePendingMock.mockResolvedValue({ kind: "refused", code: 300 }) + authorizePendingMock.mockResolvedValue({ + kind: "refused", + code: contractErrorCode(RWAError, "InsufficientBalance"), + name: "InsufficientBalance", + }) const { result } = renderSign(unauthorized, admin) await waitFor(() => expect(result.current.canSign).toBe(true)) @@ -708,7 +714,11 @@ describe("useSignPending", () => { await waitFor(() => expect(result.current.status).toEqual({ status: "failed", - failure: { kind: "contract-error", code: 300 }, + failure: { + kind: "contract-error", + code: contractErrorCode(RWAError, "InsufficientBalance"), + name: "InsufficientBalance", + }, }), ) expect(result.current.xdr).toBe(unauthorized) @@ -793,7 +803,11 @@ describe("useSignPending", () => { }) it("reports the contract's refusal from the first signer's simulation", async () => { - prepareCoSignedMock.mockResolvedValue({ kind: "refused", code: 304 }) + prepareCoSignedMock.mockResolvedValue({ + kind: "refused", + code: contractErrorCode(RWAError, "IdentityVerificationFailed"), + name: "IdentityVerificationFailed", + }) const { result } = renderSign(authorized(), wallet) await waitFor(() => expect(result.current.canSign).toBe(true)) @@ -802,7 +816,11 @@ describe("useSignPending", () => { await waitFor(() => expect(result.current.status).toEqual({ status: "failed", - failure: { kind: "contract-error", code: 304 }, + failure: { + kind: "contract-error", + code: contractErrorCode(RWAError, "IdentityVerificationFailed"), + name: "IdentityVerificationFailed", + }, }), ) expect(walletSigns).not.toHaveBeenCalled() diff --git a/app/src/hooks/useSignPending.ts b/app/src/hooks/useSignPending.ts index 69e21718..37f52a88 100644 --- a/app/src/hooks/useSignPending.ts +++ b/app/src/hooks/useSignPending.ts @@ -337,7 +337,11 @@ export function useSignPending(initialXdr: string): UseSignPending { if (step.kind === "refused") { apply({ status: "failed", - failure: { kind: "contract-error", code: step.code }, + failure: { + kind: "contract-error", + code: step.code, + name: step.name, + }, }) } else if (step.kind === "ready") { replaceXdr(step.xdr) @@ -370,7 +374,11 @@ export function useSignPending(initialXdr: string): UseSignPending { if (prepared.kind === "refused") { apply({ status: "failed", - failure: { kind: "contract-error", code: prepared.code }, + failure: { + kind: "contract-error", + code: prepared.code, + name: prepared.name, + }, }) return } diff --git a/app/src/hooks/useTokenSymbols.test.ts b/app/src/hooks/useTokenSymbols.test.ts index a12b55ee..a787ecd0 100644 --- a/app/src/hooks/useTokenSymbols.test.ts +++ b/app/src/hooks/useTokenSymbols.test.ts @@ -15,7 +15,11 @@ const value = (result: string): ContractRead => ({ value: result, }) const unreadable: ContractRead = { kind: "unreadable" } -const contractError: ContractRead = { kind: "contract-error", code: 1 } +const contractError: ContractRead = { + kind: "contract-error", + code: 1, + name: undefined, +} describe("toSymbols", () => { it("uses both reported symbols and the reported vault name when every read succeeds", () => { diff --git a/app/src/hooks/useVaultPaused.test.ts b/app/src/hooks/useVaultPaused.test.ts index 6b72e97f..dede9188 100644 --- a/app/src/hooks/useVaultPaused.test.ts +++ b/app/src/hooks/useVaultPaused.test.ts @@ -30,6 +30,7 @@ const open: ContractRead = { kind: "value", value: false } const contractError: ContractRead = { kind: "contract-error", code: 1, + name: undefined, } const unreadable: ContractRead = { kind: "unreadable" } diff --git a/app/src/test/contractErrorCode.ts b/app/src/test/contractErrorCode.ts new file mode 100644 index 00000000..8150c794 --- /dev/null +++ b/app/src/test/contractErrorCode.ts @@ -0,0 +1,17 @@ +export type ErrorTable = Record + +export function contractErrorCode(errors: ErrorTable, name: string): number { + const codes = Object.entries(errors) + .filter(([, error]) => error.message === name) + .map(([code]) => Number(code)) + const [code, ...others] = codes + if (code === undefined || others.length > 0) { + throw new Error( + `Expected one contract error named ${name} in the generated error table, found ${codes.length}`, + ) + } + return code +} + +export const simulationError = (errors: ErrorTable, name: string) => + `HostError: Error(Contract, #${contractErrorCode(errors, name)})` From 20e86eb164f9ba23aaad73c0db38beca0d94e493 Mon Sep 17 00:00:00 2001 From: luchobonatti Date: Mon, 2 Nov 2026 16:29:35 -0300 Subject: [PATCH 2/4] refactor(app): split TransactionModal copy into data Per-action copy moves to transactionCopy/ as data read by one describeStatus, and contract errors match by name. The modal tests are split by section. Fixes AdminTransferPending for the vault and oracle, and pause errors no longer show on oracle actions. Closes #415 --- .../vault/TransactionModal.test.tsx | 2936 +---------------- app/src/components/vault/TransactionModal.tsx | 1943 +---------- .../components/vault/transactionCopy/admin.ts | 581 ++++ .../vault/transactionCopy/compliance.test.tsx | 174 + .../transactionCopy/configuration.test.tsx | 304 ++ .../vault/transactionCopy/cycle.test.tsx | 691 ++++ .../components/vault/transactionCopy/cycle.ts | 250 ++ .../vault/transactionCopy/describe.ts | 106 + .../vault/transactionCopy/emergency.test.tsx | 310 ++ .../vault/transactionCopy/governance.test.tsx | 493 +++ .../components/vault/transactionCopy/index.ts | 90 + .../vault/transactionCopy/investor.test.tsx | 922 ++++++ .../vault/transactionCopy/investor.ts | 326 ++ .../transactionCopy/investorOutcomes.test.tsx | 234 ++ .../components/vault/transactionCopy/types.ts | 204 ++ app/src/pages/VaultPreview.test.tsx | 10 +- app/src/pages/vaultRequests.test.ts | 2 +- app/src/pages/vaultRequests.ts | 2 +- app/src/test/renderTransactionModal.tsx | 303 ++ 19 files changed, 5043 insertions(+), 4838 deletions(-) create mode 100644 app/src/components/vault/transactionCopy/admin.ts create mode 100644 app/src/components/vault/transactionCopy/compliance.test.tsx create mode 100644 app/src/components/vault/transactionCopy/configuration.test.tsx create mode 100644 app/src/components/vault/transactionCopy/cycle.test.tsx create mode 100644 app/src/components/vault/transactionCopy/cycle.ts create mode 100644 app/src/components/vault/transactionCopy/describe.ts create mode 100644 app/src/components/vault/transactionCopy/emergency.test.tsx create mode 100644 app/src/components/vault/transactionCopy/governance.test.tsx create mode 100644 app/src/components/vault/transactionCopy/index.ts create mode 100644 app/src/components/vault/transactionCopy/investor.test.tsx create mode 100644 app/src/components/vault/transactionCopy/investor.ts create mode 100644 app/src/components/vault/transactionCopy/investorOutcomes.test.tsx create mode 100644 app/src/components/vault/transactionCopy/types.ts create 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 99b5357e..319a031a 100644 --- a/app/src/components/vault/TransactionModal.test.tsx +++ b/app/src/components/vault/TransactionModal.test.tsx @@ -1,34 +1,12 @@ -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 { VaultError } from "../../../../app-lib/clients/async_vault/src/index" 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 { contractErrorCode } from "../../test/contractErrorCode" +import { renderModal } from "../../test/renderTransactionModal" +import TransactionModal from "./TransactionModal" const { explorerTransactionMock } = vi.hoisted(() => ({ explorerTransactionMock: vi.fn(() => null as string | null), @@ -41,212 +19,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" }) @@ -398,7 +170,14 @@ describe("TransactionModal", () => { ], [ "refused by the vault before any signature is asked for", - { status: "failed", failure: { kind: "contract-error", code: 6009 } }, + { + status: "failed", + failure: { + kind: "contract-error", + code: contractErrorCode(VaultError, "RequestOutstanding"), + name: "RequestOutstanding", + }, + }, ["Approved in your wallet, failed", "Sent to the network", "Recorded"], ], [ @@ -476,7 +255,11 @@ describe("TransactionModal", () => { it("names the vault's own reason for a contract refusal, with no hash since it never reached the network", () => { renderModal({ status: "failed", - failure: { kind: "contract-error", code: 6009 }, + failure: { + kind: "contract-error", + code: contractErrorCode(VaultError, "RequestOutstanding"), + name: "RequestOutstanding", + }, }) expect( @@ -492,7 +275,11 @@ describe("TransactionModal", () => { it("names SetupOpen as deposits starting once setup closes", () => { renderModal({ status: "failed", - failure: { kind: "contract-error", code: 6064 }, + failure: { + kind: "contract-error", + code: contractErrorCode(VaultError, "SetupOpen"), + name: "SetupOpen", + }, }) expect(screen.getByText(/setup is still open/)).toBeTruthy() @@ -502,7 +289,11 @@ describe("TransactionModal", () => { it("names FeedNotValid as subscriptions paused until the price feed is valid again", () => { renderModal({ status: "failed", - failure: { kind: "contract-error", code: 6044 }, + failure: { + kind: "contract-error", + code: contractErrorCode(VaultError, "FeedNotValid"), + name: "FeedNotValid", + }, }) expect(screen.getByText(/price feed is not valid right now/)).toBeTruthy() @@ -512,7 +303,11 @@ describe("TransactionModal", () => { it("names EscrowNotCovered as subscriptions refused until the vault is funded again", () => { renderModal({ status: "failed", - failure: { kind: "contract-error", code: 6076 }, + failure: { + kind: "contract-error", + code: contractErrorCode(VaultError, "EscrowNotCovered"), + name: "EscrowNotCovered", + }, }) expect( @@ -526,7 +321,7 @@ describe("TransactionModal", () => { it("falls back to a generic refusal for a contract error it does not recognize, without the raw code", () => { renderModal({ status: "failed", - failure: { kind: "contract-error", code: 9999 }, + failure: { kind: "contract-error", code: 9999, name: undefined }, }) expect(screen.getByText(/contact support/)).toBeTruthy() @@ -616,7 +411,7 @@ describe("TransactionModal", () => { const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}) const status: RequestDepositStatus = { status: "failed", - failure: { kind: "contract-error", code: 9999 }, + failure: { kind: "contract-error", code: 9999, name: undefined }, } const { rerender } = renderModal(status) @@ -637,2664 +432,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.tsx b/app/src/components/vault/TransactionModal.tsx index e017090d..4c87f4ff 100644 --- a/app/src/components/vault/TransactionModal.tsx +++ b/app/src/components/vault/TransactionModal.tsx @@ -1,1926 +1,21 @@ -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 { describeTransaction } from "./transactionCopy" +import { type TransactionModalProps } from "./transactionCopy/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 { ADMIN_VERBS } from "./transactionCopy/admin" +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, - ) - } -} + TransactionModalProps, +} from "./transactionCopy/types" type StepId = "signature" | "network" | "recorded" type StepState = "done" | "current" | "upcoming" | "failed" @@ -1934,21 +29,7 @@ const stepLabel: Record = { } const computeSteps = ( - status: - | RequestDepositStatus - | CancelDepositStatus - | CancelRedeemStatus - | ClaimDepositStatus - | RequestRedeemStatus - | ClaimRedeemStatus - | CloseEpochStatus - | FulfillEpochStatus - | AttestStatus - | DeployToCustodianStatus - | FundStatus - | ActivateWindDownStatus - | FinalizeWindDownRoundStatus - | TransactionStatus, + status: TransactionModalProps["status"], ): Record | undefined => { switch (status.status) { case "idle": @@ -2052,7 +133,7 @@ const TransactionModal: React.FC = (props) => { } }, [contractErrorCode]) - const content = describeStatus(props) + const content = describeTransaction(props) if (content === undefined) return null const steps = computeSteps(status) diff --git a/app/src/components/vault/transactionCopy/admin.ts b/app/src/components/vault/transactionCopy/admin.ts new file mode 100644 index 00000000..6d53279e --- /dev/null +++ b/app/src/components/vault/transactionCopy/admin.ts @@ -0,0 +1,581 @@ +import { formatDate } from "@stellar-scaffold/app-lib" +import { type SharedTransaction } from "../../../hooks/useContractTransaction" +import { describeStanding } from "../../../pages/actions" +import { EFFECTS } from "../../../pages/configuration" +import { + type AdminModalAction, + type ComplianceModalAction, + type ConfigurationModalAction, + type Copy, + type EmergencyModalAction, + type GovernanceModalAction, + type Reasons, + type StatusCopy, +} from "./types" + +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" + +type Refuser = "vault" | "share token" | "oracle" | "identity verifier" + +type AdminCopy = { + refuser: Refuser + reasons?: Reasons + 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: Reasons = { + InvalidAmount: "The minimum epoch duration must be 1 to 30 days.", +} + +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", + ), +} + +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 acceptAdminCopy = (refuser: TimelockRefuser) => (): AdminCopy => ({ + refuser, + reasons: { + WindDownActive: + "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: Reasons = { + EnforcedPause: "The vault is paused; unpause it first.", + WindDownActive: "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, + (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: Reasons = { + VaultAddress: + "A legal transfer never moves shares into or out of the vault: they are other investors' escrow.", + CoSignRequired: + "The compliance account and the token admin must be two different accounts.", + InvalidReference: "Enter a reference of 1 to 128 bytes.", + IdentityVerificationFailed: + "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 WINDING_DOWN_NO_CHANGE = + "The vault is winding down, so no change can be proposed or applied." + +const CONTRACT_REASONS: Record = { + vault: { WindDownActive: "The wind-down is already active." }, + oracle: { + SetupClosed: "The oracle's setup has already finished.", + VaultNotBound: "The oracle is not bound to a vault yet.", + WindDownActive: WINDING_DOWN_NO_CHANGE, + VaultPaused: + "The vault is paused, which holds every oracle change; unpause it first.", + DelayBelowNotice: + "The change's delay is shorter than the vault's notice now, so it cannot apply.", + }, + "share token": { + WindDownActive: WINDING_DOWN_NO_CHANGE, + VaultPaused: + "The vault is paused, which holds every share-token change; unpause it first.", + }, + "identity verifier": {}, +} + +const adminReasons = (refuser: Refuser): Reasons => ({ + // the oracle pauses through NavPaused/NavNotPaused, so OZ pause errors there are the vault's + ...(refuser === "oracle" + ? {} + : { + EnforcedPause: `The ${refuser} is already paused.`, + ExpectedPause: `The ${refuser} is not paused, so there is nothing to unpause.`, + }), + InsufficientBalance: "The address does not hold enough shares.", + LessThanZero: "The amount cannot be negative.", + InsufficientFreeTokens: "The address does not have that many frozen shares.", + Unauthorized: "This wallet does not hold the role this action needs.", + AdminNotSet: `The ${refuser} has no admin.`, + IndexOutOfBounds: "The role's holders changed before this was recorded.", + RoleNotHeld: "The account does not hold this role.", + MaxRolesExceeded: `The ${refuser} already has the maximum number of roles.`, + NoPendingTransfer: "There is no pending handover.", + InvalidLiveUntilLedger: "The expiry is outside what the network allows.", + InvalidPendingAccount: "The pending handover is to a different address.", + TransferExpired: "The handover expired before it was accepted.", + ChangeNotFound: "There is no pending change with that id.", + ChangeNotReady: "The change's delay has not passed yet.", + TooManyPendingChanges: + "There are already 16 pending changes; apply or cancel one first.", + SetupOpen: "Setup is still open, so no change can be proposed yet.", + UseTimelock: "After setup this change goes through the timelock.", + AdminTransferPending: "An admin hand-over is still pending; cancel it first.", + WindDownNotProposed: "No wind-down is proposed.", + WindDownAlreadyProposed: "A wind-down is already proposed.", + UpgradeProposalExists: "A code change is already pending; one at a time.", + UpgradeDelayTooShort: "The upgrade delay cannot be under 7 days.", + UpgradeDelayBelowNotice: "The upgrade delay cannot be under the notice.", + UpgradeDelayTooLong: "The upgrade delay cannot exceed 90 days.", + WindDownDelayTooShort: "The wind-down delay cannot be under 7 days.", + InvalidCustodian: "The custodian cannot be the vault itself.", + InvalidAmount: "The deposit cap cannot be negative.", + NoticeTooLong: "The notice cannot exceed 30 days.", + WindDownDelayTooLong: "The wind-down delay cannot exceed 90 days.", + NoticeAboveUpgradeDelay: "The notice cannot exceed the upgrade delay.", + InvalidConfig: "One of the oracle limits is out of range.", + NavNotPaused: "The NAV is not paused, so the latest price cannot be cleared.", + UpgradeAlreadyPending: "A code upgrade is already pending; cancel it first.", + ...CONTRACT_REASONS[refuser], +}) + +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 describeSharedStanding = (status: SharedTransaction): Copy => + status.standing === "admin-authorization" + ? { + heading: "Needs the token admin's authorization", + body: "Share the link with the token admin. Once they authorize it, compliance's signers sign it.", + } + : { + heading: describeStanding(status.standing), + body: describeShared(status.expiresAt), + } + +const adminStatusCopy = (copy: AdminCopy, followUp: string): StatusCopy => ({ + followUp, + refuser: copy.refuser, + reasons: { ...adminReasons(copy.refuser), ...copy.reasons }, + fallback: `The ${copy.refuser} refused this request. Try again, and contact support if it keeps happening.`, + preparing: { + heading: `Preparing to ${copy.verb}`, + body: "We are getting it ready. Your wallet will ask you to approve it next.", + }, + awaitingSignature: { heading: "Confirm in your wallet", body: copy.signing }, + submitted: { + heading: "Sending", + body: "It is on its way to the network. This should only take a moment. Closing this window will not stop it.", + }, + shared: describeSharedStanding, + confirmed: () => ({ heading: copy.done, body: copy.doneBody }), +}) + +const inTab = ( + table: Record AdminCopy)>, + followUp: string, +): Record StatusCopy> => + Object.fromEntries( + Object.entries AdminCopy)>(table).map( + ([id, copy]) => [ + id, + (value: string) => + adminStatusCopy( + typeof copy === "function" ? copy(value) : copy, + followUp, + ), + ], + ), + ) as Record StatusCopy> + +export const ADMIN_COPY: Record< + AdminModalAction, + (value: string) => StatusCopy +> = { + ...inTab(EMERGENCY_COPY, EMERGENCY_FOLLOW_UP), + ...inTab(CONFIGURATION_COPY, CONFIGURATION_FOLLOW_UP), + ...inTab(GOVERNANCE_COPY, GOVERNANCE_FOLLOW_UP), + ...inTab(COMPLIANCE_COPY, COMPLIANCE_FOLLOW_UP), +} diff --git a/app/src/components/vault/transactionCopy/compliance.test.tsx b/app/src/components/vault/transactionCopy/compliance.test.tsx new file mode 100644 index 00000000..96807c5b --- /dev/null +++ b/app/src/components/vault/transactionCopy/compliance.test.tsx @@ -0,0 +1,174 @@ +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" + +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, string, string, RegExp]>([ + [ + "freeze-shares", + "InsufficientBalance", + "share token", + /does not hold enough shares/, + ], + ["unfreeze-shares", "LessThanZero", "share token", /cannot be negative/], + [ + "unfreeze-shares", + "InsufficientFreeTokens", + "share token", + /that many frozen shares/, + ], + ["freeze-address", "Unauthorized", "share token", /does not hold the role/], + [ + "allow-address", + "Unauthorized", + "identity verifier", + /does not hold the role/, + ], + [ + "disallow-address", + "AdminNotSet", + "identity verifier", + /verifier has no admin/, + ], + [ + "legal-transfer", + "CoSignRequired", + "share token", + /compliance account and the token admin must be two different accounts/, + ], + [ + "legal-transfer", + "InvalidReference", + "share token", + /reference of 1 to 128 bytes/, + ], + [ + "legal-transfer", + "VaultAddress", + "share token", + /never moves shares into or out of the vault/, + ], + [ + "legal-transfer", + "IdentityVerificationFailed", + "share token", + /destination is not allowlisted/, + ], + [ + "legal-transfer", + "InsufficientBalance", + "share token", + /does not hold enough shares/, + ], + ])( + "names the reason %s was refused with %s", + (action, name, refuser, sentence) => { + renderComplianceModal(action, { + status: "failed", + failure: { kind: "contract-error", code: 0, name }, + }) + + 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/transactionCopy/configuration.test.tsx b/app/src/components/vault/transactionCopy/configuration.test.tsx new file mode 100644 index 00000000..c7694957 --- /dev/null +++ b/app/src/components/vault/transactionCopy/configuration.test.tsx @@ -0,0 +1,304 @@ +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" + +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, string, string, RegExp]>([ + [ + "set-deposit-cap", + "InvalidAmount", + "vault", + /The deposit cap cannot be negative/, + ], + [ + "set-notice", + "NoticeTooLong", + "vault", + /The notice cannot exceed 30 days/, + ], + [ + "set-notice", + "NoticeAboveUpgradeDelay", + "vault", + /The notice cannot exceed the upgrade delay/, + ], + [ + "set-wind-down-delay", + "WindDownDelayTooLong", + "vault", + /The wind-down delay cannot exceed 90 days/, + ], + [ + "set-wind-down-delay", + "WindDownDelayTooShort", + "vault", + /The wind-down delay cannot be under 7 days/, + ], + [ + "set-min-epoch-duration", + "InvalidAmount", + "vault", + /The minimum epoch duration must be 1 to 30 days/, + ], + [ + "propose-min-epoch-duration", + "InvalidAmount", + "vault", + /The minimum epoch duration must be 1 to 30 days/, + ], + [ + "propose-custodian", + "InvalidCustodian", + "vault", + /custodian cannot be the vault/, + ], + ["propose-notice", "SetupOpen", "vault", /Setup is still open/], + ["set-notice", "UseTimelock", "vault", /goes through the timelock/], + [ + "propose-custodian", + "TooManyPendingChanges", + "vault", + /16 pending changes/, + ], + [ + "set-oracle-config", + "InvalidConfig", + "oracle", + /One of the oracle limits is out of range/, + ], + ["set-compliance", "Unauthorized", "share token", /does not hold the role/], + [ + "propose-compliance", + "TooManyPendingChanges", + "share token", + /16 pending changes/, + ], + [ + "propose-identity-verifier", + "SetupOpen", + "share token", + /Setup is still open/, + ], + [ + "set-compliance", + "UseTimelock", + "share token", + /goes through the timelock/, + ], + [ + "propose-compliance", + "WindDownActive", + "share token", + /vault is winding down/, + ], + ])( + "names the reason %s was refused with %s", + (action, name, refuser, sentence) => { + renderConfigurationModal(action, { + status: "failed", + failure: { kind: "contract-error", code: 0, name }, + }) + + 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/transactionCopy/cycle.test.tsx b/app/src/components/vault/transactionCopy/cycle.test.tsx new file mode 100644 index 00000000..4705d6a0 --- /dev/null +++ b/app/src/components/vault/transactionCopy/cycle.test.tsx @@ -0,0 +1,691 @@ +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 { VaultError } from "../../../../../app-lib/clients/async_vault/src/index" +import { contractErrorCode } from "../../../test/contractErrorCode" +import { + renderCloseEpochModal, + renderFulfillEpochModal, + renderAttestModal, + renderDeployModal, + renderFundModal, + renderActivateModal, + renderFinalizeModal, +} from "../../../test/renderTransactionModal" + +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<[string, RegExp]>([ + ["WindDownActive", /winding down/], + ["EpochNotFound", /current epoch could not be found/], + ["EpochNotOpen", /not open/], + ["Unauthorized", /not authorized to close/], + ["RoleNotHeld", /does not hold the manager role/], + ["MinEpochDurationNotElapsed", /Only the manager can seal this epoch/], + ["SetupOpen", /setup is still open, so no epoch can be sealed/], + ])("names the vault's reason for %s", (name, sentence) => { + renderCloseEpochModal({ + status: "failed", + failure: { kind: "contract-error", code: 0, name }, + }) + + 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: contractErrorCode(VaultError, "RequestNotFound"), + name: "RequestNotFound", + }, + }) + + 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<[string, RegExp]>([ + ["WindDownActive", /winding down/], + ["EpochNotFound", /epoch could not be found/], + ["EpochNotPending", /not sealed/], + ["NoticeNotElapsed", /notice period has not elapsed/], + ["FeedNotValid", /price is not valid/], + ["AttestationBeforeClose", /attested before this epoch was sealed/], + ["InvalidSharePrice", /could not resolve a valid share price/], + ["AmountTooLarge", /too large/], + ["EnforcedPause", /paused/], + ["SetupOpen", /setup is still open, so no epoch can be priced/], + ])("names the vault's reason for %s", (name, sentence) => { + renderFulfillEpochModal({ + status: "failed", + failure: { kind: "contract-error", code: 0, name }, + }) + + 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: contractErrorCode(VaultError, "AlreadyClaimed"), + name: "AlreadyClaimed", + }, + }) + + 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<[string, RegExp]>([ + ["NavOutOfBand", /outside the band/], + ["ExpiresInPast", /already have expired/], + ["CooldownActive", /cooldown .* has not elapsed/], + ["DeviationExceeded", /moves further from the last attested price/], + ["NavPaused", /^Unpause the NAV first\.$/], + ["EmptyProofHash", /^The proof hash cannot be all zeros\.$/], + ["ProofUriTooLong", /^The proof URI is longer than 256 bytes\.$/], + ])("names the oracle's reason for %s", (name, sentence) => { + renderAttestModal({ + status: "failed", + failure: { kind: "contract-error", code: 0, name }, + }) + + expect( + screen.getByRole("heading", { name: "The vault refused this request" }), + ).toBeTruthy() + expect(screen.getByText(sentence)).toBeTruthy() + }) + + it.each<[number, string]>([ + [2000, "Unauthorized"], + [2007, "RoleNotHeld"], + ])( + "falls back to a generic refusal for code %i, without the raw code", + (code, name) => { + renderAttestModal({ + status: "failed", + failure: { kind: "contract-error", code, name }, + }) + + 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<[string, RegExp]>([ + ["WindDownActive", /winding down/], + ["InvalidAmount", /greater than zero/], + ["CustodianNotSet", /No custodian is set/], + ["ReserveCommittedToExits", /free reserve/], + ["AmountTooLarge", /too large/], + ["Unauthorized", /not authorized/], + ["RoleNotHeld", /treasury role/], + ])("names the vault's reason for %s", (name, sentence) => { + renderDeployModal({ + status: "failed", + failure: { kind: "contract-error", code: 0, name }, + }) + + 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: contractErrorCode(VaultError, "EpochNotFound"), + name: "EpochNotFound", + }, + }) + + 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<[string, RegExp]>([ + ["InvalidAmount", /greater than zero/], + ["AmountTooLarge", /too large/], + ])("names the vault's reason for %s", (name, sentence) => { + renderFundModal({ + status: "failed", + failure: { kind: "contract-error", code: 0, name }, + }) + + 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, name: undefined }, + }) + + 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<[string, RegExp]>([ + ["WindDownNotProposed", /No wind-down has been proposed/], + ["WindDownActive", /already active/], + ["WindDownDelayNotElapsed", /delay has not elapsed/], + ])("names the vault's reason for %s", (name, sentence) => { + renderActivateModal({ + status: "failed", + failure: { kind: "contract-error", code: 0, name }, + }) + + 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: contractErrorCode(VaultError, "WindDownNotActive"), + name: "WindDownNotActive", + }, + }) + + 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<[string, RegExp]>([ + ["WindDownNotActive", /not active/], + ["NothingToDistribute", /nothing to distribute/], + ["AmountTooLarge", /too large/], + ])("names the vault's reason for %s", (name, sentence) => { + renderFinalizeModal({ + status: "failed", + failure: { kind: "contract-error", code: 0, name }, + }) + + 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/transactionCopy/cycle.ts b/app/src/components/vault/transactionCopy/cycle.ts new file mode 100644 index 00000000..23acd58d --- /dev/null +++ b/app/src/components/vault/transactionCopy/cycle.ts @@ -0,0 +1,250 @@ +import { + AMOUNT_DECIMALS, + type Amount, + formatExact, + formatScaled, + type Price, + PRICE_DECIMALS, +} from "@stellar-scaffold/app-lib" +import { GENERIC_REFUSAL } from "./describe" +import { type Reasons, type StatusCopy } from "./types" + +const OPERATOR_FOLLOW_UP = "check the Cycle card before trying again" + +const OPERATOR = { + followUp: OPERATOR_FOLLOW_UP, + refuser: "vault", + fallback: GENERIC_REFUSAL, +} + +const CLOSE_EPOCH_REASONS: Reasons = { + WindDownActive: "The vault is winding down, so no more epochs can be sealed.", + EpochNotFound: "The current epoch could not be found.", + EpochNotOpen: "The current epoch is not open, so there is nothing to seal.", + Unauthorized: "This wallet is not authorized to close epochs.", + RoleNotHeld: "This wallet does not hold the manager role.", + MinEpochDurationNotElapsed: + "Only the manager can seal this epoch until it has been open for the minimum duration.", + SetupOpen: "The vault's setup is still open, so no epoch can be sealed yet.", +} + +const FULFILL_EPOCH_REASONS: Reasons = { + WindDownActive: + "The vault is winding down, so epochs can no longer be priced.", + EpochNotFound: "This epoch could not be found.", + EpochNotPending: "This epoch is not sealed, so it cannot be priced.", + NoticeNotElapsed: "The notice period has not elapsed yet.", + FeedNotValid: "The oracle price is not valid right now.", + AttestationBeforeClose: + "The latest price was attested before this epoch was sealed. A fresh attestation is needed.", + InvalidSharePrice: "The vault could not resolve a valid share price.", + AmountTooLarge: "The settlement total is too large for the vault to record.", + EnforcedPause: "The vault is paused, so epochs cannot be priced.", + SetupOpen: "The vault's setup is still open, so no epoch can be priced yet.", +} + +const ATTEST_REASONS: Reasons = { + NavOutOfBand: "That price is outside the band the oracle accepts.", + ExpiresInPast: "The attestation would already have expired when recorded.", + CooldownActive: + "The cooldown since the last attestation has not elapsed yet.", + DeviationExceeded: + "That price moves further from the last attested price than the oracle allows.", + NavPaused: "Unpause the NAV first.", + EmptyProofHash: "The proof hash cannot be all zeros.", + ProofUriTooLong: "The proof URI is longer than 256 bytes.", +} + +const DEPLOY_REASONS: Reasons = { + WindDownActive: + "The vault is winding down, so nothing can leave for the custodian.", + InvalidAmount: "Enter an amount greater than zero.", + CustodianNotSet: "No custodian is set, so there is nowhere to deploy to.", + ReserveCommittedToExits: + "That amount exceeds the free reserve. The rest is committed to priced exits.", + AmountTooLarge: "That amount is too large for the vault to record.", + Unauthorized: "This wallet is not authorized to deploy to the custodian.", + RoleNotHeld: "This wallet does not hold the treasury role.", +} + +const FUND_REASONS: Reasons = { + InvalidAmount: "Enter an amount greater than zero.", + AmountTooLarge: "That amount is too large for the vault to record.", +} + +const ACTIVATE_WIND_DOWN_REASONS: Reasons = { + WindDownNotProposed: + "No wind-down has been proposed, so there is nothing to activate.", + WindDownActive: "The wind-down is already active.", + WindDownDelayNotElapsed: "The wind-down delay has not elapsed yet.", +} + +const FINALIZE_ROUND_REASONS: Reasons = { + WindDownNotActive: + "The wind-down is not active, so no round can be finalized.", + NothingToDistribute: + "There is nothing to distribute: the free reserve is empty or too small to split across holders.", + AmountTooLarge: "The round is too large for the vault to record.", +} + +export const closeEpochCopy = ( + epoch: string, +): StatusCopy<{ sealedEpoch: bigint }> => ({ + ...OPERATOR, + reasons: CLOSE_EPOCH_REASONS, + preparing: { + heading: `Preparing to close epoch ${epoch}`, + body: "We are getting the close ready. Your wallet will ask you to approve it next.", + }, + awaitingSignature: { + heading: "Confirm in your wallet", + body: `Signing seals epoch ${epoch} and opens the next one. Requests in epoch ${epoch} wait for a price.`, + }, + submitted: { + 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.", + }, + confirmed: (status) => ({ + 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.`, + }), +}) + +export const fulfillEpochCopy = ( + epoch: string, +): StatusCopy<{ sharePrice: Price }> => ({ + ...OPERATOR, + reasons: FULFILL_EPOCH_REASONS, + preparing: { + heading: `Preparing to price epoch ${epoch}`, + body: "We are getting the settlement ready. Your wallet will ask you to approve it next.", + }, + awaitingSignature: { + heading: "Confirm in your wallet", + body: `Signing prices epoch ${epoch} at the oracle's current price and settles its requests.`, + }, + submitted: { + 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.", + }, + confirmed: (status) => ({ + 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.`, + }), +}) + +export const attestCopy = ( + price: string, + validFor: string | null, +): StatusCopy => ({ + ...OPERATOR, + reasons: ATTEST_REASONS, + preparing: { + heading: `Preparing to attest ${price}`, + body: "We are getting the attestation ready. Your wallet will ask you to approve it next.", + }, + awaitingSignature: { + 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.`, + }, + submitted: { + 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.", + }, + confirmed: () => ({ + heading: "Price attested", + body: + validFor === null + ? `${price} is the share price.` + : `${price} is the share price. Valid for ${validFor}.`, + }), +}) + +export const deployCopy = (amount: string): StatusCopy => ({ + ...OPERATOR, + reasons: DEPLOY_REASONS, + preparing: { + heading: `Preparing to deploy ${amount}`, + body: "We are getting the deployment ready. Your wallet will ask you to approve it next.", + }, + awaitingSignature: { + heading: "Confirm in your wallet", + body: `Signing sends ${amount} from the vault's reserve to the custodian.`, + }, + submitted: { + 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.", + }, + confirmed: () => ({ + heading: "Deployed", + body: `${amount} left the vault for the custodian.`, + }), +}) + +export const fundCopy = (amount: string): StatusCopy => ({ + ...OPERATOR, + reasons: FUND_REASONS, + // the asset's transfer errors are the only ones no generated client names + unnamedFallback: + "Your wallet could not cover the transfer. Check the balance and the trustline.", + preparing: { + heading: `Preparing to fund ${amount}`, + body: "We are getting the funding ready. Your wallet will ask you to approve it next.", + }, + awaitingSignature: { + heading: "Confirm in your wallet", + body: `Signing moves ${amount} from your wallet into the vault's reserve.`, + }, + submitted: { + 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.", + }, + confirmed: () => ({ + heading: "Funded", + body: `${amount} joined the reserve.`, + }), +}) + +export const activateWindDownCopy = (): StatusCopy => ({ + ...OPERATOR, + reasons: ACTIVATE_WIND_DOWN_REASONS, + preparing: { + heading: "Preparing to activate the wind-down", + body: "We are getting the activation ready. Your wallet will ask you to approve it next.", + }, + awaitingSignature: { + heading: "Confirm in your wallet", + body: "Signing stops new requests and opens distribution rounds.", + }, + submitted: { + 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.", + }, + confirmed: () => ({ + heading: "Wind-down active", + body: "New requests are closed. Rounds can now distribute the free reserve.", + }), +}) + +export const finalizeRoundCopy = ( + round: string, +): StatusCopy<{ credited: Amount }> => ({ + ...OPERATOR, + reasons: FINALIZE_ROUND_REASONS, + preparing: { + heading: `Preparing round ${round}`, + body: "We are getting the round ready. Your wallet will ask you to approve it next.", + }, + awaitingSignature: { + heading: "Confirm in your wallet", + body: `Signing distributes the free reserve to holders as round ${round}.`, + }, + submitted: { + 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.", + }, + confirmed: (status) => ({ + heading: `Round ${round} finalized`, + body: `${formatExact(status.credited, AMOUNT_DECIMALS)} is claimable by holders.`, + }), +}) diff --git a/app/src/components/vault/transactionCopy/describe.ts b/app/src/components/vault/transactionCopy/describe.ts new file mode 100644 index 00000000..36cd50f0 --- /dev/null +++ b/app/src/components/vault/transactionCopy/describe.ts @@ -0,0 +1,106 @@ +import { type SubmitFailure } from "@stellar-scaffold/app-lib" +import { + type TransactionFailure, + type TransactionStatus, +} from "../../../hooks/useContractTransaction" +import { type Content, type Copy, type StatusCopy } from "./types" + +export const GENERIC_REFUSAL = + "The vault refused this request. Try again, and contact support if it keeps happening." + +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 contractErrorReason = ( + copy: Pick, + name: string | undefined, +): string => { + if (name === undefined) return copy.unnamedFallback ?? copy.fallback + return ( + (Object.hasOwn(copy.reasons, name) ? copy.reasons[name] : undefined) ?? + copy.fallback + ) +} + +const describeFailure = ( + failure: TransactionFailure, + copy: Pick< + StatusCopy, + "reasons" | "fallback" | "unnamedFallback" | "followUp" | "refuser" + >, +): Copy => { + switch (failure.kind) { + case "network": + if (failure.reason === "unknown") return unknownOutcome(copy.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 ${copy.refuser} refused this request`, + body: contractErrorReason(copy, failure.name), + } + 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(copy.followUp) + } +} + +export const describeStatus = ( + copy: StatusCopy, + status: TransactionStatus, +): Content | undefined => { + switch (status.status) { + case "idle": + return undefined + case "preparing": + return copy.preparing + case "awaiting-signature": + return copy.awaitingSignature + case "submitted": + return { ...copy.submitted, hash: status.hash } + case "shared": + return copy.shared?.(status) + case "confirmed": + return { ...copy.confirmed(status), hash: status.hash } + case "failed": + return { ...describeFailure(status.failure, copy), hash: status.hash } + } +} diff --git a/app/src/components/vault/transactionCopy/emergency.test.tsx b/app/src/components/vault/transactionCopy/emergency.test.tsx new file mode 100644 index 00000000..ae4c8eaa --- /dev/null +++ b/app/src/components/vault/transactionCopy/emergency.test.tsx @@ -0,0 +1,310 @@ +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 { PausableError } from "../../../../../app-lib/clients/async_vault/src/index" +import { contractErrorCode } from "../../../test/contractErrorCode" +import { + renderModal, + renderEmergencyModal, +} from "../../../test/renderTransactionModal" +import TransactionModal, { + type EmergencyModalAction, +} from "../TransactionModal" + +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, string, string, RegExp]>([ + ["pause-vault", "EnforcedPause", "vault", /The vault is already paused/], + ["unpause-vault", "ExpectedPause", "vault", /The vault is not paused/], + [ + "pause-token", + "EnforcedPause", + "share token", + /The share token is already paused/, + ], + [ + "unpause-token", + "ExpectedPause", + "share token", + /The share token is not paused/, + ], + ["pause-nav", "Unauthorized", "oracle", /does not hold the role/], + ["clear-latest", "NavNotPaused", "oracle", /The NAV is not paused/], + ])( + "names the reason %s was refused with %s", + (action, name, refuser, sentence) => { + renderEmergencyModal(action, { + status: "failed", + failure: { kind: "contract-error", code: 0, name }, + }) + + 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, name: undefined }, + }) + + expect( + screen.getByText(/The share token refused this request\. Try again/), + ).toBeTruthy() + expect(screen.queryByText(/9999/)).toBeNull() + }) + + it("does not call an oracle action already paused when the vault's pause refuses it", () => { + renderEmergencyModal("pause-nav", { + status: "failed", + failure: { + kind: "contract-error", + code: contractErrorCode(PausableError, "EnforcedPause"), + name: "EnforcedPause", + }, + }) + + expect( + screen.getByText(/The oracle refused this request\. Try again/), + ).toBeTruthy() + expect(screen.queryByText(/already paused/)).toBeNull() + }) + + it("does not call an oracle action not paused when the vault's unpause refuses it", () => { + renderEmergencyModal("unpause-nav", { + status: "failed", + failure: { + kind: "contract-error", + code: contractErrorCode(PausableError, "ExpectedPause"), + name: "ExpectedPause", + }, + }) + + expect( + screen.getByText(/The oracle refused this request\. Try again/), + ).toBeTruthy() + expect(screen.queryByText(/nothing to unpause/)).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/transactionCopy/governance.test.tsx b/app/src/components/vault/transactionCopy/governance.test.tsx new file mode 100644 index 00000000..d13fc3dc --- /dev/null +++ b/app/src/components/vault/transactionCopy/governance.test.tsx @@ -0,0 +1,493 @@ +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" + +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, string, string, RegExp]>([ + ["grant-vault-role", "Unauthorized", "vault", /does not hold the role/], + ["revoke-oracle-role", "IndexOutOfBounds", "oracle", /holders changed/], + [ + "revoke-token-role", + "RoleNotHeld", + "share token", + /does not hold this role/, + ], + [ + "grant-oracle-role", + "MaxRolesExceeded", + "oracle", + /The oracle already has the maximum number of roles/, + ], + [ + "cancel-vault-handover", + "NoPendingTransfer", + "vault", + /no pending handover/, + ], + [ + "hand-over-oracle-admin", + "InvalidLiveUntilLedger", + "oracle", + /expiry is outside/, + ], + [ + "cancel-token-handover", + "InvalidPendingAccount", + "share token", + /different address/, + ], + [ + "accept-vault-admin", + "TransferExpired", + "vault", + /expired before it was accepted/, + ], + [ + "accept-vault-admin", + "WindDownActive", + "vault", + /admin role can no longer change hands/, + ], + [ + "accept-oracle-admin", + "WindDownActive", + "oracle", + /admin role can no longer change hands/, + ], + [ + "accept-token-admin", + "WindDownActive", + "share token", + /admin role can no longer change hands/, + ], + ["apply-change", "EnforcedPause", "vault", /The vault is paused/], + ["apply-change", "WindDownActive", "vault", /winding down/], + [ + "propose-wind-down", + "WindDownActive", + "vault", + /wind-down is already active/, + ], + [ + "cancel-wind-down", + "WindDownNotProposed", + "vault", + /No wind-down is proposed/, + ], + [ + "propose-wind-down", + "WindDownAlreadyProposed", + "vault", + /wind-down is already proposed/, + ], + [ + "propose-code-upgrade", + "UpgradeProposalExists", + "vault", + /code change is already pending/, + ], + [ + "cancel-change", + "ChangeNotFound", + "vault", + /no pending change with that id/, + ], + ["apply-change", "ChangeNotReady", "vault", /delay has not passed yet/], + [ + "propose-grant-role", + "TooManyPendingChanges", + "vault", + /16 pending changes/, + ], + ["propose-admin", "SetupOpen", "vault", /Setup is still open/], + ["grant-vault-role", "UseTimelock", "vault", /goes through the timelock/], + [ + "propose-upgrade-delay", + "UpgradeDelayTooShort", + "vault", + /cannot be under 7 days/, + ], + [ + "propose-upgrade-delay", + "UpgradeDelayBelowNotice", + "vault", + /cannot be under the notice/, + ], + [ + "propose-upgrade-delay", + "UpgradeDelayTooLong", + "vault", + /cannot exceed 90 days/, + ], + [ + "cancel-token-change", + "ChangeNotFound", + "share token", + /no pending change with that id/, + ], + [ + "apply-token-change", + "ChangeNotReady", + "share token", + /delay has not passed yet/, + ], + [ + "propose-token-grant-role", + "TooManyPendingChanges", + "share token", + /16 pending changes/, + ], + ["propose-token-admin", "SetupOpen", "share token", /Setup is still open/], + [ + "grant-token-role", + "UseTimelock", + "share token", + /goes through the timelock/, + ], + [ + "apply-token-change", + "WindDownActive", + "share token", + /vault is winding down/, + ], + ["apply-token-change", "VaultPaused", "share token", /vault is paused/], + [ + "apply-token-change", + "UpgradeAlreadyPending", + "share token", + /upgrade is already pending/, + ], + [ + "hand-over-token-admin", + "AdminTransferPending", + "share token", + /hand-over is still pending/, + ], + [ + "cancel-oracle-change", + "ChangeNotFound", + "oracle", + /no pending change with that id/, + ], + [ + "apply-oracle-change", + "ChangeNotReady", + "oracle", + /delay has not passed yet/, + ], + [ + "propose-oracle-grant-role", + "TooManyPendingChanges", + "oracle", + /16 pending changes/, + ], + ["propose-oracle-admin", "SetupOpen", "oracle", /Setup is still open/], + ["grant-oracle-role", "UseTimelock", "oracle", /goes through the timelock/], + [ + "apply-oracle-change", + "SetupClosed", + "oracle", + /setup has already finished/, + ], + ["apply-oracle-change", "VaultNotBound", "oracle", /not bound to a vault/], + [ + "apply-oracle-change", + "WindDownActive", + "oracle", + /vault is winding down/, + ], + [ + "apply-oracle-change", + "VaultPaused", + "oracle", + /holds every oracle change/, + ], + [ + "apply-oracle-change", + "DelayBelowNotice", + "oracle", + /shorter than the vault's notice/, + ], + [ + "propose-oracle-upgrade", + "UpgradeAlreadyPending", + "oracle", + /upgrade is already pending/, + ], + ])( + "names the reason %s was refused with %s", + (action, name, refuser, sentence) => { + renderGovernanceModal(action, { + status: "failed", + failure: { kind: "contract-error", code: 0, name }, + }) + + 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.each<[GovernanceModalAction, number, string]>([ + ["propose-grant-role", 6074, "vault"], + ["propose-oracle-grant-role", 3025, "oracle"], + ["propose-token-grant-role", 7016, "share token"], + ])( + "names a pending admin hand-over on %s, whichever contract raises it", + (action, code, refuser) => { + renderGovernanceModal(action, { + status: "failed", + failure: { kind: "contract-error", code, name: "AdminTransferPending" }, + }) + + expect( + screen.getByRole("heading", { + name: `The ${refuser} refused this request`, + }), + ).toBeTruthy() + expect( + screen.getByText( + "An admin hand-over is still pending; cancel it first.", + ), + ).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/transactionCopy/index.ts b/app/src/components/vault/transactionCopy/index.ts new file mode 100644 index 00000000..8b0fb8c1 --- /dev/null +++ b/app/src/components/vault/transactionCopy/index.ts @@ -0,0 +1,90 @@ +import { ADMIN_COPY } from "./admin" +import { + activateWindDownCopy, + attestCopy, + closeEpochCopy, + deployCopy, + finalizeRoundCopy, + fulfillEpochCopy, + fundCopy, +} from "./cycle" +import { describeStatus } from "./describe" +import { + cancelCopy, + cancelRedeemCopy, + claimCopy, + claimRedeemCopy, + claimWindDownCopy, + redeemCopy, + subscribeCopy, +} from "./investor" +import { type Content, type TransactionModalProps } from "./types" + +export const describeTransaction = ( + props: TransactionModalProps, +): Content | undefined => { + switch (props.action) { + case "subscribe": + return describeStatus( + subscribeCopy(props.amount, props.ticker), + props.status, + ) + case "cancel": + return describeStatus( + cancelCopy(props.amount, props.ticker), + props.status, + ) + case "cancel-redeem": + return describeStatus( + cancelRedeemCopy( + props.amount, + props.ticker, + props.assetTicker, + props.windingDown === true, + ), + props.status, + ) + case "claim": + return describeStatus( + claimCopy(props.amount, props.ticker, props.shareTicker), + props.status, + ) + case "redeem": + return describeStatus( + redeemCopy(props.amount, props.ticker), + props.status, + ) + case "claim-redeem": + return describeStatus( + claimRedeemCopy(props.amount, props.ticker, props.uncovered), + props.status, + ) + case "claim-wind-down": + return describeStatus( + claimWindDownCopy(props.amount, props.ticker, props.shareTicker), + props.status, + ) + case "close-epoch": + return describeStatus(closeEpochCopy(props.epoch), props.status) + case "fulfill-epoch": + return describeStatus(fulfillEpochCopy(props.epoch), props.status) + case "attest": + return describeStatus( + attestCopy(props.price, props.validFor), + props.status, + ) + case "deploy": + return describeStatus(deployCopy(props.amount), props.status) + case "fund": + return describeStatus(fundCopy(props.amount), props.status) + case "activate-wind-down": + return describeStatus(activateWindDownCopy(), props.status) + case "finalize-round": + return describeStatus(finalizeRoundCopy(props.round), props.status) + default: + return describeStatus( + ADMIN_COPY[props.action]("value" in props ? props.value : ""), + props.status, + ) + } +} diff --git a/app/src/components/vault/transactionCopy/investor.test.tsx b/app/src/components/vault/transactionCopy/investor.test.tsx new file mode 100644 index 00000000..9ad5cfdb --- /dev/null +++ b/app/src/components/vault/transactionCopy/investor.test.tsx @@ -0,0 +1,922 @@ +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 { VaultError } from "../../../../../app-lib/clients/async_vault/src/index" +import { RWAError } from "../../../../../app-lib/clients/identity_verifier/src/index" +import { contractErrorCode } from "../../../test/contractErrorCode" +import { + renderCancelModal, + renderCancelRedeemModal, + renderClaimModal, + renderRedeemModal, + renderClaimRedeemModal, +} from "../../../test/renderTransactionModal" + +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: contractErrorCode(VaultError, "AlreadyPriced"), + name: "AlreadyPriced", + }, + }) + + 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: contractErrorCode(VaultError, "PriceAvailable"), + name: "PriceAvailable", + }, + }) + + 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: contractErrorCode(VaultError, "RequestNotFound"), + name: "RequestNotFound", + }, + }) + + 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: contractErrorCode(VaultError, "EpochNotFound"), + name: "EpochNotFound", + }, + }) + + 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: contractErrorCode(VaultError, "EscrowNotCovered"), + name: "EscrowNotCovered", + }, + }) + + 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, name: undefined }, + }) + + 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: contractErrorCode(RWAError, "IdentityVerificationFailed"), + name: "IdentityVerificationFailed", + }, + }) + + 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: contractErrorCode(VaultError, "AlreadyPriced"), + name: "AlreadyPriced", + }, + }) + + 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: contractErrorCode(VaultError, "NotAllowlisted"), + name: "NotAllowlisted", + }, + }) + + 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: contractErrorCode(VaultError, "HolderNotEligible"), + name: "HolderNotEligible", + }, + }) + + 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: contractErrorCode(RWAError, "IdentityVerificationFailed"), + name: "IdentityVerificationFailed", + }, + }) + + 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, name: undefined }, + }) + + 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: contractErrorCode(VaultError, "RequestNotFound"), + name: "RequestNotFound", + }, + }) + + 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: contractErrorCode(VaultError, "EpochNotFound"), + name: "EpochNotFound", + }, + }) + + 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: contractErrorCode(VaultError, "InvalidSharePrice"), + name: "InvalidSharePrice", + }, + }) + + expect(screen.getByText(/not published a valid price/)).toBeTruthy() + }) + + it("names AlreadyClaimed distinctly", () => { + renderClaimModal({ + status: "failed", + failure: { + kind: "contract-error", + code: contractErrorCode(VaultError, "AlreadyClaimed"), + name: "AlreadyClaimed", + }, + }) + + 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: contractErrorCode(VaultError, "ClaimNotCovered"), + name: "ClaimNotCovered", + }, + }) + + 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: contractErrorCode(VaultError, "AmountTooLarge"), + name: "AmountTooLarge", + }, + }) + + 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: contractErrorCode(RWAError, "IdentityVerificationFailed"), + name: "IdentityVerificationFailed", + }, + }) + + 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, name: undefined }, + }) + + 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: contractErrorCode(VaultError, "InvalidAmount"), + name: "InvalidAmount", + }, + }) + + 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: contractErrorCode(VaultError, "RequestOutstanding"), + name: "RequestOutstanding", + }, + }) + + 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: contractErrorCode(VaultError, "AmountTooLarge"), + name: "AmountTooLarge", + }, + }) + + 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: contractErrorCode(VaultError, "EpochNotFound"), + name: "EpochNotFound", + }, + }) + + 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: contractErrorCode(VaultError, "WindDownActive"), + name: "WindDownActive", + }, + }) + + 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: contractErrorCode(VaultError, "SetupOpen"), + name: "SetupOpen", + }, + }) + + 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, name: undefined }, + }) + + 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: contractErrorCode(VaultError, "EpochNotFulfilled"), + name: "EpochNotFulfilled", + }, + }) + + 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: contractErrorCode(VaultError, "ClaimNotCovered"), + name: "ClaimNotCovered", + }, + }) + + 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: contractErrorCode(VaultError, "HolderNotEligible"), + name: "HolderNotEligible", + }, + }) + + 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: contractErrorCode(RWAError, "IdentityVerificationFailed"), + name: "IdentityVerificationFailed", + }, + }) + + 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: contractErrorCode(VaultError, "AlreadyClaimed"), + name: "AlreadyClaimed", + }, + }) + + 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, name: undefined }, + }) + + 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/transactionCopy/investor.ts b/app/src/components/vault/transactionCopy/investor.ts new file mode 100644 index 00000000..711482c7 --- /dev/null +++ b/app/src/components/vault/transactionCopy/investor.ts @@ -0,0 +1,326 @@ +import { + AMOUNT_DECIMALS, + type Amount, + formatExact, +} from "@stellar-scaffold/app-lib" +import { type ClaimDepositOutcome } from "../../../hooks/useClaimDeposit" +import { type ClaimWindDownStatus } from "../../../hooks/useClaimWindDown" +import { GENERIC_REFUSAL } from "./describe" +import { type Copy, type Reasons, type StatusCopy } from "./types" + +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 INVESTOR_FOLLOW_UP = "check your requests before doing anything else" + +const INVESTOR = { + followUp: INVESTOR_FOLLOW_UP, + refuser: "vault", + fallback: GENERIC_REFUSAL, +} + +const MOVED_TO_WIND_DOWN = "Moved to your wind-down claim" + +const PREPARING_CANCELLATION: Copy = { + heading: "Preparing your cancellation", + body: "We are getting your cancellation ready. Your wallet will ask you to approve it next.", +} + +const SENDING_CANCELLATION: Copy = { + 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.", +} + +const PREPARING_CLAIM: Copy = { + heading: "Preparing your claim", + body: "We are getting your claim ready. Your wallet will ask you to approve it next.", +} + +const SENDING_CLAIM: Copy = { + 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.", +} + +const SUBSCRIBE_REASONS: Reasons = { + InvalidAmount: "Enter an amount greater than zero.", + RequestOutstanding: "You already have a subscription request open.", + AmountTooLarge: "That amount is too large for the vault to accept.", + WindDownActive: + "The vault is winding down and is not accepting new subscriptions.", + SetupOpen: + "The vault's setup is still open. Subscriptions start once it closes.", + FeedNotValid: + "The vault's price feed is not valid right now, so subscriptions are paused until it is.", + EscrowNotCovered: + "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.", + NotAllowlisted: `${NOT_ALLOWLISTED} Only allowlisted addresses can subscribe.`, + Frozen: `${FROZEN} It cannot subscribe until the freeze is lifted.`, +} + +const CANCEL_REASONS: Reasons = { + RequestNotFound: "This request no longer exists to cancel.", + EpochNotFound: "This request could not be found.", + AlreadyPriced: + "This request has already been priced. Claim your shares instead of cancelling.", + PriceAvailable: + "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.", + EscrowNotCovered: + "The vault does not hold enough to return this deposit right now. You can cancel once it is funded again.", + Frozen: HELD_WHILE_FROZEN, +} + +const cancelRedeemReasons = (assetTicker: string): Reasons => { + const claimInstead = `Once priced, claim the ${assetTicker} it owes you instead.` + return { + ...CANCEL_REASONS, + NotAllowlisted: `${NOT_ALLOWLISTED} These shares cannot come back to you. ${claimInstead}`, + HolderNotEligible: `${SHARES_NOT_RETURNABLE} ${claimInstead}`, + } +} + +const CLAIM_REASONS: Reasons = { + RequestNotFound: "You have no request to claim.", + AmountTooLarge: + "The share conversion for this claim is too large to complete.", + EpochNotFound: "This request could not be found.", + InvalidSharePrice: + "The vault has not published a valid price for this request yet.", + AlreadyClaimed: "This request has already been claimed.", + ClaimNotCovered: + "The vault does not yet hold enough in reserve to cover this claim. Try again shortly.", + Frozen: HELD_WHILE_FROZEN, +} + +const REDEEM_REASONS: Reasons = { + InvalidAmount: "Enter an amount greater than zero.", + RequestOutstanding: "You already have a redemption request open.", + AmountTooLarge: "That amount is too large for the vault to accept.", + EpochNotFound: "This request could not be found.", + WindDownActive: + "The vault is winding down and is not accepting new redemptions.", + SetupOpen: + "The vault's setup is still open. Redemptions start once it closes.", + Frozen: + "Your address or some of these shares are frozen. Redeem only your free shares, or wait until the freeze is lifted.", +} + +const claimRedeemReasons = (ticker: string, uncovered?: string): Reasons => ({ + ...CLAIM_REASONS, + EpochNotFulfilled: + "This request has not priced yet. Check back after the vault's next update.", + ClaimNotCovered: + 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.`, + HolderNotEligible: `The price left nothing to pay out, so the vault tried to return your shares. ${SHARES_NOT_RETURNABLE}`, +}) + +const WIND_DOWN_CLAIM_REASONS: Reasons = { + DistributionNotStarted: + "The wind-down has not made its first payout yet. Check back once it does.", + NoEntitlement: "You have nothing to claim from the wind-down right now.", + Frozen: HELD_WHILE_FROZEN, +} + +export const subscribeCopy = (amount: string, ticker: string): StatusCopy => ({ + ...INVESTOR, + reasons: SUBSCRIBE_REASONS, + preparing: { + heading: "Preparing your request", + body: "We are getting your request ready. Your wallet will ask you to approve it next.", + }, + awaitingSignature: { + 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.`, + }, + submitted: { + 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.", + }, + confirmed: () => ({ + heading: "Request submitted", + body: `${amount} ${ticker} prices at the vault's next update.`, + }), +}) + +export const cancelCopy = ( + amount: string, + ticker: string, +): StatusCopy<{ refundedAmount: Amount }> => ({ + ...INVESTOR, + reasons: CANCEL_REASONS, + preparing: PREPARING_CANCELLATION, + awaitingSignature: { + heading: "Confirm in your wallet", + body: `Your request is withdrawn, not priced. Signing returns ${amount} ${ticker} from escrow to your wallet.`, + }, + submitted: SENDING_CANCELLATION, + confirmed: (status) => ({ + heading: "Request cancelled", + body: `${formatExact(status.refundedAmount, AMOUNT_DECIMALS)} ${ticker} has been returned to your wallet.`, + }), +}) + +export const cancelRedeemCopy = ( + amount: string, + ticker: string, + assetTicker: string, + windingDown: boolean, +): StatusCopy<{ returnedShares: Amount; surrendered: boolean }> => ({ + ...INVESTOR, + reasons: cancelRedeemReasons(assetTicker), + preparing: PREPARING_CANCELLATION, + awaitingSignature: { + 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.`, + }, + submitted: SENDING_CANCELLATION, + confirmed: (status) => + 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.", + } + : { + heading: "Request cancelled", + body: `${formatExact(status.returnedShares, AMOUNT_DECIMALS)} ${ticker} has been returned to your wallet.`, + }, +}) + +const convertedOutcome = (queuedInto: bigint, ticker: string): Copy => + 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 claimCopy = ( + amount: string, + ticker: string, + shareTicker: string, +): StatusCopy => ({ + ...INVESTOR, + reasons: CLAIM_REASONS, + preparing: PREPARING_CLAIM, + awaitingSignature: { + 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.`, + }, + submitted: SENDING_CLAIM, + confirmed: (status) => { + if (status.queuedInto !== undefined) + return convertedOutcome(status.queuedInto, ticker) + return status.sharesMinted > 0n + ? { + heading: "Shares claimed", + body: `${formatExact(status.sharesMinted, AMOUNT_DECIMALS)} ${shareTicker} has been added to your wallet.`, + } + : { + heading: "Deposit returned", + body: `The price left no shares to claim, so ${amount} ${ticker} has been returned to your wallet instead.`, + } + }, +}) + +const windDownClaimOutcome = ( + { + assetsClaimed, + sharesSurrendered, + }: Extract, + ticker: string, + shareTicker: string, +): Copy => { + 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 claimWindDownCopy = ( + amount: string, + ticker: string, + shareTicker: string, +): StatusCopy<{ assetsClaimed: Amount; sharesSurrendered: Amount }> => ({ + ...INVESTOR, + reasons: WIND_DOWN_CLAIM_REASONS, + preparing: PREPARING_CLAIM, + awaitingSignature: { + 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.`, + }, + submitted: SENDING_CLAIM, + confirmed: (status) => windDownClaimOutcome(status, ticker, shareTicker), +}) + +export const redeemCopy = (amount: string, ticker: string): StatusCopy => ({ + ...INVESTOR, + reasons: REDEEM_REASONS, + preparing: { + heading: "Preparing your redemption request", + body: "We are getting your redemption request ready. Your wallet will ask you to approve it next.", + }, + awaitingSignature: { + 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.`, + }, + submitted: { + 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.", + }, + confirmed: () => ({ + heading: "Redemption request submitted", + body: `${amount} ${ticker} prices at the vault's next update.`, + }), +}) + +export const claimRedeemCopy = ( + amount: string, + ticker: string, + uncovered?: string, +): StatusCopy<{ assetsClaimed: Amount; sharesReturned: boolean }> => ({ + ...INVESTOR, + reasons: claimRedeemReasons(ticker, uncovered), + preparing: PREPARING_CLAIM, + awaitingSignature: { + heading: "Confirm in your wallet", + body: `You receive what you're owed. Signing sends ${amount} ${ticker} to your wallet.`, + }, + submitted: SENDING_CLAIM, + confirmed: (status) => { + if (status.assetsClaimed > 0n) + return { + heading: `${ticker} claimed`, + body: `${formatExact(status.assetsClaimed, AMOUNT_DECIMALS)} ${ticker} has been added to your wallet.`, + } + return status.sharesReturned + ? { + heading: "Shares returned", + body: "The price left nothing to claim, so your shares have been returned to your wallet instead.", + } + : { + heading: "Nothing to pay out", + body: "The price left nothing to pay out, and the vault is winding down, so these shares were burned.", + } + }, +}) diff --git a/app/src/components/vault/transactionCopy/investorOutcomes.test.tsx b/app/src/components/vault/transactionCopy/investorOutcomes.test.tsx new file mode 100644 index 00000000..4631e6fc --- /dev/null +++ b/app/src/components/vault/transactionCopy/investorOutcomes.test.tsx @@ -0,0 +1,234 @@ +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 { + renderModal, + renderCancelModal, + renderCancelRedeemModal, + renderClaimModal, + renderRedeemModal, + renderClaimRedeemModal, + renderClaimWindDownModal, +} from "../../../test/renderTransactionModal" +import TransactionModal from "../TransactionModal" + +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 = (name: string) => ({ + status: "failed" as const, + failure: { kind: "contract-error" as const, code: 0, name }, + }) + + it("names NotAllowlisted on a subscription as an address that cannot subscribe", () => { + renderModal(contractError("NotAllowlisted")) + + expect(screen.getByText(/not allowlisted/)).toBeTruthy() + expect(screen.getByText(/can subscribe/)).toBeTruthy() + }) + + it("names Frozen on a subscription as a frozen address", () => { + renderModal(contractError("Frozen")) + + 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("Frozen")) + + expect(screen.getByText(/shares are frozen/)).toBeTruthy() + expect(screen.getByText(/free shares/)).toBeTruthy() + }) + + it.each<[string, () => void]>([ + [ + "cancelling a subscription", + () => renderCancelModal(contractError("Frozen")), + ], + [ + "cancelling a redemption", + () => renderCancelRedeemModal(contractError("Frozen")), + ], + ["claiming shares", () => renderClaimModal(contractError("Frozen"))], + [ + "claiming a redemption", + () => renderClaimRedeemModal(contractError("Frozen")), + ], + ])("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("NotAllowlisted")) + + 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<[string, RegExp]>([ + ["DistributionNotStarted", /first payout/], + ["NoEntitlement", /nothing to claim from the wind-down/], + ["Frozen", /held until the freeze is lifted/], + ])("names refusal %s in the dapp's words", (name, text) => { + renderClaimWindDownModal({ + status: "failed", + failure: { kind: "contract-error", code: 0, name }, + }) + + 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, name: undefined }, + }) + + expect(screen.getByText(/contact support/)).toBeTruthy() + expect(screen.queryByText(/9999/)).toBeNull() + }) +}) diff --git a/app/src/components/vault/transactionCopy/types.ts b/app/src/components/vault/transactionCopy/types.ts new file mode 100644 index 00000000..d8fdbddd --- /dev/null +++ b/app/src/components/vault/transactionCopy/types.ts @@ -0,0 +1,204 @@ +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 SharedTransaction, + 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 + } + +export type Copy = { heading: string; body: string } + +export type Content = Copy & { hash?: string } + +export type Reasons = Partial> + +export type StatusCopy> = { + preparing: Copy + awaitingSignature: Copy + submitted: Copy + confirmed: ( + status: { status: "confirmed"; hash?: string } & TConfirmed, + ) => Copy + shared?: (status: SharedTransaction) => Copy + reasons: Reasons + fallback: string + unnamedFallback?: string + followUp: string + refuser: string +} diff --git a/app/src/pages/VaultPreview.test.tsx b/app/src/pages/VaultPreview.test.tsx index d4d98648..ff0ae46a 100644 --- a/app/src/pages/VaultPreview.test.tsx +++ b/app/src/pages/VaultPreview.test.tsx @@ -13,11 +13,13 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query" import { act, fireEvent, render, screen, waitFor } from "@testing-library/react" import type React from "react" import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" +import { VaultError } from "../../../app-lib/clients/async_vault/src/index" import { type AddressRow } from "../components/vault/AboutVault" import { WalletContext, type WalletContextType, } from "../providers/WalletProvider" +import { simulationError } from "../test/contractErrorCode" import VaultPreview from "./VaultPreview" const { @@ -1790,11 +1792,11 @@ describe("VaultPreview", () => { ).toBeTruthy() }) - it("names the vault's own shortfall on a 6037 refusal when it can read it", async () => { + it("names the vault's own shortfall on a ClaimNotCovered refusal when it can read it", async () => { setReadyRedeem() mockFigures.uncovered = 5000_0000000n claimRedeemMock.mockImplementationOnce(async () => ({ - simulation: { error: "HostError: Error(Contract, #6037)" }, + simulation: { error: simulationError(VaultError, "ClaimNotCovered") }, signAndSend: async () => { throw new Error("should not sign a refused simulation") }, @@ -1810,11 +1812,11 @@ describe("VaultPreview", () => { ).toBeTruthy() }) - it("keeps the untickered 6037 refusal when the vault's shortfall cannot be read", async () => { + it("keeps the untickered ClaimNotCovered refusal when the vault's shortfall cannot be read", async () => { setReadyRedeem() mockFigures.uncovered = null claimRedeemMock.mockImplementationOnce(async () => ({ - simulation: { error: "HostError: Error(Contract, #6037)" }, + simulation: { error: simulationError(VaultError, "ClaimNotCovered") }, signAndSend: async () => { throw new Error("should not sign a refused simulation") }, diff --git a/app/src/pages/vaultRequests.test.ts b/app/src/pages/vaultRequests.test.ts index a825a746..aa20e63a 100644 --- a/app/src/pages/vaultRequests.test.ts +++ b/app/src/pages/vaultRequests.test.ts @@ -4,7 +4,7 @@ import { type Price, } from "@stellar-scaffold/app-lib" import { describe, expect, it, vi } from "vitest" -import { HELD_WHILE_FROZEN } from "../components/vault/TransactionModal" +import { HELD_WHILE_FROZEN } from "../components/vault/transactionCopy/investor" import { type ArchivedRequest, type InvestorRequest, diff --git a/app/src/pages/vaultRequests.ts b/app/src/pages/vaultRequests.ts index c338ec78..21d866b9 100644 --- a/app/src/pages/vaultRequests.ts +++ b/app/src/pages/vaultRequests.ts @@ -14,7 +14,7 @@ import { import { HELD_WHILE_FROZEN, NOT_ALLOWLISTED, -} from "../components/vault/TransactionModal" +} from "../components/vault/transactionCopy/investor" import { type ArchivedRequest, type InvestorRequest, diff --git a/app/src/test/renderTransactionModal.tsx b/app/src/test/renderTransactionModal.tsx new file mode 100644 index 00000000..63c384dd --- /dev/null +++ b/app/src/test/renderTransactionModal.tsx @@ -0,0 +1,303 @@ +import { render } from "@testing-library/react" +import { vi } from "vitest" +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" + +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 4922d7c60f104e7865f80138e3eacbd31680a98e Mon Sep 17 00:00:00 2001 From: luchobonatti Date: Mon, 2 Nov 2026 17:32:22 -0300 Subject: [PATCH 3/4] fix(app): restore the fund refusal fallback --- .../vault/transactionCopy/cycle.test.tsx | 32 +++++++++++++++++++ .../components/vault/transactionCopy/cycle.ts | 7 ++-- .../vault/transactionCopy/describe.ts | 26 ++++++++------- .../components/vault/transactionCopy/types.ts | 2 +- 4 files changed, 51 insertions(+), 16 deletions(-) diff --git a/app/src/components/vault/transactionCopy/cycle.test.tsx b/app/src/components/vault/transactionCopy/cycle.test.tsx index 4705d6a0..0c94a0bb 100644 --- a/app/src/components/vault/transactionCopy/cycle.test.tsx +++ b/app/src/components/vault/transactionCopy/cycle.test.tsx @@ -3,6 +3,7 @@ import type * as AppLib from "@stellar-scaffold/app-lib" import { fireEvent, screen } from "@testing-library/react" import { describe, expect, it, vi } from "vitest" import { VaultError } from "../../../../../app-lib/clients/async_vault/src/index" +import { FungibleTokenError } from "../../../../../app-lib/clients/share_token/src/index" import { contractErrorCode } from "../../../test/contractErrorCode" import { renderCloseEpochModal, @@ -512,6 +513,37 @@ describe("TransactionModal, funding the reserve", () => { expect(screen.queryByText(/\b10\b/)).toBeNull() }) + it("points the funder at their wallet when the asset refuses with a named token error", () => { + renderFundModal({ + status: "failed", + failure: { + kind: "contract-error", + code: contractErrorCode(FungibleTokenError, "InsufficientBalance"), + name: "InsufficientBalance", + }, + }) + + expect( + screen.getByText( + "Your wallet could not cover the transfer. Check the balance and the trustline.", + ), + ).toBeTruthy() + }) + + it("falls back to the vault's generic refusal for an unnamed vault code", () => { + renderFundModal({ + status: "failed", + failure: { kind: "contract-error", code: 6999, name: undefined }, + }) + + expect( + screen.getByText( + "The vault refused this request. Try again, and contact support if it keeps happening.", + ), + ).toBeTruthy() + expect(screen.queryByText(/wallet could not cover/)).toBeNull() + }) + it("sends an unknown outcome to the Cycle card, not to the investor's requests", () => { renderFundModal({ status: "failed", failure: { kind: "unknown" } }) diff --git a/app/src/components/vault/transactionCopy/cycle.ts b/app/src/components/vault/transactionCopy/cycle.ts index 23acd58d..45b6dca2 100644 --- a/app/src/components/vault/transactionCopy/cycle.ts +++ b/app/src/components/vault/transactionCopy/cycle.ts @@ -184,9 +184,10 @@ export const deployCopy = (amount: string): StatusCopy => ({ export const fundCopy = (amount: string): StatusCopy => ({ ...OPERATOR, reasons: FUND_REASONS, - // the asset's transfer errors are the only ones no generated client names - unnamedFallback: - "Your wallet could not cover the transfer. Check the balance and the trustline.", + fallbackFor: (code) => + code < 6000 + ? "Your wallet could not cover the transfer. Check the balance and the trustline." + : undefined, preparing: { heading: `Preparing to fund ${amount}`, body: "We are getting the funding ready. Your wallet will ask you to approve it next.", diff --git a/app/src/components/vault/transactionCopy/describe.ts b/app/src/components/vault/transactionCopy/describe.ts index 36cd50f0..ae20279e 100644 --- a/app/src/components/vault/transactionCopy/describe.ts +++ b/app/src/components/vault/transactionCopy/describe.ts @@ -1,4 +1,7 @@ -import { type SubmitFailure } from "@stellar-scaffold/app-lib" +import { + type ContractError, + type SubmitFailure, +} from "@stellar-scaffold/app-lib" import { type TransactionFailure, type TransactionStatus, @@ -24,21 +27,20 @@ const unknownOutcome = (followUp: string) => ({ }) const contractErrorReason = ( - copy: Pick, - name: string | undefined, -): string => { - if (name === undefined) return copy.unnamedFallback ?? copy.fallback - return ( - (Object.hasOwn(copy.reasons, name) ? copy.reasons[name] : undefined) ?? - copy.fallback - ) -} + copy: Pick, + { code, name }: ContractError, +): string => + (name !== undefined && Object.hasOwn(copy.reasons, name) + ? copy.reasons[name] + : undefined) ?? + copy.fallbackFor?.(code) ?? + copy.fallback const describeFailure = ( failure: TransactionFailure, copy: Pick< StatusCopy, - "reasons" | "fallback" | "unnamedFallback" | "followUp" | "refuser" + "reasons" | "fallback" | "fallbackFor" | "followUp" | "refuser" >, ): Copy => { switch (failure.kind) { @@ -66,7 +68,7 @@ const describeFailure = ( case "contract-error": return { heading: `The ${copy.refuser} refused this request`, - body: contractErrorReason(copy, failure.name), + body: contractErrorReason(copy, failure), } case "interrupted": return { diff --git a/app/src/components/vault/transactionCopy/types.ts b/app/src/components/vault/transactionCopy/types.ts index d8fdbddd..bd59e04e 100644 --- a/app/src/components/vault/transactionCopy/types.ts +++ b/app/src/components/vault/transactionCopy/types.ts @@ -198,7 +198,7 @@ export type StatusCopy> = { shared?: (status: SharedTransaction) => Copy reasons: Reasons fallback: string - unnamedFallback?: string + fallbackFor?: (code: number) => string | undefined followUp: string refuser: string } From 080b1a3c1f3ac2245fb10f4edecc73a632be4a0a Mon Sep 17 00:00:00 2001 From: luchobonatti Date: Mon, 2 Nov 2026 17:32:22 -0300 Subject: [PATCH 4/4] test(app): check copy reasons against errors --- .../vault/transactionCopy/reasons.test.ts | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 app/src/components/vault/transactionCopy/reasons.test.ts diff --git a/app/src/components/vault/transactionCopy/reasons.test.ts b/app/src/components/vault/transactionCopy/reasons.test.ts new file mode 100644 index 00000000..5e7fc8d1 --- /dev/null +++ b/app/src/components/vault/transactionCopy/reasons.test.ts @@ -0,0 +1,58 @@ +import { generatedSpecs } from "@stellar-scaffold/app-lib" +import { describe, expect, it } from "vitest" +import { ADMIN_COPY } from "./admin" +import { + activateWindDownCopy, + attestCopy, + closeEpochCopy, + deployCopy, + finalizeRoundCopy, + fulfillEpochCopy, + fundCopy, +} from "./cycle" +import { + cancelCopy, + cancelRedeemCopy, + claimCopy, + claimRedeemCopy, + claimWindDownCopy, + redeemCopy, + subscribeCopy, +} from "./investor" +import { type Reasons } from "./types" + +const copies: [string, { reasons: Reasons }][] = [ + ["subscribe", subscribeCopy("", "")], + ["cancel", cancelCopy("", "")], + ["cancel-redeem", cancelRedeemCopy("", "", "", false)], + ["claim", claimCopy("", "", "")], + ["redeem", redeemCopy("", "")], + ["claim-redeem", claimRedeemCopy("", "", "")], + ["claim-wind-down", claimWindDownCopy("", "", "")], + ["close-epoch", closeEpochCopy("")], + ["fulfill-epoch", fulfillEpochCopy("")], + ["attest", attestCopy("", null)], + ["deploy", deployCopy("")], + ["fund", fundCopy("")], + ["activate-wind-down", activateWindDownCopy()], + ["finalize-round", finalizeRoundCopy("")], + ...Object.entries(ADMIN_COPY).map( + ([action, copy]): [string, { reasons: Reasons }] => [action, copy("")], + ), +] + +describe("transaction copy reasons", () => { + it("keys every reason by an error name a generated client declares", () => { + const names = new Set( + generatedSpecs().flatMap((spec) => + spec.errorCases().map((errorCase) => errorCase.name().toString()), + ), + ) + + for (const [action, copy] of copies) { + for (const name of Object.keys(copy.reasons)) { + expect(names, `${action} reason ${name}`).toContain(name) + } + } + }) +})