Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 30 additions & 1 deletion app-lib/contracts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down Expand Up @@ -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)
})
})
7 changes: 5 additions & 2 deletions app-lib/contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,8 +57,11 @@ export const connectAsyncVault = (
export const isUserRejection = (error: unknown): boolean =>
error instanceof AssembledTransaction.Errors.UserRejected

export const connectNavOracle = (contractId: string): Promise<NavOracleApi> =>
Client.from<NavOracleApi>(clientOptions(contractId))
export const connectNavOracle = (
contractId: string,
signer?: Signer,
): Promise<NavOracleApi> =>
Client.from<NavOracleApi>(clientOptions(contractId, signer))

export const connectIdentityVerifier = (
contractId: string,
Expand Down
13 changes: 13 additions & 0 deletions app-lib/format.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
networkStatus,
parseAmount,
parseUnits,
PRICE_DECIMALS,
type Price,
shortAddress,
} from "./format"
Expand Down Expand Up @@ -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
Expand Down
6 changes: 5 additions & 1 deletion app-lib/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ export function formatScaled<D extends Decimals>(
export function formatExact<D extends Decimals>(
value: Scaled<D>,
decimals: NoInfer<D>,
minFractionDigits = 2,
): string {
const [integer = "0", fraction = ""] = formatUnits(
value,
Expand All @@ -157,7 +158,10 @@ export function formatExact<D extends Decimals>(
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}`
}

Expand Down
129 changes: 128 additions & 1 deletion app/src/components/admin/CycleSection.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -14,6 +15,7 @@ const {
useEpochHistoryMock,
closeEpochMock,
fulfillEpochMock,
attestMock,
} = vi.hoisted(() => ({
useCycleStateMock: vi.fn(),
useCycleEventsMock: vi.fn(),
Expand All @@ -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", () => ({
Expand All @@ -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
Expand Down Expand Up @@ -85,7 +95,15 @@ const ready = (overrides: Partial<ReadyState> = {}): CycleState => ({
attestedAt: 1_700_050_000n,
expiresAt: 1_700_136_000n,
ripcord: false,
limits: null,
recorded: true,
limits: {
freshness: 86_400n,
cooldown: 3_600n,
maxUpBps: 500,
maxDownBps: null,
min: price(1n) / 2n,
max: price(2n),
},
},
reserve: {
free: null,
Expand Down Expand Up @@ -119,6 +137,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
Expand All @@ -128,8 +147,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: [] },
Expand Down Expand Up @@ -164,9 +185,115 @@ describe("CycleSection", () => {
render(<CycleSection grants={[treasury]} wallet={WALLET} />)

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(<CycleSection grants={[attester]} wallet={WALLET} />)

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 for 1d."),
).toBeTruthy()
})

it("submits the typed price with the freshness window and follows it in the modal, labelled with the price", () => {
const { rerender } = render(
<CycleSection grants={[attester]} wallet={WALLET} />,
)

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,
freshness: 86_400n,
})

attestMock.status = { status: "preparing" }
rerender(<CycleSection grants={[attester]} wallet={WALLET} />)

expect(
screen.getByRole("heading", { name: "Preparing to attest 1.0400" }),
).toBeTruthy()
})

it("labels the modal with the exact price that is signed", () => {
const { rerender } = render(
<CycleSection grants={[attester]} wallet={WALLET} />,
)

fireEvent.change(screen.getByRole("textbox", { name: "Share price" }), {
target: { value: "1.04123456" },
})
fireEvent.click(button("Attest price"))
attestMock.status = { status: "preparing" }
rerender(<CycleSection grants={[attester]} wallet={WALLET} />)

expect(
screen.getByRole("heading", { name: "Preparing to attest 1.04123456" }),
).toBeTruthy()
})

it("clears the typed price once the attestation confirms", () => {
const { rerender } = render(
<CycleSection grants={[attester]} wallet={WALLET} />,
)
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(<CycleSection grants={[attester]} wallet={WALLET} />)
expect(field().value).toBe("1.04")

attestMock.status = { status: "confirmed" }
rerender(<CycleSection grants={[attester]} wallet={WALLET} />)

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(
<CycleSection grants={[attester]} wallet={WALLET} />,
)
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(<CycleSection grants={[attester]} wallet={WALLET} />)

expect(field().value).toBe("1.04")
})

it("tells the attester how long the attested price stays valid once confirmed", () => {
attestMock.status = { status: "confirmed" }
render(<CycleSection grants={[attester]} wallet={WALLET} />)

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 }),
Expand Down
64 changes: 52 additions & 12 deletions app/src/components/admin/CycleSection.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import React from "react"
import React, { useEffect, useState } from "react"
import { type Grant } from "../../hooks/useAdminAuthority"
import { useAttest } from "../../hooks/useAttest"
import { useCloseEpoch } from "../../hooks/useCloseEpoch"
import { useCycleEvents, useCycleState } from "../../hooks/useCycleState"
import { useEpochHistory } from "../../hooks/useEpochHistory"
import { useFulfillEpoch } from "../../hooks/useFulfillEpoch"
import { usePendingTransaction } from "../../hooks/usePendingTransaction"
import {
type CycleAction,
formatDuration,
formatSignedPrice,
toActivityList,
toCycleActions,
toCycleRows,
Expand All @@ -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,
Expand All @@ -28,19 +29,50 @@ 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 attested = attest.status.status === "confirmed"

useEffect(() => {
if (attested) setPriceInput("")
}, [attested])

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.freshness !== null)
attest.submit(
{ price: action.price, freshness: action.freshness },
formatSignedPrice(action.price),
)
}
}

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: <CycleActions actions={actions} onRun={run} /> }
: 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: <CycleActions actions={own} onRun={run} onInput={onInput} />,
}
}

const freshness =
cycle.status === "ready" ? cycle.oracle.limits?.freshness : undefined

const modalFlows: TransactionModalProps[] = [
{
Expand All @@ -57,6 +89,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")

Expand Down
Loading
Loading