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])