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/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..0c94a0bb --- /dev/null +++ b/app/src/components/vault/transactionCopy/cycle.test.tsx @@ -0,0 +1,723 @@ +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 { FungibleTokenError } from "../../../../../app-lib/clients/share_token/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("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" } }) + + 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..45b6dca2 --- /dev/null +++ b/app/src/components/vault/transactionCopy/cycle.ts @@ -0,0 +1,251 @@ +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, + 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.", + }, + 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..ae20279e --- /dev/null +++ b/app/src/components/vault/transactionCopy/describe.ts @@ -0,0 +1,108 @@ +import { + type ContractError, + 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, + { 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" | "fallbackFor" | "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), + } + 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/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) + } + } + }) +}) diff --git a/app/src/components/vault/transactionCopy/types.ts b/app/src/components/vault/transactionCopy/types.ts new file mode 100644 index 00000000..bd59e04e --- /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 + fallbackFor?: (code: number) => string | undefined + followUp: string + refuser: string +} 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/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/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)})` 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( + , + )