feat: demonstrate state-preserving V2 upgrade

This commit is contained in:
golem
2026-08-21 15:31:40 -06:00
parent ca6a99b913
commit 94f2ad6e09
16 changed files with 1082 additions and 36 deletions
+28
View File
@@ -128,6 +128,34 @@ describe("read-only operations console", () => {
expect(timeline.textContent).toContain("0xcccc…cccc");
});
it("renders the V2 version and exact internal transfer activity without transaction controls", () => {
const v2Snapshot: DashboardSnapshot = {
...snapshot,
version: 2,
paused: false,
actors: [
{ label: "Alice", address: address("5"), balance: 650_000_000n },
{ label: "Bob", address: address("6"), balance: 750_000_000n },
],
activity: [{
kind: "transfer",
from: address("5"),
to: address("6"),
amount: 250_000_000n,
blockNumber: 19n,
logIndex: 0,
transactionHash: transactionHash("f"),
}],
diagnostics: [],
};
const { container } = render(<App dashboard={ready(localManifest, v2Snapshot)} />);
expect(screen.getByText("Version 2")).toBeTruthy();
expect(screen.getByText("Alice transferred 250.000000 mUSDC to Bob")).toBeTruthy();
expect(container.querySelector("button, form")).toBeNull();
});
it("uses validated Base Sepolia explorer links and public shortened account labels", () => {
const baseManifest: DeploymentManifest = {
...localManifest,
+7
View File
@@ -13,6 +13,7 @@ function description(activity: Activity, manifest: DeploymentManifest): string {
switch (activity.kind) {
case "deposit": return `Deposited ${formatAmount(activity.amount)} for ${actorLabel(activity.account, manifest)}`;
case "withdrawal": return `Withdrawn ${formatAmount(activity.amount)} for ${actorLabel(activity.account, manifest)}`;
case "transfer": return `${actorLabel(activity.from, manifest)} transferred ${formatTransferAmount(activity.amount)} to ${actorLabel(activity.to, manifest)}`;
case "paused": return `Paused by ${actorLabel(activity.account, manifest)}`;
case "unpaused": return `Unpaused by ${actorLabel(activity.account, manifest)}`;
case "ownershipTransferred": return `Ownership transferred from ${actorLabel(activity.previousOwner, manifest)} to ${actorLabel(activity.newOwner, manifest)}`;
@@ -20,6 +21,12 @@ function description(activity: Activity, manifest: DeploymentManifest): string {
}
}
function formatTransferAmount(amount: bigint): string {
const whole = amount / 1_000_000n;
const fraction = (amount % 1_000_000n).toString().padStart(6, "0");
return `${new Intl.NumberFormat("en-US").format(whole)}.${fraction} mUSDC`;
}
function transactionUrl(manifest: DeploymentManifest, transactionHash: Hex): string | undefined {
if (!manifest.explorerBaseUrl) return undefined;
return `${manifest.explorerBaseUrl.replace(/\/$/, "")}/tx/${transactionHash}`;
+29 -2
View File
@@ -1,6 +1,6 @@
import { encodeAbiParameters, encodeEventTopics, parseAbiParameters, type Address, type Hex } from "viem";
import { describe, expect, it } from "vitest";
import { bankV1Abi } from "../generated/contracts";
import { bankV1Abi, bankV2Abi } from "../generated/contracts";
import type { DeploymentManifest } from "../types/dashboard";
import { EIP1967_IMPLEMENTATION_SLOT, loadDashboardSnapshot, type BankReader } from "./bankClient";
@@ -27,6 +27,7 @@ class ReaderDouble implements BankReader {
actorBalances = new Map<Address, bigint>([[owner, 70n], [alice, 30n]]);
logs: readonly unknown[] = [];
error?: Error;
version = 1n;
async getChainId() { this.operations.push("chain"); return this.chainId; }
async getCode({ address }: { address: Address }) { this.operations.push(`code:${address}`); return "0x6000" as Hex; }
@@ -34,7 +35,7 @@ class ReaderDouble implements BankReader {
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 === "contractVersion") return this.version;
if (call.functionName === "paused") return false;
if (call.functionName === "asset") return this.asset;
if (call.functionName === "owner") return this.bankOwner;
@@ -46,6 +47,17 @@ class ReaderDouble implements BankReader {
async getLogs(call: { address: Address; fromBlock: bigint; toBlock: bigint }) { this.operations.push("logs"); this.logCalls.push(call); return this.logs; }
}
function transferredLog(blockNumber: bigint, logIndex: number, from: Address, to: Address, amount: bigint) {
return {
address: proxy,
blockNumber,
logIndex,
transactionHash: "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
topics: encodeEventTopics({ abi: bankV2Abi, eventName: "BalanceTransferred", args: { from, to } }),
data: encodeAbiParameters(parseAbiParameters("uint256"), [amount]),
};
}
function depositedLog(blockNumber: bigint, logIndex: number, account: Address, amount: bigint) {
return {
address: proxy,
@@ -125,6 +137,21 @@ describe("loadDashboardSnapshot", () => {
expect(snapshot.diagnostics).toEqual([]);
});
it("loads a V2 snapshot through the proxy and decodes its internal transfer", async () => {
// Catches V2 being rejected or BalanceTransferred being decoded with the V1-only ABI.
const reader = new ReaderDouble();
reader.version = 2n;
reader.logs = [transferredLog(9n, 2, alice, owner, 25n)];
const snapshot = await loadDashboardSnapshot(reader, manifest);
expect(snapshot.version).toBe(2);
expect(snapshot.activity).toEqual([
expect.objectContaining({ kind: "transfer", from: alice, to: owner, amount: 25n, blockNumber: 9n, logIndex: 2 }),
]);
expect(reader.contractCalls.filter((call) => call.address !== token).every((call) => call.address === proxy)).toBe(true);
});
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();
+8 -6
View File
@@ -1,5 +1,5 @@
import { decodeEventLog, getAddress, isAddress, type Abi, type Address, type Hex, type PublicClient } from "viem";
import { bankV1Abi, mockUsdcAbi } from "../generated/contracts";
import { bankV1Abi, bankV2Abi, mockUsdcAbi } from "../generated/contracts";
import type { Activity, DashboardSnapshot, DecodeDiagnostic, DeploymentManifest } from "../types/dashboard";
export const EIP1967_IMPLEMENTATION_SLOT =
@@ -52,7 +52,7 @@ export async function loadDashboardSnapshot(reader: BankReader, manifest: Deploy
reader.getLogs({ address: manifest.proxy, fromBlock: manifest.deploymentBlock, toBlock: blockNumber }),
]);
if (version !== 1n) throw new Error("proxy is not running BankV1");
if (version !== 1n && version !== 2n) throw new Error("proxy contract version is unsupported");
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");
@@ -62,11 +62,12 @@ export async function loadDashboardSnapshot(reader: BankReader, manifest: Deploy
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);
const resolvedVersion = Number(version) as 1 | 2;
const { activity, diagnostics } = decodeActivity(logs, manifest.proxy, resolvedVersion);
return {
blockNumber,
synchronizedAt: new Date(),
version: 1,
version: resolvedVersion,
paused,
asset: manifest.token,
owner: manifest.owner,
@@ -102,7 +103,7 @@ function implementationFromSlot(value: Hex | undefined): Address {
return asAddress(`0x${value.slice(-40)}`, "proxy implementation slot");
}
function decodeActivity(logs: readonly unknown[], proxy: Address): { activity: readonly Activity[]; diagnostics: readonly DecodeDiagnostic[] } {
function decodeActivity(logs: readonly unknown[], proxy: Address, version: 1 | 2): { activity: readonly Activity[]; diagnostics: readonly DecodeDiagnostic[] } {
const activity: Activity[] = [];
const diagnostics: DecodeDiagnostic[] = [];
for (const log of logs) {
@@ -110,7 +111,7 @@ function decodeActivity(logs: readonly unknown[], proxy: Address): { activity: r
const identity = { blockNumber: log.blockNumber, logIndex: log.logIndex, transactionHash: log.transactionHash };
try {
if (!isLogPayload(log)) throw new Error("proxy event payload is malformed");
const decoded = decodeEventLog({ abi: bankV1Abi, data: log.data, topics: log.topics as [Hex, ...Hex[]] });
const decoded = decodeEventLog({ abi: version === 2 ? bankV2Abi : bankV1Abi, data: log.data, topics: log.topics as [Hex, ...Hex[]] });
const next = decodedActivity(decoded.eventName, decoded.args, identity);
if (next) activity.push(next);
} catch {
@@ -141,6 +142,7 @@ function decodedActivity(eventName: string, args: unknown, identity: Readonly<{
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 "BalanceTransferred": return { ...identity, kind: "transfer", from: asAddress(values.from, "transfer sender"), to: asAddress(values.to, "transfer recipient"), amount: asBigint(values.amount, "transfer 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") };
+2 -1
View File
@@ -29,6 +29,7 @@ type ActivityIdentity = Readonly<{
export type Activity =
| Readonly<ActivityIdentity & { kind: "deposit"; account: Address; amount: bigint }>
| Readonly<ActivityIdentity & { kind: "withdrawal"; account: Address; amount: bigint }>
| Readonly<ActivityIdentity & { kind: "transfer"; from: Address; to: Address; amount: bigint }>
| Readonly<ActivityIdentity & { kind: "paused"; account: Address }>
| Readonly<ActivityIdentity & { kind: "unpaused"; account: Address }>
| Readonly<ActivityIdentity & { kind: "ownershipTransferred"; previousOwner: Address; newOwner: Address }>
@@ -39,7 +40,7 @@ export type DecodeDiagnostic = Readonly<ActivityIdentity & { message: string }>;
export type DashboardSnapshot = Readonly<{
blockNumber: bigint;
synchronizedAt: Date;
version: 1;
version: 1 | 2;
paused: boolean;
asset: Address;
owner: Address;