From 97ac5f53457527a88866cfdc1da92c639fe0d8ab Mon Sep 17 00:00:00 2001 From: luchobonatti Date: Wed, 14 Oct 2026 11:31:29 -0300 Subject: [PATCH 01/12] feat(app): allowlist and intervene from the console --- app-lib/contracts.test.ts | 79 +-- app-lib/contracts.ts | 3 +- app-lib/readContract.test.ts | 115 +++++ app-lib/readContract.ts | 41 +- app/src/App.test.tsx | 1 + .../admin/ComplianceSection.module.css | 42 ++ .../admin/ComplianceSection.test.tsx | 238 +++++++++ .../components/admin/ComplianceSection.tsx | 193 ++++++++ .../admin/GovernanceSection.test.tsx | 1 + .../components/admin/GovernanceSection.tsx | 2 +- .../vault/TransactionModal.test.tsx | 90 ++++ app/src/components/vault/TransactionModal.tsx | 86 +++- app/src/config/clients.ts | 31 ++ app/src/hooks/useAdminAuthority.test.ts | 53 ++ app/src/hooks/useAdminAuthority.ts | 9 + app/src/hooks/useAdminTransaction.test.ts | 321 ++++++++---- app/src/hooks/useComplianceState.test.ts | 135 +++++ app/src/hooks/useComplianceState.ts | 87 ++++ app/src/hooks/useComplianceTransactions.ts | 67 +++ app/src/hooks/useIsAllowed.ts | 35 +- app/src/pages/AdminPanel.test.tsx | 51 ++ app/src/pages/AdminPanel.tsx | 11 +- app/src/pages/actions.test.ts | 17 + app/src/pages/actions.ts | 30 ++ app/src/pages/compliance.test.ts | 342 +++++++++++++ app/src/pages/compliance.ts | 467 ++++++++++++++++++ app/src/pages/governance.test.ts | 1 + app/src/pages/governance.ts | 30 +- 28 files changed, 2365 insertions(+), 213 deletions(-) create mode 100644 app/src/components/admin/ComplianceSection.module.css create mode 100644 app/src/components/admin/ComplianceSection.test.tsx create mode 100644 app/src/components/admin/ComplianceSection.tsx create mode 100644 app/src/hooks/useComplianceState.test.ts create mode 100644 app/src/hooks/useComplianceState.ts create mode 100644 app/src/hooks/useComplianceTransactions.ts create mode 100644 app/src/pages/compliance.test.ts create mode 100644 app/src/pages/compliance.ts diff --git a/app-lib/contracts.test.ts b/app-lib/contracts.test.ts index 0d5f1014..3c90793f 100644 --- a/app-lib/contracts.test.ts +++ b/app-lib/contracts.test.ts @@ -10,17 +10,27 @@ vi.mock("@stellar/stellar-sdk/contract", async (importOriginal) => { import { connectAsyncVault, + connectIdentityVerifier, connectNavOracle, connectShareToken, + type Signer, } from "./contracts" -describe("connectAsyncVault", () => { +describe.each([ + { name: "connectAsyncVault", connect: connectAsyncVault }, + { name: "connectNavOracle", connect: connectNavOracle }, + { name: "connectShareToken", connect: connectShareToken }, + { name: "connectIdentityVerifier", connect: connectIdentityVerifier }, +] satisfies { + name: string + connect: (contractId: string, signer?: Signer) => Promise +}[])("$name", ({ connect }) => { beforeEach(() => fromMock.mockClear()) it("builds a read client with no publicKey or signTransaction when no signer is given", async () => { fromMock.mockResolvedValue({}) - await connectAsyncVault("CCONTRACT") + await connect("CCONTRACT") const options = fromMock.mock.calls[0]?.[0] expect(options.contractId).toBe("CCONTRACT") @@ -32,71 +42,10 @@ describe("connectAsyncVault", () => { fromMock.mockResolvedValue({}) const signTransaction = vi.fn() - await connectAsyncVault("CCONTRACT", { - publicKey: "GADDRESS", - signTransaction, - }) + await connect("CCONTRACT", { publicKey: "GSIGNER", signTransaction }) const options = fromMock.mock.calls[0]?.[0] - expect(options.publicKey).toBe("GADDRESS") - 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) - }) -}) - -describe("connectShareToken", () => { - beforeEach(() => fromMock.mockClear()) - - it("builds a read client with no publicKey or signTransaction when no signer is given", async () => { - fromMock.mockResolvedValue({}) - - await connectShareToken("CTOKEN") - - const options = fromMock.mock.calls[0]?.[0] - expect(options.contractId).toBe("CTOKEN") - 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 connectShareToken("CTOKEN", { - publicKey: "GTOKENADMIN", - signTransaction, - }) - - const options = fromMock.mock.calls[0]?.[0] - expect(options.publicKey).toBe("GTOKENADMIN") + expect(options.publicKey).toBe("GSIGNER") expect(options.signTransaction).toBe(signTransaction) }) }) diff --git a/app-lib/contracts.ts b/app-lib/contracts.ts index c348cd80..7028ae38 100644 --- a/app-lib/contracts.ts +++ b/app-lib/contracts.ts @@ -65,8 +65,9 @@ export const connectNavOracle = ( export const connectIdentityVerifier = ( contractId: string, + signer?: Signer, ): Promise => - Client.from(clientOptions(contractId)) + Client.from(clientOptions(contractId, signer)) export const connectShareToken = ( contractId: string, diff --git a/app-lib/readContract.test.ts b/app-lib/readContract.test.ts index 86bd59eb..b3a85230 100644 --- a/app-lib/readContract.test.ts +++ b/app-lib/readContract.test.ts @@ -8,6 +8,7 @@ import { readEpochs, readEvents, readLedgerTime, + readContractAdmin, readPendingAdmins, } from "./readContract" @@ -812,3 +813,117 @@ describe("readPendingAdmins", () => { }) }) }) + +const instanceKey = (contractId: string): xdr.LedgerKey => + xdr.LedgerKey.contractData( + new xdr.LedgerKeyContractData({ + contract: Address.fromString(contractId).toScAddress(), + key: xdr.ScVal.scvLedgerKeyContractInstance(), + durability: xdr.ContractDataDurability.persistent(), + }), + ) + +const instanceEntry = ( + storage: xdr.ScMapEntry[], + liveUntilLedgerSeq = 2_000, +): Api.LedgerEntryResult => + entryFor( + instanceKey(CONTRACT_ID).toXDR("base64"), + xdr.ScVal.scvContractInstance( + new xdr.ScContractInstance({ + executable: xdr.ContractExecutable.contractExecutableStellarAsset(), + storage, + }), + ), + liveUntilLedgerSeq, + ) + +const storageEntry = (name: string, val: xdr.ScVal) => + new xdr.ScMapEntry({ + key: xdr.ScVal.scvVec([xdr.ScVal.scvSymbol(name)]), + val, + }) + +describe("readContractAdmin", () => { + beforeEach(() => { + getLedgerEntriesMock.mockReset() + getLatestLedgerMock.mockReset() + getLatestLedgerMock.mockResolvedValue({ sequence: 1_000 }) + }) + + it("reads the admin from the contract instance's storage", async () => { + getLedgerEntriesMock.mockResolvedValue({ + entries: [ + instanceEntry([ + storageEntry("PendingAdmin", xdr.ScVal.scvU32(1)), + storageEntry("Admin", Address.fromString(PENDING).toScVal()), + ]), + ], + }) + + expect(await readContractAdmin(CONTRACT_ID)).toEqual({ + kind: "value", + value: PENDING, + }) + const sent = getLedgerEntriesMock.mock.calls[0] as xdr.LedgerKey[] + expect(sent.map((key) => key.toXDR("base64"))).toEqual([ + instanceKey(CONTRACT_ID).toXDR("base64"), + ]) + }) + + it("reads the admin from an instance whose TTL the RPC reports as 0", async () => { + getLedgerEntriesMock.mockResolvedValue({ + entries: [ + instanceEntry( + [storageEntry("Admin", Address.fromString(PENDING).toScVal())], + 0, + ), + ], + }) + + expect(await readContractAdmin(CONTRACT_ID)).toEqual({ + kind: "value", + value: PENDING, + }) + }) + + it("reports an instance whose TTL has lapsed as archived", async () => { + getLedgerEntriesMock.mockResolvedValue({ + entries: [ + instanceEntry( + [storageEntry("Admin", Address.fromString(PENDING).toScVal())], + 999, + ), + ], + }) + + expect(await readContractAdmin(CONTRACT_ID)).toEqual({ kind: "archived" }) + }) + + it("reads no admin when the instance holds none", async () => { + getLedgerEntriesMock.mockResolvedValue({ entries: [instanceEntry([])] }) + + expect(await readContractAdmin(CONTRACT_ID)).toEqual({ + kind: "value", + value: null, + }) + }) + + it("reports a malformed admin as unreadable", async () => { + getLedgerEntriesMock.mockResolvedValue({ + entries: [instanceEntry([storageEntry("Admin", xdr.ScVal.scvU32(7))])], + }) + + expect(await readContractAdmin(CONTRACT_ID)).toEqual({ + kind: "unreadable", + }) + }) + + it("reports a failed ledger read as unreadable", async () => { + getLedgerEntriesMock.mockRejectedValue(new Error("rpc down")) + + expect(await readContractAdmin(CONTRACT_ID)).toEqual({ + kind: "unreadable", + }) + }) +}) diff --git a/app-lib/readContract.ts b/app-lib/readContract.ts index 456ae92b..e5e9756f 100644 --- a/app-lib/readContract.ts +++ b/app-lib/readContract.ts @@ -140,8 +140,9 @@ function decodeLedgerEntry( const entry = chunkResult.byKey.get(key.toXDR("base64")) if (entry === undefined) return { kind: "value", value: null } if (latestLedger === null) return { kind: "unreadable" } - if ((entry.liveUntilLedgerSeq ?? 0) < latestLedger) - return { kind: "archived" } + const liveUntil = entry.liveUntilLedgerSeq ?? 0 + // RPC reports 0 for a live entry whose TTL it does not track + if (liveUntil !== 0 && liveUntil < latestLedger) return { kind: "archived" } try { return { kind: "value", value: decode(entry.val.contractData().val()) } } catch { @@ -380,6 +381,42 @@ export async function readPendingAdmins( } } +const contractInstanceKey = (contractId: string): xdr.LedgerKey => + contractDataLedgerKey( + Address.fromString(contractId).toScAddress(), + xdr.ScVal.scvLedgerKeyContractInstance(), + ) + +const ADMIN_KEY = xdr.ScVal.scvVec([xdr.ScVal.scvSymbol("Admin")]).toXDR( + "base64", +) + +const decodeInstanceAdmin = (val: xdr.ScVal): string | null => { + const entry = val + .instance() + .storage() + ?.find((candidate) => candidate.key().toXDR("base64") === ADMIN_KEY) + if (entry === undefined) return null + const admin: unknown = scValToNative(entry.val()) + if (typeof admin !== "string") throw new Error("malformed admin") + return admin +} + +export async function readContractAdmin( + contractId: string, +): Promise> { + try { + const key = contractInstanceKey(contractId) + const [latestLedger, fetched] = await Promise.all([ + latestLedgerSequence(), + fetchChunk([key]), + ]) + return decodeLedgerEntry(fetched, key, decodeInstanceAdmin, latestLedger) + } catch { + return { kind: "unreadable" } + } +} + export type EventSelection = { contractId: string; names: string[] } export type ChainEvent = { diff --git a/app/src/App.test.tsx b/app/src/App.test.tsx index bd606771..9af5f5b9 100644 --- a/app/src/App.test.tsx +++ b/app/src/App.test.tsx @@ -51,6 +51,7 @@ const disconnectedAuthority: AdminAuthority = { "oracle guardian": [], "oracle admin": [], "share-token compliance": [], + "identity-verifier admin": [], "share-token manager": [], "share-token admin": [], "vault pending admin": [], diff --git a/app/src/components/admin/ComplianceSection.module.css b/app/src/components/admin/ComplianceSection.module.css new file mode 100644 index 00000000..2bbb24f8 --- /dev/null +++ b/app/src/components/admin/ComplianceSection.module.css @@ -0,0 +1,42 @@ +.target { + display: flex; + flex-direction: column; + gap: 6px; +} + +.label { + color: var(--vault-ink); +} + +.field, +.fieldInvalid { + display: flex; + align-items: center; + min-height: 48px; + padding: 8px 16px; + border: 1px solid var(--vault-action); + background: var(--vault-surface-card); +} + +.fieldInvalid { + border: 2px solid var(--vault-danger); +} + +.field:focus-within { + outline: 2px solid var(--vault-action); + outline-offset: 2px; +} + +.input { + flex: 1; + min-width: 0; + border: 0; + outline: none; + background: transparent; + color: var(--vault-ink); + overflow-wrap: anywhere; +} + +.input::placeholder { + color: var(--vault-text-muted); +} diff --git a/app/src/components/admin/ComplianceSection.test.tsx b/app/src/components/admin/ComplianceSection.test.tsx new file mode 100644 index 00000000..d1c74d4c --- /dev/null +++ b/app/src/components/admin/ComplianceSection.test.tsx @@ -0,0 +1,238 @@ +import { fireEvent, render, screen, within } from "@testing-library/react" +import { beforeEach, describe, expect, it, type Mock, vi } from "vitest" +import { type Grant } from "../../hooks/useAdminAuthority" +import { type TransactionStatus } from "../../hooks/useContractTransaction" +import ComplianceSection from "./ComplianceSection" + +const { txs, useComplianceStateMock } = vi.hoisted(() => { + const tx = (): { + status: TransactionStatus + submit: Mock<() => boolean> + reset: Mock + } => ({ + status: { status: "idle" }, + submit: vi.fn(() => true), + reset: vi.fn(), + }) + return { + txs: { + useAllowAddress: tx(), + useDisallowAddress: tx(), + useFreezeAddress: tx(), + useUnfreezeAddress: tx(), + useFreezeShares: tx(), + useUnfreezeShares: tx(), + useForceTransfer: tx(), + }, + useComplianceStateMock: vi.fn(), + } +}) + +vi.mock("../../hooks/useComplianceTransactions", () => + Object.fromEntries( + Object.entries(txs).map(([name, transaction]) => [name, () => transaction]), + ), +) +vi.mock("../../hooks/useComplianceState", () => ({ + useComplianceState: useComplianceStateMock, +})) +vi.mock("../../config/contracts", () => ({ + vaultContractId: "CABQGAYDAMBQGAYDAMBQGAYDAMBQGAYDAMBQGAYDAMBQGAYDAMBQGCK3", +})) + +const WALLET = "GWALLETADDRESS1234" +const TARGET = "GDPO5SQDJY4E2XEE47MAOT2M2F3GR4H2AYYKSMIOT2YPOHAMC36VKWIA" +const UNIT = 10_000_000n +const ADDRESSES = { + oracle: "CORACLE", + shareToken: "CTOKEN", + identityVerifier: "CVERIFIERADDRESS", +} + +const grantOf = (role: string): Grant => ({ + role, + authority: WALLET, + standing: "signs-alone", +}) + +const verifierAdmin = grantOf("identity-verifier admin") +const officer = grantOf("share-token compliance") + +const openRow = (name: string) => { + const toggle = screen + .getAllByRole("button", { name }) + .find((candidate) => candidate.hasAttribute("aria-expanded")) + if (toggle?.getAttribute("aria-expanded") === "false") fireEvent.click(toggle) +} + +const button = (name: string) => { + openRow(name) + return screen + .getAllByRole("button", { name }) + .find((candidate) => !candidate.hasAttribute("aria-expanded"))! +} + +const enterTarget = (text: string) => + fireEvent.change(screen.getByLabelText("Target address"), { + target: { value: text }, + }) + +const readsFor = ({ target }: { target: string | undefined }) => ({ + target: + target === undefined + ? { status: "none" } + : { + status: "ready", + reads: { + allowance: "not-allowed", + frozen: false, + shares: 100n * UNIT, + frozenShares: 10n * UNIT, + }, + }, + destination: "checking", +}) + +describe("ComplianceSection", () => { + beforeEach(() => { + vi.clearAllMocks() + for (const transaction of Object.values(txs)) + transaction.status = { status: "idle" } + useComplianceStateMock.mockImplementation(readsFor) + }) + + it("blocks the actions until a target address is entered", () => { + render( + , + ) + + expect(button("Freeze the address").disabled).toBe(true) + expect( + screen.getAllByText("Target address entered").length, + ).toBeGreaterThan(0) + }) + + it("reads the entered address and shows its state first", () => { + render( + , + ) + + enterTarget(` ${TARGET} `) + + expect(useComplianceStateMock).toHaveBeenLastCalledWith({ + verifier: "CVERIFIERADDRESS", + target: TARGET, + destination: undefined, + active: true, + }) + const overview = screen.getByRole("region", { name: "Target" }) + expect(within(overview).getByText("Allowlisted")).toBeTruthy() + expect(within(overview).getByText("90.00")).toBeTruthy() + }) + + it("ignores an address that is not a valid key", () => { + render( + , + ) + + enterTarget("GNOTANADDRESS") + + expect( + screen.getByLabelText("Target address").getAttribute("aria-invalid"), + ).toBe("true") + expect(useComplianceStateMock).toHaveBeenLastCalledWith( + expect.objectContaining({ target: undefined }), + ) + }) + + it("allows the target and follows it in the modal", () => { + const { rerender } = render( + , + ) + enterTarget(TARGET) + + fireEvent.click(button("Allow the address")) + expect(txs.useAllowAddress.submit).toHaveBeenCalledWith({ + verifier: "CVERIFIERADDRESS", + account: TARGET, + }) + + txs.useAllowAddress.status = { status: "preparing" } + rerender( + , + ) + expect( + screen.getByRole("heading", { name: "Preparing to allow the address" }), + ).toBeTruthy() + }) + + it("freezes an amount of the target's shares", () => { + render( + , + ) + enterTarget(TARGET) + openRow("Freeze shares") + + const amount = document.getElementById( + "compliance-freeze-shares-field-freeze-shares-amount", + ) + fireEvent.change(amount!, { target: { value: "5" } }) + fireEvent.click(button("Freeze shares")) + + expect(txs.useFreezeShares.submit).toHaveBeenCalledWith({ + account: TARGET, + amount: 5n * UNIT, + }) + expect(screen.getByText("Frozen shares 10.00 → 15.00.")).toBeTruthy() + }) + + it("shows the flow modal only while the surface is active", () => { + txs.useFreezeAddress.status = { status: "preparing" } + const { rerender } = render( + , + ) + expect(screen.getAllByRole("dialog", { hidden: true })).toHaveLength(1) + + rerender( + , + ) + expect(screen.queryAllByRole("dialog", { hidden: true })).toHaveLength(0) + expect(useComplianceStateMock).toHaveBeenLastCalledWith( + expect.objectContaining({ active: false }), + ) + }) +}) diff --git a/app/src/components/admin/ComplianceSection.tsx b/app/src/components/admin/ComplianceSection.tsx new file mode 100644 index 00000000..2baec6a8 --- /dev/null +++ b/app/src/components/admin/ComplianceSection.tsx @@ -0,0 +1,193 @@ +import React, { useEffect, useState } from "react" +import { vaultContractId } from "../../config/contracts" +import { type AdminAddresses, type Grant } from "../../hooks/useAdminAuthority" +import { useComplianceState } from "../../hooks/useComplianceState" +import { + useAllowAddress, + useDisallowAddress, + useForceTransfer, + useFreezeAddress, + useFreezeShares, + useUnfreezeAddress, + useUnfreezeShares, +} from "../../hooks/useComplianceTransactions" +import { + usePendingTransaction, + type UsePendingTransaction, +} from "../../hooks/usePendingTransaction" +import { confirmKey, type Inputs, parseAccount } from "../../pages/actions" +import { + COMPLIANCE_ACTION_IDS, + type ComplianceAction, + destinationOf, + toComplianceActions, + toComplianceTiles, +} from "../../pages/compliance" +import typeStyles from "../../styles/type.module.css" +import MetricsStrip from "../vault/MetricsStrip" +import TransactionModal, { + type ComplianceModalAction, + type TransactionModalProps, +} from "../vault/TransactionModal" +import styles from "./ComplianceSection.module.css" +import CycleActionRail from "./CycleActionRail" +import SurfaceLayout from "./SurfaceLayout" +import layoutStyles from "./SurfaceLayout.module.css" + +type Flow = Pick< + UsePendingTransaction, + "status" | "amountLabel" | "reset" | "retry" +> + +const flow = ( + action: ComplianceModalAction, + transaction: Flow, +): TransactionModalProps => ({ + action, + status: transaction.status, + value: transaction.amountLabel, + onClose: transaction.reset, + onRetry: transaction.retry, +}) + +function useComplianceFlows() { + return { + "allow-address": usePendingTransaction(useAllowAddress()), + "disallow-address": usePendingTransaction(useDisallowAddress()), + "freeze-address": usePendingTransaction(useFreezeAddress()), + "unfreeze-address": usePendingTransaction(useUnfreezeAddress()), + "freeze-shares": usePendingTransaction(useFreezeShares()), + "unfreeze-shares": usePendingTransaction(useUnfreezeShares()), + "force-transfer": usePendingTransaction(useForceTransfer()), + } satisfies Record +} + +type Flows = ReturnType + +const submitArg = ( + { submit }: { submit: (arg: Arg, label: string) => boolean }, + { arg, next }: { arg: Arg | undefined; next: string }, +) => arg !== undefined && submit(arg, next) + +function submit(flows: Flows, action: ComplianceAction) { + switch (action.id) { + case "allow-address": + case "disallow-address": + return submitArg(flows[action.id], action) + case "freeze-address": + case "unfreeze-address": + return submitArg(flows[action.id], action) + case "freeze-shares": + case "unfreeze-shares": + return submitArg(flows[action.id], action) + case "force-transfer": + return submitArg(flows[action.id], action) + } +} + +const TARGET_ID = "compliance-target" + +const ComplianceSection: React.FC<{ + grants: Grant[] + wallet: string + addresses: AdminAddresses + contracts?: React.ReactNode + active?: boolean +}> = ({ grants, wallet, addresses, contracts, active = true }) => { + const verifier = addresses.identityVerifier + const [targetText, setTargetText] = useState("") + const [inputs, setInputs] = useState({}) + const target = parseAccount(targetText) + const { target: reads, destination } = useComplianceState({ + verifier, + target, + destination: destinationOf(inputs), + active, + }) + const flows = useComplianceFlows() + + const modalFlows = COMPLIANCE_ACTION_IDS.map((id) => flow(id, flows[id])) + const recorded = modalFlows.some( + (props) => props.status.status === "confirmed", + ) + useEffect(() => { + if (recorded) setInputs({}) + }, [recorded]) + + const state = { + verifier, + target, + reads, + vault: vaultContractId, + destination, + } + const actions = toComplianceActions(state, grants, wallet, inputs) + + const onInput = (action: ComplianceAction, value: string, key?: string) => { + if (key !== undefined) + setInputs((previous) => ({ + ...previous, + [confirmKey(action.id)]: "", + [key]: value, + })) + } + + const invalid = targetText.trim() !== "" && target === undefined + const activeModal = modalFlows.find((props) => props.status.status !== "idle") + + return ( + <> + submit(flows, action)} + onInput={onInput} + /> + } + > +
+

+ Target +

+
+ +
+ setTargetText(event.target.value)} + placeholder="Address" + aria-invalid={invalid} + spellCheck={false} + autoComplete="off" + /> +
+
+ +
+ {contracts} +
+ {active && activeModal !== undefined && ( + + )} + + ) +} + +export default ComplianceSection diff --git a/app/src/components/admin/GovernanceSection.test.tsx b/app/src/components/admin/GovernanceSection.test.tsx index 0b993de3..99f76ccc 100644 --- a/app/src/components/admin/GovernanceSection.test.tsx +++ b/app/src/components/admin/GovernanceSection.test.tsx @@ -93,6 +93,7 @@ const HOLDERS: RoleHolders = { "oracle guardian": [], "oracle admin": [WALLET], "share-token compliance": [], + "identity-verifier admin": [], "share-token manager": [VAULT], "share-token admin": [WALLET], "vault pending admin": [], diff --git a/app/src/components/admin/GovernanceSection.tsx b/app/src/components/admin/GovernanceSection.tsx index 4f7effde..1f631a2c 100644 --- a/app/src/components/admin/GovernanceSection.tsx +++ b/app/src/components/admin/GovernanceSection.tsx @@ -33,8 +33,8 @@ import { usePendingTransaction, type UsePendingTransaction, } from "../../hooks/usePendingTransaction" +import { confirmKey } from "../../pages/actions" import { - confirmKey, GOVERNANCE_ACTION_IDS, type GovernanceAction, type GovernanceInputs, diff --git a/app/src/components/vault/TransactionModal.test.tsx b/app/src/components/vault/TransactionModal.test.tsx index 2ef55dd2..b8406c94 100644 --- a/app/src/components/vault/TransactionModal.test.tsx +++ b/app/src/components/vault/TransactionModal.test.tsx @@ -18,6 +18,7 @@ import { type FundStatus } from "../../hooks/useFund" import { type RequestDepositStatus } from "../../hooks/useRequestDeposit" import { type RequestRedeemStatus } from "../../hooks/useRequestRedeem" import TransactionModal, { + type ComplianceModalAction, type ConfigurationModalAction, type EmergencyModalAction, type GovernanceModalAction, @@ -2415,3 +2416,92 @@ describe("TransactionModal, governance actions", () => { ).toBeTruthy() }) }) + +const renderComplianceModal = ( + action: ComplianceModalAction, + status: TransactionStatus, + value = "GABC...WXYZ", +) => + render( + , + ) + +describe("TransactionModal, compliance actions", () => { + it.each<[ComplianceModalAction, string, string, string, string]>([ + [ + "allow-address", + "GABC...WXYZ", + "Signing adds GABC...WXYZ to the allowlist.", + "Address allowed", + "GABC...WXYZ is on the allowlist.", + ], + [ + "freeze-shares", + "5.00 shares of GABC...WXYZ", + "Signing freezes 5.00 shares of GABC...WXYZ.", + "Shares frozen", + "Froze 5.00 shares of GABC...WXYZ.", + ], + [ + "force-transfer", + "5.00 shares from GABC...WXYZ to CVAU...LTID", + "Signing moves 5.00 shares from GABC...WXYZ to CVAU...LTID.", + "Transfer forced", + "Moved 5.00 shares from GABC...WXYZ to CVAU...LTID.", + ], + ])("names what %s changes", (action, value, signing, done, doneBody) => { + const { unmount } = renderComplianceModal( + action, + { status: "awaiting-signature" }, + value, + ) + expect(screen.getByText(signing)).toBeTruthy() + unmount() + + renderComplianceModal(action, { status: "confirmed" }, value) + expect(screen.getByRole("heading", { name: done })).toBeTruthy() + expect(screen.getByText(doneBody)).toBeTruthy() + }) + + it.each<[ComplianceModalAction, number, string, RegExp]>([ + ["freeze-shares", 300, "share token", /does not hold enough shares/], + ["unfreeze-shares", 301, "share token", /cannot be negative/], + ["force-transfer", 302, "share token", /The address is frozen/], + ["unfreeze-shares", 303, "share token", /that many frozen shares/], + ["freeze-address", 2000, "share token", /does not hold the role/], + ["allow-address", 2000, "identity verifier", /does not hold the role/], + ["disallow-address", 2001, "identity verifier", /verifier has no admin/], + ])( + "names the reason %s was refused with code %i", + (action, code, refuser, sentence) => { + renderComplianceModal(action, { + status: "failed", + failure: { kind: "contract-error", code }, + }) + + expect( + screen.getByRole("heading", { + name: `The ${refuser} refused this request`, + }), + ).toBeTruthy() + expect(screen.getByText(sentence)).toBeTruthy() + }, + ) + + it("sends an unknown outcome to the Compliance tab", () => { + renderComplianceModal("unfreeze-address", { + status: "failed", + failure: { kind: "unknown" }, + }) + + expect( + screen.getByText(/check the Compliance tab before trying again/), + ).toBeTruthy() + }) +}) diff --git a/app/src/components/vault/TransactionModal.tsx b/app/src/components/vault/TransactionModal.tsx index 6b0ccbdd..07b486b0 100644 --- a/app/src/components/vault/TransactionModal.tsx +++ b/app/src/components/vault/TransactionModal.tsx @@ -24,6 +24,7 @@ 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 { type ComplianceActionId } from "../../pages/compliance" import { EFFECTS } from "../../pages/configuration" import { type GovernanceActionId } from "../../pages/governance" import typeStyles from "../../styles/type.module.css" @@ -51,6 +52,8 @@ export type ConfigurationModalAction = export type GovernanceModalAction = GovernanceActionId +export type ComplianceModalAction = ComplianceActionId + export type TransactionModalProps = | { action: EmergencyModalAction @@ -59,7 +62,8 @@ export type TransactionModalProps = onRetry: () => void } | { - action: ConfigurationModalAction | GovernanceModalAction + action: + ConfigurationModalAction | GovernanceModalAction | ComplianceModalAction status: TransactionStatus value: string onClose: () => void @@ -389,6 +393,7 @@ const EMERGENCY_FOLLOW_UP = "check the Emergency tab before trying again" const CONFIGURATION_FOLLOW_UP = "check the Configuration tab before trying again" const GOVERNANCE_FOLLOW_UP = "check the Governance tab before trying again" +const COMPLIANCE_FOLLOW_UP = "check the Compliance tab before trying again" const describeFailure = ( failure: TransactionFailure, @@ -958,7 +963,7 @@ const describeFinalizeRoundStatus = ( } type AdminCopy = { - refuser: "vault" | "share token" | "oracle" + refuser: "vault" | "share token" | "oracle" | "identity verifier" reasons?: Partial> verb: string signing: string @@ -1171,6 +1176,61 @@ const GOVERNANCE_COPY: Record< "cancel-wind-down": cancelWindDownCopy, } +const COMPLIANCE_COPY: Record< + ComplianceModalAction, + (value: string) => AdminCopy +> = { + "allow-address": (value) => ({ + refuser: "identity verifier", + verb: "allow the address", + signing: `Signing adds ${value} to the allowlist.`, + done: "Address allowed", + doneBody: `${value} is on the allowlist.`, + }), + "disallow-address": (value) => ({ + refuser: "identity verifier", + verb: "remove the address from the allowlist", + signing: `Signing removes ${value} from the allowlist.`, + done: "Removed from the allowlist", + doneBody: `${value} is off the allowlist.`, + }), + "freeze-address": (value) => ({ + refuser: "share token", + verb: "freeze the address", + signing: `Signing freezes ${value}.`, + done: "Address frozen", + doneBody: `${value} is frozen.`, + }), + "unfreeze-address": (value) => ({ + refuser: "share token", + verb: "unfreeze the address", + signing: `Signing unfreezes ${value}.`, + done: "Address unfrozen", + doneBody: `${value} is no longer frozen.`, + }), + "freeze-shares": (value) => ({ + refuser: "share token", + verb: "freeze shares", + signing: `Signing freezes ${value}.`, + done: "Shares frozen", + doneBody: `Froze ${value}.`, + }), + "unfreeze-shares": (value) => ({ + refuser: "share token", + verb: "unfreeze shares", + signing: `Signing unfreezes ${value}.`, + done: "Shares unfrozen", + doneBody: `Unfroze ${value}.`, + }), + "force-transfer": (value) => ({ + refuser: "share token", + verb: "force a transfer", + signing: `Signing moves ${value}.`, + done: "Transfer forced", + doneBody: `Moved ${value}.`, + }), +} + const adminContractErrorReason = (refuser: AdminCopy["refuser"]) => (code: number): string => { @@ -1179,8 +1239,18 @@ const adminContractErrorReason = return `The ${refuser} is already paused.` case 1001: return `The ${refuser} is not paused, so there is nothing to unpause.` + case 300: + return "The address does not hold enough shares." + case 301: + return "The amount cannot be negative." + case 302: + return "The address is frozen." + case 303: + return "The address does not have that many frozen shares." case 2000: return "This wallet does not hold the role this action needs." + case 2001: + return `The ${refuser} has no admin.` case 2002: return "The role's holders changed before this was recorded." case 2007: @@ -1363,6 +1433,18 @@ const describeStatus = ( props.status, GOVERNANCE_FOLLOW_UP, ) + case "allow-address": + case "disallow-address": + case "freeze-address": + case "unfreeze-address": + case "freeze-shares": + case "unfreeze-shares": + case "force-transfer": + return describeAdminStatus( + COMPLIANCE_COPY[props.action](props.value), + props.status, + COMPLIANCE_FOLLOW_UP, + ) } } diff --git a/app/src/config/clients.ts b/app/src/config/clients.ts index fce5369b..afc86ced 100644 --- a/app/src/config/clients.ts +++ b/app/src/config/clients.ts @@ -60,6 +60,22 @@ export const shareTokenWriter = writer((signer) => connectShareToken(addresses.share_token, signer), ) +const verifierWriters = new Map< + string, + (signer: Signer) => Promise +>() + +export const identityVerifierWriter = (address: string) => { + let verifierWriter = verifierWriters.get(address) + if (verifierWriter === undefined) { + verifierWriter = writer((signer) => + connectIdentityVerifier(address, signer), + ) + verifierWriters.set(address, verifierWriter) + } + return verifierWriter +} + let oracle: Promise | undefined export const navOracle = (): Promise => { @@ -82,6 +98,21 @@ export const identityVerifier = (): Promise => { return identityVerifierClient } +const verifierReaders = new Map>() + +export const identityVerifierAt = ( + address: string, +): Promise => { + const cached = verifierReaders.get(address) + if (cached !== undefined) return cached + const client = connectIdentityVerifier(address).catch((error: unknown) => { + verifierReaders.delete(address) + throw error + }) + verifierReaders.set(address, client) + return client +} + let shareTokenClient: Promise | undefined export const shareToken = (): Promise => { diff --git a/app/src/hooks/useAdminAuthority.test.ts b/app/src/hooks/useAdminAuthority.test.ts index dbb24c06..c924d913 100644 --- a/app/src/hooks/useAdminAuthority.test.ts +++ b/app/src/hooks/useAdminAuthority.test.ts @@ -26,6 +26,7 @@ const noHolders: RoleHolders = { "oracle guardian": [], "oracle admin": [], "share-token compliance": [], + "identity-verifier admin": [], "share-token manager": [], "share-token admin": [], "vault pending admin": [], @@ -77,6 +78,7 @@ describe("toSurfaces", () => { ["vault treasury", ["cycle"]], ["oracle attester", ["cycle"]], ["share-token compliance", ["compliance"]], + ["identity-verifier admin", ["compliance"]], ["vault guardian", ["emergency"]], ["oracle guardian", ["emergency"]], ["share-token admin", ["emergency", "configuration", "governance"]], @@ -299,6 +301,7 @@ const { connectShareTokenMock, accountSignersMock, readPendingAdminsMock, + readContractAdminMock, } = vi.hoisted(() => ({ vaultMock: { governance: vi.fn(), @@ -322,6 +325,7 @@ const { connectShareTokenMock: vi.fn(), accountSignersMock: vi.fn(), readPendingAdminsMock: vi.fn(), + readContractAdminMock: vi.fn(), })) vi.mock("../config/clients", () => ({ @@ -336,6 +340,7 @@ vi.mock("@stellar-scaffold/app-lib", async (importOriginal) => { connectShareToken: connectShareTokenMock, accountSigners: accountSignersMock, readPendingAdmins: readPendingAdminsMock, + readContractAdmin: readContractAdminMock, } }) @@ -399,6 +404,54 @@ describe("useAdminAuthority", () => { }) accountSignersMock.mockResolvedValue(singleKey) readPendingAdminsMock.mockResolvedValue(pendingAdmins({})) + readContractAdminMock.mockResolvedValue({ kind: "value", value: null }) + }) + + it("grants the compliance surface to the admin of the token's own identity verifier", async () => { + vaultRoles({}) + readContractAdminMock.mockResolvedValue({ + kind: "value", + value: investorAddress, + }) + const { result } = renderWithWallet(useAdminAuthority) + + await waitFor(() => expect(result.current.status).toBe("ready")) + + expect(readContractAdminMock).toHaveBeenCalledWith("CVERIFIERADDRESS") + expect(result.current.surfaces).toEqual(new Set(["compliance"])) + expect(result.current.holders["identity-verifier admin"]).toEqual([ + investorAddress, + ]) + }) + + it("grants nothing from a verifier whose admin is another account", async () => { + vaultRoles({}) + readContractAdminMock.mockResolvedValue({ + kind: "value", + value: contractHolder, + }) + const { result } = renderWithWallet(useAdminAuthority) + + await waitFor(() => expect(result.current.status).toBe("ready")) + expect(result.current.surfaces).toEqual(new Set()) + }) + + it("keeps the other surfaces when the verifier admin cannot be read", async () => { + readContractAdminMock.mockResolvedValue({ kind: "unreadable" }) + const { result } = renderWithWallet(useAdminAuthority) + + await waitFor(() => expect(result.current.status).toBe("ready")) + expect(result.current.surfaces).toEqual(new Set(["cycle"])) + expect(result.current.holders["identity-verifier admin"]).toEqual([]) + }) + + it("skips the verifier admin read when the token's verifier is unknown", async () => { + tokenMock.identity_verifier.mockRejectedValue(new Error("rpc down")) + const { result } = renderWithWallet(useAdminAuthority) + + await waitFor(() => expect(result.current.status).toBe("ready")) + expect(readContractAdminMock).not.toHaveBeenCalled() + expect(result.current.surfaces).toEqual(new Set(["cycle"])) }) it("reports the held surfaces and the derived addresses once every read resolves", async () => { diff --git a/app/src/hooks/useAdminAuthority.ts b/app/src/hooks/useAdminAuthority.ts index e84a2c76..0c1d952f 100644 --- a/app/src/hooks/useAdminAuthority.ts +++ b/app/src/hooks/useAdminAuthority.ts @@ -6,6 +6,7 @@ import { type ContractRead, type PendingAdmins, readContract, + readContractAdmin, } from "@stellar-scaffold/app-lib" import { type QueryClient, @@ -70,6 +71,7 @@ const ROLE_SURFACES = { "vault treasury": ["cycle"], "oracle attester": ["cycle"], "share-token compliance": ["compliance"], + "identity-verifier admin": ["compliance"], "vault guardian": ["emergency"], "oracle guardian": ["emergency"], "share-token manager": [], @@ -92,6 +94,7 @@ const emptyHolders = (): RoleHolders => ({ "vault treasury": [], "oracle attester": [], "share-token compliance": [], + "identity-verifier admin": [], "vault guardian": [], "oracle guardian": [], "share-token manager": [], @@ -333,6 +336,10 @@ async function fetchAdminAuthority( shareToken: shareTokenAddress.value, identityVerifier: toAddressOrNull(identityVerifier), } + const verifierAdmin = + addresses.identityVerifier === null + ? undefined + : await readContractAdmin(addresses.identityVerifier) const roleReads: ContractRead[] = [ governance, @@ -358,6 +365,8 @@ async function fetchAdminAuthority( "oracle guardian": asMembers(oracleGuardians), "oracle admin": asHolders(oracleAdmin), "share-token compliance": asMembers(tokenCompliance), + "identity-verifier admin": + verifierAdmin === undefined ? [] : asHolders(verifierAdmin), "share-token manager": asMembers(tokenManager), "share-token admin": asHolders(tokenAdmin), "vault pending admin": pendingAdminOf(pendingAdmins, vaultContractId), diff --git a/app/src/hooks/useAdminTransaction.test.ts b/app/src/hooks/useAdminTransaction.test.ts index 919833eb..a1e55a0f 100644 --- a/app/src/hooks/useAdminTransaction.test.ts +++ b/app/src/hooks/useAdminTransaction.test.ts @@ -5,6 +5,16 @@ import { beforeEach, describe, expect, it, vi } from "vitest" import { governanceReadsKey } from "./pendingAdmins" import { deferred, investorAddress, renderWithWallet } from "./testSupport" import { adminAuthorityKey } from "./useAdminAuthority" +import { complianceReadsKey } from "./useComplianceState" +import { + useAllowAddress, + useDisallowAddress, + useForceTransfer, + useFreezeAddress, + useFreezeShares, + useUnfreezeAddress, + useUnfreezeShares, +} from "./useComplianceTransactions" import { configurationReadsKey } from "./useConfigurationState" import { useSetCompliance, @@ -55,51 +65,60 @@ import { tokenPausedKey } from "./useTokenPaused" import { vaultAuthoritiesKey } from "./useVaultAuthorities" import { vaultPausedKey } from "./useVaultPaused" -const { vault, token, oracle } = vi.hoisted(() => ({ - vault: { - pause: vi.fn(), - unpause: vi.fn(), - set_deposit_cap: vi.fn(), - set_custodian: vi.fn(), - set_notice: vi.fn(), - set_wind_down_delay: vi.fn(), - grant_role: vi.fn(), - revoke_role: vi.fn(), - transfer_admin_role: vi.fn(), - accept_admin_transfer: vi.fn(), - propose_upgrade: vi.fn(), - propose_upgrade_delay: vi.fn(), - apply_upgrade: vi.fn(), - cancel_upgrade: vi.fn(), - propose_wind_down: vi.fn(), - cancel_wind_down_proposal: vi.fn(), - }, - token: { - pause: vi.fn(), - unpause: vi.fn(), - set_compliance: vi.fn(), - set_identity_verifier: vi.fn(), - grant_role: vi.fn(), - revoke_role: vi.fn(), - transfer_admin_role: vi.fn(), - accept_admin_transfer: vi.fn(), - }, - oracle: { - raise_ripcord: vi.fn(), - set_ripcord: vi.fn(), - clear_latest: vi.fn(), - set_config: vi.fn(), - grant_role: vi.fn(), - revoke_role: vi.fn(), - transfer_admin_role: vi.fn(), - accept_admin_transfer: vi.fn(), - }, -})) +const { vault, token, oracle, verifier, verifierWriterMock } = vi.hoisted( + () => ({ + vault: { + pause: vi.fn(), + unpause: vi.fn(), + set_deposit_cap: vi.fn(), + set_custodian: vi.fn(), + set_notice: vi.fn(), + set_wind_down_delay: vi.fn(), + grant_role: vi.fn(), + revoke_role: vi.fn(), + transfer_admin_role: vi.fn(), + accept_admin_transfer: vi.fn(), + propose_upgrade: vi.fn(), + propose_upgrade_delay: vi.fn(), + apply_upgrade: vi.fn(), + cancel_upgrade: vi.fn(), + propose_wind_down: vi.fn(), + cancel_wind_down_proposal: vi.fn(), + }, + token: { + pause: vi.fn(), + unpause: vi.fn(), + set_compliance: vi.fn(), + set_identity_verifier: vi.fn(), + grant_role: vi.fn(), + revoke_role: vi.fn(), + transfer_admin_role: vi.fn(), + accept_admin_transfer: vi.fn(), + set_address_frozen: vi.fn(), + freeze_partial_tokens: vi.fn(), + unfreeze_partial_tokens: vi.fn(), + forced_transfer: vi.fn(), + }, + verifier: { allow: vi.fn() }, + verifierWriterMock: vi.fn(), + oracle: { + raise_ripcord: vi.fn(), + set_ripcord: vi.fn(), + clear_latest: vi.fn(), + set_config: vi.fn(), + grant_role: vi.fn(), + revoke_role: vi.fn(), + transfer_admin_role: vi.fn(), + accept_admin_transfer: vi.fn(), + }, + }), +) vi.mock("../config/clients", () => ({ asyncVaultWriter: async () => vault, shareTokenWriter: async () => token, navOracleWriter: async () => oracle, + identityVerifierWriter: verifierWriterMock, })) const CONTRACT = "CCONTRACTADDRESS1234567890" @@ -427,89 +446,173 @@ const confirmed = () => ({ }), }) -describe.each([...cases, ...governanceCases])("$name", (testCase) => { - const { useHook, method, arg, args, keys, refusal } = testCase +const VERIFIER = "CVERIFIERADDRESS" +const TARGET = "GTARGETADDRESS1234" +const DESTINATION = "GDESTINATION5678" + +const complianceCases: Case[] = [ + { + name: "useAllowAddress", + useHook: useAllowAddress, + method: verifier.allow, + arg: { verifier: VERIFIER, account: TARGET }, + args: { account: TARGET, allowed: true, caller: investorAddress }, + keys: [complianceReadsKey, isAllowedKey], + refusal: 2000, + }, + { + name: "useDisallowAddress", + useHook: useDisallowAddress, + method: verifier.allow, + arg: { verifier: VERIFIER, account: TARGET }, + args: { account: TARGET, allowed: false, caller: investorAddress }, + keys: [complianceReadsKey, isAllowedKey], + refusal: 2001, + }, + { + name: "useFreezeAddress", + useHook: useFreezeAddress, + method: token.set_address_frozen, + arg: TARGET, + args: { user_address: TARGET, freeze: true, operator: investorAddress }, + keys: [complianceReadsKey], + refusal: 2000, + }, + { + name: "useUnfreezeAddress", + useHook: useUnfreezeAddress, + method: token.set_address_frozen, + arg: TARGET, + args: { user_address: TARGET, freeze: false, operator: investorAddress }, + keys: [complianceReadsKey], + refusal: 2000, + }, + { + name: "useFreezeShares", + useHook: useFreezeShares, + method: token.freeze_partial_tokens, + arg: { account: TARGET, amount: 5n }, + args: { user_address: TARGET, amount: 5n, operator: investorAddress }, + keys: [complianceReadsKey], + refusal: 300, + }, + { + name: "useUnfreezeShares", + useHook: useUnfreezeShares, + method: token.unfreeze_partial_tokens, + arg: { account: TARGET, amount: 5n }, + args: { user_address: TARGET, amount: 5n, operator: investorAddress }, + keys: [complianceReadsKey], + refusal: 303, + }, + { + name: "useForceTransfer", + useHook: useForceTransfer, + method: token.forced_transfer, + arg: { from: TARGET, to: DESTINATION, amount: 5n }, + args: { + from: TARGET, + to: DESTINATION, + amount: 5n, + operator: investorAddress, + }, + keys: [complianceReadsKey, ["share", "balance"]], + refusal: 300, + }, +] - beforeEach(() => { - vi.clearAllMocks() - }) +describe.each([...cases, ...governanceCases, ...complianceCases])( + "$name", + (testCase) => { + const { useHook, method, arg, args, keys, refusal } = testCase - it("signs as the connected wallet and confirms", async () => { - const gate = deferred() - method.mockResolvedValue({ - simulation: undefined, - signAndSend: vi.fn( - async ({ watcher }: { watcher: { onSubmitted: () => void } }) => { - await gate.promise - watcher.onSubmitted() - return { - getTransactionResponse: { status: "SUCCESS" }, - result: undefined, - } - }, - ), + beforeEach(() => { + vi.clearAllMocks() + verifierWriterMock.mockImplementation( + (address: string) => async () => + address === VERIFIER ? verifier : undefined, + ) }) - const { result } = renderWithWallet(useHook) - expect(result.current.submit(arg as never)).toBe(true) - await waitFor(() => - expect(result.current.status).toEqual({ status: "awaiting-signature" }), - ) + it("signs as the connected wallet and confirms", async () => { + const gate = deferred() + method.mockResolvedValue({ + simulation: undefined, + signAndSend: vi.fn( + async ({ watcher }: { watcher: { onSubmitted: () => void } }) => { + await gate.promise + watcher.onSubmitted() + return { + getTransactionResponse: { status: "SUCCESS" }, + result: undefined, + } + }, + ), + }) + const { result } = renderWithWallet(useHook) - gate.resolve() - await waitFor(() => - expect(result.current.status).toEqual({ status: "confirmed" }), - ) - expect(method.mock.calls).toEqual([args === undefined ? [] : [args]]) - }) + expect(result.current.submit(arg as never)).toBe(true) + await waitFor(() => + expect(result.current.status).toEqual({ status: "awaiting-signature" }), + ) - it("refuses before asking for a signature and refreshes the state it read", async () => { - const signAndSend = vi.fn() - method.mockResolvedValue({ - simulation: { error: `HostError: Error(Contract, #${refusal})` }, - signAndSend, + gate.resolve() + await waitFor(() => + expect(result.current.status).toEqual({ status: "confirmed" }), + ) + expect(method.mock.calls).toEqual([args === undefined ? [] : [args]]) }) - const { result, queryClient } = renderWithWallet(useHook) - const invalidateQueries = vi.spyOn(queryClient, "invalidateQueries") - await act(() => result.current.submit(arg as never)) + it("refuses before asking for a signature and refreshes the state it read", async () => { + const signAndSend = vi.fn() + method.mockResolvedValue({ + simulation: { error: `HostError: Error(Contract, #${refusal})` }, + signAndSend, + }) + const { result, queryClient } = renderWithWallet(useHook) + const invalidateQueries = vi.spyOn(queryClient, "invalidateQueries") - expect(result.current.status).toEqual({ - status: "failed", - failure: { kind: "contract-error", code: refusal }, - }) - expect(signAndSend).not.toHaveBeenCalled() - for (const queryKey of keys) - expect(invalidateQueries).toHaveBeenCalledWith({ queryKey }) - }) + await act(() => result.current.submit(arg as never)) - it("tells a declined signature apart from a failure", async () => { - method.mockResolvedValue({ - simulation: undefined, - signAndSend: vi - .fn() - .mockRejectedValue( - new AssembledTransaction.Errors.UserRejected("User declined access"), - ), + expect(result.current.status).toEqual({ + status: "failed", + failure: { kind: "contract-error", code: refusal }, + }) + expect(signAndSend).not.toHaveBeenCalled() + for (const queryKey of keys) + expect(invalidateQueries).toHaveBeenCalledWith({ queryKey }) }) - const { result } = renderWithWallet(useHook) - await act(() => result.current.submit(arg as never)) + it("tells a declined signature apart from a failure", async () => { + method.mockResolvedValue({ + simulation: undefined, + signAndSend: vi + .fn() + .mockRejectedValue( + new AssembledTransaction.Errors.UserRejected( + "User declined access", + ), + ), + }) + const { result } = renderWithWallet(useHook) - expect(result.current.status).toEqual({ - status: "failed", - failure: { kind: "declined" }, + await act(() => result.current.submit(arg as never)) + + expect(result.current.status).toEqual({ + status: "failed", + failure: { kind: "declined" }, + }) }) - }) - it("refreshes the state it changed once confirmed", async () => { - method.mockResolvedValue(confirmed()) - const { result, queryClient } = renderWithWallet(useHook) - const invalidateQueries = vi.spyOn(queryClient, "invalidateQueries") + it("refreshes the state it changed once confirmed", async () => { + method.mockResolvedValue(confirmed()) + const { result, queryClient } = renderWithWallet(useHook) + const invalidateQueries = vi.spyOn(queryClient, "invalidateQueries") - await act(() => result.current.submit(arg as never)) + await act(() => result.current.submit(arg as never)) - for (const queryKey of keys) - expect(invalidateQueries).toHaveBeenCalledWith({ queryKey }) - }) -}) + for (const queryKey of keys) + expect(invalidateQueries).toHaveBeenCalledWith({ queryKey }) + }) + }, +) diff --git a/app/src/hooks/useComplianceState.test.ts b/app/src/hooks/useComplianceState.test.ts new file mode 100644 index 00000000..a341b774 --- /dev/null +++ b/app/src/hooks/useComplianceState.test.ts @@ -0,0 +1,135 @@ +import { waitFor } from "@testing-library/react" +import { beforeEach, describe, expect, it, vi } from "vitest" +import { renderWithWallet } from "./testSupport" +import { useComplianceState } from "./useComplianceState" + +const { tokenMock, verifierMock, identityVerifierAtMock } = vi.hoisted(() => ({ + tokenMock: { + is_frozen: vi.fn(), + balance: vi.fn(), + get_frozen_tokens: vi.fn(), + }, + verifierMock: { is_allowed: vi.fn() }, + identityVerifierAtMock: vi.fn(), +})) + +vi.mock("../config/clients", () => ({ + shareToken: async () => tokenMock, + identityVerifierAt: identityVerifierAtMock, +})) + +const VERIFIER = "CVERIFIERADDRESS" +const TARGET = "GTARGETADDRESS1234" +const DESTINATION = "GDESTINATION5678" + +describe("useComplianceState", () => { + beforeEach(() => { + vi.clearAllMocks() + identityVerifierAtMock.mockImplementation(async () => verifierMock) + verifierMock.is_allowed.mockImplementation( + async ({ account }: { account: string }) => ({ + result: account === TARGET, + }), + ) + tokenMock.is_frozen.mockResolvedValue({ result: true }) + tokenMock.balance.mockResolvedValue({ result: 100n }) + tokenMock.get_frozen_tokens.mockResolvedValue({ result: 40n }) + }) + + it("reads nothing without a target", () => { + const { result } = renderWithWallet(() => + useComplianceState({ + verifier: VERIFIER, + target: undefined, + destination: undefined, + }), + ) + + expect(result.current.target).toEqual({ status: "none" }) + expect(tokenMock.balance).not.toHaveBeenCalled() + }) + + it("reads the target's allowlist, frozen state and balances", async () => { + const { result } = renderWithWallet(() => + useComplianceState({ + verifier: VERIFIER, + target: TARGET, + destination: undefined, + }), + ) + + expect(result.current.target).toEqual({ status: "checking" }) + await waitFor(() => + expect(result.current.target).toEqual({ + status: "ready", + reads: { + allowance: "allowed", + frozen: true, + shares: 100n, + frozenShares: 40n, + }, + }), + ) + expect(tokenMock.balance).toHaveBeenCalledWith({ account: TARGET }) + expect(tokenMock.get_frozen_tokens).toHaveBeenCalledWith({ + user_address: TARGET, + }) + }) + + it("leaves a failed read unknown without losing the others", async () => { + tokenMock.is_frozen.mockRejectedValue(new Error("rpc down")) + tokenMock.balance.mockResolvedValue({ + result: 0n, + simulation: { error: "HostError: Error(Contract, #300)" }, + }) + const { result } = renderWithWallet(() => + useComplianceState({ + verifier: VERIFIER, + target: TARGET, + destination: undefined, + }), + ) + + await waitFor(() => + expect(result.current.target).toEqual({ + status: "ready", + reads: { + allowance: "allowed", + frozen: null, + shares: null, + frozenShares: 40n, + }, + }), + ) + }) + + it("asks the token's own verifier, and leaves the allowlist unknown without one", async () => { + const { result } = renderWithWallet(() => + useComplianceState({ + verifier: null, + target: TARGET, + destination: undefined, + }), + ) + + await waitFor(() => + expect(result.current.target).toMatchObject({ + status: "ready", + reads: { allowance: "unreadable", frozen: true }, + }), + ) + expect(identityVerifierAtMock).not.toHaveBeenCalled() + }) + + it("reads whether the destination is allowlisted", async () => { + const { result } = renderWithWallet(() => + useComplianceState({ + verifier: VERIFIER, + target: undefined, + destination: DESTINATION, + }), + ) + + await waitFor(() => expect(result.current.destination).toBe("not-allowed")) + }) +}) diff --git a/app/src/hooks/useComplianceState.ts b/app/src/hooks/useComplianceState.ts new file mode 100644 index 00000000..ca5ff6d1 --- /dev/null +++ b/app/src/hooks/useComplianceState.ts @@ -0,0 +1,87 @@ +import { readContract } from "@stellar-scaffold/app-lib" +import { skipToken, useQuery } from "@tanstack/react-query" +import { identityVerifierAt, shareToken } from "../config/clients" +import { type Allowance, allowanceQuery, fetchAllowance } from "./useIsAllowed" +import { classifyShares } from "./useSharePosition" + +export const complianceReadsKey = ["compliance", "reads"] as const + +const REFRESH_MS = 15_000 + +export type TargetReads = { + allowance: Allowance + frozen: boolean | null + shares: bigint | null + frozenShares: bigint | null +} + +export type TargetState = + | { status: "none" } + | { status: "checking" } + | { status: "ready"; reads: TargetReads } + +const sharesOf = (position: ReturnType) => + position.status === "held" ? position.shares : null + +async function fetchTargetReads( + verifier: string | null, + account: string, +): Promise { + const token = shareToken() + const allowed: Promise = + verifier === null + ? Promise.resolve("unreadable") + : fetchAllowance(identityVerifierAt(verifier), account) + const [allowance, frozen, shares, frozenShares] = await Promise.all([ + allowed, + readContract(async () => + (await token).is_frozen({ user_address: account }), + ), + readContract(async () => (await token).balance({ account })), + readContract(async () => + (await token).get_frozen_tokens({ user_address: account }), + ), + ]) + return { + allowance, + frozen: frozen.kind === "value" ? frozen.value : null, + shares: sharesOf(classifyShares(shares)), + frozenShares: sharesOf(classifyShares(frozenShares)), + } +} + +export function useComplianceState({ + verifier, + target, + destination, + active = true, +}: { + verifier: string | null + target: string | undefined + destination: string | undefined + active?: boolean +}): { target: TargetState; destination: Allowance } { + const { data } = useQuery({ + queryKey: [...complianceReadsKey, verifier, target], + queryFn: + target === undefined + ? skipToken + : () => fetchTargetReads(verifier, target), + staleTime: 30_000, + refetchInterval: active ? REFRESH_MS : false, + }) + const { data: destinationAllowance } = useQuery( + allowanceQuery(verifier, destination), + ) + + const targetState: TargetState = + target === undefined + ? { status: "none" } + : data === undefined + ? { status: "checking" } + : { status: "ready", reads: data } + return { + target: targetState, + destination: destinationAllowance ?? "checking", + } +} diff --git a/app/src/hooks/useComplianceTransactions.ts b/app/src/hooks/useComplianceTransactions.ts new file mode 100644 index 00000000..820d51a0 --- /dev/null +++ b/app/src/hooks/useComplianceTransactions.ts @@ -0,0 +1,67 @@ +import { type Signer } from "@stellar-scaffold/app-lib" +import { identityVerifierWriter, shareTokenWriter } from "../config/clients" +import { adminTransaction } from "./useAdminTransaction" +import { complianceReadsKey } from "./useComplianceState" +import { isAllowedKey } from "./useIsAllowed" + +export type AllowanceChange = { verifier: string; account: string } +export type SharesChange = { account: string; amount: bigint } +export type ForcedTransfer = { from: string; to: string; amount: bigint } + +const SHARE_BALANCES_KEY = ["share", "balance"] as const + +const allowCall = + (allowed: boolean) => + (signer: Signer, { verifier, account }: AllowanceChange) => + identityVerifierWriter(verifier)(signer).then((client) => + client.allow({ account, allowed, caller: signer.publicKey }), + ) + +const freezeAddressCall = + (freeze: boolean) => (signer: Signer, account: string) => + shareTokenWriter(signer).then((token) => + token.set_address_frozen({ + user_address: account, + freeze, + operator: signer.publicKey, + }), + ) + +const sharesCall = + (method: "freeze_partial_tokens" | "unfreeze_partial_tokens") => + (signer: Signer, { account, amount }: SharesChange) => + shareTokenWriter(signer).then((token) => + token[method]({ + user_address: account, + amount, + operator: signer.publicKey, + }), + ) + +const forcedTransferCall = (signer: Signer, transfer: ForcedTransfer) => + shareTokenWriter(signer).then((token) => + token.forced_transfer({ ...transfer, operator: signer.publicKey }), + ) + +const allowKeys = [complianceReadsKey, isAllowedKey] + +export const useAllowAddress = adminTransaction(allowCall(true), allowKeys) +export const useDisallowAddress = adminTransaction(allowCall(false), allowKeys) +export const useFreezeAddress = adminTransaction(freezeAddressCall(true), [ + complianceReadsKey, +]) +export const useUnfreezeAddress = adminTransaction(freezeAddressCall(false), [ + complianceReadsKey, +]) +export const useFreezeShares = adminTransaction( + sharesCall("freeze_partial_tokens"), + [complianceReadsKey], +) +export const useUnfreezeShares = adminTransaction( + sharesCall("unfreeze_partial_tokens"), + [complianceReadsKey], +) +export const useForceTransfer = adminTransaction(forcedTransferCall, [ + complianceReadsKey, + SHARE_BALANCES_KEY, +]) diff --git a/app/src/hooks/useIsAllowed.ts b/app/src/hooks/useIsAllowed.ts index d315ae67..550e45e0 100644 --- a/app/src/hooks/useIsAllowed.ts +++ b/app/src/hooks/useIsAllowed.ts @@ -1,6 +1,10 @@ -import { type ContractRead, readContract } from "@stellar-scaffold/app-lib" -import { skipToken, useQuery } from "@tanstack/react-query" -import { identityVerifier } from "../config/clients" +import { + type ContractRead, + type IdentityVerifierApi, + readContract, +} from "@stellar-scaffold/app-lib" +import { queryOptions, skipToken, useQuery } from "@tanstack/react-query" +import { identityVerifier, identityVerifierAt } from "../config/clients" import { useWallet } from "./useWallet" export const isAllowedKey = ["identity", "is-allowed"] as const @@ -12,18 +16,37 @@ export function classifyAllowance(read: ContractRead): Allowance { return read.value ? "allowed" : "not-allowed" } -async function fetchAllowance(account: string): Promise { +export async function fetchAllowance( + verifier: Promise, + account: string, +): Promise { const read = await readContract(async () => - (await identityVerifier()).is_allowed({ account }), + (await verifier).is_allowed({ account }), ) return classifyAllowance(read) } +export const allowanceQuery = ( + verifier: string | null, + account: string | undefined, +) => + queryOptions({ + queryKey: [...isAllowedKey, verifier, account] as const, + queryFn: + verifier === null || account === undefined + ? skipToken + : () => fetchAllowance(identityVerifierAt(verifier), account), + staleTime: 30_000, + }) + export function useIsAllowed(): { allowance: Allowance } { const { address } = useWallet() const { data } = useQuery({ queryKey: [...isAllowedKey, address], - queryFn: address === undefined ? skipToken : () => fetchAllowance(address), + queryFn: + address === undefined + ? skipToken + : () => fetchAllowance(identityVerifier(), address), staleTime: 30_000, }) diff --git a/app/src/pages/AdminPanel.test.tsx b/app/src/pages/AdminPanel.test.tsx index 4a7a704f..8cd0fccc 100644 --- a/app/src/pages/AdminPanel.test.tsx +++ b/app/src/pages/AdminPanel.test.tsx @@ -46,6 +46,25 @@ vi.mock("../components/admin/CycleSection", () => ({ ), })) +vi.mock("../components/admin/ComplianceSection", () => ({ + default: ({ + grants, + contracts, + active, + }: { + grants: Grant[] + contracts: React.ReactNode + active: boolean + }) => ( +
+

+ Compliance surface for {grants.map((grant) => grant.role).join(", ")} +

+ {contracts} +
+ ), +})) + vi.mock("../components/admin/EmergencySection", () => ({ default: ({ grants, @@ -133,6 +152,7 @@ const noHolders: RoleHolders = { "oracle guardian": [], "oracle admin": [], "share-token compliance": [], + "identity-verifier admin": [], "share-token manager": [], "share-token admin": [], "vault pending admin": [], @@ -290,6 +310,37 @@ describe("AdminPanel", () => { ).toBeTruthy() }) + it("renders the compliance actions with the compliance grants, active only on its tab", () => { + const manager = holderOf("vault manager") + const officer = holderOf("share-token compliance") + const verifierAdmin = holderOf("identity-verifier admin") + useAdminAuthorityMock.mockReturnValue( + authority({ + status: "ready", + surfaces: new Set(["cycle", "compliance"]), + grantedBy: { + ...emptyGrantedBy, + cycle: [manager], + compliance: [officer, verifierAdmin], + }, + grants: [manager, officer, verifierAdmin], + }), + ) + renderAt("/admin/compliance") + + expect( + screen.getByText( + "Compliance surface for share-token compliance, identity-verifier admin", + ), + ).toBeTruthy() + expect( + screen.getByTestId("compliance-surface").getAttribute("data-active"), + ).toBe("true") + expect( + screen.getByTestId("cycle-surface").getAttribute("data-active"), + ).toBe("false") + }) + it("renders the emergency actions with the emergency grants, active only on its tab", () => { const manager = holderOf("vault manager") const guardian = holderOf("vault guardian") diff --git a/app/src/pages/AdminPanel.tsx b/app/src/pages/AdminPanel.tsx index 86239ca9..a7a4cb7f 100644 --- a/app/src/pages/AdminPanel.tsx +++ b/app/src/pages/AdminPanel.tsx @@ -1,6 +1,7 @@ import { shortAddress } from "@stellar-scaffold/app-lib" import React, { useEffect, useRef } from "react" import { Navigate, useParams } from "react-router-dom" +import ComplianceSection from "../components/admin/ComplianceSection" import ConfigurationSection from "../components/admin/ConfigurationSection" import CycleSection from "../components/admin/CycleSection" import Disclosure from "../components/admin/Disclosure" @@ -111,7 +112,15 @@ const SurfaceBody: React.FC<{ /> ) case "compliance": - return contracts + return ( + + ) } } diff --git a/app/src/pages/actions.test.ts b/app/src/pages/actions.test.ts index 3102f97d..cbc1bed4 100644 --- a/app/src/pages/actions.test.ts +++ b/app/src/pages/actions.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from "vitest" import { type Grant } from "../hooks/useAdminAuthority" import { grantFor, + parseAccount, type RailAction, readiness, readyCount, @@ -211,3 +212,19 @@ describe("toAction", () => { ) }) }) + +describe("parseAccount", () => { + const account = "GDPO5SQDJY4E2XEE47MAOT2M2F3GR4H2AYYKSMIOT2YPOHAMC36VKWIA" + const contract = "CADQOBYHA4DQOBYHA4DQOBYHA4DQOBYHA4DQOBYHA4DQOBYHA4DQP5KR" + + it.each([account, contract])("accepts %s, trimmed", (address) => { + expect(parseAccount(` ${address} `)).toBe(address) + }) + + it.each(["", "GABC", `${account.slice(0, -1)}B`, "SDPO5SQDJY4E2XEE"])( + "rejects %j", + (text) => { + expect(parseAccount(text)).toBeUndefined() + }, + ) +}) diff --git a/app/src/pages/actions.ts b/app/src/pages/actions.ts index ea5d5318..a394627b 100644 --- a/app/src/pages/actions.ts +++ b/app/src/pages/actions.ts @@ -1,3 +1,4 @@ +import { StrKey } from "@stellar/stellar-sdk" import { shortAddress } from "@stellar-scaffold/app-lib" import { type Grant, type Standing } from "../hooks/useAdminAuthority" @@ -7,8 +8,18 @@ export const ROLES = { oracleGuardian: "oracle guardian", oracleAdmin: "oracle admin", tokenAdmin: "share-token admin", + compliance: "share-token compliance", + verifierAdmin: "identity-verifier admin", } as const +export const parseAccount = (text: string): string | undefined => { + const trimmed = text.trim() + return StrKey.isValidEd25519PublicKey(trimmed) || + StrKey.isValidContract(trimmed) + ? trimmed + : undefined +} + export type Condition = { label: string met: boolean | null @@ -141,3 +152,22 @@ export const toAction = ( ...(unavailable === undefined ? {} : { unavailable }), } } + +export type Inputs = Partial> + +export const confirmKey = (id: string): string => `${id}-confirm` + +export type Confirmation = { field: ConfirmField; condition: Condition } + +export function confirmation( + key: string, + label: string, + value: string, + inputs: Inputs, +): Confirmation { + const checked = inputs[key] === value + return { + field: { kind: "confirm", key, label, value, checked }, + condition: { label: "Confirmed", met: checked }, + } +} diff --git a/app/src/pages/compliance.test.ts b/app/src/pages/compliance.test.ts new file mode 100644 index 00000000..14f493d9 --- /dev/null +++ b/app/src/pages/compliance.test.ts @@ -0,0 +1,342 @@ +import { describe, expect, it, vi } from "vitest" +import { type Grant } from "../hooks/useAdminAuthority" +import { type TargetReads } from "../hooks/useComplianceState" +import { + type ComplianceAction, + type ComplianceState, + toComplianceActions, + toComplianceTiles, +} from "./compliance" + +vi.mock("@stellar-scaffold/app-lib", async (importOriginal) => ({ + ...(await importOriginal()), + shortAddress: (address: string) => + `${address.slice(0, 4)}...${address.slice(-4)}`, +})) + +const WALLET = "GWALLETADDRESS1234" +const HOLDER = "GDPO5SQDJY4E2XEE47MAOT2M2F3GR4H2AYYKSMIOT2YPOHAMC36VKWIA" +const VAULT = "CADQOBYHA4DQOBYHA4DQOBYHA4DQOBYHA4DQOBYHA4DQOBYHA4DQP5KR" +const DESTINATION = "CABQGAYDAMBQGAYDAMBQGAYDAMBQGAYDAMBQGAYDAMBQGAYDAMBQGCK3" +const VERIFIER = "CVERIFIERADDRESS" +const UNIT = 10_000_000n + +const grantOf = (role: string): Grant => ({ + role, + authority: WALLET, + standing: "signs-alone", +}) + +const verifierAdmin = grantOf("identity-verifier admin") +const officer = grantOf("share-token compliance") + +const reads = (overrides: Partial = {}): TargetReads => ({ + allowance: "allowed", + frozen: false, + shares: 100n * UNIT, + frozenShares: 10n * UNIT, + ...overrides, +}) + +const stateOf = ( + target: string | undefined, + overrides: Partial = {}, + destination: ComplianceState["destination"] = "allowed", +): ComplianceState => ({ + verifier: VERIFIER, + target, + reads: + target === undefined + ? { status: "none" } + : { status: "ready", reads: reads(overrides) }, + vault: VAULT, + destination, +}) + +const actionOf = ( + state: ComplianceState, + id: ComplianceAction["id"], + inputs: Record = {}, + grants = [verifierAdmin, officer], +): ComplianceAction => { + const action = toComplianceActions(state, grants, WALLET, inputs).find( + (candidate) => candidate.id === id, + ) + if (action === undefined) throw new Error(`no ${id} action`) + return action +} + +const unmet = (action: ComplianceAction) => + action.conditions + .filter((condition) => condition.met !== true) + .map(({ label, met }) => [label, met]) + +describe("toComplianceActions", () => { + it("offers the allowlist only to the verifier admin and the freezes only to the compliance role", () => { + const ids = (grants: Grant[]) => + toComplianceActions(stateOf(HOLDER), grants, WALLET, {}).map( + (action) => action.id, + ) + + expect(ids([verifierAdmin])).toEqual(["allow-address", "disallow-address"]) + expect(ids([officer])).toEqual([ + "freeze-address", + "unfreeze-address", + "freeze-shares", + "unfreeze-shares", + "force-transfer", + ]) + expect(ids([grantOf("share-token admin")])).toEqual([]) + }) + + it("blocks every action until a target address is entered", () => { + const actions = toComplianceActions( + stateOf(undefined), + [verifierAdmin, officer], + WALLET, + {}, + ) + + for (const action of actions) { + expect(action.enabled).toBe(false) + expect(action.conditions[0]).toEqual({ + label: "Target address entered", + met: null, + }) + } + }) + + it("allows a target that is not allowlisted, showing the resulting state", () => { + const action = actionOf( + stateOf(HOLDER, { allowance: "not-allowed" }), + "allow-address", + ) + + expect(action.enabled).toBe(true) + expect(action.outcome).toBe("Allowlisted No → Yes.") + expect(action.arg).toEqual({ verifier: VERIFIER, account: HOLDER }) + }) + + it("reads an already-allowlisted target as no change", () => { + const action = actionOf(stateOf(HOLDER), "allow-address") + + expect(action.enabled).toBe(false) + expect(action.outcome).toBeNull() + expect(unmet(action)).toEqual([["Not allowlisted", false]]) + }) + + it("holds nothing back while the target state is unknown", () => { + const action = actionOf( + stateOf(HOLDER, { allowance: "unreadable", frozen: null }), + "freeze-address", + ) + + expect(action.enabled).toBe(true) + expect(action.outcome).toBeNull() + }) + + it("freezes and unfreezes the address on its current state", () => { + expect(actionOf(stateOf(HOLDER), "freeze-address").outcome).toBe( + "Frozen No → Yes.", + ) + expect(actionOf(stateOf(HOLDER), "unfreeze-address").enabled).toBe(false) + expect( + actionOf(stateOf(HOLDER, { frozen: true }), "unfreeze-address").outcome, + ).toBe("Frozen Yes → No.") + }) + + it.each<[ComplianceAction["id"], string]>([ + ["disallow-address", "Stops the vault receiving shares."], + ["freeze-address", "Stops the vault moving shares."], + ])("asks to confirm %s on the vault", (id, label) => { + const state = stateOf(VAULT, { frozen: false, allowance: "allowed" }) + const unconfirmed = actionOf(state, id) + + expect(unconfirmed.enabled).toBe(false) + expect(unconfirmed.tone).toBe("danger") + expect(unconfirmed.fields).toEqual([ + { + kind: "confirm", + key: `${id}-confirm`, + label, + value: VAULT, + checked: false, + }, + ]) + expect(actionOf(state, id, { [`${id}-confirm`]: VAULT }).enabled).toBe(true) + }) + + it("needs no confirmation for another address", () => { + const action = actionOf(stateOf(HOLDER), "disallow-address") + + expect(action.fields).toBeUndefined() + expect(action.tone).toBeUndefined() + expect(action.enabled).toBe(true) + }) + + it("freezes an exact amount of free shares", () => { + const action = actionOf(stateOf(HOLDER), "freeze-shares", { + "freeze-shares-amount": "5.1234567", + }) + + expect(action.enabled).toBe(true) + expect(action.outcome).toBe("Frozen shares 10.00 → 15.1234567.") + expect(action.arg).toEqual({ account: HOLDER, amount: 51_234_567n }) + expect(action.conditions[2]).toEqual({ + label: "Amount ≤ free shares", + met: true, + detail: "90.00", + }) + expect(action.fields?.[0]).toMatchObject({ max: "90.00", invalid: false }) + }) + + it("refuses to freeze more than the free shares", () => { + const action = actionOf(stateOf(HOLDER), "freeze-shares", { + "freeze-shares-amount": "90.0000001", + }) + + expect(action.enabled).toBe(false) + expect(action.outcome).toBeNull() + expect(unmet(action)).toEqual([["Amount ≤ free shares", false]]) + }) + + it.each(["0", "-1", "abc"])("blocks the amount %j", (text) => { + const action = actionOf(stateOf(HOLDER), "freeze-shares", { + "freeze-shares-amount": text, + }) + + expect(action.enabled).toBe(false) + expect(action.arg).toBeUndefined() + }) + + it("unfreezes at most the frozen shares", () => { + const within = actionOf(stateOf(HOLDER), "unfreeze-shares", { + "unfreeze-shares-amount": "10", + }) + const over = actionOf(stateOf(HOLDER), "unfreeze-shares", { + "unfreeze-shares-amount": "10.0000001", + }) + + expect(within.outcome).toBe("Frozen shares 10.00 → 0.00.") + expect(over.enabled).toBe(false) + expect(unmet(over)).toEqual([["Amount ≤ frozen shares", false]]) + }) + + it("asks to confirm freezing the vault's shares, tied to the amount", () => { + const inputs = { "freeze-shares-amount": "5" } + const state = stateOf(VAULT) + + expect(actionOf(state, "freeze-shares", inputs).enabled).toBe(false) + expect( + actionOf(state, "freeze-shares", { + ...inputs, + "freeze-shares-confirm": `${VAULT}:5`, + }).enabled, + ).toBe(true) + expect( + actionOf(state, "freeze-shares", { + "freeze-shares-amount": "6", + "freeze-shares-confirm": `${VAULT}:5`, + }).enabled, + ).toBe(false) + }) + + it("forces a transfer within the balance, unfreezing the shortfall", () => { + const inputs = { + "force-transfer-destination": DESTINATION, + "force-transfer-amount": "95", + } + const action = actionOf(stateOf(HOLDER), "force-transfer", inputs) + + expect(action.enabled).toBe(true) + expect(action.outcome).toBe( + "Shares 100.00 → 5.00. Frozen shares 10.00 → 5.00.", + ) + expect(action.arg).toEqual({ + from: HOLDER, + to: DESTINATION, + amount: 95n * UNIT, + }) + expect(action.note).toBeUndefined() + }) + + it("refuses a transfer above the balance and waits for a destination", () => { + const over = actionOf(stateOf(HOLDER), "force-transfer", { + "force-transfer-destination": DESTINATION, + "force-transfer-amount": "100.0000001", + }) + const missing = actionOf(stateOf(HOLDER), "force-transfer", { + "force-transfer-amount": "1", + }) + + expect(unmet(over)).toEqual([["Amount ≤ shares", false]]) + expect(over.enabled).toBe(false) + expect(unmet(missing)).toEqual([["Destination entered", null]]) + expect(missing.enabled).toBe(false) + }) + + it("notes a destination that is not allowlisted", () => { + const action = actionOf( + stateOf(HOLDER, {}, "not-allowed"), + "force-transfer", + { + "force-transfer-destination": DESTINATION, + "force-transfer-amount": "1", + }, + ) + + expect(action.enabled).toBe(true) + expect(action.note).toBe( + "The destination is not allowlisted and cannot move them.", + ) + }) + + it("asks to confirm a transfer out of the vault", () => { + const action = actionOf(stateOf(VAULT), "force-transfer", { + "force-transfer-destination": DESTINATION, + "force-transfer-amount": "1", + }) + + expect(action.enabled).toBe(false) + expect(action.fields?.at(-1)).toMatchObject({ + kind: "confirm", + label: "Drains the vault's redemption escrow.", + }) + }) +}) + +describe("toComplianceTiles", () => { + it("shows nothing without a target and pending tiles while reading", () => { + expect(toComplianceTiles({ status: "none" })).toEqual([]) + expect( + toComplianceTiles({ status: "checking" }).every((tile) => tile.pending), + ).toBe(true) + }) + + it("shows the allowlist, frozen state and the free balance", () => { + expect( + toComplianceTiles({ status: "ready", reads: reads({ frozen: true }) }), + ).toEqual([ + { label: "Allowlisted", value: "Yes" }, + { label: "Frozen", value: "Yes" }, + { label: "Shares", value: "100.00" }, + { label: "Frozen shares", value: "10.00" }, + { label: "Free shares", value: "90.00" }, + ]) + }) + + it("leaves an unreadable figure empty", () => { + const tiles = toComplianceTiles({ + status: "ready", + reads: reads({ allowance: "unreadable", shares: null }), + }) + + expect(tiles.map((tile) => tile.value)).toEqual([ + null, + "No", + null, + "10.00", + null, + ]) + }) +}) diff --git a/app/src/pages/compliance.ts b/app/src/pages/compliance.ts new file mode 100644 index 00000000..306034f1 --- /dev/null +++ b/app/src/pages/compliance.ts @@ -0,0 +1,467 @@ +import { + AMOUNT_DECIMALS, + type Amount, + parseUnits, + shortAddress, +} from "@stellar-scaffold/app-lib" +import { type Metric } from "../components/vault/MetricsStrip" +import { type Grant } from "../hooks/useAdminAuthority" +import { type TargetReads, type TargetState } from "../hooks/useComplianceState" +import { + type AllowanceChange, + type ForcedTransfer, + type SharesChange, +} from "../hooks/useComplianceTransactions" +import { type Allowance } from "../hooks/useIsAllowed" +import { + allMet, + type Condition, + type Confirmation, + confirmation, + confirmKey, + grantFor, + type Inputs, + parseAccount, + type RailAction, + type RailField, + ROLES, + type Spec, + toAction, +} from "./actions" +import { exactAmount } from "./cycle" + +type AllowActionId = "allow-address" | "disallow-address" +type FreezeActionId = "freeze-address" | "unfreeze-address" +type AddressActionId = AllowActionId | FreezeActionId +type SharesActionId = "freeze-shares" | "unfreeze-shares" + +export type ComplianceActionId = + AddressActionId | SharesActionId | "force-transfer" + +export const COMPLIANCE_ACTION_IDS: ComplianceActionId[] = [ + "allow-address", + "disallow-address", + "freeze-address", + "unfreeze-address", + "freeze-shares", + "unfreeze-shares", + "force-transfer", +] + +type Built = Id extends string + ? RailAction & { id: Id; arg: Arg; next: string } + : never + +export type ComplianceAction = + | Built + | Built + | Built + | Built<"force-transfer", ForcedTransfer | undefined> + +export type ComplianceState = { + verifier: string | null + target: string | undefined + reads: TargetState + vault: string + destination: Allowance +} + +type BuiltSpec = Spec & { + arg: Arg + next: string +} + +const DESTINATION_KEY = "force-transfer-destination" + +export const destinationOf = (inputs: Inputs): string | undefined => + parseAccount(inputs[DESTINATION_KEY] ?? "") + +const exact = (value: bigint): string => exactAmount(value as Amount) + +const yesNo = (value: boolean): string => (value ? "Yes" : "No") + +const targetEntered = (target: string | undefined): Condition => ({ + label: "Target address entered", + met: target === undefined ? null : true, +}) + +const readsOf = (state: ComplianceState): TargetReads | undefined => + state.reads.status === "ready" ? state.reads.reads : undefined + +const allowedOf = (allowance: Allowance | undefined): boolean | null => + allowance === "allowed" || allowance === "not-allowed" + ? allowance === "allowed" + : null + +const freeOf = (reads: TargetReads | undefined): bigint | null => { + if (reads?.shares == null || reads.frozenShares === null) return null + const free = reads.shares - reads.frozenShares + return free < 0n ? 0n : free +} + +const vaultConfirmation = ( + id: ComplianceActionId, + label: string, + state: ComplianceState, + inputs: Inputs, + detail = "", +): Confirmation | undefined => + state.target !== undefined && state.target === state.vault + ? confirmation(confirmKey(id), label, `${state.target}${detail}`, inputs) + : undefined + +type Toggle = { + id: Id + label: string + condition: string + subject: string + wanted: boolean + warning?: string +} + +const TOGGLES: { [Id in AddressActionId]: Toggle } = { + "allow-address": { + id: "allow-address", + label: "Allow the address", + condition: "Not allowlisted", + subject: "Allowlisted", + wanted: true, + }, + "disallow-address": { + id: "disallow-address", + label: "Remove from the allowlist", + condition: "Allowlisted", + subject: "Allowlisted", + wanted: false, + warning: "Stops the vault receiving shares.", + }, + "freeze-address": { + id: "freeze-address", + label: "Freeze the address", + condition: "Not frozen", + subject: "Frozen", + wanted: true, + warning: "Stops the vault moving shares.", + }, + "unfreeze-address": { + id: "unfreeze-address", + label: "Unfreeze the address", + condition: "Frozen", + subject: "Frozen", + wanted: false, + }, +} + +function toggleSpec( + toggle: Toggle, + current: boolean | null, + state: ComplianceState, + inputs: Inputs, +): BuiltSpec { + const { target } = state + const entered = targetEntered(target) + const changes = current === null ? null : current !== toggle.wanted + const confirm = + toggle.warning === undefined + ? undefined + : vaultConfirmation(toggle.id, toggle.warning, state, inputs) + const required = + confirm === undefined ? [entered] : [entered, confirm.condition] + return { + id: toggle.id, + label: toggle.label, + conditions: [ + entered, + { label: toggle.condition, met: changes, unchanged: true }, + ...(confirm === undefined ? [] : [confirm.condition]), + ], + outcome: + current === null || changes !== true + ? null + : `${toggle.subject} ${yesNo(current)} → ${yesNo(toggle.wanted)}.`, + ...(confirm === undefined ? {} : { fields: [confirm.field], danger: true }), + ready: allMet(required), + arg: target, + next: target === undefined ? "" : shortAddress(target), + } +} + +function allowSpec( + toggle: Toggle, + current: boolean | null, + state: ComplianceState, + inputs: Inputs, +): BuiltSpec { + const spec = toggleSpec(toggle, current, state, inputs) + const { verifier } = state + return { + ...spec, + arg: + spec.arg === undefined || verifier === null + ? undefined + : { verifier, account: spec.arg }, + } +} + +type AmountEntry = { + text: string + amount: bigint | undefined + field: RailField +} + +const amountEntry = ( + key: string, + inputs: Inputs, + max: bigint | null, +): AmountEntry => { + const text = inputs[key] ?? "" + const amount = parseUnits(text, AMOUNT_DECIMALS) ?? undefined + return { + text, + amount, + field: { + key, + label: "Amount", + placeholder: "0.00", + value: text, + invalid: text.trim() !== "" && amount === undefined, + ...(max === null || max <= 0n ? {} : { max: exact(max) }), + }, + } +} + +const positive = ({ text, amount }: AmountEntry): Condition => ({ + label: "Amount above 0", + met: text.trim() === "" ? null : amount !== undefined && amount > 0n, +}) + +const atMost = ( + label: string, + amount: bigint | undefined, + limit: bigint | null, +): Condition => ({ + label, + met: amount === undefined || limit === null ? null : amount <= limit, + ...(limit === null ? {} : { detail: exact(limit) }), +}) + +function sharesSpec( + id: SharesActionId, + state: ComplianceState, + inputs: Inputs, +): BuiltSpec { + const { target } = state + const reads = readsOf(state) + const frozen = reads?.frozenShares ?? null + const freezing = id === "freeze-shares" + const limit = freezing ? freeOf(reads) : frozen + const entry = amountEntry(`${id}-amount`, inputs, limit) + const { amount } = entry + const entered = targetEntered(target) + const bound = atMost( + freezing ? "Amount ≤ free shares" : "Amount ≤ frozen shares", + amount, + limit, + ) + const confirm = freezing + ? vaultConfirmation( + id, + "Locks shares the vault holds.", + state, + inputs, + `:${entry.text}`, + ) + : undefined + const required = [ + entered, + positive(entry), + ...(confirm ? [confirm.condition] : []), + ] + const fields: RailField[] = [entry.field, ...(confirm ? [confirm.field] : [])] + const valid = target !== undefined && amount !== undefined && amount > 0n + return { + id, + label: freezing ? "Freeze shares" : "Unfreeze shares", + conditions: [ + entered, + positive(entry), + bound, + ...(confirm ? [confirm.condition] : []), + ], + outcome: + !valid || frozen === null || bound.met !== true + ? null + : `Frozen shares ${exact(frozen)} → ${exact(freezing ? frozen + amount : frozen - amount)}.`, + fields, + ...(confirm === undefined ? {} : { danger: true }), + ready: allMet(required), + arg: valid ? { account: target, amount } : undefined, + next: valid ? `${exact(amount)} shares of ${shortAddress(target)}` : "", + } +} + +const transferOutcome = ( + reads: TargetReads | undefined, + amount: bigint, +): string | null => { + const free = freeOf(reads) + if (reads?.shares == null || reads.frozenShares === null || free === null) + return null + const shares = `Shares ${exact(reads.shares)} → ${exact(reads.shares - amount)}.` + if (amount <= free) return shares + const unfrozen = reads.frozenShares - (amount - free) + return `${shares} Frozen shares ${exact(reads.frozenShares)} → ${exact(unfrozen)}.` +} + +function forceTransferSpec( + state: ComplianceState, + inputs: Inputs, +): BuiltSpec<"force-transfer", ForcedTransfer | undefined> { + const id = "force-transfer" + const { target } = state + const reads = readsOf(state) + const shares = reads?.shares ?? null + const destinationText = inputs[DESTINATION_KEY] ?? "" + const destination = destinationOf(inputs) + const entry = amountEntry(`${id}-amount`, inputs, shares) + const { amount } = entry + const entered = targetEntered(target) + const destinationEntered: Condition = { + label: "Destination entered", + met: destinationText.trim() === "" ? null : destination !== undefined, + } + const bound = atMost("Amount ≤ shares", amount, shares) + const confirm = vaultConfirmation( + id, + "Drains the vault's redemption escrow.", + state, + inputs, + `:${destinationText}:${entry.text}`, + ) + const required = [ + entered, + destinationEntered, + positive(entry), + ...(confirm ? [confirm.condition] : []), + ] + const valid = + target !== undefined && + destination !== undefined && + amount !== undefined && + amount > 0n + return { + id, + label: "Force a transfer", + conditions: [ + entered, + destinationEntered, + positive(entry), + bound, + ...(confirm ? [confirm.condition] : []), + ], + outcome: + !valid || bound.met !== true ? null : transferOutcome(reads, amount), + ...(destination !== undefined && state.destination === "not-allowed" + ? { note: "The destination is not allowlisted and cannot move them." } + : {}), + fields: [ + { + key: DESTINATION_KEY, + label: "Destination", + placeholder: "Address", + value: destinationText, + invalid: destinationText.trim() !== "" && destination === undefined, + text: true, + }, + entry.field, + ...(confirm ? [confirm.field] : []), + ], + ...(confirm === undefined ? {} : { danger: true }), + ready: allMet(required), + arg: valid ? { from: target, to: destination, amount } : undefined, + next: valid + ? `${exact(amount)} shares from ${shortAddress(target)} to ${shortAddress(destination)}` + : "", + } +} + +const build = ( + spec: BuiltSpec, + grant: Grant, + wallet: string, +) => ({ ...toAction(spec, grant, wallet), arg: spec.arg, next: spec.next }) + +export function toComplianceActions( + state: ComplianceState, + grants: Grant[], + wallet: string, + inputs: Inputs, +): ComplianceAction[] { + const reads = readsOf(state) + const allowed = allowedOf(reads?.allowance) + const frozen = reads?.frozen ?? null + const actions: ComplianceAction[] = [] + const admin = grantFor(grants, ROLES.verifierAdmin, wallet) + if (admin !== undefined) + actions.push( + build( + allowSpec(TOGGLES["allow-address"], allowed, state, inputs), + admin, + wallet, + ), + build( + allowSpec(TOGGLES["disallow-address"], allowed, state, inputs), + admin, + wallet, + ), + ) + const officer = grantFor(grants, ROLES.compliance, wallet) + if (officer !== undefined) + actions.push( + build( + toggleSpec(TOGGLES["freeze-address"], frozen, state, inputs), + officer, + wallet, + ), + build( + toggleSpec(TOGGLES["unfreeze-address"], frozen, state, inputs), + officer, + wallet, + ), + build(sharesSpec("freeze-shares", state, inputs), officer, wallet), + build(sharesSpec("unfreeze-shares", state, inputs), officer, wallet), + build(forceTransferSpec(state, inputs), officer, wallet), + ) + return actions +} + +const ALLOWANCE_LABELS: Record = { + checking: null, + unreadable: null, + allowed: "Yes", + "not-allowed": "No", +} + +const tile = (label: string, value: string | null): Metric => ({ label, value }) + +export function toComplianceTiles(reads: TargetState): Metric[] { + const labels = [ + "Allowlisted", + "Frozen", + "Shares", + "Frozen shares", + "Free shares", + ] + if (reads.status === "none") return [] + if (reads.status === "checking") + return labels.map((label) => ({ label, value: null, pending: true })) + const { allowance, frozen, shares, frozenShares } = reads.reads + const free = freeOf(reads.reads) + return [ + tile("Allowlisted", ALLOWANCE_LABELS[allowance]), + tile("Frozen", frozen === null ? null : yesNo(frozen)), + tile("Shares", shares === null ? null : exact(shares)), + tile("Frozen shares", frozenShares === null ? null : exact(frozenShares)), + tile("Free shares", free === null ? null : exact(free)), + ] +} diff --git a/app/src/pages/governance.test.ts b/app/src/pages/governance.test.ts index 32912cc1..9c6ad26a 100644 --- a/app/src/pages/governance.test.ts +++ b/app/src/pages/governance.test.ts @@ -38,6 +38,7 @@ const holders = (overrides: Partial = {}): RoleHolders => ({ "oracle guardian": [], "oracle admin": [WALLET], "share-token compliance": [], + "identity-verifier admin": [], "share-token manager": [VAULT], "share-token admin": [WALLET], "vault pending admin": [], diff --git a/app/src/pages/governance.ts b/app/src/pages/governance.ts index f312997a..ce811900 100644 --- a/app/src/pages/governance.ts +++ b/app/src/pages/governance.ts @@ -1,4 +1,3 @@ -import { StrKey } from "@stellar/stellar-sdk" import { type PendingTransfer, shortAddress } from "@stellar-scaffold/app-lib" import { type Metric } from "../components/vault/MetricsStrip" import { @@ -18,7 +17,11 @@ import { import { allMet, type Condition, + type Confirmation, + confirmation, + confirmKey, grantFor, + parseAccount, type RailAction, type RailField, ROLES, @@ -65,8 +68,6 @@ export const GOVERNANCE_ACTION_IDS: GovernanceActionId[] = [ ...VAULT_ACTION_IDS, ] -export const confirmKey = (id: GovernanceActionId): string => `${id}-confirm` - type Built = Id extends string ? RailAction & { id: Id; arg: Arg; next: string } : never @@ -172,14 +173,6 @@ const WARNINGS = { mergedRoles: "Merges two vault roles on one account.", } as const -const parseAccount = (text: string): string | undefined => { - const trimmed = text.trim() - return StrKey.isValidEd25519PublicKey(trimmed) || - StrKey.isValidContract(trimmed) - ? trimmed - : undefined -} - const holderList = (accounts: string[]): string => accounts.length === 0 ? "none" : accounts.map(shortAddress).join(", ") @@ -234,21 +227,6 @@ const resultingHolders = ({ : current.filter((holder) => holder !== account) } -type Confirmation = { field: RailField; condition: Condition } - -function confirmation( - key: string, - label: string, - value: string, - inputs: GovernanceInputs, -): Confirmation { - const checked = inputs[key] === value - return { - field: { kind: "confirm", key, label, value, checked }, - condition: { label: "Confirmed", met: checked }, - } -} - const roleConfirmation = ( draft: Draft, key: string, From 1f4147b8f1ab9e4aca1b8077f2581deed6435175 Mon Sep 17 00:00:00 2001 From: luchobonatti Date: Wed, 14 Oct 2026 11:37:22 -0300 Subject: [PATCH 02/12] fix(app): archive zero-TTL entries except instance --- app-lib/readContract.test.ts | 44 ++++++++++++++++++++++++++++++++++++ app-lib/readContract.ts | 13 ++++++++--- 2 files changed, 54 insertions(+), 3 deletions(-) diff --git a/app-lib/readContract.test.ts b/app-lib/readContract.test.ts index b3a85230..1e47e47d 100644 --- a/app-lib/readContract.test.ts +++ b/app-lib/readContract.test.ts @@ -267,6 +267,26 @@ describe("readEpochRequests", () => { expect(result.get(5n)?.epoch).toEqual({ kind: "archived" }) }) + it("maps epoch and request entries reporting a TTL of 0 to archived", async () => { + getLedgerEntriesMock.mockResolvedValue({ + entries: [ + entryFor(EPOCH_5_KEY_XDR, openEpochScVal, 0), + entryFor(DEPOSIT_5_KEY_XDR, depositScVal, 0), + entryFor(REDEEM_5_KEY_XDR, redeemScVal, 0), + ], + latestLedger: 100, + }) + getLatestLedgerMock.mockResolvedValue({ sequence: 100 }) + + const result = await readEpochRequests(CONTRACT_ID, CONTROLLER, [5n]) + + expect(result.get(5n)).toEqual({ + epoch: { kind: "archived" }, + deposit: { kind: "archived" }, + redeem: { kind: "archived" }, + }) + }) + it("marks a value unreadable when the stored ScVal does not match the expected struct shape", async () => { getLedgerEntriesMock.mockResolvedValue({ entries: [entryFor(EPOCH_5_KEY_XDR, xdr.ScVal.scvBool(true), 1000)], @@ -367,6 +387,18 @@ describe("readEpochs", () => { expect(result.get(5n)).toEqual({ kind: "archived" }) }) + it("reports an entry whose TTL is 0 as archived", async () => { + getLedgerEntriesMock.mockResolvedValue({ + entries: [entryFor(EPOCH_5_KEY_XDR, openEpochScVal, 0)], + latestLedger: 100, + }) + getLatestLedgerMock.mockResolvedValue({ sequence: 100 }) + + const result = await readEpochs(CONTRACT_ID, [5n]) + + expect(result.get(5n)).toEqual({ kind: "archived" }) + }) + it("packs 201 epochs into two requests of 200 and 1", async () => { getLedgerEntriesMock.mockResolvedValue({ entries: [], latestLedger: 100 }) getLatestLedgerMock.mockResolvedValue({ sequence: 100 }) @@ -776,6 +808,18 @@ describe("readPendingAdmins", () => { ) }) + it("reads an entry whose TTL is 0 as no transfer", async () => { + getLedgerEntriesMock.mockResolvedValue({ + entries: [pendingEntry(CONTRACT_ID, 1_500, 0)], + }) + + const read = await readPendingAdmins([CONTRACT_ID]) + + expect(read.kind === "value" && read.value.pending.get(CONTRACT_ID)).toBe( + null, + ) + }) + it.each([ ["a value that is not a transfer", xdr.ScVal.scvU32(7)], [ diff --git a/app-lib/readContract.ts b/app-lib/readContract.ts index e5e9756f..483bea56 100644 --- a/app-lib/readContract.ts +++ b/app-lib/readContract.ts @@ -135,14 +135,15 @@ function decodeLedgerEntry( key: xdr.LedgerKey, decode: (val: xdr.ScVal) => T, latestLedger: number | null, + untrackedTtlIsLive = false, ): ContractRead { if (!chunkResult.ok) return { kind: "unreadable" } const entry = chunkResult.byKey.get(key.toXDR("base64")) if (entry === undefined) return { kind: "value", value: null } if (latestLedger === null) return { kind: "unreadable" } const liveUntil = entry.liveUntilLedgerSeq ?? 0 - // RPC reports 0 for a live entry whose TTL it does not track - if (liveUntil !== 0 && liveUntil < latestLedger) return { kind: "archived" } + if (liveUntil < latestLedger && !(untrackedTtlIsLive && liveUntil === 0)) + return { kind: "archived" } try { return { kind: "value", value: decode(entry.val.contractData().val()) } } catch { @@ -411,7 +412,13 @@ export async function readContractAdmin( latestLedgerSequence(), fetchChunk([key]), ]) - return decodeLedgerEntry(fetched, key, decodeInstanceAdmin, latestLedger) + return decodeLedgerEntry( + fetched, + key, + decodeInstanceAdmin, + latestLedger, + true, + ) } catch { return { kind: "unreadable" } } From 9410efbdcd113aad59bf645c0592aafed0bd81df Mon Sep 17 00:00:00 2001 From: luchobonatti Date: Wed, 14 Oct 2026 11:39:23 -0300 Subject: [PATCH 03/12] refactor(app): type target shares as Amount --- app-lib/format.test.ts | 18 ++++++++++ app-lib/format.ts | 5 +++ app/src/hooks/useComplianceState.ts | 6 ++-- app/src/pages/compliance.test.ts | 5 +-- app/src/pages/compliance.ts | 53 ++++++++++++++++------------- 5 files changed, 59 insertions(+), 28 deletions(-) diff --git a/app-lib/format.test.ts b/app-lib/format.test.ts index 95a0b224..054cd797 100644 --- a/app-lib/format.test.ts +++ b/app-lib/format.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest" import { networkPassphrase } from "./env" import { + addAmounts, AMOUNT_DECIMALS, type Amount, formatDate, @@ -15,6 +16,7 @@ import { PRICE_DECIMALS, type Price, shortAddress, + subtractAmounts, } from "./format" describe("shortAddress", () => { @@ -100,6 +102,22 @@ describe("parseUnits", () => { }) }) +describe("addAmounts", () => { + it("adds two amounts", () => { + expect(addAmounts(5n as Amount, 7n as Amount)).toBe(12n) + }) +}) + +describe("subtractAmounts", () => { + it("subtracts the second amount from the first", () => { + expect(subtractAmounts(12n as Amount, 7n as Amount)).toBe(5n) + }) + + it("floors at zero instead of going negative", () => { + expect(subtractAmounts(7n as Amount, 12n as Amount)).toBe(0n) + }) +}) + describe("parseAmount", () => { it("strips commas and parses a positive number", () => { expect(parseAmount("1,234.50")).toBe(1234.5) diff --git a/app-lib/format.ts b/app-lib/format.ts index 86315408..1522c824 100644 --- a/app-lib/format.ts +++ b/app-lib/format.ts @@ -187,6 +187,11 @@ export function parseUnits( return (negative ? -magnitude : magnitude) as Scaled } +export const addAmounts = (a: Amount, b: Amount): Amount => (a + b) as Amount + +export const subtractAmounts = (a: Amount, b: Amount): Amount => + (a > b ? a - b : 0n) as Amount + export const parseAmount = (raw: string): number | null => { const value = Number(raw.replace(/,/g, "")) return Number.isFinite(value) && value > 0 ? value : null diff --git a/app/src/hooks/useComplianceState.ts b/app/src/hooks/useComplianceState.ts index ca5ff6d1..27fd6921 100644 --- a/app/src/hooks/useComplianceState.ts +++ b/app/src/hooks/useComplianceState.ts @@ -1,4 +1,4 @@ -import { readContract } from "@stellar-scaffold/app-lib" +import { type Amount, readContract } from "@stellar-scaffold/app-lib" import { skipToken, useQuery } from "@tanstack/react-query" import { identityVerifierAt, shareToken } from "../config/clients" import { type Allowance, allowanceQuery, fetchAllowance } from "./useIsAllowed" @@ -11,8 +11,8 @@ const REFRESH_MS = 15_000 export type TargetReads = { allowance: Allowance frozen: boolean | null - shares: bigint | null - frozenShares: bigint | null + shares: Amount | null + frozenShares: Amount | null } export type TargetState = diff --git a/app/src/pages/compliance.test.ts b/app/src/pages/compliance.test.ts index 14f493d9..ba356f59 100644 --- a/app/src/pages/compliance.test.ts +++ b/app/src/pages/compliance.test.ts @@ -1,3 +1,4 @@ +import { type Amount } from "@stellar-scaffold/app-lib" import { describe, expect, it, vi } from "vitest" import { type Grant } from "../hooks/useAdminAuthority" import { type TargetReads } from "../hooks/useComplianceState" @@ -33,8 +34,8 @@ const officer = grantOf("share-token compliance") const reads = (overrides: Partial = {}): TargetReads => ({ allowance: "allowed", frozen: false, - shares: 100n * UNIT, - frozenShares: 10n * UNIT, + shares: (100n * UNIT) as Amount, + frozenShares: (10n * UNIT) as Amount, ...overrides, }) diff --git a/app/src/pages/compliance.ts b/app/src/pages/compliance.ts index 306034f1..8a4654c9 100644 --- a/app/src/pages/compliance.ts +++ b/app/src/pages/compliance.ts @@ -1,8 +1,10 @@ import { + addAmounts, AMOUNT_DECIMALS, type Amount, parseUnits, shortAddress, + subtractAmounts, } from "@stellar-scaffold/app-lib" import { type Metric } from "../components/vault/MetricsStrip" import { type Grant } from "../hooks/useAdminAuthority" @@ -76,8 +78,6 @@ const DESTINATION_KEY = "force-transfer-destination" export const destinationOf = (inputs: Inputs): string | undefined => parseAccount(inputs[DESTINATION_KEY] ?? "") -const exact = (value: bigint): string => exactAmount(value as Amount) - const yesNo = (value: boolean): string => (value ? "Yes" : "No") const targetEntered = (target: string | undefined): Condition => ({ @@ -93,11 +93,10 @@ const allowedOf = (allowance: Allowance | undefined): boolean | null => ? allowance === "allowed" : null -const freeOf = (reads: TargetReads | undefined): bigint | null => { - if (reads?.shares == null || reads.frozenShares === null) return null - const free = reads.shares - reads.frozenShares - return free < 0n ? 0n : free -} +const freeOf = (reads: TargetReads | undefined): Amount | null => + reads?.shares == null || reads.frozenShares === null + ? null + : subtractAmounts(reads.shares, reads.frozenShares) const vaultConfirmation = ( id: ComplianceActionId, @@ -205,14 +204,14 @@ function allowSpec( type AmountEntry = { text: string - amount: bigint | undefined + amount: Amount | undefined field: RailField } const amountEntry = ( key: string, inputs: Inputs, - max: bigint | null, + max: Amount | null, ): AmountEntry => { const text = inputs[key] ?? "" const amount = parseUnits(text, AMOUNT_DECIMALS) ?? undefined @@ -225,7 +224,7 @@ const amountEntry = ( placeholder: "0.00", value: text, invalid: text.trim() !== "" && amount === undefined, - ...(max === null || max <= 0n ? {} : { max: exact(max) }), + ...(max === null || max <= 0n ? {} : { max: exactAmount(max) }), }, } } @@ -237,12 +236,12 @@ const positive = ({ text, amount }: AmountEntry): Condition => ({ const atMost = ( label: string, - amount: bigint | undefined, - limit: bigint | null, + amount: Amount | undefined, + limit: Amount | null, ): Condition => ({ label, met: amount === undefined || limit === null ? null : amount <= limit, - ...(limit === null ? {} : { detail: exact(limit) }), + ...(limit === null ? {} : { detail: exactAmount(limit) }), }) function sharesSpec( @@ -291,26 +290,31 @@ function sharesSpec( outcome: !valid || frozen === null || bound.met !== true ? null - : `Frozen shares ${exact(frozen)} → ${exact(freezing ? frozen + amount : frozen - amount)}.`, + : `Frozen shares ${exactAmount(frozen)} → ${exactAmount(freezing ? addAmounts(frozen, amount) : subtractAmounts(frozen, amount))}.`, fields, ...(confirm === undefined ? {} : { danger: true }), ready: allMet(required), arg: valid ? { account: target, amount } : undefined, - next: valid ? `${exact(amount)} shares of ${shortAddress(target)}` : "", + next: valid + ? `${exactAmount(amount)} shares of ${shortAddress(target)}` + : "", } } const transferOutcome = ( reads: TargetReads | undefined, - amount: bigint, + amount: Amount, ): string | null => { const free = freeOf(reads) if (reads?.shares == null || reads.frozenShares === null || free === null) return null - const shares = `Shares ${exact(reads.shares)} → ${exact(reads.shares - amount)}.` + const shares = `Shares ${exactAmount(reads.shares)} → ${exactAmount(subtractAmounts(reads.shares, amount))}.` if (amount <= free) return shares - const unfrozen = reads.frozenShares - (amount - free) - return `${shares} Frozen shares ${exact(reads.frozenShares)} → ${exact(unfrozen)}.` + const unfrozen = subtractAmounts( + reads.frozenShares, + subtractAmounts(amount, free), + ) + return `${shares} Frozen shares ${exactAmount(reads.frozenShares)} → ${exactAmount(unfrozen)}.` } function forceTransferSpec( @@ -380,7 +384,7 @@ function forceTransferSpec( ready: allMet(required), arg: valid ? { from: target, to: destination, amount } : undefined, next: valid - ? `${exact(amount)} shares from ${shortAddress(target)} to ${shortAddress(destination)}` + ? `${exactAmount(amount)} shares from ${shortAddress(target)} to ${shortAddress(destination)}` : "", } } @@ -460,8 +464,11 @@ export function toComplianceTiles(reads: TargetState): Metric[] { return [ tile("Allowlisted", ALLOWANCE_LABELS[allowance]), tile("Frozen", frozen === null ? null : yesNo(frozen)), - tile("Shares", shares === null ? null : exact(shares)), - tile("Frozen shares", frozenShares === null ? null : exact(frozenShares)), - tile("Free shares", free === null ? null : exact(free)), + tile("Shares", shares === null ? null : exactAmount(shares)), + tile( + "Frozen shares", + frozenShares === null ? null : exactAmount(frozenShares), + ), + tile("Free shares", free === null ? null : exactAmount(free)), ] } From ce5eab8998683f28c5cc539885f2e14f954fad2e Mon Sep 17 00:00:00 2001 From: luchobonatti Date: Wed, 14 Oct 2026 11:41:23 -0300 Subject: [PATCH 04/12] refactor(app): drop unreachable 302 copy --- app/src/components/vault/TransactionModal.test.tsx | 1 - app/src/components/vault/TransactionModal.tsx | 2 -- 2 files changed, 3 deletions(-) diff --git a/app/src/components/vault/TransactionModal.test.tsx b/app/src/components/vault/TransactionModal.test.tsx index b8406c94..fdfaca14 100644 --- a/app/src/components/vault/TransactionModal.test.tsx +++ b/app/src/components/vault/TransactionModal.test.tsx @@ -2472,7 +2472,6 @@ describe("TransactionModal, compliance actions", () => { it.each<[ComplianceModalAction, number, string, RegExp]>([ ["freeze-shares", 300, "share token", /does not hold enough shares/], ["unfreeze-shares", 301, "share token", /cannot be negative/], - ["force-transfer", 302, "share token", /The address is frozen/], ["unfreeze-shares", 303, "share token", /that many frozen shares/], ["freeze-address", 2000, "share token", /does not hold the role/], ["allow-address", 2000, "identity verifier", /does not hold the role/], diff --git a/app/src/components/vault/TransactionModal.tsx b/app/src/components/vault/TransactionModal.tsx index 07b486b0..eeb667b2 100644 --- a/app/src/components/vault/TransactionModal.tsx +++ b/app/src/components/vault/TransactionModal.tsx @@ -1243,8 +1243,6 @@ const adminContractErrorReason = return "The address does not hold enough shares." case 301: return "The amount cannot be negative." - case 302: - return "The address is frozen." case 303: return "The address does not have that many frozen shares." case 2000: From de84c334247530b735c19f56477a4f97ae36cdf3 Mon Sep 17 00:00:00 2001 From: luchobonatti Date: Wed, 14 Oct 2026 11:41:23 -0300 Subject: [PATCH 05/12] refactor(app): share rail types and input reset --- .../components/admin/ComplianceSection.tsx | 16 ++---- .../components/admin/GovernanceSection.tsx | 18 ++---- app/src/hooks/useRailInputs.ts | 11 ++++ app/src/pages/actions.test.ts | 41 ++++++++++++++ app/src/pages/actions.ts | 21 +++++++ app/src/pages/compliance.ts | 13 +---- app/src/pages/governance.test.ts | 9 ++- app/src/pages/governance.ts | 56 +++++++------------ 8 files changed, 108 insertions(+), 77 deletions(-) create mode 100644 app/src/hooks/useRailInputs.ts diff --git a/app/src/components/admin/ComplianceSection.tsx b/app/src/components/admin/ComplianceSection.tsx index 2baec6a8..057db569 100644 --- a/app/src/components/admin/ComplianceSection.tsx +++ b/app/src/components/admin/ComplianceSection.tsx @@ -15,7 +15,8 @@ import { usePendingTransaction, type UsePendingTransaction, } from "../../hooks/usePendingTransaction" -import { confirmKey, type Inputs, parseAccount } from "../../pages/actions" +import { useRailInputs } from "../../hooks/useRailInputs" +import { parseAccount } from "../../pages/actions" import { COMPLIANCE_ACTION_IDS, type ComplianceAction, @@ -96,7 +97,7 @@ const ComplianceSection: React.FC<{ }> = ({ grants, wallet, addresses, contracts, active = true }) => { const verifier = addresses.identityVerifier const [targetText, setTargetText] = useState("") - const [inputs, setInputs] = useState({}) + const { inputs, setInputs, onInput } = useRailInputs() const target = parseAccount(targetText) const { target: reads, destination } = useComplianceState({ verifier, @@ -112,7 +113,7 @@ const ComplianceSection: React.FC<{ ) useEffect(() => { if (recorded) setInputs({}) - }, [recorded]) + }, [recorded, setInputs]) const state = { verifier, @@ -123,15 +124,6 @@ const ComplianceSection: React.FC<{ } const actions = toComplianceActions(state, grants, wallet, inputs) - const onInput = (action: ComplianceAction, value: string, key?: string) => { - if (key !== undefined) - setInputs((previous) => ({ - ...previous, - [confirmKey(action.id)]: "", - [key]: value, - })) - } - const invalid = targetText.trim() !== "" && target === undefined const activeModal = modalFlows.find((props) => props.status.status !== "idle") diff --git a/app/src/components/admin/GovernanceSection.tsx b/app/src/components/admin/GovernanceSection.tsx index 1f631a2c..6de73050 100644 --- a/app/src/components/admin/GovernanceSection.tsx +++ b/app/src/components/admin/GovernanceSection.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from "react" +import React, { useEffect } from "react" import { vaultContractId } from "../../config/contracts" import { type AdminAddresses, @@ -33,11 +33,10 @@ import { usePendingTransaction, type UsePendingTransaction, } from "../../hooks/usePendingTransaction" -import { confirmKey } from "../../pages/actions" +import { useRailInputs } from "../../hooks/useRailInputs" import { GOVERNANCE_ACTION_IDS, type GovernanceAction, - type GovernanceInputs, toGovernanceActions, toGovernanceGroups, } from "../../pages/governance" @@ -151,7 +150,7 @@ const GovernanceSection: React.FC<{ active, }) const flows = useGovernanceFlows() - const [inputs, setInputs] = useState({}) + const { inputs, setInputs, onInput } = useRailInputs() const modalFlows = GOVERNANCE_ACTION_IDS.map((id) => flow(id, flows[id])) const recorded = modalFlows.some( @@ -159,20 +158,11 @@ const GovernanceSection: React.FC<{ ) useEffect(() => { if (recorded) setInputs({}) - }, [recorded]) + }, [recorded, setInputs]) const state = { holders, vault: vaultContractId, handovers, upgrades } const actions = toGovernanceActions(state, grants, wallet, inputs) - const onInput = (action: GovernanceAction, value: string, key?: string) => { - if (key !== undefined) - setInputs((previous) => ({ - ...previous, - [confirmKey(action.id)]: "", - [key]: value, - })) - } - const activeModal = modalFlows.find((props) => props.status.status !== "idle") return ( diff --git a/app/src/hooks/useRailInputs.ts b/app/src/hooks/useRailInputs.ts new file mode 100644 index 00000000..fc72a633 --- /dev/null +++ b/app/src/hooks/useRailInputs.ts @@ -0,0 +1,11 @@ +import { useState } from "react" +import { type Inputs, withInput } from "../pages/actions" + +export function useRailInputs() { + const [inputs, setInputs] = useState({}) + const onInput = (action: { id: string }, value: string, key?: string) => { + if (key !== undefined) + setInputs((previous) => withInput(previous, action.id, key, value)) + } + return { inputs, setInputs, onInput } +} diff --git a/app/src/pages/actions.test.ts b/app/src/pages/actions.test.ts index cbc1bed4..8612dc5a 100644 --- a/app/src/pages/actions.test.ts +++ b/app/src/pages/actions.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from "vitest" import { type Grant } from "../hooks/useAdminAuthority" import { + clearConfirmations, grantFor, parseAccount, type RailAction, @@ -9,6 +10,7 @@ import { type Spec, toAction, unavailableFor, + withInput, } from "./actions" vi.mock("@stellar-scaffold/app-lib", () => ({ @@ -228,3 +230,42 @@ describe("parseAccount", () => { }, ) }) + +describe("withInput", () => { + it("sets the field and clears that action's confirmation only", () => { + expect( + withInput( + { "fund-confirm": "ok", "other-confirm": "ok", keep: "1" }, + "fund", + "fund-amount", + "5", + ), + ).toEqual({ + "fund-confirm": "", + "other-confirm": "ok", + keep: "1", + "fund-amount": "5", + }) + }) +}) + +describe("clearConfirmations", () => { + it("clears the confirmation of every listed action and leaves other inputs", () => { + expect( + clearConfirmations( + { + "a-confirm": "x", + "b-confirm": "y", + "c-confirm": "z", + "a-amount": "1", + }, + ["a", "b"], + ), + ).toEqual({ + "a-confirm": "", + "b-confirm": "", + "c-confirm": "z", + "a-amount": "1", + }) + }) +}) diff --git a/app/src/pages/actions.ts b/app/src/pages/actions.ts index a394627b..7e2d14f1 100644 --- a/app/src/pages/actions.ts +++ b/app/src/pages/actions.ts @@ -153,10 +153,31 @@ export const toAction = ( } } +export type Built = Id extends string + ? RailAction & { id: Id; arg: Arg; next: string } + : never + +export type BuiltSpec = Spec & { + arg: Arg + next: string +} + export type Inputs = Partial> export const confirmKey = (id: string): string => `${id}-confirm` +export const clearConfirmations = (inputs: Inputs, ids: string[]): Inputs => ({ + ...inputs, + ...Object.fromEntries(ids.map((id) => [confirmKey(id), ""])), +}) + +export const withInput = ( + inputs: Inputs, + id: string, + key: string, + value: string, +): Inputs => ({ ...clearConfirmations(inputs, [id]), [key]: value }) + export type Confirmation = { field: ConfirmField; condition: Condition } export function confirmation( diff --git a/app/src/pages/compliance.ts b/app/src/pages/compliance.ts index 8a4654c9..b6abe056 100644 --- a/app/src/pages/compliance.ts +++ b/app/src/pages/compliance.ts @@ -17,6 +17,8 @@ import { import { type Allowance } from "../hooks/useIsAllowed" import { allMet, + type Built, + type BuiltSpec, type Condition, type Confirmation, confirmation, @@ -24,10 +26,8 @@ import { grantFor, type Inputs, parseAccount, - type RailAction, type RailField, ROLES, - type Spec, toAction, } from "./actions" import { exactAmount } from "./cycle" @@ -50,10 +50,6 @@ export const COMPLIANCE_ACTION_IDS: ComplianceActionId[] = [ "force-transfer", ] -type Built = Id extends string - ? RailAction & { id: Id; arg: Arg; next: string } - : never - export type ComplianceAction = | Built | Built @@ -68,11 +64,6 @@ export type ComplianceState = { destination: Allowance } -type BuiltSpec = Spec & { - arg: Arg - next: string -} - const DESTINATION_KEY = "force-transfer-destination" export const destinationOf = (inputs: Inputs): string | undefined => diff --git a/app/src/pages/governance.test.ts b/app/src/pages/governance.test.ts index 9c6ad26a..df8debd9 100644 --- a/app/src/pages/governance.test.ts +++ b/app/src/pages/governance.test.ts @@ -4,11 +4,10 @@ import { type Metric } from "../components/vault/MetricsStrip" import { type GovernedContract } from "../hooks/pendingAdmins" import { type Grant, type RoleHolders } from "../hooks/useAdminAuthority" import { type Handovers, type Upgrades } from "../hooks/useGovernanceState" -import { type RailField, readiness } from "./actions" +import { type Inputs, type RailField, readiness } from "./actions" import { formatDuration, formatTimestamp } from "./cycle" import { type GovernanceAction, - type GovernanceInputs, type GovernanceState, toGovernanceActions, toGovernanceGroups, @@ -94,7 +93,7 @@ const state = ( const find = ( id: Id, grants: Grant[], - inputs: GovernanceInputs = {}, + inputs: Inputs = {}, current: GovernanceState = state(), ) => { const action = toGovernanceActions(current, grants, WALLET, inputs).find( @@ -224,7 +223,7 @@ describe("toGovernanceActions", () => { }) it("needs a confirmation to revoke the vault's share-token manager role", () => { - const inputs: GovernanceInputs = { + const inputs: Inputs = { "revoke-token-role-role": "manager", "revoke-token-role-account": VAULT, } @@ -271,7 +270,7 @@ describe("toGovernanceActions", () => { }) it("drops a confirmation once the role or account changes", () => { - const inputs: GovernanceInputs = { + const inputs: Inputs = { "grant-vault-role-role": "guardian", "grant-vault-role-account": MANAGER, } diff --git a/app/src/pages/governance.ts b/app/src/pages/governance.ts index ce811900..a433b924 100644 --- a/app/src/pages/governance.ts +++ b/app/src/pages/governance.ts @@ -16,13 +16,15 @@ import { } from "../hooks/useGovernanceTransactions" import { allMet, + type Built, + type BuiltSpec, type Condition, type Confirmation, confirmation, confirmKey, grantFor, + type Inputs, parseAccount, - type RailAction, type RailField, ROLES, type Spec, @@ -68,10 +70,6 @@ export const GOVERNANCE_ACTION_IDS: GovernanceActionId[] = [ ...VAULT_ACTION_IDS, ] -type Built = Id extends string - ? RailAction & { id: Id; arg: Arg; next: string } - : never - export type GovernanceAction = | Built | Built @@ -80,8 +78,6 @@ export type GovernanceAction = | Built<"propose-upgrade-delay", bigint | undefined> | Built -export type GovernanceInputs = Partial> - export type GovernanceState = { holders: RoleHolders vault: string @@ -230,7 +226,7 @@ const resultingHolders = ({ const roleConfirmation = ( draft: Draft, key: string, - inputs: GovernanceInputs, + inputs: Inputs, ): Confirmation | undefined => { const label = warningOf(draft) return label === undefined @@ -251,11 +247,8 @@ function roleSpec( operation: Operation, contract: GovernedContract, state: GovernanceState, - inputs: GovernanceInputs, -): Spec & { - arg: RoleChange | undefined - next: string -} { + inputs: Inputs, +): BuiltSpec { const id: RoleActionId = `${operation}-${contract}-role` const { noun, roles } = CONTRACTS[contract] const roleKey = `${id}-role` @@ -343,11 +336,8 @@ const limitCondition = ( function handOverSpec( contract: GovernedContract, state: GovernanceState, - inputs: GovernanceInputs, -): Spec & { - arg: AdminOffer | undefined - next: string -} { + inputs: Inputs, +): BuiltSpec { const id: OfferActionId = `hand-over-${contract}-admin` const accountKey = `${id}-account` const expiryKey = `${id}-expiry` @@ -454,10 +444,6 @@ const acceptSpec = ( }) type ReadyUpgrades = Extract -type VaultSpec = Spec & { - arg: Arg - next: string -} const MIN_UPGRADE_DELAY = 7n * DAY const MAX_UPGRADE_DELAY = 90n * DAY @@ -490,9 +476,9 @@ const aboveNotice = ( }) function codeUpgradeSpec( - inputs: GovernanceInputs, + inputs: Inputs, upgrades?: ReadyUpgrades, -): VaultSpec<"propose-code-upgrade", string | undefined> { +): BuiltSpec<"propose-code-upgrade", string | undefined> { const key = "propose-code-upgrade-hash" const text = inputs[key] ?? "" const hash = WASM_HASH.test(text.trim()) @@ -527,9 +513,9 @@ function codeUpgradeSpec( } function delayUpgradeSpec( - inputs: GovernanceInputs, + inputs: Inputs, upgrades?: ReadyUpgrades, -): VaultSpec<"propose-upgrade-delay", bigint | undefined> { +): BuiltSpec<"propose-upgrade-delay", bigint | undefined> { const key = "propose-upgrade-delay-secs" const current = upgrades?.delay ?? undefined const text = @@ -578,8 +564,8 @@ const notPaused = (paused: ReadyUpgrades["paused"]): Condition => ({ function applySpec( proposal: PendingUpgrade, upgrades: ReadyUpgrades, - inputs: GovernanceInputs, -): VaultSpec<"apply-upgrade", undefined> { + inputs: Inputs, +): BuiltSpec<"apply-upgrade", undefined> { const conditions: Condition[] = [ { label: "ETA reached", @@ -619,7 +605,7 @@ function applySpec( const cancelUpgradeSpec = ( proposal: PendingUpgrade, -): VaultSpec<"cancel-upgrade", undefined> => ({ +): BuiltSpec<"cancel-upgrade", undefined> => ({ id: "cancel-upgrade", label: "Cancel the upgrade", conditions: [], @@ -629,9 +615,9 @@ const cancelUpgradeSpec = ( }) function windDownSpec( - inputs: GovernanceInputs, + inputs: Inputs, upgrades?: ReadyUpgrades, -): VaultSpec<"propose-wind-down", undefined> { +): BuiltSpec<"propose-wind-down", undefined> { const phase = upgrades?.windDown.phase ?? null const confirm = confirmation( confirmKey("propose-wind-down"), @@ -668,7 +654,7 @@ function windDownSpec( } } -const cancelWindDownSpec = (): VaultSpec<"cancel-wind-down", undefined> => ({ +const cancelWindDownSpec = (): BuiltSpec<"cancel-wind-down", undefined> => ({ id: "cancel-wind-down", label: "Cancel the wind-down", conditions: [], @@ -688,7 +674,7 @@ function adminActions( state: GovernanceState, grant: Grant, wallet: string, - inputs: GovernanceInputs, + inputs: Inputs, ): GovernanceAction[] { const pending = state.handovers.status === "ready" @@ -709,7 +695,7 @@ function vaultActions( state: GovernanceState, grant: Grant, wallet: string, - inputs: GovernanceInputs, + inputs: Inputs, ): GovernanceAction[] { const upgrades = state.upgrades.status === "ready" ? state.upgrades : undefined @@ -755,7 +741,7 @@ export function toGovernanceActions( state: GovernanceState, grants: Grant[], wallet: string, - inputs: GovernanceInputs, + inputs: Inputs, ): GovernanceAction[] { return GOVERNED_CONTRACTS.flatMap((contract) => { const admin = grantFor(grants, CONTRACTS[contract].admin.grant, wallet) From 59b1d705de682480e86ff1faffaa42bd11974332 Mon Sep 17 00:00:00 2001 From: luchobonatti Date: Wed, 14 Oct 2026 11:42:40 -0300 Subject: [PATCH 06/12] fix(app): read allowance at the token's verifier --- app/src/config/clients.ts | 12 -------- app/src/hooks/useIsAllowed.test.ts | 45 +++++++++++++++++++++++++---- app/src/hooks/useIsAllowed.ts | 24 +++++++++------ app/src/pages/VaultPreview.test.tsx | 3 +- 4 files changed, 57 insertions(+), 27 deletions(-) diff --git a/app/src/config/clients.ts b/app/src/config/clients.ts index afc86ced..e4cbdb5c 100644 --- a/app/src/config/clients.ts +++ b/app/src/config/clients.ts @@ -86,18 +86,6 @@ export const navOracle = (): Promise => { return oracle } -let identityVerifierClient: Promise | undefined - -export const identityVerifier = (): Promise => { - identityVerifierClient ??= connectIdentityVerifier( - addresses.identity_verifier, - ).catch((error: unknown) => { - identityVerifierClient = undefined - throw error - }) - return identityVerifierClient -} - const verifierReaders = new Map>() export const identityVerifierAt = ( diff --git a/app/src/hooks/useIsAllowed.test.ts b/app/src/hooks/useIsAllowed.test.ts index 59433848..2e0c1ab6 100644 --- a/app/src/hooks/useIsAllowed.test.ts +++ b/app/src/hooks/useIsAllowed.test.ts @@ -1,11 +1,18 @@ import { type ContractRead } from "@stellar-scaffold/app-lib" -import { describe, expect, it, vi } from "vitest" -import { classifyAllowance } from "./useIsAllowed" +import { waitFor } from "@testing-library/react" +import { beforeEach, describe, expect, it, vi } from "vitest" +import { investorAddress, renderWithWallet } from "./testSupport" +import { classifyAllowance, useIsAllowed } from "./useIsAllowed" + +const { tokenMock, verifierMock, identityVerifierAtMock } = vi.hoisted(() => ({ + tokenMock: { identity_verifier: vi.fn() }, + verifierMock: { is_allowed: vi.fn() }, + identityVerifierAtMock: vi.fn(), +})) vi.mock("../config/clients", () => ({ - identityVerifier: async () => ({ - is_allowed: async () => ({}), - }), + shareToken: async () => tokenMock, + identityVerifierAt: identityVerifierAtMock, })) const allowed: ContractRead = { kind: "value", value: true } @@ -26,3 +33,31 @@ describe("classifyAllowance", () => { expect(classifyAllowance(read)).toBe(expected) }) }) + +describe("useIsAllowed", () => { + beforeEach(() => { + vi.clearAllMocks() + identityVerifierAtMock.mockImplementation(async () => verifierMock) + tokenMock.identity_verifier.mockResolvedValue({ result: "CTOKENVERIFIER" }) + verifierMock.is_allowed.mockResolvedValue({ result: true }) + }) + + it("reads the allowlist of the verifier the share token reports", async () => { + const { result } = renderWithWallet(() => useIsAllowed()) + + await waitFor(() => expect(result.current.allowance).toBe("allowed")) + expect(identityVerifierAtMock).toHaveBeenCalledWith("CTOKENVERIFIER") + expect(verifierMock.is_allowed).toHaveBeenCalledWith({ + account: investorAddress, + }) + }) + + it("is unreadable when the share token's verifier cannot be read", async () => { + tokenMock.identity_verifier.mockRejectedValue(new Error("rpc down")) + + const { result } = renderWithWallet(() => useIsAllowed()) + + await waitFor(() => expect(result.current.allowance).toBe("unreadable")) + expect(verifierMock.is_allowed).not.toHaveBeenCalled() + }) +}) diff --git a/app/src/hooks/useIsAllowed.ts b/app/src/hooks/useIsAllowed.ts index 550e45e0..9c57a096 100644 --- a/app/src/hooks/useIsAllowed.ts +++ b/app/src/hooks/useIsAllowed.ts @@ -4,7 +4,7 @@ import { readContract, } from "@stellar-scaffold/app-lib" import { queryOptions, skipToken, useQuery } from "@tanstack/react-query" -import { identityVerifier, identityVerifierAt } from "../config/clients" +import { identityVerifierAt, shareToken } from "../config/clients" import { useWallet } from "./useWallet" export const isAllowedKey = ["identity", "is-allowed"] as const @@ -39,16 +39,22 @@ export const allowanceQuery = ( staleTime: 30_000, }) +export const identityVerifierQuery = queryOptions({ + queryKey: [...isAllowedKey, "verifier"] as const, + queryFn: async () => { + const read = await readContract(async () => + (await shareToken()).identity_verifier(), + ) + return read.kind === "value" ? read.value : null + }, + staleTime: 30_000, +}) + export function useIsAllowed(): { allowance: Allowance } { const { address } = useWallet() - const { data } = useQuery({ - queryKey: [...isAllowedKey, address], - queryFn: - address === undefined - ? skipToken - : () => fetchAllowance(identityVerifier(), address), - staleTime: 30_000, - }) + const { data: verifier } = useQuery(identityVerifierQuery) + const { data } = useQuery(allowanceQuery(verifier ?? null, address)) + if (verifier === null) return { allowance: "unreadable" } return { allowance: data ?? "checking" } } diff --git a/app/src/pages/VaultPreview.test.tsx b/app/src/pages/VaultPreview.test.tsx index f3deee3c..38723201 100644 --- a/app/src/pages/VaultPreview.test.tsx +++ b/app/src/pages/VaultPreview.test.tsx @@ -354,6 +354,7 @@ vi.mock("../config/clients", () => { balance: async () => ({ result: mockShares.balance }), symbol: async () => ({ result: mockSymbols.shareToken }), name: async () => ({ result: mockSymbols.vaultName }), + identity_verifier: async () => ({ result: "CVERIFIER" }), } const depositAsset = { symbol: async () => ({ result: mockSymbols.token }), @@ -364,7 +365,7 @@ vi.mock("../config/clients", () => { asyncVault: async () => vault, asyncVaultWriter: async () => vault, navOracle: async () => oracle, - identityVerifier: async () => identity, + identityVerifierAt: async () => identity, shareToken: async () => shares, asset: async () => depositAsset, } From 7a172946dbd8c8338dbf3278b5307a6dab68cf27 Mon Sep 17 00:00:00 2001 From: luchobonatti Date: Wed, 14 Oct 2026 11:45:55 -0300 Subject: [PATCH 07/12] fix(app): flag the allowlist when admin unread --- app/src/App.test.tsx | 1 + .../admin/ComplianceSection.test.tsx | 18 ++++++++ .../components/admin/ComplianceSection.tsx | 11 ++++- app/src/hooks/useAdminAuthority.test.ts | 38 +++++++++++++--- app/src/hooks/useAdminAuthority.ts | 36 +++++++++++---- app/src/pages/AdminPanel.test.tsx | 29 +++++++++++- app/src/pages/AdminPanel.tsx | 25 ++++++++++- app/src/pages/compliance.test.ts | 45 +++++++++++++++++++ app/src/pages/compliance.ts | 41 ++++++++++++----- 9 files changed, 216 insertions(+), 28 deletions(-) diff --git a/app/src/App.test.tsx b/app/src/App.test.tsx index 9af5f5b9..edc3ce93 100644 --- a/app/src/App.test.tsx +++ b/app/src/App.test.tsx @@ -41,6 +41,7 @@ const disconnectedAuthority: AdminAuthority = { }, grants: [], signersUnknown: false, + verifierAdminUnknown: false, addresses: { oracle: null, shareToken: null, identityVerifier: null }, holders: { "vault governance": [], diff --git a/app/src/components/admin/ComplianceSection.test.tsx b/app/src/components/admin/ComplianceSection.test.tsx index d1c74d4c..1d58d6b2 100644 --- a/app/src/components/admin/ComplianceSection.test.tsx +++ b/app/src/components/admin/ComplianceSection.test.tsx @@ -138,6 +138,24 @@ describe("ComplianceSection", () => { expect(within(overview).getByText("90.00")).toBeTruthy() }) + it("tells the compliance role why the allowlist is unavailable", () => { + render( + , + ) + enterTarget(TARGET) + openRow("Allow the address") + + expect(button("Allow the address").disabled).toBe(true) + expect( + screen.getAllByText("Could not read the allowlist admin.").length, + ).toBeGreaterThan(0) + }) + it("ignores an address that is not a valid key", () => { render( = ({ grants, wallet, addresses, contracts, active = true }) => { +}> = ({ + grants, + wallet, + addresses, + verifierAdminUnknown = false, + contracts, + active = true, +}) => { const verifier = addresses.identityVerifier const [targetText, setTargetText] = useState("") const { inputs, setInputs, onInput } = useRailInputs() @@ -121,6 +129,7 @@ const ComplianceSection: React.FC<{ reads, vault: vaultContractId, destination, + adminUnknown: verifierAdminUnknown, } const actions = toComplianceActions(state, grants, wallet, inputs) diff --git a/app/src/hooks/useAdminAuthority.test.ts b/app/src/hooks/useAdminAuthority.test.ts index c924d913..a0fdae95 100644 --- a/app/src/hooks/useAdminAuthority.test.ts +++ b/app/src/hooks/useAdminAuthority.test.ts @@ -3,7 +3,7 @@ import { waitFor } from "@testing-library/react" import { beforeEach, describe, expect, it, vi } from "vitest" import { vaultContractId } from "../config/contracts" import { pendingAdminsQuery } from "./pendingAdmins" -import { renderWithWallet, investorAddress } from "./testSupport" +import { deferred, renderWithWallet, investorAddress } from "./testSupport" import { type AdminSurface, adminAuthorityKey, @@ -436,22 +436,48 @@ describe("useAdminAuthority", () => { expect(result.current.surfaces).toEqual(new Set()) }) - it("keeps the other surfaces when the verifier admin cannot be read", async () => { - readContractAdminMock.mockResolvedValue({ kind: "unreadable" }) + it.each([ + ["cannot be read", { kind: "unreadable" }], + ["is archived", { kind: "archived" }], + ])( + "keeps the other surfaces and flags the verifier admin when it %s", + async (_, read) => { + readContractAdminMock.mockResolvedValue(read) + const { result } = renderWithWallet(useAdminAuthority) + + await waitFor(() => expect(result.current.status).toBe("ready")) + expect(result.current.surfaces).toEqual(new Set(["cycle"])) + expect(result.current.holders["identity-verifier admin"]).toEqual([]) + expect(result.current.verifierAdminUnknown).toBe(true) + }, + ) + + it("does not flag the verifier admin when it is read, even with no admin", async () => { const { result } = renderWithWallet(useAdminAuthority) await waitFor(() => expect(result.current.status).toBe("ready")) - expect(result.current.surfaces).toEqual(new Set(["cycle"])) - expect(result.current.holders["identity-verifier admin"]).toEqual([]) + expect(result.current.verifierAdminUnknown).toBe(false) }) - it("skips the verifier admin read when the token's verifier is unknown", async () => { + it("skips the verifier admin read and flags it when the token's verifier is unknown", async () => { tokenMock.identity_verifier.mockRejectedValue(new Error("rpc down")) const { result } = renderWithWallet(useAdminAuthority) await waitFor(() => expect(result.current.status).toBe("ready")) expect(readContractAdminMock).not.toHaveBeenCalled() expect(result.current.surfaces).toEqual(new Set(["cycle"])) + expect(result.current.verifierAdminUnknown).toBe(true) + }) + + it("reads the verifier admin without waiting for the other token reads", async () => { + const tokenAdmin = deferred<{ result: null }>() + tokenMock.get_admin.mockReturnValue(tokenAdmin.promise) + renderWithWallet(useAdminAuthority) + + await waitFor(() => + expect(readContractAdminMock).toHaveBeenCalledWith("CVERIFIERADDRESS"), + ) + tokenAdmin.resolve({ result: null }) }) it("reports the held surfaces and the derived addresses once every read resolves", async () => { diff --git a/app/src/hooks/useAdminAuthority.ts b/app/src/hooks/useAdminAuthority.ts index 0c1d952f..4ff1c354 100644 --- a/app/src/hooks/useAdminAuthority.ts +++ b/app/src/hooks/useAdminAuthority.ts @@ -7,6 +7,7 @@ import { type PendingAdmins, readContract, readContractAdmin, + type ShareTokenApi, } from "@stellar-scaffold/app-lib" import { type QueryClient, @@ -62,6 +63,7 @@ export interface AdminAuthority { grantedBy: Record grants: Grant[] signersUnknown: boolean + verifierAdminUnknown: boolean addresses: AdminAddresses holders: RoleHolders } @@ -129,6 +131,7 @@ const emptyAuthority = ( grantedBy: emptyGrantedBy(), grants: [], signersUnknown: false, + verifierAdminUnknown: false, addresses, holders: emptyHolders(), }) @@ -272,6 +275,25 @@ async function readSigners( return { byAccount, unknown } } +type VerifierRead = { + address: ContractRead + admin: ContractRead | undefined +} + +async function readVerifier( + token: Promise, +): Promise { + const address = await readContract(async () => + (await token).identity_verifier(), + ) + return { + address, + admin: isValue(address) + ? await readContractAdmin(address.value) + : undefined, + } +} + async function fetchAdminAuthority( wallet: string, queryClient: QueryClient, @@ -310,7 +332,7 @@ async function fetchAdminAuthority( tokenCompliance, tokenManager, tokenAdmin, - identityVerifier, + verifier, pendingAdmins, ] = await Promise.all([ readRoleMembers(oraclePromise, "attester"), @@ -319,7 +341,7 @@ async function fetchAdminAuthority( readRoleMembers(tokenPromise, "compliance"), readRoleMembers(tokenPromise, "manager"), readContract(async () => (await tokenPromise).get_admin()), - readContract(async () => (await tokenPromise).identity_verifier()), + readVerifier(tokenPromise), queryClient .fetchQuery( pendingAdminsQuery({ @@ -334,12 +356,8 @@ async function fetchAdminAuthority( const addresses: AdminAddresses = { oracle: oracleAddress.value, shareToken: shareTokenAddress.value, - identityVerifier: toAddressOrNull(identityVerifier), + identityVerifier: toAddressOrNull(verifier.address), } - const verifierAdmin = - addresses.identityVerifier === null - ? undefined - : await readContractAdmin(addresses.identityVerifier) const roleReads: ContractRead[] = [ governance, @@ -366,7 +384,7 @@ async function fetchAdminAuthority( "oracle admin": asHolders(oracleAdmin), "share-token compliance": asMembers(tokenCompliance), "identity-verifier admin": - verifierAdmin === undefined ? [] : asHolders(verifierAdmin), + verifier.admin === undefined ? [] : asHolders(verifier.admin), "share-token manager": asMembers(tokenManager), "share-token admin": asHolders(tokenAdmin), "vault pending admin": pendingAdminOf(pendingAdmins, vaultContractId), @@ -382,6 +400,8 @@ async function fetchAdminAuthority( status: "ready", ...toSurfaces(wallet, holders, byAccount), signersUnknown: unknown, + verifierAdminUnknown: + verifier.admin === undefined || !isValue(verifier.admin), addresses, holders, } diff --git a/app/src/pages/AdminPanel.test.tsx b/app/src/pages/AdminPanel.test.tsx index 8cd0fccc..9c292f4a 100644 --- a/app/src/pages/AdminPanel.test.tsx +++ b/app/src/pages/AdminPanel.test.tsx @@ -51,12 +51,18 @@ vi.mock("../components/admin/ComplianceSection", () => ({ grants, contracts, active, + verifierAdminUnknown, }: { grants: Grant[] contracts: React.ReactNode active: boolean + verifierAdminUnknown: boolean }) => ( -
+

Compliance surface for {grants.map((grant) => grant.role).join(", ")}

@@ -166,6 +172,7 @@ const authority = (overrides: Partial): AdminAuthority => ({ grantedBy: emptyGrantedBy, grants: [], signersUnknown: false, + verifierAdminUnknown: false, addresses: noAddresses, holders: noHolders, ...overrides, @@ -341,6 +348,26 @@ describe("AdminPanel", () => { ).toBe("false") }) + it("tells the compliance surface when the verifier admin is unknown", () => { + const officer = holderOf("share-token compliance") + useAdminAuthorityMock.mockReturnValue( + authority({ + status: "ready", + surfaces: new Set(["compliance"]), + grantedBy: { ...emptyGrantedBy, compliance: [officer] }, + grants: [officer], + verifierAdminUnknown: true, + }), + ) + renderAt("/admin/compliance") + + expect( + screen + .getByTestId("compliance-surface") + .getAttribute("data-admin-unknown"), + ).toBe("true") + }) + it("renders the emergency actions with the emergency grants, active only on its tab", () => { const manager = holderOf("vault manager") const guardian = holderOf("vault guardian") diff --git a/app/src/pages/AdminPanel.tsx b/app/src/pages/AdminPanel.tsx index a7a4cb7f..bb737c8c 100644 --- a/app/src/pages/AdminPanel.tsx +++ b/app/src/pages/AdminPanel.tsx @@ -70,7 +70,16 @@ const SurfaceBody: React.FC<{ wallet: string addresses: AdminAddresses holders: RoleHolders -}> = ({ surface, active, grantedBy, wallet, addresses, holders }) => { + verifierAdminUnknown: boolean +}> = ({ + surface, + active, + grantedBy, + wallet, + addresses, + holders, + verifierAdminUnknown, +}) => { const contracts = switch (surface) { case "cycle": @@ -117,6 +126,7 @@ const SurfaceBody: React.FC<{ grants={grantedBy.compliance} wallet={wallet} addresses={addresses} + verifierAdminUnknown={verifierAdminUnknown} active={active} contracts={contracts} /> @@ -130,7 +140,15 @@ const HeldSurfaces: React.FC<{ wallet: string addresses: AdminAddresses holders: RoleHolders -}> = ({ held, grantedBy, wallet, addresses, holders }) => { + verifierAdminUnknown: boolean +}> = ({ + held, + grantedBy, + wallet, + addresses, + holders, + verifierAdminUnknown, +}) => { const { surface } = useParams() const active = held.find((candidate) => candidate === surface) const headingRef = useRef(null) @@ -175,6 +193,7 @@ const HeldSurfaces: React.FC<{ wallet={wallet} addresses={addresses} holders={holders} + verifierAdminUnknown={verifierAdminUnknown} /> ))} @@ -189,6 +208,7 @@ const AdminPanel: React.FC = () => { grantedBy, grants, signersUnknown, + verifierAdminUnknown, addresses, holders, } = useAdminAuthority() @@ -245,6 +265,7 @@ const AdminPanel: React.FC = () => { wallet={address ?? ""} addresses={addresses} holders={holders} + verifierAdminUnknown={verifierAdminUnknown} /> )}
diff --git a/app/src/pages/compliance.test.ts b/app/src/pages/compliance.test.ts index ba356f59..da16681a 100644 --- a/app/src/pages/compliance.test.ts +++ b/app/src/pages/compliance.test.ts @@ -52,6 +52,7 @@ const stateOf = ( : { status: "ready", reads: reads(overrides) }, vault: VAULT, destination, + adminUnknown: false, }) const actionOf = ( @@ -90,6 +91,50 @@ describe("toComplianceActions", () => { expect(ids([grantOf("share-token admin")])).toEqual([]) }) + it("withholds the allowlist from the compliance role when the verifier admin cannot be read", () => { + const actions = toComplianceActions( + { ...stateOf(HOLDER), adminUnknown: true }, + [officer], + WALLET, + {}, + ) + + expect( + actions + .filter(({ id }) => id.endsWith("allow-address")) + .map(({ id, label, enabled, unavailable }) => [ + id, + label, + enabled, + unavailable, + ]), + ).toEqual([ + [ + "allow-address", + "Allow the address", + false, + "Could not read the allowlist admin.", + ], + [ + "disallow-address", + "Remove from the allowlist", + false, + "Could not read the allowlist admin.", + ], + ]) + }) + + it("does not withhold the allowlist from a verifier admin or offer it to a wallet with no surface", () => { + const state = { ...stateOf(HOLDER), adminUnknown: true } + + expect( + toComplianceActions(state, [verifierAdmin], WALLET, {}) + .filter(({ id }) => id.endsWith("allow-address")) + .map(({ unavailable }) => unavailable), + ).toEqual([undefined, undefined]) + expect(toComplianceActions(state, [], WALLET, {})).toEqual([]) + }) + it("blocks every action until a target address is entered", () => { const actions = toComplianceActions( stateOf(undefined), diff --git a/app/src/pages/compliance.ts b/app/src/pages/compliance.ts index b6abe056..908e3717 100644 --- a/app/src/pages/compliance.ts +++ b/app/src/pages/compliance.ts @@ -62,6 +62,7 @@ export type ComplianceState = { reads: TargetState vault: string destination: Allowance + adminUnknown: boolean } const DESTINATION_KEY = "force-transfer-destination" @@ -386,6 +387,27 @@ const build = ( wallet: string, ) => ({ ...toAction(spec, grant, wallet), arg: spec.arg, next: spec.next }) +const ADMIN_UNREADABLE = "Could not read the allowlist admin." + +const allowActions = ( + state: ComplianceState, + allowed: boolean | null, + inputs: Inputs, + grant: Grant, + wallet: string, +): ComplianceAction[] => [ + build( + allowSpec(TOGGLES["allow-address"], allowed, state, inputs), + grant, + wallet, + ), + build( + allowSpec(TOGGLES["disallow-address"], allowed, state, inputs), + grant, + wallet, + ), +] + export function toComplianceActions( state: ComplianceState, grants: Grant[], @@ -397,20 +419,19 @@ export function toComplianceActions( const frozen = reads?.frozen ?? null const actions: ComplianceAction[] = [] const admin = grantFor(grants, ROLES.verifierAdmin, wallet) + const officer = grantFor(grants, ROLES.compliance, wallet) if (admin !== undefined) + actions.push(...allowActions(state, allowed, inputs, admin, wallet)) + else if (officer !== undefined && state.adminUnknown) actions.push( - build( - allowSpec(TOGGLES["allow-address"], allowed, state, inputs), - admin, - wallet, - ), - build( - allowSpec(TOGGLES["disallow-address"], allowed, state, inputs), - admin, - wallet, + ...allowActions(state, allowed, inputs, officer, wallet).map( + (action) => ({ + ...action, + enabled: false, + unavailable: ADMIN_UNREADABLE, + }), ), ) - const officer = grantFor(grants, ROLES.compliance, wallet) if (officer !== undefined) actions.push( build( From 785d39341b2fe3c3f5bd541c3ee619b08086d3e0 Mon Sep 17 00:00:00 2001 From: luchobonatti Date: Wed, 14 Oct 2026 11:46:44 -0300 Subject: [PATCH 08/12] fix(app): state vault disallow exactly --- app/src/pages/compliance.test.ts | 2 +- app/src/pages/compliance.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/pages/compliance.test.ts b/app/src/pages/compliance.test.ts index da16681a..138a822e 100644 --- a/app/src/pages/compliance.test.ts +++ b/app/src/pages/compliance.test.ts @@ -192,7 +192,7 @@ describe("toComplianceActions", () => { }) it.each<[ComplianceAction["id"], string]>([ - ["disallow-address", "Stops the vault receiving shares."], + ["disallow-address", "Stops the vault returning shares on cancellations."], ["freeze-address", "Stops the vault moving shares."], ])("asks to confirm %s on the vault", (id, label) => { const state = stateOf(VAULT, { frozen: false, allowance: "allowed" }) diff --git a/app/src/pages/compliance.ts b/app/src/pages/compliance.ts index 908e3717..b9c1838b 100644 --- a/app/src/pages/compliance.ts +++ b/app/src/pages/compliance.ts @@ -124,7 +124,7 @@ const TOGGLES: { [Id in AddressActionId]: Toggle } = { condition: "Allowlisted", subject: "Allowlisted", wanted: false, - warning: "Stops the vault receiving shares.", + warning: "Stops the vault returning shares on cancellations.", }, "freeze-address": { id: "freeze-address", From 13df8874c691db805a272e34f3af093cde1416ef Mon Sep 17 00:00:00 2001 From: luchobonatti Date: Wed, 14 Oct 2026 11:47:38 -0300 Subject: [PATCH 09/12] fix(app): refuse a transfer to the same address --- app/src/pages/compliance.test.ts | 19 ++++++++++++++++++- app/src/pages/compliance.ts | 10 ++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/app/src/pages/compliance.test.ts b/app/src/pages/compliance.test.ts index 138a822e..27b5cf88 100644 --- a/app/src/pages/compliance.test.ts +++ b/app/src/pages/compliance.test.ts @@ -317,10 +317,27 @@ describe("toComplianceActions", () => { expect(unmet(over)).toEqual([["Amount ≤ shares", false]]) expect(over.enabled).toBe(false) - expect(unmet(missing)).toEqual([["Destination entered", null]]) + expect(unmet(missing)).toEqual([ + ["Destination entered", null], + ["Destination differs from the address", null], + ]) expect(missing.enabled).toBe(false) }) + it("refuses a destination equal to the target and shows no outcome", () => { + const action = actionOf(stateOf(HOLDER), "force-transfer", { + "force-transfer-destination": HOLDER, + "force-transfer-amount": "5", + }) + + expect(unmet(action)).toEqual([ + ["Destination differs from the address", false], + ]) + expect(action.enabled).toBe(false) + expect(action.outcome).toBeNull() + expect(action.arg).toBeUndefined() + }) + it("notes a destination that is not allowlisted", () => { const action = actionOf( stateOf(HOLDER, {}, "not-allowed"), diff --git a/app/src/pages/compliance.ts b/app/src/pages/compliance.ts index b9c1838b..9e03bedb 100644 --- a/app/src/pages/compliance.ts +++ b/app/src/pages/compliance.ts @@ -326,6 +326,13 @@ function forceTransferSpec( label: "Destination entered", met: destinationText.trim() === "" ? null : destination !== undefined, } + const differs: Condition = { + label: "Destination differs from the address", + met: + target === undefined || destination === undefined + ? null + : destination !== target, + } const bound = atMost("Amount ≤ shares", amount, shares) const confirm = vaultConfirmation( id, @@ -337,12 +344,14 @@ function forceTransferSpec( const required = [ entered, destinationEntered, + differs, positive(entry), ...(confirm ? [confirm.condition] : []), ] const valid = target !== undefined && destination !== undefined && + differs.met === true && amount !== undefined && amount > 0n return { @@ -351,6 +360,7 @@ function forceTransferSpec( conditions: [ entered, destinationEntered, + differs, positive(entry), bound, ...(confirm ? [confirm.condition] : []), From 1feede7783ca1a0109b72eb859adc6591b5f2b3b Mon Sep 17 00:00:00 2001 From: luchobonatti Date: Wed, 14 Oct 2026 11:48:30 -0300 Subject: [PATCH 10/12] fix(app): explain an invalid target address --- .../admin/ComplianceSection.module.css | 6 ++++- .../admin/ComplianceSection.test.tsx | 25 ++++++++++++++++--- .../components/admin/ComplianceSection.tsx | 10 ++++++++ 3 files changed, 37 insertions(+), 4 deletions(-) diff --git a/app/src/components/admin/ComplianceSection.module.css b/app/src/components/admin/ComplianceSection.module.css index 2bbb24f8..f5f3d5be 100644 --- a/app/src/components/admin/ComplianceSection.module.css +++ b/app/src/components/admin/ComplianceSection.module.css @@ -19,7 +19,11 @@ } .fieldInvalid { - border: 2px solid var(--vault-danger); + border-color: var(--vault-danger); +} + +.error { + color: var(--vault-danger); } .field:focus-within { diff --git a/app/src/components/admin/ComplianceSection.test.tsx b/app/src/components/admin/ComplianceSection.test.tsx index 1d58d6b2..5df2c441 100644 --- a/app/src/components/admin/ComplianceSection.test.tsx +++ b/app/src/components/admin/ComplianceSection.test.tsx @@ -167,14 +167,33 @@ describe("ComplianceSection", () => { enterTarget("GNOTANADDRESS") - expect( - screen.getByLabelText("Target address").getAttribute("aria-invalid"), - ).toBe("true") + const input = screen.getByLabelText("Target address") + expect(input.getAttribute("aria-invalid")).toBe("true") + expect(input.getAttribute("aria-describedby")).toBe( + screen.getByText("Enter a G… or C… address.").id, + ) expect(useComplianceStateMock).toHaveBeenLastCalledWith( expect.objectContaining({ target: undefined }), ) }) + it("shows no address error for an empty or valid target", () => { + render( + , + ) + + expect(screen.queryByText("Enter a G… or C… address.")).toBeNull() + enterTarget(TARGET) + expect(screen.queryByText("Enter a G… or C… address.")).toBeNull() + expect( + screen.getByLabelText("Target address").hasAttribute("aria-describedby"), + ).toBe(false) + }) + it("allows the target and follows it in the modal", () => { const { rerender } = render( setTargetText(event.target.value)} placeholder="Address" aria-invalid={invalid} + aria-describedby={invalid ? TARGET_ERROR_ID : undefined} spellCheck={false} autoComplete="off" />
+ {invalid && ( +

+ Enter a G… or C… address. +

+ )} From 21ab4f8af52fe56fe813114b3b34d58a078226eb Mon Sep 17 00:00:00 2001 From: luchobonatti Date: Wed, 14 Oct 2026 11:49:24 -0300 Subject: [PATCH 11/12] fix(app): clear confirmations on target change --- .../admin/ComplianceSection.test.tsx | 26 +++++++++++++++++++ .../components/admin/ComplianceSection.tsx | 9 +++++-- 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/app/src/components/admin/ComplianceSection.test.tsx b/app/src/components/admin/ComplianceSection.test.tsx index 5df2c441..e8b28e7c 100644 --- a/app/src/components/admin/ComplianceSection.test.tsx +++ b/app/src/components/admin/ComplianceSection.test.tsx @@ -40,6 +40,7 @@ vi.mock("../../config/contracts", () => ({ vaultContractId: "CABQGAYDAMBQGAYDAMBQGAYDAMBQGAYDAMBQGAYDAMBQGAYDAMBQGCK3", })) +const VAULT = "CABQGAYDAMBQGAYDAMBQGAYDAMBQGAYDAMBQGAYDAMBQGAYDAMBQGCK3" const WALLET = "GWALLETADDRESS1234" const TARGET = "GDPO5SQDJY4E2XEE47MAOT2M2F3GR4H2AYYKSMIOT2YPOHAMC36VKWIA" const UNIT = 10_000_000n @@ -156,6 +157,31 @@ describe("ComplianceSection", () => { ).toBeGreaterThan(0) }) + it("clears a vault confirmation when the target changes", () => { + render( + , + ) + enterTarget(VAULT) + openRow("Freeze the address") + fireEvent.click(screen.getByLabelText("Stops the vault moving shares.")) + expect( + screen.getByLabelText("Stops the vault moving shares.") + .checked, + ).toBe(true) + + enterTarget(TARGET) + enterTarget(VAULT) + + expect( + screen.getByLabelText("Stops the vault moving shares.") + .checked, + ).toBe(false) + }) + it("ignores an address that is not a valid key", () => { render( { + setTargetText(text) + setInputs((previous) => clearConfirmations(previous, COMPLIANCE_ACTION_IDS)) + } + const invalid = targetText.trim() !== "" && target === undefined const activeModal = modalFlows.find((props) => props.status.status !== "idle") @@ -173,7 +178,7 @@ const ComplianceSection: React.FC<{ id={TARGET_ID} className={`${typeStyles.body} ${styles.input}`} value={targetText} - onChange={(event) => setTargetText(event.target.value)} + onChange={(event) => changeTarget(event.target.value)} placeholder="Address" aria-invalid={invalid} aria-describedby={invalid ? TARGET_ERROR_ID : undefined} From bb7b7ad94e999e3da32f97ff37946c2c05b8b4f5 Mon Sep 17 00:00:00 2001 From: luchobonatti Date: Wed, 14 Oct 2026 11:54:09 -0300 Subject: [PATCH 12/12] fix(app): retry a failed verifier read --- app/src/hooks/useIsAllowed.test.ts | 24 ++++++++++++++++++++++-- app/src/hooks/useIsAllowed.ts | 9 ++++++--- 2 files changed, 28 insertions(+), 5 deletions(-) diff --git a/app/src/hooks/useIsAllowed.test.ts b/app/src/hooks/useIsAllowed.test.ts index 2e0c1ab6..02740870 100644 --- a/app/src/hooks/useIsAllowed.test.ts +++ b/app/src/hooks/useIsAllowed.test.ts @@ -1,8 +1,14 @@ import { type ContractRead } from "@stellar-scaffold/app-lib" -import { waitFor } from "@testing-library/react" +import { QueryClientProvider, useQuery } from "@tanstack/react-query" +import { renderHook, waitFor } from "@testing-library/react" +import { createElement, type ReactNode } from "react" import { beforeEach, describe, expect, it, vi } from "vitest" import { investorAddress, renderWithWallet } from "./testSupport" -import { classifyAllowance, useIsAllowed } from "./useIsAllowed" +import { + classifyAllowance, + identityVerifierQuery, + useIsAllowed, +} from "./useIsAllowed" const { tokenMock, verifierMock, identityVerifierAtMock } = vi.hoisted(() => ({ tokenMock: { identity_verifier: vi.fn() }, @@ -60,4 +66,18 @@ describe("useIsAllowed", () => { await waitFor(() => expect(result.current.allowance).toBe("unreadable")) expect(verifierMock.is_allowed).not.toHaveBeenCalled() }) + + it("reads the verifier again on the next mount after a failed read", async () => { + tokenMock.identity_verifier.mockRejectedValueOnce(new Error("rpc down")) + const { result, queryClient } = renderWithWallet(() => useIsAllowed()) + await waitFor(() => expect(result.current.allowance).toBe("unreadable")) + + renderHook(() => useQuery(identityVerifierQuery), { + wrapper: ({ children }: { children: ReactNode }) => + createElement(QueryClientProvider, { client: queryClient }, children), + }) + + await waitFor(() => expect(result.current.allowance).toBe("allowed")) + expect(tokenMock.identity_verifier).toHaveBeenCalledTimes(2) + }) }) diff --git a/app/src/hooks/useIsAllowed.ts b/app/src/hooks/useIsAllowed.ts index 9c57a096..c164cefa 100644 --- a/app/src/hooks/useIsAllowed.ts +++ b/app/src/hooks/useIsAllowed.ts @@ -45,16 +45,19 @@ export const identityVerifierQuery = queryOptions({ const read = await readContract(async () => (await shareToken()).identity_verifier(), ) - return read.kind === "value" ? read.value : null + if (read.kind !== "value") { + throw new Error(`share token identity_verifier read failed: ${read.kind}`) + } + return read.value }, staleTime: 30_000, }) export function useIsAllowed(): { allowance: Allowance } { const { address } = useWallet() - const { data: verifier } = useQuery(identityVerifierQuery) + const { data: verifier, isError } = useQuery(identityVerifierQuery) const { data } = useQuery(allowanceQuery(verifier ?? null, address)) - if (verifier === null) return { allowance: "unreadable" } + if (isError) return { allowance: "unreadable" } return { allowance: data ?? "checking" } }