From b6f2d4b8f8d6cecbc5dab236319e0ee1033f8956 Mon Sep 17 00:00:00 2001 From: luchobonatti Date: Fri, 9 Oct 2026 10:08:32 -0300 Subject: [PATCH 1/3] feat(app): attest the share price from the admin panel --- app-lib/contracts.test.ts | 31 ++- app-lib/contracts.ts | 7 +- .../components/admin/CycleSection.test.tsx | 76 +++++- app/src/components/admin/CycleSection.tsx | 59 ++++- .../components/admin/CycleSurface.module.css | 41 ++++ .../components/admin/CycleSurface.test.tsx | 101 ++++++++ app/src/components/admin/CycleSurface.tsx | 48 +++- .../vault/TransactionModal.test.tsx | 116 +++++++++ app/src/components/vault/TransactionModal.tsx | 74 +++++- app/src/config/clients.ts | 37 +-- app/src/hooks/useAttest.test.ts | 173 +++++++++++++ app/src/hooks/useAttest.ts | 43 ++++ app/src/pages/cycle.test.ts | 228 +++++++++++++++++- app/src/pages/cycle.ts | 163 +++++++++++-- 14 files changed, 1144 insertions(+), 53 deletions(-) create mode 100644 app/src/hooks/useAttest.test.ts create mode 100644 app/src/hooks/useAttest.ts diff --git a/app-lib/contracts.test.ts b/app-lib/contracts.test.ts index d9fc86d..a9fa40e 100644 --- a/app-lib/contracts.test.ts +++ b/app-lib/contracts.test.ts @@ -8,7 +8,7 @@ vi.mock("@stellar/stellar-sdk/contract", async (importOriginal) => { return { ...actual, Client: { from: fromMock } } }) -import { connectAsyncVault } from "./contracts" +import { connectAsyncVault, connectNavOracle } from "./contracts" describe("connectAsyncVault", () => { beforeEach(() => fromMock.mockClear()) @@ -38,3 +38,32 @@ describe("connectAsyncVault", () => { expect(options.signTransaction).toBe(signTransaction) }) }) + +describe("connectNavOracle", () => { + beforeEach(() => fromMock.mockClear()) + + it("builds a read client with no publicKey or signTransaction when no signer is given", async () => { + fromMock.mockResolvedValue({}) + + await connectNavOracle("CORACLE") + + const options = fromMock.mock.calls[0]?.[0] + expect(options.contractId).toBe("CORACLE") + expect(options.publicKey).toBeUndefined() + expect(options.signTransaction).toBeUndefined() + }) + + it("carries the connected address and signer when one is given", async () => { + fromMock.mockResolvedValue({}) + const signTransaction = vi.fn() + + await connectNavOracle("CORACLE", { + publicKey: "GATTESTER", + signTransaction, + }) + + const options = fromMock.mock.calls[0]?.[0] + expect(options.publicKey).toBe("GATTESTER") + expect(options.signTransaction).toBe(signTransaction) + }) +}) diff --git a/app-lib/contracts.ts b/app-lib/contracts.ts index 2e37885..6f95766 100644 --- a/app-lib/contracts.ts +++ b/app-lib/contracts.ts @@ -57,8 +57,11 @@ export const connectAsyncVault = ( export const isUserRejection = (error: unknown): boolean => error instanceof AssembledTransaction.Errors.UserRejected -export const connectNavOracle = (contractId: string): Promise => - Client.from(clientOptions(contractId)) +export const connectNavOracle = ( + contractId: string, + signer?: Signer, +): Promise => + Client.from(clientOptions(contractId, signer)) export const connectIdentityVerifier = ( contractId: string, diff --git a/app/src/components/admin/CycleSection.test.tsx b/app/src/components/admin/CycleSection.test.tsx index 8026ad1..dc5355e 100644 --- a/app/src/components/admin/CycleSection.test.tsx +++ b/app/src/components/admin/CycleSection.test.tsx @@ -2,6 +2,7 @@ import { type Amount, type Price } from "@stellar-scaffold/app-lib" import { fireEvent, render, screen, within } from "@testing-library/react" import { beforeEach, describe, expect, it, vi } from "vitest" import { 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 EpochRecord } from "../../hooks/useEpochHistory" @@ -14,6 +15,7 @@ const { useEpochHistoryMock, closeEpochMock, fulfillEpochMock, + attestMock, } = vi.hoisted(() => ({ useCycleStateMock: vi.fn(), useCycleEventsMock: vi.fn(), @@ -28,6 +30,11 @@ const { submit: vi.fn(() => true), reset: vi.fn(), }, + attestMock: { + status: { status: "idle" } as AttestStatus, + submit: vi.fn(() => true), + reset: vi.fn(), + }, })) vi.mock("../../hooks/useCycleState", () => ({ @@ -43,6 +50,9 @@ vi.mock("../../hooks/useCloseEpoch", () => ({ vi.mock("../../hooks/useFulfillEpoch", () => ({ useFulfillEpoch: () => fulfillEpochMock, })) +vi.mock("../../hooks/useAttest", () => ({ + useAttest: () => attestMock, +})) const amount = (whole: bigint) => (whole * 10_000_000n) as Amount const price = (whole: bigint) => (whole * 10n ** 18n) as Price @@ -85,7 +95,14 @@ const ready = (overrides: Partial = {}): CycleState => ({ attestedAt: 1_700_050_000n, expiresAt: 1_700_136_000n, ripcord: false, - limits: null, + limits: { + freshness: 86_400n, + cooldown: 3_600n, + maxUpBps: 500, + maxDownBps: null, + min: price(1n) / 2n, + max: price(2n), + }, }, reserve: { free: null, @@ -119,6 +136,7 @@ const grant = (role: string, standing: Grant["standing"] = "signs-alone") => ({ const manager = grant("vault manager") const treasury = grant("vault treasury") +const attester = grant("oracle attester") const button = (name: string) => screen.getByRole("button", { name }) as HTMLButtonElement @@ -128,8 +146,10 @@ describe("CycleSection", () => { vi.clearAllMocks() closeEpochMock.status = { status: "idle" } fulfillEpochMock.status = { status: "idle" } + attestMock.status = { status: "idle" } closeEpochMock.submit.mockReturnValue(true) fulfillEpochMock.submit.mockReturnValue(true) + attestMock.submit.mockReturnValue(true) useCycleStateMock.mockReturnValue({ cycle: ready() }) useCycleEventsMock.mockReturnValue({ cycleEvents: { status: "loaded", events: [] }, @@ -164,9 +184,63 @@ describe("CycleSection", () => { 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) }) + it("offers the attester a price field under the Price group, with the button held until a price is typed", () => { + render() + + const field = screen.getByRole("textbox", { name: "Share price" }) + const priceGroup = screen.getByRole("heading", { + name: "Price", + }).parentElement! + expect(within(priceGroup).getByRole("textbox")).toBe(field) + expect(button("Attest price").disabled).toBe(true) + const band = screen.getByText("Within the band").closest("li")! + expect(within(band).getByText("—")).toBeTruthy() + + fireEvent.change(field, { target: { value: "1.04" } }) + + expect(button("Attest price").disabled).toBe(false) + expect(within(band).getByText("Met")).toBeTruthy() + expect( + screen.getByText(/^Records 1\.0400 as the share price, valid until /), + ).toBeTruthy() + }) + + it("submits the typed price with its expiry and follows it in the modal, labelled with the price", () => { + const { rerender } = render( + , + ) + + fireEvent.change(screen.getByRole("textbox", { name: "Share price" }), { + target: { value: "1.04" }, + }) + fireEvent.click(button("Attest price")) + + expect(attestMock.submit).toHaveBeenCalledWith({ + price: 1_040_000_000_000_000_000n, + expiresAt: 1_700_100_000n + 86_400n, + }) + + attestMock.status = { status: "preparing" } + rerender() + + expect( + screen.getByRole("heading", { name: "Preparing to attest 1.0400" }), + ).toBeTruthy() + }) + + it("tells the attester how long the attested price stays valid once confirmed", () => { + attestMock.status = { status: "confirmed" } + render() + + expect(screen.getByRole("heading", { name: "Price attested" })).toBeTruthy() + expect(screen.getByText(/Valid for 1d\./)).toBeTruthy() + }) + it("disables the settlement and marks the failing condition while the notice runs", () => { useCycleStateMock.mockReturnValue({ cycle: ready({ ledgerTime: sealed.priceableAt - 1n }), diff --git a/app/src/components/admin/CycleSection.tsx b/app/src/components/admin/CycleSection.tsx index 139e596..f7c7147 100644 --- a/app/src/components/admin/CycleSection.tsx +++ b/app/src/components/admin/CycleSection.tsx @@ -1,5 +1,7 @@ -import React from "react" +import { formatScaled, PRICE_DECIMALS } from "@stellar-scaffold/app-lib" +import React, { 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 { useEpochHistory } from "../../hooks/useEpochHistory" @@ -7,6 +9,7 @@ import { useFulfillEpoch } from "../../hooks/useFulfillEpoch" import { usePendingTransaction } from "../../hooks/usePendingTransaction" import { type CycleAction, + formatDuration, toActivityList, toCycleActions, toCycleRows, @@ -17,8 +20,6 @@ import TransactionModal, { } from "../vault/TransactionModal" import CycleSurface, { CycleActions, type CycleGroup } from "./CycleSurface" -const EPOCH_GROUP = "Epoch" - const CycleSection: React.FC<{ grants: Grant[]; wallet: string }> = ({ grants, wallet, @@ -28,19 +29,45 @@ const CycleSection: React.FC<{ grants: Grant[]; wallet: string }> = ({ const { cycleEvents } = useCycleEvents() const closeEpoch = usePendingTransaction(useCloseEpoch()) const fulfillEpoch = usePendingTransaction(useFulfillEpoch()) + const attest = usePendingTransaction(useAttest()) + const [priceInput, setPriceInput] = useState("") const run = (action: CycleAction) => { - if (action.epochId === null) return - const label = String(action.epochId) - if (action.id === "close-epoch") closeEpoch.submit(undefined, label) - else fulfillEpoch.submit(action.epochId, label) + switch (action.id) { + case "close-epoch": + if (action.epochId !== null) + closeEpoch.submit(undefined, String(action.epochId)) + return + case "fulfill-epoch": + if (action.epochId !== null) + fulfillEpoch.submit(action.epochId, String(action.epochId)) + return + case "attest": + if (action.price !== null && action.expiresAt !== null) + attest.submit( + { price: action.price, expiresAt: action.expiresAt }, + formatScaled(action.price, PRICE_DECIMALS, 4), + ) + } + } + + const onInput = (action: CycleAction, value: string) => { + if (action.id === "attest") setPriceInput(value) } - const actions = toCycleActions(cycle, grants, wallet) - const withActions = (group: CycleGroup): CycleGroup => - group.title === EPOCH_GROUP && actions.length > 0 - ? { ...group, actions: } - : group + const actions = toCycleActions(cycle, grants, wallet, priceInput) + const withActions = (group: CycleGroup): CycleGroup => { + const own = actions.filter((action) => action.group === group.title) + return own.length === 0 + ? group + : { + ...group, + actions: , + } + } + + const freshness = + cycle.status === "ready" ? cycle.oracle.limits?.freshness : undefined const modalFlows: TransactionModalProps[] = [ { @@ -57,6 +84,14 @@ const CycleSection: React.FC<{ grants: Grant[]; wallet: string }> = ({ onClose: fulfillEpoch.reset, onRetry: fulfillEpoch.retry, }, + { + action: "attest", + status: attest.status, + price: attest.amountLabel, + validFor: freshness === undefined ? null : formatDuration(freshness), + onClose: attest.reset, + onRetry: attest.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 c4c1d32..b118e63 100644 --- a/app/src/components/admin/CycleSurface.module.css +++ b/app/src/components/admin/CycleSurface.module.css @@ -171,3 +171,44 @@ color: var(--vault-text-muted); white-space: normal; } + +.field, +.fieldOver { + display: flex; + align-items: center; + gap: 10px; + min-height: 58px; + padding: 8px 16px; + border: 1px solid var(--vault-action); + background: var(--vault-surface-card); +} + +.fieldOver { + border: 2px solid var(--vault-danger); +} + +.fieldInput { + flex: 1; + min-width: 0; + border: 0; + outline: none; + background: transparent; + color: var(--vault-ink); +} + +.fieldInput::placeholder { + color: var(--vault-text-muted); +} + +.note { + margin: 0; + color: var(--vault-text-muted); +} + +.srOnly { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); +} diff --git a/app/src/components/admin/CycleSurface.test.tsx b/app/src/components/admin/CycleSurface.test.tsx index 632646f..f637eb7 100644 --- a/app/src/components/admin/CycleSurface.test.tsx +++ b/app/src/components/admin/CycleSurface.test.tsx @@ -283,6 +283,7 @@ describe("CycleSurface", () => { const closeAction: CycleAction = { id: "close-epoch", + group: "Epoch", label: "Close epoch", conditions: [ { label: "Wind-down not active", met: true }, @@ -295,6 +296,7 @@ const closeAction: CycleAction = { const fulfillAction: CycleAction = { id: "fulfill-epoch", + group: "Epoch", label: "Fulfill epoch", conditions: [ { label: "Sealed epoch awaiting a price", met: true }, @@ -305,6 +307,26 @@ const fulfillAction: CycleAction = { enabled: false, } +const attestAction: CycleAction = { + id: "attest", + group: "Price", + label: "Attest price", + conditions: [ + { label: "Within the band", met: null }, + { label: "Cooldown elapsed", met: true }, + ], + outcome: null, + field: { + label: "Share price", + placeholder: "0.0000", + value: "", + invalid: false, + }, + price: null, + expiresAt: null, + enabled: false, +} + describe("CycleActions", () => { it("lists each condition as met or not met, in the card's label and figure typography", () => { render() @@ -375,4 +397,83 @@ describe("CycleActions", () => { expect(screen.queryByText(/Prices epoch/)).toBeNull() expect(screen.getByRole("button", { name: "Fulfill epoch" })).toBeTruthy() }) + + it("asks for a figure in a decimal field, labelled for screen readers, and reports each keystroke", () => { + const onInput = vi.fn() + render( + , + ) + + const field = screen.getByRole("textbox", { + name: "Share price", + }) as HTMLInputElement + expect(field.placeholder).toBe("0.0000") + expect(field.inputMode).toBe("decimal") + + fireEvent.change(field, { target: { value: "1.04" } }) + + expect(onInput).toHaveBeenCalledWith(attestAction, "1.04") + }) + + it("renders no field for an action that takes no figure", () => { + render() + + expect(screen.queryByRole("textbox")).toBeNull() + }) + + it("leaves a condition open with a dash while it cannot be judged yet", () => { + render() + + const band = screen.getByText("Within the band").closest("li")! + expect(within(band).getByText("—")).toBeTruthy() + expect(within(band).queryByText("Not met")).toBeNull() + }) + + it("shows a condition's detail in place of Not met when it has one", () => { + render( + , + ) + + const band = screen.getByText("Within the band").closest("li")! + expect(within(band).getByText("0.5000 – 2.0000")).toBeTruthy() + expect(within(band).queryByText("Not met")).toBeNull() + }) + + it("states an action's note outside the conditions", () => { + render( + , + ) + + const list = screen.getByRole("list", { name: "Attest price conditions" }) + expect( + screen.getByText("Ripcord raised — attesting does not lift it."), + ).toBeTruthy() + expect(within(list).queryByText(/Ripcord/)).toBeNull() + }) }) diff --git a/app/src/components/admin/CycleSurface.tsx b/app/src/components/admin/CycleSurface.tsx index 03ef061..b20ebe5 100644 --- a/app/src/components/admin/CycleSurface.tsx +++ b/app/src/components/admin/CycleSurface.tsx @@ -1,5 +1,9 @@ import React from "react" -import { type CycleAction } from "../../pages/cycle" +import { + type ActionField as ActionFieldSpec, + type Condition, + type CycleAction, +} from "../../pages/cycle" import typeStyles from "../../styles/type.module.css" import Skeleton from "../Skeleton" import { type AddressRow, type FigureGroup } from "../vault/AboutVault" @@ -85,13 +89,46 @@ const Group: React.FC<{ group: CycleGroup }> = ({ group }) => ( ) +const conditionValue = (condition: Condition): string => { + if (condition.met === null) return "—" + if (condition.met) return "Met" + return condition.detail ?? "Not met" +} + +const ActionField: React.FC<{ + action: CycleAction + field: ActionFieldSpec + onInput: ((action: CycleAction, value: string) => void) | undefined +}> = ({ action, field, onInput }) => { + const id = `cycle-${action.id}-field` + return ( +
+ + onInput?.(action, event.target.value)} + placeholder={field.placeholder} + inputMode="decimal" + /> +
+ ) +} + export const CycleActions: React.FC<{ actions: CycleAction[] onRun: (action: CycleAction) => void -}> = ({ actions, onRun }) => ( + onInput?: (action: CycleAction, value: string) => void +}> = ({ actions, onRun, onInput }) => (
{actions.map((action) => (
+ {action.field !== undefined && ( + + )}
    - {condition.met ? "Met" : "Not met"} + {conditionValue(condition)} ))}
+ {action.note !== undefined && ( +

+ {action.note} +

+ )} {action.outcome !== null && (

{action.outcome} diff --git a/app/src/components/vault/TransactionModal.test.tsx b/app/src/components/vault/TransactionModal.test.tsx index 876ab91..4266517 100644 --- a/app/src/components/vault/TransactionModal.test.tsx +++ b/app/src/components/vault/TransactionModal.test.tsx @@ -3,6 +3,7 @@ import type * as AppLib from "@stellar-scaffold/app-lib" import { fireEvent, render, screen, within } from "@testing-library/react" import { StrictMode } from "react" import { describe, expect, it, vi } from "vitest" +import { type AttestStatus } from "../../hooks/useAttest" import { type CancelDepositStatus } from "../../hooks/useCancelDeposit" import { type CancelRedeemStatus } from "../../hooks/useCancelRedeem" import { type ClaimDepositStatus } from "../../hooks/useClaimDeposit" @@ -152,6 +153,25 @@ const renderFulfillEpochModal = (status: FulfillEpochStatus) => { return { ...view, onClose, onRetry } } +const renderAttestModal = ( + status: AttestStatus, + validFor: string | null = "1d", +) => { + const onClose = vi.fn() + const onRetry = vi.fn() + const view = render( + , + ) + return { ...view, onClose, onRetry } +} + describe("TransactionModal", () => { it("renders nothing while idle", () => { renderModal({ status: "idle" }) @@ -1437,3 +1457,99 @@ describe("TransactionModal, pricing an epoch", () => { expect(onClose).toHaveBeenCalledTimes(1) }) }) + +describe("TransactionModal, attesting a price", () => { + it("opens with a preparing state naming the price being attested", () => { + renderAttestModal({ status: "preparing" }) + + expect( + screen.getByRole("heading", { name: "Preparing to attest 1.0400" }), + ).toBeTruthy() + }) + + it("tells the attester what signing records", () => { + renderAttestModal({ status: "awaiting-signature" }) + + expect( + screen.getByRole("heading", { name: "Confirm in your wallet" }), + ).toBeTruthy() + expect( + screen.getByText( + "Signing records 1.0400 as the share price. Sealed epochs settle at it once their notice elapses.", + ), + ).toBeTruthy() + }) + + it("tells the attester the attestation is on its way", () => { + renderAttestModal({ status: "submitted", hash: "a".repeat(64) }) + + expect( + screen.getByRole("heading", { name: "Sending the attestation" }), + ).toBeTruthy() + expect(screen.getByText(/on its way to the network/)).toBeTruthy() + }) + + it("says how long the price stays valid once confirmed", () => { + renderAttestModal({ status: "confirmed", hash: "b".repeat(64) }) + + expect(screen.getByRole("heading", { name: "Price attested" })).toBeTruthy() + expect( + screen.getByText("1.0400 is the share price. Valid for 1d."), + ).toBeTruthy() + }) + + it("leaves the validity out when the freshness window is not known", () => { + renderAttestModal({ status: "confirmed" }, null) + + expect(screen.getByText("1.0400 is the share price.")).toBeTruthy() + }) + + it.each<[number, RegExp]>([ + [3002, /outside the band/], + [3005, /already have expired/], + [3003, /cooldown .* has not elapsed/], + [3004, /moves further from the last attested price/], + ])("names the oracle's reason for code %i", (code, sentence) => { + renderAttestModal({ + status: "failed", + failure: { kind: "contract-error", code }, + }) + + expect( + screen.getByRole("heading", { name: "The vault refused this request" }), + ).toBeTruthy() + expect(screen.getByText(sentence)).toBeTruthy() + }) + + it.each([2000, 2007])( + "falls back to a generic refusal for code %i, without the raw code", + (code) => { + renderAttestModal({ + status: "failed", + failure: { kind: "contract-error", code }, + }) + + expect( + screen.getByText(/The vault refused this request\. Try again/), + ).toBeTruthy() + expect(screen.queryByText(new RegExp(String(code)))).toBeNull() + }, + ) + + it("sends an unknown outcome to the Cycle card, not to the investor's requests", () => { + renderAttestModal({ status: "failed", failure: { kind: "unknown" } }) + + expect( + screen.getByText(/check the Cycle card before trying again/), + ).toBeTruthy() + expect(screen.queryByText(/your requests/)).toBeNull() + }) + + it("is reachable as a dialog and dismissible by its close control", () => { + const { onClose } = renderAttestModal({ status: "preparing" }) + + expect(screen.getByRole("dialog")).toBeTruthy() + fireEvent.click(screen.getByRole("button", { name: "Close" })) + expect(onClose).toHaveBeenCalledTimes(1) + }) +}) diff --git a/app/src/components/vault/TransactionModal.tsx b/app/src/components/vault/TransactionModal.tsx index 261243d..604fadb 100644 --- a/app/src/components/vault/TransactionModal.tsx +++ b/app/src/components/vault/TransactionModal.tsx @@ -7,6 +7,7 @@ import { shortAddress, } from "@stellar-scaffold/app-lib" import React from "react" +import { type AttestStatus } from "../../hooks/useAttest" import { type CancelDepositStatus } from "../../hooks/useCancelDeposit" import { type CancelRedeemStatus } from "../../hooks/useCancelRedeem" import { type ClaimDepositStatus } from "../../hooks/useClaimDeposit" @@ -87,6 +88,14 @@ export type TransactionModalProps = onClose: () => void onRetry: () => void } + | { + action: "attest" + status: AttestStatus + price: string + validFor: string | null + onClose: () => void + onRetry: () => void + } const GENERIC_REFUSAL = "The vault refused this request. Try again, and contact support if it keeps happening." @@ -230,6 +239,21 @@ const fulfillEpochContractErrorReason = (code: number): string => { } } +const attestContractErrorReason = (code: number): string => { + switch (code) { + case 3002: + return "That price is outside the band the oracle accepts." + case 3005: + return "The attestation would already have expired when recorded." + case 3003: + return "The cooldown since the last attestation has not elapsed yet." + case 3004: + return "That price moves further from the last attested price than the oracle allows." + 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" @@ -591,6 +615,51 @@ const describeFulfillEpochStatus = ( } } +const describeAttestStatus = ( + status: AttestStatus, + price: string, + validFor: string | null, +): { heading: string; body: string; hash?: string } | undefined => { + switch (status.status) { + case "idle": + return undefined + case "preparing": + return { + heading: `Preparing to attest ${price}`, + body: "We are getting the attestation ready. Your wallet will ask you to approve it next.", + } + case "awaiting-signature": + return { + heading: "Confirm in your wallet", + body: `Signing records ${price} as the share price. Sealed epochs settle at it once their notice elapses.`, + } + case "submitted": + return { + heading: "Sending the attestation", + body: "The attestation is on its way to the network. This should only take a moment. Closing this window will not stop it.", + hash: status.hash, + } + case "confirmed": + return { + heading: "Price attested", + body: + validFor === null + ? `${price} is the share price.` + : `${price} is the share price. Valid for ${validFor}.`, + hash: status.hash, + } + case "failed": + return { + ...describeFailure( + status.failure, + attestContractErrorReason, + OPERATOR_FOLLOW_UP, + ), + hash: status.hash, + } + } +} + const describeStatus = ( props: TransactionModalProps, ): { heading: string; body: string; hash?: string } | undefined => { @@ -626,6 +695,8 @@ const describeStatus = ( return describeCloseEpochStatus(props.status, props.epoch) case "fulfill-epoch": return describeFulfillEpochStatus(props.status, props.epoch) + case "attest": + return describeAttestStatus(props.status, props.price, props.validFor) } } @@ -649,7 +720,8 @@ const computeSteps = ( | RequestRedeemStatus | ClaimRedeemStatus | CloseEpochStatus - | FulfillEpochStatus, + | FulfillEpochStatus + | AttestStatus, ): Record | undefined => { switch (status.status) { case "idle": diff --git a/app/src/config/clients.ts b/app/src/config/clients.ts index 09d70a0..7ac03de 100644 --- a/app/src/config/clients.ts +++ b/app/src/config/clients.ts @@ -23,32 +23,39 @@ export const asyncVault = (): Promise => { return vault } -interface VaultWriterEntry { +interface WriterEntry { signer: Signer - client: Promise + client: Promise } -let vaultWriter: VaultWriterEntry | undefined - const sameSigner = (a: Signer, b: Signer) => a.publicKey === b.publicKey && a.signTransaction === b.signTransaction -export const asyncVaultWriter = (signer: Signer): Promise => { - if (!vaultWriter || !sameSigner(vaultWriter.signer, signer)) { - const entry: VaultWriterEntry = { - signer, - client: connectAsyncVault(addresses.async_vault, signer).catch( - (error: unknown) => { - if (vaultWriter === entry) vaultWriter = undefined +const writer = (connect: (signer: Signer) => Promise) => { + let current: WriterEntry | undefined + return (signer: Signer): Promise => { + if (!current || !sameSigner(current.signer, signer)) { + const entry: WriterEntry = { + signer, + client: connect(signer).catch((error: unknown) => { + if (current === entry) current = undefined throw error - }, - ), + }), + } + current = entry } - vaultWriter = entry + return current.client } - return vaultWriter.client } +export const asyncVaultWriter = writer((signer) => + connectAsyncVault(addresses.async_vault, signer), +) + +export const navOracleWriter = writer((signer) => + connectNavOracle(addresses.nav_oracle, signer), +) + let oracle: Promise | undefined export const navOracle = (): Promise => { diff --git a/app/src/hooks/useAttest.test.ts b/app/src/hooks/useAttest.test.ts new file mode 100644 index 0000000..4604419 --- /dev/null +++ b/app/src/hooks/useAttest.test.ts @@ -0,0 +1,173 @@ +import { AssembledTransaction } from "@stellar/stellar-sdk/contract" +import { type Price } from "@stellar-scaffold/app-lib" +import { act, waitFor } from "@testing-library/react" +import { beforeEach, describe, expect, it, vi } from "vitest" +import { deferred, investorAddress, renderWithWallet } from "./testSupport" +import { attestWriteKeys, useAttest } from "./useAttest" +import { investorRequestsKey } from "./useInvestorRequests" + +const { oracleMock, navOracleWriterMock } = vi.hoisted(() => ({ + oracleMock: { attest: vi.fn() }, + navOracleWriterMock: vi.fn(), +})) + +vi.mock("../config/clients", () => ({ + navOracleWriter: navOracleWriterMock, +})) + +const attestation = { + price: 1_040_000_000_000_000_000n as Price, + expiresAt: 1_700_186_400n, +} + +const renderAttest = () => renderWithWallet(useAttest) + +const successful = () => ({ + simulation: undefined, + signAndSend: vi.fn().mockResolvedValue({ + getTransactionResponse: { status: "SUCCESS" }, + result: undefined, + }), +}) + +describe("useAttest", () => { + beforeEach(() => { + vi.clearAllMocks() + navOracleWriterMock.mockResolvedValue(oracleMock) + }) + + it("moves through awaiting signature, submitted, then confirmed, in order", 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: undefined, + } + }, + ) + oracleMock.attest.mockResolvedValue({ simulation: undefined, signAndSend }) + const { result } = renderAttest() + + expect(result.current.submit(attestation)).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(oracleMock.attest).toHaveBeenCalledWith({ + report: { + nav_per_share: attestation.price, + expires_at: attestation.expiresAt, + timestamp: 0n, + }, + caller: investorAddress, + }) + }) + + it("refuses before ever asking for a signature when the simulation carries the oracle's reason", async () => { + const signAndSend = vi.fn() + oracleMock.attest.mockResolvedValue({ + simulation: { error: "HostError: Error(Contract, #3004)" }, + signAndSend, + }) + const { result } = renderAttest() + + await act(() => result.current.submit(attestation)) + + expect(result.current.status).toEqual({ + status: "failed", + failure: { kind: "contract-error", code: 3004 }, + }) + expect(signAndSend).not.toHaveBeenCalled() + }) + + it("tells a declined signature apart from a failure", async () => { + oracleMock.attest.mockResolvedValue({ + simulation: undefined, + signAndSend: vi + .fn() + .mockRejectedValue( + new AssembledTransaction.Errors.UserRejected("User declined access"), + ), + }) + const { result } = renderAttest() + + await act(() => result.current.submit(attestation)) + + 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: undefined, + } + }) + oracleMock.attest.mockResolvedValue({ simulation: undefined, signAndSend }) + const { result } = renderAttest() + + void result.current.submit(attestation) + await waitFor(() => + expect(result.current.status).toEqual({ status: "awaiting-signature" }), + ) + + void result.current.submit(attestation) + + gate.resolve() + await waitFor(() => expect(result.current.status.status).toBe("confirmed")) + + expect(oracleMock.attest).toHaveBeenCalledTimes(1) + }) + + it("refreshes the cycle, the latest price and the figures once the attestation confirms", async () => { + oracleMock.attest.mockResolvedValue(successful()) + const { result, queryClient } = renderAttest() + const invalidateQueries = vi.spyOn(queryClient, "invalidateQueries") + + await act(() => result.current.submit(attestation)) + + for (const queryKey of attestWriteKeys) { + expect(invalidateQueries).toHaveBeenCalledWith({ queryKey }) + } + expect(invalidateQueries).toHaveBeenCalledWith({ + queryKey: ["nav", "latest"], + }) + expect(invalidateQueries).toHaveBeenCalledWith({ + queryKey: investorRequestsKey(investorAddress), + }) + }) + + it("does not touch the cached cycle when the oracle refuses at simulation", async () => { + oracleMock.attest.mockResolvedValue({ + simulation: { error: "HostError: Error(Contract, #3002)" }, + signAndSend: vi.fn(), + }) + const { result, queryClient } = renderAttest() + const invalidateQueries = vi.spyOn(queryClient, "invalidateQueries") + + await act(() => result.current.submit(attestation)) + + expect(invalidateQueries).not.toHaveBeenCalled() + }) +}) diff --git a/app/src/hooks/useAttest.ts b/app/src/hooks/useAttest.ts new file mode 100644 index 0000000..47ade74 --- /dev/null +++ b/app/src/hooks/useAttest.ts @@ -0,0 +1,43 @@ +import { type Price, type Signer } from "@stellar-scaffold/app-lib" +import { useCallback } from "react" +import { navOracleWriter } from "../config/clients" +import { + useContractTransaction, + type TransactionStatus, +} from "./useContractTransaction" +import { cycleWriteKeys } from "./useCycleState" + +export type AttestStatus = TransactionStatus + +export type Attestation = { price: Price; expiresAt: bigint } + +export const attestWriteKeys = [...cycleWriteKeys, ["nav", "latest"]] as const + +export interface UseAttest { + status: AttestStatus + submit: (attestation: Attestation) => boolean + reset: () => void +} + +export function useAttest(): UseAttest { + const call = useCallback( + (signer: Signer, { price, expiresAt }: Attestation) => + navOracleWriter(signer).then((oracle) => + oracle.attest({ + report: { + nav_per_share: price, + expires_at: expiresAt, + timestamp: 0n, + }, + caller: signer.publicKey, + }), + ), + [], + ) + const toConfirmed = useCallback(() => ({}), []) + return useContractTransaction>( + call, + toConfirmed, + attestWriteKeys, + ) +} diff --git a/app/src/pages/cycle.test.ts b/app/src/pages/cycle.test.ts index 9205156..9f6cd37 100644 --- a/app/src/pages/cycle.test.ts +++ b/app/src/pages/cycle.test.ts @@ -21,8 +21,11 @@ import { type EpochRecord, } from "../hooks/useEpochHistory" import { + type AttestAction, type CycleAction, + type EpochAction, formatDuration, + formatTimestamp, toActivityList, toCycleActions, toCycleRows, @@ -608,8 +611,14 @@ const actionsFor = (state: CycleState, grants: Grant[]) => const manager = grant("vault manager") const treasury = grant("vault treasury") -const actionOf = (actions: CycleAction[], id: CycleAction["id"]) => - actions.find((action) => action.id === id) +type ActionOf = Id extends AttestAction["id"] + ? AttestAction + : EpochAction + +const actionOf = ( + actions: CycleAction[], + id: Id, +) => actions.find((action): action is ActionOf => action.id === id) const conditionsOf = (action: CycleAction | undefined) => Object.fromEntries( @@ -864,3 +873,218 @@ describe("toCycleActions", () => { expect(fulfill?.outcome).toBeNull() }) }) + +const attester = grant("oracle attester") + +const attestOf = ( + input: string, + overrides: Partial = {}, + grants: Grant[] = [attester], +) => actionOf(toCycleActions(ready(overrides), grants, WALLET, input), "attest") + +const withLimits = (limits: Partial>) => + ({ ...oracle, limits: { ...oracle.limits!, ...limits } }) as CycleOracle + +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"]) + expect(attestOf("")?.group).toBe("Price") + expect(attestOf("", {}, [manager, treasury])).toBeUndefined() + }) + + it("asks for the share price in a decimal field with a screen-reader label", () => { + const attest = attestOf("1.04") + + expect(attest?.label).toBe("Attest price") + expect(attest?.field).toEqual({ + label: "Share price", + placeholder: "0.0000", + value: "1.04", + invalid: false, + }) + }) + + it("leaves the price-dependent conditions open and the button disabled while nothing is typed", () => { + const attest = attestOf("") + + expect(conditionsOf(attest)).toEqual({ + "Within the band": null, + "Cooldown elapsed": true, + "Within the allowed move": null, + }) + expect(attest?.outcome).toBeNull() + expect(attest?.price).toBeNull() + expect(attest?.enabled).toBe(false) + expect(attest?.field?.invalid).toBe(false) + }) + + it.each(["abc", "1.2.3", "1.0000000000000000001"])( + "marks %j as invalid and keeps the conditions open", + (input) => { + const attest = attestOf(input) + + expect(attest?.field?.invalid).toBe(true) + expect(conditionsOf(attest)["Within the band"]).toBeNull() + expect(attest?.enabled).toBe(false) + }, + ) + + it("enables the attestation when every condition is met, saying what it records and until when it is valid", () => { + const attest = attestOf("1.04") + + expect(conditionsOf(attest)).toEqual({ + "Within the band": true, + "Cooldown elapsed": true, + "Within the allowed move": true, + }) + expect(attest?.price).toBe(1_040_000_000_000_000_000n) + expect(attest?.expiresAt).toBe(NOW + 86_400n) + expect(attest?.outcome).toBe( + `Records 1.0400 as the share price, valid until ${formatTimestamp(NOW + 86_400n)}.`, + ) + expect(attest?.enabled).toBe(true) + expect(attest?.unavailable).toBeUndefined() + expect(attest?.note).toBeUndefined() + }) + + it.each(["0.5", "2"])( + "accepts a price on the edge of the band, %s", + (input) => { + const attest = attestOf(input, { + oracle: { ...oracle, state: "never", price: null, attestedAt: null }, + }) + + expect(conditionsOf(attest)["Within the band"]).toBe(true) + }, + ) + + it.each(["0.4999", "2.0001", "0", "-1"])( + "shows the band when %s falls outside it", + (input) => { + const attest = attestOf(input) + const band = attest?.conditions.find( + (condition) => condition.label === "Within the band", + ) + + expect(band).toEqual({ + label: "Within the band", + met: false, + detail: "0.5000 – 2.0000", + }) + expect(attest?.enabled).toBe(false) + }, + ) + + it("treats the cooldown as elapsed the second it is due", () => { + const due = oracle.attestedAt! + oracle.limits!.cooldown + + expect( + conditionsOf(attestOf("1.04", { ledgerTime: due }))["Cooldown elapsed"], + ).toBe(true) + expect( + conditionsOf(attestOf("1.04", { ledgerTime: due - 1n }))[ + "Cooldown elapsed" + ], + ).toBe(false) + expect( + conditionsOf(attestOf("1.04", { ledgerTime: null }))["Cooldown elapsed"], + ).toBe(false) + }) + + it("caps a rise at the largest allowed, letting an equal move through", () => { + expect(conditionsOf(attestOf("1.05"))["Within the allowed move"]).toBe(true) + expect( + conditionsOf(attestOf("1.050000000000000001"))["Within the allowed move"], + ).toBe(false) + }) + + it("lets any fall through when the oracle sets no largest fall", () => { + expect(conditionsOf(attestOf("0.5"))["Within the allowed move"]).toBe(true) + }) + + it("caps a fall at the largest allowed when the oracle sets one", () => { + const limited = { oracle: withLimits({ maxDownBps: 1_000 }) } + + expect( + conditionsOf(attestOf("0.9", limited))["Within the allowed move"], + ).toBe(true) + expect( + conditionsOf(attestOf("0.899999999999999999", limited))[ + "Within the allowed move" + ], + ).toBe(false) + }) + + it("shows the ceiling when a rise exceeds the cap and no fall cap exists", () => { + const move = attestOf("1.06")?.conditions.find( + (condition) => condition.label === "Within the allowed move", + ) + + expect(move).toEqual({ + label: "Within the allowed move", + met: false, + detail: "≤ 1.0500", + }) + }) + + it("shows the allowed range when both caps exist and the move exceeds one", () => { + const limited = { oracle: withLimits({ maxDownBps: 1_000 }) } + const move = attestOf("0.8", limited)?.conditions.find( + (condition) => condition.label === "Within the allowed move", + ) + + expect(move).toEqual({ + label: "Within the allowed move", + met: false, + detail: "0.9000 – 1.0500", + }) + }) + + it("skips the cooldown and the move cap for the first attestation", () => { + const attest = attestOf("1.9", { + oracle: { ...oracle, state: "never", price: null, attestedAt: null }, + }) + + expect(conditionsOf(attest)).toEqual({ + "Within the band": true, + "Cooldown elapsed": true, + "Within the allowed move": true, + }) + expect(attest?.enabled).toBe(true) + }) + + it("cannot vouch for the band or the move when the oracle's limits could not be read", () => { + const attest = attestOf("1.04", { + oracle: { ...oracle, limits: null }, + }) + + expect(conditionsOf(attest)["Within the band"]).toBe(false) + expect(conditionsOf(attest)["Within the allowed move"]).toBe(false) + expect(attest?.outcome).toBeNull() + expect(attest?.expiresAt).toBeNull() + expect(attest?.enabled).toBe(false) + }) + + it("notes a raised ripcord without making it a condition", () => { + const attest = attestOf("1.04", { + oracle: { ...oracle, state: "paused", ripcord: true }, + }) + + expect(attest?.note).toBe("Ripcord raised — attesting does not lift it.") + expect(Object.keys(conditionsOf(attest))).toHaveLength(3) + expect(attest?.enabled).toBe(true) + }) + + it("makes the attestation unavailable when the attester's authority needs more signatures than the wallet's", () => { + const attest = attestOf("1.04", {}, [ + grant("oracle attester", { needs: 2 }), + ]) + + expect(attest?.unavailable).toBe( + "Needs 2 signatures; collecting them is not supported yet.", + ) + expect(attest?.enabled).toBe(false) + }) +}) diff --git a/app/src/pages/cycle.ts b/app/src/pages/cycle.ts index 9c8b389..14a5c92 100644 --- a/app/src/pages/cycle.ts +++ b/app/src/pages/cycle.ts @@ -4,6 +4,7 @@ import { type ChainEvent, formatDayMonth, formatScaled, + parseUnits, PRICE_DECIMALS, type Price, shortAddress, @@ -326,21 +327,46 @@ function windDownGroup(windDown: CycleWindDown): CycleGroup { } } -export type CycleActionId = "close-epoch" | "fulfill-epoch" +export type CycleActionId = "close-epoch" | "fulfill-epoch" | "attest" -export type Condition = { label: string; met: boolean } +export type ActionGroup = "Epoch" | "Price" -export type CycleAction = { - id: CycleActionId +export type Condition = { label: string; met: boolean | null; detail?: string } + +export type ActionField = { + label: string + placeholder: string + value: string + invalid: boolean +} + +type ActionBase = { + group: ActionGroup label: string conditions: Condition[] outcome: string | null - epochId: bigint | null + note?: string + field?: ActionField enabled: boolean unavailable?: string } +export type EpochAction = ActionBase & { + id: "close-epoch" | "fulfill-epoch" + epochId: bigint | null +} + +export type AttestAction = ActionBase & { + id: "attest" + price: Price | null + expiresAt: bigint | null +} + +export type CycleAction = EpochAction | AttestAction + const MANAGER_ROLE = "vault manager" +const ATTESTER_ROLE = "oracle attester" +const BPS_DENOMINATOR = 10_000n const signaturesNeeded = (standing: Standing): number | null => { if (standing === "signs-alone") return null @@ -373,23 +399,24 @@ const windDownInactive = (state: ReadyState): Condition => ({ met: state.windDown.phase !== null && state.windDown.phase !== "active", }) -const toAction = ( - id: CycleActionId, +const allMet = (conditions: Condition[]): boolean => + conditions.every((condition) => condition.met === true) + +const toEpochAction = ( + id: EpochAction["id"], label: string, conditions: Condition[], outcome: string | null, epochId: bigint | null, unavailable?: string, -): CycleAction => ({ +): EpochAction => ({ id, + group: "Epoch", label, conditions, outcome, epochId, - enabled: - unavailable === undefined && - epochId !== null && - conditions.every((condition) => condition.met), + enabled: unavailable === undefined && epochId !== null && allMet(conditions), ...(unavailable === undefined ? {} : { unavailable }), }) @@ -397,9 +424,9 @@ function toCloseAction( state: ReadyState, grant: Grant, wallet: string, -): CycleAction { +): EpochAction { const { epoch } = state - return toAction( + return toEpochAction( "close-epoch", "Close epoch", [ @@ -412,11 +439,11 @@ function toCloseAction( ) } -function toFulfillAction(state: ReadyState): CycleAction { +function toFulfillAction(state: ReadyState): EpochAction { const { awaiting } = state.epoch const { oracle, ledgerTime } = state const price = formatPriceValue(oracle.price) - return toAction( + return toEpochAction( "fulfill-epoch", "Fulfill epoch", [ @@ -446,16 +473,120 @@ function toFulfillAction(state: ReadyState): CycleAction { ) } +const withinBand = (oracle: CycleOracle, price: Price | null): Condition => { + const { limits } = oracle + if (price === null) return { label: "Within the band", met: null } + const met = limits !== null && limits.min <= price && price <= limits.max + return met || limits === null + ? { label: "Within the band", met } + : { + label: "Within the band", + met, + detail: `${formatPriceValue(limits.min)} – ${formatPriceValue(limits.max)}`, + } +} + +const cooldownElapsed = ( + oracle: CycleOracle, + ledgerTime: bigint | null, +): Condition => ({ + label: "Cooldown elapsed", + met: + oracle.state === "never" || + (ledgerTime !== null && + oracle.attestedAt !== null && + oracle.limits !== null && + ledgerTime >= oracle.attestedAt + oracle.limits.cooldown), +}) + +const capped = (previous: bigint, bps: number): bigint => + (previous * BigInt(bps)) / BPS_DENOMINATOR + +const withinAllowedMove = ( + oracle: CycleOracle, + price: Price | null, +): Condition => { + const label = "Within the allowed move" + const { limits, price: previous } = oracle + if (price === null) return { label, met: null } + if (oracle.state === "never") return { label, met: true } + if (limits === null || previous === null) return { label, met: false } + const ceiling = previous + capped(previous, limits.maxUpBps) + const floor = + limits.maxDownBps === null + ? null + : previous - capped(previous, limits.maxDownBps) + const met = price <= ceiling && (floor === null || price >= floor) + if (met) return { label, met } + const top = formatPriceValue(ceiling) + return { + label, + met, + detail: floor === null ? `≤ ${top}` : `${formatPriceValue(floor)} – ${top}`, + } +} + +function toAttestAction( + state: ReadyState, + grant: Grant, + wallet: string, + input: string, +): AttestAction { + const { oracle, ledgerTime } = state + const price = parseUnits(input, PRICE_DECIMALS) + const expiresAt = + ledgerTime === null || oracle.limits === null + ? null + : ledgerTime + oracle.limits.freshness + const conditions = [ + withinBand(oracle, price), + cooldownElapsed(oracle, ledgerTime), + withinAllowedMove(oracle, price), + ] + const unavailable = unavailableFor(grant, wallet) + return { + id: "attest", + group: "Price", + label: "Attest price", + conditions, + outcome: + price === null || expiresAt === null + ? null + : `Records ${formatPriceValue(price)} as the share price, valid until ${formatTimestamp(expiresAt)}.`, + ...(oracle.ripcord === true + ? { note: "Ripcord raised — attesting does not lift it." } + : {}), + field: { + label: "Share price", + placeholder: "0.0000", + value: input, + invalid: input.trim() !== "" && price === null, + }, + price, + expiresAt, + enabled: + unavailable === undefined && + price !== null && + expiresAt !== null && + allMet(conditions), + ...(unavailable === undefined ? {} : { unavailable }), + } +} + export function toCycleActions( state: CycleState, grants: Grant[], wallet: string, + priceInput = "", ): CycleAction[] { if (state.status !== "ready" || grants.length === 0) return [] const manager = grantFor(grants, MANAGER_ROLE, wallet) + const attester = grantFor(grants, ATTESTER_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)) return actions } From 26797402871d601550bba0f9f77b408097958a26 Mon Sep 17 00:00:00 2001 From: luchobonatti Date: Fri, 9 Oct 2026 10:13:43 -0300 Subject: [PATCH 2/3] fix(app): clear the attested price and set its expiry at signing --- .../components/admin/CycleSection.test.tsx | 41 +++++++++++++++++-- app/src/components/admin/CycleSection.tsx | 11 +++-- .../components/admin/CycleSurface.test.tsx | 2 +- app/src/hooks/useAttest.test.ts | 8 ++-- app/src/hooks/useAttest.ts | 6 +-- app/src/pages/cycle.test.ts | 9 ++-- app/src/pages/cycle.ts | 15 +++---- 7 files changed, 65 insertions(+), 27 deletions(-) diff --git a/app/src/components/admin/CycleSection.test.tsx b/app/src/components/admin/CycleSection.test.tsx index dc5355e..5b38233 100644 --- a/app/src/components/admin/CycleSection.test.tsx +++ b/app/src/components/admin/CycleSection.test.tsx @@ -206,11 +206,11 @@ describe("CycleSection", () => { expect(button("Attest price").disabled).toBe(false) expect(within(band).getByText("Met")).toBeTruthy() expect( - screen.getByText(/^Records 1\.0400 as the share price, valid until /), + screen.getByText("Records 1.0400 as the share price, valid for 1d."), ).toBeTruthy() }) - it("submits the typed price with its expiry and follows it in the modal, labelled with the price", () => { + it("submits the typed price with the freshness window and follows it in the modal, labelled with the price", () => { const { rerender } = render( , ) @@ -222,7 +222,7 @@ describe("CycleSection", () => { expect(attestMock.submit).toHaveBeenCalledWith({ price: 1_040_000_000_000_000_000n, - expiresAt: 1_700_100_000n + 86_400n, + freshness: 86_400n, }) attestMock.status = { status: "preparing" } @@ -233,6 +233,41 @@ describe("CycleSection", () => { ).toBeTruthy() }) + it("clears the typed price once the attestation confirms", () => { + const { rerender } = render( + , + ) + const field = () => + screen.getByRole("textbox", { name: "Share price" }) as HTMLInputElement + + fireEvent.change(field(), { target: { value: "1.04" } }) + fireEvent.click(button("Attest price")) + attestMock.status = { status: "awaiting-signature" } + rerender() + expect(field().value).toBe("1.04") + + attestMock.status = { status: "confirmed" } + rerender() + + expect(field().value).toBe("") + expect(button("Attest price").disabled).toBe(true) + }) + + it("keeps the typed price when the attestation is declined or refused, so it can be retried", () => { + const { rerender } = render( + , + ) + const field = () => + screen.getByRole("textbox", { name: "Share price" }) as HTMLInputElement + + fireEvent.change(field(), { target: { value: "1.04" } }) + fireEvent.click(button("Attest price")) + attestMock.status = { status: "failed", failure: { kind: "declined" } } + rerender() + + expect(field().value).toBe("1.04") + }) + it("tells the attester how long the attested price stays valid once confirmed", () => { attestMock.status = { status: "confirmed" } render() diff --git a/app/src/components/admin/CycleSection.tsx b/app/src/components/admin/CycleSection.tsx index f7c7147..d5604cd 100644 --- a/app/src/components/admin/CycleSection.tsx +++ b/app/src/components/admin/CycleSection.tsx @@ -1,5 +1,5 @@ import { formatScaled, PRICE_DECIMALS } from "@stellar-scaffold/app-lib" -import React, { useState } from "react" +import React, { useEffect, useState } from "react" import { type Grant } from "../../hooks/useAdminAuthority" import { useAttest } from "../../hooks/useAttest" import { useCloseEpoch } from "../../hooks/useCloseEpoch" @@ -31,6 +31,11 @@ const CycleSection: React.FC<{ grants: Grant[]; wallet: string }> = ({ const fulfillEpoch = usePendingTransaction(useFulfillEpoch()) const attest = usePendingTransaction(useAttest()) const [priceInput, setPriceInput] = useState("") + const attested = attest.status.status === "confirmed" + + useEffect(() => { + if (attested) setPriceInput("") + }, [attested]) const run = (action: CycleAction) => { switch (action.id) { @@ -43,9 +48,9 @@ const CycleSection: React.FC<{ grants: Grant[]; wallet: string }> = ({ fulfillEpoch.submit(action.epochId, String(action.epochId)) return case "attest": - if (action.price !== null && action.expiresAt !== null) + if (action.price !== null && action.freshness !== null) attest.submit( - { price: action.price, expiresAt: action.expiresAt }, + { price: action.price, freshness: action.freshness }, formatScaled(action.price, PRICE_DECIMALS, 4), ) } diff --git a/app/src/components/admin/CycleSurface.test.tsx b/app/src/components/admin/CycleSurface.test.tsx index f637eb7..0c44a08 100644 --- a/app/src/components/admin/CycleSurface.test.tsx +++ b/app/src/components/admin/CycleSurface.test.tsx @@ -323,7 +323,7 @@ const attestAction: CycleAction = { invalid: false, }, price: null, - expiresAt: null, + freshness: null, enabled: false, } diff --git a/app/src/hooks/useAttest.test.ts b/app/src/hooks/useAttest.test.ts index 4604419..3279477 100644 --- a/app/src/hooks/useAttest.test.ts +++ b/app/src/hooks/useAttest.test.ts @@ -17,8 +17,9 @@ vi.mock("../config/clients", () => ({ const attestation = { price: 1_040_000_000_000_000_000n as Price, - expiresAt: 1_700_186_400n, + freshness: 86_400n, } +const wallClock = 1_700_200_000 const renderAttest = () => renderWithWallet(useAttest) @@ -33,10 +34,11 @@ const successful = () => ({ describe("useAttest", () => { beforeEach(() => { vi.clearAllMocks() + vi.spyOn(Date, "now").mockReturnValue(wallClock * 1000 + 999) navOracleWriterMock.mockResolvedValue(oracleMock) }) - it("moves through awaiting signature, submitted, then confirmed, in order", async () => { + it("moves through awaiting signature, submitted, then confirmed, in order, expiring the report a freshness window after the wall clock", async () => { const signingGate = deferred() const confirmGate = deferred() const signAndSend = vi.fn( @@ -72,7 +74,7 @@ describe("useAttest", () => { expect(oracleMock.attest).toHaveBeenCalledWith({ report: { nav_per_share: attestation.price, - expires_at: attestation.expiresAt, + expires_at: BigInt(wallClock) + attestation.freshness, timestamp: 0n, }, caller: investorAddress, diff --git a/app/src/hooks/useAttest.ts b/app/src/hooks/useAttest.ts index 47ade74..a691699 100644 --- a/app/src/hooks/useAttest.ts +++ b/app/src/hooks/useAttest.ts @@ -9,7 +9,7 @@ import { cycleWriteKeys } from "./useCycleState" export type AttestStatus = TransactionStatus -export type Attestation = { price: Price; expiresAt: bigint } +export type Attestation = { price: Price; freshness: bigint } export const attestWriteKeys = [...cycleWriteKeys, ["nav", "latest"]] as const @@ -21,12 +21,12 @@ export interface UseAttest { export function useAttest(): UseAttest { const call = useCallback( - (signer: Signer, { price, expiresAt }: Attestation) => + (signer: Signer, { price, freshness }: Attestation) => navOracleWriter(signer).then((oracle) => oracle.attest({ report: { nav_per_share: price, - expires_at: expiresAt, + expires_at: BigInt(Math.floor(Date.now() / 1000)) + freshness, timestamp: 0n, }, caller: signer.publicKey, diff --git a/app/src/pages/cycle.test.ts b/app/src/pages/cycle.test.ts index 9f6cd37..8638303 100644 --- a/app/src/pages/cycle.test.ts +++ b/app/src/pages/cycle.test.ts @@ -25,7 +25,6 @@ import { type CycleAction, type EpochAction, formatDuration, - formatTimestamp, toActivityList, toCycleActions, toCycleRows, @@ -931,7 +930,7 @@ describe("toCycleActions, attesting", () => { }, ) - it("enables the attestation when every condition is met, saying what it records and until when it is valid", () => { + it("enables the attestation when every condition is met, saying what it records and for how long", () => { const attest = attestOf("1.04") expect(conditionsOf(attest)).toEqual({ @@ -940,9 +939,9 @@ describe("toCycleActions, attesting", () => { "Within the allowed move": true, }) expect(attest?.price).toBe(1_040_000_000_000_000_000n) - expect(attest?.expiresAt).toBe(NOW + 86_400n) + expect(attest?.freshness).toBe(86_400n) expect(attest?.outcome).toBe( - `Records 1.0400 as the share price, valid until ${formatTimestamp(NOW + 86_400n)}.`, + "Records 1.0400 as the share price, valid for 1d.", ) expect(attest?.enabled).toBe(true) expect(attest?.unavailable).toBeUndefined() @@ -1063,7 +1062,7 @@ describe("toCycleActions, attesting", () => { expect(conditionsOf(attest)["Within the band"]).toBe(false) expect(conditionsOf(attest)["Within the allowed move"]).toBe(false) expect(attest?.outcome).toBeNull() - expect(attest?.expiresAt).toBeNull() + expect(attest?.freshness).toBeNull() expect(attest?.enabled).toBe(false) }) diff --git a/app/src/pages/cycle.ts b/app/src/pages/cycle.ts index 14a5c92..eee2d7d 100644 --- a/app/src/pages/cycle.ts +++ b/app/src/pages/cycle.ts @@ -359,7 +359,7 @@ export type EpochAction = ActionBase & { export type AttestAction = ActionBase & { id: "attest" price: Price | null - expiresAt: bigint | null + freshness: bigint | null } export type CycleAction = EpochAction | AttestAction @@ -534,10 +534,7 @@ function toAttestAction( ): AttestAction { const { oracle, ledgerTime } = state const price = parseUnits(input, PRICE_DECIMALS) - const expiresAt = - ledgerTime === null || oracle.limits === null - ? null - : ledgerTime + oracle.limits.freshness + const freshness = oracle.limits?.freshness ?? null const conditions = [ withinBand(oracle, price), cooldownElapsed(oracle, ledgerTime), @@ -550,9 +547,9 @@ function toAttestAction( label: "Attest price", conditions, outcome: - price === null || expiresAt === null + price === null || freshness === null ? null - : `Records ${formatPriceValue(price)} as the share price, valid until ${formatTimestamp(expiresAt)}.`, + : `Records ${formatPriceValue(price)} as the share price, valid for ${formatDuration(freshness)}.`, ...(oracle.ripcord === true ? { note: "Ripcord raised — attesting does not lift it." } : {}), @@ -563,11 +560,11 @@ function toAttestAction( invalid: input.trim() !== "" && price === null, }, price, - expiresAt, + freshness, enabled: unavailable === undefined && price !== null && - expiresAt !== null && + freshness !== null && allMet(conditions), ...(unavailable === undefined ? {} : { unavailable }), } From cce191a5b76e3cdf69dc911b7a3836908b6699c7 Mon Sep 17 00:00:00 2001 From: luchobonatti Date: Fri, 9 Oct 2026 12:58:37 -0300 Subject: [PATCH 3/3] fix(app): sign the exact price and skip caps without a record --- app-lib/format.test.ts | 13 ++++ app-lib/format.ts | 6 +- .../components/admin/CycleSection.test.tsx | 18 ++++++ app/src/components/admin/CycleSection.tsx | 4 +- app/src/hooks/useCycleState.test.ts | 18 ++++++ app/src/hooks/useCycleState.ts | 11 ++++ app/src/pages/cycle.test.ts | 63 ++++++++++++++++++- app/src/pages/cycle.ts | 10 ++- 8 files changed, 134 insertions(+), 9 deletions(-) diff --git a/app-lib/format.test.ts b/app-lib/format.test.ts index 84dfe6b..95a0b22 100644 --- a/app-lib/format.test.ts +++ b/app-lib/format.test.ts @@ -12,6 +12,7 @@ import { networkStatus, parseAmount, parseUnits, + PRICE_DECIMALS, type Price, shortAddress, } from "./format" @@ -130,6 +131,18 @@ describe("formatExact", () => { }) }) +describe("formatExact with a minimum fraction width", () => { + it("pads a short price to the minimum", () => { + const value = 11n * 10n ** 17n + expect(formatExact(value as Price, PRICE_DECIMALS, 4)).toBe("1.1000") + }) + + it("keeps every significant digit beyond the minimum", () => { + const value = 1_041_234_560_000_000_000n + expect(formatExact(value as Price, PRICE_DECIMALS, 4)).toBe("1.04123456") + }) +}) + describe("formatScaled", () => { it("groups the integer part with thousands separators", () => { const value = 184000000000n as Amount diff --git a/app-lib/format.ts b/app-lib/format.ts index 396b822..8631540 100644 --- a/app-lib/format.ts +++ b/app-lib/format.ts @@ -146,6 +146,7 @@ export function formatScaled( export function formatExact( value: Scaled, decimals: NoInfer, + minFractionDigits = 2, ): string { const [integer = "0", fraction = ""] = formatUnits( value, @@ -157,7 +158,10 @@ export function formatExact( const grouped = BigInt(magnitude).toLocaleString("en-US") const sign = negative ? "-" : "" const trimmed = fraction.replace(/0+$/, "") - const minFraction = trimmed.length < 2 ? fraction.slice(0, 2) : trimmed + const minFraction = + trimmed.length < minFractionDigits + ? fraction.slice(0, minFractionDigits) + : trimmed return `${sign}${grouped}.${minFraction}` } diff --git a/app/src/components/admin/CycleSection.test.tsx b/app/src/components/admin/CycleSection.test.tsx index 5b38233..cc3f638 100644 --- a/app/src/components/admin/CycleSection.test.tsx +++ b/app/src/components/admin/CycleSection.test.tsx @@ -95,6 +95,7 @@ const ready = (overrides: Partial = {}): CycleState => ({ attestedAt: 1_700_050_000n, expiresAt: 1_700_136_000n, ripcord: false, + recorded: true, limits: { freshness: 86_400n, cooldown: 3_600n, @@ -233,6 +234,23 @@ describe("CycleSection", () => { ).toBeTruthy() }) + it("labels the modal with the exact price that is signed", () => { + const { rerender } = render( + , + ) + + fireEvent.change(screen.getByRole("textbox", { name: "Share price" }), { + target: { value: "1.04123456" }, + }) + fireEvent.click(button("Attest price")) + attestMock.status = { status: "preparing" } + rerender() + + expect( + screen.getByRole("heading", { name: "Preparing to attest 1.04123456" }), + ).toBeTruthy() + }) + it("clears the typed price once the attestation confirms", () => { const { rerender } = render( , diff --git a/app/src/components/admin/CycleSection.tsx b/app/src/components/admin/CycleSection.tsx index d5604cd..ba10e0e 100644 --- a/app/src/components/admin/CycleSection.tsx +++ b/app/src/components/admin/CycleSection.tsx @@ -1,4 +1,3 @@ -import { formatScaled, PRICE_DECIMALS } from "@stellar-scaffold/app-lib" import React, { useEffect, useState } from "react" import { type Grant } from "../../hooks/useAdminAuthority" import { useAttest } from "../../hooks/useAttest" @@ -10,6 +9,7 @@ import { usePendingTransaction } from "../../hooks/usePendingTransaction" import { type CycleAction, formatDuration, + formatSignedPrice, toActivityList, toCycleActions, toCycleRows, @@ -51,7 +51,7 @@ const CycleSection: React.FC<{ grants: Grant[]; wallet: string }> = ({ if (action.price !== null && action.freshness !== null) attest.submit( { price: action.price, freshness: action.freshness }, - formatScaled(action.price, PRICE_DECIMALS, 4), + formatSignedPrice(action.price), ) } } diff --git a/app/src/hooks/useCycleState.test.ts b/app/src/hooks/useCycleState.test.ts index 70e07da..84336de 100644 --- a/app/src/hooks/useCycleState.test.ts +++ b/app/src/hooks/useCycleState.test.ts @@ -93,6 +93,7 @@ describe("toOracle", () => { attestedAt: 1_700_050_000n, expiresAt: 1_700_136_000n, ripcord: false, + recorded: true, limits: { freshness: 86_400n, cooldown: 3_600n, @@ -132,6 +133,22 @@ describe("toOracle", () => { }) }) + it("knows there is no record even while the ripcord is raised", () => { + const oracle = toOracle( + state("Paused"), + { kind: "contract-error", code: 3006 }, + value(config), + ) + + expect(oracle).toMatchObject({ + state: "paused", + ripcord: true, + recorded: false, + price: null, + attestedAt: null, + }) + }) + it("reports nothing it could not read", () => { expect(toOracle(unreadable, unreadable, unreadable)).toEqual({ state: "unreadable", @@ -139,6 +156,7 @@ describe("toOracle", () => { attestedAt: null, expiresAt: null, ripcord: null, + recorded: null, limits: null, }) }) diff --git a/app/src/hooks/useCycleState.ts b/app/src/hooks/useCycleState.ts index aa20c19..832cd03 100644 --- a/app/src/hooks/useCycleState.ts +++ b/app/src/hooks/useCycleState.ts @@ -58,6 +58,7 @@ export type CycleOracle = { attestedAt: bigint | null expiresAt: bigint | null ripcord: boolean | null + recorded: boolean | null limits: OracleLimits | null } @@ -110,6 +111,15 @@ export type CycleState = const toAmount = (read: ContractRead): Amount | null => read.kind === "value" ? (read.value as Amount) : null +const NO_RECORD_ERROR = 3006 + +const recordedOf = (latest: ContractRead): boolean | null => { + if (latest.kind === "value") return true + return latest.kind === "contract-error" && latest.code === NO_RECORD_ERROR + ? false + : null +} + export function toOracle( state: ContractRead, latest: ContractRead, @@ -131,6 +141,7 @@ export function toOracle( attestedAt: report?.timestamp ?? null, expiresAt: report?.expires_at ?? null, ripcord: nav.status === "unreadable" ? null : nav.status === "paused", + recorded: recordedOf(latest), limits: config.kind === "value" ? { diff --git a/app/src/pages/cycle.test.ts b/app/src/pages/cycle.test.ts index 8638303..845458b 100644 --- a/app/src/pages/cycle.test.ts +++ b/app/src/pages/cycle.test.ts @@ -75,6 +75,7 @@ const oracle: CycleOracle = { attestedAt: 1_700_050_000n, expiresAt: 1_700_136_000n, ripcord: false, + recorded: true, limits: { freshness: 86_400n, cooldown: 3_600n, @@ -256,6 +257,7 @@ describe("toCycleRows", () => { oracle: { ...oracle, state: "never", + recorded: false, price: null, attestedAt: null, expiresAt: null, @@ -863,7 +865,9 @@ describe("toCycleActions", () => { it("keeps the outcome without a price figure when the oracle has none to show", () => { const fulfill = actionOf( actionsFor( - ready({ oracle: { ...oracle, state: "never", price: null } }), + ready({ + oracle: { ...oracle, state: "never", recorded: false, price: null }, + }), [treasury], ), "fulfill-epoch", @@ -952,7 +956,13 @@ describe("toCycleActions, attesting", () => { "accepts a price on the edge of the band, %s", (input) => { const attest = attestOf(input, { - oracle: { ...oracle, state: "never", price: null, attestedAt: null }, + oracle: { + ...oracle, + state: "never", + recorded: false, + price: null, + attestedAt: null, + }, }) expect(conditionsOf(attest)["Within the band"]).toBe(true) @@ -1043,7 +1053,34 @@ describe("toCycleActions, attesting", () => { it("skips the cooldown and the move cap for the first attestation", () => { const attest = attestOf("1.9", { - oracle: { ...oracle, state: "never", price: null, attestedAt: null }, + oracle: { + ...oracle, + state: "never", + recorded: false, + price: null, + attestedAt: null, + }, + }) + + expect(conditionsOf(attest)).toEqual({ + "Within the band": true, + "Cooldown elapsed": true, + "Within the allowed move": true, + }) + expect(attest?.enabled).toBe(true) + }) + + it("skips the cooldown and the move cap with the ripcord raised and no stored record", () => { + const attest = attestOf("1.9", { + ledgerTime: oracle.attestedAt! - 1n, + oracle: { + ...oracle, + state: "paused", + ripcord: true, + recorded: false, + price: null, + attestedAt: null, + }, }) expect(conditionsOf(attest)).toEqual({ @@ -1054,6 +1091,26 @@ describe("toCycleActions, attesting", () => { expect(attest?.enabled).toBe(true) }) + it("states the typed price exactly in the outcome, not rounded to four decimals", () => { + expect(attestOf("1.04123456")?.outcome).toBe( + "Records 1.04123456 as the share price, valid for 1d.", + ) + expect(attestOf("1.1")?.outcome).toBe( + "Records 1.1000 as the share price, valid for 1d.", + ) + }) + + it("makes the attestation unavailable when the attester's authority is an account the wallet only signs for", () => { + const attest = attestOf("1.04", {}, [ + grant("oracle attester", "signs-alone", "GOTHERACCOUNT9876543210"), + ]) + + expect(attest?.unavailable).toBe( + "Signs for GOTH...3210; acting on its behalf is not supported yet.", + ) + expect(attest?.enabled).toBe(false) + }) + it("cannot vouch for the band or the move when the oracle's limits could not be read", () => { const attest = attestOf("1.04", { oracle: { ...oracle, limits: null }, diff --git a/app/src/pages/cycle.ts b/app/src/pages/cycle.ts index eee2d7d..f6c6478 100644 --- a/app/src/pages/cycle.ts +++ b/app/src/pages/cycle.ts @@ -3,6 +3,7 @@ import { type Amount, type ChainEvent, formatDayMonth, + formatExact, formatScaled, parseUnits, PRICE_DECIMALS, @@ -132,6 +133,9 @@ const formatAmountValue = (raw: Amount | null): string | null => const formatPriceValue = (raw: bigint | null): string | null => raw === null ? null : formatScaled(raw as Price, PRICE_DECIMALS, 4) +export const formatSignedPrice = (raw: Price): string => + formatExact(raw, PRICE_DECIMALS, 4) + const formatPercent = (basisPoints: number): string => `${basisPoints / 100}%` const orNotApplicable = ( @@ -492,7 +496,7 @@ const cooldownElapsed = ( ): Condition => ({ label: "Cooldown elapsed", met: - oracle.state === "never" || + oracle.recorded === false || (ledgerTime !== null && oracle.attestedAt !== null && oracle.limits !== null && @@ -509,7 +513,7 @@ const withinAllowedMove = ( const label = "Within the allowed move" const { limits, price: previous } = oracle if (price === null) return { label, met: null } - if (oracle.state === "never") return { label, met: true } + if (oracle.recorded === false) return { label, met: true } if (limits === null || previous === null) return { label, met: false } const ceiling = previous + capped(previous, limits.maxUpBps) const floor = @@ -549,7 +553,7 @@ function toAttestAction( outcome: price === null || freshness === null ? null - : `Records ${formatPriceValue(price)} as the share price, valid for ${formatDuration(freshness)}.`, + : `Records ${formatSignedPrice(price)} as the share price, valid for ${formatDuration(freshness)}.`, ...(oracle.ripcord === true ? { note: "Ripcord raised — attesting does not lift it." } : {}),