Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
7a87863
feat(app): grant and revoke roles from the console
luchobonatti Oct 13, 2026
99eec6d
feat(app): hand over admin in two steps
luchobonatti Oct 13, 2026
fec4ce8
feat(app): propose and apply upgrades and wind-downs
luchobonatti Oct 13, 2026
9c0567e
fix(app): show action checks as pass or fail
luchobonatti Oct 14, 2026
5389be8
fix(app): treat unreadable pending admins as unreadable
luchobonatti Oct 14, 2026
5e3c399
fix(app-lib): read a malformed pending admin as unreadable
luchobonatti Oct 14, 2026
50bf03d
refactor(app): drop type casts from governance code
luchobonatti Oct 14, 2026
d52e318
refactor(app): share one pending admins read
luchobonatti Oct 14, 2026
becf982
fix(app): tell unreadable handovers from checking
luchobonatti Oct 14, 2026
1b2e707
refactor(app): share the day constant
luchobonatti Oct 14, 2026
053201f
fix(app): word the role limit refusal exactly
luchobonatti Oct 14, 2026
8c90128
fix(app): accept any expiry the contract accepts
luchobonatti Oct 14, 2026
e9aa395
fix(app): clear a confirmation when its action changes
luchobonatti Oct 14, 2026
12b40f3
refactor(app): collapse the governance submit arms
luchobonatti Oct 14, 2026
b5a1bfc
fix(app): show the full code hash on approval
luchobonatti Oct 14, 2026
d85188b
fix(app): label the role select visibly
luchobonatti Oct 14, 2026
0f972b9
test(app): cover the governance transactions
luchobonatti Oct 14, 2026
dd432f0
fix(app): clarify role cap and expiry input
luchobonatti Oct 14, 2026
ecf1e36
fix(app): keep authority when pending read fails
luchobonatti Oct 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
169 changes: 168 additions & 1 deletion app-lib/readContract.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { nativeToScVal, xdr } from "@stellar/stellar-sdk"
import { Address, nativeToScVal, xdr } from "@stellar/stellar-sdk"
import type { Api } from "@stellar/stellar-sdk/rpc"
import { beforeEach, describe, expect, it, vi } from "vitest"
import type { DepositRequest, EpochInfo, RedeemRequest } from "./contracts"
Expand All @@ -8,6 +8,7 @@ import {
readEpochs,
readEvents,
readLedgerTime,
readPendingAdmins,
} from "./readContract"

const {
Expand Down Expand Up @@ -645,3 +646,169 @@ describe("readEvents", () => {
expect(read).toEqual({ kind: "unreadable" })
})
})

const ORACLE_ID = "CABQGAYDAMBQGAYDAMBQGAYDAMBQGAYDAMBQGAYDAMBQGAYDAMBQGCK3"
const PENDING = "GDPO5SQDJY4E2XEE47MAOT2M2F3GR4H2AYYKSMIOT2YPOHAMC36VKWIA"

const pendingAdminKey = (contractId: string): xdr.LedgerKey =>
xdr.LedgerKey.contractData(
new xdr.LedgerKeyContractData({
contract: Address.fromString(contractId).toScAddress(),
key: xdr.ScVal.scvVec([xdr.ScVal.scvSymbol("PendingAdmin")]),
durability: xdr.ContractDataDurability.temporary(),
}),
)

const pendingEntry = (
contractId: string,
liveUntilLedger: number,
ttl = liveUntilLedger,
): Api.LedgerEntryResult =>
entryFor(
pendingAdminKey(contractId).toXDR("base64"),
structScVal({
address: Address.fromString(PENDING).toScVal(),
live_until_ledger: xdr.ScVal.scvU32(liveUntilLedger),
}),
ttl,
)

const archivalKey = xdr.LedgerKey.configSetting(
new xdr.LedgerKeyConfigSetting({
configSettingId: xdr.ConfigSettingId.configSettingStateArchival(),
}),
)

const archivalEntry = (maxEntryTtl: number): Api.LedgerEntryResult => ({
key: archivalKey,
val: xdr.LedgerEntryData.configSetting(
xdr.ConfigSettingEntry.configSettingStateArchival(
new xdr.StateArchivalSettings({
maxEntryTtl,
minTemporaryTtl: 16,
minPersistentTtl: 4096,
persistentRentRateDenominator: xdr.Int64.fromString("1"),
tempRentRateDenominator: xdr.Int64.fromString("1"),
maxEntriesToArchive: 0,
liveSorobanStateSizeWindowSampleSize: 0,
liveSorobanStateSizeWindowSamplePeriod: 0,
evictionScanSize: 0,
startingEvictionScanLevel: 0,
}),
),
),
})

describe("readPendingAdmins", () => {
beforeEach(() => {
getLedgerEntriesMock.mockReset()
getLatestLedgerMock.mockReset()
getLatestLedgerMock.mockResolvedValue({
sequence: 1_000,
closeTime: "1700000000",
})
})

it("asks for each contract's temporary PendingAdmin entry and the archival limits", async () => {
getLedgerEntriesMock.mockResolvedValue({ entries: [] })

await readPendingAdmins([CONTRACT_ID, ORACLE_ID])

const sent = (getLedgerEntriesMock.mock.calls[0] as xdr.LedgerKey[]).map(
(key) => key.toXDR("base64"),
)
expect(sent).toEqual([
pendingAdminKey(CONTRACT_ID).toXDR("base64"),
pendingAdminKey(ORACLE_ID).toXDR("base64"),
archivalKey.toXDR("base64"),
])
})

it("decodes a live transfer with the ledger it was read at and the furthest allowed expiry", async () => {
getLedgerEntriesMock.mockResolvedValue({
entries: [pendingEntry(CONTRACT_ID, 1_500), archivalEntry(100_000)],
})

const read = await readPendingAdmins([CONTRACT_ID, ORACLE_ID])

expect(read).toEqual({
kind: "value",
value: {
ledger: {
sequence: 1_000,
closeTime: 1_700_000_000n,
maxLiveUntil: 100_999,
},
pending: new Map([
[
CONTRACT_ID,
{ address: PENDING, liveUntilLedger: 1_500, expired: false },
],
[ORACLE_ID, null],
]),
},
})
})

it("reads a transfer past its live_until_ledger as expired while the entry lingers", async () => {
getLedgerEntriesMock.mockResolvedValue({
entries: [pendingEntry(CONTRACT_ID, 900, 1_200)],
})

const read = await readPendingAdmins([CONTRACT_ID])

expect(
read.kind === "value" && read.value.pending.get(CONTRACT_ID),
).toEqual({ address: PENDING, liveUntilLedger: 900, expired: true })
expect(read.kind === "value" && read.value.ledger.maxLiveUntil).toBeNull()
})

it("reads an entry whose TTL lapsed as no transfer", async () => {
getLedgerEntriesMock.mockResolvedValue({
entries: [pendingEntry(CONTRACT_ID, 900)],
})

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)],
[
"a transfer without an address",
structScVal({ live_until_ledger: xdr.ScVal.scvU32(1_500) }),
],
[
"a transfer with a non-numeric expiry",
structScVal({
address: Address.fromString(PENDING).toScVal(),
live_until_ledger: xdr.ScVal.scvString("later"),
}),
],
])("is unreadable when the entry holds %s", async (_, val) => {
getLedgerEntriesMock.mockResolvedValue({
entries: [
entryFor(pendingAdminKey(CONTRACT_ID).toXDR("base64"), val, 1_500),
],
})

expect(await readPendingAdmins([CONTRACT_ID])).toEqual({
kind: "unreadable",
})
})

it.each([
["the entries", () => getLedgerEntriesMock.mockRejectedValue(new Error())],
["the ledger", () => getLatestLedgerMock.mockRejectedValue(new Error())],
])("is unreadable when %s cannot be read", async (_, fail) => {
getLedgerEntriesMock.mockResolvedValue({ entries: [] })
fail()

expect(await readPendingAdmins([CONTRACT_ID])).toEqual({
kind: "unreadable",
})
})
})
116 changes: 111 additions & 5 deletions app-lib/readContract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,13 +71,10 @@ const userRequestDataKey = (
const contractDataLedgerKey = (
contract: xdr.ScAddress,
key: xdr.ScVal,
durability = xdr.ContractDataDurability.persistent(),
): xdr.LedgerKey =>
xdr.LedgerKey.contractData(
new xdr.LedgerKeyContractData({
contract,
key,
durability: xdr.ContractDataDurability.persistent(),
}),
new xdr.LedgerKeyContractData({ contract, key, durability }),
)

type EpochKeySet = {
Expand Down Expand Up @@ -274,6 +271,115 @@ export async function readLedgerTime(): Promise<bigint | null> {
}
}

export type PendingTransfer = {
address: string
liveUntilLedger: number
expired: boolean
}

export type LedgerWindow = {
sequence: number
closeTime: bigint
maxLiveUntil: number | null
}

export type PendingAdmins = {
ledger: LedgerWindow
pending: Map<string, PendingTransfer | null>
}

const pendingAdminKey = (contractId: string): xdr.LedgerKey =>
contractDataLedgerKey(
Address.fromString(contractId).toScAddress(),
xdr.ScVal.scvVec([xdr.ScVal.scvSymbol("PendingAdmin")]),
xdr.ContractDataDurability.temporary(),
)

const archivalSettingsKey = (): xdr.LedgerKey =>
xdr.LedgerKey.configSetting(
new xdr.LedgerKeyConfigSetting({
configSettingId: xdr.ConfigSettingId.configSettingStateArchival(),
}),
)

const isPendingTransfer = (
value: unknown,
): value is { address: string; live_until_ledger: number } =>
typeof value === "object" &&
value !== null &&
"address" in value &&
typeof value.address === "string" &&
"live_until_ledger" in value &&
typeof value.live_until_ledger === "number"

const decodePendingTransfer =
(sequence: number) =>
(val: xdr.ScVal): PendingTransfer => {
const native: unknown = scValToNative(val)
if (!isPendingTransfer(native)) throw new Error("malformed pending admin")
return {
address: native.address,
liveUntilLedger: native.live_until_ledger,
expired: sequence > native.live_until_ledger,
}
}

const maxLiveUntil = (
settings: Api.LedgerEntryResult | undefined,
sequence: number,
): number | null =>
settings === undefined
? null
: sequence +
settings.val.configSetting().stateArchivalSettings().maxEntryTtl() -
1

export async function readPendingAdmins(
contractIds: string[],
): Promise<ContractRead<PendingAdmins>> {
const keyed = contractIds.map((contractId) => ({
contractId,
key: pendingAdminKey(contractId),
}))
const archival = archivalSettingsKey()
try {
const [latest, fetched] = await Promise.all([
server.getLatestLedger(),
fetchChunk([...keyed.map(({ key }) => key), archival]),
])
if (!fetched.ok) return { kind: "unreadable" }
const { sequence } = latest
const pending = new Map<string, PendingTransfer | null>()
for (const { contractId, key } of keyed) {
const read = decodeLedgerEntry(
fetched,
key,
decodePendingTransfer(sequence),
sequence,
)
if (read.kind !== "value" && read.kind !== "archived")
return { kind: "unreadable" }
pending.set(contractId, read.kind === "value" ? read.value : null)
}
return {
kind: "value",
value: {
ledger: {
sequence,
closeTime: BigInt(latest.closeTime),
maxLiveUntil: maxLiveUntil(
fetched.byKey.get(archival.toXDR("base64")),
sequence,
),
},
pending,
},
}
} catch {
return { kind: "unreadable" }
}
}

export type EventSelection = { contractId: string; names: string[] }

export type ChainEvent = {
Expand Down
15 changes: 15 additions & 0 deletions app/src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,21 @@ const disconnectedAuthority: AdminAuthority = {
grants: [],
signersUnknown: false,
addresses: { oracle: null, shareToken: null, identityVerifier: null },
holders: {
"vault governance": [],
"vault manager": [],
"vault treasury": [],
"vault guardian": [],
"oracle attester": [],
"oracle guardian": [],
"oracle admin": [],
"share-token compliance": [],
"share-token manager": [],
"share-token admin": [],
"vault pending admin": [],
"oracle pending admin": [],
"share-token pending admin": [],
},
}

const renderAt = (path: string) => {
Expand Down
Loading
Loading