diff --git a/app/src/components/admin/ComplianceSection.test.tsx b/app/src/components/admin/ComplianceSection.test.tsx index 2782a94c..75a1a011 100644 --- a/app/src/components/admin/ComplianceSection.test.tsx +++ b/app/src/components/admin/ComplianceSection.test.tsx @@ -1,5 +1,13 @@ -import { fireEvent, render, screen, within } from "@testing-library/react" -import { beforeEach, describe, expect, it, type Mock, vi } from "vitest" +import { act, fireEvent, render, screen, within } from "@testing-library/react" +import { + afterEach, + beforeEach, + describe, + expect, + it, + type Mock, + vi, +} from "vitest" import { type Grant } from "../../hooks/useAdminAuthority" import { type TransactionStatus } from "../../hooks/useContractTransaction" import ComplianceSection from "./ComplianceSection" @@ -8,10 +16,12 @@ const { txs, useComplianceStateMock } = vi.hoisted(() => { const tx = (): { status: TransactionStatus submit: Mock<() => boolean> + prepare: Mock reset: Mock } => ({ status: { status: "idle" }, submit: vi.fn(() => true), + prepare: vi.fn(), reset: vi.fn(), }) return { @@ -95,6 +105,10 @@ const readsFor = ({ target }: { target: string | undefined }) => ({ }) describe("ComplianceSection", () => { + afterEach(() => { + vi.useRealTimers() + }) + beforeEach(() => { vi.clearAllMocks() for (const transaction of Object.values(txs)) @@ -220,6 +234,56 @@ describe("ComplianceSection", () => { ).toBe(false) }) + it("prepares allowing the target once it settles and still submits it on click", () => { + vi.useFakeTimers() + render( + , + ) + enterTarget(TARGET) + openRow("Allow the address") + + act(() => { + vi.advanceTimersByTime(400) + }) + + const allowArg = { verifier: "CVERIFIERADDRESS", account: TARGET } + expect(txs.useAllowAddress.prepare).toHaveBeenCalledWith( + allowArg, + verifierAdmin, + ) + + fireEvent.click(button("Allow the address")) + + expect(txs.useAllowAddress.submit).toHaveBeenCalledWith( + allowArg, + verifierAdmin, + ) + }) + + it("prepares nothing while the compliance tab is hidden", () => { + vi.useFakeTimers() + render( + , + ) + enterTarget(TARGET) + openRow("Allow the address") + + act(() => { + vi.advanceTimersByTime(400) + }) + + expect(txs.useAllowAddress.prepare).not.toHaveBeenCalled() + }) + it("allows the target and follows it in the modal", () => { const { rerender } = render( -const submitArg = ( - { submit }: { submit: (arg: Arg, label: string, grant?: Grant) => boolean }, +const sendArg = ( + send: Send, + flow: Sendable, { arg, next, grant }: { arg: Arg | undefined; next: string; grant?: Grant }, -) => arg !== undefined && submit(arg, next, grant) +) => (arg === undefined ? undefined : send(flow, arg, next, grant)) -function submit(flows: Flows, action: ComplianceAction) { +function dispatch(flows: Flows, action: ComplianceAction, send: Send) { switch (action.id) { case "allow-address": case "disallow-address": - return submitArg(flows[action.id], action) + return sendArg(send, flows[action.id], action) case "freeze-address": case "unfreeze-address": - return submitArg(flows[action.id], action) + return sendArg(send, flows[action.id], action) case "freeze-shares": case "unfreeze-shares": - return submitArg(flows[action.id], action) + return sendArg(send, flows[action.id], action) case "force-transfer": - return submitArg(flows[action.id], action) + return sendArg(send, flows[action.id], action) } } @@ -151,7 +156,12 @@ const ComplianceSection: React.FC<{ actions={actions} pending={false} unreadable={false} - onRun={(action) => submit(flows, action)} + onRun={(action) => dispatch(flows, action, submitTo)} + onReady={ + active + ? (action) => dispatch(flows, action, prepareFor) + : undefined + } onInput={onInput} /> } diff --git a/app/src/components/admin/ConfigurationSection.test.tsx b/app/src/components/admin/ConfigurationSection.test.tsx index 8c9e839b..60288ce1 100644 --- a/app/src/components/admin/ConfigurationSection.test.tsx +++ b/app/src/components/admin/ConfigurationSection.test.tsx @@ -1,5 +1,5 @@ -import { fireEvent, render, screen, within } from "@testing-library/react" -import { beforeEach, describe, expect, it, vi } from "vitest" +import { act, fireEvent, render, screen, within } from "@testing-library/react" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" import { type Grant } from "../../hooks/useAdminAuthority" import { type ConfigurationState } from "../../hooks/useConfigurationState" import { type TransactionStatus } from "../../hooks/useContractTransaction" @@ -18,6 +18,7 @@ const { const tx = () => ({ status: { status: "idle" } as { status: string }, submit: vi.fn(() => true), + prepare: vi.fn(), reset: vi.fn(), }) return { @@ -93,6 +94,10 @@ const submit = (name: string) => .find((candidate) => !candidate.hasAttribute("aria-expanded"))! describe("ConfigurationSection", () => { + afterEach(() => { + vi.useRealTimers() + }) + beforeEach(() => { vi.clearAllMocks() for (const mock of [ @@ -125,6 +130,46 @@ describe("ConfigurationSection", () => { expect(within(token).getByText("Identity verifier")).toBeTruthy() }) + it("prepares a typed notice once it settles and still submits it on click", () => { + vi.useFakeTimers() + render() + expand("Set the notice") + + fireEvent.change(screen.getByRole("textbox", { name: "Notice" }), { + target: { value: "2d" }, + }) + act(() => { + vi.advanceTimersByTime(400) + }) + + expect(noticeMock.prepare).toHaveBeenCalledWith(2n * DAY, governance) + + fireEvent.click(submit("Set the notice")) + + expect(noticeMock.submit).toHaveBeenCalledWith(2n * DAY, governance) + }) + + it("prepares nothing while the configuration tab is hidden", () => { + vi.useFakeTimers() + render( + , + ) + expand("Set the notice") + fireEvent.change(screen.getByRole("textbox", { name: "Notice" }), { + target: { value: "2d" }, + }) + + act(() => { + vi.advanceTimersByTime(400) + }) + + expect(noticeMock.prepare).not.toHaveBeenCalled() + }) + it("submits a new notice once typed, and follows it in the modal", () => { const { rerender } = render( , diff --git a/app/src/components/admin/ConfigurationSection.tsx b/app/src/components/admin/ConfigurationSection.tsx index 7f3846ef..286f9492 100644 --- a/app/src/components/admin/ConfigurationSection.tsx +++ b/app/src/components/admin/ConfigurationSection.tsx @@ -11,6 +11,9 @@ import { useSetWindDownDelay, } from "../../hooks/useConfigurationTransactions" import { + prepareFor, + type Send, + submitTo, usePendingTransaction, type UsePendingTransaction, } from "../../hooks/usePendingTransaction" @@ -83,23 +86,23 @@ const ConfigurationSection: React.FC<{ const actions = toConfigurationActions(configuration, grants, wallet, inputs) - const run = (action: ConfigurationAction) => { + const dispatch = (action: ConfigurationAction, send: Send) => { if (action.arg === undefined) return switch (action.id) { case "set-deposit-cap": - return depositCap.submit(action.arg, action.next, action.grant) + return send(depositCap, action.arg, action.next, action.grant) case "set-custodian": - return custodian.submit(action.arg, action.next, action.grant) + return send(custodian, action.arg, action.next, action.grant) case "set-notice": - return notice.submit(action.arg, action.next, action.grant) + return send(notice, action.arg, action.next, action.grant) case "set-wind-down-delay": - return windDownDelay.submit(action.arg, action.next, action.grant) + return send(windDownDelay, action.arg, action.next, action.grant) case "set-oracle-config": - return oracleConfig.submit(action.arg, action.next, action.grant) + return send(oracleConfig, action.arg, action.next, action.grant) case "set-compliance": - return compliance.submit(action.arg, action.next, action.grant) + return send(compliance, action.arg, action.next, action.grant) case "set-identity-verifier": - return identityVerifier.submit(action.arg, action.next, action.grant) + return send(identityVerifier, action.arg, action.next, action.grant) } } @@ -119,7 +122,10 @@ const ConfigurationSection: React.FC<{ actions={actions} pending={configuration.status === "checking"} unreadable={false} - onRun={run} + onRun={(action) => dispatch(action, submitTo)} + onReady={ + active ? (action) => dispatch(action, prepareFor) : undefined + } onInput={onInput} /> } diff --git a/app/src/components/admin/CycleActionRail.test.tsx b/app/src/components/admin/CycleActionRail.test.tsx index eec99204..2a8f2a8d 100644 --- a/app/src/components/admin/CycleActionRail.test.tsx +++ b/app/src/components/admin/CycleActionRail.test.tsx @@ -1,5 +1,5 @@ import { fireEvent, render, screen, within } from "@testing-library/react" -import { describe, expect, it, vi } from "vitest" +import { afterEach, describe, expect, it, vi } from "vitest" import { type RailAction, type RailField } from "../../pages/actions" import { type CycleAction } from "../../pages/cycle" import CycleActionRail from "./CycleActionRail" @@ -606,3 +606,143 @@ describe("CycleActionRail choice fields", () => { }, ) }) + +describe("CycleActionRail readiness", () => { + afterEach(() => { + vi.useRealTimers() + }) + + const renderWithReady = (actions: CycleAction[]) => { + const onReady = vi.fn() + render( + , + ) + return onReady + } + + it("reports the open action once it settles as ready", () => { + vi.useFakeTimers() + const onReady = renderWithReady([closeAction, fulfillAction]) + + expect(onReady).not.toHaveBeenCalled() + vi.advanceTimersByTime(400) + + expect(onReady).toHaveBeenCalledWith(closeAction) + }) + + it("releases the reported action once it is collapsed", () => { + vi.useFakeTimers() + const release = vi.fn() + render( + release} + />, + ) + vi.advanceTimersByTime(400) + + expect(release).not.toHaveBeenCalled() + + fireEvent.click(rowToggle("Close epoch")) + + expect(release).toHaveBeenCalledTimes(1) + }) + + it("releases the reported action once it is no longer ready", () => { + vi.useFakeTimers() + const release = vi.fn() + const rail = (action: CycleAction) => ( + release} + /> + ) + const { rerender } = render(rail(closeAction)) + vi.advanceTimersByTime(400) + + rerender(rail({ ...closeAction, enabled: false })) + vi.advanceTimersByTime(400) + + expect(release).toHaveBeenCalledTimes(1) + }) + + it("reports an equal action once when the parent rerenders inside the delay", () => { + vi.useFakeTimers() + const onReady = vi.fn() + const rail = () => ( + onReady(action)} + /> + ) + const { rerender } = render(rail()) + vi.advanceTimersByTime(300) + rerender(rail()) + vi.advanceTimersByTime(150) + + expect(onReady).toHaveBeenCalledTimes(1) + + rerender(rail()) + vi.advanceTimersByTime(1000) + + expect(onReady).toHaveBeenCalledTimes(1) + }) + + it("releases and re-arms when the action's argument changes", () => { + vi.useFakeTimers() + const release = vi.fn() + const onReady = vi.fn(() => release) + const rail = (epochId: bigint) => ( + + ) + const { rerender } = render(rail(5n)) + vi.advanceTimersByTime(400) + rerender(rail(6n)) + + expect(release).toHaveBeenCalledTimes(1) + + vi.advanceTimersByTime(400) + + expect(onReady).toHaveBeenCalledTimes(2) + expect(onReady).toHaveBeenLastCalledWith( + expect.objectContaining({ epochId: 6n }), + ) + }) + + it("does not report an open action that is blocked", () => { + vi.useFakeTimers() + const onReady = renderWithReady([closeAction, fulfillAction]) + + fireEvent.click(rowToggle("Close epoch")) + fireEvent.click(rowToggle("Fulfill epoch")) + vi.advanceTimersByTime(400) + + expect(onReady).not.toHaveBeenCalled() + }) +}) diff --git a/app/src/components/admin/CycleActionRail.tsx b/app/src/components/admin/CycleActionRail.tsx index eaaf51aa..080ff4c0 100644 --- a/app/src/components/admin/CycleActionRail.tsx +++ b/app/src/components/admin/CycleActionRail.tsx @@ -1,4 +1,5 @@ -import React, { useState } from "react" +import React, { useEffect, useRef, useState } from "react" +import { type Release } from "../../hooks/useContractTransaction" import { type ActionField as ActionFieldSpec, type Condition, @@ -16,6 +17,13 @@ import styles from "./CycleActionRail.module.css" type OnInput = (action: A, value: string, key?: string) => void +const PREPARE_DELAY_MS = 400 + +const serialise = (value: unknown): string => + JSON.stringify(value, (_name, part: unknown) => + typeof part === "bigint" ? `${part}n` : part, + ) + type Status = "pass" | "fail" | "pending" const statusOf = ({ met }: Condition): Status => { @@ -276,6 +284,7 @@ function CycleActionRail>({ pending, unreadable, onRun, + onReady, onInput, }: { scope: string @@ -283,11 +292,33 @@ function CycleActionRail>({ pending: boolean unreadable: boolean onRun: (action: A) => void + onReady?: (action: A) => Release | undefined onInput?: OnInput }) { const [open, setOpen] = useState() if (open === undefined && actions.length > 0) setOpen(actions.find((action) => action.enabled)?.id ?? null) + const ready = actions.find((action) => action.id === open && action.enabled) + + const latest = useRef({ ready, onReady }) + useEffect(() => { + latest.current = { ready, onReady } + }) + const readyKey = ready === undefined ? null : serialise(ready) + const reportable = onReady !== undefined + + useEffect(() => { + if (readyKey === null || !reportable) return + let release: Release | undefined + const timer = setTimeout(() => { + const { ready: action, onReady: report } = latest.current + if (action !== undefined) release = report?.(action) + }, PREPARE_DELAY_MS) + return () => { + clearTimeout(timer) + release?.() + } + }, [readyKey, reportable]) return (
diff --git a/app/src/components/admin/CycleSection.test.tsx b/app/src/components/admin/CycleSection.test.tsx index 6064be2e..249b9979 100644 --- a/app/src/components/admin/CycleSection.test.tsx +++ b/app/src/components/admin/CycleSection.test.tsx @@ -1,6 +1,6 @@ import { type Amount, type Price } from "@stellar-scaffold/app-lib" -import { fireEvent, render, screen, within } from "@testing-library/react" -import { beforeEach, describe, expect, it, vi } from "vitest" +import { act, fireEvent, render, screen, within } from "@testing-library/react" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" import { type ActivateWindDownStatus } from "../../hooks/useActivateWindDown" import { type Grant } from "../../hooks/useAdminAuthority" import { type AttestStatus } from "../../hooks/useAttest" @@ -34,36 +34,43 @@ const { closeEpochMock: { status: { status: "idle" } as CloseEpochStatus, submit: vi.fn(() => true), + prepare: vi.fn(), reset: vi.fn(), }, fulfillEpochMock: { status: { status: "idle" } as FulfillEpochStatus, submit: vi.fn(() => true), + prepare: vi.fn(), reset: vi.fn(), }, attestMock: { status: { status: "idle" } as AttestStatus, submit: vi.fn(() => true), + prepare: vi.fn(), reset: vi.fn(), }, deployMock: { status: { status: "idle" } as DeployToCustodianStatus, submit: vi.fn(() => true), + prepare: vi.fn(), reset: vi.fn(), }, fundMock: { status: { status: "idle" } as FundStatus, submit: vi.fn(() => true), + prepare: vi.fn(), reset: vi.fn(), }, activateMock: { status: { status: "idle" } as ActivateWindDownStatus, submit: vi.fn(() => true), + prepare: vi.fn(), reset: vi.fn(), }, finalizeMock: { status: { status: "idle" } as FinalizeWindDownRoundStatus, submit: vi.fn(() => true), + prepare: vi.fn(), reset: vi.fn(), }, })) @@ -218,6 +225,10 @@ const fieldOf = (name: string) => { } describe("CycleSection", () => { + afterEach(() => { + vi.useRealTimers() + }) + beforeEach(() => { vi.clearAllMocks() closeEpochMock.status = { status: "idle" } @@ -607,6 +618,32 @@ describe("CycleSection", () => { expect(notice.textContent).toMatch(/^Fail: Notice elapsed/) }) + it("prepares the open ready close and still submits it on click", () => { + vi.useFakeTimers() + render() + + act(() => { + vi.advanceTimersByTime(400) + }) + + expect(closeEpochMock.prepare).toHaveBeenCalledWith(undefined, manager) + + fireEvent.click(button("Close epoch")) + + expect(closeEpochMock.submit).toHaveBeenCalledWith(undefined, manager) + }) + + it("prepares nothing while the cycle tab is hidden", () => { + vi.useFakeTimers() + render() + + act(() => { + vi.advanceTimersByTime(400) + }) + + expect(closeEpochMock.prepare).not.toHaveBeenCalled() + }) + it("lets a manager signing through a multisig close the epoch, passing its grant", () => { const shared = grant("vault manager", { needs: 2 }) render() diff --git a/app/src/components/admin/CycleSection.tsx b/app/src/components/admin/CycleSection.tsx index 287e4e86..05e49b3a 100644 --- a/app/src/components/admin/CycleSection.tsx +++ b/app/src/components/admin/CycleSection.tsx @@ -10,7 +10,12 @@ import { useEpochHistory } from "../../hooks/useEpochHistory" import { useFinalizeWindDownRound } from "../../hooks/useFinalizeWindDownRound" import { useFulfillEpoch } from "../../hooks/useFulfillEpoch" import { useFund } from "../../hooks/useFund" -import { usePendingTransaction } from "../../hooks/usePendingTransaction" +import { + prepareFor, + type Send, + submitTo, + usePendingTransaction, +} from "../../hooks/usePendingTransaction" import { type CycleAction, exactAmount, @@ -62,38 +67,49 @@ const CycleSection: React.FC<{ fund.status.status === "confirmed", ) - const run = (action: CycleAction) => { + const dispatch = (action: CycleAction, send: Send) => { switch (action.id) { case "close-epoch": if (action.epochId !== null) - closeEpoch.submit(undefined, String(action.epochId), action.grant) - return + return send( + closeEpoch, + undefined, + String(action.epochId), + action.grant, + ) + return undefined case "fulfill-epoch": if (action.epochId !== null) - fulfillEpoch.submit(action.epochId, String(action.epochId)) - return + return send(fulfillEpoch, action.epochId, String(action.epochId)) + return undefined case "attest": if (action.price !== null && action.freshness !== null) - attest.submit( + return send( + attest, { price: action.price, freshness: action.freshness }, formatSignedPrice(action.price), action.grant, ) - return + return undefined case "deploy": if (action.amount !== null) - deploy.submit(action.amount, exactAmount(action.amount), action.grant) - return + return send( + deploy, + action.amount, + exactAmount(action.amount), + action.grant, + ) + return undefined case "fund": if (action.amount !== null) - fund.submit(action.amount, exactAmount(action.amount)) - return + return send(fund, action.amount, exactAmount(action.amount)) + return undefined case "activate-wind-down": - activateWindDown.submit(undefined, "") - return + return send(activateWindDown, undefined, "") case "finalize-round": if (action.round !== null) - finalizeRound.submit(undefined, String(action.round)) + return send(finalizeRound, undefined, String(action.round)) + return undefined } } @@ -192,7 +208,10 @@ const CycleSection: React.FC<{ actions={actions} pending={cycle.status === "checking"} unreadable={cycle.status === "unreadable"} - onRun={run} + onRun={(action) => dispatch(action, submitTo)} + onReady={ + active ? (action) => dispatch(action, prepareFor) : undefined + } onInput={onInput} /> } diff --git a/app/src/components/admin/EmergencySection.test.tsx b/app/src/components/admin/EmergencySection.test.tsx index 31e142e0..3dba67c8 100644 --- a/app/src/components/admin/EmergencySection.test.tsx +++ b/app/src/components/admin/EmergencySection.test.tsx @@ -1,5 +1,5 @@ -import { fireEvent, render, screen, within } from "@testing-library/react" -import { beforeEach, describe, expect, it, vi } from "vitest" +import { act, fireEvent, render, screen, within } from "@testing-library/react" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" import { type Grant } from "../../hooks/useAdminAuthority" import { type TransactionStatus } from "../../hooks/useContractTransaction" import EmergencySection from "./EmergencySection" @@ -7,6 +7,7 @@ import EmergencySection from "./EmergencySection" const transaction = () => ({ status: { status: "idle" } as TransactionStatus, submit: vi.fn(() => true), + prepare: vi.fn(), reset: vi.fn(), }) @@ -25,6 +26,7 @@ const { const tx = () => ({ status: { status: "idle" } as { status: string }, submit: vi.fn(() => true), + prepare: vi.fn(), reset: vi.fn(), }) return { @@ -114,6 +116,38 @@ describe("EmergencySection", () => { setState("open", "open", "valid") }) + afterEach(() => { + vi.useRealTimers() + }) + + it("prepares the open ready action and still submits it on click", () => { + vi.useFakeTimers() + render() + + act(() => { + vi.advanceTimersByTime(400) + }) + + expect(pauseVaultMock.prepare).toHaveBeenCalledWith(undefined, guardian) + + fireEvent.click(button("Pause the vault")) + + expect(pauseVaultMock.submit).toHaveBeenCalledWith(undefined, guardian) + }) + + it("prepares nothing while the emergency tab is hidden", () => { + vi.useFakeTimers() + render( + , + ) + + act(() => { + vi.advanceTimersByTime(400) + }) + + expect(pauseVaultMock.prepare).not.toHaveBeenCalled() + }) + it("shows the vault, share token and oracle state first", () => { setState("paused", "open", "paused") render() diff --git a/app/src/components/admin/EmergencySection.tsx b/app/src/components/admin/EmergencySection.tsx index 01622fcb..ae4da3c1 100644 --- a/app/src/components/admin/EmergencySection.tsx +++ b/app/src/components/admin/EmergencySection.tsx @@ -11,7 +11,12 @@ import { useUnpauseVault, } from "../../hooks/useEmergencyTransactions" import { useOracleState } from "../../hooks/useOracleState" -import { usePendingTransaction } from "../../hooks/usePendingTransaction" +import { + prepareFor, + type Send, + submitTo, + usePendingTransaction, +} from "../../hooks/usePendingTransaction" import { useTokenPaused } from "../../hooks/useTokenPaused" import { useVaultPaused } from "../../hooks/useVaultPaused" import { @@ -52,29 +57,24 @@ const EmergencySection: React.FC<{ const state: EmergencyState = { vault, token, oracle } const actions = toEmergencyActions(state, grants, wallet) - const run = (action: EmergencyAction) => { + const dispatch = (action: EmergencyAction, send: Send) => { switch (action.id) { case "pause-vault": - pauseVault.submit(undefined, "", action.grant) - return + return send(pauseVault, undefined, "", action.grant) case "unpause-vault": - unpauseVault.submit(undefined, "", action.grant) - return + return send(unpauseVault, undefined, "", action.grant) case "raise-ripcord": - if (action.viaAdmin) setRipcord.submit(true, RAISE, action.grant) - else raiseRipcord.submit(undefined, "", action.grant) - return + return action.viaAdmin + ? send(setRipcord, true, RAISE, action.grant) + : send(raiseRipcord, undefined, "", action.grant) case "lower-ripcord": - setRipcord.submit(false, LOWER, action.grant) - return + return send(setRipcord, false, LOWER, action.grant) case "clear-latest": - clearLatest.submit(undefined, "", action.grant) - return + return send(clearLatest, undefined, "", action.grant) case "pause-token": - pauseToken.submit(undefined, "", action.grant) - return + return send(pauseToken, undefined, "", action.grant) case "unpause-token": - unpauseToken.submit(undefined, "", action.grant) + return send(unpauseToken, undefined, "", action.grant) } } @@ -115,7 +115,10 @@ const EmergencySection: React.FC<{ actions={actions} pending={false} unreadable={false} - onRun={run} + onRun={(action) => dispatch(action, submitTo)} + onReady={ + active ? (action) => dispatch(action, prepareFor) : undefined + } /> } > diff --git a/app/src/components/admin/GovernanceSection.test.tsx b/app/src/components/admin/GovernanceSection.test.tsx index a27cc6d4..7cdcd4aa 100644 --- a/app/src/components/admin/GovernanceSection.test.tsx +++ b/app/src/components/admin/GovernanceSection.test.tsx @@ -1,6 +1,14 @@ import { type PendingTransfer } from "@stellar-scaffold/app-lib" -import { fireEvent, render, screen, within } from "@testing-library/react" -import { beforeEach, describe, expect, it, type Mock, vi } from "vitest" +import { act, fireEvent, render, screen, within } from "@testing-library/react" +import { + afterEach, + beforeEach, + describe, + expect, + it, + type Mock, + vi, +} from "vitest" import { type AdminAddresses, type Grant, @@ -22,10 +30,12 @@ const { txs, useGovernanceStateMock } = vi.hoisted(() => { const tx = (): { status: TransactionStatus submit: Mock<() => boolean> + prepare: Mock reset: Mock } => ({ status: { status: "idle" }, submit: vi.fn(() => true), + prepare: vi.fn(), reset: vi.fn(), }) return { @@ -148,6 +158,10 @@ const renderSection = (grants: Grant[], active = true) => ) describe("GovernanceSection", () => { + afterEach(() => { + vi.useRealTimers() + }) + beforeEach(() => { vi.clearAllMocks() useGovernanceStateMock.mockReturnValue({ @@ -157,6 +171,7 @@ describe("GovernanceSection", () => { for (const mock of Object.values(txs)) { mock.status = { status: "idle" } mock.submit = vi.fn(() => true) + mock.prepare = vi.fn() mock.reset = vi.fn() } }) @@ -207,6 +222,53 @@ describe("GovernanceSection", () => { ).toEqual([]) }) + it("prepares a filled-in grant once it settles and still submits it on click", () => { + vi.useFakeTimers() + renderSection([governance]) + expand("Grant a vault role") + fireEvent.change(screen.getByRole("combobox", { name: "Role" }), { + target: { value: "guardian" }, + }) + fireEvent.change(screen.getByRole("textbox", { name: "Account" }), { + target: { value: ACCOUNT }, + }) + + act(() => { + vi.advanceTimersByTime(400) + }) + + const grantArg = { role: "guardian", account: ACCOUNT } + expect(txs.useGrantVaultRole.prepare).toHaveBeenCalledWith( + grantArg, + governance, + ) + + fireEvent.click(submit("Grant a vault role")) + + expect(txs.useGrantVaultRole.submit).toHaveBeenCalledWith( + grantArg, + governance, + ) + }) + + it("prepares nothing while the governance tab is hidden", () => { + vi.useFakeTimers() + renderSection([governance], false) + expand("Grant a vault role") + fireEvent.change(screen.getByRole("combobox", { name: "Role" }), { + target: { value: "guardian" }, + }) + fireEvent.change(screen.getByRole("textbox", { name: "Account" }), { + target: { value: ACCOUNT }, + }) + + act(() => { + vi.advanceTimersByTime(400) + }) + + expect(txs.useGrantVaultRole.prepare).not.toHaveBeenCalled() + }) + it("grants the chosen role to the typed account", () => { renderSection([governance]) diff --git a/app/src/components/admin/GovernanceSection.tsx b/app/src/components/admin/GovernanceSection.tsx index e51a640f..f67dac73 100644 --- a/app/src/components/admin/GovernanceSection.tsx +++ b/app/src/components/admin/GovernanceSection.tsx @@ -30,6 +30,10 @@ import { useRevokeVaultRole, } from "../../hooks/useGovernanceTransactions" import { + prepareFor, + type Send, + type Sendable, + submitTo, usePendingTransaction, type UsePendingTransaction, } from "../../hooks/usePendingTransaction" @@ -94,12 +98,13 @@ function useGovernanceFlows() { type Flows = ReturnType -const submitArg = ( - { submit }: { submit: (arg: Arg, label: string, grant?: Grant) => boolean }, +const sendArg = ( + send: Send, + flow: Sendable, { arg, next, grant }: { arg: Arg | undefined; next: string; grant?: Grant }, -) => arg !== undefined && submit(arg, next, grant) +) => (arg === undefined ? undefined : send(flow, arg, next, grant)) -function submit(flows: Flows, action: GovernanceAction) { +function dispatch(flows: Flows, action: GovernanceAction, send: Send) { switch (action.id) { case "grant-vault-role": case "revoke-vault-role": @@ -107,18 +112,18 @@ function submit(flows: Flows, action: GovernanceAction) { case "revoke-oracle-role": case "grant-token-role": case "revoke-token-role": - return submitArg(flows[action.id], action) + return sendArg(send, flows[action.id], action) case "hand-over-vault-admin": case "cancel-vault-handover": case "hand-over-oracle-admin": case "cancel-oracle-handover": case "hand-over-token-admin": case "cancel-token-handover": - return submitArg(flows[action.id], action) + return sendArg(send, flows[action.id], action) case "propose-code-upgrade": - return submitArg(flows[action.id], action) + return sendArg(send, flows[action.id], action) case "propose-upgrade-delay": - return submitArg(flows[action.id], action) + return sendArg(send, flows[action.id], action) case "accept-vault-admin": case "accept-oracle-admin": case "accept-token-admin": @@ -126,7 +131,7 @@ function submit(flows: Flows, action: GovernanceAction) { case "cancel-upgrade": case "propose-wind-down": case "cancel-wind-down": - return flows[action.id].submit(undefined, action.next, action.grant) + return send(flows[action.id], undefined, action.next, action.grant) } } @@ -174,7 +179,12 @@ const GovernanceSection: React.FC<{ actions={actions} pending={handovers.status === "checking"} unreadable={handovers.status === "unreadable"} - onRun={(action) => submit(flows, action)} + onRun={(action) => dispatch(flows, action, submitTo)} + onReady={ + active + ? (action) => dispatch(flows, action, prepareFor) + : undefined + } onInput={onInput} /> } diff --git a/app/src/components/vault/TransactionModal.test.tsx b/app/src/components/vault/TransactionModal.test.tsx index 83ce5b65..18294c31 100644 --- a/app/src/components/vault/TransactionModal.test.tsx +++ b/app/src/components/vault/TransactionModal.test.tsx @@ -2088,6 +2088,24 @@ describe("TransactionModal, emergency actions", () => { ) }) + 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", diff --git a/app/src/components/vault/TransactionModal.tsx b/app/src/components/vault/TransactionModal.tsx index 98ec47ff..13f1bc52 100644 --- a/app/src/components/vault/TransactionModal.tsx +++ b/app/src/components/vault/TransactionModal.tsx @@ -452,6 +452,11 @@ const describeFailure = ( 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) } @@ -1557,7 +1562,8 @@ const computeSteps = ( } if ( status.failure.kind === "unknown" || - status.failure.kind === "network" + status.failure.kind === "network" || + status.failure.kind === "stale" ) { return { signature: "done", network: "failed", recorded: "upcoming" } } @@ -1644,7 +1650,9 @@ const TransactionModal: React.FC = (props) => { content.hash === undefined ? null : explorerTransaction(content.hash) const offersRetry = status.status === "failed" && - (status.failure.kind === "declined" || status.failure.kind === "unsigned") + (status.failure.kind === "declined" || + status.failure.kind === "unsigned" || + status.failure.kind === "stale") const headingId = "transaction-modal-heading" const headingClassName = isConfirmed diff --git a/app/src/hooks/useActivateWindDown.ts b/app/src/hooks/useActivateWindDown.ts index bf6e7124..9314081d 100644 --- a/app/src/hooks/useActivateWindDown.ts +++ b/app/src/hooks/useActivateWindDown.ts @@ -2,8 +2,9 @@ import { type Signer } from "@stellar-scaffold/app-lib" import { useCallback } from "react" import { asyncVaultWriter } from "../config/clients" import { - useContractTransaction, + type Release, type TransactionStatus, + useContractTransaction, } from "./useContractTransaction" import { cycleWriteKeys } from "./useCycleState" @@ -12,6 +13,7 @@ export type ActivateWindDownStatus = TransactionStatus export interface UseActivateWindDown { status: ActivateWindDownStatus submit: () => boolean + prepare: () => Release reset: () => void } diff --git a/app/src/hooks/useAdminTransaction.test.ts b/app/src/hooks/useAdminTransaction.test.ts index 7be5ae71..3b8cf0c3 100644 --- a/app/src/hooks/useAdminTransaction.test.ts +++ b/app/src/hooks/useAdminTransaction.test.ts @@ -647,6 +647,7 @@ describe("usePauseVault with a grant", () => { const signable = () => { const signed = { toEnvelope: () => ({ toXDR: () => "SIGNEDXDR" }), + hash: () => ({ toString: () => "abcd" }), } const tx = { simulation: undefined, diff --git a/app/src/hooks/useAttest.ts b/app/src/hooks/useAttest.ts index ff94526d..a53b8aaa 100644 --- a/app/src/hooks/useAttest.ts +++ b/app/src/hooks/useAttest.ts @@ -2,8 +2,9 @@ import { type Price, type Signer } from "@stellar-scaffold/app-lib" import { useCallback } from "react" import { navOracleWriter } from "../config/clients" import { - useContractTransaction, + type Release, type TransactionStatus, + useContractTransaction, } from "./useContractTransaction" import { cycleWriteKeys } from "./useCycleState" import { navLatestKey } from "./useNavPrice" @@ -17,6 +18,7 @@ export const attestWriteKeys = [...cycleWriteKeys, navLatestKey] as const export interface UseAttest { status: AttestStatus submit: (attestation: Attestation) => boolean + prepare: (attestation: Attestation) => Release reset: () => void } diff --git a/app/src/hooks/useCancelDeposit.ts b/app/src/hooks/useCancelDeposit.ts index 987c5e41..6da3fe08 100644 --- a/app/src/hooks/useCancelDeposit.ts +++ b/app/src/hooks/useCancelDeposit.ts @@ -2,8 +2,9 @@ import { type Amount, type Signer } from "@stellar-scaffold/app-lib" import { useCallback } from "react" import { asyncVaultWriter } from "../config/clients" import { - useContractTransaction, + type Release, type TransactionStatus, + useContractTransaction, } from "./useContractTransaction" export type CancelDepositStatus = TransactionStatus<{ refundedAmount: Amount }> @@ -11,6 +12,7 @@ export type CancelDepositStatus = TransactionStatus<{ refundedAmount: Amount }> export interface UseCancelDeposit { status: CancelDepositStatus submit: (epochId: bigint) => boolean + prepare: (epochId: bigint) => Release reset: () => void } diff --git a/app/src/hooks/useCancelRedeem.ts b/app/src/hooks/useCancelRedeem.ts index 80df2695..250397d7 100644 --- a/app/src/hooks/useCancelRedeem.ts +++ b/app/src/hooks/useCancelRedeem.ts @@ -2,8 +2,9 @@ import { type Amount, type Signer } from "@stellar-scaffold/app-lib" import { useCallback } from "react" import { asyncVaultWriter } from "../config/clients" import { - useContractTransaction, + type Release, type TransactionStatus, + useContractTransaction, } from "./useContractTransaction" export type CancelRedeemStatus = TransactionStatus<{ returnedShares: Amount }> @@ -11,6 +12,7 @@ export type CancelRedeemStatus = TransactionStatus<{ returnedShares: Amount }> export interface UseCancelRedeem { status: CancelRedeemStatus submit: (epochId: bigint) => boolean + prepare: (epochId: bigint) => Release reset: () => void } diff --git a/app/src/hooks/useClaimDeposit.ts b/app/src/hooks/useClaimDeposit.ts index bcaba295..822d8df1 100644 --- a/app/src/hooks/useClaimDeposit.ts +++ b/app/src/hooks/useClaimDeposit.ts @@ -2,8 +2,9 @@ import { type Amount, type Signer } from "@stellar-scaffold/app-lib" import { useCallback } from "react" import { asyncVaultWriter } from "../config/clients" import { - useContractTransaction, + type Release, type TransactionStatus, + useContractTransaction, } from "./useContractTransaction" export type ClaimDepositStatus = TransactionStatus<{ sharesMinted: Amount }> @@ -11,6 +12,7 @@ export type ClaimDepositStatus = TransactionStatus<{ sharesMinted: Amount }> export interface UseClaimDeposit { status: ClaimDepositStatus submit: (epochId: bigint) => boolean + prepare: (epochId: bigint) => Release reset: () => void } diff --git a/app/src/hooks/useClaimRedeem.ts b/app/src/hooks/useClaimRedeem.ts index dc6c8333..17325c8f 100644 --- a/app/src/hooks/useClaimRedeem.ts +++ b/app/src/hooks/useClaimRedeem.ts @@ -2,8 +2,9 @@ import { type Amount, type Signer } from "@stellar-scaffold/app-lib" import { useCallback } from "react" import { asyncVaultWriter } from "../config/clients" import { - useContractTransaction, + type Release, type TransactionStatus, + useContractTransaction, } from "./useContractTransaction" export type ClaimRedeemStatus = TransactionStatus<{ assetsClaimed: Amount }> @@ -11,6 +12,7 @@ export type ClaimRedeemStatus = TransactionStatus<{ assetsClaimed: Amount }> export interface UseClaimRedeem { status: ClaimRedeemStatus submit: (epochId: bigint) => boolean + prepare: (epochId: bigint) => Release reset: () => void } diff --git a/app/src/hooks/useCloseEpoch.ts b/app/src/hooks/useCloseEpoch.ts index 64359c6f..50e19bed 100644 --- a/app/src/hooks/useCloseEpoch.ts +++ b/app/src/hooks/useCloseEpoch.ts @@ -2,8 +2,9 @@ import { type Signer } from "@stellar-scaffold/app-lib" import { useCallback } from "react" import { asyncVaultWriter } from "../config/clients" import { - useContractTransaction, + type Release, type TransactionStatus, + useContractTransaction, } from "./useContractTransaction" import { cycleWriteKeys } from "./useCycleState" @@ -12,6 +13,7 @@ export type CloseEpochStatus = TransactionStatus<{ sealedEpoch: bigint }> export interface UseCloseEpoch { status: CloseEpochStatus submit: () => boolean + prepare: () => Release reset: () => void } diff --git a/app/src/hooks/useContractTransaction.test.ts b/app/src/hooks/useContractTransaction.test.ts index c5ce300c..94111fd1 100644 --- a/app/src/hooks/useContractTransaction.test.ts +++ b/app/src/hooks/useContractTransaction.test.ts @@ -1,10 +1,18 @@ import { type AssembledTransaction } from "@stellar/stellar-sdk/contract" -import { act } from "@testing-library/react" -import { describe, expect, it, vi } from "vitest" +import type * as AppLib from "@stellar-scaffold/app-lib" +import { act, waitFor } from "@testing-library/react" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" import { investorAddress, renderWithWallet } from "./testSupport" import { useContractTransaction } from "./useContractTransaction" import { investorRequestsKey } from "./useInvestorRequests" +const { submitSignedMock } = vi.hoisted(() => ({ submitSignedMock: vi.fn() })) + +vi.mock("@stellar-scaffold/app-lib", async (importOriginal) => ({ + ...(await importOriginal()), + submitSigned: submitSignedMock, +})) + const extraKeys = [ ["cycle", "history"], ["vault", "figures"], @@ -18,6 +26,9 @@ const transaction = (simulationError?: string) => getTransactionResponse: { status: "SUCCESS" }, result: 1n, }), + sign: vi.fn().mockResolvedValue(undefined), + signed: { signedBy: "wallet", hash: () => ({ toString: () => "abcd" }) }, + result: 1n, }) as unknown as AssembledTransaction const renderTransaction = (simulationError?: string) => @@ -86,3 +97,350 @@ describe("useContractTransaction", () => { expect(invalidateQueries).toHaveBeenCalledTimes(3) }) }) + +const flush = () => + waitFor(() => new Promise((resolve) => setTimeout(resolve, 0))) + +const renderPrepared = ( + call: (signer: unknown, arg: bigint) => Promise>, +) => + renderWithWallet(() => + useContractTransaction(call, (result: bigint) => ({ result }), extraKeys), + ) + +describe("useContractTransaction, prepared simulations", () => { + beforeEach(() => { + submitSignedMock.mockReset() + submitSignedMock.mockResolvedValue({ + status: "success", + hash: "h", + result: 1n, + }) + }) + + afterEach(() => { + vi.useRealTimers() + vi.restoreAllMocks() + }) + + it("signs the prepared transaction without simulating again", async () => { + const prepared = transaction() + const call = vi.fn().mockResolvedValue(prepared) + const { result } = renderPrepared(call) + + act(() => { + result.current.prepare(5n) + }) + await flush() + await act(() => result.current.submit(5n)) + await flush() + + expect(call).toHaveBeenCalledTimes(1) + expect(prepared.sign).toHaveBeenCalledTimes(1) + expect(submitSignedMock).toHaveBeenCalledWith(prepared.signed) + expect(result.current.status).toEqual({ + status: "confirmed", + result: 1n, + hash: "h", + }) + }) + + it("simulates again when the prepared transaction is 30 seconds old", async () => { + const now = vi.spyOn(Date, "now").mockReturnValue(1_000_000) + const call = vi.fn(async () => transaction()) + const { result } = renderPrepared(call) + + act(() => { + result.current.prepare(5n) + }) + await flush() + now.mockReturnValue(1_030_000) + await act(() => result.current.submit(5n)) + await flush() + + expect(call).toHaveBeenCalledTimes(2) + }) + + it("simulates again when the argument differs from the prepared one", async () => { + const call = vi.fn(async () => transaction()) + const { result } = renderPrepared(call) + + act(() => { + result.current.prepare(5n) + }) + await flush() + await act(() => result.current.submit(6n)) + await flush() + + expect(call).toHaveBeenCalledTimes(2) + expect(call).toHaveBeenLastCalledWith(expect.anything(), 6n) + }) + + it("keeps the prepared transaction when a refetch returns the same reads", async () => { + const call = vi.fn(async () => transaction()) + const { result, queryClient } = renderPrepared(call) + const read = () => + queryClient.fetchQuery({ + queryKey: [...extraKeys[0], "epoch"], + queryFn: () => ({ epoch: 4 }), + }) + await act(read) + + act(() => { + result.current.prepare(5n) + }) + await flush() + await act(() => queryClient.refetchQueries({ queryKey: extraKeys[0] })) + await flush() + await act(() => result.current.submit(5n)) + await flush() + + expect(call).toHaveBeenCalledTimes(1) + }) + + it("simulates again in the background when a refetch changes the reads", async () => { + const first = transaction() + const second = transaction() + const call = vi + .fn() + .mockResolvedValueOnce(first) + .mockResolvedValueOnce(second) + const { result, queryClient } = renderPrepared(call) + let epoch = 4 + const queryKey = [...extraKeys[0], "epoch"] + await act(() => + queryClient.fetchQuery({ queryKey, queryFn: () => ({ epoch }) }), + ) + + act(() => { + result.current.prepare(5n) + }) + await flush() + epoch = 5 + await act(() => queryClient.refetchQueries({ queryKey })) + await flush() + + expect(call).toHaveBeenCalledTimes(2) + + await act(() => result.current.submit(5n)) + await flush() + + expect(call).toHaveBeenCalledTimes(2) + expect(first.sign).not.toHaveBeenCalled() + expect(second.sign).toHaveBeenCalledTimes(1) + }) + + it("simulates again in the background once the prepared transaction expires", async () => { + vi.useFakeTimers() + const call = vi.fn(async () => transaction()) + const { result } = renderPrepared(call) + + act(() => { + result.current.prepare(5n) + }) + await act(() => vi.advanceTimersByTimeAsync(0)) + await act(() => vi.advanceTimersByTimeAsync(29_999)) + + expect(call).toHaveBeenCalledTimes(1) + + await act(() => vi.advanceTimersByTimeAsync(1)) + + expect(call).toHaveBeenCalledTimes(2) + }) + + it("shows nothing when the background simulation fails", async () => { + const call = vi.fn().mockRejectedValue(new Error("rpc down")) + const { result } = renderPrepared(call) + + act(() => { + result.current.prepare(5n) + }) + await flush() + + expect(result.current.status).toEqual({ status: "idle" }) + + await act(() => result.current.submit(5n)) + await flush() + + expect(call).toHaveBeenCalledTimes(2) + expect(result.current.status).toEqual({ + status: "failed", + failure: { kind: "interrupted" }, + }) + }) + + it("confirms a reused transaction with the value the network returned", async () => { + submitSignedMock.mockResolvedValueOnce({ + status: "success", + hash: "h", + result: 9n, + }) + const call = vi.fn().mockResolvedValue(transaction()) + const { result } = renderPrepared(call) + + act(() => { + result.current.prepare(5n) + }) + await flush() + await act(() => result.current.submit(5n)) + await flush() + + expect(result.current.status).toEqual({ + status: "confirmed", + result: 9n, + hash: "h", + }) + }) + + it("carries the signed hash while a reused transaction is in flight", async () => { + let settle: (value: unknown) => void = () => {} + submitSignedMock.mockReturnValueOnce( + new Promise((resolve) => { + settle = resolve + }), + ) + const call = vi.fn().mockResolvedValue(transaction()) + const { result } = renderPrepared(call) + + act(() => { + result.current.prepare(5n) + }) + await flush() + await act(async () => { + result.current.submit(5n) + await flush() + }) + + expect(result.current.status).toEqual({ status: "submitted", hash: "abcd" }) + + await act(async () => { + settle({ status: "success", hash: "abcd", result: 1n }) + await flush() + }) + }) + + it("does not keep a background simulation the contract refused", async () => { + const call = vi + .fn() + .mockResolvedValueOnce(transaction("HostError: Error(Contract, #6046)")) + .mockResolvedValueOnce(transaction()) + const { result } = renderPrepared(call) + + act(() => { + result.current.prepare(5n) + }) + await flush() + + expect(result.current.status).toEqual({ status: "idle" }) + + await act(() => result.current.submit(5n)) + await flush() + + expect(call).toHaveBeenCalledTimes(2) + expect(result.current.status).toEqual({ status: "confirmed", result: 1n }) + }) + + it("asks to try again when the prepared transaction's sequence was consumed", async () => { + submitSignedMock.mockResolvedValueOnce({ + status: "failed", + reason: "sequence-consumed", + hash: "h", + }) + const call = vi + .fn() + .mockResolvedValueOnce(transaction()) + .mockResolvedValueOnce(transaction()) + const { result } = renderPrepared(call) + + act(() => { + result.current.prepare(5n) + }) + await flush() + await act(() => result.current.submit(5n)) + await flush() + + expect(result.current.status).toEqual({ + status: "failed", + failure: { kind: "stale" }, + hash: "h", + }) + + await act(() => result.current.submit(5n)) + await flush() + + expect(call).toHaveBeenCalledTimes(2) + expect(result.current.status).toEqual({ status: "confirmed", result: 1n }) + }) + + it("keeps a released transaction reusable within 30 seconds", async () => { + vi.useFakeTimers() + const prepared = transaction() + const call = vi.fn().mockResolvedValue(prepared) + const { result } = renderPrepared(call) + + let release = () => {} + act(() => { + release = result.current.prepare(5n) + }) + await act(() => vi.advanceTimersByTimeAsync(0)) + release() + await act(() => vi.advanceTimersByTimeAsync(20_000)) + await act(() => result.current.submit(5n)) + await act(() => vi.advanceTimersByTimeAsync(0)) + + expect(call).toHaveBeenCalledTimes(1) + expect(prepared.sign).toHaveBeenCalledTimes(1) + }) + + it("does not refresh a released transaction once it expires", async () => { + vi.useFakeTimers() + const call = vi.fn(async () => transaction()) + const { result } = renderPrepared(call) + + let release = () => {} + act(() => { + release = result.current.prepare(5n) + }) + await act(() => vi.advanceTimersByTimeAsync(0)) + release() + await act(() => vi.advanceTimersByTimeAsync(30_000)) + + expect(call).toHaveBeenCalledTimes(1) + }) + + it("does not refresh a released transaction when the reads change", async () => { + const call = vi.fn(async () => transaction()) + const { result, queryClient } = renderPrepared(call) + let epoch = 4 + const queryKey = [...extraKeys[0], "epoch"] + await act(() => + queryClient.fetchQuery({ queryKey, queryFn: () => ({ epoch }) }), + ) + + let release = () => {} + act(() => { + release = result.current.prepare(5n) + }) + await flush() + release() + epoch = 5 + await act(() => queryClient.refetchQueries({ queryKey })) + await flush() + + expect(call).toHaveBeenCalledTimes(1) + }) + + it("does not refresh in the background while the page is hidden", async () => { + vi.useFakeTimers() + vi.spyOn(document, "visibilityState", "get").mockReturnValue("hidden") + const call = vi.fn(async () => transaction()) + const { result } = renderPrepared(call) + + act(() => { + result.current.prepare(5n) + }) + await act(() => vi.advanceTimersByTimeAsync(30_000)) + + expect(call).toHaveBeenCalledTimes(1) + }) +}) diff --git a/app/src/hooks/useContractTransaction.ts b/app/src/hooks/useContractTransaction.ts index 41b4ca94..2e99e25b 100644 --- a/app/src/hooks/useContractTransaction.ts +++ b/app/src/hooks/useContractTransaction.ts @@ -8,8 +8,19 @@ import { type SubmitFailure, submitSigned, } from "@stellar-scaffold/app-lib" -import { type QueryKey, useQueryClient } from "@tanstack/react-query" -import { useCallback, useRef, useState } from "react" +import { + partialMatchKey, + type QueryKey, + useQueryClient, +} from "@tanstack/react-query" +import { + type RefObject, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react" import { type WriterSigner } from "../config/clients" import { type Grant, type Standing } from "./useAdminAuthority" import { depositBalanceKey } from "./useDepositBalance" @@ -23,6 +34,7 @@ export type TransactionFailure = | { kind: "contract-error"; code: number } | { kind: "interrupted" } | { kind: "network"; reason: SubmitFailure } + | { kind: "stale" } | { kind: "unknown" } export type SharedTransaction = { @@ -45,12 +57,32 @@ export type TransactionStatus< const NO_EXTRA_KEYS: readonly QueryKey[] = [] +const FRESH_MS = 30_000 + +type Simulated = { + key: string + tx: AssembledTransaction + at: number +} + export interface UseContractTransaction { status: TransactionStatus submit: (arg: TArg, grant?: Grant) => boolean + prepare: (arg: TArg, grant?: Grant) => Release reset: () => void } +const requestKey = ( + owner: string, + arg: unknown, + grant: Grant | undefined, +): string => + JSON.stringify( + [owner, arg ?? null, grant?.authority ?? null, grant?.standing ?? null], + (_name, value: unknown) => + typeof value === "bigint" ? `${value}n` : value, + ) + const signerFor = ( owner: string, grant: Grant | undefined, @@ -92,6 +124,144 @@ const signedOf = (tx: AssembledTransaction): Transaction => { return tx.signed } +export type Release = () => void + +type Request = { arg: TArg; grant: Grant | undefined } + +const isPageHidden = (): boolean => document.visibilityState === "hidden" + +const isFresh = (at: number): boolean => Date.now() - at < FRESH_MS + +function usePrepared( + call: ( + signer: WriterSigner, + arg: TArg, + ) => Promise>, + owner: string | undefined, + signTransaction: WriterSigner["signTransaction"], + readKeys: readonly QueryKey[], + submitting: RefObject, +) { + const queryClient = useQueryClient() + const simulated = useRef | null>(null) + const simulating = useRef(null) + const generation = useRef(0) + const last = useRef | null>(null) + const reads = useRef(new Map()) + const expiry = useRef>(undefined) + const refresh = useRef(() => {}) + + const drop = useCallback(() => { + simulated.current = null + simulating.current = null + generation.current += 1 + clearTimeout(expiry.current) + }, []) + + const matches = useCallback( + (queryKey: QueryKey) => + readKeys.some((key) => partialMatchKey(queryKey, key)), + [readKeys], + ) + + const simulate = useCallback( + (request: Request) => { + if (owner === undefined || submitting.current) return + const key = requestKey(owner, request.arg, request.grant) + simulating.current = key + reads.current = new Map( + queryClient + .getQueryCache() + .findAll() + .filter((query) => matches(query.queryKey)) + .map((query) => [query.queryHash, query.state.data]), + ) + const started = generation.current + const at = Date.now() + const latest = () => + started === generation.current && simulating.current === key + call(signerFor(owner, request.grant, signTransaction), request.arg).then( + (tx) => { + if (!latest()) return + simulating.current = null + if (parseErrorCode(tx.simulation) !== null) return + simulated.current = { key, tx, at } + expiry.current = setTimeout( + () => refresh.current(), + Math.max(0, at + FRESH_MS - Date.now()), + ) + }, + () => { + if (latest()) simulating.current = null + }, + ) + }, + [owner, submitting, queryClient, matches, call, signTransaction], + ) + + useEffect(() => { + refresh.current = () => { + const request = last.current + drop() + if (request !== null && !isPageHidden()) simulate(request) + } + }, [drop, simulate]) + + useEffect( + () => () => { + last.current = null + drop() + }, + [drop], + ) + + useEffect( + () => + queryClient.getQueryCache().subscribe((event) => { + if (event.type !== "updated" || event.action.type !== "success") return + if (simulated.current === null && simulating.current === null) return + const { query } = event + if (!matches(query.queryKey)) return + if (reads.current.get(query.queryHash) === query.state.data) return + refresh.current() + }), + [queryClient, matches], + ) + + const prepare = useCallback( + (arg: TArg, grant?: Grant): Release => { + const request = { arg, grant } + const release = () => { + if (last.current === request) last.current = null + } + if (owner === undefined) return release + last.current = request + const key = requestKey(owner, arg, grant) + const cached = simulated.current + if (cached?.key === key && isFresh(cached.at)) return release + if (simulating.current === key) return release + drop() + simulate(request) + return release + }, + [owner, drop, simulate], + ) + + const take = useCallback( + (key: string): AssembledTransaction | undefined => { + const cached = simulated.current + last.current = null + drop() + if (cached === null || cached.key !== key || !isFresh(cached.at)) + return undefined + return cached.tx + }, + [drop], + ) + + return { prepare, take } +} + export function useContractTransaction< TArg, TResult, @@ -112,9 +282,34 @@ export function useContractTransaction< const submitting = useRef(false) const dismissed = useRef(false) const lastStatus = useRef>({ status: "idle" }) + const readKeys = useMemo( + () => + address === undefined + ? extraKeys + : [ + investorRequestsKey(address), + depositBalanceKey(address), + sharePositionKey(address), + ...extraKeys, + ], + [address, extraKeys], + ) + + const { prepare, take } = usePrepared( + call, + address, + signTransaction, + readKeys, + submitting, + ) const runSubmission = useCallback( - async (arg: TArg, owner: string, grant: Grant | undefined) => { + async ( + arg: TArg, + owner: string, + grant: Grant | undefined, + reused: AssembledTransaction | undefined, + ) => { let hash: string | undefined let signatureRequested = false let reachedNetwork = false @@ -142,7 +337,7 @@ export function useContractTransaction< } as TransactionStatus) invalidateRequestData() } - const submitAsOperator = async ( + const signAndSubmit = async ( tx: AssembledTransaction, standing: Standing, ) => { @@ -153,19 +348,24 @@ export function useContractTransaction< return } reachedNetwork = true - applyStatus({ status: "submitted" }) + hash = signed.hash().toString("hex") + applyStatus({ status: "submitted", hash }) const sent = await submitSigned(signed) hash = sent.hash if (sent.status === "failed") { + const stale = + reused !== undefined && sent.reason === "sequence-consumed" applyStatus({ status: "failed", - failure: { kind: "network", reason: sent.reason }, + failure: stale + ? { kind: "stale" } + : { kind: "network", reason: sent.reason }, hash, }) invalidateRequestData() return } - confirm(tx.result) + confirm(sent.result as TResult) } const signAndSend = async (tx: AssembledTransaction) => { const sent = await tx.signAndSend({ @@ -187,7 +387,8 @@ export function useContractTransaction< } applyStatus({ status: "preparing" }) try { - const tx = await call(signerFor(owner, grant, signTransaction), arg) + const tx = + reused ?? (await call(signerFor(owner, grant, signTransaction), arg)) const code = parseErrorCode(tx.simulation) if (code !== null) { applyStatus({ @@ -199,8 +400,8 @@ export function useContractTransaction< } signatureRequested = true applyStatus({ status: "awaiting-signature" }) - if (grant === undefined) await signAndSend(tx) - else await submitAsOperator(tx, grant.standing) + if (grant === undefined && reused === undefined) await signAndSend(tx) + else await signAndSubmit(tx, grant?.standing ?? "signs-alone") } catch (error) { applyStatus({ status: "failed", @@ -225,10 +426,11 @@ export function useContractTransaction< } submitting.current = true dismissed.current = false - void runSubmission(arg, address, grant) + const reused = take(requestKey(address, arg, grant)) + void runSubmission(arg, address, grant, reused) return true }, - [address, runSubmission], + [address, runSubmission, take], ) const reset = useCallback(() => { @@ -236,5 +438,5 @@ export function useContractTransaction< setStatus({ status: "idle" }) }, []) - return { status, submit, reset } + return { status, submit, prepare, reset } } diff --git a/app/src/hooks/useDeployToCustodian.ts b/app/src/hooks/useDeployToCustodian.ts index 779c061b..0449864a 100644 --- a/app/src/hooks/useDeployToCustodian.ts +++ b/app/src/hooks/useDeployToCustodian.ts @@ -2,8 +2,9 @@ import { type Amount, type Signer } from "@stellar-scaffold/app-lib" import { useCallback } from "react" import { asyncVaultWriter } from "../config/clients" import { - useContractTransaction, + type Release, type TransactionStatus, + useContractTransaction, } from "./useContractTransaction" import { custodianBalanceKey, cycleWriteKeys } from "./useCycleState" @@ -12,6 +13,7 @@ export type DeployToCustodianStatus = TransactionStatus export interface UseDeployToCustodian { status: DeployToCustodianStatus submit: (assets: Amount) => boolean + prepare: (assets: Amount) => Release reset: () => void } diff --git a/app/src/hooks/useFinalizeWindDownRound.ts b/app/src/hooks/useFinalizeWindDownRound.ts index 34223c7c..c60d9739 100644 --- a/app/src/hooks/useFinalizeWindDownRound.ts +++ b/app/src/hooks/useFinalizeWindDownRound.ts @@ -2,8 +2,9 @@ import { type Amount, type Signer } from "@stellar-scaffold/app-lib" import { useCallback } from "react" import { asyncVaultWriter } from "../config/clients" import { - useContractTransaction, + type Release, type TransactionStatus, + useContractTransaction, } from "./useContractTransaction" import { cycleWriteKeys } from "./useCycleState" @@ -14,6 +15,7 @@ export type FinalizeWindDownRoundStatus = TransactionStatus<{ export interface UseFinalizeWindDownRound { status: FinalizeWindDownRoundStatus submit: () => boolean + prepare: () => Release reset: () => void } diff --git a/app/src/hooks/useFulfillEpoch.ts b/app/src/hooks/useFulfillEpoch.ts index 03322d97..3ab3e9d7 100644 --- a/app/src/hooks/useFulfillEpoch.ts +++ b/app/src/hooks/useFulfillEpoch.ts @@ -2,8 +2,9 @@ import { type Price, type Signer } from "@stellar-scaffold/app-lib" import { useCallback } from "react" import { asyncVaultWriter } from "../config/clients" import { - useContractTransaction, + type Release, type TransactionStatus, + useContractTransaction, } from "./useContractTransaction" import { cycleWriteKeys } from "./useCycleState" @@ -12,6 +13,7 @@ export type FulfillEpochStatus = TransactionStatus<{ sharePrice: Price }> export interface UseFulfillEpoch { status: FulfillEpochStatus submit: (epochId: bigint) => boolean + prepare: (epochId: bigint) => Release reset: () => void } diff --git a/app/src/hooks/useFund.ts b/app/src/hooks/useFund.ts index 4c5e2b30..d0ded3c8 100644 --- a/app/src/hooks/useFund.ts +++ b/app/src/hooks/useFund.ts @@ -2,8 +2,9 @@ import { type Amount, type Signer } from "@stellar-scaffold/app-lib" import { useCallback, useMemo } from "react" import { asyncVaultWriter } from "../config/clients" import { - useContractTransaction, + type Release, type TransactionStatus, + useContractTransaction, } from "./useContractTransaction" import { custodianBalanceKey, cycleWriteKeys } from "./useCycleState" import { depositBalanceKey } from "./useDepositBalance" @@ -14,6 +15,7 @@ export type FundStatus = TransactionStatus export interface UseFund { status: FundStatus submit: (assets: Amount) => boolean + prepare: (assets: Amount) => Release reset: () => void } diff --git a/app/src/hooks/usePendingTransaction.test.ts b/app/src/hooks/usePendingTransaction.test.ts index 4af8db72..4734b86a 100644 --- a/app/src/hooks/usePendingTransaction.test.ts +++ b/app/src/hooks/usePendingTransaction.test.ts @@ -9,6 +9,7 @@ const makeTransaction = ( ): UseContractTransaction> => ({ status: { status: "idle" }, submit, + prepare: vi.fn(), reset: vi.fn(), }) @@ -109,4 +110,22 @@ describe("usePendingTransaction", () => { expect(reset).toHaveBeenCalledTimes(1) }) + + it("prepares through the underlying transaction without recording a label", () => { + const prepare = vi.fn() + const transaction = makeTransaction(() => true) + const grant: Grant = { + role: "manager", + authority: "GAUTHORITY", + standing: "signs-alone", + } + const { result } = renderHook(() => + usePendingTransaction({ ...transaction, prepare }), + ) + + result.current.prepare(5n, grant) + + expect(prepare).toHaveBeenCalledWith(5n, grant) + expect(result.current.amountLabel).toBe("") + }) }) diff --git a/app/src/hooks/usePendingTransaction.ts b/app/src/hooks/usePendingTransaction.ts index f81d7f52..8fe3f539 100644 --- a/app/src/hooks/usePendingTransaction.ts +++ b/app/src/hooks/usePendingTransaction.ts @@ -1,6 +1,7 @@ import { useCallback, useRef, useState } from "react" import { type Grant } from "./useAdminAuthority" import { + type Release, type TransactionStatus, type UseContractTransaction, } from "./useContractTransaction" @@ -9,6 +10,7 @@ export interface UsePendingTransaction { status: TransactionStatus amountLabel: string submit: (arg: TArg, amountLabel: string, grant?: Grant) => boolean + prepare: (arg: TArg, grant?: Grant) => Release retry: () => void reset: () => void } @@ -16,7 +18,7 @@ export interface UsePendingTransaction { export function usePendingTransaction( transaction: UseContractTransaction, ): UsePendingTransaction { - const { status, submit: submitTransaction, reset } = transaction + const { status, submit: submitTransaction, prepare, reset } = transaction const [amountLabel, setAmountLabel] = useState("") const pending = useRef<{ arg: TArg; grant: Grant | undefined } | null>(null) @@ -35,5 +37,25 @@ export function usePendingTransaction( submitTransaction(pending.current.arg, pending.current.grant) }, [submitTransaction]) - return { status, amountLabel, submit, retry, reset } + return { status, amountLabel, submit, prepare, retry, reset } } + +export type Sendable = Pick< + UsePendingTransaction, + "submit" | "prepare" +> + +export type Send = ( + flow: Sendable, + arg: TArg, + amountLabel: string, + grant?: Grant, +) => Release | undefined + +export const submitTo: Send = (flow, arg, amountLabel, grant) => { + flow.submit(arg, amountLabel, grant) + return undefined +} + +export const prepareFor: Send = (flow, arg, _amountLabel, grant) => + flow.prepare(arg, grant) diff --git a/app/src/hooks/useRequestDeposit.ts b/app/src/hooks/useRequestDeposit.ts index b035d216..8e684899 100644 --- a/app/src/hooks/useRequestDeposit.ts +++ b/app/src/hooks/useRequestDeposit.ts @@ -2,8 +2,9 @@ import { type Amount, type Signer } from "@stellar-scaffold/app-lib" import { useCallback } from "react" import { asyncVaultWriter } from "../config/clients" import { - useContractTransaction, + type Release, type TransactionStatus, + useContractTransaction, } from "./useContractTransaction" export type RequestDepositStatus = TransactionStatus @@ -11,6 +12,7 @@ export type RequestDepositStatus = TransactionStatus export interface UseRequestDeposit { status: RequestDepositStatus submit: (amount: Amount) => boolean + prepare: (amount: Amount) => Release reset: () => void } diff --git a/app/src/hooks/useRequestRedeem.ts b/app/src/hooks/useRequestRedeem.ts index 80fac4cb..b5b90b01 100644 --- a/app/src/hooks/useRequestRedeem.ts +++ b/app/src/hooks/useRequestRedeem.ts @@ -2,8 +2,9 @@ import { type Amount, type Signer } from "@stellar-scaffold/app-lib" import { useCallback } from "react" import { asyncVaultWriter } from "../config/clients" import { - useContractTransaction, + type Release, type TransactionStatus, + useContractTransaction, } from "./useContractTransaction" export type RequestRedeemStatus = TransactionStatus @@ -11,6 +12,7 @@ export type RequestRedeemStatus = TransactionStatus export interface UseRequestRedeem { status: RequestRedeemStatus submit: (shares: Amount) => boolean + prepare: (shares: Amount) => Release reset: () => void } diff --git a/app/src/pages/VaultPreview.test.tsx b/app/src/pages/VaultPreview.test.tsx index 5a8758c5..dbc7ee14 100644 --- a/app/src/pages/VaultPreview.test.tsx +++ b/app/src/pages/VaultPreview.test.tsx @@ -9,7 +9,7 @@ import { } from "@stellar-scaffold/app-lib" import type * as AppLib from "@stellar-scaffold/app-lib" import { QueryClient, QueryClientProvider } from "@tanstack/react-query" -import { fireEvent, render, screen, waitFor } from "@testing-library/react" +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 { type AddressRow } from "../components/vault/AboutVault" @@ -406,6 +406,9 @@ const renderVaultPreview = ( return render(, { wrapper }) } +const settlePreparation = () => + act(() => new Promise((resolve) => setTimeout(resolve, 700))) + describe("VaultPreview", () => { it("renders the action panel in the side column", () => { renderVaultPreview() @@ -680,6 +683,68 @@ describe("VaultPreview", () => { }) }) + it("prepares the subscription only once the amount fits the balance", async () => { + renderVaultPreview(connectedWallet) + const input = await screen.findByRole("textbox", { + name: "Amount to subscribe", + }) + + fireEvent.change(input, { target: { value: "5000" } }) + await settlePreparation() + + expect(requestDepositMock).not.toHaveBeenCalled() + + fireEvent.change(input, { target: { value: "100" } }) + + await waitFor(() => expect(requestDepositMock).toHaveBeenCalledTimes(1), { + timeout: 2000, + }) + expect(requestDepositMock).toHaveBeenCalledWith({ + from: investorAddress, + amount: 100_0000000n, + }) + }) + + it("does not prepare while the wallet is on the wrong network", async () => { + renderVaultPreview(wrongNetworkWallet) + const input = await screen.findByRole("textbox", { + name: "Amount to subscribe", + }) + + fireEvent.change(input, { target: { value: "100" } }) + await settlePreparation() + + expect(requestDepositMock).not.toHaveBeenCalled() + }) + + it("prepares the hook of whichever side the investor switches to", async () => { + renderVaultPreview(connectedWallet) + const subscribe = await screen.findByRole("textbox", { + name: "Amount to subscribe", + }) + fireEvent.change(subscribe, { target: { value: "100" } }) + + await waitFor(() => expect(requestDepositMock).toHaveBeenCalledTimes(1), { + timeout: 2000, + }) + expect(requestRedeemMock).not.toHaveBeenCalled() + + fireEvent.click(await screen.findByRole("tab", { name: "Redeem" })) + const redeem = await screen.findByRole("textbox", { + name: "Amount to redeem", + }) + fireEvent.change(redeem, { target: { value: "100" } }) + + await waitFor(() => expect(requestRedeemMock).toHaveBeenCalledTimes(1), { + timeout: 2000, + }) + expect(requestRedeemMock).toHaveBeenCalledWith({ + from: investorAddress, + shares: 100_0000000n, + }) + expect(requestDepositMock).toHaveBeenCalledTimes(1) + }) + it("reflects a confirmed redemption request in the request list without a manual refresh", async () => { renderVaultPreview(connectedWallet) fireEvent.click(await screen.findByRole("tab", { name: "Redeem" })) @@ -808,6 +873,34 @@ describe("VaultPreview", () => { }) }) + it("simulates the subscription in the background once a valid amount settles", async () => { + renderVaultPreview(connectedWallet) + const input = await screen.findByRole("textbox", { + name: "Amount to subscribe", + }) + fireEvent.change(input, { target: { value: "1" } }) + fireEvent.change(input, { target: { value: "15" } }) + fireEvent.change(input, { target: { value: "150" } }) + + await waitFor(() => expect(requestDepositMock).toHaveBeenCalledTimes(1)) + expect(requestDepositMock).toHaveBeenCalledWith({ + from: investorAddress, + amount: 150_0000000n, + }) + }) + + it("does not simulate an amount that cannot be submitted", async () => { + renderVaultPreview(connectedWallet) + const input = await screen.findByRole("textbox", { + name: "Amount to subscribe", + }) + fireEvent.change(input, { target: { value: "0" } }) + + await new Promise((resolve) => setTimeout(resolve, 600)) + + expect(requestDepositMock).not.toHaveBeenCalled() + }) + it("reflects a confirmed subscription in the request list without a manual refresh", async () => { renderVaultPreview(connectedWallet) const input = await screen.findByRole("textbox", { diff --git a/app/src/pages/VaultPreview.tsx b/app/src/pages/VaultPreview.tsx index a519ac84..0682e80e 100644 --- a/app/src/pages/VaultPreview.tsx +++ b/app/src/pages/VaultPreview.tsx @@ -27,6 +27,7 @@ import { useCancelDeposit } from "../hooks/useCancelDeposit" import { useCancelRedeem } from "../hooks/useCancelRedeem" import { useClaimDeposit } from "../hooks/useClaimDeposit" import { useClaimRedeem } from "../hooks/useClaimRedeem" +import { type Release } from "../hooks/useContractTransaction" import { useDepositBalance } from "../hooks/useDepositBalance" import { type InvestorRequest, @@ -67,6 +68,8 @@ import { toRequestEntriesByStage, } from "./vaultRequests" +const PREPARE_DELAY_MS = 400 + const sections: { id: string; label: string }[] = [ { id: "requests", label: "Requests" }, { id: "position", label: "Position" }, @@ -244,6 +247,30 @@ const VaultPreview: React.FC = () => { requestRedeem.submit(amount, amountLabel) } + const settledAmount = + block === undefined ? parseUnits(actionAmount, AMOUNT_DECIMALS) : null + const preparable = + settledAmount !== null && + settledAmount > 0n && + (balance === null || settledAmount <= balance) + ? settledAmount + : null + const prepareDeposit = requestDeposit.prepare + const prepareRedeem = requestRedeem.prepare + + React.useEffect(() => { + if (preparable === null) return + const prepare = isSubscribe ? prepareDeposit : prepareRedeem + let release: Release | undefined + const timer = setTimeout(() => { + release = prepare(preparable) + }, PREPARE_DELAY_MS) + return () => { + clearTimeout(timer) + release?.() + } + }, [preparable, isSubscribe, prepareDeposit, prepareRedeem]) + React.useEffect(() => { if (requestDeposit.status.status === "confirmed") setActionAmount("") }, [requestDeposit.status])