diff --git a/app/src/components/admin/CycleSection.test.tsx b/app/src/components/admin/CycleSection.test.tsx index cc3f6389..8ca3e05d 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, + uncovered: amount(0n), 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,240 @@ 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("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( + , + ) + 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("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" }) + + 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 ba10e0ef..b98ddba2 100644 --- a/app/src/components/admin/CycleSection.tsx +++ b/app/src/components/admin/CycleSection.tsx @@ -3,11 +3,15 @@ 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, + exactAmount, formatDuration, formatSignedPrice, toActivityList, @@ -20,6 +24,14 @@ 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 CycleSection: React.FC<{ grants: Grant[]; wallet: string }> = ({ grants, wallet, @@ -27,15 +39,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 +71,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, exactAmount(action.amount)) + return + case "fund": + if (action.amount !== null) + fund.submit(action.amount, exactAmount(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 +141,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 b118e63a..65ee2f97 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 0c44a080..25a1e7cd 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 42665174..6e0c156b 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,178 @@ 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("points the funder at their balance and trustline when the asset transfer refuses, without the raw code", () => { + renderFundModal({ + status: "failed", + failure: { kind: "contract-error", code: 10 }, + }) + + expect( + screen.getByText( + "Your wallet could not cover the transfer. Check the balance and the trustline.", + ), + ).toBeTruthy() + expect(screen.queryByText(/\b10\b/)).toBeNull() + }) + + it("sends an unknown outcome to the Cycle card, not to the investor's requests", () => { + renderFundModal({ status: "failed", failure: { kind: "unknown" } }) + + expect( + screen.getByText(/check the Cycle card before trying again/), + ).toBeTruthy() + expect(screen.queryByText(/your requests/)).toBeNull() + }) + + it("is reachable as a dialog and dismissible by its close control", () => { + const { onClose } = renderFundModal({ status: "preparing" }) + + expect(screen.getByRole("dialog")).toBeTruthy() + fireEvent.click(screen.getByRole("button", { name: "Close" })) + expect(onClose).toHaveBeenCalledTimes(1) + }) +}) diff --git a/app/src/components/vault/TransactionModal.tsx b/app/src/components/vault/TransactionModal.tsx index 604fadbc..15c78c8e 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,40 @@ 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 code < 6000 + ? "Your wallet could not cover the transfer. Check the balance and the trustline." + : 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 +710,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 +829,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 +857,9 @@ const computeSteps = ( | ClaimRedeemStatus | CloseEpochStatus | FulfillEpochStatus - | AttestStatus, + | AttestStatus + | DeployToCustodianStatus + | FundStatus, ): Record | undefined => { switch (status.status) { case "idle": diff --git a/app/src/hooks/useCycleState.ts b/app/src/hooks/useCycleState.ts index 832cd03c..b88c2b73 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 new file mode 100644 index 00000000..f247cff8 --- /dev/null +++ b/app/src/hooks/useDeployToCustodian.test.ts @@ -0,0 +1,178 @@ +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("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)" }, + 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 00000000..779c061b --- /dev/null +++ b/app/src/hooks/useDeployToCustodian.ts @@ -0,0 +1,34 @@ +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 { custodianBalanceKey, cycleWriteKeys } from "./useCycleState" + +export type DeployToCustodianStatus = TransactionStatus + +export interface UseDeployToCustodian { + status: DeployToCustodianStatus + submit: (assets: Amount) => boolean + reset: () => void +} + +const refreshKeys = [...cycleWriteKeys, custodianBalanceKey] + +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, + refreshKeys, + ) +} diff --git a/app/src/hooks/useFund.test.ts b/app/src/hooks/useFund.test.ts new file mode 100644 index 00000000..0e147e33 --- /dev/null +++ b/app/src/hooks/useFund.test.ts @@ -0,0 +1,197 @@ +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 type * as ContractTransactionModule from "./useContractTransaction" +import { useContractTransaction } from "./useContractTransaction" +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, +})) + +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) + +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("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 + + 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)" }, + 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 00000000..4c5e2b30 --- /dev/null +++ b/app/src/hooks/useFund.ts @@ -0,0 +1,42 @@ +import { type Amount, type Signer } from "@stellar-scaffold/app-lib" +import { useCallback, useMemo } from "react" +import { asyncVaultWriter } from "../config/clients" +import { + useContractTransaction, + type TransactionStatus, +} from "./useContractTransaction" +import { custodianBalanceKey, cycleWriteKeys } from "./useCycleState" +import { depositBalanceKey } from "./useDepositBalance" +import { useWallet } from "./useWallet" + +export type FundStatus = TransactionStatus + +export interface UseFund { + status: FundStatus + submit: (assets: Amount) => boolean + reset: () => void +} + +export function useFund(): UseFund { + const { address } = useWallet() + const refreshKeys = useMemo( + () => + address + ? [...cycleWriteKeys, custodianBalanceKey, depositBalanceKey(address)] + : [...cycleWriteKeys, custodianBalanceKey], + [address], + ) + const call = useCallback( + (signer: Signer, assets: Amount) => + asyncVaultWriter(signer).then((vault) => + vault.fund({ from: signer.publicKey, assets }), + ), + [], + ) + const toConfirmed = useCallback(() => ({}), []) + return useContractTransaction>( + call, + toConfirmed, + refreshKeys, + ) +} diff --git a/app/src/pages/cycle.test.ts b/app/src/pages/cycle.test.ts index 845458be..c1539c81 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,351 @@ 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) + }) + + 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) + }) + + 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 f6c64782..495412bf 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,166 @@ 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, +}) + +export 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 }), + } +} + +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, uncovered } = state.reserve + const amount = parseUnits(input, AMOUNT_DECIMALS) + const conditions = [ + amountAboveZero(amount), + coveredBy( + "Wallet balance covers it", + amount, + walletBalance, + (available) => `Balance ${available}`, + "Balance unavailable", + ), + ] + return { + id: "fund", + group: "Reserve", + label: "Fund the reserve", + conditions, + outcome: fundOutcome(amount, free, uncovered), + 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 }