From f5e4e0ad7863cb06eb2ba800dca0d478b79f5f8f Mon Sep 17 00:00:00 2001 From: luchobonatti Date: Fri, 9 Oct 2026 10:53:01 -0300 Subject: [PATCH 1/4] feat(app): deploy and fund the reserve from the admin panel --- .../components/admin/CycleSection.test.tsx | 233 +++++++++++- app/src/components/admin/CycleSection.tsx | 80 ++++- .../components/admin/CycleSurface.module.css | 19 + .../components/admin/CycleSurface.test.tsx | 49 +++ app/src/components/admin/CycleSurface.tsx | 12 +- .../vault/TransactionModal.test.tsx | 205 +++++++++++ app/src/components/vault/TransactionModal.tsx | 138 ++++++- app/src/hooks/useDeployToCustodian.test.ts | 166 +++++++++ app/src/hooks/useDeployToCustodian.ts | 32 ++ app/src/hooks/useFund.test.ts | 164 +++++++++ app/src/hooks/useFund.ts | 32 ++ app/src/pages/cycle.test.ts | 337 +++++++++++++++++- app/src/pages/cycle.ts | 162 ++++++++- 13 files changed, 1602 insertions(+), 27 deletions(-) create mode 100644 app/src/hooks/useDeployToCustodian.test.ts create mode 100644 app/src/hooks/useDeployToCustodian.ts create mode 100644 app/src/hooks/useFund.test.ts create mode 100644 app/src/hooks/useFund.ts diff --git a/app/src/components/admin/CycleSection.test.tsx b/app/src/components/admin/CycleSection.test.tsx index cc3f638..12c1c1d 100644 --- a/app/src/components/admin/CycleSection.test.tsx +++ b/app/src/components/admin/CycleSection.test.tsx @@ -5,21 +5,28 @@ import { type Grant } from "../../hooks/useAdminAuthority" import { type AttestStatus } from "../../hooks/useAttest" import { type CloseEpochStatus } from "../../hooks/useCloseEpoch" import { type CycleState } from "../../hooks/useCycleState" +import { type DeployToCustodianStatus } from "../../hooks/useDeployToCustodian" +import { type DepositBalance } from "../../hooks/useDepositBalance" import { type EpochRecord } from "../../hooks/useEpochHistory" import { type FulfillEpochStatus } from "../../hooks/useFulfillEpoch" +import { type FundStatus } from "../../hooks/useFund" import CycleSection from "./CycleSection" const { useCycleStateMock, useCycleEventsMock, useEpochHistoryMock, + useDepositBalanceMock, closeEpochMock, fulfillEpochMock, attestMock, + deployMock, + fundMock, } = vi.hoisted(() => ({ useCycleStateMock: vi.fn(), useCycleEventsMock: vi.fn(), useEpochHistoryMock: vi.fn(), + useDepositBalanceMock: vi.fn(), closeEpochMock: { status: { status: "idle" } as CloseEpochStatus, submit: vi.fn(() => true), @@ -35,6 +42,16 @@ const { submit: vi.fn(() => true), reset: vi.fn(), }, + deployMock: { + status: { status: "idle" } as DeployToCustodianStatus, + submit: vi.fn(() => true), + reset: vi.fn(), + }, + fundMock: { + status: { status: "idle" } as FundStatus, + submit: vi.fn(() => true), + reset: vi.fn(), + }, })) vi.mock("../../hooks/useCycleState", () => ({ @@ -53,6 +70,15 @@ vi.mock("../../hooks/useFulfillEpoch", () => ({ vi.mock("../../hooks/useAttest", () => ({ useAttest: () => attestMock, })) +vi.mock("../../hooks/useDeployToCustodian", () => ({ + useDeployToCustodian: () => deployMock, +})) +vi.mock("../../hooks/useFund", () => ({ + useFund: () => fundMock, +})) +vi.mock("../../hooks/useDepositBalance", () => ({ + useDepositBalance: useDepositBalanceMock, +})) const amount = (whole: bigint) => (whole * 10_000_000n) as Amount const price = (whole: bigint) => (whole * 10n ** 18n) as Price @@ -106,13 +132,13 @@ const ready = (overrides: Partial = {}): CycleState => ({ }, }, reserve: { - free: null, + free: amount(900n), committed: null, uncovered: null, liquid: null, netDeployed: null, depositCap: null, - custodian: null, + custodian: "CCUSTODIAN1234567890", custodianBalance: null, }, windDown: { @@ -148,10 +174,17 @@ describe("CycleSection", () => { closeEpochMock.status = { status: "idle" } fulfillEpochMock.status = { status: "idle" } attestMock.status = { status: "idle" } + deployMock.status = { status: "idle" } + fundMock.status = { status: "idle" } closeEpochMock.submit.mockReturnValue(true) fulfillEpochMock.submit.mockReturnValue(true) attestMock.submit.mockReturnValue(true) + deployMock.submit.mockReturnValue(true) + fundMock.submit.mockReturnValue(true) useCycleStateMock.mockReturnValue({ cycle: ready() }) + useDepositBalanceMock.mockReturnValue({ + balance: { status: "held", amount: amount(1_000n) } as DepositBalance, + }) useCycleEventsMock.mockReturnValue({ cycleEvents: { status: "loaded", events: [] }, }) @@ -181,13 +214,205 @@ describe("CycleSection", () => { ).toBeTruthy() }) - it("offers the treasury only the settlement", () => { + it("offers the treasury the settlement and both reserve actions, under the Reserve group", () => { render() expect(screen.queryByRole("button", { name: "Close epoch" })).toBeNull() expect(screen.queryByRole("button", { name: "Attest price" })).toBeNull() - expect(screen.queryByRole("textbox")).toBeNull() expect(button("Fulfill epoch").disabled).toBe(false) + const reserveGroup = screen.getByRole("heading", { + name: "Reserve", + }).parentElement! + expect( + within(reserveGroup).getByRole("button", { name: "Deploy to custodian" }), + ).toBeTruthy() + expect( + within(reserveGroup).getByRole("button", { name: "Fund the reserve" }), + ).toBeTruthy() + expect( + within(reserveGroup).getByRole("textbox", { name: "Amount to deploy" }), + ).toBeTruthy() + expect( + within(reserveGroup).getByRole("textbox", { name: "Amount to fund" }), + ).toBeTruthy() + }) + + it("offers the manager the funding but not the deployment", () => { + render() + + expect( + screen.queryByRole("button", { name: "Deploy to custodian" }), + ).toBeNull() + expect(screen.queryByRole("button", { name: "Use max" })).toBeNull() + expect(button("Fund the reserve").disabled).toBe(true) + expect(screen.getByRole("textbox", { name: "Amount to fund" })).toBeTruthy() + }) + + it("holds the deployment until an amount is typed, then enables it and states the resulting reserve", () => { + render() + + expect(button("Deploy to custodian").disabled).toBe(true) + const within_ = screen.getByText("Within the free reserve").closest("li")! + expect(within(within_).getByText("—")).toBeTruthy() + + fireEvent.change( + screen.getByRole("textbox", { name: "Amount to deploy" }), + { + target: { value: "250" }, + }, + ) + + expect(button("Deploy to custodian").disabled).toBe(false) + expect(within(within_).getByText("Met")).toBeTruthy() + expect( + screen.getByText( + "Sends 250.00 to the custodian; free reserve becomes 650.00.", + ), + ).toBeTruthy() + }) + + it("fills the deployment with the exact free reserve on Use max", () => { + render() + + fireEvent.click(button("Use max")) + + const field = screen.getByRole("textbox", { + name: "Amount to deploy", + }) as HTMLInputElement + expect(field.value).toBe("900.00") + expect(button("Deploy to custodian").disabled).toBe(false) + expect( + screen.getByText( + "Sends 900.00 to the custodian; free reserve becomes 0.00.", + ), + ).toBeTruthy() + }) + + it("submits the deployment and follows it in the modal, labelled with the amount", () => { + const { rerender } = render( + , + ) + + fireEvent.change( + screen.getByRole("textbox", { name: "Amount to deploy" }), + { + target: { value: "250" }, + }, + ) + fireEvent.click(button("Deploy to custodian")) + + expect(deployMock.submit).toHaveBeenCalledWith(amount(250n)) + + deployMock.status = { status: "preparing" } + rerender() + + expect( + screen.getByRole("heading", { name: "Preparing to deploy 250.00" }), + ).toBeTruthy() + }) + + it("clears the deployment amount once it confirms", () => { + const { rerender } = render( + , + ) + const field = () => + screen.getByRole("textbox", { + name: "Amount to deploy", + }) as HTMLInputElement + + fireEvent.change(field(), { target: { value: "250" } }) + fireEvent.click(button("Deploy to custodian")) + deployMock.status = { status: "confirmed" } + rerender() + + expect(field().value).toBe("") + expect(screen.getByRole("heading", { name: "Deployed" })).toBeTruthy() + }) + + it("judges the funding against the connected wallet's balance", () => { + render() + const field = screen.getByRole("textbox", { name: "Amount to fund" }) + + fireEvent.change(field, { target: { value: "1000.0000001" } }) + + expect(button("Fund the reserve").disabled).toBe(true) + const covers = screen.getByText("Wallet balance covers it").closest("li")! + expect(within(covers).getByText("Balance 1,000.00")).toBeTruthy() + + fireEvent.change(field, { target: { value: "100" } }) + + expect(button("Fund the reserve").disabled).toBe(false) + expect( + screen.getByText( + "Adds 100.00 to the reserve; free reserve becomes 1,000.00.", + ), + ).toBeTruthy() + }) + + it("says the balance is unavailable and holds the funding while the wallet's balance cannot be read", () => { + useDepositBalanceMock.mockReturnValue({ + balance: { status: "unreadable" } as DepositBalance, + }) + render() + + fireEvent.change(screen.getByRole("textbox", { name: "Amount to fund" }), { + target: { value: "100" }, + }) + + expect(button("Fund the reserve").disabled).toBe(true) + const covers = screen.getByText("Wallet balance covers it").closest("li")! + expect(within(covers).getByText("Balance unavailable")).toBeTruthy() + }) + + it("submits the funding and follows it in the modal, labelled with the amount", () => { + const { rerender } = render( + , + ) + + fireEvent.change(screen.getByRole("textbox", { name: "Amount to fund" }), { + target: { value: "100" }, + }) + fireEvent.click(button("Fund the reserve")) + + expect(fundMock.submit).toHaveBeenCalledWith(amount(100n)) + + fundMock.status = { status: "awaiting-signature" } + rerender() + + expect( + screen.getByText( + "Signing moves 100.00 from your wallet into the vault's reserve.", + ), + ).toBeTruthy() + }) + + it("tells a treasury signing through a multisig that collecting signatures is not supported yet, while funding stays open", () => { + render( + , + ) + + fireEvent.change( + screen.getByRole("textbox", { name: "Amount to deploy" }), + { + target: { value: "250" }, + }, + ) + + expect(button("Deploy to custodian").disabled).toBe(true) + expect( + screen.getByText( + "Needs 2 signatures; collecting them is not supported yet.", + ), + ).toBeTruthy() + + fireEvent.change(screen.getByRole("textbox", { name: "Amount to fund" }), { + target: { value: "100" }, + }) + + expect(button("Fund the reserve").disabled).toBe(false) }) it("offers the attester a price field under the Price group, with the button held until a price is typed", () => { diff --git a/app/src/components/admin/CycleSection.tsx b/app/src/components/admin/CycleSection.tsx index ba10e0e..9d3c59e 100644 --- a/app/src/components/admin/CycleSection.tsx +++ b/app/src/components/admin/CycleSection.tsx @@ -1,10 +1,18 @@ +import { + AMOUNT_DECIMALS, + type Amount, + formatScaled, +} from "@stellar-scaffold/app-lib" import React, { useEffect, useState } from "react" import { type Grant } from "../../hooks/useAdminAuthority" import { useAttest } from "../../hooks/useAttest" import { useCloseEpoch } from "../../hooks/useCloseEpoch" import { useCycleEvents, useCycleState } from "../../hooks/useCycleState" +import { useDeployToCustodian } from "../../hooks/useDeployToCustodian" +import { useDepositBalance } from "../../hooks/useDepositBalance" import { useEpochHistory } from "../../hooks/useEpochHistory" import { useFulfillEpoch } from "../../hooks/useFulfillEpoch" +import { useFund } from "../../hooks/useFund" import { usePendingTransaction } from "../../hooks/usePendingTransaction" import { type CycleAction, @@ -20,6 +28,16 @@ import TransactionModal, { } from "../vault/TransactionModal" import CycleSurface, { CycleActions, type CycleGroup } from "./CycleSurface" +const useAmountInput = (confirmed: boolean) => { + const [input, setInput] = useState("") + useEffect(() => { + if (confirmed) setInput("") + }, [confirmed]) + return [input, setInput] as const +} + +const amountLabel = (amount: Amount) => formatScaled(amount, AMOUNT_DECIMALS) + const CycleSection: React.FC<{ grants: Grant[]; wallet: string }> = ({ grants, wallet, @@ -27,15 +45,21 @@ const CycleSection: React.FC<{ grants: Grant[]; wallet: string }> = ({ const { cycle } = useCycleState() const { history } = useEpochHistory() const { cycleEvents } = useCycleEvents() + const { balance } = useDepositBalance() const closeEpoch = usePendingTransaction(useCloseEpoch()) const fulfillEpoch = usePendingTransaction(useFulfillEpoch()) const attest = usePendingTransaction(useAttest()) - const [priceInput, setPriceInput] = useState("") - const attested = attest.status.status === "confirmed" - - useEffect(() => { - if (attested) setPriceInput("") - }, [attested]) + const deploy = usePendingTransaction(useDeployToCustodian()) + const fund = usePendingTransaction(useFund()) + const [priceInput, setPriceInput] = useAmountInput( + attest.status.status === "confirmed", + ) + const [deployInput, setDeployInput] = useAmountInput( + deploy.status.status === "confirmed", + ) + const [fundInput, setFundInput] = useAmountInput( + fund.status.status === "confirmed", + ) const run = (action: CycleAction) => { switch (action.id) { @@ -53,14 +77,40 @@ const CycleSection: React.FC<{ grants: Grant[]; wallet: string }> = ({ { price: action.price, freshness: action.freshness }, formatSignedPrice(action.price), ) + return + case "deploy": + if (action.amount !== null) + deploy.submit(action.amount, amountLabel(action.amount)) + return + case "fund": + if (action.amount !== null) + fund.submit(action.amount, amountLabel(action.amount)) } } const onInput = (action: CycleAction, value: string) => { - if (action.id === "attest") setPriceInput(value) + switch (action.id) { + case "attest": + setPriceInput(value) + return + case "deploy": + setDeployInput(value) + return + case "fund": + setFundInput(value) + return + case "close-epoch": + case "fulfill-epoch": + return + } } - const actions = toCycleActions(cycle, grants, wallet, priceInput) + const actions = toCycleActions(cycle, grants, wallet, { + price: priceInput, + deploy: deployInput, + fund: fundInput, + walletBalance: balance.status === "held" ? balance.amount : null, + }) const withActions = (group: CycleGroup): CycleGroup => { const own = actions.filter((action) => action.group === group.title) return own.length === 0 @@ -97,6 +147,20 @@ const CycleSection: React.FC<{ grants: Grant[]; wallet: string }> = ({ onClose: attest.reset, onRetry: attest.retry, }, + { + action: "deploy", + status: deploy.status, + amount: deploy.amountLabel, + onClose: deploy.reset, + onRetry: deploy.retry, + }, + { + action: "fund", + status: fund.status, + amount: fund.amountLabel, + onClose: fund.reset, + onRetry: fund.retry, + }, ] const activeModal = modalFlows.find((flow) => flow.status.status !== "idle") diff --git a/app/src/components/admin/CycleSurface.module.css b/app/src/components/admin/CycleSurface.module.css index b118e63..65ee2f9 100644 --- a/app/src/components/admin/CycleSurface.module.css +++ b/app/src/components/admin/CycleSurface.module.css @@ -200,6 +200,25 @@ color: var(--vault-text-muted); } +.useMax { + flex: none; + min-height: 40px; + padding: 9px 11px; + border: 1px solid var(--vault-border-hairline); + border-radius: 0; + background: var(--vault-surface-card); + color: var(--vault-text-body); + font-family: var(--vault-font-mono); + font-weight: 500; + font-size: 12px; +} + +.useMax:hover { + background: var(--vault-action-ring); + border-color: var(--vault-action); + color: var(--vault-action); +} + .note { margin: 0; color: var(--vault-text-muted); diff --git a/app/src/components/admin/CycleSurface.test.tsx b/app/src/components/admin/CycleSurface.test.tsx index 0c44a08..25a1e7c 100644 --- a/app/src/components/admin/CycleSurface.test.tsx +++ b/app/src/components/admin/CycleSurface.test.tsx @@ -327,6 +327,26 @@ const attestAction: CycleAction = { enabled: false, } +const deployAction: CycleAction = { + id: "deploy", + group: "Reserve", + label: "Deploy to custodian", + conditions: [ + { label: "Custodian set", met: true, detail: "CCUS...7890" }, + { label: "Within the free reserve", met: null }, + ], + outcome: null, + field: { + label: "Amount to deploy", + placeholder: "0.00", + value: "", + invalid: false, + max: "900.0000001", + }, + amount: null, + enabled: false, +} + describe("CycleActions", () => { it("lists each condition as met or not met, in the card's label and figure typography", () => { render() @@ -457,6 +477,35 @@ describe("CycleActions", () => { expect(within(band).queryByText("Not met")).toBeNull() }) + it("shows a condition's detail in place of Met when it has one", () => { + render() + + const custodian = screen.getByText("Custodian set").closest("li")! + expect(within(custodian).getByText("CCUS...7890")).toBeTruthy() + expect(within(custodian).queryByText("Met")).toBeNull() + }) + + it("offers to fill the field with its max, reporting the exact figure", () => { + const onInput = vi.fn() + render( + , + ) + + fireEvent.click(screen.getByRole("button", { name: "Use max" })) + + expect(onInput).toHaveBeenCalledWith(deployAction, "900.0000001") + }) + + it("offers no max for a field that has none", () => { + render() + + expect(screen.queryByRole("button", { name: "Use max" })).toBeNull() + }) + it("states an action's note outside the conditions", () => { render( = ({ group }) => ( const conditionValue = (condition: Condition): string => { if (condition.met === null) return "—" - if (condition.met) return "Met" - return condition.detail ?? "Not met" + return condition.detail ?? (condition.met ? "Met" : "Not met") } const ActionField: React.FC<{ @@ -114,6 +113,15 @@ const ActionField: React.FC<{ placeholder={field.placeholder} inputMode="decimal" /> + {field.max !== undefined && ( + + )} ) } diff --git a/app/src/components/vault/TransactionModal.test.tsx b/app/src/components/vault/TransactionModal.test.tsx index 4266517..dec362c 100644 --- a/app/src/components/vault/TransactionModal.test.tsx +++ b/app/src/components/vault/TransactionModal.test.tsx @@ -9,7 +9,9 @@ import { type CancelRedeemStatus } from "../../hooks/useCancelRedeem" import { type ClaimDepositStatus } from "../../hooks/useClaimDeposit" import { type ClaimRedeemStatus } from "../../hooks/useClaimRedeem" import { type CloseEpochStatus } from "../../hooks/useCloseEpoch" +import { type DeployToCustodianStatus } from "../../hooks/useDeployToCustodian" import { type FulfillEpochStatus } from "../../hooks/useFulfillEpoch" +import { type FundStatus } from "../../hooks/useFund" import { type RequestDepositStatus } from "../../hooks/useRequestDeposit" import { type RequestRedeemStatus } from "../../hooks/useRequestRedeem" import TransactionModal from "./TransactionModal" @@ -172,6 +174,36 @@ const renderAttestModal = ( return { ...view, onClose, onRetry } } +const renderDeployModal = (status: DeployToCustodianStatus) => { + const onClose = vi.fn() + const onRetry = vi.fn() + const view = render( + , + ) + return { ...view, onClose, onRetry } +} + +const renderFundModal = (status: FundStatus) => { + const onClose = vi.fn() + const onRetry = vi.fn() + const view = render( + , + ) + return { ...view, onClose, onRetry } +} + describe("TransactionModal", () => { it("renders nothing while idle", () => { renderModal({ status: "idle" }) @@ -1553,3 +1585,176 @@ describe("TransactionModal, attesting a price", () => { expect(onClose).toHaveBeenCalledTimes(1) }) }) + +describe("TransactionModal, deploying to the custodian", () => { + it("opens with a preparing state naming the amount being deployed", () => { + renderDeployModal({ status: "preparing" }) + + expect( + screen.getByRole("heading", { name: "Preparing to deploy 250.00" }), + ).toBeTruthy() + }) + + it("tells the treasury what signing sends and where", () => { + renderDeployModal({ status: "awaiting-signature" }) + + expect( + screen.getByRole("heading", { name: "Confirm in your wallet" }), + ).toBeTruthy() + expect( + screen.getByText( + "Signing sends 250.00 from the vault's reserve to the custodian.", + ), + ).toBeTruthy() + }) + + it("tells the treasury the deployment is on its way", () => { + renderDeployModal({ status: "submitted", hash: "a".repeat(64) }) + + expect( + screen.getByRole("heading", { name: "Sending the deployment" }), + ).toBeTruthy() + expect(screen.getByText(/on its way to the network/)).toBeTruthy() + }) + + it("says what left the vault once confirmed", () => { + renderDeployModal({ status: "confirmed", hash: "b".repeat(64) }) + + expect(screen.getByRole("heading", { name: "Deployed" })).toBeTruthy() + expect( + screen.getByText("250.00 left the vault for the custodian."), + ).toBeTruthy() + }) + + it.each<[number, RegExp]>([ + [6046, /winding down/], + [6007, /greater than zero/], + [6012, /No custodian is set/], + [6005, /free reserve/], + [6014, /too large/], + [2000, /not authorized/], + [2007, /treasury role/], + ])("names the vault's reason for code %i", (code, sentence) => { + renderDeployModal({ + status: "failed", + failure: { kind: "contract-error", code }, + }) + + expect( + screen.getByRole("heading", { name: "The vault refused this request" }), + ).toBeTruthy() + expect(screen.getByText(sentence)).toBeTruthy() + }) + + it("falls back to a generic refusal for a code it does not recognize, without the raw code", () => { + renderDeployModal({ + status: "failed", + failure: { kind: "contract-error", code: 6029 }, + }) + + expect( + screen.getByText(/The vault refused this request\. Try again/), + ).toBeTruthy() + expect(screen.queryByText(/6029/)).toBeNull() + }) + + it("sends an unknown outcome to the Cycle card, not to the investor's requests", () => { + renderDeployModal({ status: "failed", failure: { kind: "unknown" } }) + + expect( + screen.getByText(/check the Cycle card before trying again/), + ).toBeTruthy() + expect(screen.queryByText(/your requests/)).toBeNull() + }) + + it("is reachable as a dialog and dismissible by its close control", () => { + const { onClose } = renderDeployModal({ status: "preparing" }) + + expect(screen.getByRole("dialog")).toBeTruthy() + fireEvent.click(screen.getByRole("button", { name: "Close" })) + expect(onClose).toHaveBeenCalledTimes(1) + }) +}) + +describe("TransactionModal, funding the reserve", () => { + it("opens with a preparing state naming the amount being funded", () => { + renderFundModal({ status: "preparing" }) + + expect( + screen.getByRole("heading", { name: "Preparing to fund 100.00" }), + ).toBeTruthy() + }) + + it("tells the funder what signing moves out of their wallet", () => { + renderFundModal({ status: "awaiting-signature" }) + + expect( + screen.getByRole("heading", { name: "Confirm in your wallet" }), + ).toBeTruthy() + expect( + screen.getByText( + "Signing moves 100.00 from your wallet into the vault's reserve.", + ), + ).toBeTruthy() + }) + + it("tells the funder the funding is on its way", () => { + renderFundModal({ status: "submitted", hash: "a".repeat(64) }) + + expect( + screen.getByRole("heading", { name: "Sending the funding" }), + ).toBeTruthy() + expect(screen.getByText(/on its way to the network/)).toBeTruthy() + }) + + it("says what joined the reserve once confirmed", () => { + renderFundModal({ status: "confirmed", hash: "b".repeat(64) }) + + expect(screen.getByRole("heading", { name: "Funded" })).toBeTruthy() + expect(screen.getByText("100.00 joined the reserve.")).toBeTruthy() + }) + + it.each<[number, RegExp]>([ + [6007, /greater than zero/], + [6014, /too large/], + ])("names the vault's reason for code %i", (code, sentence) => { + renderFundModal({ + status: "failed", + failure: { kind: "contract-error", code }, + }) + + expect( + screen.getByRole("heading", { name: "The vault refused this request" }), + ).toBeTruthy() + expect(screen.getByText(sentence)).toBeTruthy() + }) + + it("falls back to a generic refusal when the asset transfer itself refuses, without the raw code", () => { + renderFundModal({ + status: "failed", + failure: { kind: "contract-error", code: 10 }, + }) + + expect( + screen.getByText(/The vault refused this request\. Try again/), + ).toBeTruthy() + expect(screen.queryByText(/\b10\b/)).toBeNull() + }) + + it("sends an unknown outcome to the Cycle card, not to the investor's requests", () => { + renderFundModal({ status: "failed", failure: { kind: "unknown" } }) + + expect( + screen.getByText(/check the Cycle card before trying again/), + ).toBeTruthy() + expect(screen.queryByText(/your requests/)).toBeNull() + }) + + it("is reachable as a dialog and dismissible by its close control", () => { + const { onClose } = renderFundModal({ status: "preparing" }) + + expect(screen.getByRole("dialog")).toBeTruthy() + fireEvent.click(screen.getByRole("button", { name: "Close" })) + expect(onClose).toHaveBeenCalledTimes(1) + }) +}) diff --git a/app/src/components/vault/TransactionModal.tsx b/app/src/components/vault/TransactionModal.tsx index 604fadb..ba7da08 100644 --- a/app/src/components/vault/TransactionModal.tsx +++ b/app/src/components/vault/TransactionModal.tsx @@ -14,7 +14,9 @@ import { type ClaimDepositStatus } from "../../hooks/useClaimDeposit" import { type ClaimRedeemStatus } from "../../hooks/useClaimRedeem" import { type CloseEpochStatus } from "../../hooks/useCloseEpoch" import { type TransactionFailure } from "../../hooks/useContractTransaction" +import { type DeployToCustodianStatus } from "../../hooks/useDeployToCustodian" import { type FulfillEpochStatus } from "../../hooks/useFulfillEpoch" +import { type FundStatus } from "../../hooks/useFund" import { type RequestDepositStatus } from "../../hooks/useRequestDeposit" import { type RequestRedeemStatus } from "../../hooks/useRequestRedeem" import typeStyles from "../../styles/type.module.css" @@ -96,6 +98,20 @@ export type TransactionModalProps = onClose: () => void onRetry: () => void } + | { + action: "deploy" + status: DeployToCustodianStatus + amount: string + onClose: () => void + onRetry: () => void + } + | { + action: "fund" + status: FundStatus + amount: string + onClose: () => void + onRetry: () => void + } const GENERIC_REFUSAL = "The vault refused this request. Try again, and contact support if it keeps happening." @@ -254,6 +270,38 @@ const attestContractErrorReason = (code: number): string => { } } +const deployContractErrorReason = (code: number): string => { + switch (code) { + case 6046: + return "The vault is winding down, so nothing can leave for the custodian." + case 6007: + return "Enter an amount greater than zero." + case 6012: + return "No custodian is set, so there is nowhere to deploy to." + case 6005: + return "That amount exceeds the free reserve. The rest is committed to priced exits." + case 6014: + return "That amount is too large for the vault to record." + case 2000: + return "This wallet is not authorized to deploy to the custodian." + case 2007: + return "This wallet does not hold the treasury role." + default: + return GENERIC_REFUSAL + } +} + +const fundContractErrorReason = (code: number): string => { + switch (code) { + case 6007: + return "Enter an amount greater than zero." + case 6014: + return "That amount is too large for the vault to record." + default: + return GENERIC_REFUSAL + } +} + const INVESTOR_FOLLOW_UP = "check your requests before doing anything else" const OPERATOR_FOLLOW_UP = "check the Cycle card before trying again" @@ -660,6 +708,88 @@ const describeAttestStatus = ( } } +const describeDeployStatus = ( + status: DeployToCustodianStatus, + amount: string, +): { heading: string; body: string; hash?: string } | undefined => { + switch (status.status) { + case "idle": + return undefined + case "preparing": + return { + heading: `Preparing to deploy ${amount}`, + body: "We are getting the deployment ready. Your wallet will ask you to approve it next.", + } + case "awaiting-signature": + return { + heading: "Confirm in your wallet", + body: `Signing sends ${amount} from the vault's reserve to the custodian.`, + } + case "submitted": + return { + heading: "Sending the deployment", + body: "The deployment is on its way to the network. This should only take a moment. Closing this window will not stop it.", + hash: status.hash, + } + case "confirmed": + return { + heading: "Deployed", + body: `${amount} left the vault for the custodian.`, + hash: status.hash, + } + case "failed": + return { + ...describeFailure( + status.failure, + deployContractErrorReason, + OPERATOR_FOLLOW_UP, + ), + hash: status.hash, + } + } +} + +const describeFundStatus = ( + status: FundStatus, + amount: string, +): { heading: string; body: string; hash?: string } | undefined => { + switch (status.status) { + case "idle": + return undefined + case "preparing": + return { + heading: `Preparing to fund ${amount}`, + body: "We are getting the funding ready. Your wallet will ask you to approve it next.", + } + case "awaiting-signature": + return { + heading: "Confirm in your wallet", + body: `Signing moves ${amount} from your wallet into the vault's reserve.`, + } + case "submitted": + return { + heading: "Sending the funding", + body: "The funding is on its way to the network. This should only take a moment. Closing this window will not stop it.", + hash: status.hash, + } + case "confirmed": + return { + heading: "Funded", + body: `${amount} joined the reserve.`, + hash: status.hash, + } + case "failed": + return { + ...describeFailure( + status.failure, + fundContractErrorReason, + OPERATOR_FOLLOW_UP, + ), + hash: status.hash, + } + } +} + const describeStatus = ( props: TransactionModalProps, ): { heading: string; body: string; hash?: string } | undefined => { @@ -697,6 +827,10 @@ const describeStatus = ( return describeFulfillEpochStatus(props.status, props.epoch) case "attest": return describeAttestStatus(props.status, props.price, props.validFor) + case "deploy": + return describeDeployStatus(props.status, props.amount) + case "fund": + return describeFundStatus(props.status, props.amount) } } @@ -721,7 +855,9 @@ const computeSteps = ( | ClaimRedeemStatus | CloseEpochStatus | FulfillEpochStatus - | AttestStatus, + | AttestStatus + | DeployToCustodianStatus + | FundStatus, ): Record | undefined => { switch (status.status) { case "idle": diff --git a/app/src/hooks/useDeployToCustodian.test.ts b/app/src/hooks/useDeployToCustodian.test.ts new file mode 100644 index 0000000..ebd9e23 --- /dev/null +++ b/app/src/hooks/useDeployToCustodian.test.ts @@ -0,0 +1,166 @@ +import { AssembledTransaction } from "@stellar/stellar-sdk/contract" +import { type Amount } from "@stellar-scaffold/app-lib" +import { act, waitFor } from "@testing-library/react" +import { beforeEach, describe, expect, it, vi } from "vitest" +import { deferred, investorAddress, renderWithWallet } from "./testSupport" +import { cycleWriteKeys } from "./useCycleState" +import { useDeployToCustodian } from "./useDeployToCustodian" + +const { vaultMock, asyncVaultWriterMock } = vi.hoisted(() => ({ + vaultMock: { deploy_to_custodian: vi.fn() }, + asyncVaultWriterMock: vi.fn(), +})) + +vi.mock("../config/clients", () => ({ + asyncVaultWriter: asyncVaultWriterMock, +})) + +const assets = 2_500_000_000n as Amount + +const renderDeploy = () => renderWithWallet(useDeployToCustodian) + +const successful = () => ({ + simulation: undefined, + signAndSend: vi.fn().mockResolvedValue({ + getTransactionResponse: { status: "SUCCESS" }, + result: 4_500_000_000n, + }), +}) + +describe("useDeployToCustodian", () => { + beforeEach(() => { + vi.clearAllMocks() + asyncVaultWriterMock.mockResolvedValue(vaultMock) + }) + + it("moves through awaiting signature, submitted, then confirmed, in order, deploying as the connected wallet", async () => { + const signingGate = deferred() + const confirmGate = deferred() + const signAndSend = vi.fn( + async ({ watcher }: { watcher: { onSubmitted: () => void } }) => { + await signingGate.promise + watcher.onSubmitted() + await confirmGate.promise + return { + getTransactionResponse: { status: "SUCCESS" }, + result: 4_500_000_000n, + } + }, + ) + vaultMock.deploy_to_custodian.mockResolvedValue({ + simulation: undefined, + signAndSend, + }) + const { result } = renderDeploy() + + expect(result.current.submit(assets)).toBe(true) + + await waitFor(() => + expect(result.current.status).toEqual({ status: "awaiting-signature" }), + ) + + signingGate.resolve() + await waitFor(() => + expect(result.current.status).toEqual({ status: "submitted" }), + ) + + confirmGate.resolve() + await waitFor(() => + expect(result.current.status).toEqual({ status: "confirmed" }), + ) + + expect(vaultMock.deploy_to_custodian).toHaveBeenCalledWith({ + caller: investorAddress, + assets, + }) + }) + + it("refuses before ever asking for a signature when the simulation carries the vault's reason", async () => { + const signAndSend = vi.fn() + vaultMock.deploy_to_custodian.mockResolvedValue({ + simulation: { error: "HostError: Error(Contract, #6005)" }, + signAndSend, + }) + const { result } = renderDeploy() + + await act(() => result.current.submit(assets)) + + expect(result.current.status).toEqual({ + status: "failed", + failure: { kind: "contract-error", code: 6005 }, + }) + expect(signAndSend).not.toHaveBeenCalled() + }) + + it("tells a declined signature apart from a failure", async () => { + vaultMock.deploy_to_custodian.mockResolvedValue({ + simulation: undefined, + signAndSend: vi + .fn() + .mockRejectedValue( + new AssembledTransaction.Errors.UserRejected("User declined access"), + ), + }) + const { result } = renderDeploy() + + await act(() => result.current.submit(assets)) + + expect(result.current.status).toEqual({ + status: "failed", + failure: { kind: "declined" }, + }) + }) + + it("ignores a second submission while one is already in flight", async () => { + const gate = deferred() + const signAndSend = vi.fn(async () => { + await gate.promise + return { + getTransactionResponse: { status: "SUCCESS" }, + result: 4_500_000_000n, + } + }) + vaultMock.deploy_to_custodian.mockResolvedValue({ + simulation: undefined, + signAndSend, + }) + const { result } = renderDeploy() + + void result.current.submit(assets) + await waitFor(() => + expect(result.current.status).toEqual({ status: "awaiting-signature" }), + ) + + void result.current.submit(assets) + + gate.resolve() + await waitFor(() => expect(result.current.status.status).toBe("confirmed")) + + expect(vaultMock.deploy_to_custodian).toHaveBeenCalledTimes(1) + }) + + it("refreshes the cycle and the figures once the deployment confirms", async () => { + vaultMock.deploy_to_custodian.mockResolvedValue(successful()) + const { result, queryClient } = renderDeploy() + const invalidateQueries = vi.spyOn(queryClient, "invalidateQueries") + + await act(() => result.current.submit(assets)) + + for (const queryKey of cycleWriteKeys) { + expect(invalidateQueries).toHaveBeenCalledWith({ queryKey }) + } + }) + + it("does not touch the cached cycle when the vault refuses at simulation", async () => { + vaultMock.deploy_to_custodian.mockResolvedValue({ + simulation: { error: "HostError: Error(Contract, #6012)" }, + signAndSend: vi.fn(), + }) + const { result, queryClient } = renderDeploy() + const invalidateQueries = vi.spyOn(queryClient, "invalidateQueries") + + await act(() => result.current.submit(assets)) + + expect(invalidateQueries).not.toHaveBeenCalled() + }) +}) diff --git a/app/src/hooks/useDeployToCustodian.ts b/app/src/hooks/useDeployToCustodian.ts new file mode 100644 index 0000000..3c1666e --- /dev/null +++ b/app/src/hooks/useDeployToCustodian.ts @@ -0,0 +1,32 @@ +import { type Amount, type Signer } from "@stellar-scaffold/app-lib" +import { useCallback } from "react" +import { asyncVaultWriter } from "../config/clients" +import { + useContractTransaction, + type TransactionStatus, +} from "./useContractTransaction" +import { cycleWriteKeys } from "./useCycleState" + +export type DeployToCustodianStatus = TransactionStatus + +export interface UseDeployToCustodian { + status: DeployToCustodianStatus + submit: (assets: Amount) => boolean + reset: () => void +} + +export function useDeployToCustodian(): UseDeployToCustodian { + const call = useCallback( + (signer: Signer, assets: Amount) => + asyncVaultWriter(signer).then((vault) => + vault.deploy_to_custodian({ caller: signer.publicKey, assets }), + ), + [], + ) + const toConfirmed = useCallback(() => ({}), []) + return useContractTransaction>( + call, + toConfirmed, + cycleWriteKeys, + ) +} diff --git a/app/src/hooks/useFund.test.ts b/app/src/hooks/useFund.test.ts new file mode 100644 index 0000000..6e3e71c --- /dev/null +++ b/app/src/hooks/useFund.test.ts @@ -0,0 +1,164 @@ +import { AssembledTransaction } from "@stellar/stellar-sdk/contract" +import { type Amount } from "@stellar-scaffold/app-lib" +import { act, waitFor } from "@testing-library/react" +import { beforeEach, describe, expect, it, vi } from "vitest" +import { deferred, investorAddress, renderWithWallet } from "./testSupport" +import { cycleWriteKeys } from "./useCycleState" +import { depositBalanceKey } from "./useDepositBalance" +import { useFund } from "./useFund" + +const { vaultMock, asyncVaultWriterMock } = vi.hoisted(() => ({ + vaultMock: { fund: vi.fn() }, + asyncVaultWriterMock: vi.fn(), +})) + +vi.mock("../config/clients", () => ({ + asyncVaultWriter: asyncVaultWriterMock, +})) + +const assets = 1_000_000_000n as Amount + +const renderFund = () => renderWithWallet(useFund) + +const successful = () => ({ + simulation: undefined, + signAndSend: vi.fn().mockResolvedValue({ + getTransactionResponse: { status: "SUCCESS" }, + result: 1_000_000_000n, + }), +}) + +describe("useFund", () => { + beforeEach(() => { + vi.clearAllMocks() + asyncVaultWriterMock.mockResolvedValue(vaultMock) + }) + + it("moves through awaiting signature, submitted, then confirmed, in order, funding from the connected wallet", async () => { + const signingGate = deferred() + const confirmGate = deferred() + const signAndSend = vi.fn( + async ({ watcher }: { watcher: { onSubmitted: () => void } }) => { + await signingGate.promise + watcher.onSubmitted() + await confirmGate.promise + return { + getTransactionResponse: { status: "SUCCESS" }, + result: 1_000_000_000n, + } + }, + ) + vaultMock.fund.mockResolvedValue({ simulation: undefined, signAndSend }) + const { result } = renderFund() + + expect(result.current.submit(assets)).toBe(true) + + await waitFor(() => + expect(result.current.status).toEqual({ status: "awaiting-signature" }), + ) + + signingGate.resolve() + await waitFor(() => + expect(result.current.status).toEqual({ status: "submitted" }), + ) + + confirmGate.resolve() + await waitFor(() => + expect(result.current.status).toEqual({ status: "confirmed" }), + ) + + expect(vaultMock.fund).toHaveBeenCalledWith({ + from: investorAddress, + assets, + }) + }) + + it("refuses before ever asking for a signature when the simulation carries the vault's reason", async () => { + const signAndSend = vi.fn() + vaultMock.fund.mockResolvedValue({ + simulation: { error: "HostError: Error(Contract, #6007)" }, + signAndSend, + }) + const { result } = renderFund() + + await act(() => result.current.submit(assets)) + + expect(result.current.status).toEqual({ + status: "failed", + failure: { kind: "contract-error", code: 6007 }, + }) + expect(signAndSend).not.toHaveBeenCalled() + }) + + it("tells a declined signature apart from a failure", async () => { + vaultMock.fund.mockResolvedValue({ + simulation: undefined, + signAndSend: vi + .fn() + .mockRejectedValue( + new AssembledTransaction.Errors.UserRejected("User declined access"), + ), + }) + const { result } = renderFund() + + await act(() => result.current.submit(assets)) + + expect(result.current.status).toEqual({ + status: "failed", + failure: { kind: "declined" }, + }) + }) + + it("ignores a second submission while one is already in flight", async () => { + const gate = deferred() + const signAndSend = vi.fn(async () => { + await gate.promise + return { + getTransactionResponse: { status: "SUCCESS" }, + result: 1_000_000_000n, + } + }) + vaultMock.fund.mockResolvedValue({ simulation: undefined, signAndSend }) + const { result } = renderFund() + + void result.current.submit(assets) + await waitFor(() => + expect(result.current.status).toEqual({ status: "awaiting-signature" }), + ) + + void result.current.submit(assets) + + gate.resolve() + await waitFor(() => expect(result.current.status.status).toBe("confirmed")) + + expect(vaultMock.fund).toHaveBeenCalledTimes(1) + }) + + it("refreshes the cycle, the figures and the wallet's balance once the funding confirms", async () => { + vaultMock.fund.mockResolvedValue(successful()) + const { result, queryClient } = renderFund() + const invalidateQueries = vi.spyOn(queryClient, "invalidateQueries") + + await act(() => result.current.submit(assets)) + + for (const queryKey of cycleWriteKeys) { + expect(invalidateQueries).toHaveBeenCalledWith({ queryKey }) + } + expect(invalidateQueries).toHaveBeenCalledWith({ + queryKey: depositBalanceKey(investorAddress), + }) + }) + + it("does not touch the cached cycle when the vault refuses at simulation", async () => { + vaultMock.fund.mockResolvedValue({ + simulation: { error: "HostError: Error(Contract, #6014)" }, + signAndSend: vi.fn(), + }) + const { result, queryClient } = renderFund() + const invalidateQueries = vi.spyOn(queryClient, "invalidateQueries") + + await act(() => result.current.submit(assets)) + + expect(invalidateQueries).not.toHaveBeenCalled() + }) +}) diff --git a/app/src/hooks/useFund.ts b/app/src/hooks/useFund.ts new file mode 100644 index 0000000..f0e88fc --- /dev/null +++ b/app/src/hooks/useFund.ts @@ -0,0 +1,32 @@ +import { type Amount, type Signer } from "@stellar-scaffold/app-lib" +import { useCallback } from "react" +import { asyncVaultWriter } from "../config/clients" +import { + useContractTransaction, + type TransactionStatus, +} from "./useContractTransaction" +import { cycleWriteKeys } from "./useCycleState" + +export type FundStatus = TransactionStatus + +export interface UseFund { + status: FundStatus + submit: (assets: Amount) => boolean + reset: () => void +} + +export function useFund(): UseFund { + const call = useCallback( + (signer: Signer, assets: Amount) => + asyncVaultWriter(signer).then((vault) => + vault.fund({ from: signer.publicKey, assets }), + ), + [], + ) + const toConfirmed = useCallback(() => ({}), []) + return useContractTransaction>( + call, + toConfirmed, + cycleWriteKeys, + ) +} diff --git a/app/src/pages/cycle.test.ts b/app/src/pages/cycle.test.ts index 845458b..1904ab0 100644 --- a/app/src/pages/cycle.test.ts +++ b/app/src/pages/cycle.test.ts @@ -24,6 +24,7 @@ import { type AttestAction, type CycleAction, type EpochAction, + type TreasuryAction, formatDuration, toActivityList, toCycleActions, @@ -614,7 +615,9 @@ const treasury = grant("vault treasury") type ActionOf = Id extends AttestAction["id"] ? AttestAction - : EpochAction + : Id extends TreasuryAction["id"] + ? TreasuryAction + : EpochAction const actionOf = ( actions: CycleAction[], @@ -627,13 +630,13 @@ const conditionsOf = (action: CycleAction | undefined) => ) describe("toCycleActions", () => { - it("offers the manager both actions and any other cycle role only the settlement", () => { + it("offers the manager the close, and every cycle role the settlement and the funding", () => { expect(actionsFor(ready({}), [manager]).map((action) => action.id)).toEqual( - ["close-epoch", "fulfill-epoch"], + ["close-epoch", "fulfill-epoch", "fund"], ) expect( - actionsFor(ready({}), [treasury]).map((action) => action.id), - ).toEqual(["fulfill-epoch"]) + actionsFor(ready({}), [attester]).map((action) => action.id), + ).toEqual(["fulfill-epoch", "attest", "fund"]) }) it("offers nothing while the cycle is still being read or could not be", () => { @@ -883,7 +886,11 @@ const attestOf = ( input: string, overrides: Partial = {}, grants: Grant[] = [attester], -) => actionOf(toCycleActions(ready(overrides), grants, WALLET, input), "attest") +) => + actionOf( + toCycleActions(ready(overrides), grants, WALLET, { price: input }), + "attest", + ) const withLimits = (limits: Partial>) => ({ ...oracle, limits: { ...oracle.limits!, ...limits } }) as CycleOracle @@ -892,7 +899,7 @@ describe("toCycleActions, attesting", () => { it("offers the attestation only to the oracle attester, in the Price group", () => { expect( actionsFor(ready({}), [attester]).map((action) => action.id), - ).toEqual(["fulfill-epoch", "attest"]) + ).toEqual(["fulfill-epoch", "attest", "fund"]) expect(attestOf("")?.group).toBe("Price") expect(attestOf("", {}, [manager, treasury])).toBeUndefined() }) @@ -1144,3 +1151,319 @@ describe("toCycleActions, attesting", () => { expect(attest?.enabled).toBe(false) }) }) + +const deployOf = ( + input: string, + overrides: Partial = {}, + grants: Grant[] = [treasury], +) => + actionOf( + toCycleActions(ready(overrides), grants, WALLET, { deploy: input }), + "deploy", + ) + +const fundOf = ( + input: string, + walletBalance: Amount | null = amount(1_000n), + overrides: Partial = {}, + grants: Grant[] = [treasury], +) => + actionOf( + toCycleActions(ready(overrides), grants, WALLET, { + fund: input, + walletBalance, + }), + "fund", + ) + +const conditionOf = (action: CycleAction | undefined, label: string) => + action?.conditions.find((condition) => condition.label === label) + +describe("toCycleActions, deploying to the custodian", () => { + it("offers the deployment only to the treasury, in the Reserve group, after the settlement", () => { + expect( + actionsFor(ready({}), [treasury]).map((action) => action.id), + ).toEqual(["fulfill-epoch", "deploy", "fund"]) + expect(deployOf("")?.group).toBe("Reserve") + expect(deployOf("", {}, [manager, attester])).toBeUndefined() + }) + + it("asks for the amount in a decimal field whose max fills the free reserve exactly", () => { + const deploy = deployOf("25", { + reserve: { ...reserve, free: (amount(900n) + 1n) as Amount }, + }) + + expect(deploy?.label).toBe("Deploy to custodian") + expect(deploy?.field).toEqual({ + label: "Amount to deploy", + placeholder: "0.00", + value: "25", + invalid: false, + max: "900.0000001", + }) + }) + + it("offers no max while the free reserve could not be read", () => { + const deploy = deployOf("25", { + reserve: { ...reserve, free: null }, + }) + + expect(deploy?.field?.max).toBeUndefined() + }) + + it("leaves the amount conditions open and the button disabled while nothing is typed", () => { + const deploy = deployOf("") + + expect(conditionsOf(deploy)).toEqual({ + "Wind-down not active": true, + "Custodian set": true, + "Amount above zero": null, + "Within the free reserve": null, + }) + expect(deploy?.outcome).toBeNull() + expect(deploy?.amount).toBeNull() + expect(deploy?.enabled).toBe(false) + expect(deploy?.field?.invalid).toBe(false) + }) + + it.each(["abc", "1.2.3", "1.00000001"])( + "marks %j as invalid and keeps the conditions open", + (input) => { + const deploy = deployOf(input) + + expect(deploy?.field?.invalid).toBe(true) + expect(conditionsOf(deploy)["Within the free reserve"]).toBeNull() + expect(deploy?.enabled).toBe(false) + }, + ) + + it("enables the deployment when every condition is met, saying what leaves and what remains free", () => { + const deploy = deployOf("250") + + expect(conditionsOf(deploy)).toEqual({ + "Wind-down not active": true, + "Custodian set": true, + "Amount above zero": true, + "Within the free reserve": true, + }) + expect(deploy?.amount).toBe(amount(250n)) + expect(deploy?.outcome).toBe( + "Sends 250.00 to the custodian; free reserve becomes 650.00.", + ) + expect(deploy?.enabled).toBe(true) + expect(deploy?.unavailable).toBeUndefined() + }) + + it("keeps every digit the treasury typed in the resulting line and the free reserve after it", () => { + const deploy = deployOf("250.1234567") + + expect(deploy?.outcome).toBe( + "Sends 250.1234567 to the custodian; free reserve becomes 649.8765433.", + ) + }) + + it("shows the free reserve exactly when the amount exceeds it", () => { + const deploy = deployOf("1000", { + reserve: { ...reserve, free: (amount(900n) + 1n) as Amount }, + }) + + expect(conditionOf(deploy, "Within the free reserve")?.detail).toBe( + "Free 900.0000001", + ) + }) + + it("names the custodian beside the condition it satisfies", () => { + expect(conditionOf(deployOf("250"), "Custodian set")).toEqual({ + label: "Custodian set", + met: true, + detail: "CCUS...7890", + }) + }) + + it("lets an amount equal to the free reserve through and empties it", () => { + const deploy = deployOf("900") + + expect(conditionsOf(deploy)["Within the free reserve"]).toBe(true) + expect(deploy?.outcome).toBe( + "Sends 900.00 to the custodian; free reserve becomes 0.00.", + ) + expect(deploy?.enabled).toBe(true) + }) + + it("shows the free reserve when the amount exceeds it, with no resulting line", () => { + const deploy = deployOf("900.0000001") + + expect(conditionOf(deploy, "Within the free reserve")).toEqual({ + label: "Within the free reserve", + met: false, + detail: "Free 900.00", + }) + expect(deploy?.outcome).toBeNull() + expect(deploy?.enabled).toBe(false) + }) + + it.each(["0", "-1"])("refuses %s as an amount", (input) => { + const deploy = deployOf(input) + + expect(conditionsOf(deploy)["Amount above zero"]).toBe(false) + expect(deploy?.outcome).toBeNull() + expect(deploy?.enabled).toBe(false) + }) + + it("cannot vouch for the free reserve when it could not be read", () => { + const deploy = deployOf("250", { reserve: { ...reserve, free: null } }) + + expect(conditionOf(deploy, "Within the free reserve")).toEqual({ + label: "Within the free reserve", + met: false, + }) + expect(deploy?.outcome).toBeNull() + expect(deploy?.enabled).toBe(false) + }) + + it("disables the deployment when no custodian is set", () => { + const deploy = deployOf("250", { + reserve: { ...reserve, custodian: null }, + }) + + expect(conditionOf(deploy, "Custodian set")).toEqual({ + label: "Custodian set", + met: false, + }) + expect(deploy?.enabled).toBe(false) + }) + + it("disables the deployment while the wind-down is active", () => { + const deploy = deployOf("250", { + windDown: { ...windDown, phase: "active", round: 1 }, + }) + + expect(conditionsOf(deploy)["Wind-down not active"]).toBe(false) + expect(deploy?.enabled).toBe(false) + }) + + it("makes the deployment unavailable when the treasury's authority needs more signatures than the wallet's", () => { + const deploy = deployOf("250", {}, [ + grant("vault treasury", { weight: 1, threshold: 3 }), + ]) + + expect(deploy?.unavailable).toBe( + "Needs 3 signatures; collecting them is not supported yet.", + ) + expect(deploy?.enabled).toBe(false) + }) +}) + +describe("toCycleActions, funding the reserve", () => { + it("offers the funding to every cycle role, in the Reserve group, never asking for signatures", () => { + for (const holder of [manager, attester, treasury]) { + const fund = fundOf("10", amount(1_000n), {}, [holder]) + expect(fund?.group).toBe("Reserve") + expect(fund?.unavailable).toBeUndefined() + } + expect( + toCycleActions( + ready({}), + [grant("vault manager", { needs: 2 })], + WALLET, + { + fund: "10", + walletBalance: amount(1_000n), + }, + ).find((action) => action.id === "fund")?.enabled, + ).toBe(true) + }) + + it("asks for the amount in a decimal field without a max", () => { + const fund = fundOf("10") + + expect(fund?.label).toBe("Fund the reserve") + expect(fund?.field).toEqual({ + label: "Amount to fund", + placeholder: "0.00", + value: "10", + invalid: false, + }) + }) + + it("leaves the conditions open and the button disabled while nothing is typed", () => { + const fund = fundOf("") + + expect(conditionsOf(fund)).toEqual({ + "Amount above zero": null, + "Wallet balance covers it": null, + }) + expect(fund?.outcome).toBeNull() + expect(fund?.amount).toBeNull() + expect(fund?.enabled).toBe(false) + }) + + it("enables the funding when the wallet covers it, saying what joins and what becomes free", () => { + const fund = fundOf("100") + + expect(conditionsOf(fund)).toEqual({ + "Amount above zero": true, + "Wallet balance covers it": true, + }) + expect(fund?.amount).toBe(amount(100n)) + expect(fund?.outcome).toBe( + "Adds 100.00 to the reserve; free reserve becomes 1,000.00.", + ) + expect(fund?.enabled).toBe(true) + }) + + it("lets an amount equal to the wallet balance through", () => { + expect(fundOf("1000")?.enabled).toBe(true) + }) + + it("keeps every digit the funder typed in the resulting line and the balance detail", () => { + expect(fundOf("0.0000001")?.outcome).toBe( + "Adds 0.0000001 to the reserve; free reserve becomes 900.0000001.", + ) + expect( + conditionOf( + fundOf("2000", (amount(1_000n) + 1n) as Amount), + "Wallet balance covers it", + )?.detail, + ).toBe("Balance 1,000.0000001") + }) + + it("shows the wallet balance when the amount exceeds it", () => { + const fund = fundOf("1000.0000001") + + expect(conditionOf(fund, "Wallet balance covers it")).toEqual({ + label: "Wallet balance covers it", + met: false, + detail: "Balance 1,000.00", + }) + expect(fund?.enabled).toBe(false) + }) + + it.each(["0", "-5"])("refuses %s as an amount", (input) => { + const fund = fundOf(input) + + expect(conditionsOf(fund)["Amount above zero"]).toBe(false) + expect(fund?.outcome).toBeNull() + expect(fund?.enabled).toBe(false) + }) + + it("says the balance is unavailable when the wallet's could not be read", () => { + const fund = fundOf("100", null) + + expect(conditionOf(fund, "Wallet balance covers it")).toEqual({ + label: "Wallet balance covers it", + met: false, + detail: "Balance unavailable", + }) + expect(fund?.enabled).toBe(false) + }) + + it("allows the funding without a resulting line when the free reserve could not be read", () => { + const fund = fundOf("100", amount(1_000n), { + reserve: { ...reserve, free: null }, + }) + + expect(fund?.outcome).toBeNull() + expect(fund?.enabled).toBe(true) + }) +}) diff --git a/app/src/pages/cycle.ts b/app/src/pages/cycle.ts index f6c6478..90514e8 100644 --- a/app/src/pages/cycle.ts +++ b/app/src/pages/cycle.ts @@ -331,9 +331,10 @@ function windDownGroup(windDown: CycleWindDown): CycleGroup { } } -export type CycleActionId = "close-epoch" | "fulfill-epoch" | "attest" +export type CycleActionId = + "close-epoch" | "fulfill-epoch" | "attest" | "deploy" | "fund" -export type ActionGroup = "Epoch" | "Price" +export type ActionGroup = "Epoch" | "Price" | "Reserve" export type Condition = { label: string; met: boolean | null; detail?: string } @@ -342,6 +343,7 @@ export type ActionField = { placeholder: string value: string invalid: boolean + max?: string } type ActionBase = { @@ -366,10 +368,23 @@ export type AttestAction = ActionBase & { freshness: bigint | null } -export type CycleAction = EpochAction | AttestAction +export type TreasuryAction = ActionBase & { + id: "deploy" | "fund" + amount: Amount | null +} + +export type CycleAction = EpochAction | AttestAction | TreasuryAction + +export type ActionInputs = { + price?: string + deploy?: string + fund?: string + walletBalance?: Amount | null +} const MANAGER_ROLE = "vault manager" const ATTESTER_ROLE = "oracle attester" +const TREASURY_ROLE = "vault treasury" const BPS_DENOMINATOR = 10_000n const signaturesNeeded = (standing: Standing): number | null => { @@ -574,20 +589,157 @@ function toAttestAction( } } +const amountField = ( + label: string, + input: string, + amount: Amount | null, + max?: string, +): ActionField => ({ + label, + placeholder: "0.00", + value: input, + invalid: input.trim() !== "" && amount === null, + ...(max === undefined ? {} : { max }), +}) + +const amountAboveZero = (amount: Amount | null): Condition => ({ + label: "Amount above zero", + met: amount === null ? null : amount > 0n, +}) + +const exactAmount = (amount: Amount): string => + formatExact(amount, AMOUNT_DECIMALS) + +const coveredBy = ( + label: string, + amount: Amount | null, + available: Amount | null, + describe: (available: string) => string, + unavailable?: string, +): Condition => { + if (amount === null) return { label, met: null } + if (available === null) + return unavailable === undefined + ? { label, met: false } + : { label, met: false, detail: unavailable } + const met = amount <= available + return met + ? { label, met } + : { label, met, detail: describe(exactAmount(available)) } +} + +const custodianSet = (custodian: string | null): Condition => + custodian === null + ? { label: "Custodian set", met: false } + : { label: "Custodian set", met: true, detail: shortAddress(custodian) } + +const reserveAfter = ( + amount: Amount | null, + free: Amount | null, + apply: (free: Amount, amount: Amount) => bigint, +): string | null => + amount === null || amount <= 0n || free === null + ? null + : exactAmount(apply(free, amount) as Amount) + +function toDeployAction( + state: ReadyState, + grant: Grant, + wallet: string, + input: string, +): TreasuryAction { + const { free, custodian } = state.reserve + const amount = parseUnits(input, AMOUNT_DECIMALS) + const conditions = [ + windDownInactive(state), + custodianSet(custodian), + amountAboveZero(amount), + coveredBy( + "Within the free reserve", + amount, + free, + (available) => `Free ${available}`, + ), + ] + const unavailable = unavailableFor(grant, wallet) + const remaining = + amount !== null && free !== null && amount > free + ? null + : reserveAfter(amount, free, (held, sent) => held - sent) + return { + id: "deploy", + group: "Reserve", + label: "Deploy to custodian", + conditions, + outcome: + amount === null || remaining === null + ? null + : `Sends ${exactAmount(amount)} to the custodian; free reserve becomes ${remaining}.`, + field: amountField( + "Amount to deploy", + input, + amount, + free === null ? undefined : exactAmount(free), + ), + amount, + enabled: unavailable === undefined && amount !== null && allMet(conditions), + ...(unavailable === undefined ? {} : { unavailable }), + } +} + +function toFundAction( + state: ReadyState, + input: string, + walletBalance: Amount | null, +): TreasuryAction { + const { free } = state.reserve + const amount = parseUnits(input, AMOUNT_DECIMALS) + const conditions = [ + amountAboveZero(amount), + coveredBy( + "Wallet balance covers it", + amount, + walletBalance, + (available) => `Balance ${available}`, + "Balance unavailable", + ), + ] + const total = reserveAfter(amount, free, (held, added) => held + added) + return { + id: "fund", + group: "Reserve", + label: "Fund the reserve", + conditions, + outcome: + amount === null || total === null + ? null + : `Adds ${exactAmount(amount)} to the reserve; free reserve becomes ${total}.`, + field: amountField("Amount to fund", input, amount), + amount, + enabled: amount !== null && allMet(conditions), + } +} + export function toCycleActions( state: CycleState, grants: Grant[], wallet: string, - priceInput = "", + inputs: ActionInputs = {}, ): CycleAction[] { if (state.status !== "ready" || grants.length === 0) return [] const manager = grantFor(grants, MANAGER_ROLE, wallet) const attester = grantFor(grants, ATTESTER_ROLE, wallet) + const treasury = grantFor(grants, TREASURY_ROLE, wallet) const actions: CycleAction[] = [] if (manager !== undefined) actions.push(toCloseAction(state, manager, wallet)) actions.push(toFulfillAction(state)) if (attester !== undefined) - actions.push(toAttestAction(state, attester, wallet, priceInput)) + actions.push(toAttestAction(state, attester, wallet, inputs.price ?? "")) + if (treasury !== undefined) + actions.push(toDeployAction(state, treasury, wallet, inputs.deploy ?? "")) + actions.push( + toFundAction(state, inputs.fund ?? "", inputs.walletBalance ?? null), + ) return actions } From 6d74e20158700598a8194c4f367c7c6f734eb2ea Mon Sep 17 00:00:00 2001 From: luchobonatti Date: Fri, 9 Oct 2026 11:06:24 -0300 Subject: [PATCH 2/4] fix(app): name the funder's balance refresh and transfer refusals --- .../vault/TransactionModal.test.tsx | 6 ++++-- app/src/components/vault/TransactionModal.tsx | 4 +++- app/src/hooks/useFund.test.ts | 21 +++++++++++++++++++ app/src/hooks/useFund.ts | 14 +++++++++++-- 4 files changed, 40 insertions(+), 5 deletions(-) diff --git a/app/src/components/vault/TransactionModal.test.tsx b/app/src/components/vault/TransactionModal.test.tsx index dec362c..6e0c156 100644 --- a/app/src/components/vault/TransactionModal.test.tsx +++ b/app/src/components/vault/TransactionModal.test.tsx @@ -1729,14 +1729,16 @@ describe("TransactionModal, funding the reserve", () => { expect(screen.getByText(sentence)).toBeTruthy() }) - it("falls back to a generic refusal when the asset transfer itself refuses, without the raw code", () => { + it("points the funder at their balance and trustline when the asset transfer refuses, without the raw code", () => { renderFundModal({ status: "failed", failure: { kind: "contract-error", code: 10 }, }) expect( - screen.getByText(/The vault refused this request\. Try again/), + screen.getByText( + "Your wallet could not cover the transfer. Check the balance and the trustline.", + ), ).toBeTruthy() expect(screen.queryByText(/\b10\b/)).toBeNull() }) diff --git a/app/src/components/vault/TransactionModal.tsx b/app/src/components/vault/TransactionModal.tsx index ba7da08..15c78c8 100644 --- a/app/src/components/vault/TransactionModal.tsx +++ b/app/src/components/vault/TransactionModal.tsx @@ -298,7 +298,9 @@ const fundContractErrorReason = (code: number): string => { case 6014: return "That amount is too large for the vault to record." default: - return GENERIC_REFUSAL + return code < 6000 + ? "Your wallet could not cover the transfer. Check the balance and the trustline." + : GENERIC_REFUSAL } } diff --git a/app/src/hooks/useFund.test.ts b/app/src/hooks/useFund.test.ts index 6e3e71c..00dde73 100644 --- a/app/src/hooks/useFund.test.ts +++ b/app/src/hooks/useFund.test.ts @@ -5,6 +5,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest" import { deferred, investorAddress, renderWithWallet } from "./testSupport" import { cycleWriteKeys } from "./useCycleState" import { depositBalanceKey } from "./useDepositBalance" +import { useContractTransaction } from "./useContractTransaction" import { useFund } from "./useFund" const { vaultMock, asyncVaultWriterMock } = vi.hoisted(() => ({ @@ -16,6 +17,15 @@ vi.mock("../config/clients", () => ({ asyncVaultWriter: asyncVaultWriterMock, })) +vi.mock("./useContractTransaction", async (importOriginal) => { + const actual = + await importOriginal() + return { + ...actual, + useContractTransaction: vi.fn(actual.useContractTransaction), + } +}) + const assets = 1_000_000_000n as Amount const renderFund = () => renderWithWallet(useFund) @@ -149,6 +159,17 @@ describe("useFund", () => { }) }) + it("names the funding wallet's balance among the keys it refreshes, keeping submit stable", () => { + const { result, rerender } = renderFund() + const firstSubmit = result.current.submit + + rerender() + + const extraKeys = vi.mocked(useContractTransaction).mock.lastCall?.[2] + expect(extraKeys).toContainEqual(depositBalanceKey(investorAddress)) + expect(result.current.submit).toBe(firstSubmit) + }) + it("does not touch the cached cycle when the vault refuses at simulation", async () => { vaultMock.fund.mockResolvedValue({ simulation: { error: "HostError: Error(Contract, #6014)" }, diff --git a/app/src/hooks/useFund.ts b/app/src/hooks/useFund.ts index f0e88fc..284c699 100644 --- a/app/src/hooks/useFund.ts +++ b/app/src/hooks/useFund.ts @@ -1,11 +1,13 @@ import { type Amount, type Signer } from "@stellar-scaffold/app-lib" -import { useCallback } from "react" +import { useCallback, useMemo } from "react" import { asyncVaultWriter } from "../config/clients" import { useContractTransaction, type TransactionStatus, } from "./useContractTransaction" import { cycleWriteKeys } from "./useCycleState" +import { depositBalanceKey } from "./useDepositBalance" +import { useWallet } from "./useWallet" export type FundStatus = TransactionStatus @@ -16,6 +18,14 @@ export interface UseFund { } export function useFund(): UseFund { + const { address } = useWallet() + const refreshKeys = useMemo( + () => + address + ? [...cycleWriteKeys, depositBalanceKey(address)] + : cycleWriteKeys, + [address], + ) const call = useCallback( (signer: Signer, assets: Amount) => asyncVaultWriter(signer).then((vault) => @@ -27,6 +37,6 @@ export function useFund(): UseFund { return useContractTransaction>( call, toConfirmed, - cycleWriteKeys, + refreshKeys, ) } From c6fde8e80ddd8b8ff5cbbb9ea83b9deec899841c Mon Sep 17 00:00:00 2001 From: luchobonatti Date: Fri, 9 Oct 2026 11:25:38 -0300 Subject: [PATCH 3/4] style(app): order the fund test imports --- app/src/hooks/useFund.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/src/hooks/useFund.test.ts b/app/src/hooks/useFund.test.ts index 00dde73..bb3d301 100644 --- a/app/src/hooks/useFund.test.ts +++ b/app/src/hooks/useFund.test.ts @@ -3,9 +3,10 @@ import { type Amount } from "@stellar-scaffold/app-lib" import { act, waitFor } from "@testing-library/react" import { beforeEach, describe, expect, it, vi } from "vitest" import { deferred, investorAddress, renderWithWallet } from "./testSupport" +import type * as ContractTransactionModule from "./useContractTransaction" +import { useContractTransaction } from "./useContractTransaction" import { cycleWriteKeys } from "./useCycleState" import { depositBalanceKey } from "./useDepositBalance" -import { useContractTransaction } from "./useContractTransaction" import { useFund } from "./useFund" const { vaultMock, asyncVaultWriterMock } = vi.hoisted(() => ({ @@ -18,8 +19,7 @@ vi.mock("../config/clients", () => ({ })) vi.mock("./useContractTransaction", async (importOriginal) => { - const actual = - await importOriginal() + const actual = await importOriginal() return { ...actual, useContractTransaction: vi.fn(actual.useContractTransaction), From 4faf7d77552a33b945aa7eed371c63ec74b11a8e Mon Sep 17 00:00:00 2001 From: luchobonatti Date: Fri, 9 Oct 2026 13:00:47 -0300 Subject: [PATCH 4/4] fix(app): sign exact amounts and refresh the custodian --- .../components/admin/CycleSection.test.tsx | 37 ++++++++++++++++++- app/src/components/admin/CycleSection.tsx | 12 ++---- app/src/hooks/useCycleState.ts | 3 +- app/src/hooks/useDeployToCustodian.test.ts | 12 ++++++ app/src/hooks/useDeployToCustodian.ts | 6 ++- app/src/hooks/useFund.test.ts | 12 ++++++ app/src/hooks/useFund.ts | 6 +-- app/src/pages/cycle.test.ts | 32 ++++++++++++++++ app/src/pages/cycle.ts | 23 ++++++++---- 9 files changed, 120 insertions(+), 23 deletions(-) diff --git a/app/src/components/admin/CycleSection.test.tsx b/app/src/components/admin/CycleSection.test.tsx index 12c1c1d..8ca3e05 100644 --- a/app/src/components/admin/CycleSection.test.tsx +++ b/app/src/components/admin/CycleSection.test.tsx @@ -134,7 +134,7 @@ const ready = (overrides: Partial = {}): CycleState => ({ reserve: { free: amount(900n), committed: null, - uncovered: null, + uncovered: amount(0n), liquid: null, netDeployed: null, depositCap: null, @@ -311,6 +311,24 @@ describe("CycleSection", () => { ).toBeTruthy() }) + it("labels the deployment modal with the exact amount that is signed", () => { + const { rerender } = render( + , + ) + + fireEvent.change( + screen.getByRole("textbox", { name: "Amount to deploy" }), + { target: { value: "250.1234567" } }, + ) + fireEvent.click(button("Deploy to custodian")) + deployMock.status = { status: "preparing" } + rerender() + + expect( + screen.getByRole("heading", { name: "Preparing to deploy 250.1234567" }), + ).toBeTruthy() + }) + it("clears the deployment amount once it confirms", () => { const { rerender } = render( , @@ -329,6 +347,23 @@ describe("CycleSection", () => { expect(screen.getByRole("heading", { name: "Deployed" })).toBeTruthy() }) + it("labels the funding modal with the exact amount that is signed", () => { + const { rerender } = render( + , + ) + + fireEvent.change(screen.getByRole("textbox", { name: "Amount to fund" }), { + target: { value: "100.1234567" }, + }) + fireEvent.click(button("Fund the reserve")) + fundMock.status = { status: "preparing" } + rerender() + + expect( + screen.getByRole("heading", { name: "Preparing to fund 100.1234567" }), + ).toBeTruthy() + }) + it("judges the funding against the connected wallet's balance", () => { render() const field = screen.getByRole("textbox", { name: "Amount to fund" }) diff --git a/app/src/components/admin/CycleSection.tsx b/app/src/components/admin/CycleSection.tsx index 9d3c59e..b98ddba 100644 --- a/app/src/components/admin/CycleSection.tsx +++ b/app/src/components/admin/CycleSection.tsx @@ -1,8 +1,3 @@ -import { - AMOUNT_DECIMALS, - type Amount, - formatScaled, -} from "@stellar-scaffold/app-lib" import React, { useEffect, useState } from "react" import { type Grant } from "../../hooks/useAdminAuthority" import { useAttest } from "../../hooks/useAttest" @@ -16,6 +11,7 @@ import { useFund } from "../../hooks/useFund" import { usePendingTransaction } from "../../hooks/usePendingTransaction" import { type CycleAction, + exactAmount, formatDuration, formatSignedPrice, toActivityList, @@ -36,8 +32,6 @@ const useAmountInput = (confirmed: boolean) => { return [input, setInput] as const } -const amountLabel = (amount: Amount) => formatScaled(amount, AMOUNT_DECIMALS) - const CycleSection: React.FC<{ grants: Grant[]; wallet: string }> = ({ grants, wallet, @@ -80,11 +74,11 @@ const CycleSection: React.FC<{ grants: Grant[]; wallet: string }> = ({ return case "deploy": if (action.amount !== null) - deploy.submit(action.amount, amountLabel(action.amount)) + deploy.submit(action.amount, exactAmount(action.amount)) return case "fund": if (action.amount !== null) - fund.submit(action.amount, amountLabel(action.amount)) + fund.submit(action.amount, exactAmount(action.amount)) } } diff --git a/app/src/hooks/useCycleState.ts b/app/src/hooks/useCycleState.ts index 832cd03..b88c2b7 100644 --- a/app/src/hooks/useCycleState.ts +++ b/app/src/hooks/useCycleState.ts @@ -35,6 +35,7 @@ const VAULT_EVENTS = [ const ORACLE_EVENTS = ["nav_attested"] export const cycleReadsKey = ["cycle", "reads"] as const +export const custodianBalanceKey = ["cycle", "custodianBalance"] as const export const cycleEventsKey = ["cycle", "events"] as const export const cycleWriteKeys = [ epochHistoryKey, @@ -274,7 +275,7 @@ export function useCycleState(): { cycle: CycleState } { }) const custodian = authorities?.custodian ?? null const { data: custodianBalance, isPending: balancePending } = useQuery({ - queryKey: ["cycle", "custodianBalance", custodian], + queryKey: [...custodianBalanceKey, custodian], queryFn: custodian === null ? skipToken : () => fetchCustodianBalance(custodian), staleTime: STALE_MS, diff --git a/app/src/hooks/useDeployToCustodian.test.ts b/app/src/hooks/useDeployToCustodian.test.ts index ebd9e23..f247cff 100644 --- a/app/src/hooks/useDeployToCustodian.test.ts +++ b/app/src/hooks/useDeployToCustodian.test.ts @@ -151,6 +151,18 @@ describe("useDeployToCustodian", () => { } }) + it("refreshes the custodian's balance once the deployment confirms", async () => { + vaultMock.deploy_to_custodian.mockResolvedValue(successful()) + const { result, queryClient } = renderDeploy() + const invalidateQueries = vi.spyOn(queryClient, "invalidateQueries") + + await act(() => result.current.submit(assets)) + + expect(invalidateQueries).toHaveBeenCalledWith({ + queryKey: ["cycle", "custodianBalance"], + }) + }) + it("does not touch the cached cycle when the vault refuses at simulation", async () => { vaultMock.deploy_to_custodian.mockResolvedValue({ simulation: { error: "HostError: Error(Contract, #6012)" }, diff --git a/app/src/hooks/useDeployToCustodian.ts b/app/src/hooks/useDeployToCustodian.ts index 3c1666e..779c061 100644 --- a/app/src/hooks/useDeployToCustodian.ts +++ b/app/src/hooks/useDeployToCustodian.ts @@ -5,7 +5,7 @@ import { useContractTransaction, type TransactionStatus, } from "./useContractTransaction" -import { cycleWriteKeys } from "./useCycleState" +import { custodianBalanceKey, cycleWriteKeys } from "./useCycleState" export type DeployToCustodianStatus = TransactionStatus @@ -15,6 +15,8 @@ export interface UseDeployToCustodian { reset: () => void } +const refreshKeys = [...cycleWriteKeys, custodianBalanceKey] + export function useDeployToCustodian(): UseDeployToCustodian { const call = useCallback( (signer: Signer, assets: Amount) => @@ -27,6 +29,6 @@ export function useDeployToCustodian(): UseDeployToCustodian { return useContractTransaction>( call, toConfirmed, - cycleWriteKeys, + refreshKeys, ) } diff --git a/app/src/hooks/useFund.test.ts b/app/src/hooks/useFund.test.ts index bb3d301..0e147e3 100644 --- a/app/src/hooks/useFund.test.ts +++ b/app/src/hooks/useFund.test.ts @@ -159,6 +159,18 @@ describe("useFund", () => { }) }) + it("refreshes the custodian's balance once the funding confirms", async () => { + vaultMock.fund.mockResolvedValue(successful()) + const { result, queryClient } = renderFund() + const invalidateQueries = vi.spyOn(queryClient, "invalidateQueries") + + await act(() => result.current.submit(assets)) + + expect(invalidateQueries).toHaveBeenCalledWith({ + queryKey: ["cycle", "custodianBalance"], + }) + }) + it("names the funding wallet's balance among the keys it refreshes, keeping submit stable", () => { const { result, rerender } = renderFund() const firstSubmit = result.current.submit diff --git a/app/src/hooks/useFund.ts b/app/src/hooks/useFund.ts index 284c699..4c5e2b3 100644 --- a/app/src/hooks/useFund.ts +++ b/app/src/hooks/useFund.ts @@ -5,7 +5,7 @@ import { useContractTransaction, type TransactionStatus, } from "./useContractTransaction" -import { cycleWriteKeys } from "./useCycleState" +import { custodianBalanceKey, cycleWriteKeys } from "./useCycleState" import { depositBalanceKey } from "./useDepositBalance" import { useWallet } from "./useWallet" @@ -22,8 +22,8 @@ export function useFund(): UseFund { const refreshKeys = useMemo( () => address - ? [...cycleWriteKeys, depositBalanceKey(address)] - : cycleWriteKeys, + ? [...cycleWriteKeys, custodianBalanceKey, depositBalanceKey(address)] + : [...cycleWriteKeys, custodianBalanceKey], [address], ) const call = useCallback( diff --git a/app/src/pages/cycle.test.ts b/app/src/pages/cycle.test.ts index 1904ab0..c1539c8 100644 --- a/app/src/pages/cycle.test.ts +++ b/app/src/pages/cycle.test.ts @@ -1412,6 +1412,38 @@ describe("toCycleActions, funding the reserve", () => { expect(fund?.enabled).toBe(true) }) + describe("against a shortfall", () => { + const short = (uncovered: bigint) => ({ + reserve: { ...reserve, free: amount(0n), uncovered: amount(uncovered) }, + }) + + it("only shrinks the shortfall when the funding does not cover it", () => { + expect(fundOf("100", amount(1_000n), short(250n))?.outcome).toBe( + "Adds 100.00 to the reserve; the shortfall becomes 150.00.", + ) + }) + + it("leaves nothing free when the funding exactly covers the shortfall", () => { + expect(fundOf("250", amount(1_000n), short(250n))?.outcome).toBe( + "Adds 250.00 to the reserve; free reserve becomes 0.00.", + ) + }) + + it("frees only what is left once the shortfall is covered", () => { + expect(fundOf("400", amount(1_000n), short(250n))?.outcome).toBe( + "Adds 400.00 to the reserve; free reserve becomes 150.00.", + ) + }) + + it("states no outcome when the shortfall could not be read", () => { + const unknown = { + reserve: { ...reserve, free: amount(0n), uncovered: null }, + } + + expect(fundOf("100", amount(1_000n), unknown)?.outcome).toBeNull() + }) + }) + it("lets an amount equal to the wallet balance through", () => { expect(fundOf("1000")?.enabled).toBe(true) }) diff --git a/app/src/pages/cycle.ts b/app/src/pages/cycle.ts index 90514e8..495412b 100644 --- a/app/src/pages/cycle.ts +++ b/app/src/pages/cycle.ts @@ -607,7 +607,7 @@ const amountAboveZero = (amount: Amount | null): Condition => ({ met: amount === null ? null : amount > 0n, }) -const exactAmount = (amount: Amount): string => +export const exactAmount = (amount: Amount): string => formatExact(amount, AMOUNT_DECIMALS) const coveredBy = ( @@ -687,12 +687,25 @@ function toDeployAction( } } +const fundOutcome = ( + amount: Amount | null, + free: Amount | null, + uncovered: Amount | null, +): string | null => { + if (amount === null || amount <= 0n || free === null || uncovered === null) + return null + const added = `Adds ${exactAmount(amount)} to the reserve; ` + if (amount < uncovered) + return `${added}the shortfall becomes ${exactAmount((uncovered - amount) as Amount)}.` + return `${added}free reserve becomes ${exactAmount((free + amount - uncovered) as Amount)}.` +} + function toFundAction( state: ReadyState, input: string, walletBalance: Amount | null, ): TreasuryAction { - const { free } = state.reserve + const { free, uncovered } = state.reserve const amount = parseUnits(input, AMOUNT_DECIMALS) const conditions = [ amountAboveZero(amount), @@ -704,16 +717,12 @@ function toFundAction( "Balance unavailable", ), ] - const total = reserveAfter(amount, free, (held, added) => held + added) return { id: "fund", group: "Reserve", label: "Fund the reserve", conditions, - outcome: - amount === null || total === null - ? null - : `Adds ${exactAmount(amount)} to the reserve; free reserve becomes ${total}.`, + outcome: fundOutcome(amount, free, uncovered), field: amountField("Amount to fund", input, amount), amount, enabled: amount !== null && allMet(conditions),