feat: demonstrate state-preserving V2 upgrade
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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") };
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user