feat: bridge chain artifacts to typed web reads

This commit is contained in:
golem
2026-08-21 03:11:24 -06:00
parent 54c893fdb7
commit ba393bbbc3
12 changed files with 749 additions and 1 deletions
+18
View File
@@ -0,0 +1,18 @@
import { createPublicClient, defineChain, http } from "viem";
import { baseSepolia } from "viem/chains";
import type { DeploymentManifest } from "../types/dashboard";
export const anvil = defineChain({
id: 31337,
name: "Anvil",
nativeCurrency: { name: "Ether", symbol: "ETH", decimals: 18 },
rpcUrls: { default: { http: ["http://127.0.0.1:8545"] } },
});
export const supportedChains = [anvil, baseSepolia] as const;
export function createManifestPublicClient(manifest: DeploymentManifest, viteRpcUrl = (import.meta as ImportMeta & { env?: { VITE_RPC_URL?: string } }).env?.VITE_RPC_URL) {
const rpcUrl = manifest.rpcUrl ?? viteRpcUrl;
if (!rpcUrl) throw new Error("deployment manifest has no RPC URL and VITE_RPC_URL is not configured");
return createPublicClient({ chain: manifest.chainId === anvil.id ? anvil : baseSepolia, transport: http(rpcUrl) });
}
+61
View File
@@ -0,0 +1,61 @@
import { describe, expect, it } from "vitest";
import { parseDeploymentManifest } from "./manifest";
const owner = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266";
const alice = "0x70997970C51812dc3A010C7d01b50e0d17dc79C8";
const baseManifest = {
schemaVersion: 1,
network: "anvil",
chainId: 31337,
deploymentBlock: 3,
rpcUrl: "http://127.0.0.1:8545",
token: "0x5FbDB2315678afecb367f032d93F642f64180aa3",
proxy: "0x9fE46736679d2D9a65F0992F2272dE9f3c7fa6e0",
implementation: "0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512",
owner,
actors: [{ label: "owner", address: owner }, { label: "Alice", address: alice }],
};
describe("parseDeploymentManifest", () => {
it("normalizes a valid local deployment without inventing optional URLs", () => {
const manifest = parseDeploymentManifest({ ...baseManifest, rpcUrl: undefined });
expect(manifest).toMatchObject({
schemaVersion: 1,
network: "anvil",
chainId: 31337,
deploymentBlock: 3n,
token: "0x5FbDB2315678afecb367f032d93F642f64180aa3",
});
expect(manifest.rpcUrl).toBeUndefined();
});
it("accepts a valid public Base Sepolia deployment", () => {
const manifest = parseDeploymentManifest({
...baseManifest,
network: "baseSepolia",
chainId: 84532,
rpcUrl: "https://sepolia.base.org",
explorerBaseUrl: "https://sepolia.basescan.org",
});
expect(manifest).toMatchObject({ network: "baseSepolia", chainId: 84532, deploymentBlock: 3n });
});
it.each([
["malformed JSON-shaped input", "not an object"],
["schema mismatch", { ...baseManifest, schemaVersion: 2 }],
["chain and network mismatch", { ...baseManifest, chainId: 84532 }],
["invalid address", { ...baseManifest, token: "not-an-address" }],
["zero contract address", { ...baseManifest, proxy: "0x0000000000000000000000000000000000000000" }],
["empty actor label", { ...baseManifest, actors: [{ label: "", address: owner }] }],
["duplicate actor", { ...baseManifest, actors: [{ label: "owner", address: owner }, { label: "copy", address: owner }] }],
["deployment block below one", { ...baseManifest, deploymentBlock: 0 }],
["RPC user info", { ...baseManifest, rpcUrl: "https://key@example.test" }],
["RPC key query", { ...baseManifest, rpcUrl: "https://example.test/?key=value" }],
["RPC token query", { ...baseManifest, rpcUrl: "https://example.test/?token=value" }],
["RPC secret query", { ...baseManifest, rpcUrl: "https://example.test/?secret=value" }],
])("rejects %s instead of accepting unsafe deployment state", (_name, input) => {
expect(() => parseDeploymentManifest(input)).toThrow(/manifest|address|actor|deployment|rpc/i);
});
});
+101
View File
@@ -0,0 +1,101 @@
import { getAddress, isAddress, type Address } from "viem";
import type { DeploymentManifest } from "../types/dashboard";
const requiredFields = [
"schemaVersion", "network", "chainId", "deploymentBlock", "token", "proxy", "implementation", "owner", "actors",
] as const;
const optionalFields = ["rpcUrl", "explorerBaseUrl"] as const;
const zeroAddress = "0x0000000000000000000000000000000000000000";
export function parseDeploymentManifest(value: unknown): DeploymentManifest {
if (!isRecord(value)) throw new Error("manifest must be an object");
assertSchema(value);
if (value.schemaVersion !== 1) throw new Error("manifest schemaVersion must be 1");
if (value.network !== "anvil" && value.network !== "baseSepolia") throw new Error("manifest network is unsupported");
const network = value.network;
const chainId = value.chainId;
const deploymentBlock = value.deploymentBlock;
if (typeof chainId !== "number" || !Number.isSafeInteger(chainId) || (network === "anvil" ? chainId !== 31337 : chainId !== 84532)) {
throw new Error("manifest chainId does not match network");
}
if (typeof deploymentBlock !== "number" || !Number.isSafeInteger(deploymentBlock) || deploymentBlock < 1) {
throw new Error("manifest deploymentBlock must be at least 1");
}
const rpcUrl = optionalUrl(value, "rpcUrl");
const explorerBaseUrl = optionalUrl(value, "explorerBaseUrl");
return {
schemaVersion: 1,
network,
chainId: chainId as 31337 | 84532,
deploymentBlock: BigInt(deploymentBlock),
...(rpcUrl === undefined ? {} : { rpcUrl }),
...(explorerBaseUrl === undefined ? {} : { explorerBaseUrl }),
token: parseAddress(value.token, "token"),
proxy: parseAddress(value.proxy, "proxy"),
implementation: parseAddress(value.implementation, "implementation"),
owner: parseAddress(value.owner, "owner"),
actors: parseActors(value.actors),
};
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function assertSchema(manifest: Record<string, unknown>): void {
for (const field of requiredFields) {
if (!Object.hasOwn(manifest, field)) throw new Error(`manifest is missing required field ${field}`);
}
for (const field of Object.keys(manifest)) {
if (!(requiredFields as readonly string[]).includes(field) && !(optionalFields as readonly string[]).includes(field)) {
throw new Error(`manifest contains unknown field ${field}`);
}
}
}
function parseAddress(value: unknown, field: string): Address {
if (typeof value !== "string" || !isAddress(value) || value.toLowerCase() === zeroAddress) {
throw new Error(`manifest ${field} must be a nonzero address`);
}
return getAddress(value);
}
function parseActors(value: unknown): readonly Readonly<{ label: string; address: Address }>[] {
if (!Array.isArray(value) || value.length === 0) throw new Error("manifest actors must be a nonempty array");
const labels = new Set<string>();
const addresses = new Set<string>();
return value.map((actor, index) => {
if (!isRecord(actor) || Object.keys(actor).length !== 2 || !Object.hasOwn(actor, "label") || !Object.hasOwn(actor, "address")) {
throw new Error(`manifest actors[${index}] must contain label and address`);
}
if (typeof actor.label !== "string" || actor.label.trim() === "" || labels.has(actor.label)) {
throw new Error("manifest actor labels must be unique nonempty strings");
}
const address = parseAddress(actor.address, `actors[${index}].address`);
if (addresses.has(address.toLowerCase())) throw new Error("manifest actors must be unique");
labels.add(actor.label);
addresses.add(address.toLowerCase());
return { label: actor.label, address };
});
}
function optionalUrl(manifest: Record<string, unknown>, field: "rpcUrl" | "explorerBaseUrl"): string | undefined {
const value = manifest[field];
if (value === undefined) return undefined;
if (typeof value !== "string") throw new Error(`manifest ${field} must be a public URL`);
let url: URL;
try {
url = new URL(value);
} catch {
throw new Error(`manifest ${field} must be a public URL`);
}
if ((url.protocol !== "http:" && url.protocol !== "https:") || url.username || url.password) {
throw new Error(`manifest ${field} must be a public URL without credentials`);
}
for (const key of url.searchParams.keys()) {
if (/(key|token|secret)/i.test(key)) throw new Error(`manifest ${field} must not contain credential query parameters`);
}
return value;
}
+143
View File
@@ -0,0 +1,143 @@
import { encodeAbiParameters, encodeEventTopics, parseAbiParameters, type Address, type Hex } from "viem";
import { describe, expect, it } from "vitest";
import { bankV1Abi } from "../generated/contracts";
import type { DeploymentManifest } from "../types/dashboard";
import { EIP1967_IMPLEMENTATION_SLOT, loadDashboardSnapshot, type BankReader } from "./bankClient";
const token = "0x5FbDB2315678afecb367f032d93F642f64180aa3" as const;
const proxy = "0x9fE46736679d2D9a65F0992F2272dE9f3c7fa6e0" as const;
const implementation = "0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512" as const;
const owner = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266" as const;
const alice = "0x70997970C51812dc3A010C7d01b50e0d17dc79C8" as const;
const manifest: DeploymentManifest = { schemaVersion: 1, network: "anvil", chainId: 31337, deploymentBlock: 3n, token, proxy, implementation, owner, actors: [{ label: "owner", address: owner }, { label: "Alice", address: alice }] };
const wordFor = (address: Address): Hex => `0x${"0".repeat(24)}${address.slice(2)}` as Hex;
class ReaderDouble implements BankReader {
readonly operations: string[] = [];
readonly contractCalls: Array<{ address: Address; functionName: string; args?: readonly unknown[]; blockNumber: bigint }> = [];
readonly storageCalls: Array<{ address: Address; slot: Hex; blockNumber: bigint }> = [];
readonly logCalls: Array<{ address: Address; fromBlock: bigint; toBlock: bigint }> = [];
chainId = 31337;
blockNumber = 12n;
implementation: Address = implementation;
asset: Address = token;
bankOwner: Address = owner;
reserves = 150n;
liabilities = 100n;
actorBalances = new Map<Address, bigint>([[owner, 70n], [alice, 30n]]);
logs: readonly unknown[] = [];
error?: Error;
async getChainId() { this.operations.push("chain"); return this.chainId; }
async getCode({ address }: { address: Address }) { this.operations.push(`code:${address}`); return "0x6000" as Hex; }
async getBlockNumber() { this.operations.push("block"); return this.blockNumber; }
async readContract(call: { address: Address; functionName: string; args?: readonly unknown[]; blockNumber: bigint }) {
this.operations.push(`read:${call.functionName}`); this.contractCalls.push(call);
if (this.error) throw this.error;
if (call.functionName === "contractVersion") return 1n;
if (call.functionName === "paused") return false;
if (call.functionName === "asset") return this.asset;
if (call.functionName === "owner") return this.bankOwner;
if (call.functionName === "totalLiabilities") return this.liabilities;
if (call.functionName === "balanceOf") return call.address === token ? this.reserves : this.actorBalances.get(call.args?.[0] as Address) ?? 0n;
throw new Error(`unexpected function ${call.functionName}`);
}
async getStorageAt(call: { address: Address; slot: Hex; blockNumber: bigint }) { this.operations.push("storage"); this.storageCalls.push(call); return wordFor(this.implementation); }
async getLogs(call: { address: Address; fromBlock: bigint; toBlock: bigint }) { this.operations.push("logs"); this.logCalls.push(call); return this.logs; }
}
function depositedLog(blockNumber: bigint, logIndex: number, account: Address, amount: bigint) {
return {
address: proxy,
blockNumber,
logIndex,
transactionHash: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
topics: encodeEventTopics({ abi: bankV1Abi, eventName: "Deposited", args: { account } }),
data: encodeAbiParameters(parseAbiParameters("uint256"), [amount]),
};
}
describe("loadDashboardSnapshot", () => {
it("pins every state and activity read to one latest block", async () => {
// Catches a dashboard that mixes different blocks into one accounting snapshot.
const reader = new ReaderDouble();
const snapshot = await loadDashboardSnapshot(reader, manifest);
expect(snapshot).toMatchObject({ blockNumber: 12n, version: 1, reserves: 150n, liabilities: 100n, surplus: 50n });
expect(reader.contractCalls.every((call) => call.blockNumber === 12n)).toBe(true);
expect(reader.storageCalls).toEqual([{ address: proxy, slot: EIP1967_IMPLEMENTATION_SLOT, blockNumber: 12n }]);
expect(reader.logCalls).toEqual([{ address: proxy, fromBlock: 3n, toBlock: 12n }]);
});
it("checks the endpoint chain and contract bytecode before contract state", async () => {
// Catches reads against a wrong endpoint or empty deployment addresses.
const reader = new ReaderDouble();
await loadDashboardSnapshot(reader, manifest);
expect(reader.operations.slice(0, 5)).toEqual([`chain`, `code:${proxy}`, `code:${token}`, `code:${implementation}`, `block`]);
});
it("rejects a chain that differs from the active manifest before reading contracts", async () => {
// Catches state being displayed for a different network.
const reader = new ReaderDouble(); reader.chainId = 84532;
await expect(loadDashboardSnapshot(reader, manifest)).rejects.toThrow(/chain/i);
expect(reader.contractCalls).toHaveLength(0);
});
it("reads the V1 state from the proxy and token reserve from MockUSDC", async () => {
// Catches bank calls directed at the implementation or native-balance reserve accounting.
const reader = new ReaderDouble();
const snapshot = await loadDashboardSnapshot(reader, manifest);
expect(reader.contractCalls.filter((call) => call.functionName !== "balanceOf").every((call) => call.address === proxy)).toBe(true);
expect(reader.contractCalls.find((call) => call.address === token && call.functionName === "balanceOf")?.args).toEqual([proxy]);
expect(snapshot.actors).toEqual([{ label: "owner", address: owner, balance: 70n }, { label: "Alice", address: alice, balance: 30n }]);
});
it("rejects a mismatched implementation slot, asset, or owner", async () => {
// Catches a manifest whose identity no longer describes the deployed proxy.
for (const mutate of [
(reader: ReaderDouble) => { reader.implementation = token; },
(reader: ReaderDouble) => { reader.asset = implementation; },
(reader: ReaderDouble) => { reader.bankOwner = alice; },
]) {
const reader = new ReaderDouble(); mutate(reader);
await expect(loadDashboardSnapshot(reader, manifest)).rejects.toThrow(/implementation|asset|owner/i);
}
});
it("rejects insolvency instead of fabricating a negative surplus", async () => {
// Catches under-collateralized state being presented as a valid bigint surplus.
const reader = new ReaderDouble(); reader.reserves = 99n;
await expect(loadDashboardSnapshot(reader, manifest)).rejects.toThrow(/insolvent/i);
});
it("decodes only proxy V1 events in deterministic block and log order", async () => {
// Catches V2/foreign activity or nondeterministic activity ordering in the dashboard.
const reader = new ReaderDouble();
reader.logs = [depositedLog(5n, 4, alice, 9n), depositedLog(4n, 8, owner, 7n)];
const snapshot = await loadDashboardSnapshot(reader, manifest);
expect(snapshot.activity).toEqual([
expect.objectContaining({ kind: "deposit", blockNumber: 4n, logIndex: 8, account: owner, amount: 7n }),
expect.objectContaining({ kind: "deposit", blockNumber: 5n, logIndex: 4, account: alice, amount: 9n }),
]);
expect(snapshot.diagnostics).toEqual([]);
});
it("keeps valid activity when one proxy log cannot be decoded", async () => {
// Catches a single malformed RPC log blanking the entire timeline.
const reader = new ReaderDouble();
reader.logs = [depositedLog(4n, 1, owner, 7n), { ...depositedLog(4n, 2, alice, 9n), data: "0x1234" }];
const snapshot = await loadDashboardSnapshot(reader, manifest);
expect(snapshot.activity).toHaveLength(1);
expect(snapshot.diagnostics).toEqual([expect.objectContaining({ blockNumber: 4n, logIndex: 2, message: expect.any(String) })]);
});
it("preserves a rejected contract read instead of substituting zero", async () => {
// Catches disconnected RPC reads being silently rendered as zero balances.
const reader = new ReaderDouble(); reader.error = new Error("node read failed");
await expect(loadDashboardSnapshot(reader, manifest)).rejects.toThrow("node read failed");
});
});
+145
View File
@@ -0,0 +1,145 @@
import { decodeEventLog, getAddress, isAddress, type Abi, type Address, type Hex, type PublicClient } from "viem";
import { bankV1Abi, mockUsdcAbi } from "../generated/contracts";
import type { Activity, DashboardSnapshot, DecodeDiagnostic, DeploymentManifest } from "../types/dashboard";
export const EIP1967_IMPLEMENTATION_SLOT =
"0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc";
type ContractCall = Readonly<{ address: Address; abi: Abi; functionName: string; args?: readonly unknown[]; blockNumber: bigint }>;
type StorageCall = Readonly<{ address: Address; slot: Hex; blockNumber: bigint }>;
type LogCall = Readonly<{ address: Address; fromBlock: bigint; toBlock: bigint }>;
export interface BankReader {
getChainId(): Promise<number>;
getCode(call: Readonly<{ address: Address }>): Promise<Hex | undefined>;
getBlockNumber(): Promise<bigint>;
readContract(call: ContractCall): Promise<unknown>;
getStorageAt(call: StorageCall): Promise<Hex | undefined>;
getLogs(call: LogCall): Promise<readonly unknown[]>;
}
export class ViemBankReader implements BankReader {
constructor(private readonly client: PublicClient) {}
getChainId(): Promise<number> { return this.client.getChainId(); }
getCode({ address }: Readonly<{ address: Address }>): Promise<Hex | undefined> { return this.client.getCode({ address }); }
getBlockNumber(): Promise<bigint> { return this.client.getBlockNumber(); }
readContract({ address, abi, functionName, args, blockNumber }: ContractCall): Promise<unknown> {
return this.client.readContract({ address, abi, functionName, args, blockNumber } as never);
}
getStorageAt({ address, slot, blockNumber }: StorageCall): Promise<Hex | undefined> { return this.client.getStorageAt({ address, slot, blockNumber }); }
getLogs({ address, fromBlock, toBlock }: LogCall): Promise<readonly unknown[]> { return this.client.getLogs({ address, fromBlock, toBlock }); }
}
export async function loadDashboardSnapshot(reader: BankReader, manifest: DeploymentManifest): Promise<DashboardSnapshot> {
const chainId = await reader.getChainId();
if (chainId !== manifest.chainId) throw new Error(`endpoint chain ID ${chainId} does not match manifest chain ID ${manifest.chainId}`);
for (const [label, address] of [["proxy", manifest.proxy], ["token", manifest.token], ["implementation", manifest.implementation]] as const) {
const code = await reader.getCode({ address });
if (!code || code === "0x") throw new Error(`${label} has no bytecode`);
}
const blockNumber = await reader.getBlockNumber();
const [version, paused, asset, owner, liabilities, reserves, implementationSlot, actorBalances, logs] = await Promise.all([
read(reader, manifest.proxy, bankV1Abi, "contractVersion", blockNumber),
read(reader, manifest.proxy, bankV1Abi, "paused", blockNumber),
read(reader, manifest.proxy, bankV1Abi, "asset", blockNumber),
read(reader, manifest.proxy, bankV1Abi, "owner", blockNumber),
read(reader, manifest.proxy, bankV1Abi, "totalLiabilities", blockNumber),
read(reader, manifest.token, mockUsdcAbi, "balanceOf", blockNumber, [manifest.proxy]),
reader.getStorageAt({ address: manifest.proxy, slot: EIP1967_IMPLEMENTATION_SLOT, blockNumber }),
Promise.all(manifest.actors.map(async (actor) => ({ ...actor, balance: asBigint(await read(reader, manifest.proxy, bankV1Abi, "balanceOf", blockNumber, [actor.address]), "actor balance") }))),
reader.getLogs({ address: manifest.proxy, fromBlock: manifest.deploymentBlock, toBlock: blockNumber }),
]);
if (version !== 1n) throw new Error("proxy is not running BankV1");
if (typeof paused !== "boolean") throw new Error("proxy pause state is invalid");
if (sameAddress(asAddress(asset, "asset"), manifest.token) === false) throw new Error("proxy asset does not match manifest token");
if (sameAddress(asAddress(owner, "owner"), manifest.owner) === false) throw new Error("proxy owner does not match manifest owner");
const implementation = implementationFromSlot(implementationSlot);
if (!sameAddress(implementation, manifest.implementation)) throw new Error("proxy implementation slot does not match manifest implementation");
const resolvedLiabilities = asBigint(liabilities, "liabilities");
const resolvedReserves = asBigint(reserves, "reserves");
if (resolvedReserves < resolvedLiabilities) throw new Error("proxy is insolvent: reserves are below liabilities");
const { activity, diagnostics } = decodeActivity(logs, manifest.proxy);
return {
blockNumber,
synchronizedAt: new Date(),
version: 1,
paused,
asset: manifest.token,
owner: manifest.owner,
proxy: manifest.proxy,
implementation,
reserves: resolvedReserves,
liabilities: resolvedLiabilities,
surplus: resolvedReserves - resolvedLiabilities,
actors: actorBalances,
activity,
diagnostics,
};
}
function read(reader: BankReader, address: Address, abi: Abi, functionName: string, blockNumber: bigint, args?: readonly unknown[]): Promise<unknown> {
return reader.readContract({ address, abi, functionName, ...(args === undefined ? {} : { args }), blockNumber });
}
function asBigint(value: unknown, label: string): bigint {
if (typeof value !== "bigint") throw new Error(`${label} read is invalid`);
return value;
}
function asAddress(value: unknown, label: string): Address {
if (typeof value !== "string" || !isAddress(value)) throw new Error(`${label} read is invalid`);
return getAddress(value);
}
function sameAddress(first: Address, second: Address): boolean { return first.toLowerCase() === second.toLowerCase(); }
function implementationFromSlot(value: Hex | undefined): Address {
if (typeof value !== "string" || !/^0x[0-9a-fA-F]{64}$/.test(value)) throw new Error("proxy implementation slot is invalid");
return asAddress(`0x${value.slice(-40)}`, "proxy implementation slot");
}
function decodeActivity(logs: readonly unknown[], proxy: Address): { activity: readonly Activity[]; diagnostics: readonly DecodeDiagnostic[] } {
const activity: Activity[] = [];
const diagnostics: DecodeDiagnostic[] = [];
for (const log of logs) {
if (!isLog(log) || !sameAddress(log.address, proxy)) continue;
const identity = { blockNumber: log.blockNumber, logIndex: log.logIndex, transactionHash: log.transactionHash };
try {
const decoded = decodeEventLog({ abi: bankV1Abi, data: log.data, topics: log.topics as [Hex, ...Hex[]] });
const next = decodedActivity(decoded.eventName, decoded.args, identity);
if (next) activity.push(next);
} catch {
diagnostics.push({ ...identity, message: "Could not decode a proxy event log." });
}
}
activity.sort((first, second) => first.blockNumber === second.blockNumber ? first.logIndex - second.logIndex : first.blockNumber < second.blockNumber ? -1 : 1);
diagnostics.sort((first, second) => first.blockNumber === second.blockNumber ? first.logIndex - second.logIndex : first.blockNumber < second.blockNumber ? -1 : 1);
return { activity, diagnostics };
}
function isLog(value: unknown): value is Readonly<{ address: Address; blockNumber: bigint; logIndex: number; transactionHash: Hex; data: Hex; topics: readonly Hex[] }> {
return typeof value === "object" && value !== null
&& typeof (value as { address?: unknown }).address === "string" && isAddress((value as { address: string }).address)
&& typeof (value as { blockNumber?: unknown }).blockNumber === "bigint"
&& typeof (value as { logIndex?: unknown }).logIndex === "number"
&& typeof (value as { transactionHash?: unknown }).transactionHash === "string"
&& typeof (value as { data?: unknown }).data === "string"
&& Array.isArray((value as { topics?: unknown }).topics);
}
function decodedActivity(eventName: string, args: unknown, identity: Readonly<{ blockNumber: bigint; logIndex: number; transactionHash: Hex }>): Activity | undefined {
const values = args as Record<string, unknown>;
switch (eventName) {
case "Deposited": return { ...identity, kind: "deposit", account: asAddress(values.account, "deposit account"), amount: asBigint(values.amount, "deposit amount") };
case "Withdrawn": return { ...identity, kind: "withdrawal", account: asAddress(values.account, "withdrawal account"), amount: asBigint(values.amount, "withdrawal amount") };
case "Paused": return { ...identity, kind: "paused", account: asAddress(values.account, "pause account") };
case "Unpaused": return { ...identity, kind: "unpaused", account: asAddress(values.account, "unpause account") };
case "OwnershipTransferred": return { ...identity, kind: "ownershipTransferred", previousOwner: asAddress(values.previousOwner, "previous owner"), newOwner: asAddress(values.newOwner, "new owner") };
case "Upgraded": return { ...identity, kind: "upgraded", implementation: asAddress(values.implementation, "upgraded implementation") };
default: return undefined;
}
}
+1
View File
@@ -0,0 +1 @@
+54
View File
@@ -0,0 +1,54 @@
import type { Address, Hex } from "viem";
export type DeploymentManifest = Readonly<{
schemaVersion: 1;
network: "anvil" | "baseSepolia";
chainId: 31337 | 84532;
deploymentBlock: bigint;
rpcUrl?: string;
explorerBaseUrl?: string;
token: Address;
proxy: Address;
implementation: Address;
owner: Address;
actors: readonly Readonly<{ label: string; address: Address }>[];
}>;
export type ActorBalance = Readonly<{
label: string;
address: Address;
balance: bigint;
}>;
type ActivityIdentity = Readonly<{
blockNumber: bigint;
logIndex: number;
transactionHash: Hex;
}>;
export type Activity =
| Readonly<ActivityIdentity & { kind: "deposit"; account: Address; amount: bigint }>
| Readonly<ActivityIdentity & { kind: "withdrawal"; account: Address; amount: bigint }>
| Readonly<ActivityIdentity & { kind: "paused"; account: Address }>
| Readonly<ActivityIdentity & { kind: "unpaused"; account: Address }>
| Readonly<ActivityIdentity & { kind: "ownershipTransferred"; previousOwner: Address; newOwner: Address }>
| Readonly<ActivityIdentity & { kind: "upgraded"; implementation: Address }>;
export type DecodeDiagnostic = Readonly<ActivityIdentity & { message: string }>;
export type DashboardSnapshot = Readonly<{
blockNumber: bigint;
synchronizedAt: Date;
version: 1;
paused: boolean;
asset: Address;
owner: Address;
proxy: Address;
implementation: Address;
reserves: bigint;
liabilities: bigint;
surplus: bigint;
actors: readonly ActorBalance[];
activity: readonly Activity[];
diagnostics: readonly DecodeDiagnostic[];
}>;