From db377c2bb336b917a246a0779f468250514df5ba Mon Sep 17 00:00:00 2001 From: golem Date: Mon, 17 Aug 2026 16:01:42 -0600 Subject: [PATCH 01/30] build: pin demo toolchains --- .env.example | 1 + .gitignore | 16 + .gitmodules | 9 + .nvmrc | 1 + Makefile | 19 + foundry.lock | 20 + foundry.toml | 27 + lib/forge-std | 1 + lib/openzeppelin-contracts-upgradeable | 1 + lib/openzeppelin-foundry-upgrades | 1 + package-lock.json | 1258 +++++++ package.json | 14 + remappings.txt | 4 + tools/check-upgrades-cli.mjs | 28 + tools/doctor.sh | 13 + web/eslint.config.js | 24 + web/index.html | 11 + web/package-lock.json | 4500 ++++++++++++++++++++++++ web/package.json | 40 + web/src/config/toolchain.ts | 6 + web/src/test/setup.ts | 1 + web/src/test/toolchain.test.ts | 13 + web/tsconfig.app.json | 20 + web/tsconfig.json | 7 + web/tsconfig.node.json | 16 + web/vite.config.ts | 11 + 26 files changed, 6062 insertions(+) create mode 100644 .env.example create mode 100644 .gitmodules create mode 100644 .nvmrc create mode 100644 Makefile create mode 100644 foundry.lock create mode 100644 foundry.toml create mode 160000 lib/forge-std create mode 160000 lib/openzeppelin-contracts-upgradeable create mode 160000 lib/openzeppelin-foundry-upgrades create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 remappings.txt create mode 100644 tools/check-upgrades-cli.mjs create mode 100755 tools/doctor.sh create mode 100644 web/eslint.config.js create mode 100644 web/index.html create mode 100644 web/package-lock.json create mode 100644 web/package.json create mode 100644 web/src/config/toolchain.ts create mode 100644 web/src/test/setup.ts create mode 100644 web/src/test/toolchain.test.ts create mode 100644 web/tsconfig.app.json create mode 100644 web/tsconfig.json create mode 100644 web/tsconfig.node.json create mode 100644 web/vite.config.ts diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..591c6c2 --- /dev/null +++ b/.env.example @@ -0,0 +1 @@ +# Copy this file to .env for local-only configuration. diff --git a/.gitignore b/.gitignore index f927ba5..ab5e232 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,18 @@ .superpowers/ .worktrees/ +.env +.env.local +.demo/ +cache/ +out/ +broadcast/ +deployments/*.json +deployments/**/*.json +!deployments/*.example.json +node_modules/ +web/node_modules/ +web/dist/ +web/coverage/ +web/public/deployment.json +web/src/generated/*.ts +!.gitkeep diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..23acfb1 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,9 @@ +[submodule "lib/forge-std"] + path = lib/forge-std + url = https://github.com/foundry-rs/forge-std +[submodule "lib/openzeppelin-foundry-upgrades"] + path = lib/openzeppelin-foundry-upgrades + url = https://github.com/OpenZeppelin/openzeppelin-foundry-upgrades +[submodule "lib/openzeppelin-contracts-upgradeable"] + path = lib/openzeppelin-contracts-upgradeable + url = https://github.com/OpenZeppelin/openzeppelin-contracts-upgradeable diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..ca5c350 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +24.18.0 diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..00edc2c --- /dev/null +++ b/Makefile @@ -0,0 +1,19 @@ +SHELL := /bin/bash +.SHELLFLAGS := -euo pipefail -c + +.PHONY: doctor setup verify +doctor: + @./tools/doctor.sh +setup: + @git submodule update --init --recursive + @npm ci + @npm --prefix web ci +verify: + @forge fmt --check + @forge clean + @npm_config_offline=true forge build --force + @npm_config_offline=true forge test --force + @npm --prefix web run lint + @npm --prefix web run typecheck + @npm --prefix web test + @npm --prefix web run build diff --git a/foundry.lock b/foundry.lock new file mode 100644 index 0000000..ebdd117 --- /dev/null +++ b/foundry.lock @@ -0,0 +1,20 @@ +{ + "lib/forge-std": { + "tag": { + "name": "v1.16.1", + "rev": "620536fa5277db4e3fd46772d5cbc1ea0696fb43" + } + }, + "lib/openzeppelin-contracts-upgradeable": { + "tag": { + "name": "v5.6.1", + "rev": "7bf4727aacdbfaa0f36cbd664654d0c9e1dc52bf" + } + }, + "lib/openzeppelin-foundry-upgrades": { + "tag": { + "name": "v0.4.1", + "rev": "258e12e727bfe7f0ec30c51995d01ec88b82efc1" + } + } +} \ No newline at end of file diff --git a/foundry.toml b/foundry.toml new file mode 100644 index 0000000..7706bc4 --- /dev/null +++ b/foundry.toml @@ -0,0 +1,27 @@ +[profile.default] +src = "src" +test = "test" +script = "script" +out = "out" +libs = ["lib"] +solc_version = "0.8.35" +evm_version = "cancun" +optimizer = true +optimizer_runs = 200 +ffi = true +ast = true +build_info = true +extra_output = ["storageLayout"] +fs_permissions = [ + { access = "read", path = "out" }, + { access = "read-write", path = "deployments" } +] + +[fuzz] +runs = 512 +seed = "0x5555505342414e4b" + +[invariant] +runs = 128 +depth = 64 +fail_on_revert = true diff --git a/lib/forge-std b/lib/forge-std new file mode 160000 index 0000000..620536f --- /dev/null +++ b/lib/forge-std @@ -0,0 +1 @@ +Subproject commit 620536fa5277db4e3fd46772d5cbc1ea0696fb43 diff --git a/lib/openzeppelin-contracts-upgradeable b/lib/openzeppelin-contracts-upgradeable new file mode 160000 index 0000000..7bf4727 --- /dev/null +++ b/lib/openzeppelin-contracts-upgradeable @@ -0,0 +1 @@ +Subproject commit 7bf4727aacdbfaa0f36cbd664654d0c9e1dc52bf diff --git a/lib/openzeppelin-foundry-upgrades b/lib/openzeppelin-foundry-upgrades new file mode 160000 index 0000000..258e12e --- /dev/null +++ b/lib/openzeppelin-foundry-upgrades @@ -0,0 +1 @@ +Subproject commit 258e12e727bfe7f0ec30c51995d01ec88b82efc1 diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..73ed416 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1258 @@ +{ + "name": "uups-bank-demo", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "uups-bank-demo", + "version": "0.1.0", + "devDependencies": { + "@openzeppelin/upgrades-core": "1.46.0" + }, + "engines": { + "node": ">=24.18.0 <25", + "npm": ">=11.17.0 <12" + } + }, + "node_modules/@bytecodealliance/preview2-shim": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@bytecodealliance/preview2-shim/-/preview2-shim-0.17.0.tgz", + "integrity": "sha512-JorcEwe4ud0x5BS/Ar2aQWOQoFzjq/7jcnxYXCvSMh0oRm0dQXzOA+hqLDBnOMks1LLBA7dmiLLsEBl09Yd6iQ==", + "dev": true, + "license": "(Apache-2.0 WITH LLVM-exception)" + }, + "node_modules/@nomicfoundation/slang": { + "version": "0.18.3", + "resolved": "https://registry.npmjs.org/@nomicfoundation/slang/-/slang-0.18.3.tgz", + "integrity": "sha512-YqAWgckqbHM0/CZxi9Nlf4hjk9wUNLC9ngWCWBiqMxPIZmzsVKYuChdlrfeBPQyvQQBoOhbx+7C1005kLVQDZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bytecodealliance/preview2-shim": "0.17.0" + } + }, + "node_modules/@openzeppelin/upgrades-core": { + "version": "1.46.0", + "resolved": "https://registry.npmjs.org/@openzeppelin/upgrades-core/-/upgrades-core-1.46.0.tgz", + "integrity": "sha512-UFSeO/4r8eeXj0C/HAwV+J4b72sE1HX0aALQFs5S2RBOsfXvKweyjQf35vrK32LQiyHdP6IPShAsEBVvpSEgGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nomicfoundation/slang": "^0.18.3", + "bignumber.js": "^9.1.2", + "cbor": "^10.0.0", + "chalk": "^4.1.0", + "compare-versions": "^6.0.0", + "debug": "^4.1.1", + "ethereumjs-util": "^7.0.3", + "minimatch": "^10.2.5", + "minimist": "^1.2.7", + "proper-lockfile": "^4.1.1", + "solidity-ast": "^0.4.60" + }, + "bin": { + "openzeppelin-upgrades-core": "dist/cli/cli.js" + } + }, + "node_modules/@types/bn.js": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@types/bn.js/-/bn.js-5.2.0.tgz", + "integrity": "sha512-DLbJ1BPqxvQhIGbeu8VbUC1DiAiahHtAYvA0ZEAa4P31F7IaArc8z3C3BRQdWX4mtLQuABG4yzp76ZrS02Ui1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/node": { + "version": "26.2.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.2.0.tgz", + "integrity": "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.0" + } + }, + "node_modules/@types/pbkdf2": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@types/pbkdf2/-/pbkdf2-3.1.2.tgz", + "integrity": "sha512-uRwJqmiXmh9++aSu1VNEn3iIxWOhd8AHXNSdlaLfdAAdSTY9jYVeGWnzejM3dvrkbqE3/hyQkQQ29IFATEGlew==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/secp256k1": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/@types/secp256k1/-/secp256k1-4.0.7.tgz", + "integrity": "sha512-Rcvjl6vARGAKRO6jHeKMatGrvOMGrR/AR11N1x2LqintPCyDZ7NBhrh238Z2VZc7aM7KIwnFpFQ7fnfK4H/9Qw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/base-x": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/base-x/-/base-x-3.0.11.tgz", + "integrity": "sha512-xz7wQ8xDhdyP7tQxwdteLYeFfS68tSMNCZ/Y37WJ4bhGfKPpqEIlmIyueQHqOyoPhE6xNUqjzRr8ra0eF9VRvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/blakejs": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/blakejs/-/blakejs-1.2.1.tgz", + "integrity": "sha512-QXUSXI3QVc/gJME0dBpXrag1kbzOqCjCX8/b54ntNyW6sjtoqxqRk3LTmXzaJoh71zMsDCjM+47jS7XiwN/+fQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/bn.js": { + "version": "5.2.5", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.5.tgz", + "integrity": "sha512-Vq886eXykuP5E6HcKSSStP3bJgrE6In5WKxVUvJ8XGpWWYs2xZHWqUwzCtGgEtBcxyd57KBFDPFoUfNzdaHCNg==", + "dev": true, + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/brorand": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/brorand/-/brorand-1.1.0.tgz", + "integrity": "sha512-cKV8tMCEpQs4hK/ik71d6LrPOnpkpGBR0wzxqr68g2m/LB2GxVYQroAjMJZRVM1Y4BCjCKc3vAamxSzOY2RP+w==", + "dev": true, + "license": "MIT" + }, + "node_modules/browserify-aes": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/browserify-aes/-/browserify-aes-1.2.0.tgz", + "integrity": "sha512-+7CHXqGuspUn/Sl5aO7Ea0xWGAtETPXNSAjHo48JfLdPWcMng33Xe4znFvQweqc/uzk5zSOI3H52CYnjCfb5hA==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-xor": "^1.0.3", + "cipher-base": "^1.0.0", + "create-hash": "^1.1.0", + "evp_bytestokey": "^1.0.3", + "inherits": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/bs58": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/bs58/-/bs58-4.0.1.tgz", + "integrity": "sha512-Ok3Wdf5vOIlBrgCvTq96gBkJw+JUEzdBgyaza5HLtPm7yTHkjRy8+JzNyHF7BHa0bNWOQIp3m5YF0nnFcOIKLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "base-x": "^3.0.2" + } + }, + "node_modules/bs58check": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/bs58check/-/bs58check-2.1.2.tgz", + "integrity": "sha512-0TS1jicxdU09dwJMNZtVAfzPi6Q6QeN0pM1Fkzrjn+XYHvzMKPU3pHVpva+769iNVSfIYWf7LJ6WR+BuuMf8cA==", + "dev": true, + "license": "MIT", + "dependencies": { + "bs58": "^4.0.0", + "create-hash": "^1.1.0", + "safe-buffer": "^5.1.2" + } + }, + "node_modules/buffer-xor": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/buffer-xor/-/buffer-xor-1.0.3.tgz", + "integrity": "sha512-571s0T7nZWK6vB67HI5dyUF7wXiNcfaPPPTl6zYCNApANjIvYJTg7hlud/+cJpdAhS7dVzqMLmfhfHR3rAcOjQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/call-bind": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/cbor": { + "version": "10.0.12", + "resolved": "https://registry.npmjs.org/cbor/-/cbor-10.0.12.tgz", + "integrity": "sha512-exQDevYd7ZQLP4moMQcZkKCVZsXLAtUSflObr3xTh4xzFIv/xBCdvCd6L259kQOUP2kcTC0jvC6PpZIf/WmRXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "nofilter": "^3.0.2" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/cipher-base": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cipher-base/-/cipher-base-1.0.7.tgz", + "integrity": "sha512-Mz9QMT5fJe7bKI7MH31UilT5cEK5EHHRCccw/YRFsRY47AuNgaV6HY3rscp0/I4Q+tTW/5zoqpSeRRI54TkDWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.4", + "safe-buffer": "^5.2.1", + "to-buffer": "^1.2.2" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/compare-versions": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/compare-versions/-/compare-versions-6.1.1.tgz", + "integrity": "sha512-4hm4VPpIecmlg59CHXnRDnqGplJFrbLG4aFEl5vl6cK1u76ws3LLvX7ikFnTDl5vo39sjWD6AaDPYodJp/NNHg==", + "dev": true, + "license": "MIT" + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/create-hash": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/create-hash/-/create-hash-1.2.0.tgz", + "integrity": "sha512-z00bCGNHDG8mHAkP7CtT1qVu+bFQUPjYq/4Iv3C3kWjTFV10zIjfSoeqXo9Asws8gwSHDGj/hl2u4OGIjapeCg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cipher-base": "^1.0.1", + "inherits": "^2.0.1", + "md5.js": "^1.3.4", + "ripemd160": "^2.0.1", + "sha.js": "^2.4.0" + } + }, + "node_modules/create-hmac": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/create-hmac/-/create-hmac-1.1.7.tgz", + "integrity": "sha512-MJG9liiZ+ogc4TzUwuvbER1JRdgvUFSB5+VR/g5h82fGaIRWMWddtKBHi7/sVhfjQZ6SehlyhvQYrcYkaUIpLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cipher-base": "^1.0.3", + "create-hash": "^1.1.0", + "inherits": "^2.0.1", + "ripemd160": "^2.0.0", + "safe-buffer": "^5.0.1", + "sha.js": "^2.4.8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/elliptic": { + "version": "6.6.1", + "resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.6.1.tgz", + "integrity": "sha512-RaddvvMatK2LJHqFJ+YA4WysVN5Ita9E35botqIYspQ4TkRAlCicdzKOjlyv/1Za5RyTNn7di//eEV0uTAfe3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "bn.js": "^4.11.9", + "brorand": "^1.1.0", + "hash.js": "^1.0.0", + "hmac-drbg": "^1.0.1", + "inherits": "^2.0.4", + "minimalistic-assert": "^1.0.1", + "minimalistic-crypto-utils": "^1.0.1" + } + }, + "node_modules/elliptic/node_modules/bn.js": { + "version": "4.12.5", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.5.tgz", + "integrity": "sha512-3aRg6/JxfffFD+OlOjOFR3Vo79l39ooBTFucxx+MT3dhCtzn3EmiUPQo+6/OZuI2jbXi3YKgmiTFBgChQMwIRQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ethereum-cryptography": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/ethereum-cryptography/-/ethereum-cryptography-0.1.3.tgz", + "integrity": "sha512-w8/4x1SGGzc+tO97TASLja6SLd3fRIK2tLVcV2Gx4IB21hE19atll5Cq9o3d0ZmAYC/8aw0ipieTSiekAea4SQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/pbkdf2": "^3.0.0", + "@types/secp256k1": "^4.0.1", + "blakejs": "^1.1.0", + "browserify-aes": "^1.2.0", + "bs58check": "^2.1.2", + "create-hash": "^1.2.0", + "create-hmac": "^1.1.7", + "hash.js": "^1.1.7", + "keccak": "^3.0.0", + "pbkdf2": "^3.0.17", + "randombytes": "^2.1.0", + "safe-buffer": "^5.1.2", + "scrypt-js": "^3.0.0", + "secp256k1": "^4.0.1", + "setimmediate": "^1.0.5" + } + }, + "node_modules/ethereumjs-util": { + "version": "7.1.5", + "resolved": "https://registry.npmjs.org/ethereumjs-util/-/ethereumjs-util-7.1.5.tgz", + "integrity": "sha512-SDl5kKrQAudFBUe5OJM9Ac6WmMyYmXX/6sTmLZ3ffG2eY6ZIGBes3pEDxNN6V72WyOw4CPD5RomKdsa8DAAwLg==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "@types/bn.js": "^5.1.0", + "bn.js": "^5.1.2", + "create-hash": "^1.1.2", + "ethereum-cryptography": "^0.1.3", + "rlp": "^2.2.4" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/evp_bytestokey": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/evp_bytestokey/-/evp_bytestokey-1.0.3.tgz", + "integrity": "sha512-/f2Go4TognH/KvCISP7OUsHn85hT9nUkxxA9BEWxFn+Oj9o8ZNLm/40hdlgSLyuOimsrTKLUMEorQexp/aPQeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "md5.js": "^1.3.4", + "safe-buffer": "^5.1.1" + } + }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hash-base": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/hash-base/-/hash-base-3.1.2.tgz", + "integrity": "sha512-Bb33KbowVTIj5s7Ked1OsqHUeCpz//tPwR+E2zJgJKo9Z5XolZ9b6bdUgjmYlwnWhoOQKoTd1TYToZGn5mAYOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.4", + "readable-stream": "^2.3.8", + "safe-buffer": "^5.2.1", + "to-buffer": "^1.2.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/hash-base/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/hash-base/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/hash-base/node_modules/readable-stream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/hash-base/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/hash-base/node_modules/string_decoder/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/hash.js": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz", + "integrity": "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "minimalistic-assert": "^1.0.1" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hmac-drbg": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/hmac-drbg/-/hmac-drbg-1.0.1.tgz", + "integrity": "sha512-Tti3gMqLdZfhOQY1Mzf/AanLiqh1WTiJgEj26ZuYQ9fbkLomzGchCws4FyrSd4VkpBfiNhaE1On+lOz894jvXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "hash.js": "^1.0.3", + "minimalistic-assert": "^1.0.0", + "minimalistic-crypto-utils": "^1.0.1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, + "node_modules/keccak": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/keccak/-/keccak-3.0.4.tgz", + "integrity": "sha512-3vKuW0jV8J3XNTzvfyicFR5qvxrSAGl7KIhvgOu5cmWwM7tZRj3fMbj/pfIf4be7aznbc+prBWGjywox/g2Y6Q==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^2.0.0", + "node-gyp-build": "^4.2.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/md5.js": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/md5.js/-/md5.js-1.3.5.tgz", + "integrity": "sha512-xitP+WxNPcTTOgnTJcrhM0xvdPepipPSf3I8EIpGKeFLjt3PlJLIDG3u8EX53ZIubkb+5U2+3rELYpEhHhzdkg==", + "dev": true, + "license": "MIT", + "dependencies": { + "hash-base": "^3.0.0", + "inherits": "^2.0.1", + "safe-buffer": "^5.1.2" + } + }, + "node_modules/minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", + "dev": true, + "license": "ISC" + }, + "node_modules/minimalistic-crypto-utils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-crypto-utils/-/minimalistic-crypto-utils-1.0.1.tgz", + "integrity": "sha512-JIYlbt6g8i5jKfJ3xz7rF0LXmv2TkDxBLUkiBeZ7bAx4GnnNMr8xFpGnOxn6GhTEHx3SjRrZEoU+j04prX1ktg==", + "dev": true, + "license": "MIT" + }, + "node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-addon-api": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-2.0.2.tgz", + "integrity": "sha512-Ntyt4AIXyaLIuMHF6IOoTakB3K+RWxwtsHNRxllEoA6vPwP9o4866g6YWDLUdnucilZhmkxiHwHr11gAENw+QA==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "dev": true, + "license": "MIT", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, + "node_modules/nofilter": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/nofilter/-/nofilter-3.1.0.tgz", + "integrity": "sha512-l2NNj07e9afPnhAhvgVrCD/oy2Ai1yfLpuo3EpiO1jFTsB4sFz6oIfAfSZyQzVpkZQ9xS8ZS5g1jCBgq4Hwo0g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.19" + } + }, + "node_modules/pbkdf2": { + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/pbkdf2/-/pbkdf2-3.1.6.tgz", + "integrity": "sha512-BT6eelPB1EyGHo8pC0o9Bl6k6SYVhKO1jEbd3lcTrtr7XHdjP8BW1YpfCV3G9Kwkxgattk+S5q2/RvuttCsS1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "create-hash": "^1.2.0", + "create-hmac": "^1.1.7", + "ripemd160": "^2.0.3", + "safe-buffer": "^5.2.1", + "sha.js": "^2.4.12", + "to-buffer": "^1.2.2" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "dev": true, + "license": "MIT" + }, + "node_modules/proper-lockfile": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", + "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "retry": "^0.12.0", + "signal-exit": "^3.0.2" + } + }, + "node_modules/randombytes": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", + "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "^5.1.0" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/ripemd160": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/ripemd160/-/ripemd160-2.0.3.tgz", + "integrity": "sha512-5Di9UC0+8h1L6ZD2d7awM7E/T4uA1fJRlx6zk/NvdCCVEoAnFqvHmCuNeIKoCeIixBX/q8uM+6ycDvF8woqosA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hash-base": "^3.1.2", + "inherits": "^2.0.4" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/rlp": { + "version": "2.2.7", + "resolved": "https://registry.npmjs.org/rlp/-/rlp-2.2.7.tgz", + "integrity": "sha512-d5gdPmgQ0Z+AklL2NVXr/IoSjNZFfTVvQWzL/AM2AOcSzYP2xjlb0AC8YyCLc41MSNf6P6QVtjgPdmVtzb+4lQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "bn.js": "^5.2.0" + }, + "bin": { + "rlp": "bin/rlp" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/scrypt-js": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/scrypt-js/-/scrypt-js-3.0.1.tgz", + "integrity": "sha512-cdwTTnqPu0Hyvf5in5asVdZocVDTNRmR7XEcJuIzMjJeSHybHl7vpB66AzwTaIg6CLSbtjcxc8fqcySfnTkccA==", + "dev": true, + "license": "MIT" + }, + "node_modules/secp256k1": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/secp256k1/-/secp256k1-4.0.5.tgz", + "integrity": "sha512-SQZi5+/uiJIFPYbeRrVuu77Sr3bFOTq0oCQs67CqYwdmg0lhnqi/8djSWhzNO3GKGOqxBYCdx8zJJv0zUwDDvw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "elliptic": "^6.5.7", + "node-addon-api": "^5.0.0", + "node-gyp-build": "^4.2.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/secp256k1/node_modules/node-addon-api": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-5.1.0.tgz", + "integrity": "sha512-eh0GgfEkpnoWDq+VY8OyvYhFEzBk6jIYbRKdIlyTiAXIVJ8PyBaKb0rp7oDtoddbdoHWhq8wwr+XZ81F1rpNdA==", + "dev": true, + "license": "MIT" + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "dev": true, + "license": "MIT" + }, + "node_modules/sha.js": { + "version": "2.4.12", + "resolved": "https://registry.npmjs.org/sha.js/-/sha.js-2.4.12.tgz", + "integrity": "sha512-8LzC5+bvI45BjpfXU8V5fdU2mfeKiQe1D1gIMn7XUlF3OTUrpdJpPPH4EMAnF0DsHHdSZqCdSss5qCmJKuiO3w==", + "dev": true, + "license": "(MIT AND BSD-3-Clause)", + "dependencies": { + "inherits": "^2.0.4", + "safe-buffer": "^5.2.1", + "to-buffer": "^1.2.0" + }, + "bin": { + "sha.js": "bin.js" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/solidity-ast": { + "version": "0.4.62", + "resolved": "https://registry.npmjs.org/solidity-ast/-/solidity-ast-0.4.62.tgz", + "integrity": "sha512-jSC7msQCkJXIzM8LlDjRZ5cif5w40g6THlXHFk3zchbL5dm3YLoBETvqPGo5KndYkftjhcs5kz1fnTu4d34lVQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/to-buffer": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/to-buffer/-/to-buffer-1.2.2.tgz", + "integrity": "sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "isarray": "^2.0.5", + "safe-buffer": "^5.2.1", + "typed-array-buffer": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/which-typed-array": { + "version": "1.1.22", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.22.tgz", + "integrity": "sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..1612ecb --- /dev/null +++ b/package.json @@ -0,0 +1,14 @@ +{ + "name": "uups-bank-demo", + "private": true, + "version": "0.1.0", + "type": "module", + "packageManager": "npm@11.17.0", + "engines": { + "node": ">=24.18.0 <25", + "npm": ">=11.17.0 <12" + }, + "devDependencies": { + "@openzeppelin/upgrades-core": "1.46.0" + } +} diff --git a/remappings.txt b/remappings.txt new file mode 100644 index 0000000..e701804 --- /dev/null +++ b/remappings.txt @@ -0,0 +1,4 @@ +forge-std/=lib/forge-std/src/ +openzeppelin-foundry-upgrades/=lib/openzeppelin-foundry-upgrades/src/ +@openzeppelin/contracts/=lib/openzeppelin-contracts-upgradeable/lib/openzeppelin-contracts/contracts/ +@openzeppelin/contracts-upgradeable/=lib/openzeppelin-contracts-upgradeable/contracts/ diff --git a/tools/check-upgrades-cli.mjs b/tools/check-upgrades-cli.mjs new file mode 100644 index 0000000..61d5706 --- /dev/null +++ b/tools/check-upgrades-cli.mjs @@ -0,0 +1,28 @@ +import { readFile } from "node:fs/promises"; +import { createRequire } from "node:module"; + +const require = createRequire(import.meta.url); +const expectedVersion = "1.46.0"; +const pluginSource = await readFile( + new URL("../lib/openzeppelin-foundry-upgrades/src/internal/Versions.sol", import.meta.url), + "utf8" +); +const lockfile = JSON.parse( + await readFile(new URL("../package-lock.json", import.meta.url), "utf8") +); +const lockedVersion = lockfile.packages?.["node_modules/@openzeppelin/upgrades-core"]?.version; +const installedVersion = require("@openzeppelin/upgrades-core/package.json").version; + +if (!pluginSource.includes('UPGRADES_CORE = "^1.45.0"')) { + throw new Error('Expected the vendored plugin to declare UPGRADES_CORE = "^1.45.0".'); +} + +if (lockedVersion !== expectedVersion) { + throw new Error(`Expected package-lock.json to resolve @openzeppelin/upgrades-core to ${expectedVersion}, found ${lockedVersion}.`); +} + +if (installedVersion !== expectedVersion) { + throw new Error(`Expected installed @openzeppelin/upgrades-core to report ${expectedVersion}, found ${installedVersion}.`); +} + +console.log(`@openzeppelin/upgrades-core ${expectedVersion} is pinned and installed.`); diff --git a/tools/doctor.sh b/tools/doctor.sh new file mode 100755 index 0000000..721b7f2 --- /dev/null +++ b/tools/doctor.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +set -euo pipefail + +printf '%s\n' 'Expected: Foundry 1.7.1, Node 24.18.0, npm 11.17.0' + +for tool in forge anvil node npm make; do + if command -v "$tool" >/dev/null 2>&1; then + printf '%s: ' "$tool" + "$tool" --version | sed -n '1p' + else + printf '%s\n' "missing: $tool" + fi +done diff --git a/web/eslint.config.js b/web/eslint.config.js new file mode 100644 index 0000000..f01216a --- /dev/null +++ b/web/eslint.config.js @@ -0,0 +1,24 @@ +import js from "@eslint/js"; +import globals from "globals"; +import reactHooks from "eslint-plugin-react-hooks"; +import reactRefresh from "eslint-plugin-react-refresh"; + +export default [ + { ignores: ["dist", "coverage"] }, + js.configs.recommended, + { + files: ["**/*.{ts,tsx}"], + languageOptions: { + ecmaVersion: 2022, + globals: { ...globals.browser, ...globals.node } + }, + plugins: { + "react-hooks": reactHooks, + "react-refresh": reactRefresh + }, + rules: { + ...reactHooks.configs.recommended.rules, + "react-refresh/only-export-components": ["warn", { allowConstantExport: true }] + } + } +]; diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..d91f1f3 --- /dev/null +++ b/web/index.html @@ -0,0 +1,11 @@ + + + + + + UUPS Bank Operations Console + + +
+ + diff --git a/web/package-lock.json b/web/package-lock.json new file mode 100644 index 0000000..e04a6fc --- /dev/null +++ b/web/package-lock.json @@ -0,0 +1,4500 @@ +{ + "name": "uups-bank-operations-console", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "uups-bank-operations-console", + "version": "0.1.0", + "dependencies": { + "@tanstack/react-query": "5.101.4", + "react": "19.2.8", + "react-dom": "19.2.8", + "viem": "2.55.8", + "wagmi": "3.7.5" + }, + "devDependencies": { + "@eslint/js": "10.0.1", + "@testing-library/dom": "10.4.1", + "@testing-library/react": "16.3.2", + "@types/node": "24.10.0", + "@types/react": "19.2.14", + "@types/react-dom": "19.2.4", + "@vitejs/plugin-react": "6.0.4", + "eslint": "10.0.1", + "eslint-plugin-react-hooks": "7.1.1", + "eslint-plugin-react-refresh": "0.5.3", + "globals": "17.7.0", + "jsdom": "30.0.1", + "typescript": "7.0.2", + "typescript-eslint": "8.65.0", + "vite": "8.2.0", + "vitest": "4.1.10" + }, + "engines": { + "node": ">=24.18.0 <25", + "npm": ">=11.17.0 <12" + } + }, + "node_modules/@adraffy/ens-normalize": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@adraffy/ens-normalize/-/ens-normalize-1.11.1.tgz", + "integrity": "sha512-nhCBV3quEgesuf7c7KYfperqSS14T8bYuvJ8PcLJp6znkZpFc0AuW4qBtr8eKVyPPe/8RSr7sglCWPU5eaxwKQ==", + "license": "MIT" + }, + "node_modules/@asamuzakjp/css-color": { + "version": "6.0.7", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-6.0.7.tgz", + "integrity": "sha512-vC/bk1Lz7Tn/EfU9/apOTBk80/8dyGyWMowPoV1tJ52muDGsDqt2HPT2klrFUiY60MQmQv9q8yIht15JnBgDGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.3.0", + "@csstools/css-color-parser": "^4.1.10", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "lru-cache": "^11.5.2" + }, + "engines": { + "node": "^22.13.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-8.3.2.tgz", + "integrity": "sha512-93Z1N+BQNXysodoicpOIyNh2drHfz/CTf9nnT0FEx72GJcIiwgydD7tGAr78j41LsYn3hlRn+LdGPuBLn1Bl8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.2" + }, + "engines": { + "node": "^22.13.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.1.tgz", + "integrity": "sha512-gLNsunvwf3mCi5u5o46/Z/JcJMnhbHSaZ69rkgPzNM3J4s8hWwpPUQB6/tt0EDFyCiWzxANlx+2LJwpYj4zS1w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz", + "integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.0.tgz", + "integrity": "sha512-5+5LEmFuY1AjXdYhmgjTJogtQnP1evJ1zrBZGUNZ0thkpwnnmKxcHdAMn/OtFjAb25zA+jKDVYVRl+5G7rjv1A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.1", + "@csstools/css-calc": "^3.3.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.8.tgz", + "integrity": "sha512-CpMLjAvwQg3BL5S0IeqsZNMH7EQrEWi0kLKOC13ZBF0ZwERiLWlibNPJr8G1kdU3Ms/r2KiNrF81pUh2HwAHdg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.5.5.tgz", + "integrity": "sha512-eIJYKTCECbP/nsKaaruF6LW967mtbQbsw4JTtSVkUQc9MneSkbrgPJAbKl9nWr0ZeowV8BfsarBmPpBzGelA2w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.6.1.tgz", + "integrity": "sha512-iH1B076HoAshH1mLpHMgwdGeTs0CYwL0SPMkGuSebZrwBp16v415e9NZXg2jtrqPVQjf6IANe2Vtlr5KswtcZQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.1.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@noble/ciphers": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz", + "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/curves": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.1.tgz", + "integrity": "sha512-k11yZxZg+t+gWvBbIswW0yoJlu8cHOC7dhunwOzoWH/mXGBiYyR4YY6hAEK/3EUs4UpB8la1RfdRpeGsFHkWsA==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.8.0" + }, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.144.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.144.0.tgz", + "integrity": "sha512-nuhZIOLuI6TFQ32I/WnUx+SCPY7SdSKwgnFHydAuoS1+Z4BRcaP+RRJmGzl9lw+0OFF7UmaESf7KQRXaNLHypg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.4.tgz", + "integrity": "sha512-jHC2cnyKz5xU2fhECtFl8OZ83cYNt13GZQD+0uMJ/X3o+ijmd56okHhTUwxVSHPx1IRVIJEZ1/1pPzeLCU6XKA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-Dc5mPD8F5F/FS8i01syd7FTF6yB2fVthH/TRkjwJkzUK6EpoxHtqvZQP5Zwq80/5z19TWYHIg1KOHboCgVx/aQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.4.tgz", + "integrity": "sha512-fpDm4oBo6SqLvWUYCmFhdde3U9KH2fRNNMeAnAPAIwxRL345xutL0EtEUcuoxsoazdJGv/MuDBQHlCDrtbvqOg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.4.tgz", + "integrity": "sha512-rSJoreDE/HoIzoaib6MTp5jQtCTdMHKIvItAKT/ImS6Y6Ww76oUaeMyp4Vc/fAgd/ehji068IxetHXAnqUwN9A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.4.tgz", + "integrity": "sha512-/jm8OGHgn7oGaJu3i/qZI9spUGcJ+y/lk43ttQ/iO1tOd9NissG6o97bighBCiL+BKRngmcDuR6ikfwYdJmVuQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.4.tgz", + "integrity": "sha512-tIP06BeD9EqvECBrPZ+sqdPlYrT+aYaAiu1wYziVx5elRK/ftm33JxVDy2bXGbr6J0CrtirCkR87/X5a2euEng==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.4.tgz", + "integrity": "sha512-Ql1Q0EQqVThvn9VAVlwNzsUvbSFtCMGjLpRRi4pk5i7NZZ4n5ISiLMjHYtus4VQ2PvkSw24zyaCVsiS+sXPj1w==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.4.tgz", + "integrity": "sha512-GjbjXD4XXfN19D0LZNbmiCBUoDiRACsYHr0yaIbbn8aFsXjHZifcYqu/W5Er5X2X990WjHXFrxarn5chzItorQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.4.tgz", + "integrity": "sha512-p5WR0NOwaRmJ/B1b6IjEFLLivwEsf3PrdBIhRbhTCQisbo2SvHHpG4ELB/+FgQNnB88LTOF86upmJmbvZdQ2lw==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.4.tgz", + "integrity": "sha512-4/GyVjmhR+Tc6HLJvwc1sOhPqAZtySiSMesOZyX6JQ5XBxoTDEMKQzvo07NIK6nTon/SivlZqvhzvuVBNQhObQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.4.tgz", + "integrity": "sha512-l9eeLsCNvPpmSXUej0etw/J1eqV0Jj1D5G/xG6YTijmE6dkv6E2QezgWbTfQk63v952DPqrjOCoiqxq7Bw0YUQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.4.tgz", + "integrity": "sha512-e0F355MSTMm3+UOqtV3L24gFUp2N5m1f8L/7d56deik6va+AXdrt9F8LbzGpeWGWRbZEDq4m8NVnJDeBtf9DZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.4.tgz", + "integrity": "sha512-AWLi0uBRYh6QlE7OKhiz+phZC0qwtij2QZmhmOdsLdFn64m7oMpooE9ICE3lhm9xMb4SpDo2WbHcxX1iFLFtqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.4.tgz", + "integrity": "sha512-UwSDJOg3dqCAejWdxclJjCsh3Qq4vLYMDxmyHqo1btz3stK2VqgwNd3mm5tuIwzSlGIQ/1H9Hr+Zn09mrezNqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@scure/base": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.2.6.tgz", + "integrity": "sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip32": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-1.7.0.tgz", + "integrity": "sha512-E4FFX/N3f4B80AKWp5dP6ow+flD1LQZo/w8UnLGYZO674jS6YnYeepycOOksv+vLPSpgN35wgKgy+ybfTb2SMw==", + "license": "MIT", + "dependencies": { + "@noble/curves": "~1.9.0", + "@noble/hashes": "~1.8.0", + "@scure/base": "~1.2.5" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip39": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.6.0.tgz", + "integrity": "sha512-+lF0BbLiJNwVlev4eKelw1WWLaiKXw7sSl8T6FvBlWkdX+94aGJ4o8XjUdlyhTCjd8c+B3KT3JfS8P0bLRNU6A==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "~1.8.0", + "@scure/base": "~1.2.5" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tanstack/query-core": { + "version": "5.101.4", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.4.tgz", + "integrity": "sha512-gNwcvOJcRbLWPOLG/2OBm+zM+Yv+MKsXKEOWC57USuZDEsI71hEErQsiEGx5wX9rzWWkfwM0fVSPoiIFSsxfiw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.101.4", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.4.tgz", + "integrity": "sha512-yRg2pfOCxIs4ZJW3XYYHU/WgtD04FHSnfHlpRT7h7pR77hwkdRG4wxbKe4aq6P0RvXUTBSQpQeadS1SUYUe+KA==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.101.4" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.10.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.0.tgz", + "integrity": "sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.65.0.tgz", + "integrity": "sha512-IEgob78X12rHpUmtcwFsXhZdVGJtwTVP8FiCLZkR6GlYVrl2PcuB+KhCE5BlVC/eQpQnu8WXRtkHZuPar+gCRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.65.0", + "@typescript-eslint/type-utils": "8.65.0", + "@typescript-eslint/utils": "8.65.0", + "@typescript-eslint/visitor-keys": "8.65.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.65.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz", + "integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.65.0.tgz", + "integrity": "sha512-CZ4nMxWwgu1HEEFNkeaCptra9QCtkmKdgf3sWh1rl1trIhmxLilgTV4cwcbQ4wemnT4sWQN8CaKOmdYx+g2gMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.65.0", + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/typescript-estree": "8.65.0", + "@typescript-eslint/visitor-keys": "8.65.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.65.0.tgz", + "integrity": "sha512-SxnPhbTsGahizDgbu7oqFH/xVtzIqMd/s+WtnSxNxJZJpLbdT5IPdzg8EZxO3+PoKahXmwJLeNQOpKJb3/bi7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.65.0", + "@typescript-eslint/types": "^8.65.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.65.0.tgz", + "integrity": "sha512-Esbl8OSYiVxBokYgWPf7VVWg/BE798wXhimnn9ML9Pt5qoDf8bfQlgjlKXR/k98+AcNzlLKYrpCcrcuZ9DZLgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/visitor-keys": "8.65.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.65.0.tgz", + "integrity": "sha512-j6GzGqCiRdA7Qhur2VVmKZAkBLfnHFQfx4TaJGL9RMveZqCo48jSHHO0DTgizEnGhtWnqmbtCUSrqSkdiY/0Hg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.65.0.tgz", + "integrity": "sha512-YjaZ7PRI5qY7ax2L3PbvX0rRyGtipAReCWs0mhhDBHjH/vl0g0BonaGXrKdKpMbIIsMIwDgbk/xzkBTyAltS5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/typescript-estree": "8.65.0", + "@typescript-eslint/utils": "8.65.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.65.0.tgz", + "integrity": "sha512-JSSwWNy+H0E/01jJEM+hrX6N0OFDzFzeIhHFSAS01tlVaevpG8cFyYRPhS5yjGOvBUx3sqQHVMjCL1CAZZMxBg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.65.0.tgz", + "integrity": "sha512-JboAE2swaYt4tb1fHhHTABE2K+OLy09XfcTbhnk4Pw96f9dd2e9iYsJ28gBggHlo5z5x1rkyWvcPoTuNTd4oGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.65.0", + "@typescript-eslint/tsconfig-utils": "8.65.0", + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/visitor-keys": "8.65.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.65.0.tgz", + "integrity": "sha512-gXiwIHsYreboxeJucHKPvgwl7dXt50mF8s1/c00cP/WoVTyWKFdtfhRWwZiXYFU5H2O8vVoSLNrexFZjYS/SGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.65.0", + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/typescript-estree": "8.65.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.65.0.tgz", + "integrity": "sha512-8C71BQkGjiMmXtop7pHVJu1l2NNShFdkCyD6a2ezzs5vU/L3LRtb69EtcteFwz0mYMPzIgOw0n6OV4VBUWZd7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.65.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.4.tgz", + "integrity": "sha512-XcCQz0TBpBgljhj0gMuuDj49i6Ytqh5q1osT/Gp5uAVJUCTWxyskk/l1jwYYiu2xcNHHipdMz40EGfM1VdamVg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.10", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@wagmi/connectors": { + "version": "8.0.26", + "resolved": "https://registry.npmjs.org/@wagmi/connectors/-/connectors-8.0.26.tgz", + "integrity": "sha512-W/mDMvQ6Q1zIehY/ZjGRBjI/GgmXEfcF6z45b6uiSSvjGgtg4dT9eLw/NGi4x47eMKCrIuIqFYFLVDpQawx8GA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/wevm" + }, + "peerDependencies": { + "@base-org/account": "^2.5.1", + "@coinbase/wallet-sdk": "^4.3.6", + "@metamask/connect-evm": "^2.1.0", + "@safe-global/safe-apps-provider": "~0.18.6", + "@safe-global/safe-apps-sdk": "^9.1.0", + "@wagmi/core": "3.6.4", + "@walletconnect/ethereum-provider": "^2.21.1", + "accounts": "~0.14", + "porto": "~0.2.35", + "typescript": ">=5.9.3", + "viem": "2.x" + }, + "peerDependenciesMeta": { + "@base-org/account": { + "optional": true + }, + "@coinbase/wallet-sdk": { + "optional": true + }, + "@metamask/connect-evm": { + "optional": true + }, + "@safe-global/safe-apps-provider": { + "optional": true + }, + "@safe-global/safe-apps-sdk": { + "optional": true + }, + "@walletconnect/ethereum-provider": { + "optional": true + }, + "accounts": { + "optional": true + }, + "porto": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/@wagmi/core": { + "version": "3.6.4", + "resolved": "https://registry.npmjs.org/@wagmi/core/-/core-3.6.4.tgz", + "integrity": "sha512-YERJ+vbFZw3UI6p4KoFU5DkxkqmtzBq5lddpgVAvcu5Tz7kSicKVA3DMCWvjVAFsikNdZYIs39FIkMGWfzpSMQ==", + "license": "MIT", + "dependencies": { + "eventemitter3": "5.0.1", + "mipd": "0.0.7", + "zustand": "5.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/wevm" + }, + "peerDependencies": { + "@tanstack/query-core": ">=5.0.0", + "accounts": "~0.14", + "typescript": ">=5.9.3", + "viem": "2.x" + }, + "peerDependenciesMeta": { + "@tanstack/query-core": { + "optional": true + }, + "accounts": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/abitype": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/abitype/-/abitype-1.2.3.tgz", + "integrity": "sha512-Ofer5QUnuUdTFsBRwARMoWKOH1ND5ehwYhJ3OJ/BQO+StkwQjHw0XyVh4vDttzHB7QOFhPHa/o413PJ82gU/Tg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/wevm" + }, + "peerDependencies": { + "typescript": ">=5.0.4", + "zod": "^3.22.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/acorn": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.15", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.15.tgz", + "integrity": "sha512-FwMjJJ7HnyZpWe+oWxegG0fezZyBZUagI5LZEoO3GCbtbKNwRfMH9Ue5d5v01PNePBy1QSfPSDTTeVL0Hb9EzA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/browserslist": { + "version": "4.28.8", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz", + "integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.11.12", + "caniuse-lite": "^1.0.30001809", + "electron-to-chromium": "^1.5.402", + "node-releases": "^2.0.53", + "update-browserslist-db": "^1.3.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001809", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001809.tgz", + "integrity": "sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/data-urls/node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.408", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.408.tgz", + "integrity": "sha512-SLoprcYpJ/OH2v2ps0+N5biv9H4/KBT3+YmmDew64TwK5y9j2wv7pMOFY7IorVkyMtEyLSCRlXKLsNlakeAlPw==", + "dev": true, + "license": "ISC" + }, + "node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.0.1.tgz", + "integrity": "sha512-20MV9SUdeN6Jd84xESsKhRly+/vxI+hwvpBMA93s+9dAcjdCuCojn4IqUGS3lvVaqjVYGYHSRMCpeFtF2rQYxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.2", + "@eslint/config-helpers": "^0.5.2", + "@eslint/core": "^1.1.0", + "@eslint/plugin-kit": "^0.6.0", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.12.4", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.1", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.1.1", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.1", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz", + "integrity": "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.5.3.tgz", + "integrity": "sha512-5EMmLCV98Pi4o/f/3DP/v/tNqLHMIc9I8LKClNDWhZ9JTho89/kQcitCXQBMG7sAfVRK0Ie3T2EDOzp1YXYiVA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": "^9 || ^10" + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eventemitter3": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", + "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", + "license": "MIT" + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "17.7.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.7.0.tgz", + "integrity": "sha512-Czmyns5dUsq4seFBR/Kdydhmo8y9kC79hiSkPn0YcGtNnYWnrgt0vjrSjx9tspoDGWm2CMarffRuLjM4xUz8xg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/isows": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/isows/-/isows-1.0.7.tgz", + "integrity": "sha512-I1fSfDCZL5P0v33sVqeTDSpcstAg/N+wF5HS033mogOVIp4B+oHC7oOCsA3axAbBSGTJ8QubbNmnIRN/h8U7hg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/wevm" + } + ], + "license": "MIT", + "peerDependencies": { + "ws": "*" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsdom": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.0.1.tgz", + "integrity": "sha512-52v7mUVUfNQVYYqE1lcdaymWL0njO7lTLUog6ZvW2U5KsbiLk/GnZlVJ+qx0xfNJZ6Gn+KSpPNE52vurbxZwrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^6.0.5", + "@asamuzakjp/dom-selector": "^8.3.0", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.7", + "@exodus/bytes": "^1.15.1", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.2", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.2", + "undici": "^8.9.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^17.1.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + }, + "peerDependencies": { + "canvas": "^3.2.3" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/mipd": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/mipd/-/mipd-0.0.7.tgz", + "integrity": "sha512-aAPZPNDQ3uMTdKbuO2YmAw2TxLHO0moa4YKAyETM/DTj5FloZo+a+8tU+iv4GmW+sOxKLSRwcSFuczk+Cpt6fg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/wagmi-dev" + } + ], + "license": "MIT", + "peerDependencies": { + "typescript": ">=5.0.4" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.53", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.53.tgz", + "integrity": "sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/ox": { + "version": "0.14.32", + "resolved": "https://registry.npmjs.org/ox/-/ox-0.14.32.tgz", + "integrity": "sha512-EPB214GvtsP2TtAYZXkNdizLzGp6PXtfaHcRrD4pcBk/D0Y7ZCNv71QgwrjeCsZ+82moVeMlZZG+NDEIUfxMpw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/wevm" + } + ], + "license": "MIT", + "dependencies": { + "@adraffy/ens-normalize": "^1.11.0", + "@noble/ciphers": "^1.3.0", + "@noble/curves": "1.9.1", + "@noble/hashes": "^1.8.0", + "@scure/bip32": "^1.7.0", + "@scure/bip39": "^1.6.0", + "abitype": "^1.2.3", + "eventemitter3": "5.0.1" + }, + "peerDependencies": { + "typescript": ">=5.4.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/react": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", + "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.8" + } + }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT" + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rolldown": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.4.tgz", + "integrity": "sha512-rSr7irW0K7QRWzjdJXqZowkcRdDtjRduh43rBltnVKd0VFq839l1lJoDvGJb6gl7+4rTTCrPWu+YfujUL8Ug7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.144.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.2.4", + "@rolldown/binding-darwin-arm64": "1.2.4", + "@rolldown/binding-darwin-x64": "1.2.4", + "@rolldown/binding-freebsd-x64": "1.2.4", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.4", + "@rolldown/binding-linux-arm64-gnu": "1.2.4", + "@rolldown/binding-linux-arm64-musl": "1.2.4", + "@rolldown/binding-linux-ppc64-gnu": "1.2.4", + "@rolldown/binding-linux-s390x-gnu": "1.2.4", + "@rolldown/binding-linux-x64-gnu": "1.2.4", + "@rolldown/binding-linux-x64-musl": "1.2.4", + "@rolldown/binding-openharmony-arm64": "1.2.4", + "@rolldown/binding-win32-arm64-msvc": "1.2.4", + "@rolldown/binding-win32-x64-msvc": "1.2.4" + } + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", + "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", + "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.4.10", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.10.tgz", + "integrity": "sha512-GgouD1B+sWwvkaEq8vXC15DjQitxbvs12oIXELpconwm+Tg3zfcEv4jgzq3vtKverDXsg3VI8aRgNL2Nra0Iog==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.10" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.10", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.10.tgz", + "integrity": "sha512-KnQjp53ZekKgm/r3l+u8kJGGzYgrWdP8+Mql7a4vijh2WE0IrZWspQj/TpTxDho/YxO+AnOZnIjQcCD+q6iJsw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc" + }, + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" + } + }, + "node_modules/typescript-eslint": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.65.0.tgz", + "integrity": "sha512-/ggrHAwyjENDusvyxbuqxAC2dTnZg/Z8F+fgQtYIz+L6n/9HfSlEZcFGV/NsMNa6CkGk0xUjUAFwC0vHOflvIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.65.0", + "@typescript-eslint/parser": "8.65.0", + "@typescript-eslint/typescript-estree": "8.65.0", + "@typescript-eslint/utils": "8.65.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/undici": { + "version": "8.10.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.10.0.tgz", + "integrity": "sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.1.tgz", + "integrity": "sha512-ZZ61DsRsOnakl74HAmp3oSN4aXUmEWXf+i/yv0h7tIBfICc3VdrFErQKUUKPgu3AMsTUMbcongALEN4l6GSUrQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/use-sync-external-store": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.4.0.tgz", + "integrity": "sha512-9WXSPC5fMv61vaupRkCKCxsPxBocVnwakBEkMIHHpkTTg6icbJtg6jzgtLDm4bl3cSHAca52rYWih0k4K3PfHw==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/viem": { + "version": "2.55.8", + "resolved": "https://registry.npmjs.org/viem/-/viem-2.55.8.tgz", + "integrity": "sha512-BHqtsmK4iMLuLnRyrPIB1jVrmFVliRIP/K0dnFT7gBOpfo8Ko4ozhkzUCRNfR+Z/ZZdnlnVrh04fAOuIm5Svkg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/wevm" + } + ], + "license": "MIT", + "dependencies": { + "@noble/curves": "1.9.1", + "@noble/hashes": "1.8.0", + "@scure/bip32": "1.7.0", + "@scure/bip39": "1.6.0", + "abitype": "1.2.3", + "isows": "1.0.7", + "ox": "0.14.32", + "ws": "8.21.0" + }, + "peerDependencies": { + "typescript": ">=5.0.4" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vite": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.0.tgz", + "integrity": "sha512-pn+CFpM0lwDeKwmOq1ZaBK/9sjorZcgqxki6MbY/jPEVd9vichIlmlD4HmQ5wdP5EgqQCFRaACBxMC7uEGc6lQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.23", + "rolldown": "~1.2.0", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.4.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/wagmi": { + "version": "3.7.5", + "resolved": "https://registry.npmjs.org/wagmi/-/wagmi-3.7.5.tgz", + "integrity": "sha512-icXdl/fLp6sQlZMDFZX6EazkzR8dBDSjJfYrN96A0tJeEYww++TUHva7oUzOU49k/guV6u+rUpYTjc2egC+hLA==", + "license": "MIT", + "dependencies": { + "@wagmi/connectors": "8.0.26", + "@wagmi/core": "3.6.4", + "use-sync-external-store": "1.4.0" + }, + "funding": { + "url": "https://github.com/sponsors/wevm" + }, + "peerDependencies": { + "@tanstack/react-query": ">=5.0.0", + "react": ">=18", + "typescript": ">=5.9.3", + "viem": "2.x" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "17.1.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.1.0.tgz", + "integrity": "sha512-3GeworPmc2ZfEEHP7lEbUfBX/L75wdEsi0rLNhXcXxnoN5jyq0SL5gCy06SGW2cyTIZdTvWIDQNQoza++vKeaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.15.1", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^22.14.0 || >=24.0.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + }, + "node_modules/zustand": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.0.tgz", + "integrity": "sha512-LE+VcmbartOPM+auOjCCLQOsQ05zUTp8RkgwRzefUk+2jISdMMFnxvyTjA4YNWr5ZGXYbVsEMZosttuxUBkojQ==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } + } + } +} diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..f98cee4 --- /dev/null +++ b/web/package.json @@ -0,0 +1,40 @@ +{ + "name": "uups-bank-operations-console", + "private": true, + "version": "0.1.0", + "type": "module", + "packageManager": "npm@11.17.0", + "engines": { "node": ">=24.18.0 <25", "npm": ">=11.17.0 <12" }, + "scripts": { + "dev": "vite --host 127.0.0.1", + "lint": "eslint . --max-warnings 0", + "typecheck": "tsc -b --pretty false", + "test": "vitest run", + "build": "tsc -b && vite build" + }, + "dependencies": { + "@tanstack/react-query": "5.101.4", + "react": "19.2.8", + "react-dom": "19.2.8", + "viem": "2.55.8", + "wagmi": "3.7.5" + }, + "devDependencies": { + "@eslint/js": "10.0.1", + "@testing-library/dom": "10.4.1", + "@testing-library/react": "16.3.2", + "@types/node": "24.10.0", + "@types/react": "19.2.14", + "@types/react-dom": "19.2.4", + "@vitejs/plugin-react": "6.0.4", + "eslint": "10.0.1", + "eslint-plugin-react-hooks": "7.1.1", + "eslint-plugin-react-refresh": "0.5.3", + "globals": "17.7.0", + "jsdom": "30.0.1", + "typescript": "7.0.2", + "typescript-eslint": "8.65.0", + "vite": "8.2.0", + "vitest": "4.1.10" + } +} diff --git a/web/src/config/toolchain.ts b/web/src/config/toolchain.ts new file mode 100644 index 0000000..46852ce --- /dev/null +++ b/web/src/config/toolchain.ts @@ -0,0 +1,6 @@ +export const toolchainLabels = { + foundry: "Foundry 1.7.1", + solidity: "Solidity 0.8.35", + openZeppelin: "OpenZeppelin 5.6.1", + proxy: "UUPS" +}; diff --git a/web/src/test/setup.ts b/web/src/test/setup.ts new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/web/src/test/setup.ts @@ -0,0 +1 @@ +export {}; diff --git a/web/src/test/toolchain.test.ts b/web/src/test/toolchain.test.ts new file mode 100644 index 0000000..f8ffc03 --- /dev/null +++ b/web/src/test/toolchain.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from "vitest"; +import { toolchainLabels } from "../config/toolchain"; + +describe("toolchain labels", () => { + it("exposes the versions shown by the operations console", () => { + expect(toolchainLabels).toEqual({ + foundry: "Foundry 1.7.1", + solidity: "Solidity 0.8.35", + openZeppelin: "OpenZeppelin 5.6.1", + proxy: "UUPS" + }); + }); +}); diff --git a/web/tsconfig.app.json b/web/tsconfig.app.json new file mode 100644 index 0000000..fbbf81f --- /dev/null +++ b/web/tsconfig.app.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "allowJs": false, + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "module": "ESNext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx" + }, + "include": ["src"] +} diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/web/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/web/tsconfig.node.json b/web/tsconfig.node.json new file mode 100644 index 0000000..b64bd9f --- /dev/null +++ b/web/tsconfig.node.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "ES2023", + "lib": ["ES2023"], + "module": "ESNext", + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "skipLibCheck": true, + "strict": true + }, + "include": ["vite.config.ts"] +} diff --git a/web/vite.config.ts b/web/vite.config.ts new file mode 100644 index 0000000..1619764 --- /dev/null +++ b/web/vite.config.ts @@ -0,0 +1,11 @@ +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + plugins: [react()], + test: { + environment: "jsdom", + globals: true, + setupFiles: ["./src/test/setup.ts"] + } +}); From eece34c088d52f1650baef34b51a8fa26ec12a00 Mon Sep 17 00:00:00 2001 From: golem Date: Mon, 17 Aug 2026 16:06:23 -0600 Subject: [PATCH 02/30] build: resolve TypeScript 7 tooling compatibility --- .../plans/2026-08-17-uups-bank-demo.md | 4 +- web/eslint.config.js | 6 +- web/package-lock.json | 99 ++++++++++++------- web/package.json | 7 +- 4 files changed, 75 insertions(+), 41 deletions(-) diff --git a/docs/superpowers/plans/2026-08-17-uups-bank-demo.md b/docs/superpowers/plans/2026-08-17-uups-bank-demo.md index 7f88085..3a3ca8f 100644 --- a/docs/superpowers/plans/2026-08-17-uups-bank-demo.md +++ b/docs/superpowers/plans/2026-08-17-uups-bank-demo.md @@ -6,7 +6,7 @@ **Architecture:** Foundry scripts are the only state-changing control plane. A six-decimal `MockUSDC` and a UUPS `BankV1` implementation run behind a stable ERC-1967 proxy on Anvil or optional Base Sepolia. Scripts export a public deployment manifest and contract ABIs; a wagmi/viem client reads those artifacts and renders reserves, liabilities, identities, actors, and events without a signer or wallet connector. V2 inherits V1, adds no storage, and adds one internal transfer function. -**Tech Stack:** Foundry `v1.7.1`, forge-std `v1.16.1`, Solidity `0.8.35`, OpenZeppelin Contracts Upgradeable `v5.6.1`, OpenZeppelin Foundry Upgrades `v0.4.1`, OpenZeppelin Upgrades Core `1.46.0`, Node `24.18.0`, npm `11.17.0`, React `19.2.8`, TypeScript `7.0.2`, Vite `8.2.0`, wagmi `3.7.5`, viem `2.55.8`, TanStack Query `5.101.4`, Vitest `4.1.10`, Testing Library React `16.3.2`, and jsdom `30.0.1`. +**Tech Stack:** Foundry `v1.7.1`, forge-std `v1.16.1`, Solidity `0.8.35`, OpenZeppelin Contracts Upgradeable `v5.6.1`, OpenZeppelin Foundry Upgrades `v0.4.1`, OpenZeppelin Upgrades Core `1.46.0`, Node `24.18.0`, npm `11.17.0`, React `19.2.8`, TypeScript compiler `7.0.2` via `@typescript/native`, TypeScript API compatibility `6.0.2` via the `typescript` alias, Vite `8.2.0`, wagmi `3.7.5`, viem `2.55.8`, TanStack Query `5.101.4`, Vitest `4.1.10`, Testing Library React `16.3.2`, and jsdom `30.0.1`. ## Global Constraints @@ -152,7 +152,7 @@ web/src/generated/*.ts } ``` -Add exact runtime dependencies `@tanstack/react-query@5.101.4`, `react@19.2.8`, `react-dom@19.2.8`, `viem@2.55.8`, and `wagmi@3.7.5`. Add exact dev dependencies `@eslint/js@10.0.1`, `@testing-library/dom@10.4.1`, `@testing-library/react@16.3.2`, `@types/node@24.10.0`, `@types/react@19.2.14`, `@types/react-dom@19.2.4`, `@vitejs/plugin-react@6.0.4`, `eslint@10.0.1`, `eslint-plugin-react-hooks@7.1.1`, `eslint-plugin-react-refresh@0.5.3`, `globals@17.7.0`, `jsdom@30.0.1`, `typescript@7.0.2`, `typescript-eslint@8.65.0`, `vite@8.2.0`, and `vitest@4.1.10`. If npm rejects one exact revision because the registry changed, verify the official release before changing both this plan and the package file. +Add exact runtime dependencies `@tanstack/react-query@5.101.4`, `react@19.2.8`, `react-dom@19.2.8`, `viem@2.55.8`, and `wagmi@3.7.5`. Add exact dev dependencies `@eslint/js@10.0.1`, `@testing-library/dom@10.4.1`, `@testing-library/react@16.3.2`, `@types/node@24.10.0`, `@types/react@19.2.14`, `@types/react-dom@19.2.4`, `@typescript/native@npm:typescript@7.0.2`, `@vitejs/plugin-react@6.0.4`, `eslint@10.0.1`, `eslint-plugin-react-hooks@7.1.1`, `eslint-plugin-react-refresh@0.5.3`, `globals@17.7.0`, `jsdom@30.0.1`, `typescript@npm:@typescript/typescript6@6.0.2`, `typescript-eslint@8.65.0`, `vite@8.2.0`, and `vitest@4.1.10`. The TypeScript 7 native compiler ships without the API consumed by typescript-eslint, so install its range-free TypeScript 6 compatibility alias under `typescript` and the range-free TypeScript 7 compiler alias under `@typescript/native`. If npm rejects one exact revision because the registry changed, verify the official release before changing both this plan and the package file. - [ ] Configure Foundry in `foundry.toml`: diff --git a/web/eslint.config.js b/web/eslint.config.js index f01216a..c272be2 100644 --- a/web/eslint.config.js +++ b/web/eslint.config.js @@ -2,10 +2,12 @@ import js from "@eslint/js"; import globals from "globals"; import reactHooks from "eslint-plugin-react-hooks"; import reactRefresh from "eslint-plugin-react-refresh"; +import tseslint from "typescript-eslint"; -export default [ +export default tseslint.config( { ignores: ["dist", "coverage"] }, js.configs.recommended, + ...tseslint.configs.recommended, { files: ["**/*.{ts,tsx}"], languageOptions: { @@ -21,4 +23,4 @@ export default [ "react-refresh/only-export-components": ["warn", { allowConstantExport: true }] } } -]; +); diff --git a/web/package-lock.json b/web/package-lock.json index e04a6fc..49d9592 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -21,13 +21,14 @@ "@types/node": "24.10.0", "@types/react": "19.2.14", "@types/react-dom": "19.2.4", + "@typescript/native": "npm:typescript@7.0.2", "@vitejs/plugin-react": "6.0.4", "eslint": "10.0.1", "eslint-plugin-react-hooks": "7.1.1", "eslint-plugin-react-refresh": "0.5.3", "globals": "17.7.0", "jsdom": "30.0.1", - "typescript": "7.0.2", + "typescript": "npm:@typescript/typescript6@6.0.2", "typescript-eslint": "8.65.0", "vite": "8.2.0", "vitest": "4.1.10" @@ -1250,7 +1251,7 @@ "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -1509,6 +1510,57 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/@typescript/native": { + "name": "typescript", + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc" + }, + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" + } + }, + "node_modules/@typescript/old": { + "name": "typescript", + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "node_modules/@typescript/typescript-aix-ppc64": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", @@ -2325,7 +2377,7 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/data-urls": { @@ -3947,38 +3999,17 @@ } }, "node_modules/typescript": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", - "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", - "dev": true, + "name": "@typescript/typescript6", + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript6/-/typescript6-6.0.2.tgz", + "integrity": "sha512-mbCddXd+jm7hfx7w2YU64/Av4/NqqeG3GoRZgxPcgoTxYjhrcfJRw9ULch71SS4G+Q3bOXFhRvPqjguN0Hyp5w==", + "devOptional": true, "license": "Apache-2.0", + "dependencies": { + "@typescript/old": "npm:typescript@^6" + }, "bin": { - "tsc": "bin/tsc" - }, - "engines": { - "node": ">=16.20.0" - }, - "optionalDependencies": { - "@typescript/typescript-aix-ppc64": "7.0.2", - "@typescript/typescript-darwin-arm64": "7.0.2", - "@typescript/typescript-darwin-x64": "7.0.2", - "@typescript/typescript-freebsd-arm64": "7.0.2", - "@typescript/typescript-freebsd-x64": "7.0.2", - "@typescript/typescript-linux-arm": "7.0.2", - "@typescript/typescript-linux-arm64": "7.0.2", - "@typescript/typescript-linux-loong64": "7.0.2", - "@typescript/typescript-linux-mips64el": "7.0.2", - "@typescript/typescript-linux-ppc64": "7.0.2", - "@typescript/typescript-linux-riscv64": "7.0.2", - "@typescript/typescript-linux-s390x": "7.0.2", - "@typescript/typescript-linux-x64": "7.0.2", - "@typescript/typescript-netbsd-arm64": "7.0.2", - "@typescript/typescript-netbsd-x64": "7.0.2", - "@typescript/typescript-openbsd-arm64": "7.0.2", - "@typescript/typescript-openbsd-x64": "7.0.2", - "@typescript/typescript-sunos-x64": "7.0.2", - "@typescript/typescript-win32-arm64": "7.0.2", - "@typescript/typescript-win32-x64": "7.0.2" + "tsc6": "bin/tsc6" } }, "node_modules/typescript-eslint": { @@ -4448,7 +4479,7 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", - "dev": true, + "devOptional": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/web/package.json b/web/package.json index f98cee4..46337c0 100644 --- a/web/package.json +++ b/web/package.json @@ -8,9 +8,9 @@ "scripts": { "dev": "vite --host 127.0.0.1", "lint": "eslint . --max-warnings 0", - "typecheck": "tsc -b --pretty false", + "typecheck": "node ./node_modules/@typescript/native/bin/tsc -b --pretty false", "test": "vitest run", - "build": "tsc -b && vite build" + "build": "node ./node_modules/@typescript/native/bin/tsc -b && vite build" }, "dependencies": { "@tanstack/react-query": "5.101.4", @@ -26,13 +26,14 @@ "@types/node": "24.10.0", "@types/react": "19.2.14", "@types/react-dom": "19.2.4", + "@typescript/native": "npm:typescript@7.0.2", "@vitejs/plugin-react": "6.0.4", "eslint": "10.0.1", "eslint-plugin-react-hooks": "7.1.1", "eslint-plugin-react-refresh": "0.5.3", "globals": "17.7.0", "jsdom": "30.0.1", - "typescript": "7.0.2", + "typescript": "npm:@typescript/typescript6@6.0.2", "typescript-eslint": "8.65.0", "vite": "8.2.0", "vitest": "4.1.10" From a6f9533aacd4d6cdbbf9682bfd8482ced065ebf5 Mon Sep 17 00:00:00 2001 From: golem Date: Mon, 17 Aug 2026 16:12:26 -0600 Subject: [PATCH 03/30] build: approve pinned install scripts --- package.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/package.json b/package.json index 1612ecb..dd15769 100644 --- a/package.json +++ b/package.json @@ -10,5 +10,9 @@ }, "devDependencies": { "@openzeppelin/upgrades-core": "1.46.0" + }, + "allowScripts": { + "keccak@3.0.4": true, + "secp256k1@4.0.5": true } } From 1f4175ba729fbbab58538cd3ac02b5459e014c86 Mon Sep 17 00:00:00 2001 From: golem Date: Mon, 17 Aug 2026 16:22:30 -0600 Subject: [PATCH 04/30] feat: add valueless mock USDC --- src/MockUSDC.sol | 18 ++++++++++++++ test/MockUSDC.t.sol | 60 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+) create mode 100644 src/MockUSDC.sol create mode 100644 test/MockUSDC.t.sol diff --git a/src/MockUSDC.sol b/src/MockUSDC.sol new file mode 100644 index 0000000..96ca297 --- /dev/null +++ b/src/MockUSDC.sol @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.35; + +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; + +/// @notice Educational mock token with no monetary value. Never use as real USDC. +contract MockUSDC is ERC20, Ownable { + constructor(address initialOwner) ERC20("Mock USD Coin", "mUSDC") Ownable(initialOwner) {} + + function decimals() public pure override returns (uint8) { + return 6; + } + + function mint(address to, uint256 amount) external onlyOwner { + _mint(to, amount); + } +} diff --git a/test/MockUSDC.t.sol b/test/MockUSDC.t.sol new file mode 100644 index 0000000..028c1a0 --- /dev/null +++ b/test/MockUSDC.t.sol @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.35; + +import {Test} from "forge-std/Test.sol"; +import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; +import {MockUSDC} from "../src/MockUSDC.sol"; + +contract MockUSDCTest is Test { + MockUSDC internal token; + address internal constant STRANGER = address(0xBEEF); + address internal constant RECIPIENT = address(0xCAFE); + address internal constant SPENDER = address(0xD00D); + + function setUp() public { + token = new MockUSDC(address(this)); + } + + function testMetadataUsesSixDecimalMockUSDC() public view { + assertEq(token.name(), "Mock USD Coin"); + assertEq(token.symbol(), "mUSDC"); + assertEq(token.decimals(), 6); + } + + function testInitialSupplyIsZero() public view { + assertEq(token.totalSupply(), 0); + } + + function testMintMintsToRecipientWhenCalledByOwner() public { + token.mint(RECIPIENT, 1_250_000); + + assertEq(token.totalSupply(), 1_250_000); + assertEq(token.balanceOf(RECIPIENT), 1_250_000); + } + + function testMintRevertsWhenCalledByNonOwner() public { + vm.prank(STRANGER); + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, STRANGER)); + token.mint(RECIPIENT, 1); + } + + function testTransferMovesMintedBalance() public { + token.mint(address(this), 1_250_000); + + token.transfer(RECIPIENT, 250_000); + + assertEq(token.balanceOf(address(this)), 1_000_000); + assertEq(token.balanceOf(RECIPIENT), 250_000); + } + + function testApproveSetsAllowanceForSpender() public { + token.approve(SPENDER, 750_000); + + assertEq(token.allowance(address(this), SPENDER), 750_000); + } + + function testConstructorRevertsForZeroInitialOwner() public { + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableInvalidOwner.selector, address(0))); + new MockUSDC(address(0)); + } +} From 0664eb4688bd236e2678c5dda2f4a10e898205ed Mon Sep 17 00:00:00 2001 From: golem Date: Mon, 17 Aug 2026 16:54:31 -0600 Subject: [PATCH 05/30] feat: establish UUPS bank V1 --- .../plans/2026-08-17-uups-bank-demo.md | 10 +- src/BankV1.sol | 60 ++++++++++++ test/BankV1Admin.t.sol | 94 +++++++++++++++++++ test/helpers/BankTestBase.sol | 33 +++++++ 4 files changed, 192 insertions(+), 5 deletions(-) create mode 100644 src/BankV1.sol create mode 100644 test/BankV1Admin.t.sol create mode 100644 test/helpers/BankTestBase.sol diff --git a/docs/superpowers/plans/2026-08-17-uups-bank-demo.md b/docs/superpowers/plans/2026-08-17-uups-bank-demo.md index 3a3ca8f..cfaaaa6 100644 --- a/docs/superpowers/plans/2026-08-17-uups-bank-demo.md +++ b/docs/superpowers/plans/2026-08-17-uups-bank-demo.md @@ -14,7 +14,7 @@ - State-changing scripts accept only chain IDs `31337` and `84532`; no mainnet RPC, address, target, or configuration is added. - Local accounts come only from Anvil’s standard development mnemonic. Testnet signing uses a named encrypted Foundry keystore and `--account`; no supported command accepts a raw private key or mnemonic environment variable. - Every application call uses the proxy address. The implementation address is read only for validation and explanation. -- `Upgrades`, never `UnsafeUpgrades`, performs deploy/upgrade validation. The sole validator allowance is the constructor annotation immediately above `_disableInitializers()`. +- `Upgrades`, never `UnsafeUpgrades`, performs deploy/upgrade validation. The sole validator allowance is `@custom:oz-upgrades-unsafe-allow constructor` immediately above the constructor that calls `_disableInitializers()`; no Options `unsafeAllow`, exclude, skip, `UnsafeUpgrades`, reachable annotation, or other bypass is permitted. BankV1 uses OpenZeppelin 5.6.1 `ReentrancyGuardTransient`, the user-approved constructor-free guard for this project's Cancun-targeted Anvil and Base Sepolia networks. - V2 does not add, delete, reorder, or change the type of any storage variable and has no initializer. - The browser has no connector, signer, transaction client, or write button. Failed reads remain unknown; they are never rendered as zero. - Generated Foundry output, local manifests, copied web artifacts, `.demo/` process state, `.env`, and `.superpowers/` are ignored by Git. @@ -374,7 +374,7 @@ Expected red: `BankV1` is missing. ```solidity import {Initializable} from "@openzeppelin/contracts/proxy/utils/Initializable.sol"; import {UUPSUpgradeable} from "@openzeppelin/contracts/proxy/utils/UUPSUpgradeable.sol"; -import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; +import {ReentrancyGuardTransient} from "@openzeppelin/contracts/utils/ReentrancyGuardTransient.sol"; import {OwnableUpgradeable} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; import {PausableUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/PausableUpgradeable.sol"; @@ -383,7 +383,7 @@ contract BankV1 is UUPSUpgradeable, OwnableUpgradeable, PausableUpgradeable, - ReentrancyGuard + ReentrancyGuardTransient { IERC20 internal _asset; mapping(address account => uint256 balance) internal _balances; @@ -392,7 +392,7 @@ contract BankV1 is } ``` -Use `error InvalidAsset(address asset);`. The initializer checks the asset before calling only `__Ownable_init(initialOwner)` and `__Pausable_init()`. `Initializable`, `UUPSUpgradeable`, and `ReentrancyGuard` are stateless/shared in pinned OpenZeppelin 5.6.1 and have no initializer calls. +Use `error InvalidAsset(address asset);`. The initializer checks the asset before calling only `__Ownable_init(initialOwner)` and `__Pausable_init()`. `Initializable`, `UUPSUpgradeable`, and `ReentrancyGuardTransient` are stateless/shared in pinned OpenZeppelin 5.6.1 and have no initializer calls. The transient guard is required by the user-approved final design because it is constructor-free and the project's supported Anvil and Base Sepolia networks target Cancun/EIP-1153. - [ ] Add the only permitted validator annotation and no other bypass: @@ -1081,7 +1081,7 @@ Retain the explicit `deploy-v1`, `seed-v1`, and `sync-artifacts` lower-level tar - [ ] Write the prepared portion of `PRESENTER_RUNBOOK.md`: preflight, rehearsal timing, exact three-act story, the approved live Codex prompt verbatim, Act 1 expected state, commands/console callouts, occupied-port/stale-console/test-failure recovery, and the rule that no upgrade runs until `make verify` passes. Describe Act 2/3 expected outcomes without including reference V2 source, and include a closing trust disclosure checklist matching README/UI word-for-word in substance. -- [ ] Implement `scan-project.sh` with a NUL-safe array from `git ls-files`, excluding dependency gitlinks, `docs/superpowers`, and generated lockfiles. Return nonzero if project-owned tracked content contains an actual `PRIVATE_KEY`/`MNEMONIC` assignment, PEM private key, `TODO`, `TBD`, `FIXME`, filler text, or an unsafe upgrade bypass in `src`/`test`/`script`. Construct the scanner’s own pattern from split shell literals so it does not match itself. Permit only the exact hyphenated constructor annotation. The committed `ANVIL_TEST_PHRASE` is the universally known test fixture, not a supported secret input; test that exact fixture is the only scan exception. +- [ ] Implement `scan-project.sh` with a NUL-safe array from `git ls-files`, excluding dependency gitlinks, `docs/superpowers`, and generated lockfiles. Return nonzero if project-owned tracked content contains an actual `PRIVATE_KEY`/`MNEMONIC` assignment, PEM private key, `TODO`, `TBD`, `FIXME`, filler text, or an unsafe upgrade bypass in `src`/`test`/`script`. Construct the scanner’s own pattern from split shell literals so it does not match itself. Permit only the exact hyphenated `oz-upgrades-unsafe-allow constructor` annotation approved for `BankV1`. The committed `ANVIL_TEST_PHRASE` is the universally known test fixture, not a supported secret input; test that exact fixture is the only scan exception. - [ ] Run process and full verification: diff --git a/src/BankV1.sol b/src/BankV1.sol new file mode 100644 index 0000000..13ffca5 --- /dev/null +++ b/src/BankV1.sol @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.35; + +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {Initializable} from "@openzeppelin/contracts/proxy/utils/Initializable.sol"; +import {UUPSUpgradeable} from "@openzeppelin/contracts/proxy/utils/UUPSUpgradeable.sol"; +import {ReentrancyGuardTransient} from "@openzeppelin/contracts/utils/ReentrancyGuardTransient.sol"; +import {OwnableUpgradeable} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; +import {PausableUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/PausableUpgradeable.sol"; + +contract BankV1 is Initializable, UUPSUpgradeable, OwnableUpgradeable, PausableUpgradeable, ReentrancyGuardTransient { + error InvalidAsset(address asset); + + IERC20 internal _asset; + mapping(address account => uint256 balance) internal _balances; + uint256 internal _totalLiabilities; + uint256[47] private __gap; + + /// @custom:oz-upgrades-unsafe-allow constructor + constructor() { + _disableInitializers(); + } + + function initialize(address asset_, address initialOwner) external initializer { + if (asset_ == address(0)) { + revert InvalidAsset(asset_); + } + + __Ownable_init(initialOwner); + __Pausable_init(); + + _asset = IERC20(asset_); + } + + function pause() external onlyOwner { + _pause(); + } + + function unpause() external onlyOwner { + _unpause(); + } + + function asset() external view returns (IERC20) { + return _asset; + } + + function balanceOf(address account) external view returns (uint256) { + return _balances[account]; + } + + function totalLiabilities() external view returns (uint256) { + return _totalLiabilities; + } + + function contractVersion() public pure virtual returns (uint256) { + return 1; + } + + function _authorizeUpgrade(address) internal override onlyOwner {} +} diff --git a/test/BankV1Admin.t.sol b/test/BankV1Admin.t.sol new file mode 100644 index 0000000..7e3c26c --- /dev/null +++ b/test/BankV1Admin.t.sol @@ -0,0 +1,94 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.35; + +import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import {Initializable} from "@openzeppelin/contracts/proxy/utils/Initializable.sol"; +import {UUPSUpgradeable} from "@openzeppelin/contracts/proxy/utils/UUPSUpgradeable.sol"; +import {OwnableUpgradeable} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; + +import {BankV1} from "../src/BankV1.sol"; +import {BankTestBase} from "./helpers/BankTestBase.sol"; + +contract BankV1AdminTest is BankTestBase { + bytes32 private constant ERC1967_IMPLEMENTATION_SLOT = + 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc; + + function testProxyStartsWithConfiguredAdministrationAndEmptyAccounting() public view { + assertEq(address(bank.asset()), address(token)); + assertEq(bank.owner(), owner); + assertFalse(bank.paused()); + assertEq(bank.balanceOf(alice), 0); + assertEq(bank.totalLiabilities(), 0); + assertEq(bank.contractVersion(), 1); + } + + function testInitializationChecksZeroAssetBeforeZeroOwner() public { + vm.expectRevert(abi.encodeWithSelector(BankV1.InvalidAsset.selector, address(0))); + new ERC1967Proxy(implementation, abi.encodeCall(BankV1.initialize, (address(0), address(0)))); + } + + function testInitializationRejectsZeroOwner() public { + vm.expectRevert(abi.encodeWithSelector(OwnableUpgradeable.OwnableInvalidOwner.selector, address(0))); + new ERC1967Proxy(implementation, abi.encodeCall(BankV1.initialize, (address(token), address(0)))); + } + + function testProxyCannotBeInitializedTwice() public { + vm.expectRevert(Initializable.InvalidInitialization.selector); + bank.initialize(address(token), owner); + } + + function testImplementationCannotBeInitialized() public { + vm.expectRevert(Initializable.InvalidInitialization.selector); + BankV1(implementation).initialize(address(token), owner); + } + + function testOwnerCanPauseAndUnpause() public { + vm.prank(owner); + bank.pause(); + assertTrue(bank.paused()); + + vm.prank(owner); + bank.unpause(); + assertFalse(bank.paused()); + } + + function testNonOwnerCannotPause() public { + vm.prank(stranger); + vm.expectRevert(abi.encodeWithSelector(OwnableUpgradeable.OwnableUnauthorizedAccount.selector, stranger)); + bank.pause(); + } + + function testNonOwnerCannotUnpause() public { + vm.prank(owner); + bank.pause(); + + vm.prank(stranger); + vm.expectRevert(abi.encodeWithSelector(OwnableUpgradeable.OwnableUnauthorizedAccount.selector, stranger)); + bank.unpause(); + } + + function testViewsRemainAvailableWhilePaused() public { + vm.prank(owner); + bank.pause(); + + assertEq(address(bank.asset()), address(token)); + assertEq(bank.balanceOf(alice), 0); + assertEq(bank.totalLiabilities(), 0); + assertEq(bank.contractVersion(), 1); + } + + function testNonOwnerCannotAuthorizeUpgrade() public { + vm.prank(stranger); + vm.expectRevert(abi.encodeWithSelector(OwnableUpgradeable.OwnableUnauthorizedAccount.selector, stranger)); + bank.upgradeToAndCall(implementation, ""); + } + + function testImplementationExposesERC1967ProxiableUUID() public view { + assertEq(BankV1(implementation).proxiableUUID(), ERC1967_IMPLEMENTATION_SLOT); + } + + function testProxyRejectsProxiableUUIDCall() public { + vm.expectRevert(UUPSUpgradeable.UUPSUnauthorizedCallContext.selector); + bank.proxiableUUID(); + } +} diff --git a/test/helpers/BankTestBase.sol b/test/helpers/BankTestBase.sol new file mode 100644 index 0000000..9287cc4 --- /dev/null +++ b/test/helpers/BankTestBase.sol @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.35; + +import {Test} from "forge-std/Test.sol"; +import {Upgrades} from "openzeppelin-foundry-upgrades/Upgrades.sol"; + +import {BankV1} from "../../src/BankV1.sol"; +import {MockUSDC} from "../../src/MockUSDC.sol"; + +abstract contract BankTestBase is Test { + address internal owner; + address internal alice; + address internal bob; + address internal stranger; + + MockUSDC internal token; + address internal proxy; + address internal implementation; + BankV1 internal bank; + + function setUp() public virtual { + owner = makeAddr("owner"); + alice = makeAddr("alice"); + bob = makeAddr("bob"); + stranger = makeAddr("stranger"); + + token = new MockUSDC(owner); + proxy = + Upgrades.deployUUPSProxy("BankV1.sol:BankV1", abi.encodeCall(BankV1.initialize, (address(token), owner))); + bank = BankV1(proxy); + implementation = Upgrades.getImplementationAddress(proxy); + } +} From 2a21766237a9c29970519f7fa00482faecd06fdd Mon Sep 17 00:00:00 2001 From: golem Date: Mon, 17 Aug 2026 17:10:35 -0600 Subject: [PATCH 06/30] feat: add V1 custody accounting --- src/BankV1.sol | 34 ++++ test/BankV1.t.sol | 326 ++++++++++++++++++++++++++++++ test/mocks/FeeOnTransferToken.sol | 26 +++ test/mocks/ReentrantToken.sol | 104 ++++++++++ 4 files changed, 490 insertions(+) create mode 100644 test/BankV1.t.sol create mode 100644 test/mocks/FeeOnTransferToken.sol create mode 100644 test/mocks/ReentrantToken.sol diff --git a/src/BankV1.sol b/src/BankV1.sol index 13ffca5..0f55426 100644 --- a/src/BankV1.sol +++ b/src/BankV1.sol @@ -2,6 +2,7 @@ pragma solidity 0.8.35; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import {Initializable} from "@openzeppelin/contracts/proxy/utils/Initializable.sol"; import {UUPSUpgradeable} from "@openzeppelin/contracts/proxy/utils/UUPSUpgradeable.sol"; import {ReentrancyGuardTransient} from "@openzeppelin/contracts/utils/ReentrancyGuardTransient.sol"; @@ -9,7 +10,15 @@ import {OwnableUpgradeable} from "@openzeppelin/contracts-upgradeable/access/Own import {PausableUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/PausableUpgradeable.sol"; contract BankV1 is Initializable, UUPSUpgradeable, OwnableUpgradeable, PausableUpgradeable, ReentrancyGuardTransient { + using SafeERC20 for IERC20; + error InvalidAsset(address asset); + error ZeroAmount(); + error InsufficientBalance(address account, uint256 available, uint256 requested); + error UnexpectedAssetDelta(uint256 expected, uint256 actual); + + event Deposited(address indexed account, uint256 amount); + event Withdrawn(address indexed account, uint256 amount); IERC20 internal _asset; mapping(address account => uint256 balance) internal _balances; @@ -40,6 +49,31 @@ contract BankV1 is Initializable, UUPSUpgradeable, OwnableUpgradeable, PausableU _unpause(); } + function deposit(uint256 amount) external whenNotPaused nonReentrant { + if (amount == 0) revert ZeroAmount(); + + uint256 reservesBefore = _asset.balanceOf(address(this)); + _asset.safeTransferFrom(msg.sender, address(this), amount); + uint256 reservesAfter = _asset.balanceOf(address(this)); + uint256 received = reservesAfter >= reservesBefore ? reservesAfter - reservesBefore : 0; + if (received != amount) revert UnexpectedAssetDelta(amount, received); + + _balances[msg.sender] += amount; + _totalLiabilities += amount; + emit Deposited(msg.sender, amount); + } + + function withdraw(uint256 amount) external whenNotPaused nonReentrant { + if (amount == 0) revert ZeroAmount(); + uint256 available = _balances[msg.sender]; + if (amount > available) revert InsufficientBalance(msg.sender, available, amount); + + _balances[msg.sender] = available - amount; + _totalLiabilities -= amount; + _asset.safeTransfer(msg.sender, amount); + emit Withdrawn(msg.sender, amount); + } + function asset() external view returns (IERC20) { return _asset; } diff --git a/test/BankV1.t.sol b/test/BankV1.t.sol new file mode 100644 index 0000000..e53edc1 --- /dev/null +++ b/test/BankV1.t.sol @@ -0,0 +1,326 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.35; + +import {IERC20Errors} from "@openzeppelin/contracts/interfaces/draft-IERC6093.sol"; +import {PausableUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/PausableUpgradeable.sol"; +import {ReentrancyGuardTransient} from "@openzeppelin/contracts/utils/ReentrancyGuardTransient.sol"; +import {Upgrades} from "openzeppelin-foundry-upgrades/Upgrades.sol"; + +import {BankV1} from "../src/BankV1.sol"; +import {BankTestBase} from "./helpers/BankTestBase.sol"; +import {FeeOnTransferToken} from "./mocks/FeeOnTransferToken.sol"; +import {ReentrantToken} from "./mocks/ReentrantToken.sol"; + +event Deposited(address indexed account, uint256 amount); +event Withdrawn(address indexed account, uint256 amount); + +error ZeroAmount(); +error InsufficientBalance(address account, uint256 available, uint256 requested); +error UnexpectedAssetDelta(uint256 expected, uint256 actual); + +contract BankV1CustodyTest is BankTestBase { + uint256 private constant MAX_DEPOSIT = 1_000_000e6; + + function testDepositCreditsExactCustomerAndLiabilityAgainstReceivedReserves() public { + _mintAndApprove(alice, 1_000e6, 100e6); + + vm.prank(alice); + bank.deposit(100e6); + + assertEq(token.balanceOf(alice), 900e6); + assertEq(token.balanceOf(proxy), 100e6); + assertEq(bank.balanceOf(alice), 100e6); + assertEq(bank.totalLiabilities(), 100e6); + } + + function testDepositEmitsDepositedEvent() public { + _mintAndApprove(alice, 100e6, 100e6); + + vm.expectEmit(true, false, false, true, proxy); + emit Deposited(alice, 100e6); + vm.prank(alice); + bank.deposit(100e6); + } + + function testDepositRejectsZeroAmount() public { + vm.prank(alice); + vm.expectRevert(ZeroAmount.selector); + bank.deposit(0); + } + + function testDepositRejectsCallsWhilePaused() public { + _mintAndApprove(alice, 100e6, 100e6); + vm.prank(owner); + bank.pause(); + + vm.prank(alice); + vm.expectRevert(PausableUpgradeable.EnforcedPause.selector); + bank.deposit(100e6); + } + + function testDepositRollsBackWhenAllowanceIsInadequate() public { + _mintAndApprove(alice, 100e6, 99e6); + + vm.prank(alice); + vm.expectRevert(abi.encodeWithSelector(IERC20Errors.ERC20InsufficientAllowance.selector, proxy, 99e6, 100e6)); + bank.deposit(100e6); + + _assertEmptyAccounting(alice); + assertEq(token.balanceOf(alice), 100e6); + } + + function testDepositRollsBackWhenWalletBalanceIsInadequate() public { + _mintAndApprove(alice, 99e6, 100e6); + + vm.prank(alice); + vm.expectRevert(abi.encodeWithSelector(IERC20Errors.ERC20InsufficientBalance.selector, alice, 99e6, 100e6)); + bank.deposit(100e6); + + _assertEmptyAccounting(alice); + assertEq(token.balanceOf(alice), 99e6); + } + + function testDepositsKeepTwoCustomersAccountingIndependent() public { + _mintAndApprove(alice, 1_000e6, 300e6); + _mintAndApprove(bob, 1_000e6, 700e6); + + vm.prank(alice); + bank.deposit(300e6); + vm.prank(bob); + bank.deposit(700e6); + + assertEq(bank.balanceOf(alice), 300e6); + assertEq(bank.balanceOf(bob), 700e6); + assertEq(bank.totalLiabilities(), 1_000e6); + assertEq(token.balanceOf(proxy), 1_000e6); + } + + function testFeeOnTransferDepositRevertsAndRollsBackTokenAndAccounting() public { + FeeOnTransferToken feeToken = new FeeOnTransferToken(); + BankV1 feeBank = _deployBank(address(feeToken)); + feeToken.mint(alice, 100e6); + vm.prank(alice); + feeToken.approve(address(feeBank), 100e6); + + vm.prank(alice); + vm.expectRevert(abi.encodeWithSelector(UnexpectedAssetDelta.selector, 100e6, 99e6)); + feeBank.deposit(100e6); + + assertEq(feeToken.balanceOf(alice), 100e6); + assertEq(feeToken.balanceOf(address(feeBank)), 0); + assertEq(feeBank.balanceOf(alice), 0); + assertEq(feeBank.totalLiabilities(), 0); + } + + function testDepositSwallowsNestedRevertAndCreditsOnlyOnce() public { + ReentrantToken reentrantToken = new ReentrantToken(); + BankV1 reentrantBank = _deployBank(address(reentrantToken)); + reentrantToken.mint(alice, 100e6); + vm.prank(alice); + reentrantToken.approve(address(reentrantBank), 100e6); + reentrantToken.configureDepositCallback(address(reentrantBank), false); + + vm.prank(alice); + reentrantBank.deposit(100e6); + + assertTrue(reentrantToken.nestedCallAttempted()); + assertFalse(reentrantToken.nestedCallSucceeded()); + assertEq(reentrantToken.nestedRevertSelector(), ReentrancyGuardTransient.ReentrancyGuardReentrantCall.selector); + assertEq(reentrantToken.observedAccountBalance(), 0); + assertEq(reentrantToken.observedLiabilities(), 0); + assertEq(reentrantToken.balanceOf(alice), 0); + assertEq(reentrantToken.balanceOf(address(reentrantBank)), 100e6); + assertEq(reentrantBank.balanceOf(alice), 100e6); + assertEq(reentrantBank.totalLiabilities(), 100e6); + } + + function testDepositPropagatesNestedRevertAtomicallyWhenConfigured() public { + ReentrantToken reentrantToken = new ReentrantToken(); + BankV1 reentrantBank = _deployBank(address(reentrantToken)); + reentrantToken.mint(alice, 100e6); + vm.prank(alice); + reentrantToken.approve(address(reentrantBank), 100e6); + reentrantToken.configureDepositCallback(address(reentrantBank), true); + + vm.prank(alice); + vm.expectRevert(ReentrancyGuardTransient.ReentrancyGuardReentrantCall.selector); + reentrantBank.deposit(100e6); + + assertEq(reentrantToken.balanceOf(alice), 100e6); + assertEq(reentrantToken.balanceOf(address(reentrantBank)), 0); + assertEq(reentrantBank.balanceOf(alice), 0); + assertEq(reentrantBank.totalLiabilities(), 0); + } + + function testWithdrawDebitsExactCustomerLiabilityAndReserves() public { + _deposit(alice, 1_000e6); + + vm.prank(alice); + bank.withdraw(400e6); + + assertEq(token.balanceOf(alice), 400e6); + assertEq(token.balanceOf(proxy), 600e6); + assertEq(bank.balanceOf(alice), 600e6); + assertEq(bank.totalLiabilities(), 600e6); + } + + function testWithdrawEmitsWithdrawnEvent() public { + _deposit(alice, 100e6); + + vm.expectEmit(true, false, false, true, proxy); + emit Withdrawn(alice, 40e6); + vm.prank(alice); + bank.withdraw(40e6); + } + + function testWithdrawRejectsZeroAmount() public { + vm.prank(alice); + vm.expectRevert(ZeroAmount.selector); + bank.withdraw(0); + } + + function testWithdrawRejectsCallsWhilePaused() public { + _deposit(alice, 100e6); + vm.prank(owner); + bank.pause(); + + vm.prank(alice); + vm.expectRevert(PausableUpgradeable.EnforcedPause.selector); + bank.withdraw(100e6); + } + + function testWithdrawReportsAvailableAndRequestedOnInsufficientInternalBalance() public { + _deposit(alice, 40e6); + + vm.prank(alice); + vm.expectRevert(abi.encodeWithSelector(InsufficientBalance.selector, alice, 40e6, 41e6)); + bank.withdraw(41e6); + + assertEq(bank.balanceOf(alice), 40e6); + assertEq(bank.totalLiabilities(), 40e6); + assertEq(token.balanceOf(proxy), 40e6); + } + + function testWithdrawUpdatesAccountingBeforeTransferCallbackAndCannotDoubleDebit() public { + ReentrantToken reentrantToken = new ReentrantToken(); + BankV1 reentrantBank = _deployBank(address(reentrantToken)); + reentrantToken.mint(alice, 100e6); + vm.prank(alice); + reentrantToken.approve(address(reentrantBank), 100e6); + vm.prank(alice); + reentrantBank.deposit(100e6); + reentrantToken.configureWithdrawalCallback(address(reentrantBank), false); + + vm.prank(alice); + reentrantBank.withdraw(40e6); + + assertEq(reentrantToken.observedAccountBalance(), 60e6); + assertEq(reentrantToken.observedLiabilities(), 60e6); + assertTrue(reentrantToken.nestedCallAttempted()); + assertFalse(reentrantToken.nestedCallSucceeded()); + assertEq(reentrantToken.nestedRevertSelector(), ReentrancyGuardTransient.ReentrancyGuardReentrantCall.selector); + assertEq(reentrantToken.balanceOf(alice), 40e6); + assertEq(reentrantToken.balanceOf(address(reentrantBank)), 60e6); + assertEq(reentrantBank.balanceOf(alice), 60e6); + assertEq(reentrantBank.totalLiabilities(), 60e6); + } + + function testOneCustomersWithdrawalLeavesOtherCustomerUnchanged() public { + _deposit(alice, 100e6); + _deposit(bob, 200e6); + + vm.prank(alice); + bank.withdraw(40e6); + + assertEq(bank.balanceOf(alice), 60e6); + assertEq(bank.balanceOf(bob), 200e6); + assertEq(bank.totalLiabilities(), 260e6); + assertEq(token.balanceOf(proxy), 260e6); + } + + function testDirectTransferCreatesSurplusThatRemainsAfterFullWithdrawal() public { + _deposit(alice, 100e6); + vm.prank(owner); + token.mint(bob, 25e6); + vm.prank(bob); + token.transfer(proxy, 25e6); + + assertEq(token.balanceOf(proxy), 125e6); + assertEq(bank.totalLiabilities(), 100e6); + + vm.prank(alice); + bank.withdraw(100e6); + + assertEq(token.balanceOf(proxy), 25e6); + assertEq(bank.balanceOf(alice), 0); + assertEq(bank.totalLiabilities(), 0); + } + + function testFuzzDepositPreservesExactAccounting(uint256 amountSeed) public { + uint256 amount = bound(amountSeed, 1, MAX_DEPOSIT); + _mintAndApprove(alice, amount, amount); + + vm.prank(alice); + bank.deposit(amount); + + assertEq(token.balanceOf(alice), 0); + assertEq(token.balanceOf(proxy), amount); + assertEq(bank.balanceOf(alice), amount); + assertEq(bank.totalLiabilities(), amount); + } + + function testFuzzWithdrawPreservesExactAccounting(uint256 depositSeed, uint256 withdrawalSeed) public { + uint256 deposited = bound(depositSeed, 1, MAX_DEPOSIT); + uint256 withdrawn = bound(withdrawalSeed, 1, deposited); + _deposit(alice, deposited); + + vm.prank(alice); + bank.withdraw(withdrawn); + + uint256 remaining = deposited - withdrawn; + assertEq(token.balanceOf(alice), withdrawn); + assertEq(token.balanceOf(proxy), remaining); + assertEq(bank.balanceOf(alice), remaining); + assertEq(bank.totalLiabilities(), remaining); + } + + function testFuzzOverWithdrawAlwaysReverts(uint256 depositSeed, uint256 excessSeed) public { + uint256 deposited = bound(depositSeed, 1, MAX_DEPOSIT); + uint256 excess = bound(excessSeed, 1, MAX_DEPOSIT); + uint256 requested = deposited + excess; + _deposit(alice, deposited); + + vm.prank(alice); + vm.expectRevert(abi.encodeWithSelector(InsufficientBalance.selector, alice, deposited, requested)); + bank.withdraw(requested); + + assertEq(bank.balanceOf(alice), deposited); + assertEq(bank.totalLiabilities(), deposited); + assertEq(token.balanceOf(proxy), deposited); + } + + function _mintAndApprove(address account, uint256 mintAmount, uint256 approveAmount) private { + vm.prank(owner); + token.mint(account, mintAmount); + vm.prank(account); + token.approve(proxy, approveAmount); + } + + function _deposit(address account, uint256 amount) private { + _mintAndApprove(account, amount, amount); + vm.prank(account); + bank.deposit(amount); + } + + function _deployBank(address asset_) private returns (BankV1 deployedBank) { + address deployedProxy = + Upgrades.deployUUPSProxy("BankV1.sol:BankV1", abi.encodeCall(BankV1.initialize, (asset_, owner))); + deployedBank = BankV1(deployedProxy); + } + + function _assertEmptyAccounting(address account) private view { + assertEq(token.balanceOf(proxy), 0); + assertEq(bank.balanceOf(account), 0); + assertEq(bank.totalLiabilities(), 0); + } +} diff --git a/test/mocks/FeeOnTransferToken.sol b/test/mocks/FeeOnTransferToken.sol new file mode 100644 index 0000000..80f5280 --- /dev/null +++ b/test/mocks/FeeOnTransferToken.sol @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.35; + +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; + +contract FeeOnTransferToken is ERC20 { + constructor() ERC20("Fee-on-Transfer Token", "FOT") {} + + function decimals() public pure override returns (uint8) { + return 6; + } + + function mint(address to, uint256 amount) external { + _mint(to, amount); + } + + function transferFrom(address from, address to, uint256 amount) public override returns (bool) { + address spender = _msgSender(); + _spendAllowance(from, spender, amount); + + uint256 received = amount * 99 / 100; + _transfer(from, to, received); + _burn(from, amount - received); + return true; + } +} diff --git a/test/mocks/ReentrantToken.sol b/test/mocks/ReentrantToken.sol new file mode 100644 index 0000000..98dabf3 --- /dev/null +++ b/test/mocks/ReentrantToken.sol @@ -0,0 +1,104 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.35; + +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; + +interface IReentrantBankTarget { + function deposit(uint256 amount) external; + function withdraw(uint256 amount) external; + function balanceOf(address account) external view returns (uint256); + function totalLiabilities() external view returns (uint256); +} + +contract ReentrantToken is ERC20 { + enum Callback { + None, + Deposit, + Withdraw + } + + IReentrantBankTarget public callbackTarget; + Callback public callback; + bool public propagateRevert; + bool public nestedCallAttempted; + bool public nestedCallSucceeded; + bytes4 public nestedRevertSelector; + uint256 public observedAccountBalance; + uint256 public observedLiabilities; + + constructor() ERC20("Reentrant Token", "REENT") {} + + function decimals() public pure override returns (uint8) { + return 6; + } + + function mint(address to, uint256 amount) external { + _mint(to, amount); + } + + function configureDepositCallback(address bank, bool propagate) external { + callbackTarget = IReentrantBankTarget(bank); + callback = Callback.Deposit; + propagateRevert = propagate; + _resetObservations(); + } + + function configureWithdrawalCallback(address bank, bool propagate) external { + callbackTarget = IReentrantBankTarget(bank); + callback = Callback.Withdraw; + propagateRevert = propagate; + _resetObservations(); + } + + function clearCallback() external { + callback = Callback.None; + propagateRevert = false; + _resetObservations(); + } + + function transferFrom(address from, address to, uint256 amount) public override returns (bool) { + if (callback == Callback.Deposit && _msgSender() == address(callbackTarget)) { + observedAccountBalance = callbackTarget.balanceOf(from); + observedLiabilities = callbackTarget.totalLiabilities(); + _attemptNestedCall(abi.encodeCall(IReentrantBankTarget.deposit, (1))); + } + return super.transferFrom(from, to, amount); + } + + function transfer(address to, uint256 amount) public override returns (bool) { + if (callback == Callback.Withdraw && _msgSender() == address(callbackTarget)) { + observedAccountBalance = callbackTarget.balanceOf(to); + observedLiabilities = callbackTarget.totalLiabilities(); + _attemptNestedCall(abi.encodeCall(IReentrantBankTarget.withdraw, (1))); + } + return super.transfer(to, amount); + } + + function _attemptNestedCall(bytes memory callData) private { + nestedCallAttempted = true; + bytes memory revertData; + (nestedCallSucceeded, revertData) = address(callbackTarget).call(callData); + + if (!nestedCallSucceeded && revertData.length >= 4) { + bytes4 selector; + assembly ("memory-safe") { + selector := mload(add(revertData, 0x20)) + } + nestedRevertSelector = selector; + } + + if (!nestedCallSucceeded && propagateRevert) { + assembly ("memory-safe") { + revert(add(revertData, 0x20), mload(revertData)) + } + } + } + + function _resetObservations() private { + nestedCallAttempted = false; + nestedCallSucceeded = false; + nestedRevertSelector = bytes4(0); + observedAccountBalance = 0; + observedLiabilities = 0; + } +} From 6dcbb03f3c79979416c2f0dd37556539276a4e29 Mon Sep 17 00:00:00 2001 From: golem Date: Mon, 17 Aug 2026 17:19:02 -0600 Subject: [PATCH 07/30] test: prove V1 accounting invariants --- test/BankInvariant.t.sol | 43 ++++++++++++++++++++ test/helpers/BankHandler.sol | 79 ++++++++++++++++++++++++++++++++++++ 2 files changed, 122 insertions(+) create mode 100644 test/BankInvariant.t.sol create mode 100644 test/helpers/BankHandler.sol diff --git a/test/BankInvariant.t.sol b/test/BankInvariant.t.sol new file mode 100644 index 0000000..f699902 --- /dev/null +++ b/test/BankInvariant.t.sol @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.35; + +import {BankTestBase} from "./helpers/BankTestBase.sol"; +import {BankHandler} from "./helpers/BankHandler.sol"; + +contract BankInvariantTest is BankTestBase { + BankHandler internal handler; + + function setUp() public override { + super.setUp(); + + handler = new BankHandler(token, bank); + vm.prank(owner); + token.transferOwnership(address(handler)); + + targetContract(address(handler)); + bytes4[] memory selectors = new bytes4[](3); + selectors[0] = handler.deposit.selector; + selectors[1] = handler.withdraw.selector; + selectors[2] = handler.donate.selector; + targetSelector(FuzzSelector({addr: address(handler), selectors: selectors})); + } + + function invariant_liabilitiesEqualTrackedBalances() public view { + uint256 sum; + for (uint256 i; i < handler.actorCount(); ++i) { + sum += bank.balanceOf(handler.actorAt(i)); + } + assertEq(sum, bank.totalLiabilities()); + } + + function invariant_reservesCoverLiabilities() public view { + assertGe(token.balanceOf(address(bank)), bank.totalLiabilities()); + } + + function invariant_ghostAccountingMatchesChain() public view { + assertEq(handler.ghostDeposited() - handler.ghostWithdrawn(), bank.totalLiabilities()); + assertEq( + handler.ghostDeposited() + handler.ghostDonated() - handler.ghostWithdrawn(), token.balanceOf(address(bank)) + ); + } +} diff --git a/test/helpers/BankHandler.sol b/test/helpers/BankHandler.sol new file mode 100644 index 0000000..20ec183 --- /dev/null +++ b/test/helpers/BankHandler.sol @@ -0,0 +1,79 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.35; + +import {Test} from "forge-std/Test.sol"; + +import {BankV1} from "../../src/BankV1.sol"; +import {MockUSDC} from "../../src/MockUSDC.sol"; + +contract BankHandler is Test { + MockUSDC internal immutable token; + BankV1 internal immutable bank; + + address internal immutable actor0; + address internal immutable actor1; + address internal immutable actor2; + address internal immutable actor3; + + uint256 public ghostDeposited; + uint256 public ghostWithdrawn; + uint256 public ghostDonated; + + constructor(MockUSDC token_, BankV1 bank_) { + token = token_; + bank = bank_; + actor0 = address(0x1001); + actor1 = address(0x1002); + actor2 = address(0x1003); + actor3 = address(0x1004); + } + + function deposit(uint256 actorSeed, uint256 amount) external { + address actor = actorAt(actorSeed % actorCount()); + amount = bound(amount, 1, 10_000e6); + token.mint(actor, amount); + + vm.startPrank(actor); + token.approve(address(bank), amount); + bank.deposit(amount); + vm.stopPrank(); + + ghostDeposited += amount; + } + + function withdraw(uint256 actorSeed, uint256 amount) external { + address actor = actorAt(actorSeed % actorCount()); + uint256 balance = bank.balanceOf(actor); + if (balance == 0) return; + + amount = bound(amount, 1, balance); + vm.startPrank(actor); + bank.withdraw(amount); + vm.stopPrank(); + + ghostWithdrawn += amount; + } + + function donate(uint256 actorSeed, uint256 amount) external { + address actor = actorAt(actorSeed % actorCount()); + amount = bound(amount, 1, 1_000e6); + token.mint(actor, amount); + + vm.startPrank(actor); + token.transfer(address(bank), amount); + vm.stopPrank(); + + ghostDonated += amount; + } + + function actorCount() public pure returns (uint256) { + return 4; + } + + function actorAt(uint256 index) public view returns (address) { + if (index == 0) return actor0; + if (index == 1) return actor1; + if (index == 2) return actor2; + return actor3; + } +} From 52935acf5e908570246ba52bb0d497775891b516 Mon Sep 17 00:00:00 2001 From: golem Date: Fri, 21 Aug 2026 02:17:18 -0600 Subject: [PATCH 08/30] feat: script deterministic V1 demo state --- Makefile | 18 +- deployments/.gitkeep | 1 + script/CheckState.s.sol | 59 +++++++ script/DeployV1.s.sol | 70 ++++++++ script/SeedV1Demo.s.sol | 45 +++++ script/lib/DemoScript.sol | 124 ++++++++++++++ test/ScriptPreflight.t.sol | 278 +++++++++++++++++++++++++++++++ tools/finalize-manifest.mjs | 220 ++++++++++++++++++++++++ tools/select-manifest.mjs | 27 +++ tools/test-finalize-manifest.mjs | 169 +++++++++++++++++++ 10 files changed, 1010 insertions(+), 1 deletion(-) create mode 100644 deployments/.gitkeep create mode 100644 script/CheckState.s.sol create mode 100644 script/DeployV1.s.sol create mode 100644 script/SeedV1Demo.s.sol create mode 100644 script/lib/DemoScript.sol create mode 100644 test/ScriptPreflight.t.sol create mode 100644 tools/finalize-manifest.mjs create mode 100644 tools/select-manifest.mjs create mode 100644 tools/test-finalize-manifest.mjs diff --git a/Makefile b/Makefile index 00edc2c..1cbaa49 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,10 @@ SHELL := /bin/bash .SHELLFLAGS := -euo pipefail -c -.PHONY: doctor setup verify +RPC_LOCAL := http://127.0.0.1:8545 +ANVIL_OWNER := 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 + +.PHONY: doctor setup verify deploy-v1 seed-v1 check-state test-finalize-manifest doctor: @./tools/doctor.sh setup: @@ -13,7 +16,20 @@ verify: @forge clean @npm_config_offline=true forge build --force @npm_config_offline=true forge test --force + @node tools/test-finalize-manifest.mjs @npm --prefix web run lint @npm --prefix web run typecheck @npm --prefix web test @npm --prefix web run build +test-finalize-manifest: + @node tools/test-finalize-manifest.mjs +deploy-v1: + @node tools/finalize-manifest.mjs preflight-deploy anvil + @SCRIPT_SENDER=$(ANVIL_OWNER) DEPLOYMENT_MANIFEST_PATH=deployments/pending.json npm_config_offline=true forge script script/DeployV1.s.sol:DeployV1 --rpc-url $(RPC_LOCAL) --sender $(ANVIL_OWNER) --broadcast --force + @DEPLOYMENT_MANIFEST_PATH=deployments/pending.json DEMO_EXPECTED_STAGE=deployed forge script script/CheckState.s.sol:CheckState --rpc-url $(RPC_LOCAL) --force + @node tools/finalize-manifest.mjs deploy --rpc-url $(RPC_LOCAL) + @node tools/select-manifest.mjs anvil +seed-v1: + @forge script script/SeedV1Demo.s.sol:SeedV1Demo --rpc-url $(RPC_LOCAL) --broadcast --force +check-state: + @DEMO_EXPECTED_STAGE=$${DEMO_EXPECTED_STAGE:-v1} forge script script/CheckState.s.sol:CheckState --rpc-url $(RPC_LOCAL) --force diff --git a/deployments/.gitkeep b/deployments/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/deployments/.gitkeep @@ -0,0 +1 @@ + diff --git a/script/CheckState.s.sol b/script/CheckState.s.sol new file mode 100644 index 0000000..b2aef5a --- /dev/null +++ b/script/CheckState.s.sol @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.35; + +import {console2} from "forge-std/console2.sol"; +import {Upgrades} from "openzeppelin-foundry-upgrades/Upgrades.sol"; +import {BankV1} from "../src/BankV1.sol"; +import {MockUSDC} from "../src/MockUSDC.sol"; +import {DemoScript} from "./lib/DemoScript.sol"; + +contract CheckState is DemoScript { + error Insolvent(uint256 reserves, uint256 liabilities); + error UnknownStage(string stage); + + function run() external view { + _requireSupportedChain(block.chainid); + string memory stage = vm.envOr("DEMO_EXPECTED_STAGE", string("v1")); + bool deployedStage = keccak256(bytes(stage)) == keccak256("deployed"); + Manifest memory manifest = _readManifest(_manifestPath(ACTIVE_MANIFEST_PATH), !deployedStage); + BankV1 bank = BankV1(manifest.proxy); + MockUSDC token = MockUSDC(manifest.token); + + address actualImplementation = Upgrades.getImplementationAddress(manifest.proxy); + _assertAddress("implementation", manifest.implementation, actualImplementation); + _assertAddress("owner", manifest.owner, bank.owner()); + _assertAddress("asset", manifest.token, address(bank.asset())); + + uint256 reserves = token.balanceOf(manifest.proxy); + uint256 liabilities = bank.totalLiabilities(); + if (reserves < liabilities) revert Insolvent(reserves, liabilities); + uint256 surplus = reserves - liabilities; + + console2.log("network", manifest.network); + console2.log("block", block.number); + console2.log("token", manifest.token); + console2.log("proxy", manifest.proxy); + console2.log("implementation", actualImplementation); + console2.log("owner", bank.owner()); + console2.log("paused", bank.paused()); + console2.log("version", bank.contractVersion()); + for (uint256 i; i < manifest.actors.length; ++i) { + console2.log(manifest.actorLabels[i], manifest.actors[i]); + console2.log(" bank balance", bank.balanceOf(manifest.actors[i])); + console2.log(" token balance", token.balanceOf(manifest.actors[i])); + } + console2.log("reserves", reserves); + console2.log("liabilities", liabilities); + console2.log("surplus", surplus); + + bytes32 stageHash = keccak256(bytes(stage)); + if (deployedStage) { + _assertDeployedState(manifest); + } else if (stageHash == keccak256("v1")) { + _assertV1State(manifest); + _assertUint("surplus", 0, surplus); + } else if (stageHash != keccak256("invariants")) { + revert UnknownStage(stage); + } + } +} diff --git a/script/DeployV1.s.sol b/script/DeployV1.s.sol new file mode 100644 index 0000000..871ffda --- /dev/null +++ b/script/DeployV1.s.sol @@ -0,0 +1,70 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.35; + +import {Upgrades} from "openzeppelin-foundry-upgrades/Upgrades.sol"; +import {BankV1} from "../src/BankV1.sol"; +import {MockUSDC} from "../src/MockUSDC.sol"; +import {DemoScript} from "./lib/DemoScript.sol"; + +contract DeployV1 is DemoScript { + function run() external returns (address tokenAddress, address proxy, address implementation) { + _requireSupportedChain(block.chainid); + address sender = vm.envAddress("SCRIPT_SENDER"); + + if (block.chainid == ANVIL_CHAIN_ID) { + (uint256 ownerKey, address derivedOwner) = _deriveLocalActor(block.chainid, 0); + _assertAddress("SCRIPT_SENDER", derivedOwner, sender); + vm.startBroadcast(ownerKey); + } else { + vm.startBroadcast(sender); + } + + MockUSDC token = new MockUSDC(sender); + proxy = + Upgrades.deployUUPSProxy("BankV1.sol:BankV1", abi.encodeCall(BankV1.initialize, (address(token), sender))); + vm.stopBroadcast(); + + tokenAddress = address(token); + implementation = Upgrades.getImplementationAddress(proxy); + _requireCode("token", tokenAddress); + _requireCode("proxy", proxy); + _requireCode("implementation", implementation); + _assertAddress("owner", sender, BankV1(proxy).owner()); + _assertAddress("asset", tokenAddress, address(BankV1(proxy).asset())); + _assertUint("version", 1, BankV1(proxy).contractVersion()); + _assertAddress("implementation", implementation, Upgrades.getImplementationAddress(proxy)); + + Manifest memory manifest; + manifest.schemaVersion = 1; + manifest.network = block.chainid == ANVIL_CHAIN_ID ? "anvil" : "base-sepolia"; + manifest.chainId = block.chainid; + manifest.deploymentBlock = 0; + manifest.rpcUrl = block.chainid == ANVIL_CHAIN_ID ? "http://127.0.0.1:8545" : "https://sepolia.base.org"; + manifest.explorerUrl = block.chainid == ANVIL_CHAIN_ID ? "" : "https://sepolia.basescan.org"; + manifest.token = tokenAddress; + manifest.proxy = proxy; + manifest.implementation = implementation; + manifest.owner = sender; + (manifest.actorLabels, manifest.actors) = _actors(sender); + + _writeManifest(_manifestPath(PENDING_MANIFEST_PATH), manifest); + } + + function _actors(address sender) private returns (string[] memory labels, address[] memory actors) { + if (block.chainid == ANVIL_CHAIN_ID) { + labels = new string[](3); + actors = new address[](3); + labels[0] = "owner"; + labels[1] = "Alice"; + labels[2] = "Bob"; + actors[0] = sender; + (, actors[1]) = _deriveLocalActor(block.chainid, 1); + (, actors[2]) = _deriveLocalActor(block.chainid, 2); + } else { + labels = new string[](1); + actors = new address[](1); + labels[0] = "owner"; + actors[0] = sender; + } + } +} diff --git a/script/SeedV1Demo.s.sol b/script/SeedV1Demo.s.sol new file mode 100644 index 0000000..d57aef7 --- /dev/null +++ b/script/SeedV1Demo.s.sol @@ -0,0 +1,45 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.35; + +import {BankV1} from "../src/BankV1.sol"; +import {MockUSDC} from "../src/MockUSDC.sol"; +import {DemoScript} from "./lib/DemoScript.sol"; + +contract SeedV1Demo is DemoScript { + function run() external { + if (block.chainid != ANVIL_CHAIN_ID) revert UnsupportedChain(block.chainid); + Manifest memory manifest = _readManifest(_manifestPath(ACTIVE_MANIFEST_PATH), true); + if (manifest.actors.length != 3 || manifest.actorLabels.length != 3) revert InvalidManifestSchema(1); + + (uint256 ownerKey, address owner) = _deriveLocalActor(block.chainid, 0); + (uint256 aliceKey, address alice) = _deriveLocalActor(block.chainid, 1); + (uint256 bobKey, address bob) = _deriveLocalActor(block.chainid, 2); + _assertAddress("owner actor", manifest.owner, owner); + _assertAddress("Alice actor", manifest.actors[1], alice); + _assertAddress("Bob actor", manifest.actors[2], bob); + + MockUSDC token = MockUSDC(manifest.token); + BankV1 bank = BankV1(manifest.proxy); + + vm.startBroadcast(ownerKey); + token.mint(alice, 2_000e6); + token.mint(bob, 1_000e6); + vm.stopBroadcast(); + + vm.startBroadcast(aliceKey); + token.approve(manifest.proxy, 1_000e6); + bank.deposit(1_000e6); + vm.stopBroadcast(); + + vm.startBroadcast(bobKey); + token.approve(manifest.proxy, 500e6); + bank.deposit(500e6); + vm.stopBroadcast(); + + vm.startBroadcast(aliceKey); + bank.withdraw(100e6); + vm.stopBroadcast(); + + _assertV1State(manifest); + } +} diff --git a/script/lib/DemoScript.sol b/script/lib/DemoScript.sol new file mode 100644 index 0000000..28f3d7f --- /dev/null +++ b/script/lib/DemoScript.sol @@ -0,0 +1,124 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.35; + +import {Script} from "forge-std/Script.sol"; +import {BankV1} from "../../src/BankV1.sol"; +import {MockUSDC} from "../../src/MockUSDC.sol"; + +abstract contract DemoScript is Script { + uint256 internal constant ANVIL_CHAIN_ID = 31337; + uint256 internal constant BASE_SEPOLIA_CHAIN_ID = 84532; + string internal constant ANVIL_TEST_PHRASE = "test test test test test test test test test test test junk"; + string internal constant PENDING_MANIFEST_PATH = "deployments/pending.json"; + string internal constant ACTIVE_MANIFEST_PATH = "deployments/active.json"; + + error UnsupportedChain(uint256 chainId); + error ManifestChainMismatch(uint256 expected, uint256 actual); + error MissingCode(string label, address target); + error InvalidDeploymentBlock(); + error InvalidManifestSchema(uint256 schemaVersion); + error UnexpectedState(string label, uint256 expected, uint256 actual); + error UnexpectedAddress(string label, address expected, address actual); + + struct Manifest { + uint256 schemaVersion; + string network; + uint256 chainId; + uint256 deploymentBlock; + string rpcUrl; + string explorerUrl; + address token; + address proxy; + address implementation; + address owner; + string[] actorLabels; + address[] actors; + } + + function _requireSupportedChain(uint256 chainId) internal pure { + if (chainId != ANVIL_CHAIN_ID && chainId != BASE_SEPOLIA_CHAIN_ID) revert UnsupportedChain(chainId); + } + + function _deriveLocalActor(uint256 chainId, uint32 index) internal returns (uint256 privateKey, address actor) { + if (chainId != ANVIL_CHAIN_ID) revert UnsupportedChain(chainId); + privateKey = vm.deriveKey(ANVIL_TEST_PHRASE, index); + actor = vm.addr(privateKey); + } + + function _manifestPath(string memory defaultPath) internal view returns (string memory) { + return vm.envOr("DEPLOYMENT_MANIFEST_PATH", defaultPath); + } + + function _readManifest(string memory path, bool active) internal view returns (Manifest memory manifest) { + string memory json = vm.readFile(path); + manifest.schemaVersion = vm.parseJsonUint(json, ".schemaVersion"); + manifest.network = vm.parseJsonString(json, ".network"); + manifest.chainId = vm.parseJsonUint(json, ".chainId"); + manifest.deploymentBlock = vm.parseJsonUint(json, ".deploymentBlock"); + manifest.rpcUrl = vm.parseJsonString(json, ".rpcUrl"); + manifest.explorerUrl = vm.parseJsonString(json, ".explorerUrl"); + manifest.token = vm.parseJsonAddress(json, ".token"); + manifest.proxy = vm.parseJsonAddress(json, ".proxy"); + manifest.implementation = vm.parseJsonAddress(json, ".implementation"); + manifest.owner = vm.parseJsonAddress(json, ".owner"); + manifest.actorLabels = vm.parseJsonStringArray(json, ".actorLabels"); + manifest.actors = vm.parseJsonAddressArray(json, ".actors"); + + if (manifest.schemaVersion != 1) revert InvalidManifestSchema(manifest.schemaVersion); + if (manifest.chainId != block.chainid) revert ManifestChainMismatch(block.chainid, manifest.chainId); + if (active && manifest.deploymentBlock == 0) revert InvalidDeploymentBlock(); + _requireCode("token", manifest.token); + _requireCode("proxy", manifest.proxy); + _requireCode("implementation", manifest.implementation); + } + + function _requireCode(string memory label, address target) internal view { + if (target == address(0) || target.code.length == 0) revert MissingCode(label, target); + } + + function _serializeManifest(Manifest memory manifest) internal returns (string memory json) { + string memory objectKey = "demo-manifest"; + vm.serializeUint(objectKey, "schemaVersion", manifest.schemaVersion); + vm.serializeString(objectKey, "network", manifest.network); + vm.serializeUint(objectKey, "chainId", manifest.chainId); + vm.serializeUint(objectKey, "deploymentBlock", manifest.deploymentBlock); + vm.serializeString(objectKey, "rpcUrl", manifest.rpcUrl); + vm.serializeString(objectKey, "explorerUrl", manifest.explorerUrl); + vm.serializeAddress(objectKey, "token", manifest.token); + vm.serializeAddress(objectKey, "proxy", manifest.proxy); + vm.serializeAddress(objectKey, "implementation", manifest.implementation); + vm.serializeAddress(objectKey, "owner", manifest.owner); + vm.serializeString(objectKey, "actorLabels", manifest.actorLabels); + json = vm.serializeAddress(objectKey, "actors", manifest.actors); + } + + function _writeManifest(string memory path, Manifest memory manifest) internal { + vm.writeJson(_serializeManifest(manifest), path); + } + + function _assertDeployedState(Manifest memory manifest) internal view { + BankV1 bank = BankV1(manifest.proxy); + _assertAddress("owner", manifest.owner, bank.owner()); + _assertAddress("asset", manifest.token, address(bank.asset())); + _assertUint("version", 1, bank.contractVersion()); + _assertUint("liabilities", 0, bank.totalLiabilities()); + _assertUint("reserves", 0, MockUSDC(manifest.token).balanceOf(manifest.proxy)); + } + + function _assertV1State(Manifest memory manifest) internal view { + BankV1 bank = BankV1(manifest.proxy); + _assertUint("Alice internal balance", 900e6, bank.balanceOf(manifest.actors[1])); + _assertUint("Bob internal balance", 500e6, bank.balanceOf(manifest.actors[2])); + _assertUint("liabilities", 1_400e6, bank.totalLiabilities()); + _assertUint("reserves", 1_400e6, MockUSDC(manifest.token).balanceOf(manifest.proxy)); + _assertUint("version", 1, bank.contractVersion()); + } + + function _assertUint(string memory label, uint256 expected, uint256 actual) internal pure { + if (actual != expected) revert UnexpectedState(label, expected, actual); + } + + function _assertAddress(string memory label, address expected, address actual) internal pure { + if (actual != expected) revert UnexpectedAddress(label, expected, actual); + } +} diff --git a/test/ScriptPreflight.t.sol b/test/ScriptPreflight.t.sol new file mode 100644 index 0000000..2e1f25e --- /dev/null +++ b/test/ScriptPreflight.t.sol @@ -0,0 +1,278 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.35; + +import {Test} from "forge-std/Test.sol"; +import {DemoScript} from "../script/lib/DemoScript.sol"; +import {DeployV1} from "../script/DeployV1.s.sol"; +import {SeedV1Demo} from "../script/SeedV1Demo.s.sol"; +import {CheckState} from "../script/CheckState.s.sol"; +import {BankV1} from "../src/BankV1.sol"; +import {MockUSDC} from "../src/MockUSDC.sol"; + +contract ScriptPreflightHarness is DemoScript { + function requireSupportedChain(uint256 chainId) external pure { + _requireSupportedChain(chainId); + } + + function deriveLocalActor(uint256 chainId, uint32 index) external returns (address actor) { + (, actor) = _deriveLocalActor(chainId, index); + } + + function readManifest(string calldata path, bool active) external view returns (Manifest memory) { + return _readManifest(path, active); + } + + function requireCode(string calldata label, address target) external view { + _requireCode(label, target); + } + + function serializeManifest(Manifest calldata manifest) external returns (string memory) { + return _serializeManifest(manifest); + } +} + +contract ScriptPreflightTest is Test { + ScriptPreflightHarness internal harness; + string internal fixtureDir; + + address internal constant OWNER = 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266; + address internal constant ALICE = 0x70997970C51812dc3A010C7d01b50e0d17dc79C8; + address internal constant TOKEN = 0x1000000000000000000000000000000000000001; + address internal constant PROXY = 0x2000000000000000000000000000000000000002; + address internal constant IMPLEMENTATION = 0x3000000000000000000000000000000000000003; + + function setUp() public { + harness = new ScriptPreflightHarness(); + fixtureDir = string.concat(vm.projectRoot(), "/deployments/test-script-preflight"); + vm.createDir(fixtureDir, true); + } + + function testSupportedChainsAreAccepted() public view { + harness.requireSupportedChain(31337); + harness.requireSupportedChain(84532); + } + + function testUnsupportedChainsAreRejectedBeforeBroadcast() public { + uint256[3] memory rejected = [uint256(1), uint256(8453), uint256(7777777)]; + for (uint256 i; i < rejected.length; ++i) { + vm.expectRevert(abi.encodeWithSelector(DemoScript.UnsupportedChain.selector, rejected[i])); + harness.requireSupportedChain(rejected[i]); + } + } + + function testLocalActorsDeriveOnlyOnAnvil() public { + assertEq(harness.deriveLocalActor(31337, 0), OWNER); + assertEq(harness.deriveLocalActor(31337, 1), ALICE); + + vm.expectRevert(abi.encodeWithSelector(DemoScript.UnsupportedChain.selector, uint256(84532))); + harness.deriveLocalActor(84532, 0); + } + + function testMissingManifestIsRejected() public { + vm.expectRevert(); + harness.readManifest(string.concat(fixtureDir, "/missing.json"), false); + } + + function testInvalidManifestIsRejected() public { + string memory path = string.concat(fixtureDir, "/invalid.json"); + vm.writeFile(path, "not-json"); + vm.expectRevert(); + harness.readManifest(path, false); + } + + function testWrongManifestChainIsRejected() public { + string memory path = _writeManifest(84532, 1, TOKEN, PROXY, IMPLEMENTATION); + vm.chainId(31337); + vm.expectRevert( + abi.encodeWithSelector(DemoScript.ManifestChainMismatch.selector, uint256(31337), uint256(84532)) + ); + harness.readManifest(path, true); + } + + function testZeroManifestAddressIsRejected() public { + string memory path = _writeManifest(31337, 1, address(0), PROXY, IMPLEMENTATION); + vm.chainId(31337); + vm.expectRevert(abi.encodeWithSelector(DemoScript.MissingCode.selector, "token", address(0))); + harness.readManifest(path, true); + } + + function testAddressWithoutCodeIsRejected() public { + vm.expectRevert(abi.encodeWithSelector(DemoScript.MissingCode.selector, "token", TOKEN)); + harness.requireCode("token", TOKEN); + } + + function testPendingManifestMayUseDeploymentBlockZero() public { + string memory path = _writeManifest(31337, 0, TOKEN, PROXY, IMPLEMENTATION); + vm.chainId(31337); + vm.etch(TOKEN, hex"00"); + vm.etch(PROXY, hex"00"); + vm.etch(IMPLEMENTATION, hex"00"); + DemoScript.Manifest memory manifest = harness.readManifest(path, false); + assertEq(manifest.deploymentBlock, 0); + } + + function testActiveManifestRejectsDeploymentBlockZero() public { + string memory path = _writeManifest(31337, 0, TOKEN, PROXY, IMPLEMENTATION); + vm.chainId(31337); + vm.etch(TOKEN, hex"00"); + vm.etch(PROXY, hex"00"); + vm.etch(IMPLEMENTATION, hex"00"); + vm.expectRevert(DemoScript.InvalidDeploymentBlock.selector); + harness.readManifest(path, true); + } + + function testSerializedManifestContainsPublicAddressesAndNoSecrets() public { + DemoScript.Manifest memory manifest = DemoScript.Manifest({ + schemaVersion: 1, + network: "anvil", + chainId: 31337, + deploymentBlock: 0, + rpcUrl: "http://127.0.0.1:8545", + explorerUrl: "", + token: TOKEN, + proxy: PROXY, + implementation: IMPLEMENTATION, + owner: OWNER, + actorLabels: _labels(), + actors: _actors() + }); + + string memory json = harness.serializeManifest(manifest); + assertTrue(_contains(json, vm.toString(TOKEN))); + assertTrue(_contains(json, vm.toString(PROXY))); + assertTrue(_contains(json, vm.toString(IMPLEMENTATION))); + assertTrue(_contains(json, vm.toString(OWNER))); + assertFalse(_contains(json, "test test test test test test test test test test test junk")); + assertFalse(_contains(json, "PRIVATE_KEY")); + assertFalse(_contains(json, "MNEMONIC")); + assertFalse(_contains(json, "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80")); + } + + function testDeployV1CreatesInitializedProxyAndPublicPendingManifest() public { + string memory path = string.concat(fixtureDir, "/deployed.json"); + vm.chainId(31337); + vm.setEnv("SCRIPT_SENDER", vm.toString(OWNER)); + vm.setEnv("DEPLOYMENT_MANIFEST_PATH", path); + + DeployV1 deployer = new DeployV1(); + (address token, address proxy, address implementation) = deployer.run(); + + DemoScript.Manifest memory manifest = harness.readManifest(path, false); + assertEq(manifest.token, token); + assertEq(manifest.proxy, proxy); + assertEq(manifest.implementation, implementation); + assertEq(BankV1(proxy).owner(), OWNER); + assertEq(address(BankV1(proxy).asset()), token); + assertEq(BankV1(proxy).contractVersion(), 1); + assertEq(manifest.actorLabels[0], "owner"); + assertEq(manifest.actorLabels[1], "Alice"); + assertEq(manifest.actorLabels[2], "Bob"); + assertEq(manifest.actors[0], OWNER); + assertEq(manifest.actors[1], ALICE); + assertEq(manifest.actors[2], 0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC); + } + + function testSeedV1DemoExecutesExactActOneStateAndCheckStateAcceptsIt() public { + (string memory path, DemoScript.Manifest memory manifest) = _deployFixture(); + vm.setEnv("DEPLOYMENT_MANIFEST_PATH", path); + + new SeedV1Demo().run(); + + BankV1 bank = BankV1(manifest.proxy); + assertEq(bank.balanceOf(manifest.actors[1]), 900e6); + assertEq(bank.balanceOf(manifest.actors[2]), 500e6); + assertEq(bank.totalLiabilities(), 1_400e6); + assertEq(MockUSDC(manifest.token).balanceOf(manifest.proxy), 1_400e6); + assertEq(MockUSDC(manifest.token).balanceOf(manifest.actors[1]), 1_100e6); + assertEq(MockUSDC(manifest.token).balanceOf(manifest.actors[2]), 500e6); + + vm.setEnv("DEMO_EXPECTED_STAGE", "v1"); + new CheckState().run(); + } + + function testCheckStateRejectsManifestImplementationMismatch() public { + (string memory path, DemoScript.Manifest memory manifest) = _deployFixture(); + vm.writeJson(string.concat('"', vm.toString(manifest.token), '"'), path, ".implementation"); + vm.setEnv("DEPLOYMENT_MANIFEST_PATH", path); + vm.setEnv("DEMO_EXPECTED_STAGE", "deployed"); + CheckState checker = new CheckState(); + + vm.expectRevert( + abi.encodeWithSelector( + DemoScript.UnexpectedAddress.selector, "implementation", manifest.token, manifest.implementation + ) + ); + checker.run(); + } + + function _writeManifest( + uint256 chainId, + uint256 deploymentBlock, + address token, + address proxy, + address implementation + ) internal returns (string memory path) { + path = string.concat(fixtureDir, "/manifest.json"); + string memory json = string.concat( + '{"schemaVersion":1,"network":"anvil","chainId":', + vm.toString(chainId), + ',"deploymentBlock":', + vm.toString(deploymentBlock), + ',"rpcUrl":"http://127.0.0.1:8545","explorerUrl":"","token":"', + vm.toString(token), + '","proxy":"', + vm.toString(proxy), + '","implementation":"', + vm.toString(implementation), + '","owner":"', + vm.toString(OWNER), + '","actorLabels":["owner","Alice","Bob"],"actors":["', + vm.toString(OWNER), + '","', + vm.toString(ALICE), + '","0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC"]}' + ); + vm.writeFile(path, json); + } + + function _deployFixture() internal returns (string memory path, DemoScript.Manifest memory manifest) { + path = string.concat(fixtureDir, "/deployed.json"); + vm.chainId(31337); + vm.setEnv("SCRIPT_SENDER", vm.toString(OWNER)); + vm.setEnv("DEPLOYMENT_MANIFEST_PATH", path); + new DeployV1().run(); + vm.writeJson("1", path, ".deploymentBlock"); + manifest = harness.readManifest(path, true); + } + + function _labels() internal pure returns (string[] memory labels) { + labels = new string[](3); + labels[0] = "owner"; + labels[1] = "Alice"; + labels[2] = "Bob"; + } + + function _actors() internal pure returns (address[] memory actors) { + actors = new address[](3); + actors[0] = OWNER; + actors[1] = ALICE; + actors[2] = 0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC; + } + + function _contains(string memory haystack, string memory needle) internal pure returns (bool) { + bytes memory h = bytes(haystack); + bytes memory n = bytes(needle); + if (n.length > h.length) return false; + for (uint256 i; i + n.length <= h.length; ++i) { + bool match_ = true; + for (uint256 j; j < n.length; ++j) { + if (h[i + j] != n[j]) { + match_ = false; + break; + } + } + if (match_) return true; + } + return false; + } +} diff --git a/tools/finalize-manifest.mjs b/tools/finalize-manifest.mjs new file mode 100644 index 0000000..90d4c9b --- /dev/null +++ b/tools/finalize-manifest.mjs @@ -0,0 +1,220 @@ +import { randomUUID } from "node:crypto"; +import { readFile, rename, writeFile } from "node:fs/promises"; +import { dirname, join, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; + +export const IMPLEMENTATION_SLOT = "0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc"; + +const NETWORKS = { + anvil: { name: "anvil", chainId: 31337, canonical: "anvil.json", recovery: "make reset-local" }, + "base-sepolia": { name: "base-sepolia", chainId: 84532, canonical: "base-sepolia.json", recovery: "make archive-base-manifest" }, +}; + +export function networkSpec(network) { + const normalized = network === "baseSepolia" ? "base-sepolia" : network; + const spec = NETWORKS[normalized]; + if (!spec) throw new Error(`unsupported deployment network: ${network}`); + return spec; +} + +export async function preflightDeploy({ root = process.cwd(), network }) { + const spec = networkSpec(network); + const target = join(root, "deployments", spec.canonical); + try { + await readFile(target); + } catch (error) { + if (error.code === "ENOENT") return; + throw error; + } + throw new Error(`refusing to overwrite ${target}; recover safely with: ${spec.recovery}`); +} + +export async function finalizeDeployment({ root = process.cwd(), rpc }) { + if (typeof rpc !== "function") throw new Error("finalizer requires an RPC function"); + const pendingPath = join(root, "deployments", "pending.json"); + const pending = await readManifest(pendingPath, { pending: true }); + const spec = networkSpec(pending.network); + if (pending.chainId !== spec.chainId) throw new Error(`pending manifest chain ID does not match ${pending.network}`); + if (pending.deploymentBlock !== 0) throw new Error("pending manifest deploymentBlock must be 0"); + + const broadcastPath = join(root, "broadcast", "DeployV1.s.sol", String(pending.chainId), "run-latest.json"); + const broadcast = await readJson(broadcastPath); + if (!Array.isArray(broadcast.transactions)) throw new Error("broadcast is partial: transactions are missing"); + const proxyTransactions = broadcast.transactions.filter( + (transaction) => typeof transaction?.contractAddress === "string" + && transaction.contractAddress.toLowerCase() === pending.proxy.toLowerCase() + && transaction.transactionType === "CREATE" + && typeof transaction.hash === "string" + ); + if (proxyTransactions.length !== 1) { + throw new Error(`expected exactly one proxy creation transaction, found ${proxyTransactions.length}`); + } + + const proxyTransaction = proxyTransactions[0]; + const receipt = await rpc("eth_getTransactionReceipt", [proxyTransaction.hash]); + if (!receipt) throw new Error(`missing receipt for proxy transaction ${proxyTransaction.hash}`); + if (!isSuccessfulReceipt(receipt.status)) throw new Error(`proxy receipt ${proxyTransaction.hash} was not successful`); + const deploymentBlock = parseRpcQuantity(receipt.blockNumber, "receipt block number"); + if (deploymentBlock === 0) throw new Error("receipt block number must be nonzero"); + + const actualChainId = parseRpcQuantity(await rpc("eth_chainId", []), "RPC chain ID"); + if (actualChainId !== pending.chainId) { + throw new Error(`RPC chain ID ${actualChainId} does not match pending manifest chain ID ${pending.chainId}`); + } + for (const [label, address] of [["token", pending.token], ["proxy", pending.proxy], ["implementation", pending.implementation]]) { + const code = await rpc("eth_getCode", [address, "latest"]); + if (typeof code !== "string" || !/^0x[0-9a-fA-F]+$/.test(code) || code.length <= 2) { + throw new Error(`${label} ${address} has no code`); + } + } + const storage = await rpc("eth_getStorageAt", [pending.proxy, IMPLEMENTATION_SLOT, "latest"]); + if (slotAddress(storage) !== pending.implementation.toLowerCase()) { + throw new Error("proxy implementation slot does not match pending manifest implementation"); + } + + const confirmed = { ...pending, deploymentBlock }; + validateManifest(confirmed); + const path = join(root, "deployments", spec.canonical); + await atomicWriteJson(path, confirmed); + return { path, manifest: confirmed }; +} + +export async function readManifest(path, { pending = false } = {}) { + const manifest = await readJson(path); + validateManifest(manifest, { pending }); + return manifest; +} + +export function validateManifest(manifest, { pending = false } = {}) { + if (!manifest || typeof manifest !== "object" || Array.isArray(manifest)) throw new Error("manifest must be a JSON object"); + rejectSecretBearingContent(manifest); + if (manifest.schemaVersion !== 1) throw new Error("manifest schemaVersion must be 1"); + const spec = networkSpec(manifest.network); + if (manifest.chainId !== spec.chainId) throw new Error(`manifest chain ID does not match ${manifest.network}`); + if (!Number.isSafeInteger(manifest.deploymentBlock) || manifest.deploymentBlock < (pending ? 0 : 1)) { + throw new Error(`manifest deploymentBlock must be ${pending ? "a nonnegative integer" : "at least 1"}`); + } + assertPublicUrl(manifest.rpcUrl, "rpcUrl", false); + assertPublicUrl(manifest.explorerUrl, "explorerUrl", true); + for (const field of ["token", "proxy", "implementation", "owner"]) assertAddress(manifest[field], field); + if (!Array.isArray(manifest.actorLabels) || !Array.isArray(manifest.actors) || manifest.actorLabels.length !== manifest.actors.length || manifest.actors.length === 0) { + throw new Error("manifest actors and actorLabels must be nonempty parallel arrays"); + } + const labels = new Set(); + const actors = new Set(); + for (let index = 0; index < manifest.actors.length; index += 1) { + const label = manifest.actorLabels[index]; + if (typeof label !== "string" || label.trim() === "" || labels.has(label)) throw new Error("manifest actor labels must be unique nonempty strings"); + labels.add(label); + assertAddress(manifest.actors[index], `actors[${index}]`); + const actor = manifest.actors[index].toLowerCase(); + if (actors.has(actor)) throw new Error("manifest actors must be unique"); + actors.add(actor); + } +} + +export async function atomicWriteJson(path, value) { + await atomicWrite(path, `${JSON.stringify(value, null, 2)}\n`); +} + +export async function atomicWrite(path, contents, io = { writeFile, rename }) { + const temporary = join(dirname(path), `.${randomUUID()}.tmp`); + await io.writeFile(temporary, contents, { mode: 0o600 }); + await io.rename(temporary, path); +} + +function assertAddress(value, label) { + if (typeof value !== "string" || !/^0x[0-9a-fA-F]{40}$/.test(value) || /^0x0{40}$/i.test(value)) { + throw new Error(`manifest ${label} must be a nonzero address`); + } +} + +function assertPublicUrl(value, label, allowEmpty) { + if (allowEmpty && value === "") return; + if (typeof value !== "string") throw new Error(`manifest ${label} must be a URL`); + let parsed; + try { + parsed = new URL(value); + } catch { + throw new Error(`manifest ${label} must be a URL`); + } + if (!/^https?:$/.test(parsed.protocol)) throw new Error(`manifest ${label} must use http or https`); + if (parsed.username || parsed.password) throw new Error(`manifest ${label} contains credentials`); + for (const key of parsed.searchParams.keys()) { + if (/(?:key|token|secret|password|credential|private)/i.test(key)) { + throw new Error(`manifest ${label} contains a secret-bearing query parameter`); + } + } +} + +function rejectSecretBearingContent(value, path = "") { + if (Array.isArray(value)) { + value.forEach((item, index) => rejectSecretBearingContent(item, `${path}[${index}]`)); + return; + } + if (!value || typeof value !== "object") return; + for (const [key, nested] of Object.entries(value)) { + const nestedPath = path ? `${path}.${key}` : key; + if (/(?:private[_ -]?key|mnemonic|secret|password|credential|api[_ -]?key)/i.test(key)) { + throw new Error(`manifest contains secret-bearing field ${nestedPath}`); + } + rejectSecretBearingContent(nested, nestedPath); + } +} + +function isSuccessfulReceipt(status) { + return status === "0x1" || status === 1 || status === "1"; +} + +function parseRpcQuantity(value, label) { + if (typeof value !== "string" || !/^0x[0-9a-fA-F]+$/.test(value)) throw new Error(`${label} is not a hexadecimal RPC quantity`); + const parsed = Number.parseInt(value, 16); + if (!Number.isSafeInteger(parsed)) throw new Error(`${label} exceeds JavaScript safe integer range`); + return parsed; +} + +function slotAddress(value) { + if (typeof value !== "string" || !/^0x[0-9a-fA-F]{64}$/.test(value)) throw new Error("proxy implementation slot response is invalid"); + return `0x${value.slice(-40)}`.toLowerCase(); +} + +async function readJson(path) { + try { + return JSON.parse(await readFile(path, "utf8")); + } catch (error) { + if (error instanceof SyntaxError) throw new Error(`invalid JSON at ${path}`); + throw error; + } +} + +function fetchRpc(rpcUrl) { + let nextId = 1; + return async (method, params) => { + const response = await fetch(rpcUrl, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ jsonrpc: "2.0", id: nextId++, method, params }), + }); + if (!response.ok) throw new Error(`RPC ${method} returned HTTP ${response.status}`); + const body = await response.json(); + if (body.error) throw new Error(`RPC ${method} failed: ${body.error.message ?? "unknown error"}`); + return body.result; + }; +} + +async function main(argv) { + const [command, network, ...rest] = argv; + if (command === "preflight-deploy" && network && rest.length === 0) return preflightDeploy({ network }); + if (command === "deploy") { + if (network !== "--rpc-url" || typeof rest[0] !== "string" || rest.length !== 1) throw new Error("usage: finalize-manifest.mjs deploy --rpc-url "); + return finalizeDeployment({ rpc: fetchRpc(rest[0]) }); + } + throw new Error("usage: finalize-manifest.mjs preflight-deploy | deploy --rpc-url "); +} + +if (process.argv[1] && pathToFileURL(resolve(process.argv[1])).href === import.meta.url) { + main(process.argv.slice(2)).catch((error) => { + console.error(error.message); + process.exitCode = 1; + }); +} diff --git a/tools/select-manifest.mjs b/tools/select-manifest.mjs new file mode 100644 index 0000000..36dd327 --- /dev/null +++ b/tools/select-manifest.mjs @@ -0,0 +1,27 @@ +import { readFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; + +import { atomicWrite, networkSpec, readManifest } from "./finalize-manifest.mjs"; + +export async function selectManifest({ root = process.cwd(), network }) { + const spec = networkSpec(network); + const source = join(root, "deployments", spec.canonical); + await readManifest(source); + const contents = await readFile(source); + const active = join(root, "deployments", "active.json"); + await atomicWrite(active, contents); + return { source, active }; +} + +async function main(argv) { + if (argv.length !== 1) throw new Error("usage: select-manifest.mjs "); + return selectManifest({ network: argv[0] }); +} + +if (process.argv[1] && pathToFileURL(resolve(process.argv[1])).href === import.meta.url) { + main(process.argv.slice(2)).catch((error) => { + console.error(error.message); + process.exitCode = 1; + }); +} diff --git a/tools/test-finalize-manifest.mjs b/tools/test-finalize-manifest.mjs new file mode 100644 index 0000000..e795c3c --- /dev/null +++ b/tools/test-finalize-manifest.mjs @@ -0,0 +1,169 @@ +import assert from "node:assert/strict"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import test from "node:test"; + +import { atomicWrite, finalizeDeployment, preflightDeploy } from "./finalize-manifest.mjs"; +import { selectManifest } from "./select-manifest.mjs"; + +const TOKEN = "0x1000000000000000000000000000000000000001"; +const PROXY = "0x2000000000000000000000000000000000000002"; +const IMPLEMENTATION = "0x3000000000000000000000000000000000000003"; +const OWNER = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"; +const IMPLEMENTATION_SLOT = "0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc"; + +test("preflight rejects an existing target canonical manifest with the safe recovery command", async () => { + await withFixture(async (root) => { + await writeJson(join(root, "deployments", "anvil.json"), manifest()); + await assert.rejects( + () => preflightDeploy({ root, network: "anvil" }), + /make reset-local/ + ); + await writeJson(join(root, "deployments", "base-sepolia.json"), manifest({ network: "base-sepolia", chainId: 84532 })); + await assert.rejects( + () => preflightDeploy({ root, network: "base-sepolia" }), + /make archive-base-manifest/ + ); + }); +}); + +test("finalizer writes only a receipt-confirmed manifest and preserves active until selection", async () => { + await withFixture(async (root) => { + const pending = manifest({ deploymentBlock: 0 }); + await writeJson(join(root, "deployments", "pending.json"), pending); + await writeJson(join(root, "deployments", "active.json"), manifest({ network: "base-sepolia", chainId: 84532, deploymentBlock: 88 })); + await writeBroadcast(root, pending, { hash: "0xaaa", contractAddress: PROXY }); + + const output = await finalizeDeployment({ root, rpc: fakeRpc() }); + assert.equal(output.path, join(root, "deployments", "anvil.json")); + assert.deepEqual(await readJson(output.path), { ...pending, deploymentBlock: 42 }); + assert.deepEqual(await readJson(join(root, "deployments", "active.json")), manifest({ network: "base-sepolia", chainId: 84532, deploymentBlock: 88 })); + }); +}); + +test("finalizer rejects invalid receipt, transaction, RPC, code, slot, and secret data without touching confirmed files", async () => { + const cases = [ + ["failed receipt", { rpc: fakeRpc({ receipt: { status: "0x0", blockNumber: "0x2a" } }) }, /not successful/], + ["missing receipt", { rpc: fakeRpc({ receipt: null }) }, /missing receipt/], + ["ambiguous proxy transaction", { broadcast: { extraProxy: true } }, /exactly one/], + ["partial broadcast", { broadcast: { omitProxy: true } }, /exactly one/], + ["non-creation proxy transaction", { broadcast: { transactionType: "CALL" } }, /exactly one/], + ["wrong chain", { rpc: fakeRpc({ chainId: "0x14a34" }) }, /chain ID/], + ["missing code", { rpc: fakeRpc({ missingCode: TOKEN }) }, /has no code/], + ["implementation slot mismatch", { rpc: fakeRpc({ slot: TOKEN }) }, /implementation slot/], + ["secret-bearing pending manifest", { pending: manifest({ rpcUrl: "https://user:password@example.invalid" }) }, /credential|secret/i], + ]; + + for (const [name, options, expected] of cases) { + await withFixture(async (root) => { + const pending = options.pending ?? manifest({ deploymentBlock: 0 }); + await writeJson(join(root, "deployments", "pending.json"), pending); + await writeJson(join(root, "deployments", "anvil.json"), manifest({ deploymentBlock: 7 })); + await writeJson(join(root, "deployments", "active.json"), manifest({ deploymentBlock: 8 })); + await writeBroadcast(root, pending, options.broadcast); + const beforeCanonical = await readFile(join(root, "deployments", "anvil.json")); + const beforeActive = await readFile(join(root, "deployments", "active.json")); + + await assert.rejects(() => finalizeDeployment({ root, rpc: options.rpc ?? fakeRpc() }), expected, name); + assert.deepEqual(await readFile(join(root, "deployments", "anvil.json")), beforeCanonical, name); + assert.deepEqual(await readFile(join(root, "deployments", "active.json")), beforeActive, name); + }); + } +}); + +test("selection atomically replaces active with only a valid named canonical manifest", async () => { + await withFixture(async (root) => { + const anvil = manifest({ deploymentBlock: 31 }); + const base = manifest({ network: "base-sepolia", chainId: 84532, deploymentBlock: 32 }); + await writeJson(join(root, "deployments", "anvil.json"), anvil); + await writeJson(join(root, "deployments", "base-sepolia.json"), base); + + await selectManifest({ root, network: "anvil" }); + const anvilBytes = await readFile(join(root, "deployments", "anvil.json")); + assert.deepEqual(await readFile(join(root, "deployments", "active.json")), anvilBytes); + await selectManifest({ root, network: "base-sepolia" }); + assert.deepEqual(await readFile(join(root, "deployments", "anvil.json")), anvilBytes); + assert.deepEqual(await readJson(join(root, "deployments", "active.json")), base); + + await writeJson(join(root, "deployments", "base-sepolia.json"), manifest({ network: "base-sepolia", chainId: 84532, deploymentBlock: 0 })); + const beforeActive = await readFile(join(root, "deployments", "active.json")); + await assert.rejects(() => selectManifest({ root, network: "base-sepolia" }), /deploymentBlock/); + assert.deepEqual(await readFile(join(root, "deployments", "active.json")), beforeActive); + }); +}); + +test("atomic writes stage a same-directory temporary file before renaming it into place", async () => { + const target = "/tmp/deployments/active.json"; + const calls = []; + const io = { + writeFile: async (path, contents) => calls.push(["write", path, contents]), + rename: async (source, destination) => calls.push(["rename", source, destination]), + }; + + await atomicWrite(target, "confirmed", io); + + assert.equal(calls[0][0], "write"); + assert.equal(dirname(calls[0][1]), dirname(target)); + assert.notEqual(calls[0][1], target); + assert.deepEqual(calls[1], ["rename", calls[0][1], target]); +}); + +function manifest(overrides = {}) { + return { + schemaVersion: 1, + network: "anvil", + chainId: 31337, + deploymentBlock: 1, + rpcUrl: "http://127.0.0.1:8545", + explorerUrl: "", + token: TOKEN, + proxy: PROXY, + implementation: IMPLEMENTATION, + owner: OWNER, + actorLabels: ["owner", "Alice", "Bob"], + actors: [OWNER, "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC"], + ...overrides, + }; +} + +async function withFixture(fn) { + const root = await mkdtemp(join(tmpdir(), "uups-finalizer-")); + try { + await import("node:fs/promises").then(({ mkdir }) => mkdir(join(root, "deployments"), { recursive: true })); + await fn(root); + } finally { + await rm(root, { recursive: true, force: true }); + } +} + +async function writeBroadcast(root, pending, options = {}) { + const directory = join(root, "broadcast", "DeployV1.s.sol", String(pending.chainId)); + await import("node:fs/promises").then(({ mkdir }) => mkdir(directory, { recursive: true })); + const transactions = options.omitProxy + ? [{ hash: "0xbbb", transactionType: "CREATE", contractAddress: pending.token }] + : [{ hash: "0xaaa", transactionType: options.transactionType ?? "CREATE", contractAddress: pending.proxy }]; + if (options.extraProxy) transactions.push({ hash: "0xccc", transactionType: "CREATE", contractAddress: pending.proxy }); + await writeJson(join(directory, "run-latest.json"), { transactions }); +} + +function fakeRpc(overrides = {}) { + return async (method, params) => { + if (method === "eth_chainId") return overrides.chainId ?? "0x7a69"; + if (method === "eth_getTransactionReceipt") return Object.hasOwn(overrides, "receipt") ? overrides.receipt : { status: "0x1", blockNumber: "0x2a" }; + if (method === "eth_getCode") return params[0].toLowerCase() === overrides.missingCode?.toLowerCase() ? "0x" : "0x6000"; + if (method === "eth_getStorageAt") { + assert.equal(params[1], IMPLEMENTATION_SLOT); + return `0x000000000000000000000000${(overrides.slot ?? IMPLEMENTATION).slice(2)}`; + } + throw new Error(`unexpected RPC method: ${method}`); + }; +} + +async function writeJson(path, value) { + await writeFile(path, `${JSON.stringify(value, null, 2)}\n`); +} + +async function readJson(path) { + return JSON.parse(await readFile(path, "utf8")); +} From be8f01ea4eae22dff8671d267bbe3e8b0c31f779 Mon Sep 17 00:00:00 2001 From: golem Date: Fri, 21 Aug 2026 02:36:17 -0600 Subject: [PATCH 09/30] fix: harden V1 deployment manifests --- script/CheckState.s.sol | 1 + script/DeployV1.s.sol | 3 +- script/SeedV1Demo.s.sol | 2 +- script/lib/DemoScript.sol | 48 +++++++++++++++- test/ScriptPreflight.t.sol | 76 +++++++++++++++++++++++- tools/finalize-manifest.mjs | 99 +++++++++++++++++++++----------- tools/select-manifest.mjs | 9 +-- tools/test-finalize-manifest.mjs | 64 ++++++++++++++++++--- 8 files changed, 252 insertions(+), 50 deletions(-) diff --git a/script/CheckState.s.sol b/script/CheckState.s.sol index b2aef5a..19804e9 100644 --- a/script/CheckState.s.sol +++ b/script/CheckState.s.sol @@ -13,6 +13,7 @@ contract CheckState is DemoScript { function run() external view { _requireSupportedChain(block.chainid); + _printEducationalWarning(); string memory stage = vm.envOr("DEMO_EXPECTED_STAGE", string("v1")); bool deployedStage = keccak256(bytes(stage)) == keccak256("deployed"); Manifest memory manifest = _readManifest(_manifestPath(ACTIVE_MANIFEST_PATH), !deployedStage); diff --git a/script/DeployV1.s.sol b/script/DeployV1.s.sol index 871ffda..54a0b59 100644 --- a/script/DeployV1.s.sol +++ b/script/DeployV1.s.sol @@ -9,6 +9,7 @@ import {DemoScript} from "./lib/DemoScript.sol"; contract DeployV1 is DemoScript { function run() external returns (address tokenAddress, address proxy, address implementation) { _requireSupportedChain(block.chainid); + _printEducationalWarning(); address sender = vm.envAddress("SCRIPT_SENDER"); if (block.chainid == ANVIL_CHAIN_ID) { @@ -50,7 +51,7 @@ contract DeployV1 is DemoScript { _writeManifest(_manifestPath(PENDING_MANIFEST_PATH), manifest); } - function _actors(address sender) private returns (string[] memory labels, address[] memory actors) { + function _actors(address sender) private view returns (string[] memory labels, address[] memory actors) { if (block.chainid == ANVIL_CHAIN_ID) { labels = new string[](3); actors = new address[](3); diff --git a/script/SeedV1Demo.s.sol b/script/SeedV1Demo.s.sol index d57aef7..6bc27f5 100644 --- a/script/SeedV1Demo.s.sol +++ b/script/SeedV1Demo.s.sol @@ -8,8 +8,8 @@ import {DemoScript} from "./lib/DemoScript.sol"; contract SeedV1Demo is DemoScript { function run() external { if (block.chainid != ANVIL_CHAIN_ID) revert UnsupportedChain(block.chainid); + _printEducationalWarning(); Manifest memory manifest = _readManifest(_manifestPath(ACTIVE_MANIFEST_PATH), true); - if (manifest.actors.length != 3 || manifest.actorLabels.length != 3) revert InvalidManifestSchema(1); (uint256 ownerKey, address owner) = _deriveLocalActor(block.chainid, 0); (uint256 aliceKey, address alice) = _deriveLocalActor(block.chainid, 1); diff --git a/script/lib/DemoScript.sol b/script/lib/DemoScript.sol index 28f3d7f..ffe1d63 100644 --- a/script/lib/DemoScript.sol +++ b/script/lib/DemoScript.sol @@ -2,6 +2,7 @@ pragma solidity 0.8.35; import {Script} from "forge-std/Script.sol"; +import {console2} from "forge-std/console2.sol"; import {BankV1} from "../../src/BankV1.sol"; import {MockUSDC} from "../../src/MockUSDC.sol"; @@ -11,12 +12,14 @@ abstract contract DemoScript is Script { string internal constant ANVIL_TEST_PHRASE = "test test test test test test test test test test test junk"; string internal constant PENDING_MANIFEST_PATH = "deployments/pending.json"; string internal constant ACTIVE_MANIFEST_PATH = "deployments/active.json"; + string internal constant EDUCATIONAL_WARNING = unicode"Educational demo — mock token — never use real funds."; error UnsupportedChain(uint256 chainId); error ManifestChainMismatch(uint256 expected, uint256 actual); error MissingCode(string label, address target); error InvalidDeploymentBlock(); error InvalidManifestSchema(uint256 schemaVersion); + error InvalidActorConfiguration(); error UnexpectedState(string label, uint256 expected, uint256 actual); error UnexpectedAddress(string label, address expected, address actual); @@ -39,7 +42,11 @@ abstract contract DemoScript is Script { if (chainId != ANVIL_CHAIN_ID && chainId != BASE_SEPOLIA_CHAIN_ID) revert UnsupportedChain(chainId); } - function _deriveLocalActor(uint256 chainId, uint32 index) internal returns (uint256 privateKey, address actor) { + function _deriveLocalActor(uint256 chainId, uint32 index) + internal + pure + returns (uint256 privateKey, address actor) + { if (chainId != ANVIL_CHAIN_ID) revert UnsupportedChain(chainId); privateKey = vm.deriveKey(ANVIL_TEST_PHRASE, index); actor = vm.addr(privateKey); @@ -67,6 +74,7 @@ abstract contract DemoScript is Script { if (manifest.schemaVersion != 1) revert InvalidManifestSchema(manifest.schemaVersion); if (manifest.chainId != block.chainid) revert ManifestChainMismatch(block.chainid, manifest.chainId); if (active && manifest.deploymentBlock == 0) revert InvalidDeploymentBlock(); + _validateActorConfiguration(manifest); _requireCode("token", manifest.token); _requireCode("proxy", manifest.proxy); _requireCode("implementation", manifest.implementation); @@ -76,6 +84,44 @@ abstract contract DemoScript is Script { if (target == address(0) || target.code.length == 0) revert MissingCode(label, target); } + function _validateActorConfiguration(Manifest memory manifest) internal pure { + if (manifest.actorLabels.length != manifest.actors.length) revert InvalidActorConfiguration(); + if (manifest.chainId == ANVIL_CHAIN_ID) { + if ( + manifest.actorLabels.length != 3 || !_equals(manifest.network, "anvil") + || !_equals(manifest.actorLabels[0], "owner") || !_equals(manifest.actorLabels[1], "Alice") + || !_equals(manifest.actorLabels[2], "Bob") + ) revert InvalidActorConfiguration(); + + (, address owner) = _deriveLocalActor(ANVIL_CHAIN_ID, 0); + (, address alice) = _deriveLocalActor(ANVIL_CHAIN_ID, 1); + (, address bob) = _deriveLocalActor(ANVIL_CHAIN_ID, 2); + if ( + manifest.actors[0] != manifest.owner || manifest.actors[0] != owner || manifest.actors[1] != alice + || manifest.actors[2] != bob + ) revert InvalidActorConfiguration(); + return; + } + + if ( + manifest.chainId != BASE_SEPOLIA_CHAIN_ID || manifest.actorLabels.length != 1 + || !_equals(manifest.network, "base-sepolia") || !_equals(manifest.actorLabels[0], "owner") + || manifest.actors[0] != manifest.owner + ) revert InvalidActorConfiguration(); + } + + function _educationalWarning() internal pure returns (string memory) { + return EDUCATIONAL_WARNING; + } + + function _printEducationalWarning() internal pure { + console2.log(EDUCATIONAL_WARNING); + } + + function _equals(string memory left, string memory right) private pure returns (bool) { + return keccak256(bytes(left)) == keccak256(bytes(right)); + } + function _serializeManifest(Manifest memory manifest) internal returns (string memory json) { string memory objectKey = "demo-manifest"; vm.serializeUint(objectKey, "schemaVersion", manifest.schemaVersion); diff --git a/test/ScriptPreflight.t.sol b/test/ScriptPreflight.t.sol index 2e1f25e..30e4a23 100644 --- a/test/ScriptPreflight.t.sol +++ b/test/ScriptPreflight.t.sol @@ -14,7 +14,7 @@ contract ScriptPreflightHarness is DemoScript { _requireSupportedChain(chainId); } - function deriveLocalActor(uint256 chainId, uint32 index) external returns (address actor) { + function deriveLocalActor(uint256 chainId, uint32 index) external pure returns (address actor) { (, actor) = _deriveLocalActor(chainId, index); } @@ -29,6 +29,10 @@ contract ScriptPreflightHarness is DemoScript { function serializeManifest(Manifest calldata manifest) external returns (string memory) { return _serializeManifest(manifest); } + + function educationalWarning() external pure returns (string memory) { + return _educationalWarning(); + } } contract ScriptPreflightTest is Test { @@ -121,6 +125,50 @@ contract ScriptPreflightTest is Test { harness.readManifest(path, true); } + function testManifestRejectsNonParallelActorArrays() public { + string memory path = _writeManifest(31337, 1, TOKEN, PROXY, IMPLEMENTATION); + _etchManifestContracts(); + vm.writeJson('["owner","Alice"]', path, ".actorLabels"); + vm.expectRevert(DemoScript.InvalidActorConfiguration.selector); + harness.readManifest(path, true); + } + + function testAnvilManifestRejectsUnexpectedActorLabels() public { + string memory path = _writeManifest(31337, 1, TOKEN, PROXY, IMPLEMENTATION); + _etchManifestContracts(); + vm.writeJson('["owner","Mallory","Bob"]', path, ".actorLabels"); + vm.expectRevert(DemoScript.InvalidActorConfiguration.selector); + harness.readManifest(path, true); + } + + function testAnvilManifestRequiresOwnerAtActorZero() public { + string memory path = _writeManifest(31337, 1, TOKEN, PROXY, IMPLEMENTATION); + _etchManifestContracts(); + vm.writeJson( + string.concat( + '["', vm.toString(ALICE), '","', vm.toString(OWNER), '","0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC"]' + ), + path, + ".actors" + ); + vm.expectRevert(DemoScript.InvalidActorConfiguration.selector); + harness.readManifest(path, true); + } + + function testBaseManifestRequiresOnlyOwnerActor() public { + string memory path = _writeBaseManifest(); + vm.chainId(84532); + _etchManifestContracts(); + vm.writeJson('["owner","Alice"]', path, ".actorLabels"); + vm.writeJson(string.concat('["', vm.toString(OWNER), '","', vm.toString(ALICE), '"]'), path, ".actors"); + vm.expectRevert(DemoScript.InvalidActorConfiguration.selector); + harness.readManifest(path, true); + } + + function testEducationalWarningIsExact() public view { + assertEq(harness.educationalWarning(), unicode"Educational demo — mock token — never use real funds."); + } + function testSerializedManifestContainsPublicAddressesAndNoSecrets() public { DemoScript.Manifest memory manifest = DemoScript.Manifest({ schemaVersion: 1, @@ -245,6 +293,32 @@ contract ScriptPreflightTest is Test { manifest = harness.readManifest(path, true); } + function _writeBaseManifest() internal returns (string memory path) { + path = string.concat(fixtureDir, "/base-manifest.json"); + vm.writeFile( + path, + string.concat( + '{"schemaVersion":1,"network":"base-sepolia","chainId":84532,"deploymentBlock":1,"rpcUrl":"https://sepolia.base.org","explorerUrl":"https://sepolia.basescan.org","token":"', + vm.toString(TOKEN), + '","proxy":"', + vm.toString(PROXY), + '","implementation":"', + vm.toString(IMPLEMENTATION), + '","owner":"', + vm.toString(OWNER), + '","actorLabels":["owner"],"actors":["', + vm.toString(OWNER), + '"]}' + ) + ); + } + + function _etchManifestContracts() internal { + vm.etch(TOKEN, hex"00"); + vm.etch(PROXY, hex"00"); + vm.etch(IMPLEMENTATION, hex"00"); + } + function _labels() internal pure returns (string[] memory labels) { labels = new string[](3); labels[0] = "owner"; diff --git a/tools/finalize-manifest.mjs b/tools/finalize-manifest.mjs index 90d4c9b..01309d2 100644 --- a/tools/finalize-manifest.mjs +++ b/tools/finalize-manifest.mjs @@ -1,15 +1,26 @@ import { randomUUID } from "node:crypto"; -import { readFile, rename, writeFile } from "node:fs/promises"; +import { readFile, rename, rm, writeFile } from "node:fs/promises"; import { dirname, join, resolve } from "node:path"; import { pathToFileURL } from "node:url"; export const IMPLEMENTATION_SLOT = "0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc"; +export const EDUCATIONAL_WARNING = "Educational demo — mock token — never use real funds."; const NETWORKS = { - anvil: { name: "anvil", chainId: 31337, canonical: "anvil.json", recovery: "make reset-local" }, - "base-sepolia": { name: "base-sepolia", chainId: 84532, canonical: "base-sepolia.json", recovery: "make archive-base-manifest" }, + anvil: { + name: "anvil", chainId: 31337, canonical: "anvil.json", recovery: "make reset-local", rpcUrl: "http://127.0.0.1:8545", explorerUrl: "", + }, + "base-sepolia": { + name: "base-sepolia", chainId: 84532, canonical: "base-sepolia.json", recovery: "make archive-base-manifest", rpcUrl: "https://sepolia.base.org", explorerUrl: "https://sepolia.basescan.org", + }, }; +const MANIFEST_FIELDS = [ + "schemaVersion", "network", "chainId", "deploymentBlock", "rpcUrl", "explorerUrl", "token", "proxy", "implementation", "owner", "actorLabels", "actors", +]; +const ANVIL_TEST_PHRASE = "test test test test test test test test test test test junk"; +const PROHIBITED_STRING_VALUE = /(?:private[_ -]?key|mnemonic|secret|password|credential|api[_ -]?key)|0x[a-fA-F0-9]{64}/i; + export function networkSpec(network) { const normalized = network === "baseSepolia" ? "base-sepolia" : network; const spec = NETWORKS[normalized]; @@ -72,7 +83,7 @@ export async function finalizeDeployment({ root = process.cwd(), rpc }) { throw new Error("proxy implementation slot does not match pending manifest implementation"); } - const confirmed = { ...pending, deploymentBlock }; + const confirmed = publicManifest(pending, deploymentBlock); validateManifest(confirmed); const path = join(root, "deployments", spec.canonical); await atomicWriteJson(path, confirmed); @@ -87,15 +98,17 @@ export async function readManifest(path, { pending = false } = {}) { export function validateManifest(manifest, { pending = false } = {}) { if (!manifest || typeof manifest !== "object" || Array.isArray(manifest)) throw new Error("manifest must be a JSON object"); - rejectSecretBearingContent(manifest); + assertExactSchema(manifest); + rejectProhibitedStringValues(manifest); if (manifest.schemaVersion !== 1) throw new Error("manifest schemaVersion must be 1"); const spec = networkSpec(manifest.network); if (manifest.chainId !== spec.chainId) throw new Error(`manifest chain ID does not match ${manifest.network}`); if (!Number.isSafeInteger(manifest.deploymentBlock) || manifest.deploymentBlock < (pending ? 0 : 1)) { throw new Error(`manifest deploymentBlock must be ${pending ? "a nonnegative integer" : "at least 1"}`); } - assertPublicUrl(manifest.rpcUrl, "rpcUrl", false); - assertPublicUrl(manifest.explorerUrl, "explorerUrl", true); + if (manifest.rpcUrl !== spec.rpcUrl || manifest.explorerUrl !== spec.explorerUrl) { + throw new Error(`manifest display URLs must use the public ${manifest.network} endpoints`); + } for (const field of ["token", "proxy", "implementation", "owner"]) assertAddress(manifest[field], field); if (!Array.isArray(manifest.actorLabels) || !Array.isArray(manifest.actors) || manifest.actorLabels.length !== manifest.actors.length || manifest.actors.length === 0) { throw new Error("manifest actors and actorLabels must be nonempty parallel arrays"); @@ -118,9 +131,15 @@ export async function atomicWriteJson(path, value) { } export async function atomicWrite(path, contents, io = { writeFile, rename }) { - const temporary = join(dirname(path), `.${randomUUID()}.tmp`); - await io.writeFile(temporary, contents, { mode: 0o600 }); - await io.rename(temporary, path); + const temporary = join(dirname(path), `.${randomUUID()}.json`); + const operations = { writeFile, rename, rm, ...io }; + try { + await operations.writeFile(temporary, contents, { mode: 0o600 }); + await operations.rename(temporary, path); + } catch (error) { + await operations.rm(temporary, { force: true }).catch(() => {}); + throw error; + } } function assertAddress(value, label) { @@ -129,39 +148,50 @@ function assertAddress(value, label) { } } -function assertPublicUrl(value, label, allowEmpty) { - if (allowEmpty && value === "") return; - if (typeof value !== "string") throw new Error(`manifest ${label} must be a URL`); - let parsed; - try { - parsed = new URL(value); - } catch { - throw new Error(`manifest ${label} must be a URL`); +function assertExactSchema(manifest) { + for (const field of MANIFEST_FIELDS) { + if (!Object.hasOwn(manifest, field)) throw new Error(`manifest is missing required field ${field}`); } - if (!/^https?:$/.test(parsed.protocol)) throw new Error(`manifest ${label} must use http or https`); - if (parsed.username || parsed.password) throw new Error(`manifest ${label} contains credentials`); - for (const key of parsed.searchParams.keys()) { - if (/(?:key|token|secret|password|credential|private)/i.test(key)) { - throw new Error(`manifest ${label} contains a secret-bearing query parameter`); - } + for (const field of Object.keys(manifest)) { + if (!MANIFEST_FIELDS.includes(field)) throw new Error(`manifest contains unknown field ${field}`); } } -function rejectSecretBearingContent(value, path = "") { +function rejectProhibitedStringValues(value, path = "") { + if (typeof value === "string") { + if (value.includes(ANVIL_TEST_PHRASE) || PROHIBITED_STRING_VALUE.test(value)) { + throw new Error(`manifest contains prohibited secret material at ${path}`); + } + return; + } if (Array.isArray(value)) { - value.forEach((item, index) => rejectSecretBearingContent(item, `${path}[${index}]`)); + value.forEach((item, index) => rejectProhibitedStringValues(item, `${path}[${index}]`)); return; } if (!value || typeof value !== "object") return; for (const [key, nested] of Object.entries(value)) { const nestedPath = path ? `${path}.${key}` : key; - if (/(?:private[_ -]?key|mnemonic|secret|password|credential|api[_ -]?key)/i.test(key)) { - throw new Error(`manifest contains secret-bearing field ${nestedPath}`); - } - rejectSecretBearingContent(nested, nestedPath); + rejectProhibitedStringValues(nested, nestedPath); } } +function publicManifest(manifest, deploymentBlock) { + return { + schemaVersion: manifest.schemaVersion, + network: manifest.network, + chainId: manifest.chainId, + deploymentBlock, + rpcUrl: manifest.rpcUrl, + explorerUrl: manifest.explorerUrl, + token: manifest.token, + proxy: manifest.proxy, + implementation: manifest.implementation, + owner: manifest.owner, + actorLabels: [...manifest.actorLabels], + actors: [...manifest.actors], + }; +} + function isSuccessfulReceipt(status) { return status === "0x1" || status === 1 || status === "1"; } @@ -202,18 +232,19 @@ function fetchRpc(rpcUrl) { }; } -async function main(argv) { +export async function runFinalizeCli(argv, { root = process.cwd(), log = console.log } = {}) { + log(EDUCATIONAL_WARNING); const [command, network, ...rest] = argv; - if (command === "preflight-deploy" && network && rest.length === 0) return preflightDeploy({ network }); + if (command === "preflight-deploy" && network && rest.length === 0) return preflightDeploy({ root, network }); if (command === "deploy") { if (network !== "--rpc-url" || typeof rest[0] !== "string" || rest.length !== 1) throw new Error("usage: finalize-manifest.mjs deploy --rpc-url "); - return finalizeDeployment({ rpc: fetchRpc(rest[0]) }); + return finalizeDeployment({ root, rpc: fetchRpc(rest[0]) }); } throw new Error("usage: finalize-manifest.mjs preflight-deploy | deploy --rpc-url "); } if (process.argv[1] && pathToFileURL(resolve(process.argv[1])).href === import.meta.url) { - main(process.argv.slice(2)).catch((error) => { + runFinalizeCli(process.argv.slice(2)).catch((error) => { console.error(error.message); process.exitCode = 1; }); diff --git a/tools/select-manifest.mjs b/tools/select-manifest.mjs index 36dd327..a17974b 100644 --- a/tools/select-manifest.mjs +++ b/tools/select-manifest.mjs @@ -2,7 +2,7 @@ import { readFile } from "node:fs/promises"; import { join, resolve } from "node:path"; import { pathToFileURL } from "node:url"; -import { atomicWrite, networkSpec, readManifest } from "./finalize-manifest.mjs"; +import { atomicWrite, EDUCATIONAL_WARNING, networkSpec, readManifest } from "./finalize-manifest.mjs"; export async function selectManifest({ root = process.cwd(), network }) { const spec = networkSpec(network); @@ -14,13 +14,14 @@ export async function selectManifest({ root = process.cwd(), network }) { return { source, active }; } -async function main(argv) { +export async function runSelectCli(argv, { root = process.cwd(), log = console.log } = {}) { + log(EDUCATIONAL_WARNING); if (argv.length !== 1) throw new Error("usage: select-manifest.mjs "); - return selectManifest({ network: argv[0] }); + return selectManifest({ root, network: argv[0] }); } if (process.argv[1] && pathToFileURL(resolve(process.argv[1])).href === import.meta.url) { - main(process.argv.slice(2)).catch((error) => { + runSelectCli(process.argv.slice(2)).catch((error) => { console.error(error.message); process.exitCode = 1; }); diff --git a/tools/test-finalize-manifest.mjs b/tools/test-finalize-manifest.mjs index e795c3c..f571757 100644 --- a/tools/test-finalize-manifest.mjs +++ b/tools/test-finalize-manifest.mjs @@ -1,17 +1,18 @@ import assert from "node:assert/strict"; -import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { access, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import test from "node:test"; -import { atomicWrite, finalizeDeployment, preflightDeploy } from "./finalize-manifest.mjs"; -import { selectManifest } from "./select-manifest.mjs"; +import { atomicWrite, finalizeDeployment, preflightDeploy, runFinalizeCli } from "./finalize-manifest.mjs"; +import { runSelectCli, selectManifest } from "./select-manifest.mjs"; const TOKEN = "0x1000000000000000000000000000000000000001"; const PROXY = "0x2000000000000000000000000000000000000002"; const IMPLEMENTATION = "0x3000000000000000000000000000000000000003"; const OWNER = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"; const IMPLEMENTATION_SLOT = "0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc"; +const EDUCATIONAL_WARNING = "Educational demo — mock token — never use real funds."; test("preflight rejects an existing target canonical manifest with the safe recovery command", async () => { await withFixture(async (root) => { @@ -28,6 +29,19 @@ test("preflight rejects an existing target canonical manifest with the safe reco }); }); +test("direct manifest CLIs print the exact educational warning", async () => { + await withFixture(async (root) => { + const finalizeLogs = []; + await runFinalizeCli(["preflight-deploy", "anvil"], { root, log: (line) => finalizeLogs.push(line) }); + assert.deepEqual(finalizeLogs, [EDUCATIONAL_WARNING]); + + await writeJson(join(root, "deployments", "anvil.json"), manifest()); + const selectLogs = []; + await runSelectCli(["anvil"], { root, log: (line) => selectLogs.push(line) }); + assert.deepEqual(selectLogs, [EDUCATIONAL_WARNING]); + }); +}); + test("finalizer writes only a receipt-confirmed manifest and preserves active until selection", async () => { await withFixture(async (root) => { const pending = manifest({ deploymentBlock: 0 }); @@ -52,7 +66,11 @@ test("finalizer rejects invalid receipt, transaction, RPC, code, slot, and secre ["wrong chain", { rpc: fakeRpc({ chainId: "0x14a34" }) }, /chain ID/], ["missing code", { rpc: fakeRpc({ missingCode: TOKEN }) }, /has no code/], ["implementation slot mismatch", { rpc: fakeRpc({ slot: TOKEN }) }, /implementation slot/], - ["secret-bearing pending manifest", { pending: manifest({ rpcUrl: "https://user:password@example.invalid" }) }, /credential|secret/i], + ["secret-bearing pending manifest", { pending: manifest({ rpcUrl: "https://user:password@example.invalid" }) }, /credential|secret|endpoints/i], + ["mnemonic in actor label", { pending: manifest({ deploymentBlock: 0, actorLabels: ["owner", "test test test test test test test test test test test junk", "Bob"] }) }, /prohibited/i], + ["private key in actor label", { pending: manifest({ deploymentBlock: 0, actorLabels: ["owner", "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", "Bob"] }) }, /prohibited/i], + ["credential in RPC path", { pending: manifest({ deploymentBlock: 0, rpcUrl: "https://sepolia.base.org/v1/secret-token" }) }, /public endpoint|prohibited/i], + ["unknown manifest field", { pending: manifest({ deploymentBlock: 0, harmlessLookingField: "not allowed" }) }, /unknown field/i], ]; for (const [name, options, expected] of cases) { @@ -72,6 +90,18 @@ test("finalizer rejects invalid receipt, transaction, RPC, code, slot, and secre } }); +test("finalizer failure leaves an initially absent canonical manifest absent", async () => { + await withFixture(async (root) => { + const pending = manifest({ deploymentBlock: 0, actorLabels: ["owner", "MNEMONIC", "Bob"] }); + await writeJson(join(root, "deployments", "pending.json"), pending); + await writeJson(join(root, "deployments", "active.json"), manifest({ deploymentBlock: 8 })); + await writeBroadcast(root, pending); + + await assert.rejects(() => finalizeDeployment({ root, rpc: fakeRpc() }), /prohibited/i); + await assert.rejects(() => access(join(root, "deployments", "anvil.json"))); + }); +}); + test("selection atomically replaces active with only a valid named canonical manifest", async () => { await withFixture(async (root) => { const anvil = manifest({ deploymentBlock: 31 }); @@ -85,6 +115,9 @@ test("selection atomically replaces active with only a valid named canonical man await selectManifest({ root, network: "base-sepolia" }); assert.deepEqual(await readFile(join(root, "deployments", "anvil.json")), anvilBytes); assert.deepEqual(await readJson(join(root, "deployments", "active.json")), base); + const baseBytes = await readFile(join(root, "deployments", "base-sepolia.json")); + await selectManifest({ root, network: "anvil" }); + assert.deepEqual(await readFile(join(root, "deployments", "base-sepolia.json")), baseBytes); await writeJson(join(root, "deployments", "base-sepolia.json"), manifest({ network: "base-sepolia", chainId: 84532, deploymentBlock: 0 })); const beforeActive = await readFile(join(root, "deployments", "active.json")); @@ -106,17 +139,32 @@ test("atomic writes stage a same-directory temporary file before renaming it int assert.equal(calls[0][0], "write"); assert.equal(dirname(calls[0][1]), dirname(target)); assert.notEqual(calls[0][1], target); + assert.match(calls[0][1], /\.json$/); assert.deepEqual(calls[1], ["rename", calls[0][1], target]); }); +test("atomic writes remove the ignored staging file when rename fails", async () => { + const target = "/tmp/deployments/active.json"; + const calls = []; + const io = { + writeFile: async (path) => calls.push(["write", path]), + rename: async () => { throw new Error("rename failed"); }, + rm: async (path) => calls.push(["rm", path]), + }; + + await assert.rejects(() => atomicWrite(target, "confirmed", io), /rename failed/); + assert.deepEqual(calls[1], ["rm", calls[0][1]]); +}); + function manifest(overrides = {}) { + const baseSepolia = overrides.network === "base-sepolia"; return { schemaVersion: 1, - network: "anvil", - chainId: 31337, + network: baseSepolia ? "base-sepolia" : "anvil", + chainId: baseSepolia ? 84532 : 31337, deploymentBlock: 1, - rpcUrl: "http://127.0.0.1:8545", - explorerUrl: "", + rpcUrl: baseSepolia ? "https://sepolia.base.org" : "http://127.0.0.1:8545", + explorerUrl: baseSepolia ? "https://sepolia.basescan.org" : "", token: TOKEN, proxy: PROXY, implementation: IMPLEMENTATION, From c6bf8a683fd97130d9217346f4077569df9c94a7 Mon Sep 17 00:00:00 2001 From: golem Date: Fri, 21 Aug 2026 02:55:41 -0600 Subject: [PATCH 10/30] fix: align deployment manifest schema --- script/CheckState.s.sol | 6 +- script/DeployV1.s.sol | 29 ++--- script/SeedV1Demo.s.sol | 4 +- script/lib/DemoScript.sol | 131 ++++++++++++++++------ test/ScriptPreflight.t.sol | 186 ++++++++++++++++++++++++------- tools/finalize-manifest.mjs | 91 ++++++++++----- tools/select-manifest.mjs | 2 +- tools/test-finalize-manifest.mjs | 79 ++++++++++--- 8 files changed, 389 insertions(+), 139 deletions(-) diff --git a/script/CheckState.s.sol b/script/CheckState.s.sol index 19804e9..1f6c712 100644 --- a/script/CheckState.s.sol +++ b/script/CheckState.s.sol @@ -39,9 +39,9 @@ contract CheckState is DemoScript { console2.log("paused", bank.paused()); console2.log("version", bank.contractVersion()); for (uint256 i; i < manifest.actors.length; ++i) { - console2.log(manifest.actorLabels[i], manifest.actors[i]); - console2.log(" bank balance", bank.balanceOf(manifest.actors[i])); - console2.log(" token balance", token.balanceOf(manifest.actors[i])); + console2.log(manifest.actors[i].label, manifest.actors[i].address_); + console2.log(" bank balance", bank.balanceOf(manifest.actors[i].address_)); + console2.log(" token balance", token.balanceOf(manifest.actors[i].address_)); } console2.log("reserves", reserves); console2.log("liabilities", liabilities); diff --git a/script/DeployV1.s.sol b/script/DeployV1.s.sol index 54a0b59..1fa06b9 100644 --- a/script/DeployV1.s.sol +++ b/script/DeployV1.s.sol @@ -37,35 +37,30 @@ contract DeployV1 is DemoScript { Manifest memory manifest; manifest.schemaVersion = 1; - manifest.network = block.chainid == ANVIL_CHAIN_ID ? "anvil" : "base-sepolia"; + manifest.network = block.chainid == ANVIL_CHAIN_ID ? "anvil" : "baseSepolia"; manifest.chainId = block.chainid; manifest.deploymentBlock = 0; - manifest.rpcUrl = block.chainid == ANVIL_CHAIN_ID ? "http://127.0.0.1:8545" : "https://sepolia.base.org"; - manifest.explorerUrl = block.chainid == ANVIL_CHAIN_ID ? "" : "https://sepolia.basescan.org"; + manifest.rpcUrl = block.chainid == ANVIL_CHAIN_ID ? "http://127.0.0.1:8545" : ""; manifest.token = tokenAddress; manifest.proxy = proxy; manifest.implementation = implementation; manifest.owner = sender; - (manifest.actorLabels, manifest.actors) = _actors(sender); + manifest.actors = _actors(sender); _writeManifest(_manifestPath(PENDING_MANIFEST_PATH), manifest); } - function _actors(address sender) private view returns (string[] memory labels, address[] memory actors) { + function _actors(address sender) private view returns (Actor[] memory actors) { if (block.chainid == ANVIL_CHAIN_ID) { - labels = new string[](3); - actors = new address[](3); - labels[0] = "owner"; - labels[1] = "Alice"; - labels[2] = "Bob"; - actors[0] = sender; - (, actors[1]) = _deriveLocalActor(block.chainid, 1); - (, actors[2]) = _deriveLocalActor(block.chainid, 2); + actors = new Actor[](3); + actors[0] = Actor({label: "owner", address_: sender}); + (, actors[1].address_) = _deriveLocalActor(block.chainid, 1); + (, actors[2].address_) = _deriveLocalActor(block.chainid, 2); + actors[1].label = "Alice"; + actors[2].label = "Bob"; } else { - labels = new string[](1); - actors = new address[](1); - labels[0] = "owner"; - actors[0] = sender; + actors = new Actor[](1); + actors[0] = Actor({label: "owner", address_: sender}); } } } diff --git a/script/SeedV1Demo.s.sol b/script/SeedV1Demo.s.sol index 6bc27f5..1724916 100644 --- a/script/SeedV1Demo.s.sol +++ b/script/SeedV1Demo.s.sol @@ -15,8 +15,8 @@ contract SeedV1Demo is DemoScript { (uint256 aliceKey, address alice) = _deriveLocalActor(block.chainid, 1); (uint256 bobKey, address bob) = _deriveLocalActor(block.chainid, 2); _assertAddress("owner actor", manifest.owner, owner); - _assertAddress("Alice actor", manifest.actors[1], alice); - _assertAddress("Bob actor", manifest.actors[2], bob); + _assertAddress("Alice actor", manifest.actors[1].address_, alice); + _assertAddress("Bob actor", manifest.actors[2].address_, bob); MockUSDC token = MockUSDC(manifest.token); BankV1 bank = BankV1(manifest.proxy); diff --git a/script/lib/DemoScript.sol b/script/lib/DemoScript.sol index ffe1d63..1cc026c 100644 --- a/script/lib/DemoScript.sol +++ b/script/lib/DemoScript.sol @@ -23,19 +23,23 @@ abstract contract DemoScript is Script { error UnexpectedState(string label, uint256 expected, uint256 actual); error UnexpectedAddress(string label, address expected, address actual); + struct Actor { + string label; + address address_; + } + struct Manifest { uint256 schemaVersion; string network; uint256 chainId; uint256 deploymentBlock; string rpcUrl; - string explorerUrl; + string explorerBaseUrl; address token; address proxy; address implementation; address owner; - string[] actorLabels; - address[] actors; + Actor[] actors; } function _requireSupportedChain(uint256 chainId) internal pure { @@ -58,22 +62,23 @@ abstract contract DemoScript is Script { function _readManifest(string memory path, bool active) internal view returns (Manifest memory manifest) { string memory json = vm.readFile(path); + _assertExactManifestSchema(json); manifest.schemaVersion = vm.parseJsonUint(json, ".schemaVersion"); manifest.network = vm.parseJsonString(json, ".network"); manifest.chainId = vm.parseJsonUint(json, ".chainId"); manifest.deploymentBlock = vm.parseJsonUint(json, ".deploymentBlock"); - manifest.rpcUrl = vm.parseJsonString(json, ".rpcUrl"); - manifest.explorerUrl = vm.parseJsonString(json, ".explorerUrl"); + if (vm.keyExistsJson(json, ".rpcUrl")) manifest.rpcUrl = vm.parseJsonString(json, ".rpcUrl"); + if (vm.keyExistsJson(json, ".explorerBaseUrl")) { + manifest.explorerBaseUrl = vm.parseJsonString(json, ".explorerBaseUrl"); + } manifest.token = vm.parseJsonAddress(json, ".token"); manifest.proxy = vm.parseJsonAddress(json, ".proxy"); manifest.implementation = vm.parseJsonAddress(json, ".implementation"); manifest.owner = vm.parseJsonAddress(json, ".owner"); - manifest.actorLabels = vm.parseJsonStringArray(json, ".actorLabels"); - manifest.actors = vm.parseJsonAddressArray(json, ".actors"); - if (manifest.schemaVersion != 1) revert InvalidManifestSchema(manifest.schemaVersion); if (manifest.chainId != block.chainid) revert ManifestChainMismatch(block.chainid, manifest.chainId); if (active && manifest.deploymentBlock == 0) revert InvalidDeploymentBlock(); + manifest.actors = _parseActors(json, manifest.chainId); _validateActorConfiguration(manifest); _requireCode("token", manifest.token); _requireCode("proxy", manifest.proxy); @@ -84,29 +89,63 @@ abstract contract DemoScript is Script { if (target == address(0) || target.code.length == 0) revert MissingCode(label, target); } + function _assertExactManifestSchema(string memory json) private view { + string[] memory keys = vm.parseJsonKeys(json, "."); + if (keys.length < 9 || keys.length > 11) revert InvalidManifestSchema(0); + bool[9] memory required; + for (uint256 i; i < keys.length; ++i) { + string memory key = keys[i]; + if (_equals(key, "schemaVersion")) required[0] = true; + else if (_equals(key, "network")) required[1] = true; + else if (_equals(key, "chainId")) required[2] = true; + else if (_equals(key, "deploymentBlock")) required[3] = true; + else if (_equals(key, "token")) required[4] = true; + else if (_equals(key, "proxy")) required[5] = true; + else if (_equals(key, "implementation")) required[6] = true; + else if (_equals(key, "owner")) required[7] = true; + else if (_equals(key, "actors")) required[8] = true; + else if (!_equals(key, "rpcUrl") && !_equals(key, "explorerBaseUrl")) revert InvalidManifestSchema(0); + } + for (uint256 i; i < required.length; ++i) { + if (!required[i]) revert InvalidManifestSchema(0); + } + } + + function _parseActors(string memory json, uint256 chainId) private view returns (Actor[] memory actors) { + uint256 actorCount = chainId == ANVIL_CHAIN_ID ? 3 : 1; + actors = new Actor[](actorCount); + for (uint256 i; i < actorCount; ++i) { + string memory index = vm.toString(i); + actors[i].label = vm.parseJsonString(json, string.concat(".actors[", index, "].label")); + actors[i].address_ = vm.parseJsonAddress(json, string.concat(".actors[", index, "].address")); + } + if (vm.keyExistsJson(json, string.concat(".actors[", vm.toString(actorCount), "]"))) { + revert InvalidActorConfiguration(); + } + } + function _validateActorConfiguration(Manifest memory manifest) internal pure { - if (manifest.actorLabels.length != manifest.actors.length) revert InvalidActorConfiguration(); if (manifest.chainId == ANVIL_CHAIN_ID) { if ( - manifest.actorLabels.length != 3 || !_equals(manifest.network, "anvil") - || !_equals(manifest.actorLabels[0], "owner") || !_equals(manifest.actorLabels[1], "Alice") - || !_equals(manifest.actorLabels[2], "Bob") + manifest.actors.length != 3 || !_equals(manifest.network, "anvil") + || !_equals(manifest.actors[0].label, "owner") || !_equals(manifest.actors[1].label, "Alice") + || !_equals(manifest.actors[2].label, "Bob") ) revert InvalidActorConfiguration(); (, address owner) = _deriveLocalActor(ANVIL_CHAIN_ID, 0); (, address alice) = _deriveLocalActor(ANVIL_CHAIN_ID, 1); (, address bob) = _deriveLocalActor(ANVIL_CHAIN_ID, 2); if ( - manifest.actors[0] != manifest.owner || manifest.actors[0] != owner || manifest.actors[1] != alice - || manifest.actors[2] != bob + manifest.actors[0].address_ != manifest.owner || manifest.actors[0].address_ != owner + || manifest.actors[1].address_ != alice || manifest.actors[2].address_ != bob ) revert InvalidActorConfiguration(); return; } if ( - manifest.chainId != BASE_SEPOLIA_CHAIN_ID || manifest.actorLabels.length != 1 - || !_equals(manifest.network, "base-sepolia") || !_equals(manifest.actorLabels[0], "owner") - || manifest.actors[0] != manifest.owner + manifest.chainId != BASE_SEPOLIA_CHAIN_ID || manifest.actors.length != 1 + || !_equals(manifest.network, "baseSepolia") || !_equals(manifest.actors[0].label, "owner") + || manifest.actors[0].address_ != manifest.owner ) revert InvalidActorConfiguration(); } @@ -122,20 +161,46 @@ abstract contract DemoScript is Script { return keccak256(bytes(left)) == keccak256(bytes(right)); } - function _serializeManifest(Manifest memory manifest) internal returns (string memory json) { - string memory objectKey = "demo-manifest"; - vm.serializeUint(objectKey, "schemaVersion", manifest.schemaVersion); - vm.serializeString(objectKey, "network", manifest.network); - vm.serializeUint(objectKey, "chainId", manifest.chainId); - vm.serializeUint(objectKey, "deploymentBlock", manifest.deploymentBlock); - vm.serializeString(objectKey, "rpcUrl", manifest.rpcUrl); - vm.serializeString(objectKey, "explorerUrl", manifest.explorerUrl); - vm.serializeAddress(objectKey, "token", manifest.token); - vm.serializeAddress(objectKey, "proxy", manifest.proxy); - vm.serializeAddress(objectKey, "implementation", manifest.implementation); - vm.serializeAddress(objectKey, "owner", manifest.owner); - vm.serializeString(objectKey, "actorLabels", manifest.actorLabels); - json = vm.serializeAddress(objectKey, "actors", manifest.actors); + function _serializeManifest(Manifest memory manifest) internal view returns (string memory json) { + json = string.concat( + '{"schemaVersion":', + vm.toString(manifest.schemaVersion), + ',"network":"', + manifest.network, + '","chainId":', + vm.toString(manifest.chainId), + ',"deploymentBlock":', + vm.toString(manifest.deploymentBlock) + ); + if (bytes(manifest.rpcUrl).length != 0) json = string.concat(json, ',"rpcUrl":"', manifest.rpcUrl, '"'); + if (bytes(manifest.explorerBaseUrl).length != 0) { + json = string.concat(json, ',"explorerBaseUrl":"', manifest.explorerBaseUrl, '"'); + } + json = string.concat( + json, + ',"token":"', + vm.toString(manifest.token), + '","proxy":"', + vm.toString(manifest.proxy), + '","implementation":"', + vm.toString(manifest.implementation), + '","owner":"', + vm.toString(manifest.owner), + '","actors":', + _serializeActors(manifest.actors), + "}" + ); + } + + function _serializeActors(Actor[] memory actors) private view returns (string memory json) { + json = "["; + for (uint256 i; i < actors.length; ++i) { + if (i != 0) json = string.concat(json, ","); + json = string.concat( + json, '{"label":"', actors[i].label, '","address":"', vm.toString(actors[i].address_), '"}' + ); + } + json = string.concat(json, "]"); } function _writeManifest(string memory path, Manifest memory manifest) internal { @@ -153,8 +218,8 @@ abstract contract DemoScript is Script { function _assertV1State(Manifest memory manifest) internal view { BankV1 bank = BankV1(manifest.proxy); - _assertUint("Alice internal balance", 900e6, bank.balanceOf(manifest.actors[1])); - _assertUint("Bob internal balance", 500e6, bank.balanceOf(manifest.actors[2])); + _assertUint("Alice internal balance", 900e6, bank.balanceOf(manifest.actors[1].address_)); + _assertUint("Bob internal balance", 500e6, bank.balanceOf(manifest.actors[2].address_)); _assertUint("liabilities", 1_400e6, bank.totalLiabilities()); _assertUint("reserves", 1_400e6, MockUSDC(manifest.token).balanceOf(manifest.proxy)); _assertUint("version", 1, bank.contractVersion()); diff --git a/test/ScriptPreflight.t.sol b/test/ScriptPreflight.t.sol index 30e4a23..a9a381a 100644 --- a/test/ScriptPreflight.t.sol +++ b/test/ScriptPreflight.t.sol @@ -125,18 +125,38 @@ contract ScriptPreflightTest is Test { harness.readManifest(path, true); } - function testManifestRejectsNonParallelActorArrays() public { + function testAnvilManifestRejectsUnexpectedActorObjectCount() public { string memory path = _writeManifest(31337, 1, TOKEN, PROXY, IMPLEMENTATION); _etchManifestContracts(); - vm.writeJson('["owner","Alice"]', path, ".actorLabels"); - vm.expectRevert(DemoScript.InvalidActorConfiguration.selector); + vm.writeJson( + string.concat( + '[{"label":"owner","address":"', + vm.toString(OWNER), + '"},{"label":"Alice","address":"', + vm.toString(ALICE), + '"}]' + ), + path, + ".actors" + ); + vm.expectRevert(); harness.readManifest(path, true); } function testAnvilManifestRejectsUnexpectedActorLabels() public { string memory path = _writeManifest(31337, 1, TOKEN, PROXY, IMPLEMENTATION); _etchManifestContracts(); - vm.writeJson('["owner","Mallory","Bob"]', path, ".actorLabels"); + vm.writeJson( + string.concat( + '[{"label":"owner","address":"', + vm.toString(OWNER), + '"},{"label":"Mallory","address":"', + vm.toString(ALICE), + '"},{"label":"Bob","address":"0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC"}]' + ), + path, + ".actors" + ); vm.expectRevert(DemoScript.InvalidActorConfiguration.selector); harness.readManifest(path, true); } @@ -146,7 +166,11 @@ contract ScriptPreflightTest is Test { _etchManifestContracts(); vm.writeJson( string.concat( - '["', vm.toString(ALICE), '","', vm.toString(OWNER), '","0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC"]' + '[{"label":"owner","address":"', + vm.toString(ALICE), + '"},{"label":"Alice","address":"', + vm.toString(OWNER), + '"},{"label":"Bob","address":"0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC"}]' ), path, ".actors" @@ -156,11 +180,20 @@ contract ScriptPreflightTest is Test { } function testBaseManifestRequiresOnlyOwnerActor() public { - string memory path = _writeBaseManifest(); + string memory path = _writePublicBaseManifest(); vm.chainId(84532); _etchManifestContracts(); - vm.writeJson('["owner","Alice"]', path, ".actorLabels"); - vm.writeJson(string.concat('["', vm.toString(OWNER), '","', vm.toString(ALICE), '"]'), path, ".actors"); + vm.writeJson( + string.concat( + '[{"label":"owner","address":"', + vm.toString(OWNER), + '"},{"label":"Alice","address":"', + vm.toString(ALICE), + '"}]' + ), + path, + ".actors" + ); vm.expectRevert(DemoScript.InvalidActorConfiguration.selector); harness.readManifest(path, true); } @@ -169,6 +202,41 @@ contract ScriptPreflightTest is Test { assertEq(harness.educationalWarning(), unicode"Educational demo — mock token — never use real funds."); } + function testNestedActorsRoundTripThroughManifestSerialization() public { + string memory path = _writePublicAnvilManifest(); + _etchManifestContracts(); + DemoScript.Manifest memory manifest = harness.readManifest(path, true); + assertEq(manifest.actors[0].label, "owner"); + assertEq(manifest.actors[1].address_, ALICE); + + string memory serialized = harness.serializeManifest(manifest); + assertFalse(_contains(serialized, "actorLabels")); + assertFalse(_contains(serialized, "explorerUrl")); + vm.writeFile(path, serialized); + DemoScript.Manifest memory roundTrip = harness.readManifest(path, true); + assertEq(roundTrip.actors[2].label, "Bob"); + assertEq(roundTrip.actors[2].address_, 0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC); + } + + function testLegacyParallelActorManifestIsRejected() public { + string memory path = _writeLegacyParallelActorManifest(); + _etchManifestContracts(); + vm.expectRevert(abi.encodeWithSelector(DemoScript.InvalidManifestSchema.selector, uint256(0))); + harness.readManifest(path, true); + } + + function testBaseSepoliaManifestUsesCamelCaseAndOmitsUnavailableUrls() public { + string memory path = _writePublicBaseManifest(); + vm.chainId(84532); + _etchManifestContracts(); + DemoScript.Manifest memory manifest = harness.readManifest(path, true); + assertEq(manifest.network, "baseSepolia"); + assertEq(manifest.rpcUrl, ""); + assertEq(manifest.explorerBaseUrl, ""); + assertEq(manifest.actors[0].label, "owner"); + assertEq(manifest.actors[0].address_, OWNER); + } + function testSerializedManifestContainsPublicAddressesAndNoSecrets() public { DemoScript.Manifest memory manifest = DemoScript.Manifest({ schemaVersion: 1, @@ -176,12 +244,11 @@ contract ScriptPreflightTest is Test { chainId: 31337, deploymentBlock: 0, rpcUrl: "http://127.0.0.1:8545", - explorerUrl: "", + explorerBaseUrl: "", token: TOKEN, proxy: PROXY, implementation: IMPLEMENTATION, owner: OWNER, - actorLabels: _labels(), actors: _actors() }); @@ -212,12 +279,12 @@ contract ScriptPreflightTest is Test { assertEq(BankV1(proxy).owner(), OWNER); assertEq(address(BankV1(proxy).asset()), token); assertEq(BankV1(proxy).contractVersion(), 1); - assertEq(manifest.actorLabels[0], "owner"); - assertEq(manifest.actorLabels[1], "Alice"); - assertEq(manifest.actorLabels[2], "Bob"); - assertEq(manifest.actors[0], OWNER); - assertEq(manifest.actors[1], ALICE); - assertEq(manifest.actors[2], 0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC); + assertEq(manifest.actors[0].label, "owner"); + assertEq(manifest.actors[1].label, "Alice"); + assertEq(manifest.actors[2].label, "Bob"); + assertEq(manifest.actors[0].address_, OWNER); + assertEq(manifest.actors[1].address_, ALICE); + assertEq(manifest.actors[2].address_, 0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC); } function testSeedV1DemoExecutesExactActOneStateAndCheckStateAcceptsIt() public { @@ -227,12 +294,12 @@ contract ScriptPreflightTest is Test { new SeedV1Demo().run(); BankV1 bank = BankV1(manifest.proxy); - assertEq(bank.balanceOf(manifest.actors[1]), 900e6); - assertEq(bank.balanceOf(manifest.actors[2]), 500e6); + assertEq(bank.balanceOf(manifest.actors[1].address_), 900e6); + assertEq(bank.balanceOf(manifest.actors[2].address_), 500e6); assertEq(bank.totalLiabilities(), 1_400e6); assertEq(MockUSDC(manifest.token).balanceOf(manifest.proxy), 1_400e6); - assertEq(MockUSDC(manifest.token).balanceOf(manifest.actors[1]), 1_100e6); - assertEq(MockUSDC(manifest.token).balanceOf(manifest.actors[2]), 500e6); + assertEq(MockUSDC(manifest.token).balanceOf(manifest.actors[1].address_), 1_100e6); + assertEq(MockUSDC(manifest.token).balanceOf(manifest.actors[2].address_), 500e6); vm.setEnv("DEMO_EXPECTED_STAGE", "v1"); new CheckState().run(); @@ -266,7 +333,7 @@ contract ScriptPreflightTest is Test { vm.toString(chainId), ',"deploymentBlock":', vm.toString(deploymentBlock), - ',"rpcUrl":"http://127.0.0.1:8545","explorerUrl":"","token":"', + ',"rpcUrl":"http://127.0.0.1:8545","token":"', vm.toString(token), '","proxy":"', vm.toString(proxy), @@ -274,11 +341,11 @@ contract ScriptPreflightTest is Test { vm.toString(implementation), '","owner":"', vm.toString(OWNER), - '","actorLabels":["owner","Alice","Bob"],"actors":["', + '","actors":[{"label":"owner","address":"', vm.toString(OWNER), - '","', + '"},{"label":"Alice","address":"', vm.toString(ALICE), - '","0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC"]}' + '"},{"label":"Bob","address":"0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC"}]}' ); vm.writeFile(path, json); } @@ -293,12 +360,12 @@ contract ScriptPreflightTest is Test { manifest = harness.readManifest(path, true); } - function _writeBaseManifest() internal returns (string memory path) { - path = string.concat(fixtureDir, "/base-manifest.json"); + function _writePublicAnvilManifest() internal returns (string memory path) { + path = string.concat(fixtureDir, "/public-anvil-manifest.json"); vm.writeFile( path, string.concat( - '{"schemaVersion":1,"network":"base-sepolia","chainId":84532,"deploymentBlock":1,"rpcUrl":"https://sepolia.base.org","explorerUrl":"https://sepolia.basescan.org","token":"', + '{"schemaVersion":1,"network":"anvil","chainId":31337,"deploymentBlock":1,"rpcUrl":"http://127.0.0.1:8545","token":"', vm.toString(TOKEN), '","proxy":"', vm.toString(PROXY), @@ -306,9 +373,53 @@ contract ScriptPreflightTest is Test { vm.toString(IMPLEMENTATION), '","owner":"', vm.toString(OWNER), - '","actorLabels":["owner"],"actors":["', + '","actors":[{"label":"owner","address":"', vm.toString(OWNER), - '"]}' + '"},{"label":"Alice","address":"', + vm.toString(ALICE), + '"},{"label":"Bob","address":"0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC"}]}' + ) + ); + } + + function _writeLegacyParallelActorManifest() internal returns (string memory path) { + path = string.concat(fixtureDir, "/legacy-parallel-actors.json"); + vm.writeFile( + path, + string.concat( + '{"schemaVersion":1,"network":"anvil","chainId":31337,"deploymentBlock":1,"rpcUrl":"http://127.0.0.1:8545","token":"', + vm.toString(TOKEN), + '","proxy":"', + vm.toString(PROXY), + '","implementation":"', + vm.toString(IMPLEMENTATION), + '","owner":"', + vm.toString(OWNER), + '","actorLabels":["owner","Alice","Bob"],"actors":[{"label":"owner","address":"', + vm.toString(OWNER), + '"},{"label":"Alice","address":"', + vm.toString(ALICE), + '"},{"label":"Bob","address":"0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC"}]}' + ) + ); + } + + function _writePublicBaseManifest() internal returns (string memory path) { + path = string.concat(fixtureDir, "/public-base-manifest.json"); + vm.writeFile( + path, + string.concat( + '{"schemaVersion":1,"network":"baseSepolia","chainId":84532,"deploymentBlock":1,"token":"', + vm.toString(TOKEN), + '","proxy":"', + vm.toString(PROXY), + '","implementation":"', + vm.toString(IMPLEMENTATION), + '","owner":"', + vm.toString(OWNER), + '","actors":[{"label":"owner","address":"', + vm.toString(OWNER), + '"}]}' ) ); } @@ -319,18 +430,11 @@ contract ScriptPreflightTest is Test { vm.etch(IMPLEMENTATION, hex"00"); } - function _labels() internal pure returns (string[] memory labels) { - labels = new string[](3); - labels[0] = "owner"; - labels[1] = "Alice"; - labels[2] = "Bob"; - } - - function _actors() internal pure returns (address[] memory actors) { - actors = new address[](3); - actors[0] = OWNER; - actors[1] = ALICE; - actors[2] = 0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC; + function _actors() internal pure returns (DemoScript.Actor[] memory actors) { + actors = new DemoScript.Actor[](3); + actors[0] = DemoScript.Actor({label: "owner", address_: OWNER}); + actors[1] = DemoScript.Actor({label: "Alice", address_: ALICE}); + actors[2] = DemoScript.Actor({label: "Bob", address_: 0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC}); } function _contains(string memory haystack, string memory needle) internal pure returns (bool) { diff --git a/tools/finalize-manifest.mjs b/tools/finalize-manifest.mjs index 01309d2..4aa4c1d 100644 --- a/tools/finalize-manifest.mjs +++ b/tools/finalize-manifest.mjs @@ -8,22 +8,20 @@ export const EDUCATIONAL_WARNING = "Educational demo — mock token — never us const NETWORKS = { anvil: { - name: "anvil", chainId: 31337, canonical: "anvil.json", recovery: "make reset-local", rpcUrl: "http://127.0.0.1:8545", explorerUrl: "", + name: "anvil", chainId: 31337, canonical: "anvil.json", recovery: "make reset-local", rpcUrl: "http://127.0.0.1:8545", }, - "base-sepolia": { - name: "base-sepolia", chainId: 84532, canonical: "base-sepolia.json", recovery: "make archive-base-manifest", rpcUrl: "https://sepolia.base.org", explorerUrl: "https://sepolia.basescan.org", + baseSepolia: { + name: "baseSepolia", chainId: 84532, canonical: "base-sepolia.json", recovery: "make archive-base-manifest", }, }; -const MANIFEST_FIELDS = [ - "schemaVersion", "network", "chainId", "deploymentBlock", "rpcUrl", "explorerUrl", "token", "proxy", "implementation", "owner", "actorLabels", "actors", -]; +const REQUIRED_MANIFEST_FIELDS = ["schemaVersion", "network", "chainId", "deploymentBlock", "token", "proxy", "implementation", "owner", "actors"]; +const OPTIONAL_MANIFEST_FIELDS = ["rpcUrl", "explorerBaseUrl"]; const ANVIL_TEST_PHRASE = "test test test test test test test test test test test junk"; const PROHIBITED_STRING_VALUE = /(?:private[_ -]?key|mnemonic|secret|password|credential|api[_ -]?key)|0x[a-fA-F0-9]{64}/i; export function networkSpec(network) { - const normalized = network === "baseSepolia" ? "base-sepolia" : network; - const spec = NETWORKS[normalized]; + const spec = NETWORKS[network]; if (!spec) throw new Error(`unsupported deployment network: ${network}`); return spec; } @@ -106,24 +104,26 @@ export function validateManifest(manifest, { pending = false } = {}) { if (!Number.isSafeInteger(manifest.deploymentBlock) || manifest.deploymentBlock < (pending ? 0 : 1)) { throw new Error(`manifest deploymentBlock must be ${pending ? "a nonnegative integer" : "at least 1"}`); } - if (manifest.rpcUrl !== spec.rpcUrl || manifest.explorerUrl !== spec.explorerUrl) { - throw new Error(`manifest display URLs must use the public ${manifest.network} endpoints`); - } + assertManifestUrls(manifest, spec); for (const field of ["token", "proxy", "implementation", "owner"]) assertAddress(manifest[field], field); - if (!Array.isArray(manifest.actorLabels) || !Array.isArray(manifest.actors) || manifest.actorLabels.length !== manifest.actors.length || manifest.actors.length === 0) { - throw new Error("manifest actors and actorLabels must be nonempty parallel arrays"); - } + if (!Array.isArray(manifest.actors) || manifest.actors.length === 0) throw new Error("manifest actors must be a nonempty array"); const labels = new Set(); const actors = new Set(); - for (let index = 0; index < manifest.actors.length; index += 1) { - const label = manifest.actorLabels[index]; + for (const [index, actorRecord] of manifest.actors.entries()) { + if (!actorRecord || typeof actorRecord !== "object" || Array.isArray(actorRecord)) throw new Error(`actors[${index}] must be an object`); + const keys = Object.keys(actorRecord); + if (keys.length !== 2 || !Object.hasOwn(actorRecord, "label") || !Object.hasOwn(actorRecord, "address")) { + throw new Error(`actors[${index}] must contain exactly label and address`); + } + const { label, address } = actorRecord; if (typeof label !== "string" || label.trim() === "" || labels.has(label)) throw new Error("manifest actor labels must be unique nonempty strings"); labels.add(label); - assertAddress(manifest.actors[index], `actors[${index}]`); - const actor = manifest.actors[index].toLowerCase(); + assertAddress(address, `actors[${index}].address`); + const actor = address.toLowerCase(); if (actors.has(actor)) throw new Error("manifest actors must be unique"); actors.add(actor); } + assertActorConfiguration(manifest); } export async function atomicWriteJson(path, value) { @@ -149,14 +149,55 @@ function assertAddress(value, label) { } function assertExactSchema(manifest) { - for (const field of MANIFEST_FIELDS) { + for (const field of REQUIRED_MANIFEST_FIELDS) { if (!Object.hasOwn(manifest, field)) throw new Error(`manifest is missing required field ${field}`); } for (const field of Object.keys(manifest)) { - if (!MANIFEST_FIELDS.includes(field)) throw new Error(`manifest contains unknown field ${field}`); + if (![...REQUIRED_MANIFEST_FIELDS, ...OPTIONAL_MANIFEST_FIELDS].includes(field)) throw new Error(`manifest contains unknown field ${field}`); } } +function assertManifestUrls(manifest, spec) { + if (manifest.network === "anvil") { + if (manifest.rpcUrl !== spec.rpcUrl) throw new Error("manifest anvil rpcUrl must use the local public endpoint"); + if (Object.hasOwn(manifest, "explorerBaseUrl")) throw new Error("manifest anvil must omit explorerBaseUrl"); + return; + } + for (const field of OPTIONAL_MANIFEST_FIELDS) { + if (Object.hasOwn(manifest, field)) assertPublicUrl(manifest[field], field); + } +} + +function assertPublicUrl(value, field) { + if (typeof value !== "string") throw new Error(`manifest ${field} must be a URL string`); + let url; + try { + url = new URL(value); + } catch { + throw new Error(`manifest ${field} must be a public URL`); + } + if ((url.protocol !== "https:" && url.protocol !== "http:") || url.username || url.password) { + throw new Error(`manifest ${field} must be a public URL without credentials`); + } +} + +function assertActorConfiguration(manifest) { + const { actors, network, owner } = manifest; + if (network === "anvil") { + const expected = [ + ["owner", "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"], + ["Alice", "0x70997970C51812dc3A010C7d01b50e0d17dc79C8"], + ["Bob", "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC"], + ]; + if (actors.length !== expected.length || actors.some((actor, index) => actor.label !== expected[index][0] || actor.address.toLowerCase() !== expected[index][1].toLowerCase())) { + throw new Error("manifest anvil actors must match the documented local actor configuration"); + } + } else if (actors.length !== 1 || actors[0].label !== "owner") { + throw new Error("manifest baseSepolia must contain only the owner actor"); + } + if (actors[0].address.toLowerCase() !== owner.toLowerCase()) throw new Error("manifest owner must be actor zero"); +} + function rejectProhibitedStringValues(value, path = "") { if (typeof value === "string") { if (value.includes(ANVIL_TEST_PHRASE) || PROHIBITED_STRING_VALUE.test(value)) { @@ -171,6 +212,7 @@ function rejectProhibitedStringValues(value, path = "") { if (!value || typeof value !== "object") return; for (const [key, nested] of Object.entries(value)) { const nestedPath = path ? `${path}.${key}` : key; + rejectProhibitedStringValues(key, nestedPath); rejectProhibitedStringValues(nested, nestedPath); } } @@ -181,14 +223,13 @@ function publicManifest(manifest, deploymentBlock) { network: manifest.network, chainId: manifest.chainId, deploymentBlock, - rpcUrl: manifest.rpcUrl, - explorerUrl: manifest.explorerUrl, + ...(Object.hasOwn(manifest, "rpcUrl") ? { rpcUrl: manifest.rpcUrl } : {}), + ...(Object.hasOwn(manifest, "explorerBaseUrl") ? { explorerBaseUrl: manifest.explorerBaseUrl } : {}), token: manifest.token, proxy: manifest.proxy, implementation: manifest.implementation, owner: manifest.owner, - actorLabels: [...manifest.actorLabels], - actors: [...manifest.actors], + actors: manifest.actors.map(({ label, address }) => ({ label, address })), }; } @@ -240,7 +281,7 @@ export async function runFinalizeCli(argv, { root = process.cwd(), log = console if (network !== "--rpc-url" || typeof rest[0] !== "string" || rest.length !== 1) throw new Error("usage: finalize-manifest.mjs deploy --rpc-url "); return finalizeDeployment({ root, rpc: fetchRpc(rest[0]) }); } - throw new Error("usage: finalize-manifest.mjs preflight-deploy | deploy --rpc-url "); + throw new Error("usage: finalize-manifest.mjs preflight-deploy | deploy --rpc-url "); } if (process.argv[1] && pathToFileURL(resolve(process.argv[1])).href === import.meta.url) { diff --git a/tools/select-manifest.mjs b/tools/select-manifest.mjs index a17974b..f89cb55 100644 --- a/tools/select-manifest.mjs +++ b/tools/select-manifest.mjs @@ -16,7 +16,7 @@ export async function selectManifest({ root = process.cwd(), network }) { export async function runSelectCli(argv, { root = process.cwd(), log = console.log } = {}) { log(EDUCATIONAL_WARNING); - if (argv.length !== 1) throw new Error("usage: select-manifest.mjs "); + if (argv.length !== 1) throw new Error("usage: select-manifest.mjs "); return selectManifest({ root, network: argv[0] }); } diff --git a/tools/test-finalize-manifest.mjs b/tools/test-finalize-manifest.mjs index f571757..d1840fb 100644 --- a/tools/test-finalize-manifest.mjs +++ b/tools/test-finalize-manifest.mjs @@ -4,7 +4,7 @@ import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import test from "node:test"; -import { atomicWrite, finalizeDeployment, preflightDeploy, runFinalizeCli } from "./finalize-manifest.mjs"; +import { atomicWrite, finalizeDeployment, preflightDeploy, runFinalizeCli, validateManifest } from "./finalize-manifest.mjs"; import { runSelectCli, selectManifest } from "./select-manifest.mjs"; const TOKEN = "0x1000000000000000000000000000000000000001"; @@ -21,9 +21,9 @@ test("preflight rejects an existing target canonical manifest with the safe reco () => preflightDeploy({ root, network: "anvil" }), /make reset-local/ ); - await writeJson(join(root, "deployments", "base-sepolia.json"), manifest({ network: "base-sepolia", chainId: 84532 })); + await writeJson(join(root, "deployments", "base-sepolia.json"), manifest({ network: "baseSepolia", chainId: 84532 })); await assert.rejects( - () => preflightDeploy({ root, network: "base-sepolia" }), + () => preflightDeploy({ root, network: "baseSepolia" }), /make archive-base-manifest/ ); }); @@ -42,17 +42,38 @@ test("direct manifest CLIs print the exact educational warning", async () => { }); }); +test("finalizer confirms a nested public actor manifest and preserves omitted Base URLs", async () => { + await withFixture(async (root) => { + const pending = manifest({ network: "baseSepolia", chainId: 84532, deploymentBlock: 0 }); + await writeJson(join(root, "deployments", "pending.json"), pending); + await writeBroadcast(root, pending); + + const output = await finalizeDeployment({ root, rpc: fakeRpc({ chainId: "0x14a34" }) }); + assert.deepEqual(output.manifest, { ...pending, deploymentBlock: 42 }); + assert.equal(Object.hasOwn(output.manifest, "rpcUrl"), false); + assert.equal(Object.hasOwn(output.manifest, "explorerBaseUrl"), false); + assert.deepEqual(output.manifest.actors, [{ label: "owner", address: OWNER }]); + }); +}); + +test("validator rejects the legacy parallel actor and explorer schema", () => { + assert.throws( + () => validateManifest(legacyManifest()), + /unknown field|missing required field/ + ); +}); + test("finalizer writes only a receipt-confirmed manifest and preserves active until selection", async () => { await withFixture(async (root) => { const pending = manifest({ deploymentBlock: 0 }); await writeJson(join(root, "deployments", "pending.json"), pending); - await writeJson(join(root, "deployments", "active.json"), manifest({ network: "base-sepolia", chainId: 84532, deploymentBlock: 88 })); + await writeJson(join(root, "deployments", "active.json"), manifest({ network: "baseSepolia", chainId: 84532, deploymentBlock: 88 })); await writeBroadcast(root, pending, { hash: "0xaaa", contractAddress: PROXY }); const output = await finalizeDeployment({ root, rpc: fakeRpc() }); assert.equal(output.path, join(root, "deployments", "anvil.json")); assert.deepEqual(await readJson(output.path), { ...pending, deploymentBlock: 42 }); - assert.deepEqual(await readJson(join(root, "deployments", "active.json")), manifest({ network: "base-sepolia", chainId: 84532, deploymentBlock: 88 })); + assert.deepEqual(await readJson(join(root, "deployments", "active.json")), manifest({ network: "baseSepolia", chainId: 84532, deploymentBlock: 88 })); }); }); @@ -67,9 +88,9 @@ test("finalizer rejects invalid receipt, transaction, RPC, code, slot, and secre ["missing code", { rpc: fakeRpc({ missingCode: TOKEN }) }, /has no code/], ["implementation slot mismatch", { rpc: fakeRpc({ slot: TOKEN }) }, /implementation slot/], ["secret-bearing pending manifest", { pending: manifest({ rpcUrl: "https://user:password@example.invalid" }) }, /credential|secret|endpoints/i], - ["mnemonic in actor label", { pending: manifest({ deploymentBlock: 0, actorLabels: ["owner", "test test test test test test test test test test test junk", "Bob"] }) }, /prohibited/i], - ["private key in actor label", { pending: manifest({ deploymentBlock: 0, actorLabels: ["owner", "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", "Bob"] }) }, /prohibited/i], - ["credential in RPC path", { pending: manifest({ deploymentBlock: 0, rpcUrl: "https://sepolia.base.org/v1/secret-token" }) }, /public endpoint|prohibited/i], + ["mnemonic in actor label", { pending: manifest({ deploymentBlock: 0, actors: localActors("test test test test test test test test test test test junk") }) }, /prohibited/i], + ["private key in actor label", { pending: manifest({ deploymentBlock: 0, actors: localActors("0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80") }) }, /prohibited/i], + ["credential in RPC path", { pending: manifest({ network: "baseSepolia", chainId: 84532, deploymentBlock: 0, rpcUrl: "https://sepolia.base.org/v1/secret-token" }) }, /public URL|prohibited/i], ["unknown manifest field", { pending: manifest({ deploymentBlock: 0, harmlessLookingField: "not allowed" }) }, /unknown field/i], ]; @@ -92,7 +113,7 @@ test("finalizer rejects invalid receipt, transaction, RPC, code, slot, and secre test("finalizer failure leaves an initially absent canonical manifest absent", async () => { await withFixture(async (root) => { - const pending = manifest({ deploymentBlock: 0, actorLabels: ["owner", "MNEMONIC", "Bob"] }); + const pending = manifest({ deploymentBlock: 0, actors: localActors("MNEMONIC") }); await writeJson(join(root, "deployments", "pending.json"), pending); await writeJson(join(root, "deployments", "active.json"), manifest({ deploymentBlock: 8 })); await writeBroadcast(root, pending); @@ -105,23 +126,23 @@ test("finalizer failure leaves an initially absent canonical manifest absent", a test("selection atomically replaces active with only a valid named canonical manifest", async () => { await withFixture(async (root) => { const anvil = manifest({ deploymentBlock: 31 }); - const base = manifest({ network: "base-sepolia", chainId: 84532, deploymentBlock: 32 }); + const base = manifest({ network: "baseSepolia", chainId: 84532, deploymentBlock: 32 }); await writeJson(join(root, "deployments", "anvil.json"), anvil); await writeJson(join(root, "deployments", "base-sepolia.json"), base); await selectManifest({ root, network: "anvil" }); const anvilBytes = await readFile(join(root, "deployments", "anvil.json")); assert.deepEqual(await readFile(join(root, "deployments", "active.json")), anvilBytes); - await selectManifest({ root, network: "base-sepolia" }); + await selectManifest({ root, network: "baseSepolia" }); assert.deepEqual(await readFile(join(root, "deployments", "anvil.json")), anvilBytes); assert.deepEqual(await readJson(join(root, "deployments", "active.json")), base); const baseBytes = await readFile(join(root, "deployments", "base-sepolia.json")); await selectManifest({ root, network: "anvil" }); assert.deepEqual(await readFile(join(root, "deployments", "base-sepolia.json")), baseBytes); - await writeJson(join(root, "deployments", "base-sepolia.json"), manifest({ network: "base-sepolia", chainId: 84532, deploymentBlock: 0 })); + await writeJson(join(root, "deployments", "base-sepolia.json"), manifest({ network: "baseSepolia", chainId: 84532, deploymentBlock: 0 })); const beforeActive = await readFile(join(root, "deployments", "active.json")); - await assert.rejects(() => selectManifest({ root, network: "base-sepolia" }), /deploymentBlock/); + await assert.rejects(() => selectManifest({ root, network: "baseSepolia" }), /deploymentBlock/); assert.deepEqual(await readFile(join(root, "deployments", "active.json")), beforeActive); }); }); @@ -157,14 +178,30 @@ test("atomic writes remove the ignored staging file when rename fails", async () }); function manifest(overrides = {}) { - const baseSepolia = overrides.network === "base-sepolia"; + const baseSepolia = overrides.network === "baseSepolia"; return { schemaVersion: 1, - network: baseSepolia ? "base-sepolia" : "anvil", + network: baseSepolia ? "baseSepolia" : "anvil", chainId: baseSepolia ? 84532 : 31337, deploymentBlock: 1, - rpcUrl: baseSepolia ? "https://sepolia.base.org" : "http://127.0.0.1:8545", - explorerUrl: baseSepolia ? "https://sepolia.basescan.org" : "", + ...(baseSepolia ? {} : { rpcUrl: "http://127.0.0.1:8545" }), + token: TOKEN, + proxy: PROXY, + implementation: IMPLEMENTATION, + owner: OWNER, + actors: baseSepolia ? [{ label: "owner", address: OWNER }] : localActors(), + ...overrides, + }; +} + +function legacyManifest(overrides = {}) { + return { + schemaVersion: 1, + network: "anvil", + chainId: 31337, + deploymentBlock: 1, + rpcUrl: "http://127.0.0.1:8545", + explorerUrl: "", token: TOKEN, proxy: PROXY, implementation: IMPLEMENTATION, @@ -175,6 +212,14 @@ function manifest(overrides = {}) { }; } +function localActors(aliceLabel = "Alice") { + return [ + { label: "owner", address: OWNER }, + { label: aliceLabel, address: "0x70997970C51812dc3A010C7d01b50e0d17dc79C8" }, + { label: "Bob", address: "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC" }, + ]; +} + async function withFixture(fn) { const root = await mkdtemp(join(tmpdir(), "uups-finalizer-")); try { From 54c893fdb73e5bb0d75ff0883f23bc01db092b81 Mon Sep 17 00:00:00 2001 From: golem Date: Fri, 21 Aug 2026 03:00:23 -0600 Subject: [PATCH 11/30] fix: enforce optional manifest fields --- script/lib/DemoScript.sol | 14 +++++++++++ test/ScriptPreflight.t.sol | 43 ++++++++++++++++++++++++++++++++ tools/finalize-manifest.mjs | 4 ++- tools/test-finalize-manifest.mjs | 13 ++++++++++ 4 files changed, 73 insertions(+), 1 deletion(-) diff --git a/script/lib/DemoScript.sol b/script/lib/DemoScript.sol index 1cc026c..769cd12 100644 --- a/script/lib/DemoScript.sol +++ b/script/lib/DemoScript.sol @@ -116,6 +116,7 @@ abstract contract DemoScript is Script { actors = new Actor[](actorCount); for (uint256 i; i < actorCount; ++i) { string memory index = vm.toString(i); + _assertExactActorSchema(json, index); actors[i].label = vm.parseJsonString(json, string.concat(".actors[", index, "].label")); actors[i].address_ = vm.parseJsonAddress(json, string.concat(".actors[", index, "].address")); } @@ -124,6 +125,19 @@ abstract contract DemoScript is Script { } } + function _assertExactActorSchema(string memory json, string memory index) private view { + string[] memory keys = vm.parseJsonKeys(json, string.concat(".actors[", index, "]")); + bool hasLabel; + bool hasAddress; + if (keys.length != 2) revert InvalidManifestSchema(0); + for (uint256 i; i < keys.length; ++i) { + if (_equals(keys[i], "label")) hasLabel = true; + else if (_equals(keys[i], "address")) hasAddress = true; + else revert InvalidManifestSchema(0); + } + if (!hasLabel || !hasAddress) revert InvalidManifestSchema(0); + } + function _validateActorConfiguration(Manifest memory manifest) internal pure { if (manifest.chainId == ANVIL_CHAIN_ID) { if ( diff --git a/test/ScriptPreflight.t.sol b/test/ScriptPreflight.t.sol index a9a381a..7f1f96b 100644 --- a/test/ScriptPreflight.t.sol +++ b/test/ScriptPreflight.t.sol @@ -225,6 +225,20 @@ contract ScriptPreflightTest is Test { harness.readManifest(path, true); } + function testActorObjectWithExtraKeyIsRejected() public { + string memory path = _writeAnvilManifestWithExtraActorKey("note", "public"); + _etchManifestContracts(); + vm.expectRevert(abi.encodeWithSelector(DemoScript.InvalidManifestSchema.selector, uint256(0))); + harness.readManifest(path, true); + } + + function testActorObjectWithSecretBearingKeyIsRejected() public { + string memory path = _writeAnvilManifestWithExtraActorKey("privateKey", "not-a-key"); + _etchManifestContracts(); + vm.expectRevert(abi.encodeWithSelector(DemoScript.InvalidManifestSchema.selector, uint256(0))); + harness.readManifest(path, true); + } + function testBaseSepoliaManifestUsesCamelCaseAndOmitsUnavailableUrls() public { string memory path = _writePublicBaseManifest(); vm.chainId(84532); @@ -404,6 +418,35 @@ contract ScriptPreflightTest is Test { ); } + function _writeAnvilManifestWithExtraActorKey(string memory key, string memory value) + internal + returns (string memory path) + { + path = string.concat(fixtureDir, "/extra-actor-key.json"); + vm.writeFile( + path, + string.concat( + '{"schemaVersion":1,"network":"anvil","chainId":31337,"deploymentBlock":1,"rpcUrl":"http://127.0.0.1:8545","token":"', + vm.toString(TOKEN), + '","proxy":"', + vm.toString(PROXY), + '","implementation":"', + vm.toString(IMPLEMENTATION), + '","owner":"', + vm.toString(OWNER), + '","actors":[{"label":"owner","address":"', + vm.toString(OWNER), + '","', + key, + '":"', + value, + '"},{"label":"Alice","address":"', + vm.toString(ALICE), + '"},{"label":"Bob","address":"0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC"}]}' + ) + ); + } + function _writePublicBaseManifest() internal returns (string memory path) { path = string.concat(fixtureDir, "/public-base-manifest.json"); vm.writeFile( diff --git a/tools/finalize-manifest.mjs b/tools/finalize-manifest.mjs index 4aa4c1d..47c2b78 100644 --- a/tools/finalize-manifest.mjs +++ b/tools/finalize-manifest.mjs @@ -159,7 +159,9 @@ function assertExactSchema(manifest) { function assertManifestUrls(manifest, spec) { if (manifest.network === "anvil") { - if (manifest.rpcUrl !== spec.rpcUrl) throw new Error("manifest anvil rpcUrl must use the local public endpoint"); + if (Object.hasOwn(manifest, "rpcUrl") && manifest.rpcUrl !== spec.rpcUrl) { + throw new Error("manifest anvil rpcUrl must use the local public endpoint"); + } if (Object.hasOwn(manifest, "explorerBaseUrl")) throw new Error("manifest anvil must omit explorerBaseUrl"); return; } diff --git a/tools/test-finalize-manifest.mjs b/tools/test-finalize-manifest.mjs index d1840fb..7b4e1eb 100644 --- a/tools/test-finalize-manifest.mjs +++ b/tools/test-finalize-manifest.mjs @@ -56,6 +56,19 @@ test("finalizer confirms a nested public actor manifest and preserves omitted Ba }); }); +test("finalizer accepts an Anvil manifest with omitted optional rpcUrl and preserves its absence", async () => { + await withFixture(async (root) => { + const pending = manifest({ deploymentBlock: 0 }); + delete pending.rpcUrl; + await writeJson(join(root, "deployments", "pending.json"), pending); + await writeBroadcast(root, pending); + + const output = await finalizeDeployment({ root, rpc: fakeRpc() }); + assert.equal(Object.hasOwn(output.manifest, "rpcUrl"), false); + assert.equal(Object.hasOwn(await readJson(output.path), "rpcUrl"), false); + }); +}); + test("validator rejects the legacy parallel actor and explorer schema", () => { assert.throws( () => validateManifest(legacyManifest()), From ba393bbbc33cccf0e233cc3fe2ec4a1b964b6b36 Mon Sep 17 00:00:00 2001 From: golem Date: Fri, 21 Aug 2026 03:11:24 -0600 Subject: [PATCH 12/30] feat: bridge chain artifacts to typed web reads --- Makefile | 11 ++- tools/publish-web-manifest.mjs | 71 +++++++++++++++ tools/sync-web-artifacts.mjs | 68 ++++++++++++++ tools/test-sync-web-artifacts.mjs | 76 ++++++++++++++++ web/public/.gitkeep | 1 + web/src/config/chains.ts | 18 ++++ web/src/config/manifest.test.ts | 61 +++++++++++++ web/src/config/manifest.ts | 101 +++++++++++++++++++++ web/src/data/bankClient.test.ts | 143 +++++++++++++++++++++++++++++ web/src/data/bankClient.ts | 145 ++++++++++++++++++++++++++++++ web/src/generated/.gitkeep | 1 + web/src/types/dashboard.ts | 54 +++++++++++ 12 files changed, 749 insertions(+), 1 deletion(-) create mode 100644 tools/publish-web-manifest.mjs create mode 100644 tools/sync-web-artifacts.mjs create mode 100644 tools/test-sync-web-artifacts.mjs create mode 100644 web/public/.gitkeep create mode 100644 web/src/config/chains.ts create mode 100644 web/src/config/manifest.test.ts create mode 100644 web/src/config/manifest.ts create mode 100644 web/src/data/bankClient.test.ts create mode 100644 web/src/data/bankClient.ts create mode 100644 web/src/generated/.gitkeep create mode 100644 web/src/types/dashboard.ts diff --git a/Makefile b/Makefile index 1cbaa49..b65219b 100644 --- a/Makefile +++ b/Makefile @@ -4,7 +4,7 @@ SHELL := /bin/bash RPC_LOCAL := http://127.0.0.1:8545 ANVIL_OWNER := 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 -.PHONY: doctor setup verify deploy-v1 seed-v1 check-state test-finalize-manifest +.PHONY: doctor setup verify deploy-v1 seed-v1 check-state test-finalize-manifest sync-abis publish-web-manifest sync-artifacts sync-artifacts-check doctor: @./tools/doctor.sh setup: @@ -23,6 +23,15 @@ verify: @npm --prefix web run build test-finalize-manifest: @node tools/test-finalize-manifest.mjs +sync-abis: + @node tools/sync-web-artifacts.mjs +publish-web-manifest: + @node tools/publish-web-manifest.mjs +sync-artifacts: sync-abis publish-web-manifest +sync-artifacts-check: + @node tools/test-sync-web-artifacts.mjs + @node tools/sync-web-artifacts.mjs + @node tools/sync-web-artifacts.mjs --check deploy-v1: @node tools/finalize-manifest.mjs preflight-deploy anvil @SCRIPT_SENDER=$(ANVIL_OWNER) DEPLOYMENT_MANIFEST_PATH=deployments/pending.json npm_config_offline=true forge script script/DeployV1.s.sol:DeployV1 --rpc-url $(RPC_LOCAL) --sender $(ANVIL_OWNER) --broadcast --force diff --git a/tools/publish-web-manifest.mjs b/tools/publish-web-manifest.mjs new file mode 100644 index 0000000..b79ff5f --- /dev/null +++ b/tools/publish-web-manifest.mjs @@ -0,0 +1,71 @@ +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const requiredFields = ["schemaVersion", "network", "chainId", "deploymentBlock", "token", "proxy", "implementation", "owner", "actors"]; +const optionalFields = ["rpcUrl", "explorerBaseUrl"]; +const zeroAddress = "0x0000000000000000000000000000000000000000"; +const secretMarker = /(?:private[_ -]?key|mnemonic|secret|password|credential|api[_ -]?key)|0x[a-fA-F0-9]{64}/i; + +export async function publishManifest({ + activePath = resolve(repositoryRoot, "deployments/active.json"), + outputPath = resolve(repositoryRoot, "web/public/deployment.json"), +} = {}) { + let contents; + try { contents = await readFile(activePath, "utf8"); } catch { throw new Error(`active manifest is missing at ${activePath}`); } + let manifest; + try { manifest = JSON.parse(contents); } catch { throw new Error("active manifest contains invalid JSON"); } + validatePublicManifest(manifest); + await mkdir(dirname(outputPath), { recursive: true }); + await writeFile(outputPath, `${JSON.stringify(manifest, null, 2)}\n`); + return manifest; +} + +export function validatePublicManifest(manifest) { + if (!isRecord(manifest)) throw new Error("manifest must be an object"); + 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.includes(field) && !optionalFields.includes(field)) throw new Error(`manifest contains unknown field ${field}`); + rejectSecrets(manifest); + if (manifest.schemaVersion !== 1) throw new Error("manifest schemaVersion must be 1"); + if (manifest.network !== "anvil" && manifest.network !== "baseSepolia") throw new Error("manifest network is unsupported"); + if (manifest.chainId !== (manifest.network === "anvil" ? 31337 : 84532)) throw new Error("manifest chainId does not match network"); + if (!Number.isSafeInteger(manifest.deploymentBlock) || manifest.deploymentBlock < 1) throw new Error("manifest deploymentBlock must be at least 1"); + for (const field of ["token", "proxy", "implementation", "owner"]) assertAddress(manifest[field], field); + assertActors(manifest.actors); + for (const field of optionalFields) if (Object.hasOwn(manifest, field)) assertPublicUrl(manifest[field], field); +} + +function isRecord(value) { return typeof value === "object" && value !== null && !Array.isArray(value); } +function assertAddress(value, field) { + if (typeof value !== "string" || !/^0x[0-9a-fA-F]{40}$/.test(value) || value.toLowerCase() === zeroAddress) throw new Error(`manifest ${field} must be a nonzero address`); +} +function assertActors(value) { + if (!Array.isArray(value) || value.length === 0) throw new Error("manifest actors must be a nonempty array"); + const labels = new Set(); const addresses = new Set(); + for (const [index, actor] of value.entries()) { + 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"); + assertAddress(actor.address, `actors[${index}].address`); + const normalized = actor.address.toLowerCase(); + if (addresses.has(normalized)) throw new Error("manifest actors must be unique"); + labels.add(actor.label); addresses.add(normalized); + } +} +function assertPublicUrl(value, field) { + if (typeof value !== "string") throw new Error(`manifest ${field} must be a public URL`); + let 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`); +} +function rejectSecrets(value, path = "") { + if (typeof value === "string") { if (secretMarker.test(value)) throw new Error(`manifest contains prohibited secret material at ${path}`); return; } + if (Array.isArray(value)) { value.forEach((item, index) => rejectSecrets(item, `${path}[${index}]`)); return; } + if (!isRecord(value)) return; + for (const [key, nested] of Object.entries(value)) { rejectSecrets(key, path); rejectSecrets(nested, path ? `${path}.${key}` : key); } +} + +if (process.argv[1] && pathToFileURL(resolve(process.argv[1])).href === import.meta.url) { + if (process.argv.length !== 2) { console.error("usage: publish-web-manifest.mjs"); process.exitCode = 1; } + else publishManifest().catch((error) => { console.error(error.message); process.exitCode = 1; }); +} diff --git a/tools/sync-web-artifacts.mjs b/tools/sync-web-artifacts.mjs new file mode 100644 index 0000000..5cd871e --- /dev/null +++ b/tools/sync-web-artifacts.mjs @@ -0,0 +1,68 @@ +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const bankRequirements = { + functions: ["contractVersion", "paused", "asset", "owner", "totalLiabilities", "balanceOf"], + events: ["Deposited", "Withdrawn", "Paused", "Unpaused", "OwnershipTransferred", "Upgraded"], +}; +const tokenRequirements = { functions: ["balanceOf"], events: [] }; + +export function extractAbi(artifact, contractName) { + if (!artifact || typeof artifact !== "object" || !Array.isArray(artifact.abi)) { + throw new Error(`${contractName} artifact must contain an ABI`); + } + const requirements = contractName === "BankV1" ? bankRequirements : contractName === "MockUSDC" ? tokenRequirements : null; + if (!requirements) throw new Error(`unsupported contract artifact ${contractName}`); + for (const [type, names] of Object.entries(requirements)) { + for (const name of names) { + if (!artifact.abi.some((entry) => entry && entry.type === type.slice(0, -1) && entry.name === name)) { + throw new Error(`${contractName} ABI is missing required ${type.slice(0, -1)} ${name}`); + } + } + } + return artifact.abi; +} + +export function renderContractsModule(bankV1Abi, mockUsdcAbi) { + return `/* This file is generated by tools/sync-web-artifacts.mjs. Do not edit. */\n\nexport const bankV1Abi = ${JSON.stringify(bankV1Abi, null, 2)} as const;\n\nexport const mockUsdcAbi = ${JSON.stringify(mockUsdcAbi, null, 2)} as const;\n`; +} + +export async function syncArtifacts({ + bankArtifactPath = resolve(repositoryRoot, "out/BankV1.sol/BankV1.json"), + tokenArtifactPath = resolve(repositoryRoot, "out/MockUSDC.sol/MockUSDC.json"), + outputPath = resolve(repositoryRoot, "web/src/generated/contracts.ts"), + check = false, +} = {}) { + const [bankArtifact, tokenArtifact] = await Promise.all([ + readArtifact(bankArtifactPath, "BankV1"), + readArtifact(tokenArtifactPath, "MockUSDC"), + ]); + const contents = renderContractsModule(extractAbi(bankArtifact, "BankV1"), extractAbi(tokenArtifact, "MockUSDC")); + if (check) { + let existing; + try { existing = await readFile(outputPath, "utf8"); } catch { throw new Error("generated contracts module is stale or missing"); } + if (existing !== contents) throw new Error("generated contracts module is stale"); + return contents; + } + await mkdir(dirname(outputPath), { recursive: true }); + await writeFile(outputPath, contents); + return contents; +} + +async function readArtifact(path, contractName) { + let contents; + try { contents = await readFile(path, "utf8"); } catch { throw new Error(`${contractName} artifact is missing at ${path}`); } + try { return JSON.parse(contents); } catch { throw new Error(`${contractName} artifact contains invalid JSON`); } +} + +if (process.argv[1] && pathToFileURL(resolve(process.argv[1])).href === import.meta.url) { + const check = process.argv.slice(2).every((argument) => argument === "--check") && process.argv.includes("--check"); + if (process.argv.slice(2).some((argument) => argument !== "--check")) { + console.error("usage: sync-web-artifacts.mjs [--check]"); + process.exitCode = 1; + } else { + syncArtifacts({ check }).catch((error) => { console.error(error.message); process.exitCode = 1; }); + } +} diff --git a/tools/test-sync-web-artifacts.mjs b/tools/test-sync-web-artifacts.mjs new file mode 100644 index 0000000..16ee7dd --- /dev/null +++ b/tools/test-sync-web-artifacts.mjs @@ -0,0 +1,76 @@ +import assert from "node:assert/strict"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { extractAbi, renderContractsModule, syncArtifacts } from "./sync-web-artifacts.mjs"; +import { publishManifest } from "./publish-web-manifest.mjs"; + +const address = "0x5FbDB2315678afecb367f032d93F642f64180aa3"; +const owner = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"; +const bankAbi = [ + ...["contractVersion", "paused", "asset", "owner", "totalLiabilities", "balanceOf"].map((name) => ({ type: "function", name, inputs: [], outputs: [], stateMutability: "view" })), + ...["Deposited", "Withdrawn", "Paused", "Unpaused", "OwnershipTransferred", "Upgraded"].map((name) => ({ type: "event", name, inputs: [], anonymous: false })), +]; +const tokenAbi = [{ type: "function", name: "balanceOf", inputs: [{ name: "account", type: "address" }], outputs: [{ type: "uint256" }], stateMutability: "view" }]; +const manifest = { + schemaVersion: 1, network: "anvil", chainId: 31337, deploymentBlock: 3, + rpcUrl: "http://127.0.0.1:8545", token: address, proxy: address, + implementation: "0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512", owner, + actors: [{ label: "owner", address: owner }], +}; + +async function withFixture(run) { + const root = await mkdtemp(join(tmpdir(), "uups-artifacts-")); + try { await run(root); } finally { await rm(root, { recursive: true, force: true }); } +} + +async function expectRejects(action, pattern) { + await assert.rejects(action, pattern); +} + +await withFixture(async (root) => { + const bank = join(root, "BankV1.json"); + const token = join(root, "MockUSDC.json"); + const output = join(root, "contracts.ts"); + + // Catches a production bridge that silently produces an ABI module from incomplete artifacts. + await expectRejects(() => syncArtifacts({ bankArtifactPath: bank, tokenArtifactPath: token, outputPath: output }), /BankV1 artifact/i); + await writeFile(bank, JSON.stringify({ abi: bankAbi })); + await expectRejects(() => syncArtifacts({ bankArtifactPath: bank, tokenArtifactPath: token, outputPath: output }), /MockUSDC artifact/i); + await writeFile(token, JSON.stringify({ abi: tokenAbi })); + + // Catches a bridge that exports an ABI missing a V1 contract function or event. + assert.throws(() => extractAbi({ abi: bankAbi.filter((entry) => entry.name !== "Withdrawn") }, "BankV1"), /Withdrawn/); + const rendered = renderContractsModule(extractAbi({ abi: bankAbi }, "BankV1"), extractAbi({ abi: tokenAbi }, "MockUSDC")); + assert.match(rendered, /export const bankV1Abi = .* as const;/s); + assert.match(rendered, /export const mockUsdcAbi = .* as const;/s); + assert.doesNotMatch(rendered, /bankV2Abi|BankV2/); + + // Catches a bridge that requires an active manifest or reads V2 as part of ABI generation. + await syncArtifacts({ bankArtifactPath: bank, tokenArtifactPath: token, outputPath: output }); + assert.equal(await readFile(output, "utf8"), rendered); + await writeFile(output, "stale\n"); + await expectRejects(() => syncArtifacts({ bankArtifactPath: bank, tokenArtifactPath: token, outputPath: output, check: true }), /stale/i); +}); + +await withFixture(async (root) => { + const activePath = join(root, "active.json"); + const outputPath = join(root, "deployment.json"); + await writeFile(activePath, JSON.stringify(manifest)); + + // Catches a publisher that copies pending, secret-bearing, or malformed live state into the browser bundle. + await publishManifest({ activePath, outputPath }); + assert.deepEqual(JSON.parse(await readFile(outputPath, "utf8")), manifest); + for (const invalid of [ + { ...manifest, deploymentBlock: 0 }, + { ...manifest, rpcUrl: "https://token@example.test" }, + { ...manifest, rpcUrl: "https://example.test/?secret=value" }, + { ...manifest, privateKey: "not-public" }, + { ...manifest, proxy: "0x0000000000000000000000000000000000000000" }, + ]) { + await writeFile(activePath, JSON.stringify(invalid)); + await expectRejects(() => publishManifest({ activePath, outputPath }), /manifest|deployment|credential|secret|address/i); + } +}); + +console.log("artifact bridge tests passed"); diff --git a/web/public/.gitkeep b/web/public/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/web/public/.gitkeep @@ -0,0 +1 @@ + diff --git a/web/src/config/chains.ts b/web/src/config/chains.ts new file mode 100644 index 0000000..e3a059f --- /dev/null +++ b/web/src/config/chains.ts @@ -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) }); +} diff --git a/web/src/config/manifest.test.ts b/web/src/config/manifest.test.ts new file mode 100644 index 0000000..58fee32 --- /dev/null +++ b/web/src/config/manifest.test.ts @@ -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); + }); +}); diff --git a/web/src/config/manifest.ts b/web/src/config/manifest.ts new file mode 100644 index 0000000..50d70b3 --- /dev/null +++ b/web/src/config/manifest.ts @@ -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 { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function assertSchema(manifest: Record): 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(); + const addresses = new Set(); + 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, 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; +} diff --git a/web/src/data/bankClient.test.ts b/web/src/data/bankClient.test.ts new file mode 100644 index 0000000..101805e --- /dev/null +++ b/web/src/data/bankClient.test.ts @@ -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([[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"); + }); +}); diff --git a/web/src/data/bankClient.ts b/web/src/data/bankClient.ts new file mode 100644 index 0000000..df07d4b --- /dev/null +++ b/web/src/data/bankClient.ts @@ -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; + getCode(call: Readonly<{ address: Address }>): Promise; + getBlockNumber(): Promise; + readContract(call: ContractCall): Promise; + getStorageAt(call: StorageCall): Promise; + getLogs(call: LogCall): Promise; +} + +export class ViemBankReader implements BankReader { + constructor(private readonly client: PublicClient) {} + + getChainId(): Promise { return this.client.getChainId(); } + getCode({ address }: Readonly<{ address: Address }>): Promise { return this.client.getCode({ address }); } + getBlockNumber(): Promise { return this.client.getBlockNumber(); } + readContract({ address, abi, functionName, args, blockNumber }: ContractCall): Promise { + return this.client.readContract({ address, abi, functionName, args, blockNumber } as never); + } + getStorageAt({ address, slot, blockNumber }: StorageCall): Promise { return this.client.getStorageAt({ address, slot, blockNumber }); } + getLogs({ address, fromBlock, toBlock }: LogCall): Promise { return this.client.getLogs({ address, fromBlock, toBlock }); } +} + +export async function loadDashboardSnapshot(reader: BankReader, manifest: DeploymentManifest): Promise { + 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 { + 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; + 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; + } +} diff --git a/web/src/generated/.gitkeep b/web/src/generated/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/web/src/generated/.gitkeep @@ -0,0 +1 @@ + diff --git a/web/src/types/dashboard.ts b/web/src/types/dashboard.ts new file mode 100644 index 0000000..928ee19 --- /dev/null +++ b/web/src/types/dashboard.ts @@ -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 + | Readonly + | Readonly + | Readonly + | Readonly + | Readonly; + +export type DecodeDiagnostic = Readonly; + +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[]; +}>; From 9ce884327918e6a43a6db9d2665e62d2b2cb39eb Mon Sep 17 00:00:00 2001 From: golem Date: Fri, 21 Aug 2026 03:19:22 -0600 Subject: [PATCH 13/30] fix: validate bridged ABI and malformed logs --- tools/sync-web-artifacts.mjs | 48 ++++++++++++++++++++++++------- tools/test-sync-web-artifacts.mjs | 23 +++++++++++++-- web/src/data/bankClient.test.ts | 11 +++++++ web/src/data/bankClient.ts | 15 ++++++---- 4 files changed, 79 insertions(+), 18 deletions(-) diff --git a/tools/sync-web-artifacts.mjs b/tools/sync-web-artifacts.mjs index 5cd871e..912e686 100644 --- a/tools/sync-web-artifacts.mjs +++ b/tools/sync-web-artifacts.mjs @@ -3,11 +3,21 @@ import { dirname, resolve } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); -const bankRequirements = { - functions: ["contractVersion", "paused", "asset", "owner", "totalLiabilities", "balanceOf"], - events: ["Deposited", "Withdrawn", "Paused", "Unpaused", "OwnershipTransferred", "Upgraded"], -}; -const tokenRequirements = { functions: ["balanceOf"], events: [] }; +const bankRequirements = [ + functionSignature("contractVersion", [], ["uint256"], "pure"), + functionSignature("paused", [], ["bool"], "view"), + functionSignature("asset", [], ["address"], "view"), + functionSignature("owner", [], ["address"], "view"), + functionSignature("totalLiabilities", [], ["uint256"], "view"), + functionSignature("balanceOf", [parameter("account", "address")], ["uint256"], "view"), + eventSignature("Deposited", [parameter("account", "address", true), parameter("amount", "uint256", false)]), + eventSignature("Withdrawn", [parameter("account", "address", true), parameter("amount", "uint256", false)]), + eventSignature("Paused", [parameter("account", "address", false)]), + eventSignature("Unpaused", [parameter("account", "address", false)]), + eventSignature("OwnershipTransferred", [parameter("previousOwner", "address", true), parameter("newOwner", "address", true)]), + eventSignature("Upgraded", [parameter("implementation", "address", true)]), +]; +const tokenRequirements = [functionSignature("balanceOf", [parameter("account", "address")], ["uint256"], "view")]; export function extractAbi(artifact, contractName) { if (!artifact || typeof artifact !== "object" || !Array.isArray(artifact.abi)) { @@ -15,16 +25,34 @@ export function extractAbi(artifact, contractName) { } const requirements = contractName === "BankV1" ? bankRequirements : contractName === "MockUSDC" ? tokenRequirements : null; if (!requirements) throw new Error(`unsupported contract artifact ${contractName}`); - for (const [type, names] of Object.entries(requirements)) { - for (const name of names) { - if (!artifact.abi.some((entry) => entry && entry.type === type.slice(0, -1) && entry.name === name)) { - throw new Error(`${contractName} ABI is missing required ${type.slice(0, -1)} ${name}`); - } + for (const requirement of requirements) { + const entries = artifact.abi.filter((entry) => entry && entry.type === requirement.type && entry.name === requirement.name); + if (entries.length === 0) throw new Error(`${contractName} ABI is missing required ${requirement.type} ${requirement.name}`); + if (!entries.some((entry) => matchesSignature(entry, requirement))) { + throw new Error(`${contractName} ABI has an invalid signature for ${requirement.name}`); } } return artifact.abi; } +function parameter(name, type, indexed) { return { name, type, ...(indexed === undefined ? {} : { indexed }) }; } +function functionSignature(name, inputs, outputs, stateMutability) { + return { type: "function", name, inputs, outputs: outputs.map((type) => parameter("", type)), stateMutability }; +} +function eventSignature(name, inputs) { return { type: "event", name, inputs, anonymous: false }; } +function matchesSignature(entry, requirement) { + return entry.stateMutability === requirement.stateMutability + && entry.anonymous === requirement.anonymous + && matchesParameters(entry.inputs, requirement.inputs) + && (requirement.outputs === undefined || matchesParameters(entry.outputs, requirement.outputs)); +} +function matchesParameters(actual, expected) { + return Array.isArray(actual) && actual.length === expected.length && actual.every((parameter, index) => { + const required = expected[index]; + return parameter && parameter.name === required.name && parameter.type === required.type && parameter.indexed === required.indexed; + }); +} + export function renderContractsModule(bankV1Abi, mockUsdcAbi) { return `/* This file is generated by tools/sync-web-artifacts.mjs. Do not edit. */\n\nexport const bankV1Abi = ${JSON.stringify(bankV1Abi, null, 2)} as const;\n\nexport const mockUsdcAbi = ${JSON.stringify(mockUsdcAbi, null, 2)} as const;\n`; } diff --git a/tools/test-sync-web-artifacts.mjs b/tools/test-sync-web-artifacts.mjs index 16ee7dd..a5fdc4a 100644 --- a/tools/test-sync-web-artifacts.mjs +++ b/tools/test-sync-web-artifacts.mjs @@ -8,10 +8,20 @@ import { publishManifest } from "./publish-web-manifest.mjs"; const address = "0x5FbDB2315678afecb367f032d93F642f64180aa3"; const owner = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"; const bankAbi = [ - ...["contractVersion", "paused", "asset", "owner", "totalLiabilities", "balanceOf"].map((name) => ({ type: "function", name, inputs: [], outputs: [], stateMutability: "view" })), - ...["Deposited", "Withdrawn", "Paused", "Unpaused", "OwnershipTransferred", "Upgraded"].map((name) => ({ type: "event", name, inputs: [], anonymous: false })), + { type: "function", name: "contractVersion", inputs: [], outputs: [{ name: "", type: "uint256" }], stateMutability: "pure" }, + { type: "function", name: "paused", inputs: [], outputs: [{ name: "", type: "bool" }], stateMutability: "view" }, + { type: "function", name: "asset", inputs: [], outputs: [{ name: "", type: "address" }], stateMutability: "view" }, + { type: "function", name: "owner", inputs: [], outputs: [{ name: "", type: "address" }], stateMutability: "view" }, + { type: "function", name: "totalLiabilities", inputs: [], outputs: [{ name: "", type: "uint256" }], stateMutability: "view" }, + { type: "function", name: "balanceOf", inputs: [{ name: "account", type: "address" }], outputs: [{ name: "", type: "uint256" }], stateMutability: "view" }, + { type: "event", name: "Deposited", inputs: [{ name: "account", type: "address", indexed: true }, { name: "amount", type: "uint256", indexed: false }], anonymous: false }, + { type: "event", name: "Withdrawn", inputs: [{ name: "account", type: "address", indexed: true }, { name: "amount", type: "uint256", indexed: false }], anonymous: false }, + { type: "event", name: "Paused", inputs: [{ name: "account", type: "address", indexed: false }], anonymous: false }, + { type: "event", name: "Unpaused", inputs: [{ name: "account", type: "address", indexed: false }], anonymous: false }, + { type: "event", name: "OwnershipTransferred", inputs: [{ name: "previousOwner", type: "address", indexed: true }, { name: "newOwner", type: "address", indexed: true }], anonymous: false }, + { type: "event", name: "Upgraded", inputs: [{ name: "implementation", type: "address", indexed: true }], anonymous: false }, ]; -const tokenAbi = [{ type: "function", name: "balanceOf", inputs: [{ name: "account", type: "address" }], outputs: [{ type: "uint256" }], stateMutability: "view" }]; +const tokenAbi = [{ type: "function", name: "balanceOf", inputs: [{ name: "account", type: "address" }], outputs: [{ name: "", type: "uint256" }], stateMutability: "view" }]; const manifest = { schemaVersion: 1, network: "anvil", chainId: 31337, deploymentBlock: 3, rpcUrl: "http://127.0.0.1:8545", token: address, proxy: address, @@ -41,6 +51,13 @@ await withFixture(async (root) => { // Catches a bridge that exports an ABI missing a V1 contract function or event. assert.throws(() => extractAbi({ abi: bankAbi.filter((entry) => entry.name !== "Withdrawn") }, "BankV1"), /Withdrawn/); + // Catches generated calls/log decoders accepting ABI entries with the right name but wrong wire signature. + const wrongFunction = structuredClone(bankAbi); + wrongFunction.find((entry) => entry.name === "balanceOf").outputs = []; + assert.throws(() => extractAbi({ abi: wrongFunction }, "BankV1"), /signature.*balanceOf/i); + const wrongEvent = structuredClone(bankAbi); + wrongEvent.find((entry) => entry.name === "Deposited").inputs[0].indexed = false; + assert.throws(() => extractAbi({ abi: wrongEvent }, "BankV1"), /signature.*Deposited/i); const rendered = renderContractsModule(extractAbi({ abi: bankAbi }, "BankV1"), extractAbi({ abi: tokenAbi }, "MockUSDC")); assert.match(rendered, /export const bankV1Abi = .* as const;/s); assert.match(rendered, /export const mockUsdcAbi = .* as const;/s); diff --git a/web/src/data/bankClient.test.ts b/web/src/data/bankClient.test.ts index 101805e..ed664bb 100644 --- a/web/src/data/bankClient.test.ts +++ b/web/src/data/bankClient.test.ts @@ -135,6 +135,17 @@ describe("loadDashboardSnapshot", () => { expect(snapshot.diagnostics).toEqual([expect.objectContaining({ blockNumber: 4n, logIndex: 2, message: expect.any(String) })]); }); + it("diagnoses proxy logs with malformed topics or payload data", async () => { + // Catches payload-shape validation skipping malformed proxy logs before the diagnostic boundary. + const reader = new ReaderDouble(); + const valid = depositedLog(4n, 1, owner, 7n); + reader.logs = [valid, { ...valid, logIndex: 2, topics: "not-topics" }, { ...valid, logIndex: 3, data: "not-hex" }, { ...valid, logIndex: 4, data: undefined }]; + const snapshot = await loadDashboardSnapshot(reader, manifest); + + expect(snapshot.activity).toHaveLength(1); + expect(snapshot.diagnostics.map((diagnostic) => diagnostic.logIndex)).toEqual([2, 3, 4]); + }); + 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"); diff --git a/web/src/data/bankClient.ts b/web/src/data/bankClient.ts index df07d4b..8b3673c 100644 --- a/web/src/data/bankClient.ts +++ b/web/src/data/bankClient.ts @@ -106,9 +106,10 @@ function decodeActivity(logs: readonly unknown[], proxy: Address): { activity: r const activity: Activity[] = []; const diagnostics: DecodeDiagnostic[] = []; for (const log of logs) { - if (!isLog(log) || !sameAddress(log.address, proxy)) continue; + if (!isLogIdentity(log) || !sameAddress(log.address, proxy)) continue; 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 next = decodedActivity(decoded.eventName, decoded.args, identity); if (next) activity.push(next); @@ -121,14 +122,18 @@ function decodeActivity(logs: readonly unknown[], proxy: Address): { activity: r return { activity, diagnostics }; } -function isLog(value: unknown): value is Readonly<{ address: Address; blockNumber: bigint; logIndex: number; transactionHash: Hex; data: Hex; topics: readonly Hex[] }> { +function isLogIdentity(value: unknown): value is Readonly<{ address: Address; blockNumber: bigint; logIndex: number; transactionHash: Hex; data?: unknown; topics?: unknown }> { 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); + && typeof (value as { transactionHash?: unknown }).transactionHash === "string"; +} + +function isLogPayload(value: Readonly<{ data?: unknown; topics?: unknown }>): value is Readonly<{ data: Hex; topics: readonly Hex[] }> { + return typeof value.data === "string" && /^0x[0-9a-fA-F]*$/.test(value.data) + && Array.isArray(value.topics) && value.topics.length > 0 + && value.topics.every((topic) => typeof topic === "string" && /^0x[0-9a-fA-F]*$/.test(topic)); } function decodedActivity(eventName: string, args: unknown, identity: Readonly<{ blockNumber: bigint; logIndex: number; transactionHash: Hex }>): Activity | undefined { From 10507e769da54bda432cfcdbc9b458375fcd2bca Mon Sep 17 00:00:00 2001 From: golem Date: Fri, 21 Aug 2026 03:35:45 -0600 Subject: [PATCH 14/30] feat: add read-only bank operations console --- web/index.html | 1 + web/src/App.test.tsx | 191 ++++++++++++++++++++++++ web/src/App.tsx | 37 +++++ web/src/app.css | 109 ++++++++++++++ web/src/components/AccountTable.tsx | 27 ++++ web/src/components/AccountingGrid.tsx | 19 +++ web/src/components/ActivityTimeline.tsx | 69 +++++++++ web/src/components/ContractIdentity.tsx | 41 +++++ web/src/components/StatusHeader.tsx | 42 ++++++ web/src/components/TrustDisclosure.tsx | 14 ++ web/src/components/WarningBanner.tsx | 8 + web/src/components/format.test.ts | 35 +++++ web/src/components/format.ts | 44 ++++++ web/src/hooks/useBankDashboard.test.tsx | 173 +++++++++++++++++++++ web/src/hooks/useBankDashboard.ts | 118 +++++++++++++++ web/src/main.tsx | 34 +++++ web/src/test/setup.ts | 5 +- web/src/vite-env.d.ts | 1 + 18 files changed, 967 insertions(+), 1 deletion(-) create mode 100644 web/src/App.test.tsx create mode 100644 web/src/App.tsx create mode 100644 web/src/app.css create mode 100644 web/src/components/AccountTable.tsx create mode 100644 web/src/components/AccountingGrid.tsx create mode 100644 web/src/components/ActivityTimeline.tsx create mode 100644 web/src/components/ContractIdentity.tsx create mode 100644 web/src/components/StatusHeader.tsx create mode 100644 web/src/components/TrustDisclosure.tsx create mode 100644 web/src/components/WarningBanner.tsx create mode 100644 web/src/components/format.test.ts create mode 100644 web/src/components/format.ts create mode 100644 web/src/hooks/useBankDashboard.test.tsx create mode 100644 web/src/hooks/useBankDashboard.ts create mode 100644 web/src/main.tsx create mode 100644 web/src/vite-env.d.ts diff --git a/web/index.html b/web/index.html index d91f1f3..d5df951 100644 --- a/web/index.html +++ b/web/index.html @@ -7,5 +7,6 @@
+ diff --git a/web/src/App.test.tsx b/web/src/App.test.tsx new file mode 100644 index 0000000..6e5af60 --- /dev/null +++ b/web/src/App.test.tsx @@ -0,0 +1,191 @@ +import { render, screen } from "@testing-library/react"; +import type { Address, Hex } from "viem"; +import { describe, expect, it } from "vitest"; +import shellHtml from "../index.html?raw"; +import { App } from "./App"; +import type { DashboardState } from "./hooks/useBankDashboard"; +import type { DashboardSnapshot, DeploymentManifest } from "./types/dashboard"; + +const address = (digit: string) => `0x${digit.repeat(40)}` as Address; +const transactionHash = (digit: string) => `0x${digit.repeat(64)}` as Hex; + +const localManifest: DeploymentManifest = { + schemaVersion: 1, + network: "anvil", + chainId: 31337, + deploymentBlock: 3n, + rpcUrl: "http://127.0.0.1:8545", + token: address("1"), + proxy: address("2"), + implementation: address("3"), + owner: address("4"), + actors: [ + { label: "Alice", address: address("5") }, + { label: "Bob", address: address("6") }, + ], +}; + +const snapshot: DashboardSnapshot = { + blockNumber: 18n, + synchronizedAt: new Date("2026-08-21T10:00:00.000Z"), + version: 1, + paused: true, + asset: localManifest.token, + owner: localManifest.owner, + proxy: localManifest.proxy, + implementation: localManifest.implementation, + reserves: 1_500_000_000n, + liabilities: 1_400_000_000n, + surplus: 100_000_000n, + actors: [ + { label: "Alice", address: address("5"), balance: 900_000_000n }, + { label: "Bob", address: address("6"), balance: 500_000_000n }, + ], + activity: [ + { kind: "deposit", account: address("5"), amount: 1_000_000_000n, blockNumber: 4n, logIndex: 0, transactionHash: transactionHash("a") }, + { kind: "withdrawal", account: address("5"), amount: 100_000_000n, blockNumber: 5n, logIndex: 0, transactionHash: transactionHash("b") }, + { kind: "paused", account: address("4"), blockNumber: 6n, logIndex: 0, transactionHash: transactionHash("c") }, + { kind: "ownershipTransferred", previousOwner: address("7"), newOwner: address("4"), blockNumber: 3n, logIndex: 1, transactionHash: transactionHash("d") }, + { kind: "upgraded", implementation: address("3"), blockNumber: 3n, logIndex: 0, transactionHash: transactionHash("d") }, + ], + diagnostics: [{ blockNumber: 7n, logIndex: 2, transactionHash: transactionHash("e"), message: "Could not decode a proxy event log." }], +}; + +function ready(manifest = localManifest, value = snapshot): DashboardState { + return { status: "ready", manifest, snapshot: value }; +} + +describe("read-only operations console", () => { + it("loads the console entry point from the browser document", () => { + const document = new DOMParser().parseFromString(shellHtml, "text/html"); + expect(document.querySelector('script[type="module"]')?.getAttribute("src")).toBe("/src/main.tsx"); + }); + + it("keeps the exact educational funds warning and complete trust disclosure visible", () => { + render(); + + expect(screen.getByText("Educational demo — mock token — never use real funds.")).toBeTruthy(); + expect(screen.getByText(/educational and unaudited/i)).toBeTruthy(); + expect(screen.getByText(/owner can pause customer actions and install arbitrary future logic/i)).toBeTruthy(); + expect(screen.getByText(/UUPS mistakes can corrupt state or permanently brick upgradeability/i)).toBeTruthy(); + expect(screen.getByText(/professional audits, operational key controls, multisig or timelocked governance, incident procedures, legal advice, and jurisdiction-specific compliance/i)).toBeTruthy(); + }); + + it("renders network, synchronization, lifecycle, pause, and V1 status", () => { + render(); + + expect(screen.getByText("Local Anvil")).toBeTruthy(); + expect(screen.getByText("Ready")).toBeTruthy(); + expect(screen.getByText("Block 18")).toBeTruthy(); + expect(screen.getByText("Paused")).toBeTruthy(); + expect(screen.getByText("Version 1")).toBeTruthy(); + expect(screen.getByText(/Aug 21, 2026/)).toBeTruthy(); + }); + + it("renders accounting, contract identity, and local tracked accounts", () => { + render(); + + expect(screen.getByText("1,500.00 mUSDC")).toBeTruthy(); + expect(screen.getByText("1,400.00 mUSDC")).toBeTruthy(); + expect(screen.getByText("100.00 mUSDC")).toBeTruthy(); + expect(screen.getByText("107.14%")).toBeTruthy(); + expect(screen.getByText("Proxy")).toBeTruthy(); + expect(screen.getByText("Implementation")).toBeTruthy(); + expect(screen.getByText("Token")).toBeTruthy(); + expect(screen.getByText("Owner")).toBeTruthy(); + expect(screen.getByText("Deployment block")).toBeTruthy(); + expect(screen.getByText("Alice")).toBeTruthy(); + expect(screen.getByText("Bob")).toBeTruthy(); + expect(screen.getByText("900.00 mUSDC")).toBeTruthy(); + expect(screen.getByText("500.00 mUSDC")).toBeTruthy(); + }); + + it("renders valid V1 activity newest first beside an isolated decode warning", () => { + render(); + + const timeline = screen.getByLabelText("Proxy activity"); + expect(timeline.textContent).toContain("Deposited"); + expect(timeline.textContent).toContain("Withdrawn"); + expect(timeline.textContent).toContain("Paused by"); + expect(timeline.textContent).toContain("Ownership transferred"); + expect(timeline.textContent).toContain("Implementation upgraded"); + expect(timeline.textContent).toContain("Block 7"); + expect(timeline.textContent).toContain("Could not decode a proxy event log."); + expect(timeline.textContent?.indexOf("Paused by")).toBeLessThan(timeline.textContent?.indexOf("Withdrawn") ?? 0); + expect(timeline.textContent).toContain("0xcccc…cccc"); + }); + + it("uses validated Base Sepolia explorer links and public shortened account labels", () => { + const baseManifest: DeploymentManifest = { + ...localManifest, + network: "baseSepolia", + chainId: 84532, + rpcUrl: "https://sepolia.base.org", + explorerBaseUrl: "https://sepolia.basescan.org", + }; + render(); + + expect(screen.getByText("Base Sepolia")).toBeTruthy(); + expect(screen.queryByText("Alice")).toBeNull(); + expect(screen.getAllByText("0x5555…5555").length).toBeGreaterThan(0); + const proxyLink = screen.getByRole("link", { name: /proxy.*0x2222…2222/i }); + expect(proxyLink.getAttribute("href")).toBe(`https://sepolia.basescan.org/address/${snapshot.proxy}`); + const transactionLink = screen.getByRole("link", { name: /transaction 0xcccc…cccc/i }); + expect(transactionLink.getAttribute("href")).toBe(`https://sepolia.basescan.org/tx/${transactionHash("c")}`); + }); + + it("does not create explorer links for local Anvil", () => { + render(); + expect(screen.queryAllByRole("link")).toHaveLength(0); + }); + + it("shows No deposits instead of dividing zero liabilities", () => { + render(); + expect(screen.getByText("No deposits")).toBeTruthy(); + }); + + it("distinguishes stale and disconnected conditions without hiding the persistent warnings", () => { + const { rerender } = render(); + expect(screen.getByText("Stale")).toBeTruthy(); + expect(screen.getByText(/RPC timeout/)).toBeTruthy(); + expect(screen.getByText("1,500.00 mUSDC")).toBeTruthy(); + + rerender(); + expect(screen.getAllByText("Disconnected")).toHaveLength(2); + expect(screen.getByText(/connection refused/)).toBeTruthy(); + expect(screen.getByText("Educational demo — mock token — never use real funds.")).toBeTruthy(); + expect(screen.getByText(/educational and unaudited/i)).toBeTruthy(); + }); + + it("renders explanatory invalid-manifest and chain-mismatch terminal screens", () => { + const { rerender } = render(); + expect(screen.getAllByText("Invalid manifest")).toHaveLength(2); + expect(screen.getByText(/missing required field proxy/)).toBeTruthy(); + + rerender(); + expect(screen.getAllByText("Chain mismatch")).toHaveLength(2); + expect(screen.getByText(/84532.*31337/)).toBeTruthy(); + }); + + it("exposes no forms, buttons, wallet connection, signing, or transaction controls", () => { + const { container } = render(); + expect(container.querySelector("button")).toBeNull(); + expect(container.querySelector("form")).toBeNull(); + expect(container.textContent).not.toMatch(/connect wallet|sign transaction|submit transaction|deposit funds|withdraw funds/i); + }); +}); diff --git a/web/src/App.tsx b/web/src/App.tsx new file mode 100644 index 0000000..30534df --- /dev/null +++ b/web/src/App.tsx @@ -0,0 +1,37 @@ +import { AccountingGrid } from "./components/AccountingGrid"; +import { AccountTable } from "./components/AccountTable"; +import { ActivityTimeline } from "./components/ActivityTimeline"; +import { ContractIdentity } from "./components/ContractIdentity"; +import { StatusHeader } from "./components/StatusHeader"; +import { TrustDisclosure } from "./components/TrustDisclosure"; +import { WarningBanner } from "./components/WarningBanner"; +import type { DashboardState } from "./hooks/useBankDashboard"; + +export function App({ dashboard }: { dashboard: DashboardState }) { + const hasSnapshot = dashboard.status === "ready" || dashboard.status === "stale"; + return ( +
+ +
+ + {hasSnapshot ? ( +
+ + + + +
+ ) : ( +
+

State unavailable

+

+ {dashboard.status === "loading" ? "Synchronizing deployment" : dashboard.status === "invalid-manifest" ? "Invalid manifest" : dashboard.status === "chain-mismatch" ? "Chain mismatch" : "Disconnected"} +

+

{dashboard.status === "loading" ? "Validating the public deployment manifest and reconciling a block-consistent snapshot." : "Contract values remain unknown until the configuration and RPC endpoint can be verified."}

+
+ )} +
+ +
+ ); +} diff --git a/web/src/app.css b/web/src/app.css new file mode 100644 index 0000000..c036fe7 --- /dev/null +++ b/web/src/app.css @@ -0,0 +1,109 @@ +:root { + color: #f5f7f3; + background: #0b0d0c; + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + font-synthesis: none; + --canvas: #0b0d0c; + --surface: #121513; + --surface-raised: #181c19; + --line: #303630; + --muted: #aab3aa; + --green: #5ff59b; + --amber: #ffcf5a; + --red: #ff716c; + --white: #f5f7f3; +} + +* { box-sizing: border-box; } +html { background: var(--canvas); } +body { margin: 0; min-width: 320px; min-height: 100vh; background: radial-gradient(circle at 75% 0%, #18251d 0, transparent 32rem), var(--canvas); } +body::before { content: ""; position: fixed; inset: 0; pointer-events: none; opacity: .2; background-image: linear-gradient(rgba(255,255,255,.025) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,.025) 1px, transparent 1px); background-size: 32px 32px; } +a { color: var(--green); text-underline-offset: .2em; } +a:hover { color: var(--white); } +a:focus-visible, abbr:focus-visible { outline: 3px solid var(--amber); outline-offset: 4px; border-radius: 2px; } +abbr[title] { text-decoration-color: #758078; text-underline-offset: .2em; cursor: help; } +code { font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace; } + +.app-shell { position: relative; width: min(1440px, 100%); margin: 0 auto; padding: 1rem clamp(1rem, 3vw, 3rem) 3rem; } +.warning-banner { position: sticky; z-index: 10; top: .75rem; display: flex; align-items: center; justify-content: center; gap: .65rem; margin-bottom: clamp(2rem, 5vw, 4.5rem); padding: .8rem 1rem; border: 1px solid #806e32; border-radius: 4px; color: #fff3c5; background: rgba(66, 52, 10, .96); box-shadow: 0 12px 40px rgba(0,0,0,.32); font-size: .88rem; letter-spacing: .02em; } +main { display: grid; gap: 1.25rem; } +.status-header { display: grid; grid-template-columns: minmax(0, 1.3fr) minmax(34rem, 1fr); column-gap: 3rem; align-items: end; padding: 0 0 1.6rem; border-bottom: 1px solid var(--line); } +.eyebrow { margin: 0 0 .55rem; color: var(--green); font: 700 .7rem/1.2 "SFMono-Regular", Consolas, monospace; letter-spacing: .16em; text-transform: uppercase; } +h1, h2, p { margin-top: 0; } +h1 { margin-bottom: .75rem; max-width: 13ch; font-size: clamp(2.25rem, 6vw, 5rem); line-height: .92; letter-spacing: -.065em; } +h2 { margin-bottom: 0; font-size: 1.3rem; letter-spacing: -.025em; } +.lede { max-width: 62ch; margin-bottom: 0; color: var(--muted); line-height: 1.6; } +.status-strip { display: grid; grid-template-columns: repeat(5, auto); gap: .5rem; margin: 0; } +.status-strip > div { min-width: 0; padding: .7rem .8rem; border-left: 1px solid var(--line); } +dt { color: var(--muted); font-size: .72rem; letter-spacing: .08em; text-transform: uppercase; } +dd { margin: .35rem 0 0; font-weight: 680; } +.status-strip dd { white-space: nowrap; font-size: .85rem; } +.status-dot { display: inline-block; width: .55rem; height: .55rem; margin-right: .45rem; border-radius: 50%; background: var(--muted); box-shadow: 0 0 12px currentColor; } +.status-dot.good { color: var(--green); background: var(--green); } +.status-dot.warn { color: var(--amber); background: var(--amber); } +.status-dot.bad { color: var(--red); background: var(--red); } +.sync-time { grid-column: 2; margin: .7rem 0 0; color: var(--muted); font-size: .78rem; text-align: right; } +.state-message { grid-column: 1 / -1; margin: 1.2rem 0 0; padding: .8rem 1rem; border-left: 3px solid var(--amber); color: #ffe8a6; background: #28220f; } +.console-grid { display: grid; grid-template-columns: minmax(0, 1.45fr) minmax(24rem, .85fr); gap: 1.25rem; align-items: start; } +.panel, .trust-disclosure { border: 1px solid var(--line); border-radius: 5px; background: linear-gradient(145deg, rgba(24, 28, 25, .97), rgba(16, 19, 17, .97)); box-shadow: 0 18px 50px rgba(0,0,0,.17); } +.panel { padding: clamp(1.2rem, 3vw, 2rem); } +.section-heading { display: flex; justify-content: space-between; align-items: end; gap: 2rem; margin-bottom: 1.7rem; } +.helper { max-width: 38ch; margin: 0; color: var(--muted); font-size: .8rem; line-height: 1.5; text-align: right; } +.accounting-panel { grid-column: 1; } +.accounting-grid { display: grid; grid-template-columns: 1fr 1fr; margin: 0; } +.accounting-grid > div { padding: 1rem 0; border-top: 1px solid var(--line); } +.accounting-grid > div:nth-child(even) { padding-left: 1.5rem; border-left: 1px solid var(--line); } +.accounting-grid dd { font-family: "SFMono-Regular", Consolas, monospace; font-size: 1.15rem; } +.accounting-grid .hero-figure { padding-top: 1.25rem; padding-bottom: 2.2rem; } +.accounting-grid .hero-figure dd { color: var(--white); font-size: clamp(1.75rem, 3.4vw, 3.25rem); line-height: 1; letter-spacing: -.07em; } +.identity-panel { grid-column: 2; grid-row: 1 / span 2; } +.identity-list { display: grid; gap: 0; margin: 0; } +.identity-list > div { padding: 1rem 0; border-top: 1px solid var(--line); } +.identity-list code { display: block; overflow-wrap: anywhere; color: var(--muted); font-size: .78rem; line-height: 1.6; } +.identity-list .proxy-row { margin: 0 -.75rem; padding: 1.25rem .75rem; border: 1px solid #3d5d49; background: #142119; } +.identity-list .primary-address code { color: var(--green); font-size: .93rem; } +.accounts-panel { grid-column: 1; } +.table-scroll { overflow-x: auto; } +table { width: 100%; border-collapse: collapse; } +th, td { padding: .9rem .8rem; border-top: 1px solid var(--line); text-align: left; } +thead th { color: var(--muted); font-size: .68rem; letter-spacing: .1em; text-transform: uppercase; } +tbody th { color: var(--white); } +td:last-child, th:last-child { text-align: right; } +td code { color: var(--muted); font-size: .82rem; } +.activity-panel { grid-column: 1 / -1; } +.timeline { display: grid; grid-template-columns: 1fr 1fr; gap: 0 2rem; margin: 0; padding: 0; list-style: none; } +.timeline li { position: relative; display: grid; grid-template-columns: 1rem 1fr; gap: .8rem; min-width: 0; padding: 1rem 0; border-top: 1px solid var(--line); } +.timeline-marker { width: .5rem; height: .5rem; margin-top: .35rem; border: 1px solid var(--green); border-radius: 50%; background: #163522; box-shadow: 0 0 10px rgba(95,245,155,.28); } +.timeline p { margin-bottom: .4rem; line-height: 1.45; } +.timeline .event-context { margin: 0; color: var(--muted); font-size: .76rem; } +.timeline .diagnostic { color: #ffe3a0; } +.timeline .diagnostic .timeline-marker { border-color: var(--amber); background: #3a2e0d; box-shadow: 0 0 10px rgba(255,207,90,.25); } +.empty-state { margin: 0; color: var(--muted); } +.unavailable-panel { min-height: 15rem; display: grid; align-content: center; justify-items: start; } +.unavailable-panel p:last-child { max-width: 60ch; margin-bottom: 0; color: var(--muted); line-height: 1.6; } +.trust-disclosure { margin-top: 1.25rem; padding: clamp(1.3rem, 3vw, 2.2rem); border-color: #704542; background: linear-gradient(145deg, #211515, #171313); } +.trust-disclosure .eyebrow { color: var(--red); } +.trust-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1.5rem; margin-top: 1.5rem; } +.trust-grid p { margin-bottom: 0; color: #d3c4c2; line-height: 1.55; } +.professional-note { margin: 1.5rem 0 0; padding-top: 1.25rem; border-top: 1px solid #533632; color: var(--white); line-height: 1.6; } + +@media (max-width: 980px) { + .status-header { grid-template-columns: 1fr; align-items: start; } + .status-strip { grid-template-columns: repeat(5, 1fr); margin-top: 2rem; } + .sync-time { grid-column: 1; text-align: left; } + .console-grid { grid-template-columns: 1fr; } + .accounting-panel, .identity-panel, .accounts-panel, .activity-panel { grid-column: 1; grid-row: auto; } + .timeline { grid-template-columns: 1fr; } +} + +@media (max-width: 700px) { + .app-shell { padding-inline: .75rem; } + .warning-banner { top: .35rem; margin-bottom: 2.5rem; } + .status-strip { grid-template-columns: repeat(2, 1fr); } + .section-heading { display: block; } + .helper { margin-top: .75rem; text-align: left; } + .accounting-grid { grid-template-columns: 1fr; } + .accounting-grid > div:nth-child(even) { padding-left: 0; border-left: 0; } + .accounting-grid .hero-figure { padding-bottom: 1.35rem; } + .trust-grid { grid-template-columns: 1fr; gap: .7rem; } +} diff --git a/web/src/components/AccountTable.tsx b/web/src/components/AccountTable.tsx new file mode 100644 index 0000000..8c15894 --- /dev/null +++ b/web/src/components/AccountTable.tsx @@ -0,0 +1,27 @@ +import type { DashboardSnapshot, DeploymentManifest } from "../types/dashboard"; +import { formatAmount, shortenAddress } from "./format"; + +export function AccountTable({ manifest, snapshot }: { manifest: DeploymentManifest; snapshot: DashboardSnapshot }) { + return ( +
+
+

Internal ledger

Tracked accounts

+

Balances are bank liabilities, not wallet token balances.

+
+
+ + + + {snapshot.actors.map((actor) => ( + + + + + + ))} + +
AccountAddressBalance
{manifest.network === "anvil" ? actor.label : shortenAddress(actor.address)}{shortenAddress(actor.address)}{formatAmount(actor.balance)}
+
+
+ ); +} diff --git a/web/src/components/AccountingGrid.tsx b/web/src/components/AccountingGrid.tsx new file mode 100644 index 0000000..84b83ac --- /dev/null +++ b/web/src/components/AccountingGrid.tsx @@ -0,0 +1,19 @@ +import type { DashboardSnapshot } from "../types/dashboard"; +import { formatAmount, formatReserveRatio, formatSurplus } from "./format"; + +export function AccountingGrid({ snapshot }: { snapshot: DashboardSnapshot }) { + return ( +
+
+

Custody invariant

Accounting

+

Solvent when reserves are greater than or equal to liabilities.

+
+
+
Reserves
{formatAmount(snapshot.reserves)}
+
Liabilities
{formatAmount(snapshot.liabilities)}
+
Surplus
{formatSurplus(snapshot.surplus)}
+
Reserve ratio
{formatReserveRatio(snapshot.reserves, snapshot.liabilities)}
+
+
+ ); +} diff --git a/web/src/components/ActivityTimeline.tsx b/web/src/components/ActivityTimeline.tsx new file mode 100644 index 0000000..1e05f21 --- /dev/null +++ b/web/src/components/ActivityTimeline.tsx @@ -0,0 +1,69 @@ +import type { Address, Hex } from "viem"; +import type { Activity, DashboardSnapshot, DecodeDiagnostic, DeploymentManifest } from "../types/dashboard"; +import { formatAmount, shortenAddress } from "./format"; + +type TimelineItem = (Activity & { diagnostic?: false }) | (DecodeDiagnostic & { diagnostic: true }); + +function actorLabel(address: Address, manifest: DeploymentManifest): string { + if (manifest.network === "baseSepolia") return shortenAddress(address); + return manifest.actors.find((actor) => actor.address.toLowerCase() === address.toLowerCase())?.label ?? shortenAddress(address); +} + +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 "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)}`; + case "upgraded": return `Implementation upgraded to ${shortenAddress(activity.implementation)}`; + } +} + +function transactionUrl(manifest: DeploymentManifest, transactionHash: Hex): string | undefined { + if (!manifest.explorerBaseUrl) return undefined; + return `${manifest.explorerBaseUrl.replace(/\/$/, "")}/tx/${transactionHash}`; +} + +function newestFirst(first: TimelineItem, second: TimelineItem): number { + if (first.blockNumber === second.blockNumber) return second.logIndex - first.logIndex; + return first.blockNumber > second.blockNumber ? -1 : 1; +} + +export function ActivityTimeline({ manifest, snapshot }: { manifest: DeploymentManifest; snapshot: DashboardSnapshot }) { + const items: TimelineItem[] = [ + ...snapshot.activity.map((activity) => ({ ...activity, diagnostic: false as const })), + ...snapshot.diagnostics.map((diagnostic) => ({ ...diagnostic, diagnostic: true as const })), + ].sort(newestFirst); + + return ( +
+
+

Proxy-only log stream

Activity

+

Newest first. Direct reads reconcile the accounting shown above.

+
+ {items.length === 0 ?

No proxy activity found from the deployment block.

: ( +
    + {items.map((item) => { + const key = `${item.transactionHash}-${item.logIndex}`; + const url = transactionUrl(manifest, item.transactionHash); + const transaction = {shortenAddress(item.transactionHash)}; + return ( +
  1. +
  2. + ); + })} +
+ )} +
+ ); +} diff --git a/web/src/components/ContractIdentity.tsx b/web/src/components/ContractIdentity.tsx new file mode 100644 index 0000000..f5c8543 --- /dev/null +++ b/web/src/components/ContractIdentity.tsx @@ -0,0 +1,41 @@ +import type { Address } from "viem"; +import type { DashboardSnapshot, DeploymentManifest } from "../types/dashboard"; +import { shortenAddress } from "./format"; + +function addressUrl(manifest: DeploymentManifest, address: Address): string | undefined { + if (!manifest.explorerBaseUrl) return undefined; + return `${manifest.explorerBaseUrl.replace(/\/$/, "")}/address/${address}`; +} + +function AddressValue({ label, address, manifest, primary = false }: { + label: string; + address: Address; + manifest: DeploymentManifest; + primary?: boolean; +}) { + const value = {manifest.network === "baseSepolia" ? shortenAddress(address) : address}; + const url = addressUrl(manifest, address); + return ( +
+ {url ? {value} : value} +
+ ); +} + +export function ContractIdentity({ manifest, snapshot }: { manifest: DeploymentManifest; snapshot: DashboardSnapshot }) { + return ( +
+
+

Upgrade boundary

Contract identity

+

Reads target the proxy. The implementation address identifies its current logic.

+
+
+
Proxy
+
Implementation
+
Token
+
Owner
+
Deployment block
{manifest.deploymentBlock.toString()}
+
+
+ ); +} diff --git a/web/src/components/StatusHeader.tsx b/web/src/components/StatusHeader.tsx new file mode 100644 index 0000000..7107bc1 --- /dev/null +++ b/web/src/components/StatusHeader.tsx @@ -0,0 +1,42 @@ +import type { DashboardState } from "../hooks/useBankDashboard"; +import { formatDate } from "./format"; + +const networkLabel = (state: DashboardState) => { + if (!("manifest" in state)) return "Network unavailable"; + return state.manifest.network === "anvil" ? "Local Anvil" : "Base Sepolia"; +}; + +const statusLabel = (status: DashboardState["status"]) => ({ + loading: "Loading", + ready: "Ready", + stale: "Stale", + disconnected: "Disconnected", + "invalid-manifest": "Invalid manifest", + "chain-mismatch": "Chain mismatch", +})[status]; + +export function StatusHeader({ state }: { state: DashboardState }) { + const snapshot = "snapshot" in state ? state.snapshot : undefined; + const statusTone = state.status === "ready" ? "good" : state.status === "loading" ? "neutral" : state.status === "stale" ? "warn" : "bad"; + return ( +
+
+

UUPS custody lab / read-only telemetry

+

Bank operations console

+

Reconciled proxy state and event history. State changes happen only through the project’s Foundry scripts.

+
+
+
Network
{networkLabel(state)}
+
Sync
+
Height
{snapshot ? `Block ${snapshot.blockNumber}` : "—"}
+
Bank
{snapshot ? (snapshot.paused ? "Paused" : "Active") : "—"}
+
Logic
{snapshot ? `Version ${snapshot.version}` : "—"}
+
+ {snapshot &&

Last synchronized

} + {(state.status === "stale" || state.status === "disconnected") && ( +

{state.error} Failed at .

+ )} + {(state.status === "invalid-manifest" || state.status === "chain-mismatch") &&

{state.error}

} +
+ ); +} diff --git a/web/src/components/TrustDisclosure.tsx b/web/src/components/TrustDisclosure.tsx new file mode 100644 index 0000000..953dff9 --- /dev/null +++ b/web/src/components/TrustDisclosure.tsx @@ -0,0 +1,14 @@ +export function TrustDisclosure() { + return ( + + ); +} diff --git a/web/src/components/WarningBanner.tsx b/web/src/components/WarningBanner.tsx new file mode 100644 index 0000000..cc3431b --- /dev/null +++ b/web/src/components/WarningBanner.tsx @@ -0,0 +1,8 @@ +export function WarningBanner() { + return ( + + ); +} diff --git a/web/src/components/format.test.ts b/web/src/components/format.test.ts new file mode 100644 index 0000000..d54de01 --- /dev/null +++ b/web/src/components/format.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; +import { formatAmount, formatReserveRatio, formatSurplus, shortenAddress } from "./format"; + +describe("console formatters", () => { + it("preserves six-decimal token precision without showing raw base units", () => { + expect(formatAmount(1_234_567_890n)).toBe("1,234.56789 mUSDC"); + }); + + it("shortens addresses while preserving recognizable ends", () => { + expect(shortenAddress("0x1234567890abcdef1234567890abcdef1234cDEF")).toBe("0x1234…cDEF"); + }); + + it("formats reserve surplus as a token amount", () => { + expect(formatSurplus(25_500_000n)).toBe("25.50 mUSDC"); + }); + + it("reports an exact fully reserved ratio when reserves equal liabilities", () => { + expect(formatReserveRatio(1_400_000_000n, 1_400_000_000n)).toBe("100.00%"); + }); + + it("reports an overcollateralized reserve ratio", () => { + expect(formatReserveRatio(1_750_000_000n, 1_400_000_000n)).toBe("125.00%"); + }); + + it("explains that a zero-liability ratio has no deposits", () => { + expect(formatReserveRatio(0n, 0n)).toBe("No deposits"); + }); + + it("renders failed or unknown values as an em dash rather than fabricated zero", () => { + expect(formatAmount(undefined)).toBe("—"); + expect(formatSurplus(null)).toBe("—"); + expect(formatReserveRatio(undefined, 1n)).toBe("—"); + expect(formatReserveRatio(1n, undefined)).toBe("—"); + }); +}); diff --git a/web/src/components/format.ts b/web/src/components/format.ts new file mode 100644 index 0000000..27ec567 --- /dev/null +++ b/web/src/components/format.ts @@ -0,0 +1,44 @@ +import type { Address, Hex } from "viem"; + +const UNKNOWN = "—"; + +export function formatAmount(value: bigint | null | undefined): string { + if (value === null || value === undefined) return UNKNOWN; + const negative = value < 0n; + const absolute = negative ? -value : value; + const whole = absolute / 1_000_000n; + const fraction = absolute % 1_000_000n; + const groupedWhole = new Intl.NumberFormat("en-US").format(whole); + let fractionText = fraction.toString().padStart(6, "0").replace(/0+$/, ""); + if (fractionText.length < 2) fractionText = fractionText.padEnd(2, "0"); + return `${negative ? "−" : ""}${groupedWhole}.${fractionText} mUSDC`; +} + +export function formatSurplus(value: bigint | null | undefined): string { + return formatAmount(value); +} + +export function formatReserveRatio( + reserves: bigint | null | undefined, + liabilities: bigint | null | undefined, +): string { + if (reserves === null || reserves === undefined || liabilities === null || liabilities === undefined) return UNKNOWN; + if (liabilities === 0n) return "No deposits"; + const hundredthsOfPercent = (reserves * 10_000n + liabilities / 2n) / liabilities; + const whole = hundredthsOfPercent / 100n; + const fraction = (hundredthsOfPercent % 100n).toString().padStart(2, "0"); + return `${whole}.${fraction}%`; +} + +export function shortenAddress(value: Address | Hex): string { + return `${value.slice(0, 6)}…${value.slice(-4)}`; +} + +export function formatDate(value: Date | undefined): string { + if (!value) return UNKNOWN; + return new Intl.DateTimeFormat("en-US", { + dateStyle: "medium", + timeStyle: "medium", + timeZone: "UTC", + }).format(value); +} diff --git a/web/src/hooks/useBankDashboard.test.tsx b/web/src/hooks/useBankDashboard.test.tsx new file mode 100644 index 0000000..624c1c0 --- /dev/null +++ b/web/src/hooks/useBankDashboard.test.tsx @@ -0,0 +1,173 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { act, renderHook, waitFor } from "@testing-library/react"; +import type { ReactNode } from "react"; +import type { Address } from "viem"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { DashboardSnapshot, DeploymentManifest } from "../types/dashboard"; +import { useBankDashboard } from "./useBankDashboard"; + +let notifyBlock: ((blockNumber: bigint) => void) | undefined; + +vi.mock("wagmi", () => ({ + useWatchBlockNumber: (options: { onBlockNumber?: (blockNumber: bigint) => void }) => { + notifyBlock = options.onBlockNumber; + }, +})); + +const address = (digit: string) => `0x${digit.repeat(40)}` as Address; +const manifest: DeploymentManifest = { + schemaVersion: 1, + network: "anvil", + chainId: 31337, + deploymentBlock: 3n, + rpcUrl: "http://127.0.0.1:8545", + token: address("1"), + proxy: address("2"), + implementation: address("3"), + owner: address("4"), + actors: [{ label: "Alice", address: address("5") }], +}; +const manifestJson = { ...manifest, deploymentBlock: 3 }; +const snapshot: DashboardSnapshot = { + blockNumber: 12n, + synchronizedAt: new Date("2026-08-21T10:00:00.000Z"), + version: 1, + paused: false, + asset: manifest.token, + owner: manifest.owner, + proxy: manifest.proxy, + implementation: manifest.implementation, + reserves: 900_000_000n, + liabilities: 900_000_000n, + surplus: 0n, + actors: [{ label: "Alice", address: address("5"), balance: 900_000_000n }], + activity: [], + diagnostics: [], +}; + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +} + +function createWrapper() { + const client = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } }); + return function Wrapper({ children }: { children: ReactNode }) { + return {children}; + }; +} + +describe("useBankDashboard", () => { + beforeEach(() => { + notifyBlock = undefined; + }); + + it("starts in loading while the manifest and first reconciled snapshot are pending", () => { + const pendingManifest = deferred(); + const { result } = renderHook(() => useBankDashboard({ + loadManifest: () => pendingManifest.promise, + loader: vi.fn(), + }), { wrapper: createWrapper() }); + + expect(result.current.status).toBe("loading"); + }); + + it("becomes ready with the synchronized block and time from the successful snapshot", async () => { + const { result } = renderHook(() => useBankDashboard({ + loadManifest: async () => manifestJson, + loader: async () => snapshot, + }), { wrapper: createWrapper() }); + + await waitFor(() => expect(result.current.status).toBe("ready")); + if (result.current.status !== "ready") throw new Error("expected ready dashboard"); + expect(result.current.snapshot.blockNumber).toBe(12n); + expect(result.current.snapshot.synchronizedAt.toISOString()).toBe("2026-08-21T10:00:00.000Z"); + }); + + it("retains the last successful snapshot as stale when a later reconciliation fails", async () => { + const loader = vi.fn() + .mockResolvedValueOnce(snapshot) + .mockRejectedValueOnce(new Error("RPC timeout")); + const clock = vi.fn(() => new Date("2026-08-21T10:05:00.000Z")); + const { result } = renderHook(() => useBankDashboard({ + loadManifest: async () => manifestJson, + loader, + now: clock, + }), { wrapper: createWrapper() }); + await waitFor(() => expect(result.current.status).toBe("ready")); + + act(() => notifyBlock?.(13n)); + + await waitFor(() => expect(result.current.status).toBe("stale")); + if (result.current.status !== "stale") throw new Error("expected stale dashboard"); + expect(result.current.snapshot).toBe(snapshot); + expect(result.current.failedAt.toISOString()).toBe("2026-08-21T10:05:00.000Z"); + expect(result.current.error).toContain("RPC timeout"); + }); + + it("reports an initial RPC failure as disconnected without inventing a snapshot", async () => { + const { result } = renderHook(() => useBankDashboard({ + loadManifest: async () => manifestJson, + loader: async () => { throw new Error("connection refused"); }, + now: () => new Date("2026-08-21T10:06:00.000Z"), + }), { wrapper: createWrapper() }); + + await waitFor(() => expect(result.current.status).toBe("disconnected")); + if (result.current.status !== "disconnected") throw new Error("expected disconnected dashboard"); + expect(result.current.snapshot).toBeUndefined(); + expect(result.current.error).toContain("connection refused"); + }); + + it("makes an invalid manifest terminal and never calls the snapshot loader", async () => { + const loader = vi.fn(); + const { result } = renderHook(() => useBankDashboard({ + loadManifest: async () => ({ network: "anvil" }), + loader, + }), { wrapper: createWrapper() }); + + await waitFor(() => expect(result.current.status).toBe("invalid-manifest")); + expect(loader).not.toHaveBeenCalled(); + act(() => notifyBlock?.(14n)); + expect(loader).not.toHaveBeenCalled(); + }); + + it("makes a manifest/network mismatch terminal and stops block reconciliation", async () => { + const loader = vi.fn().mockRejectedValue(new Error( + "endpoint chain ID 84532 does not match manifest chain ID 31337", + )); + const { result } = renderHook(() => useBankDashboard({ + loadManifest: async () => manifestJson, + loader, + }), { wrapper: createWrapper() }); + + await waitFor(() => expect(result.current.status).toBe("chain-mismatch")); + act(() => notifyBlock?.(14n)); + await Promise.resolve(); + expect(loader).toHaveBeenCalledTimes(1); + }); + + it("reconciles exactly once when a new watched block arrives", async () => { + const nextSnapshot = { ...snapshot, blockNumber: 13n, synchronizedAt: new Date("2026-08-21T10:01:00.000Z") }; + const loader = vi.fn().mockResolvedValueOnce(snapshot).mockResolvedValueOnce(nextSnapshot); + const { result } = renderHook(() => useBankDashboard({ + loadManifest: async () => manifestJson, + loader, + }), { wrapper: createWrapper() }); + await waitFor(() => expect(result.current.status).toBe("ready")); + + act(() => { + notifyBlock?.(13n); + notifyBlock?.(13n); + }); + + await waitFor(() => { + expect(loader).toHaveBeenCalledTimes(2); + expect(result.current.status === "ready" && result.current.snapshot.blockNumber).toBe(13n); + }); + }); +}); diff --git a/web/src/hooks/useBankDashboard.ts b/web/src/hooks/useBankDashboard.ts new file mode 100644 index 0000000..9f79d2c --- /dev/null +++ b/web/src/hooks/useBankDashboard.ts @@ -0,0 +1,118 @@ +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useCallback, useEffect, useMemo, useRef } from "react"; +import type { PublicClient } from "viem"; +import { useWatchBlockNumber } from "wagmi"; +import { createManifestPublicClient } from "../config/chains"; +import { parseDeploymentManifest } from "../config/manifest"; +import { loadDashboardSnapshot, ViemBankReader } from "../data/bankClient"; +import type { DashboardSnapshot, DeploymentManifest } from "../types/dashboard"; + +export type DashboardState = + | Readonly<{ status: "loading" }> + | Readonly<{ status: "ready"; manifest: DeploymentManifest; snapshot: DashboardSnapshot }> + | Readonly<{ status: "stale"; manifest: DeploymentManifest; snapshot: DashboardSnapshot; failedAt: Date; error: string }> + | Readonly<{ status: "disconnected"; manifest: DeploymentManifest; snapshot?: undefined; failedAt: Date; error: string }> + | Readonly<{ status: "invalid-manifest"; error: string }> + | Readonly<{ status: "chain-mismatch"; manifest: DeploymentManifest; error: string }>; + +export type BankDashboardOptions = Readonly<{ + loadManifest?: () => Promise; + loader?: (manifest: DeploymentManifest) => Promise; + now?: () => Date; +}>; + +const manifestQueryKey = ["deployment-manifest"] as const; +const defaultNow = () => new Date(); + +async function fetchManifest(): Promise { + const response = await fetch("/deployment.json", { headers: { Accept: "application/json" } }); + if (!response.ok) throw new Error(`deployment manifest request failed with HTTP ${response.status}`); + return response.json() as Promise; +} + +async function fetchSnapshot(manifest: DeploymentManifest): Promise { + const client = createManifestPublicClient(manifest); + return loadDashboardSnapshot(new ViemBankReader(client as PublicClient), manifest); +} + +function message(error: unknown): string { + return error instanceof Error ? error.message : "Unknown dashboard error"; +} + +function isChainMismatch(error: unknown): boolean { + return /endpoint chain ID \d+ does not match manifest chain ID \d+/i.test(message(error)); +} + +export function useBankDashboard(options: BankDashboardOptions = {}): DashboardState { + const loadManifest = options.loadManifest ?? fetchManifest; + const loader = options.loader ?? fetchSnapshot; + const now = options.now ?? defaultNow; + const queryClient = useQueryClient(); + const lastWatchedBlock = useRef(undefined); + + const manifestQuery = useQuery({ + queryKey: manifestQueryKey, + queryFn: loadManifest, + retry: false, + staleTime: Number.POSITIVE_INFINITY, + }); + + const parsedManifest = useMemo(() => { + if (manifestQuery.data === undefined) return undefined; + try { + return { manifest: parseDeploymentManifest(manifestQuery.data) } as const; + } catch (error) { + return { error: message(error) } as const; + } + }, [manifestQuery.data]); + const manifest = parsedManifest && "manifest" in parsedManifest ? parsedManifest.manifest : undefined; + const snapshotQueryKey = useMemo( + () => ["bank-dashboard", manifest?.chainId, manifest?.proxy] as const, + [manifest?.chainId, manifest?.proxy], + ); + + const snapshotQuery = useQuery({ + queryKey: snapshotQueryKey, + queryFn: () => { + if (!manifest) throw new Error("deployment manifest is unavailable"); + return loader(manifest); + }, + enabled: manifest !== undefined, + retry: false, + }); + const mismatch = snapshotQuery.error !== null && isChainMismatch(snapshotQuery.error); + const latestSnapshot = useRef(undefined); + useEffect(() => { + latestSnapshot.current = snapshotQuery.data; + }, [snapshotQuery.data]); + + const reconcileBlock = useCallback((blockNumber: bigint) => { + if (!manifest || mismatch || latestSnapshot.current?.blockNumber === blockNumber || lastWatchedBlock.current === blockNumber) return; + lastWatchedBlock.current = blockNumber; + void queryClient.invalidateQueries({ queryKey: snapshotQueryKey, exact: true }); + }, [manifest, mismatch, queryClient, snapshotQueryKey]); + + useWatchBlockNumber({ + chainId: manifest?.chainId, + enabled: manifest !== undefined && !mismatch, + onBlockNumber: reconcileBlock, + }); + + const failedAt = useMemo( + () => snapshotQuery.error === null ? undefined : now(), + [now, snapshotQuery.error], + ); + + if (manifestQuery.isPending) return { status: "loading" }; + if (manifestQuery.error !== null) return { status: "invalid-manifest", error: message(manifestQuery.error) }; + if (parsedManifest && "error" in parsedManifest) return { status: "invalid-manifest", error: parsedManifest.error ?? "Deployment manifest is invalid" }; + if (!manifest) return { status: "loading" }; + if (mismatch) return { status: "chain-mismatch", manifest, error: message(snapshotQuery.error) }; + if (snapshotQuery.error !== null) { + const error = message(snapshotQuery.error); + if (snapshotQuery.data) return { status: "stale", manifest, snapshot: snapshotQuery.data, failedAt: failedAt ?? now(), error }; + return { status: "disconnected", manifest, failedAt: failedAt ?? now(), error }; + } + if (snapshotQuery.data) return { status: "ready", manifest, snapshot: snapshotQuery.data }; + return { status: "loading" }; +} diff --git a/web/src/main.tsx b/web/src/main.tsx new file mode 100644 index 0000000..d4fbf88 --- /dev/null +++ b/web/src/main.tsx @@ -0,0 +1,34 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { createConfig, http, WagmiProvider } from "wagmi"; +import { baseSepolia } from "wagmi/chains"; +import { App } from "./App"; +import "./app.css"; +import { anvil } from "./config/chains"; +import { useBankDashboard } from "./hooks/useBankDashboard"; + +const wagmiConfig = createConfig({ + chains: [anvil, baseSepolia], + connectors: [], + transports: { + [anvil.id]: http("http://127.0.0.1:8545"), + [baseSepolia.id]: http("https://sepolia.base.org"), + }, +}); +const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + +export function LiveConsole() { + const dashboard = useBankDashboard(); + return ; +} + +createRoot(document.getElementById("root")!).render( + + + + + + + , +); diff --git a/web/src/test/setup.ts b/web/src/test/setup.ts index cb0ff5c..b37ae6c 100644 --- a/web/src/test/setup.ts +++ b/web/src/test/setup.ts @@ -1 +1,4 @@ -export {}; +import { cleanup } from "@testing-library/react"; +import { afterEach } from "vitest"; + +afterEach(cleanup); diff --git a/web/src/vite-env.d.ts b/web/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/web/src/vite-env.d.ts @@ -0,0 +1 @@ +/// From d1a86ad162631d2e5f839e25ddccf80f3d6425d8 Mon Sep 17 00:00:00 2001 From: golem Date: Fri, 21 Aug 2026 03:44:42 -0600 Subject: [PATCH 15/30] fix: stabilize terminal dashboard states --- web/src/App.test.tsx | 13 ++++++ web/src/components/AccountingGrid.tsx | 4 +- web/src/components/ContractIdentity.tsx | 6 +-- web/src/hooks/useBankDashboard.test.tsx | 60 +++++++++++++++++++++++-- web/src/hooks/useBankDashboard.ts | 8 ++++ 5 files changed, 82 insertions(+), 9 deletions(-) diff --git a/web/src/App.test.tsx b/web/src/App.test.tsx index 6e5af60..db10867 100644 --- a/web/src/App.test.tsx +++ b/web/src/App.test.tsx @@ -100,6 +100,19 @@ describe("read-only operations console", () => { expect(screen.getByText("500.00 mUSDC")).toBeTruthy(); }); + it("makes every learning abbreviation keyboard-focusable with its explanation intact", () => { + const { container } = render(); + const abbreviations = Array.from(container.querySelectorAll("abbr")); + expect(abbreviations).toHaveLength(5); + + for (const abbreviation of abbreviations) { + expect(abbreviation.getAttribute("title")?.trim().length).toBeGreaterThan(0); + expect(abbreviation.getAttribute("tabindex")).toBe("0"); + (abbreviation as HTMLElement).focus(); + expect(document.activeElement).toBe(abbreviation); + } + }); + it("renders valid V1 activity newest first beside an isolated decode warning", () => { render(); diff --git a/web/src/components/AccountingGrid.tsx b/web/src/components/AccountingGrid.tsx index 84b83ac..888fbb1 100644 --- a/web/src/components/AccountingGrid.tsx +++ b/web/src/components/AccountingGrid.tsx @@ -9,8 +9,8 @@ export function AccountingGrid({ snapshot }: { snapshot: DashboardSnapshot }) {

Solvent when reserves are greater than or equal to liabilities.

-
Reserves
{formatAmount(snapshot.reserves)}
-
Liabilities
{formatAmount(snapshot.liabilities)}
+
Reserves
{formatAmount(snapshot.reserves)}
+
Liabilities
{formatAmount(snapshot.liabilities)}
Surplus
{formatSurplus(snapshot.surplus)}
Reserve ratio
{formatReserveRatio(snapshot.reserves, snapshot.liabilities)}
diff --git a/web/src/components/ContractIdentity.tsx b/web/src/components/ContractIdentity.tsx index f5c8543..356be89 100644 --- a/web/src/components/ContractIdentity.tsx +++ b/web/src/components/ContractIdentity.tsx @@ -30,10 +30,10 @@ export function ContractIdentity({ manifest, snapshot }: { manifest: DeploymentM

Reads target the proxy. The implementation address identifies its current logic.

-
Proxy
-
Implementation
+
Proxy
+
Implementation
Token
-
Owner
+
Owner
Deployment block
{manifest.deploymentBlock.toString()}
diff --git a/web/src/hooks/useBankDashboard.test.tsx b/web/src/hooks/useBankDashboard.test.tsx index 624c1c0..f72a586 100644 --- a/web/src/hooks/useBankDashboard.test.tsx +++ b/web/src/hooks/useBankDashboard.test.tsx @@ -1,8 +1,8 @@ -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { focusManager, onlineManager, QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { act, renderHook, waitFor } from "@testing-library/react"; import type { ReactNode } from "react"; import type { Address } from "viem"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { DashboardSnapshot, DeploymentManifest } from "../types/dashboard"; import { useBankDashboard } from "./useBankDashboard"; @@ -55,8 +55,7 @@ function deferred() { return { promise, resolve, reject }; } -function createWrapper() { - const client = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } }); +function createWrapper(client = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } })) { return function Wrapper({ children }: { children: ReactNode }) { return {children}; }; @@ -65,6 +64,13 @@ function createWrapper() { describe("useBankDashboard", () => { beforeEach(() => { notifyBlock = undefined; + focusManager.setFocused(true); + onlineManager.setOnline(true); + }); + + afterEach(() => { + focusManager.setFocused(undefined); + onlineManager.setOnline(true); }); it("starts in loading while the manifest and first reconciled snapshot are pending", () => { @@ -151,6 +157,52 @@ describe("useBankDashboard", () => { expect(loader).toHaveBeenCalledTimes(1); }); + it("does not retry a terminal manifest-fetch failure on focus, reconnect, or remount", async () => { + const loadManifest = vi.fn().mockRejectedValue(new Error("manifest request failed")); + const client = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: Number.POSITIVE_INFINITY } } }); + const wrapper = createWrapper(client); + const options = { loadManifest, loader: vi.fn() }; + const first = renderHook(() => useBankDashboard(options), { wrapper }); + await waitFor(() => expect(first.result.current.status).toBe("invalid-manifest")); + + await act(async () => { + focusManager.setFocused(false); + focusManager.setFocused(true); + onlineManager.setOnline(false); + onlineManager.setOnline(true); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + first.unmount(); + const second = renderHook(() => useBankDashboard(options), { wrapper }); + await waitFor(() => expect(second.result.current.status).toBe("invalid-manifest")); + + expect(loadManifest).toHaveBeenCalledTimes(1); + }); + + it("does not retry a terminal chain mismatch on focus, reconnect, or remount", async () => { + const loader = vi.fn().mockRejectedValue(new Error( + "endpoint chain ID 84532 does not match manifest chain ID 31337", + )); + const client = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: Number.POSITIVE_INFINITY } } }); + const wrapper = createWrapper(client); + const options = { loadManifest: async () => manifestJson, loader }; + const first = renderHook(() => useBankDashboard(options), { wrapper }); + await waitFor(() => expect(first.result.current.status).toBe("chain-mismatch")); + + await act(async () => { + focusManager.setFocused(false); + focusManager.setFocused(true); + onlineManager.setOnline(false); + onlineManager.setOnline(true); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + first.unmount(); + const second = renderHook(() => useBankDashboard(options), { wrapper }); + await waitFor(() => expect(second.result.current.status).toBe("chain-mismatch")); + + expect(loader).toHaveBeenCalledTimes(1); + }); + it("reconciles exactly once when a new watched block arrives", async () => { const nextSnapshot = { ...snapshot, blockNumber: 13n, synchronizedAt: new Date("2026-08-21T10:01:00.000Z") }; const loader = vi.fn().mockResolvedValueOnce(snapshot).mockResolvedValueOnce(nextSnapshot); diff --git a/web/src/hooks/useBankDashboard.ts b/web/src/hooks/useBankDashboard.ts index 9f79d2c..11ee88c 100644 --- a/web/src/hooks/useBankDashboard.ts +++ b/web/src/hooks/useBankDashboard.ts @@ -54,7 +54,11 @@ export function useBankDashboard(options: BankDashboardOptions = {}): DashboardS queryKey: manifestQueryKey, queryFn: loadManifest, retry: false, + retryOnMount: false, staleTime: Number.POSITIVE_INFINITY, + refetchOnMount: false, + refetchOnReconnect: false, + refetchOnWindowFocus: false, }); const parsedManifest = useMemo(() => { @@ -79,6 +83,10 @@ export function useBankDashboard(options: BankDashboardOptions = {}): DashboardS }, enabled: manifest !== undefined, retry: false, + retryOnMount: false, + refetchOnMount: false, + refetchOnReconnect: false, + refetchOnWindowFocus: false, }); const mismatch = snapshotQuery.error !== null && isChainMismatch(snapshotQuery.error); const latestSnapshot = useRef(undefined); From 2446f0b2ef22762ac68bae4fa9614be9ce89df85 Mon Sep 17 00:00:00 2001 From: golem Date: Fri, 21 Aug 2026 04:04:03 -0600 Subject: [PATCH 16/30] docs: prepare repeatable V1 presentation --- Makefile | 16 ++- README.md | 54 ++++++++++ docs/LEARNING_GUIDE.md | 101 ++++++++++++++++++ docs/PRESENTER_RUNBOOK.md | 87 +++++++++++++++ tools/demo-local.sh | 73 +++++++++++++ tools/doctor.sh | 64 +++++++++-- tools/process-lib.sh | 164 ++++++++++++++++++++++++++++ tools/reset-local.sh | 45 ++++++++ tools/scan-project.sh | 53 +++++++++ tools/test-process-safety.sh | 201 +++++++++++++++++++++++++++++++++++ 10 files changed, 848 insertions(+), 10 deletions(-) create mode 100644 README.md create mode 100644 docs/LEARNING_GUIDE.md create mode 100644 docs/PRESENTER_RUNBOOK.md create mode 100755 tools/demo-local.sh create mode 100755 tools/process-lib.sh create mode 100755 tools/reset-local.sh create mode 100755 tools/scan-project.sh create mode 100755 tools/test-process-safety.sh diff --git a/Makefile b/Makefile index b65219b..32b0da7 100644 --- a/Makefile +++ b/Makefile @@ -4,9 +4,9 @@ SHELL := /bin/bash RPC_LOCAL := http://127.0.0.1:8545 ANVIL_OWNER := 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 -.PHONY: doctor setup verify deploy-v1 seed-v1 check-state test-finalize-manifest sync-abis publish-web-manifest sync-artifacts sync-artifacts-check +.PHONY: doctor setup demo-local verify check-state reset-local deploy-v1 seed-v1 sync-artifacts sync-artifacts-check sync-abis publish-web-manifest test-finalize-manifest doctor: - @./tools/doctor.sh + @bash tools/doctor.sh setup: @git submodule update --init --recursive @npm ci @@ -15,12 +15,22 @@ verify: @forge fmt --check @forge clean @npm_config_offline=true forge build --force + @node tools/sync-web-artifacts.mjs + @node tools/sync-web-artifacts.mjs --check + @node tools/check-upgrades-cli.mjs @npm_config_offline=true forge test --force @node tools/test-finalize-manifest.mjs + @node tools/test-sync-web-artifacts.mjs + @bash tools/test-process-safety.sh + @bash tools/scan-project.sh @npm --prefix web run lint @npm --prefix web run typecheck @npm --prefix web test @npm --prefix web run build +demo-local: + @bash tools/demo-local.sh +reset-local: + @bash tools/reset-local.sh test-finalize-manifest: @node tools/test-finalize-manifest.mjs sync-abis: @@ -41,4 +51,4 @@ deploy-v1: seed-v1: @forge script script/SeedV1Demo.s.sol:SeedV1Demo --rpc-url $(RPC_LOCAL) --broadcast --force check-state: - @DEMO_EXPECTED_STAGE=$${DEMO_EXPECTED_STAGE:-v1} forge script script/CheckState.s.sol:CheckState --rpc-url $(RPC_LOCAL) --force + @forge script script/CheckState.s.sol:CheckState --rpc-url $(RPC_LOCAL) --force diff --git a/README.md b/README.md new file mode 100644 index 0000000..9d7d30d --- /dev/null +++ b/README.md @@ -0,0 +1,54 @@ +# UUPS Bank V1 Demo + +This repository is the prepared starting point for a live upgradeability lesson. It deploys a local V1 bank that custodies a six-decimal mock ERC-20, records customer balances behind an ERC-1967 proxy, and presents the result in a read-only operations console. + +> **Trust boundary:** MockUSDC has no value. These contracts are educational and unaudited; real deposits must never be sent here. The owner can pause customer actions and install arbitrary future logic. UUPS mistakes can corrupt state or permanently brick upgradeability. A real custody product requires professional audits, operational key controls, multisig or timelocked governance, incident procedures, legal advice, and jurisdiction-specific compliance work. + +## Prerequisites + +Use a Linux-like Bash environment with Git, GNU Make, `curl`, Foundry `1.7.1` (`forge`, `anvil`, and `cast`), Node `24.18.0`, and npm `11.17.0`. The exact package graph is committed in the lockfiles. Installation documentation: [Git](https://git-scm.com/downloads), [Foundry](https://getfoundry.sh), [Node](https://nodejs.org/en/download), [npm](https://docs.npmjs.com/downloading-and-installing-node-js-and-npm), [Bash](https://www.gnu.org/software/bash/), [Make](https://www.gnu.org/software/make/), and [curl](https://curl.se/download.html). + +```bash +make setup +make doctor +``` + +`make setup` initializes recursive Git submodules and installs both pinned npm dependency trees. `make doctor` is read-only: it verifies versions, dependencies, writable runtime locations, and that local ports `8545` and `5173` are available. + +## Ten-minute local quick start + +In the first terminal: + +```bash +make demo-local +``` + +The command performs a scoped reset, starts a deterministic Anvil chain at `http://127.0.0.1:8545`, deploys and seeds V1, checks its exact state, exports public artifacts, and starts the console at `http://127.0.0.1:5173/`. It remains attached so Ctrl-C safely stops only the recorded project process groups. + +In a second terminal: + +```bash +DEMO_EXPECTED_STAGE=v1 make check-state +curl --fail http://127.0.0.1:5173/ +``` + +The state check proves Alice has `900 mUSDC`, Bob has `500 mUSDC`, liabilities and reserves are both `1,400 mUSDC`, and the contract version is `1`. After Ctrl-C in the first terminal, run `make reset-local` to remove reproducible local state. + +## Architecture + +Foundry scripts are the state-changing control plane; the browser never signs. `MockUSDC` holds no value. An ERC-1967 proxy keeps the bank address and storage stable while delegating calls to `BankV1`. The proxy itself holds token reserves and the internal ledger records liabilities. A confirmed public deployment manifest and generated ABI connect that on-chain system to a React/Vite console using viem and wagmi for read-only, block-consistent state and event display. + +## Command reference + +- `make doctor` — read-only prerequisite, dependency, directory, and port checks. +- `make setup` — initialize pinned submodules and npm dependencies. +- `make demo-local` — run the complete attached V1 experience. +- `make verify` — run formatting, clean build, artifact checks, upgrade CLI check, Solidity tests, script tests, process tests, project scan, web lint/typecheck/tests, and production build. +- `make check-state` — validate and print the active local V1 state at `http://127.0.0.1:8545`. +- `make reset-local` — validate recorded process identity, stop only owned groups, and remove only known local artifacts. +- `make deploy-v1` — lower-level guarded V1 deployment and manifest finalization. +- `make seed-v1` — lower-level deterministic Act 1 deposits and withdrawal. +- `make sync-artifacts` — regenerate the ABI module and publish the confirmed active manifest. +- `make sync-artifacts-check` — test generation and prove generated ABIs are current. + +Continue with the [learning guide](docs/LEARNING_GUIDE.md) or rehearse from the [presenter runbook](docs/PRESENTER_RUNBOOK.md). diff --git a/docs/LEARNING_GUIDE.md b/docs/LEARNING_GUIDE.md new file mode 100644 index 0000000..beabfa0 --- /dev/null +++ b/docs/LEARNING_GUIDE.md @@ -0,0 +1,101 @@ +# Learning Guide: Custody Accounting Behind a UUPS Proxy + +## The two addresses that make upgrades possible + +Users call the **proxy**, whose address remains stable and whose storage contains the asset address, customer ledger, liabilities, owner, and pause state. The **implementation** contains executable logic. The proxy forwards each application call with `delegatecall`: implementation code runs in the proxy's context, so `address(this)` is the proxy and reads/writes affect proxy storage. Calling the implementation directly is not equivalent and is never the supported application path. + +UUPS places upgrade authorization in the implementation. OpenZeppelin's proxy-context checks ensure upgrade entry points run only through a compatible proxy; `BankV1._authorizeUpgrade` then restricts authorization to the owner. This keeps the proxy small, but makes implementation correctness and storage compatibility critical. + +## Initializers replace constructor state + +A normal implementation constructor changes only the implementation's own storage. It cannot initialize the proxy storage used by delegated calls. `initialize(asset, initialOwner)` therefore performs the one-time proxy setup and initializes ownership and pausing. The encoded initializer runs atomically when the proxy is created, avoiding an uninitialized-proxy takeover window. + +The `BankV1` constructor calls `_disableInitializers()`. That locks the standalone implementation so an outsider cannot initialize it and create a misleading or dangerous separately owned instance. The narrow constructor annotation tells the OpenZeppelin validator why this constructor is intentional; it does not bypass storage-layout or UUPS compatibility checks. Double initialization of the proxy and direct initialization of the implementation both revert with `InvalidInitialization`. + +## Storage layout is an API + +Delegate calls interpret numbered storage slots according to the current implementation. A compatible future implementation preserves every existing declaration in its original order and consumes reserved gap space only when new state is truly required. + +Safe conceptual extension: + +```solidity +IERC20 internal _asset; // unchanged slot +mapping(address => uint256) internal _balances; // unchanged slot +uint256 internal _totalLiabilities; // unchanged slot +uint256 internal _newValue; // consumes reserved space +uint256[46] private __gap; +``` + +Unsafe conceptual extension: + +```solidity +uint256 internal _totalLiabilities; // reordered: corrupts interpretation +IERC20 internal _asset; +mapping(address => uint256) internal _balances; +``` + +Changing order, type, inheritance order, or removing state can make balances appear as addresses or overwrite control data. A bad implementation can also remove working upgrade machinery and permanently brick future upgrades. Layout validation is necessary, but authorization and implementation behavior still require review. + +## Reserves, liabilities, and surplus + +**Reserves** are `MockUSDC.balanceOf(proxy)`: tokens actually held by the proxy. **Liabilities** are `totalLiabilities()`: the sum the ledger owes customers. **Surplus** is reserves minus liabilities. The solvency rule is: + +```text +reserves >= total liabilities +``` + +Equality holds in the prepared Act 1 state. Anyone can transfer mock tokens directly to the proxy without receiving ledger credit, so a surplus is possible and the invariant deliberately uses `>=`. + +## Exact V1 money flows + +For `deposit(amount)`, the bank rejects zero, paused, or reentrant calls; reads reserves; uses `SafeERC20.safeTransferFrom`; measures the exact received delta; and only then credits the sender's internal balance and total liabilities. A fee-on-transfer or otherwise unexpected asset delta reverts the entire transaction. + +For `withdraw(amount)`, the bank rejects zero, paused, reentrant, or underfunded ledger calls. It debits the customer's internal balance and total liabilities before `SafeERC20.safeTransfer` sends tokens. That ordering is checks-effects-interactions: validate first, commit internal effects second, interact externally last. `nonReentrant` adds a second boundary against a malicious token callback. A revert from the token rolls the whole transaction back. + +`SafeERC20` handles ERC-20 implementations that return `false`, omit return values, or revert in different ways. Pausing gives the owner an emergency stop for deposits and withdrawals while views remain available. There is intentionally no owner reserve sweep. + +An internal V2 customer transfer, when implemented during the presentation, moves ledger balances only. It must not move ERC-20 reserves or change aggregate liabilities. + +## The owner is a central trust assumption + +The owner may pause customer actions and authorize an implementation containing arbitrary future logic. Tests proving today's V1 behavior cannot constrain tomorrow's authorized implementation. Multisig/timelocked governance and operational controls are absent from this educational V1. + +> **Trust boundary:** MockUSDC has no value. These contracts are educational and unaudited; real deposits must never be sent here. The owner can pause customer actions and install arbitrary future logic. UUPS mistakes can corrupt state or permanently brick upgradeability. A real custody product requires professional audits, operational key controls, multisig or timelocked governance, incident procedures, legal advice, and jurisdiction-specific compliance work. + +## Local exercises: observe the named failures + +These commands run deterministic tests and do not require a wallet or public RPC. Add `-vvvv` to inspect a revert trace. + +```bash +# BankV1.InvalidAsset +forge test --match-test testInitializationChecksZeroAssetBeforeZeroOwner -vv + +# BankV1.ZeroAmount on both customer paths +forge test --match-test 'test(Deposit|Withdraw)RejectsZeroAmount' -vv + +# BankV1.InsufficientBalance with available/requested values +forge test --match-test testWithdrawReportsAvailableAndRequestedOnInsufficientInternalBalance -vv + +# BankV1.UnexpectedAssetDelta with a fee-taking token +forge test --match-test testFeeOnTransferDepositRevertsAndRollsBackTokenAndAccounting -vv + +# OpenZeppelin EnforcedPause +forge test --match-test 'test(Deposit|Withdraw)RejectsCallsWhilePaused' -vv + +# OpenZeppelin OwnableUnauthorizedAccount +forge test --match-test 'testNonOwnerCannot(Pause|Unpause|AuthorizeUpgrade)' -vv + +# OpenZeppelin InvalidInitialization +forge test --match-test 'test(ProxyCannotBeInitializedTwice|ImplementationCannotBeInitialized)' -vv + +# OpenZeppelin ReentrancyGuardReentrantCall +forge test --match-test testDepositPropagatesNestedRevertAtomicallyWhenConfigured -vv + +# Script UnsupportedChain (the test deliberately uses a rejected chain) +forge test --match-test testUnsupportedChainsAreRejectedBeforeBroadcast -vv + +# ManifestChainMismatch and MissingCode +forge test --match-test 'test(WrongManifestChain|AddressWithoutCode)IsRejected' -vv +``` + +Finish by running `make verify`; it combines unit, fuzz, invariant, script, process-safety, scanner, and web gates. diff --git a/docs/PRESENTER_RUNBOOK.md b/docs/PRESENTER_RUNBOOK.md new file mode 100644 index 0000000..2c71d11 --- /dev/null +++ b/docs/PRESENTER_RUNBOOK.md @@ -0,0 +1,87 @@ +# Presenter Runbook: Prepared V1 and Live UUPS Upgrade + +## Preflight and rehearsal + +Rehearse once from a clean disposable branch created at the `demo-start` tag. Allow 25 minutes: 3 minutes for preflight, 5 for Act 1, 10 for the Codex change and verification, 4 for Act 3, and 3 for questions. Keep two terminals visible and open the browser only after Vite reports ready. + +Before the audience arrives: + +```bash +make setup +make reset-local +make doctor +make verify +git diff --check +git status --short +``` + +The doctor must show Foundry `1.7.1`, Node `24.18.0`, npm `11.17.0`, initialized dependencies, writable runtime paths, and free ports `8545`/`5173`. Verification must exit `0`. No upgrade or transfer command runs until `make verify` passes. + +## The three-act story + +### Act 1 — Prepared V1 establishes trust + +First terminal: + +```bash +make demo-local +``` + +Second terminal: + +```bash +DEMO_EXPECTED_STAGE=v1 make check-state +curl --fail http://127.0.0.1:5173/ +``` + +Open `http://127.0.0.1:5173/`; Anvil is at `http://127.0.0.1:8545`. Call out that every browser read targets the proxy, while the distinct implementation address is shown only to teach delegation. The exact expected state is Alice `900 mUSDC`, Bob `500 mUSDC`, liabilities `1,400 mUSDC`, reserves `1,400 mUSDC`, surplus `0`, and version `1`. Point to deposit, withdrawal, and upgrade/ownership events, then state that the browser is read-only. + +Explain the initial flow: scripts minted 2,000 mUSDC to Alice and 1,000 to Bob; Alice deposited 1,000 and withdrew 100; Bob deposited 500. The stable proxy holds reserves and storage. Implementation code runs against that storage with `delegatecall`. + +### Act 2 — Codex changes the system live + +Give Codex this approved prompt verbatim: + +> Add `BankV2` with customer-to-customer internal transfers. Preserve the UUPS storage layout and all V1 behavior. Add unit, fuzz, invariant, and upgrade-regression tests; an owner upgrade script; a scripted Alice-to-Bob transfer; exported ABI support; and the read-only console updates needed to show V2 and its transfer event. Explain each security decision. Do not perform an upgrade until all verification passes. + +Ask Codex to show the diff and explain storage compatibility, authorization, conservation, error paths, and why the browser remains read-only. The expected prepared change adds transfer-aware source, tests, scripts, ABI output, and console rendering without changing V1 storage. Run: + +```bash +make verify +``` + +If any gate fails, stop and let Codex diagnose it. Do not run an upgrade merely because a partial test command passed. + +### Act 3 — V2 proves continuity + +After Codex has implemented these targets and the full gate has passed: + +```bash +make upgrade-v2 +make demo-transfer +make check-state +``` + +The expected result is the same proxy and a new implementation, version `2`, Alice `650 mUSDC`, Bob `750 mUSDC`, liabilities `1,400 mUSDC`, reserves `1,400 mUSDC`, and a decoded 250 mUSDC internal transfer event. No ERC-20 transfer should accompany the ledger transfer. Refresh only if the console has not observed the next block; otherwise let its live status prove the change. + +## Recovery without broad cleanup + +- **Occupied port:** `make demo-local` refuses to claim either port. Use `curl http://127.0.0.1:8545` and `curl http://127.0.0.1:5173/` plus your operating system's process inspection to identify the external owner. Stop it yourself only after proving ownership; the project never uses broad process matching. +- **Recorded stale process:** run `make reset-local`. It validates numeric PID, process group, command signature, and Linux start tick before signaling. A mismatched live process is preserved and reset exits nonzero. +- **Stale console:** confirm `DEMO_EXPECTED_STAGE=v1 make check-state`, inspect `.demo/vite.log`, and use `curl --fail http://127.0.0.1:5173/`. The console labels stale/disconnected state and preserves the last good snapshot rather than inventing zeros. +- **Failed test or live edit:** do not upgrade. Save the diff for review, continue diagnosis on the disposable live branch, or start a new disposable branch from `demo-start`; never overwrite unrelated work. The prepared V1 remains the successful ending if time expires. +- **Unexpected child exit:** the attached launcher stops its other validated group. Inspect `.demo/anvil.log` and `.demo/vite.log`, then run `make reset-local` and restart. + +End the local session with Ctrl-C in the attached terminal, then `make reset-local`. The optional public encore is outside this prepared V1 run: local success does not depend on Base Sepolia, a faucet, an explorer, a wallet, or any external RPC. + +## Closing trust disclosure checklist + +Read these points while the matching console panel is visible: + +- MockUSDC has no value. +- These contracts are educational and unaudited; real deposits must never be sent here. +- The owner can pause customer actions and install arbitrary future logic. +- UUPS mistakes can corrupt state or permanently brick upgradeability. +- A real custody product requires professional audits, operational key controls, multisig or timelocked governance, incident procedures, legal advice, and jurisdiction-specific compliance work. + +Codex supplies the cross-stack implementation, tests, orchestration, and explanation. OpenZeppelin supplies reviewed contract primitives and upgrade validation; Foundry supplies compilation, tests, scripts, and the local chain. None of those tools turns this teaching artifact into an audited or regulated custody product. diff --git a/tools/demo-local.sh b/tools/demo-local.sh new file mode 100755 index 0000000..fc50ab2 --- /dev/null +++ b/tools/demo-local.sh @@ -0,0 +1,73 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd -P) +# shellcheck source=process-lib.sh +source "$ROOT/tools/process-lib.sh" +ANVIL_TEST_PHRASE='test test test test test test test test test test test junk' +CLEANING=0 + +cleanup() { + local status=$? cleanup_status=0 + ((CLEANING == 0)) || return + CLEANING=1 + trap - INT TERM EXIT + demo_stop_recorded "$ROOT" vite || cleanup_status=1 + demo_stop_recorded "$ROOT" anvil || cleanup_status=1 + ((status == 0 && cleanup_status != 0)) && status=$cleanup_status + exit "$status" +} +trap 'exit 130' INT +trap 'exit 143' TERM +trap cleanup EXIT + +record_launched() { + local kind=$1 pid=$2 + for _ in {1..50}; do + if demo_record_process "$ROOT" "$kind" "$pid"; then return 0; fi + demo_pid_is_running "$pid" || break + sleep 0.02 + done + printf 'Could not prove identity for launched %s PID %s; stopping without targeting an unverified PID.\n' "$kind" "$pid" >&2 + return 1 +} + +cd "$ROOT" +bash tools/reset-local.sh +bash tools/doctor.sh +install -d -m 0700 "$ROOT/.demo" + +setsid anvil --host 127.0.0.1 --port 8545 --chain-id 31337 --mnemonic "$ANVIL_TEST_PHRASE" >"$ROOT/.demo/anvil.log" 2>&1 & +ANVIL_PID=$! +record_launched anvil "$ANVIL_PID" +for _ in {1..100}; do + if [[ $(cast chain-id --rpc-url http://127.0.0.1:8545 2>/dev/null || true) == 31337 ]]; then ANVIL_READY=1; break; fi + demo_pid_is_running "$ANVIL_PID" || break + sleep 0.1 +done +[[ ${ANVIL_READY:-0} == 1 ]] || { printf '%s\n' 'Anvil did not become ready; see .demo/anvil.log' >&2; exit 1; } + +make deploy-v1 +make seed-v1 +DEMO_EXPECTED_STAGE=v1 make check-state +npm_config_offline=true forge build --force +node tools/sync-web-artifacts.mjs +node tools/sync-web-artifacts.mjs --check +node tools/publish-web-manifest.mjs + +setsid "$ROOT/web/node_modules/.bin/vite" web --host 127.0.0.1 --port 5173 >"$ROOT/.demo/vite.log" 2>&1 & +VITE_PID=$! +record_launched vite "$VITE_PID" +for _ in {1..100}; do + if curl --fail --silent --output /dev/null http://127.0.0.1:5173/; then VITE_READY=1; break; fi + demo_pid_is_running "$VITE_PID" || break + sleep 0.1 +done +[[ ${VITE_READY:-0} == 1 ]] || { printf '%s\n' 'Vite did not become ready; see .demo/vite.log' >&2; exit 1; } + +printf '\nV1 operations console: http://127.0.0.1:5173/\n' +printf '%s\n' 'In a second terminal: DEMO_EXPECTED_STAGE=v1 make check-state' +printf '%s\n' 'Stop this attached demo with Ctrl-C; make reset-local is the recovery command.' +while demo_pid_is_running "$ANVIL_PID" && demo_pid_is_running "$VITE_PID"; do sleep 1; done +printf '%s\n' 'A demo child exited unexpectedly; inspect .demo/anvil.log and .demo/vite.log.' >&2 +exit 1 diff --git a/tools/doctor.sh b/tools/doctor.sh index 721b7f2..e22467a 100755 --- a/tools/doctor.sh +++ b/tools/doctor.sh @@ -1,13 +1,63 @@ #!/usr/bin/env bash set -euo pipefail -printf '%s\n' 'Expected: Foundry 1.7.1, Node 24.18.0, npm 11.17.0' +ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd -P) +FAILURES=0 -for tool in forge anvil node npm make; do - if command -v "$tool" >/dev/null 2>&1; then - printf '%s: ' "$tool" - "$tool" --version | sed -n '1p' - else - printf '%s\n' "missing: $tool" +fail() { printf 'FAIL: %s\n' "$1" >&2; FAILURES=$((FAILURES + 1)); } +have() { + local name=$1 link=$2 + if ! command -v "$name" >/dev/null 2>&1; then + fail "$name is missing — install from $link" + return 1 fi +} +exact_version() { + local name=$1 expected=$2 actual=$3 link=$4 + if [[ "$actual" == "$expected" ]]; then printf 'ok: %s %s\n' "$name" "$actual" + else fail "$name must be $expected (found $actual) — install from $link"; fi +} +port_free() { + local port=$1 + if (exec 9<>"/dev/tcp/127.0.0.1/$port") 2>/dev/null; then + fail "127.0.0.1:$port is occupied; stop that external service before starting the demo" + else + printf 'ok: 127.0.0.1:%s is available\n' "$port" + fi +} + +printf '%s\n' 'Checking the pinned V1 demo environment (read-only).' +if have git https://git-scm.com/downloads; then printf 'ok: %s\n' "$(git --version)"; fi +if have forge https://getfoundry.sh; then exact_version forge 1.7.1 "$(forge --version | sed -nE 's/^forge Version: ([^ ]+).*/\1/p')" https://getfoundry.sh; fi +if have anvil https://getfoundry.sh; then exact_version anvil 1.7.1 "$(anvil --version | sed -nE 's/^anvil Version: ([^ ]+).*/\1/p')" https://getfoundry.sh; fi +if have cast https://getfoundry.sh; then exact_version cast 1.7.1 "$(cast --version | sed -nE 's/^cast Version: ([^ ]+).*/\1/p')" https://getfoundry.sh; fi +if have node https://nodejs.org/en/download; then exact_version Node 24.18.0 "$(node --version | sed 's/^v//')" https://nodejs.org/en/download; fi +if have npm https://docs.npmjs.com/downloading-and-installing-node-js-and-npm; then exact_version npm 11.17.0 "$(npm --version)" https://docs.npmjs.com/downloading-and-installing-node-js-and-npm; fi +if have bash https://www.gnu.org/software/bash/; then printf 'ok: %s\n' "$(bash --version | sed -n '1p')"; fi +if have make https://www.gnu.org/software/make/; then printf 'ok: %s\n' "$(make --version | sed -n '1p')"; fi +if have curl https://curl.se/download.html; then printf 'ok: %s\n' "$(curl --version | sed -n '1p')"; fi + +if git -C "$ROOT" submodule status --recursive | while IFS= read -r line; do [[ "$line" == ' '* ]] || exit 1; done; then + printf '%s\n' 'ok: recursive Git submodules are initialized at recorded commits' +else + fail 'recursive Git submodules are missing or differ from recorded commits — run make setup' +fi +if [[ -d "$ROOT/node_modules" ]]; then printf '%s\n' 'ok: root node_modules is installed'; else fail 'root node_modules is missing — run make setup'; fi +if [[ -d "$ROOT/web/node_modules" ]]; then printf '%s\n' 'ok: web node_modules is installed'; else fail 'web node_modules is missing — run make setup'; fi +if command -v node >/dev/null 2>&1 && [[ -d "$ROOT/node_modules" ]]; then + node "$ROOT/tools/check-upgrades-cli.mjs" || fail 'the pinned offline OpenZeppelin upgrades CLI is unavailable — run make setup' +fi + +for path in "$ROOT" "$ROOT/deployments" "$ROOT/web/public" "$ROOT/web/src/generated"; do + if [[ -d "$path" && -w "$path" ]]; then printf 'ok: writable runtime directory %s\n' "${path#"$ROOT/"}"; else fail "runtime directory is not writable: $path"; fi done +if [[ -d "$ROOT/.demo" ]]; then [[ -w "$ROOT/.demo" ]] || fail "$ROOT/.demo is not writable"; fi +port_free 8545 +port_free 5173 + +for variable in BASE_SEPOLIA_RPC_URL BASE_SEPOLIA_ACCOUNT; do + if [[ -n ${!variable:-} ]]; then printf 'optional: %s is configured\n' "$variable"; else printf 'optional: %s is not configured (local demo unaffected)\n' "$variable"; fi +done + +if ((FAILURES)); then printf 'Doctor found %d problem(s).\n' "$FAILURES" >&2; exit 1; fi +printf '%s\n' 'Doctor passed.' diff --git a/tools/process-lib.sh b/tools/process-lib.sh new file mode 100755 index 0000000..9623b04 --- /dev/null +++ b/tools/process-lib.sh @@ -0,0 +1,164 @@ +#!/usr/bin/env bash + +demo_repository_root() { + cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd -P +} + +demo_is_uint() { + [[ ${1:-} =~ ^[0-9]+$ ]] +} + +demo_read_one_line() { + local path=$1 destination=$2 value extra + IFS= read -r value <"$path" || return 1 + if IFS= read -r extra < <(sed -n '2p' "$path") && [[ -n "$extra" ]]; then return 1; fi + printf -v "$destination" '%s' "$value" +} + +demo_start_tick() { + local pid=$1 stat rest + local -a fields + demo_is_uint "$pid" || return 1 + [[ -r "/proc/$pid/stat" ]] || return 1 + stat=$(<"/proc/$pid/stat") + rest=${stat##*) } + read -r -a fields <<<"$rest" + demo_is_uint "${fields[19]:-}" || return 1 + printf '%s\n' "${fields[19]}" +} + +demo_process_group() { + local pid=$1 pgid + pgid=$(ps -o pgid= -p "$pid" 2>/dev/null) || return 1 + pgid=${pgid//[[:space:]]/} + demo_is_uint "$pgid" || return 1 + printf '%s\n' "$pgid" +} + +demo_has_sequence() { + local array_name=$1 + local -n argv_ref=$array_name + shift + local -a wanted=("$@") + local i j + for ((i = 0; i + ${#wanted[@]} <= ${#argv_ref[@]}; i++)); do + for ((j = 0; j < ${#wanted[@]}; j++)); do + [[ ${argv_ref[i+j]} == "${wanted[j]}" ]] || break + done + ((j == ${#wanted[@]})) && return 0 + done + return 1 +} + +demo_command_matches() { + local pid=$1 kind=$2 argument base found=0 + local -a arguments=() + [[ -r "/proc/$pid/cmdline" ]] || return 1 + mapfile -d '' -t arguments <"/proc/$pid/cmdline" + ((${#arguments[@]} > 0)) || return 1 + for argument in "${arguments[@]}"; do + base=${argument##*/} + if [[ "$kind" == anvil && "$base" == anvil ]]; then found=1; fi + if [[ "$kind" == vite && ( "$base" == vite || "$base" == vite.js ) ]]; then found=1; fi + done + ((found == 1)) || return 1 + if [[ "$kind" == anvil ]]; then + demo_has_sequence arguments --host 127.0.0.1 || return 1 + demo_has_sequence arguments --port 8545 || return 1 + demo_has_sequence arguments --chain-id 31337 || return 1 + elif [[ "$kind" == vite ]]; then + demo_has_sequence arguments web --host 127.0.0.1 --port 5173 || return 1 + else + return 1 + fi +} + +demo_remove_record() { + local root=$1 kind=$2 path + for path in "$root/.demo/$kind.pid" "$root/.demo/$kind.start" "$root/.demo/$kind.pgid"; do + if [[ -e "$path" ]]; then + rm -f -- "$path" + printf 'Removed %s\n' "${path#"$root/"}" + fi + done +} + +demo_record_process() { + local root=$1 kind=$2 pid=$3 start pgid + [[ "$kind" == anvil || "$kind" == vite ]] || return 1 + demo_is_uint "$pid" || return 1 + start=$(demo_start_tick "$pid") || return 1 + pgid=$(demo_process_group "$pid") || return 1 + [[ "$pid" == "$pgid" ]] || return 1 + demo_command_matches "$pid" "$kind" || return 1 + mkdir -p "$root/.demo" + chmod 0700 "$root/.demo" + printf '%s\n' "$pid" >"$root/.demo/$kind.pid" + printf '%s\n' "$start" >"$root/.demo/$kind.start" + printf '%s\n' "$pgid" >"$root/.demo/$kind.pgid" + chmod 0600 "$root/.demo/$kind.pid" "$root/.demo/$kind.start" "$root/.demo/$kind.pgid" +} + +demo_pid_is_running() { + local pid=$1 stat rest state + { IFS= read -r stat <"/proc/$pid/stat"; } 2>/dev/null || return 1 + rest=${stat##*) } + state=${rest%% *} + [[ "$state" != Z ]] +} + +demo_stop_recorded() { + local root=$1 kind=$2 pid start pgid actual_start actual_pgid + local pid_path="$root/.demo/$kind.pid" + local start_path="$root/.demo/$kind.start" + local pgid_path="$root/.demo/$kind.pgid" + [[ "$kind" == anvil || "$kind" == vite ]] || return 1 + [[ -e "$pid_path" ]] || return 0 + if ! demo_read_one_line "$pid_path" pid || [[ ! "$pid" =~ ^[1-9][0-9]*$ ]] || ((10#$pid <= 1)); then + printf 'Refusing invalid %s PID record\n' "$kind" >&2 + return 1 + fi + if ! demo_pid_is_running "$pid"; then + printf 'Discarding stale %s process record for PID %s\n' "$kind" "$pid" + demo_remove_record "$root" "$kind" + return 0 + fi + if ! demo_read_one_line "$start_path" start || ! demo_is_uint "$start"; then + printf 'Refusing incomplete %s start-tick record\n' "$kind" >&2 + return 1 + fi + if ! demo_read_one_line "$pgid_path" pgid || [[ ! "$pgid" =~ ^[1-9][0-9]*$ ]] || ((10#$pgid <= 1)); then + printf 'Refusing incomplete %s process-group record\n' "$kind" >&2 + return 1 + fi + actual_start=$(demo_start_tick "$pid") || return 1 + actual_pgid=$(demo_process_group "$pid") || return 1 + if [[ "$actual_start" != "$start" || "$actual_pgid" != "$pgid" || "$pid" != "$pgid" ]]; then + printf 'Refusing %s PID %s: recorded identity does not match\n' "$kind" "$pid" >&2 + return 1 + fi + if ! demo_command_matches "$pid" "$kind"; then + printf 'Refusing %s PID %s: command signature does not match\n' "$kind" "$pid" >&2 + return 1 + fi + printf 'Stopping validated %s process group %s\n' "$kind" "$pgid" + kill -TERM -- "-$pgid" + for _ in {1..50}; do + demo_pid_is_running "$pid" || break + sleep 0.1 + done + if demo_pid_is_running "$pid"; then + printf 'Escalating validated %s process group %s to KILL\n' "$kind" "$pgid" + kill -KILL -- "-$pgid" + for _ in {1..20}; do + demo_pid_is_running "$pid" || break + sleep 0.1 + done + fi + wait "$pid" 2>/dev/null || true + demo_pid_is_running "$pid" && { + printf 'Validated %s process group %s did not stop\n' "$kind" "$pgid" >&2 + return 1 + } + demo_remove_record "$root" "$kind" +} diff --git a/tools/reset-local.sh b/tools/reset-local.sh new file mode 100755 index 0000000..e782dc0 --- /dev/null +++ b/tools/reset-local.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd -P) +# shellcheck source=process-lib.sh +source "$ROOT/tools/process-lib.sh" + +remove_exact() { + local path=$1 + if [[ -e "$path" ]]; then + rm -f -- "$path" + printf 'Removed %s\n' "${path#"$ROOT/"}" + fi +} + +remove_local_manifest() { + local path=$1 + [[ -e "$path" ]] || return 0 + if node -e 'const fs=require("fs"); const value=JSON.parse(fs.readFileSync(process.argv[1],"utf8")); process.exit(value && value.network === "anvil" && value.chainId === 31337 ? 0 : 1)' "$path" 2>/dev/null; then + remove_exact "$path" + else + printf 'Preserved non-local or invalid manifest %s\n' "${path#"$ROOT/"}" + fi +} + +demo_stop_recorded "$ROOT" vite +demo_stop_recorded "$ROOT" anvil + +remove_exact "$ROOT/.demo/anvil.pid" +remove_exact "$ROOT/.demo/anvil.start" +remove_exact "$ROOT/.demo/anvil.pgid" +remove_exact "$ROOT/.demo/anvil.log" +remove_exact "$ROOT/.demo/vite.pid" +remove_exact "$ROOT/.demo/vite.start" +remove_exact "$ROOT/.demo/vite.pgid" +remove_exact "$ROOT/.demo/vite.log" + +remove_local_manifest "$ROOT/deployments/pending.json" +remove_local_manifest "$ROOT/deployments/anvil.json" +remove_local_manifest "$ROOT/deployments/active.json" +remove_local_manifest "$ROOT/web/public/deployment.json" +remove_exact "$ROOT/web/src/generated/contracts.ts" + +rmdir -- "$ROOT/.demo" 2>/dev/null || true +printf '%s\n' 'Local generated state is reproducible with make demo-local.' diff --git a/tools/scan-project.sh b/tools/scan-project.sh new file mode 100755 index 0000000..55d5a8c --- /dev/null +++ b/tools/scan-project.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd -P) +cd "$ROOT" +mapfile -d '' -t TRACKED < <(git ls-files -z --cached --others --exclude-standard -- ':!docs/superpowers/**' ':!foundry.lock' ':!package-lock.json' ':!web/package-lock.json') + +secret_names='PRIVATE''_KEY|MNEM''ONIC' +assignment_pattern="(^|[^[:alnum:]_])(${secret_names})[[:space:]]*=" +pem_pattern='-----BEGIN .*PRI''VATE KEY-----' +unfinished_pattern='(^|[^[:alnum:]_])(TO''DO|T''BD|FIX''ME)([^[:alnum:]_]|$)' +filler_pattern='lorem[[:space:]]+ip''sum|fill''er[[:space:]]+text' +unsafe_pattern='unsafe''Allow|unsafe''SkipStorageCheck|unsafe''SkipAllChecks|oz-upgrades-unsafe-allow' +allowed_annotation=' /// @custom:oz-upgrades-unsafe-allow constructor' +fixture_name='ANVIL_''TEST_PHRASE' +fixture_value='test test test test test test test test test test test junk' +violations=0 + +report_matches() { + local path=$1 pattern=$2 label=$3 line number=0 + while IFS= read -r line || [[ -n "$line" ]]; do + number=$((number + 1)) + if [[ "$line" =~ $pattern ]]; then + printf 'forbidden %s: %s:%d:%s\n' "$label" "$path" "$number" "$line" >&2 + violations=$((violations + 1)) + fi + done <"$path" +} + +for path in "${TRACKED[@]}"; do + [[ "$path" == lib/* || ! -f "$path" ]] && continue + report_matches "$path" "$assignment_pattern" 'secret assignment' + report_matches "$path" "$pem_pattern" 'PEM private key' + report_matches "$path" "$unfinished_pattern" 'unfinished marker' + report_matches "$path" "$filler_pattern" 'filler content' + while IFS= read -r line || [[ -n "$line" ]]; do + if [[ "$line" == *"$fixture_name"*'='* && "$line" != *"$fixture_value"* ]]; then + printf 'forbidden non-fixture local phrase assignment: %s:%s\n' "$path" "$line" >&2 + violations=$((violations + 1)) + fi + done <"$path" + if [[ "$path" == src/* || "$path" == test/* || "$path" == script/* ]]; then + while IFS= read -r line || [[ -n "$line" ]]; do + if [[ "$line" =~ $unsafe_pattern && "$path:$line" != "src/BankV1.sol:$allowed_annotation" ]]; then + printf 'forbidden unsafe upgrade bypass: %s:%s\n' "$path" "$line" >&2 + violations=$((violations + 1)) + fi + done <"$path" + fi +done + +((violations == 0)) || { printf 'Project scan failed with %d violation(s).\n' "$violations" >&2; exit 1; } +printf 'Project scan passed across %d tracked paths.\n' "${#TRACKED[@]}" diff --git a/tools/test-process-safety.sh b/tools/test-process-safety.sh new file mode 100755 index 0000000..882e963 --- /dev/null +++ b/tools/test-process-safety.sh @@ -0,0 +1,201 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd -P) +PROCESS_LIB="$ROOT/tools/process-lib.sh" +RESET_SCRIPT="$ROOT/tools/reset-local.sh" +TEST_ROOT=$(mktemp -d /tmp/uups-bank-process-test.XXXXXX) +declare -a TEST_PIDS=() +PASSED=0 +STARTED_PID= + +cleanup() { + local pid + for pid in "${TEST_PIDS[@]}"; do + if [[ "$pid" =~ ^[0-9]+$ ]] && kill -0 "$pid" 2>/dev/null; then + kill -TERM -- "-$pid" 2>/dev/null || kill -TERM -- "$pid" 2>/dev/null || true + wait "$pid" 2>/dev/null || true + fi + done + rm -rf -- "$TEST_ROOT" +} +trap cleanup EXIT + +fail() { printf 'FAIL: %s\n' "$*" >&2; exit 1; } +pass() { PASSED=$((PASSED + 1)); printf 'ok %d - %s\n' "$PASSED" "$1"; } +assert_exists() { [[ -e "$1" ]] || fail "expected $1 to exist"; } +assert_absent() { [[ ! -e "$1" ]] || fail "expected $1 to be absent"; } +assert_dead() { ! kill -0 "$1" 2>/dev/null || fail "expected PID $1 to be stopped"; } +assert_alive() { kill -0 "$1" 2>/dev/null || fail "expected PID $1 to remain alive"; } + +# RED gate: these are the missing production interfaces this suite specifies. +[[ -f "$PROCESS_LIB" ]] || fail "missing process library: $PROCESS_LIB" +[[ -x "$RESET_SCRIPT" ]] || fail "missing executable reset script: $RESET_SCRIPT" +# shellcheck source=process-lib.sh +source "$PROCESS_LIB" + +make_root() { + local root="$TEST_ROOT/$1" + mkdir -p "$root/.demo" "$root/tools" "$root/deployments" "$root/web/public" "$root/web/src/generated" + cp "$PROCESS_LIB" "$RESET_SCRIPT" "$root/tools/" + chmod +x "$root/tools/reset-local.sh" + printf '%s\n' "$root" +} + +write_record() { + local root=$1 kind=$2 pid=$3 start=$4 pgid=$5 + printf '%s\n' "$pid" >"$root/.demo/$kind.pid" + printf '%s\n' "$start" >"$root/.demo/$kind.start" + printf '%s\n' "$pgid" >"$root/.demo/$kind.pgid" +} + +start_tick() { + local stat rest + local -a fields + stat=$(<"/proc/$1/stat") + rest=${stat##*) } + read -r -a fields <<<"$rest" + printf '%s\n' "${fields[19]}" +} + +start_owned() { + local root=$1 kind=$2 helper="$TEST_ROOT/bin/$2" + mkdir -p "$TEST_ROOT/bin" + cat >"$helper" <<'HELPER' +#!/usr/bin/env bash +sleep 120 & +wait +HELPER + chmod +x "$helper" + if [[ "$kind" == anvil ]]; then + setsid "$helper" --host 127.0.0.1 --port 8545 --chain-id 31337 >/dev/null 2>&1 & + else + setsid "$helper" web --host 127.0.0.1 --port 5173 >/dev/null 2>&1 & + fi + local pid=$! + TEST_PIDS+=("$pid") + write_record "$root" "$kind" "$pid" "$(start_tick "$pid")" "$pid" + STARTED_PID=$pid +} + +printf '1..11\n' + +# Catches cleanup treating a missing record as an error or signaling an inferred PID. +root=$(make_root absent) +demo_stop_recorded "$root" anvil +pass 'an absent PID file is a no-op' + +# Catches stale PID metadata accumulating or being treated as a live target. +root=$(make_root stale) +write_record "$root" anvil 999999999 1 999999999 +demo_stop_recorded "$root" anvil +assert_absent "$root/.demo/anvil.pid" +assert_absent "$root/.demo/anvil.start" +assert_absent "$root/.demo/anvil.pgid" +pass 'a stale numeric PID record is removed' + +# Catches unvalidated PID text reaching kill or shell option parsing. +root=$(make_root nonnumeric) +write_record "$root" anvil 'not-a-pid' 1 1 +if demo_stop_recorded "$root" anvil 2>/dev/null; then fail 'nonnumeric PID was accepted'; fi +assert_exists "$root/.demo/anvil.pid" +pass 'a nonnumeric PID is rejected' + +# Catches PID collision signaling an unrelated live process with the wrong command. +root=$(make_root wrong-command) +setsid /bin/sleep 120 & wrong_pid=$! +TEST_PIDS+=("$wrong_pid") +write_record "$root" anvil "$wrong_pid" "$(start_tick "$wrong_pid")" "$wrong_pid" +if demo_stop_recorded "$root" anvil 2>/dev/null; then fail 'wrong command signature was accepted'; fi +assert_alive "$wrong_pid" +pass 'a live PID with the wrong command signature is never signaled' + +# Catches recycled PID ownership being inferred from PID and command alone. +root=$(make_root reused) +start_owned "$root" anvil +reused_pid=$STARTED_PID +printf '%s\n' "$(( $(start_tick "$reused_pid") + 1 ))" >"$root/.demo/anvil.start" +if demo_stop_recorded "$root" anvil 2>/dev/null; then fail 'mismatched start tick was accepted'; fi +assert_alive "$reused_pid" +pass 'PID reuse is rejected by recorded process start tick' + +# Catches a validated project child not being terminated and reaped. +root=$(make_root matching) +start_owned "$root" anvil +matching_pid=$STARTED_PID +demo_stop_recorded "$root" anvil +wait "$matching_pid" 2>/dev/null || true +assert_dead "$matching_pid" +assert_absent "$root/.demo/anvil.pid" +pass 'a matching project-started child is terminated and reaped' + +# Catches stopping only the Vite leader and orphaning a child, or broad group signaling. +root=$(make_root group) +group_helper="$TEST_ROOT/bin/vite" +child_file="$TEST_ROOT/vite-child.pid" +cat >"$group_helper" <<'HELPER' +#!/usr/bin/env bash +sleep 120 & +printf '%s\n' "$!" >"$DEMO_CHILD_PID_FILE" +wait +HELPER +chmod +x "$group_helper" +DEMO_CHILD_PID_FILE="$child_file" setsid "$group_helper" web --host 127.0.0.1 --port 5173 & group_pid=$! +TEST_PIDS+=("$group_pid") +for _ in {1..50}; do [[ -s "$child_file" ]] && break; sleep 0.02; done +[[ -s "$child_file" ]] || fail 'Vite helper child did not start' +child_pid=$(<"$child_file") +setsid /bin/sleep 120 & unrelated_pid=$! +TEST_PIDS+=("$unrelated_pid") +write_record "$root" vite "$group_pid" "$(start_tick "$group_pid")" "$group_pid" +demo_stop_recorded "$root" vite +wait "$group_pid" 2>/dev/null || true +for _ in {1..50}; do ! kill -0 "$child_pid" 2>/dev/null && break; sleep 0.02; done +assert_dead "$child_pid" +assert_alive "$unrelated_pid" +pass 'an owned process group is stopped completely while an unrelated group survives' + +# Catches reset deleting arbitrary neighbors or leaving known reproducible local artifacts. +root=$(make_root local-reset) +for kind in anvil vite; do printf 'log\n' >"$root/.demo/$kind.log"; printf '1\n' >"$root/.demo/$kind.start"; printf '1\n' >"$root/.demo/$kind.pgid"; done +printf 'sentinel\n' >"$root/.demo/sentinel" +for path in deployments/pending.json deployments/anvil.json deployments/active.json web/public/deployment.json; do + printf '{"network":"anvil","chainId":31337}\n' >"$root/$path" +done +printf 'generated ABI\n' >"$root/web/src/generated/contracts.ts" +(cd "$root" && bash tools/reset-local.sh >/dev/null) +for path in .demo/anvil.pid .demo/anvil.start .demo/anvil.pgid .demo/anvil.log .demo/vite.pid .demo/vite.start .demo/vite.pgid .demo/vite.log deployments/pending.json deployments/anvil.json deployments/active.json web/public/deployment.json web/src/generated/contracts.ts; do + assert_absent "$root/$path" +done +assert_exists "$root/.demo/sentinel" +pass 'reset removes only explicitly known local runtime and generated files' + +# Catches a local reset corrupting a canonical Base Sepolia deployment. +root=$(make_root base-canonical) +printf '{"network":"baseSepolia","chainId":84532,"marker":"canonical"}\n' >"$root/deployments/base-sepolia.json" +before=$(sha256sum "$root/deployments/base-sepolia.json") +(cd "$root" && bash tools/reset-local.sh >/dev/null) +after=$(sha256sum "$root/deployments/base-sepolia.json") +[[ "$before" == "$after" ]] || fail 'Base canonical manifest changed' +pass 'a Base canonical manifest survives reset byte-for-byte' + +# Catches a local reset deleting or rewriting selected and browser-copied Base state. +root=$(make_root base-active) +base='{"network":"baseSepolia","chainId":84532,"marker":"active"}' +printf '%s\n' "$base" >"$root/deployments/active.json" +printf '%s\n' "$base" >"$root/web/public/deployment.json" +active_before=$(sha256sum "$root/deployments/active.json") +browser_before=$(sha256sum "$root/web/public/deployment.json") +(cd "$root" && bash tools/reset-local.sh >/dev/null) +[[ "$active_before" == "$(sha256sum "$root/deployments/active.json")" ]] || fail 'Base active manifest changed' +[[ "$browser_before" == "$(sha256sum "$root/web/public/deployment.json")" ]] || fail 'Base browser manifest changed' +pass 'Base active and browser manifests survive reset byte-for-byte' + +# Catches cleanup broadening from exact files to recursive .demo deletion. +root=$(make_root sentinel) +printf 'keep me\n' >"$root/.demo/adjacent.keep" +(cd "$root" && bash tools/reset-local.sh >/dev/null) +assert_exists "$root/.demo/adjacent.keep" +pass 'a sentinel adjacent to runtime records survives' + +printf 'PASS: %d process-safety cases\n' "$PASSED" From a71010693284632b872ed1f3ced70c31dac3d070 Mon Sep 17 00:00:00 2001 From: golem Date: Fri, 21 Aug 2026 04:31:37 -0600 Subject: [PATCH 17/30] fix: harden demo lifecycle and scanner --- Makefile | 1 + docs/LEARNING_GUIDE.md | 25 ++- tools/demo-local.sh | 35 +++- tools/finalize-manifest.mjs | 4 +- tools/process-lib.sh | 342 ++++++++++++++++++++++++++--------- tools/scan-project.sh | 19 +- tools/test-process-safety.sh | 173 ++++++++++++++++-- tools/test-scan-project.sh | 38 ++++ 8 files changed, 509 insertions(+), 128 deletions(-) create mode 100755 tools/test-scan-project.sh diff --git a/Makefile b/Makefile index 32b0da7..598bae8 100644 --- a/Makefile +++ b/Makefile @@ -22,6 +22,7 @@ verify: @node tools/test-finalize-manifest.mjs @node tools/test-sync-web-artifacts.mjs @bash tools/test-process-safety.sh + @bash tools/test-scan-project.sh @bash tools/scan-project.sh @npm --prefix web run lint @npm --prefix web run typecheck diff --git a/docs/LEARNING_GUIDE.md b/docs/LEARNING_GUIDE.md index beabfa0..c719ba6 100644 --- a/docs/LEARNING_GUIDE.md +++ b/docs/LEARNING_GUIDE.md @@ -91,11 +91,32 @@ forge test --match-test 'test(ProxyCannotBeInitializedTwice|ImplementationCannot # OpenZeppelin ReentrancyGuardReentrantCall forge test --match-test testDepositPropagatesNestedRevertAtomicallyWhenConfigured -vv -# Script UnsupportedChain (the test deliberately uses a rejected chain) +# DemoScript.UnsupportedChain (the test deliberately uses a rejected chain) forge test --match-test testUnsupportedChainsAreRejectedBeforeBroadcast -vv -# ManifestChainMismatch and MissingCode +# DemoScript.ManifestChainMismatch and DemoScript.MissingCode forge test --match-test 'test(WrongManifestChain|AddressWithoutCode)IsRejected' -vv + +# DemoScript.InvalidDeploymentBlock +forge test --match-test testActiveManifestRejectsDeploymentBlockZero -vv + +# DemoScript.InvalidManifestSchema +forge test --match-test testLegacyParallelActorManifestIsRejected -vv + +# DemoScript.InvalidActorConfiguration +forge test --match-test testAnvilManifestRequiresOwnerAtActorZero -vv + +# DemoScript.UnexpectedState example payload: label, expected, actual +cast calldata 'UnexpectedState(string,uint256,uint256)' 'reserves' 1400000000 1399000000 + +# DemoScript.UnexpectedAddress, including expected/actual implementation addresses +forge test --match-test testCheckStateRejectsManifestImplementationMismatch -vv + +# CheckState.Insolvent example payload: reserves below ledger liabilities +cast calldata 'Insolvent(uint256,uint256)' 1399000000 1400000000 + +# CheckState.UnknownStage example payload +cast calldata 'UnknownStage(string)' 'mystery' ``` Finish by running `make verify`; it combines unit, fuzz, invariant, script, process-safety, scanner, and web gates. diff --git a/tools/demo-local.sh b/tools/demo-local.sh index fc50ab2..f7cfa3b 100755 --- a/tools/demo-local.sh +++ b/tools/demo-local.sh @@ -4,16 +4,26 @@ set -euo pipefail ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd -P) # shellcheck source=process-lib.sh source "$ROOT/tools/process-lib.sh" -ANVIL_TEST_PHRASE='test test test test test test test test test test test junk' +ANVIL_DEV_WORDS=(test test test test test test test test test test test junk) CLEANING=0 +ANVIL_PID='' ANVIL_START='' ANVIL_PGID='' +VITE_PID='' VITE_START='' VITE_PGID='' cleanup() { local status=$? cleanup_status=0 ((CLEANING == 0)) || return CLEANING=1 trap - INT TERM EXIT - demo_stop_recorded "$ROOT" vite || cleanup_status=1 - demo_stop_recorded "$ROOT" anvil || cleanup_status=1 + if [[ -n "$VITE_PID" && -n "$VITE_START" && -n "$VITE_PGID" ]]; then + demo_stop_launch "$ROOT" vite "$VITE_PID" "$VITE_START" "$VITE_PGID" || cleanup_status=1 + else + demo_stop_recorded "$ROOT" vite || cleanup_status=1 + fi + if [[ -n "$ANVIL_PID" && -n "$ANVIL_START" && -n "$ANVIL_PGID" ]]; then + demo_stop_launch "$ROOT" anvil "$ANVIL_PID" "$ANVIL_START" "$ANVIL_PGID" || cleanup_status=1 + else + demo_stop_recorded "$ROOT" anvil || cleanup_status=1 + fi ((status == 0 && cleanup_status != 0)) && status=$cleanup_status exit "$status" } @@ -21,14 +31,19 @@ trap 'exit 130' INT trap 'exit 143' TERM trap cleanup EXIT -record_launched() { - local kind=$1 pid=$2 +capture_and_publish() { + local kind=$1 pid=$2 start pgid for _ in {1..50}; do - if demo_record_process "$ROOT" "$kind" "$pid"; then return 0; fi + if demo_capture_process "$ROOT" "$kind" "$pid" start pgid; then + if [[ "$kind" == anvil ]]; then ANVIL_START=$start; ANVIL_PGID=$pgid + else VITE_START=$start; VITE_PGID=$pgid; fi + demo_publish_process "$ROOT" "$kind" "$pid" "$start" "$pgid" + return + fi demo_pid_is_running "$pid" || break sleep 0.02 done - printf 'Could not prove identity for launched %s PID %s; stopping without targeting an unverified PID.\n' "$kind" "$pid" >&2 + printf 'Could not prove identity for launched %s PID %s; no signal will be guessed.\n' "$kind" "$pid" >&2 return 1 } @@ -37,9 +52,9 @@ bash tools/reset-local.sh bash tools/doctor.sh install -d -m 0700 "$ROOT/.demo" -setsid anvil --host 127.0.0.1 --port 8545 --chain-id 31337 --mnemonic "$ANVIL_TEST_PHRASE" >"$ROOT/.demo/anvil.log" 2>&1 & +setsid anvil --host 127.0.0.1 --port 8545 --chain-id 31337 --mnemonic "${ANVIL_DEV_WORDS[*]}" >"$ROOT/.demo/anvil.log" 2>&1 & ANVIL_PID=$! -record_launched anvil "$ANVIL_PID" +capture_and_publish anvil "$ANVIL_PID" for _ in {1..100}; do if [[ $(cast chain-id --rpc-url http://127.0.0.1:8545 2>/dev/null || true) == 31337 ]]; then ANVIL_READY=1; break; fi demo_pid_is_running "$ANVIL_PID" || break @@ -57,7 +72,7 @@ node tools/publish-web-manifest.mjs setsid "$ROOT/web/node_modules/.bin/vite" web --host 127.0.0.1 --port 5173 >"$ROOT/.demo/vite.log" 2>&1 & VITE_PID=$! -record_launched vite "$VITE_PID" +capture_and_publish vite "$VITE_PID" for _ in {1..100}; do if curl --fail --silent --output /dev/null http://127.0.0.1:5173/; then VITE_READY=1; break; fi demo_pid_is_running "$VITE_PID" || break diff --git a/tools/finalize-manifest.mjs b/tools/finalize-manifest.mjs index 47c2b78..0ac0bb1 100644 --- a/tools/finalize-manifest.mjs +++ b/tools/finalize-manifest.mjs @@ -17,7 +17,7 @@ const NETWORKS = { const REQUIRED_MANIFEST_FIELDS = ["schemaVersion", "network", "chainId", "deploymentBlock", "token", "proxy", "implementation", "owner", "actors"]; const OPTIONAL_MANIFEST_FIELDS = ["rpcUrl", "explorerBaseUrl"]; -const ANVIL_TEST_PHRASE = "test test test test test test test test test test test junk"; +const ANVIL_TEST_WORDS = [...Array(11).fill("test"), "junk"].join(" "); const PROHIBITED_STRING_VALUE = /(?:private[_ -]?key|mnemonic|secret|password|credential|api[_ -]?key)|0x[a-fA-F0-9]{64}/i; export function networkSpec(network) { @@ -202,7 +202,7 @@ function assertActorConfiguration(manifest) { function rejectProhibitedStringValues(value, path = "") { if (typeof value === "string") { - if (value.includes(ANVIL_TEST_PHRASE) || PROHIBITED_STRING_VALUE.test(value)) { + if (value.includes(ANVIL_TEST_WORDS) || PROHIBITED_STRING_VALUE.test(value)) { throw new Error(`manifest contains prohibited secret material at ${path}`); } return; diff --git a/tools/process-lib.sh b/tools/process-lib.sh index 9623b04..b2eea7d 100755 --- a/tools/process-lib.sh +++ b/tools/process-lib.sh @@ -8,6 +8,10 @@ demo_is_uint() { [[ ${1:-} =~ ^[0-9]+$ ]] } +demo_is_process_id() { + [[ ${1:-} =~ ^[1-9][0-9]*$ ]] && ((10#$1 > 1)) +} + demo_read_one_line() { local path=$1 destination=$2 value extra IFS= read -r value <"$path" || return 1 @@ -15,33 +19,83 @@ demo_read_one_line() { printf -v "$destination" '%s' "$value" } -demo_start_tick() { - local pid=$1 stat rest +demo_read_process_identity() { + local pid=$1 state_name=$2 start_name=$3 pgid_name=$4 sid_name=$5 stat rest local -a fields - demo_is_uint "$pid" || return 1 - [[ -r "/proc/$pid/stat" ]] || return 1 - stat=$(<"/proc/$pid/stat") + demo_is_process_id "$pid" || return 1 + { IFS= read -r stat <"/proc/$pid/stat"; } 2>/dev/null || return 1 rest=${stat##*) } read -r -a fields <<<"$rest" - demo_is_uint "${fields[19]:-}" || return 1 - printf '%s\n' "${fields[19]}" + ((${#fields[@]} >= 20)) || return 1 + demo_is_uint "${fields[2]}" && demo_is_uint "${fields[3]}" && demo_is_uint "${fields[19]}" || return 1 + printf -v "$state_name" '%s' "${fields[0]}" + printf -v "$pgid_name" '%s' "${fields[2]}" + printf -v "$sid_name" '%s' "${fields[3]}" + printf -v "$start_name" '%s' "${fields[19]}" +} + +demo_start_tick() { + local state start pgid sid + demo_read_process_identity "$1" state start pgid sid || return 1 + printf '%s\n' "$start" } demo_process_group() { - local pid=$1 pgid - pgid=$(ps -o pgid= -p "$pid" 2>/dev/null) || return 1 - pgid=${pgid//[[:space:]]/} - demo_is_uint "$pgid" || return 1 + local state start pgid sid + demo_read_process_identity "$1" state start pgid sid || return 1 printf '%s\n' "$pgid" } -demo_has_sequence() { - local array_name=$1 +demo_pid_is_running() { + local state start pgid sid + demo_read_process_identity "$1" state start pgid sid && [[ "$state" != Z ]] +} + +demo_process_has_command_line() { + local pid=$1 first + { IFS= read -r -d '' first <"/proc/$pid/cmdline"; } 2>/dev/null || return 1 + [[ -n "$first" ]] +} + +demo_resolve_command() { + local value=$1 resolved + if [[ "$value" == */* ]]; then + readlink -f -- "$value" 2>/dev/null + else + resolved=$(command -v -- "$value" 2>/dev/null) || return 1 + readlink -f -- "$resolved" 2>/dev/null + fi +} + +demo_entry_index() { + local expected=$1 array_name=$2 argument resolved index local -n argv_ref=$array_name - shift + for index in "${!argv_ref[@]}"; do + argument=${argv_ref[index]} + resolved=$(demo_resolve_command "$argument" 2>/dev/null || true) + if [[ "$resolved" == "$expected" ]]; then printf '%s\n' "$index"; return 0; fi + done + return 1 +} + +demo_interpreter_for_entry() { + local entry=$1 line interpreter + IFS= read -r line <"$entry" 2>/dev/null || return 1 + [[ "$line" == '#!'* ]] || return 1 + line=${line#\#!} + read -r -a interpreter <<<"$line" + ((${#interpreter[@]} > 0)) || return 1 + if [[ ${interpreter[0]} == */env && ${interpreter[1]:-} ]]; then demo_resolve_command "${interpreter[1]}" + else demo_resolve_command "${interpreter[0]}"; fi +} + +demo_has_sequence_from() { + local array_name=$1 begin=$2 + local -n argv_ref=$array_name + shift 2 local -a wanted=("$@") local i j - for ((i = 0; i + ${#wanted[@]} <= ${#argv_ref[@]}; i++)); do + for ((i = begin; i + ${#wanted[@]} <= ${#argv_ref[@]}; i++)); do for ((j = 0; j < ${#wanted[@]}; j++)); do [[ ${argv_ref[i+j]} == "${wanted[j]}" ]] || break done @@ -51,26 +105,54 @@ demo_has_sequence() { } demo_command_matches() { - local pid=$1 kind=$2 argument base found=0 + local root=$1 pid=$2 kind=$3 expected executable interpreter entry_index local -a arguments=() - [[ -r "/proc/$pid/cmdline" ]] || return 1 + [[ -r "/proc/$pid/cmdline" && -e "/proc/$pid/exe" ]] || return 1 mapfile -d '' -t arguments <"/proc/$pid/cmdline" ((${#arguments[@]} > 0)) || return 1 - for argument in "${arguments[@]}"; do - base=${argument##*/} - if [[ "$kind" == anvil && "$base" == anvil ]]; then found=1; fi - if [[ "$kind" == vite && ( "$base" == vite || "$base" == vite.js ) ]]; then found=1; fi - done - ((found == 1)) || return 1 + executable=$(readlink -f -- "/proc/$pid/exe") || return 1 + if [[ "$kind" == anvil ]]; then - demo_has_sequence arguments --host 127.0.0.1 || return 1 - demo_has_sequence arguments --port 8545 || return 1 - demo_has_sequence arguments --chain-id 31337 || return 1 + expected=$(demo_resolve_command anvil) || return 1 elif [[ "$kind" == vite ]]; then - demo_has_sequence arguments web --host 127.0.0.1 --port 5173 || return 1 + expected=$(readlink -f -- "$root/web/node_modules/.bin/vite" 2>/dev/null) || return 1 else return 1 fi + entry_index=$(demo_entry_index "$expected" arguments) || return 1 + if [[ "$executable" != "$expected" ]]; then + interpreter=$(demo_interpreter_for_entry "$expected") || return 1 + [[ "$executable" == "$interpreter" && "$entry_index" -eq 1 ]] || return 1 + else + [[ "$entry_index" -eq 0 ]] || return 1 + fi + + if [[ "$kind" == anvil ]]; then + demo_has_sequence_from arguments "$((entry_index + 1))" --host 127.0.0.1 || return 1 + demo_has_sequence_from arguments "$((entry_index + 1))" --port 8545 || return 1 + demo_has_sequence_from arguments "$((entry_index + 1))" --chain-id 31337 || return 1 + else + local -a expected_tail=(web --host 127.0.0.1 --port 5173) + local -a actual_tail=("${arguments[@]:entry_index+1}") + [[ "${actual_tail[*]}" == "${expected_tail[*]}" && ${#actual_tail[@]} -eq ${#expected_tail[@]} ]] || return 1 + fi +} + +demo_identity_matches() { + local root=$1 kind=$2 pid=$3 expected_start=$4 expected_pgid=$5 state start pgid sid + demo_read_process_identity "$pid" state start pgid sid || return 1 + [[ "$state" != Z && "$start" == "$expected_start" && "$pgid" == "$expected_pgid" \ + && "$sid" == "$expected_pgid" && "$pid" == "$expected_pgid" ]] || return 1 + demo_command_matches "$root" "$pid" "$kind" +} + +demo_capture_process() { + local root=$1 kind=$2 pid=$3 start_name=$4 pgid_name=$5 state actual_start actual_pgid sid + demo_read_process_identity "$pid" state actual_start actual_pgid sid || return 1 + [[ "$state" != Z && "$pid" == "$actual_pgid" && "$pid" == "$sid" ]] || return 1 + demo_command_matches "$root" "$pid" "$kind" || return 1 + printf -v "$start_name" '%s' "$actual_start" + printf -v "$pgid_name" '%s' "$actual_pgid" } demo_remove_record() { @@ -83,82 +165,162 @@ demo_remove_record() { done } -demo_record_process() { - local root=$1 kind=$2 pid=$3 start pgid - [[ "$kind" == anvil || "$kind" == vite ]] || return 1 - demo_is_uint "$pid" || return 1 - start=$(demo_start_tick "$pid") || return 1 - pgid=$(demo_process_group "$pid") || return 1 - [[ "$pid" == "$pgid" ]] || return 1 - demo_command_matches "$pid" "$kind" || return 1 +demo_publish_process() { + local root=$1 kind=$2 pid=$3 start=$4 pgid=$5 path + demo_is_process_id "$pid" && demo_is_uint "$start" && demo_is_process_id "$pgid" && [[ "$pid" == "$pgid" ]] || return 1 + demo_identity_matches "$root" "$kind" "$pid" "$start" "$pgid" || return 1 mkdir -p "$root/.demo" chmod 0700 "$root/.demo" - printf '%s\n' "$pid" >"$root/.demo/$kind.pid" + for path in "$root/.demo/$kind.pid" "$root/.demo/$kind.start" "$root/.demo/$kind.pgid"; do [[ ! -e "$path" ]] || return 1; done printf '%s\n' "$start" >"$root/.demo/$kind.start" printf '%s\n' "$pgid" >"$root/.demo/$kind.pgid" - chmod 0600 "$root/.demo/$kind.pid" "$root/.demo/$kind.start" "$root/.demo/$kind.pgid" + chmod 0600 "$root/.demo/$kind.start" "$root/.demo/$kind.pgid" + demo_identity_matches "$root" "$kind" "$pid" "$start" "$pgid" || return 1 + # PID is the atomic commit marker. PID equals PGID, so a hard link publishes + # the validated value without a temporary path or separately visible write. + ln -- "$root/.demo/$kind.pgid" "$root/.demo/$kind.pid" || return 1 + chmod 0600 "$root/.demo/$kind.pid" } -demo_pid_is_running() { - local pid=$1 stat rest state - { IFS= read -r stat <"/proc/$pid/stat"; } 2>/dev/null || return 1 - rest=${stat##*) } - state=${rest%% *} - [[ "$state" != Z ]] +demo_record_process() { + local root=$1 kind=$2 pid=$3 start pgid + demo_capture_process "$root" "$kind" "$pid" start pgid || return 1 + demo_publish_process "$root" "$kind" "$pid" "$start" "$pgid" +} + +demo_collect_group_members() { + local expected_pgid=$1 expected_sid=$2 output_name=$3 pid ps_pgid ps_sid ps_state state start pgid sid + local -n output_ref=$output_name + output_ref=() + while read -r pid ps_pgid ps_sid ps_state; do + [[ "$ps_pgid" == "$expected_pgid" && "$ps_sid" == "$expected_sid" && "$ps_state" != Z* ]] || continue + demo_read_process_identity "$pid" state start pgid sid || continue + [[ "$state" != Z && "$pgid" == "$expected_pgid" && "$sid" == "$expected_sid" ]] || continue + output_ref+=("$pid:$start") + done < <(ps -eo pid=,pgid=,sid=,stat=) +} + +demo_anchor_allows_signal() { + local root=$1 kind=$2 leader=$3 expected_start=$4 expected_pgid=$5 state start pgid sid + if ! demo_read_process_identity "$leader" state start pgid sid; then return 0; fi + [[ "$start" == "$expected_start" && "$pgid" == "$expected_pgid" && "$sid" == "$expected_pgid" ]] || return 1 + [[ "$state" == Z ]] && return 0 + demo_process_has_command_line "$leader" || return 0 + demo_command_matches "$root" "$leader" "$kind" +} + +demo_signal_member() { + local pid=$1 expected_start=$2 expected_pgid=$3 expected_sid=$4 signal=$5 state start pgid sid + if ! demo_read_process_identity "$pid" state start pgid sid; then return 0; fi + [[ "$state" != Z ]] || return 0 + [[ "$start" == "$expected_start" && "$pgid" == "$expected_pgid" && "$sid" == "$expected_sid" ]] || return 2 + kill -"$signal" -- "$pid" +} + +demo_group_has_live_members() { + local pgid=$1 sid=$2 + local -a members + demo_collect_group_members "$pgid" "$sid" members + ((${#members[@]} > 0)) +} + +demo_stop_launch() { + local root=$1 kind=$2 leader=$3 expected_start=$4 expected_pgid=$5 member member_pid member_start result + local -a members + demo_is_process_id "$leader" && demo_is_uint "$expected_start" && demo_is_process_id "$expected_pgid" \ + && [[ "$leader" == "$expected_pgid" ]] || return 1 + demo_anchor_allows_signal "$root" "$kind" "$leader" "$expected_start" "$expected_pgid" || { + printf 'Refusing %s PID %s: process identity changed\n' "$kind" "$leader" >&2 + return 1 + } + demo_collect_group_members "$expected_pgid" "$expected_pgid" members + if ((${#members[@]} == 0)); then + demo_remove_record "$root" "$kind" + return 0 + fi + + printf 'Stopping validated %s session/process group %s\n' "$kind" "$expected_pgid" + for member in "${members[@]}"; do + IFS=: read -r member_pid member_start <<<"$member" + [[ "$member_pid" != "$leader" ]] || continue + demo_anchor_allows_signal "$root" "$kind" "$leader" "$expected_start" "$expected_pgid" || return 1 + demo_signal_member "$member_pid" "$member_start" "$expected_pgid" "$expected_pgid" TERM || return 1 + done + for member in "${members[@]}"; do + IFS=: read -r member_pid member_start <<<"$member" + [[ "$member_pid" == "$leader" ]] || continue + demo_anchor_allows_signal "$root" "$kind" "$leader" "$expected_start" "$expected_pgid" || return 1 + demo_signal_member "$member_pid" "$member_start" "$expected_pgid" "$expected_pgid" TERM || return 1 + done + for ((result = 0; result < ${DEMO_TERM_WAIT_ATTEMPTS:-50}; result++)); do + demo_group_has_live_members "$expected_pgid" "$expected_pgid" || break + sleep 0.1 + done + + if demo_group_has_live_members "$expected_pgid" "$expected_pgid"; then + printf 'Escalating surviving validated %s session members to KILL\n' "$kind" + for ((result = 0; result < ${DEMO_KILL_WAIT_ATTEMPTS:-20}; result++)); do + demo_collect_group_members "$expected_pgid" "$expected_pgid" members + ((${#members[@]} > 0)) || break + for member in "${members[@]}"; do + IFS=: read -r member_pid member_start <<<"$member" + if ! demo_anchor_allows_signal "$root" "$kind" "$leader" "$expected_start" "$expected_pgid"; then + printf 'Refusing KILL escalation: %s leader identity changed\n' "$kind" >&2 + return 1 + fi + if demo_signal_member "$member_pid" "$member_start" "$expected_pgid" "$expected_pgid" KILL; then + : + else + result=$? + printf 'Refusing KILL escalation: %s member identity changed (%s)\n' "$kind" "$result" >&2 + return 1 + fi + done + sleep 0.05 + done + fi + wait "$leader" 2>/dev/null || true + if demo_group_has_live_members "$expected_pgid" "$expected_pgid"; then + printf 'Validated %s session/process group %s did not stop\n' "$kind" "$expected_pgid" >&2 + return 1 + fi + demo_remove_record "$root" "$kind" } demo_stop_recorded() { - local root=$1 kind=$2 pid start pgid actual_start actual_pgid - local pid_path="$root/.demo/$kind.pid" - local start_path="$root/.demo/$kind.start" - local pgid_path="$root/.demo/$kind.pgid" + local root=$1 kind=$2 pid start pgid + local pid_path="$root/.demo/$kind.pid" start_path="$root/.demo/$kind.start" pgid_path="$root/.demo/$kind.pgid" [[ "$kind" == anvil || "$kind" == vite ]] || return 1 - [[ -e "$pid_path" ]] || return 0 - if ! demo_read_one_line "$pid_path" pid || [[ ! "$pid" =~ ^[1-9][0-9]*$ ]] || ((10#$pid <= 1)); then - printf 'Refusing invalid %s PID record\n' "$kind" >&2 - return 1 + if [[ ! -e "$pid_path" ]]; then + if [[ ! -e "$start_path" && ! -e "$pgid_path" ]]; then return 0; fi + if [[ -e "$start_path" && -e "$pgid_path" ]] && demo_read_one_line "$start_path" start \ + && demo_read_one_line "$pgid_path" pgid && demo_is_uint "$start" && demo_is_process_id "$pgid"; then + pid=$pgid + printf 'Recovering partial %s launch record with validated in-memory shape\n' "$kind" + else + printf 'Removing incomplete %s launch metadata without signaling\n' "$kind" + demo_remove_record "$root" "$kind" + return 0 + fi + else + if ! demo_read_one_line "$pid_path" pid || ! demo_is_process_id "$pid"; then + printf 'Refusing invalid %s PID record\n' "$kind" >&2 + return 1 + fi + if ! demo_read_one_line "$start_path" start || ! demo_is_uint "$start"; then + printf 'Refusing incomplete %s start-tick record\n' "$kind" >&2 + return 1 + fi + if ! demo_read_one_line "$pgid_path" pgid || ! demo_is_process_id "$pgid"; then + printf 'Refusing incomplete %s process-group record\n' "$kind" >&2 + return 1 + fi fi - if ! demo_pid_is_running "$pid"; then + [[ "$pid" == "$pgid" ]] || { printf 'Refusing %s: PID/PGID ownership mismatch\n' "$kind" >&2; return 1; } + if ! demo_group_has_live_members "$pgid" "$pgid" && [[ ! -e "/proc/$pid/stat" ]]; then printf 'Discarding stale %s process record for PID %s\n' "$kind" "$pid" demo_remove_record "$root" "$kind" return 0 fi - if ! demo_read_one_line "$start_path" start || ! demo_is_uint "$start"; then - printf 'Refusing incomplete %s start-tick record\n' "$kind" >&2 - return 1 - fi - if ! demo_read_one_line "$pgid_path" pgid || [[ ! "$pgid" =~ ^[1-9][0-9]*$ ]] || ((10#$pgid <= 1)); then - printf 'Refusing incomplete %s process-group record\n' "$kind" >&2 - return 1 - fi - actual_start=$(demo_start_tick "$pid") || return 1 - actual_pgid=$(demo_process_group "$pid") || return 1 - if [[ "$actual_start" != "$start" || "$actual_pgid" != "$pgid" || "$pid" != "$pgid" ]]; then - printf 'Refusing %s PID %s: recorded identity does not match\n' "$kind" "$pid" >&2 - return 1 - fi - if ! demo_command_matches "$pid" "$kind"; then - printf 'Refusing %s PID %s: command signature does not match\n' "$kind" "$pid" >&2 - return 1 - fi - printf 'Stopping validated %s process group %s\n' "$kind" "$pgid" - kill -TERM -- "-$pgid" - for _ in {1..50}; do - demo_pid_is_running "$pid" || break - sleep 0.1 - done - if demo_pid_is_running "$pid"; then - printf 'Escalating validated %s process group %s to KILL\n' "$kind" "$pgid" - kill -KILL -- "-$pgid" - for _ in {1..20}; do - demo_pid_is_running "$pid" || break - sleep 0.1 - done - fi - wait "$pid" 2>/dev/null || true - demo_pid_is_running "$pid" && { - printf 'Validated %s process group %s did not stop\n' "$kind" "$pgid" >&2 - return 1 - } - demo_remove_record "$root" "$kind" + demo_stop_launch "$root" "$kind" "$pid" "$start" "$pgid" } diff --git a/tools/scan-project.sh b/tools/scan-project.sh index 55d5a8c..e576714 100755 --- a/tools/scan-project.sh +++ b/tools/scan-project.sh @@ -13,7 +13,9 @@ filler_pattern='lorem[[:space:]]+ip''sum|fill''er[[:space:]]+text' unsafe_pattern='unsafe''Allow|unsafe''SkipStorageCheck|unsafe''SkipAllChecks|oz-upgrades-unsafe-allow' allowed_annotation=' /// @custom:oz-upgrades-unsafe-allow constructor' fixture_name='ANVIL_''TEST_PHRASE' -fixture_value='test test test test test test test test test test test junk' +allowed_fixture_path='script/lib/DemoScript.sol' +allowed_fixture_line=' string internal constant ANVIL_''TEST_PHRASE = "test test test test test test test test test test test junk";' +fixture_count=0 violations=0 report_matches() { @@ -34,9 +36,13 @@ for path in "${TRACKED[@]}"; do report_matches "$path" "$unfinished_pattern" 'unfinished marker' report_matches "$path" "$filler_pattern" 'filler content' while IFS= read -r line || [[ -n "$line" ]]; do - if [[ "$line" == *"$fixture_name"*'='* && "$line" != *"$fixture_value"* ]]; then - printf 'forbidden non-fixture local phrase assignment: %s:%s\n' "$path" "$line" >&2 - violations=$((violations + 1)) + if [[ "$line" == *"$fixture_name"*'='* ]]; then + if [[ "$path" == "$allowed_fixture_path" && "$line" == "$allowed_fixture_line" ]]; then + fixture_count=$((fixture_count + 1)) + else + printf 'forbidden local phrase assignment outside exact fixture: %s:%s\n' "$path" "$line" >&2 + violations=$((violations + 1)) + fi fi done <"$path" if [[ "$path" == src/* || "$path" == test/* || "$path" == script/* ]]; then @@ -49,5 +55,10 @@ for path in "${TRACKED[@]}"; do fi done +if ((fixture_count != 1)); then + printf 'expected exactly one local phrase fixture assignment, found %d\n' "$fixture_count" >&2 + violations=$((violations + 1)) +fi + ((violations == 0)) || { printf 'Project scan failed with %d violation(s).\n' "$violations" >&2; exit 1; } printf 'Project scan passed across %d tracked paths.\n' "${#TRACKED[@]}" diff --git a/tools/test-process-safety.sh b/tools/test-process-safety.sh index 882e963..1d38c96 100755 --- a/tools/test-process-safety.sh +++ b/tools/test-process-safety.sh @@ -5,19 +5,32 @@ ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd -P) PROCESS_LIB="$ROOT/tools/process-lib.sh" RESET_SCRIPT="$ROOT/tools/reset-local.sh" TEST_ROOT=$(mktemp -d /tmp/uups-bank-process-test.XXXXXX) -declare -a TEST_PIDS=() +PATH="$TEST_ROOT/bin:$PATH" +export DEMO_TERM_WAIT_ATTEMPTS=5 +export DEMO_KILL_WAIT_ATTEMPTS=10 +declare -a TEST_IDENTITIES=() PASSED=0 STARTED_PID= cleanup() { - local pid - for pid in "${TEST_PIDS[@]}"; do - if [[ "$pid" =~ ^[0-9]+$ ]] && kill -0 "$pid" 2>/dev/null; then - kill -TERM -- "-$pid" 2>/dev/null || kill -TERM -- "$pid" 2>/dev/null || true - wait "$pid" 2>/dev/null || true - fi + local identity root relative + for identity in "${TEST_IDENTITIES[@]}"; do test_safe_stop "$identity"; done + for root in absent stale nonnumeric wrong-command false-anvil false-vite reused atomic matching group mutated partial local-reset base-canonical base-active sentinel; do + for relative in \ + .demo/anvil.pid .demo/anvil.start .demo/anvil.pgid .demo/anvil.log \ + .demo/vite.pid .demo/vite.start .demo/vite.pgid .demo/vite.log \ + .demo/sentinel .demo/adjacent.keep tools/process-lib.sh tools/reset-local.sh \ + deployments/pending.json deployments/anvil.json deployments/active.json deployments/base-sepolia.json \ + web/public/deployment.json web/src/generated/contracts.ts web/node_modules/.bin/vite; do + rm -f -- "$TEST_ROOT/$root/$relative" + done + rmdir -- "$TEST_ROOT/$root/web/node_modules/.bin" "$TEST_ROOT/$root/web/node_modules" \ + "$TEST_ROOT/$root/web/src/generated" "$TEST_ROOT/$root/web/src" "$TEST_ROOT/$root/web/public" \ + "$TEST_ROOT/$root/web" "$TEST_ROOT/$root/deployments" "$TEST_ROOT/$root/tools" "$TEST_ROOT/$root/.demo" \ + "$TEST_ROOT/$root" 2>/dev/null || true done - rm -rf -- "$TEST_ROOT" + rm -f -- "$TEST_ROOT/bin/anvil" "$TEST_ROOT/bin/vite" "$TEST_ROOT/vite-child.pid" + rmdir -- "$TEST_ROOT/bin" "$TEST_ROOT" 2>/dev/null || true } trap cleanup EXIT @@ -25,8 +38,65 @@ fail() { printf 'FAIL: %s\n' "$*" >&2; exit 1; } pass() { PASSED=$((PASSED + 1)); printf 'ok %d - %s\n' "$PASSED" "$1"; } assert_exists() { [[ -e "$1" ]] || fail "expected $1 to exist"; } assert_absent() { [[ ! -e "$1" ]] || fail "expected $1 to be absent"; } -assert_dead() { ! kill -0 "$1" 2>/dev/null || fail "expected PID $1 to be stopped"; } -assert_alive() { kill -0 "$1" 2>/dev/null || fail "expected PID $1 to remain alive"; } +assert_dead() { + local state start pgid sid + ! test_process_identity "$1" state start pgid sid || [[ "$state" == Z ]] || fail "expected PID $1 to be stopped" +} +assert_alive() { + local state start pgid sid + test_process_identity "$1" state start pgid sid && [[ "$state" != Z ]] || fail "expected PID $1 to remain alive" +} + +test_process_identity() { + local pid=$1 state_name=$2 start_name=$3 pgid_name=$4 sid_name=$5 stat rest + local -a fields + { IFS= read -r stat <"/proc/$pid/stat"; } 2>/dev/null || return 1 + rest=${stat##*) } + read -r -a fields <<<"$rest" + printf -v "$state_name" '%s' "${fields[0]}" + printf -v "$pgid_name" '%s' "${fields[2]}" + printf -v "$sid_name" '%s' "${fields[3]}" + printf -v "$start_name" '%s' "${fields[19]}" +} + +track_process() { + local pid=$1 state start pgid sid + for _ in {1..50}; do + if test_process_identity "$pid" state start pgid sid && [[ "$pid" == "$pgid" && "$pid" == "$sid" ]]; then + TEST_IDENTITIES+=("$pid:$start:$pgid:$sid") + return 0 + fi + sleep 0.02 + done + fail "could not capture test process identity for PID $pid" +} + +test_safe_stop() { + local identity=$1 leader expected_start expected_pgid expected_sid state start pgid sid signal line member member_start + local current_state current_start current_pgid current_sid + IFS=: read -r leader expected_start expected_pgid expected_sid <<<"$identity" + if test_process_identity "$leader" state start pgid sid && [[ "$start" != "$expected_start" ]]; then return 0; fi + for signal in TERM KILL; do + while read -r member pgid sid state; do + [[ "$pgid" == "$expected_pgid" && "$sid" == "$expected_sid" && "$state" != Z ]] || continue + test_process_identity "$member" state member_start pgid sid || continue + [[ "$pgid" == "$expected_pgid" && "$sid" == "$expected_sid" ]] || continue + test_process_identity "$leader" current_state current_start current_pgid current_sid || continue + [[ "$current_start" == "$expected_start" && "$current_pgid" == "$expected_pgid" \ + && "$current_sid" == "$expected_sid" ]] || continue + test_process_identity "$member" current_state current_start current_pgid current_sid || continue + [[ "$current_state" != Z && "$current_start" == "$member_start" \ + && "$current_pgid" == "$expected_pgid" && "$current_sid" == "$expected_sid" ]] || continue + kill -"$signal" -- "$member" 2>/dev/null || true + done < <(ps -eo pid=,pgid=,sid=,stat=) + for _ in {1..20}; do + line=$(ps -eo pgid=,sid=,stat= | awk -v p="$expected_pgid" -v s="$expected_sid" '$1 == p && $2 == s && $3 !~ /^Z/ { print; exit }') + [[ -z "$line" ]] && break + sleep 0.02 + done + done + wait "$leader" 2>/dev/null || true +} # RED gate: these are the missing production interfaces this suite specifies. [[ -f "$PROCESS_LIB" ]] || fail "missing process library: $PROCESS_LIB" @@ -73,12 +143,16 @@ HELPER setsid "$helper" web --host 127.0.0.1 --port 5173 >/dev/null 2>&1 & fi local pid=$! - TEST_PIDS+=("$pid") - write_record "$root" "$kind" "$pid" "$(start_tick "$pid")" "$pid" + track_process "$pid" + for _ in {1..50}; do + if demo_record_process "$root" "$kind" "$pid"; then break; fi + sleep 0.02 + done + assert_exists "$root/.demo/$kind.pid" STARTED_PID=$pid } -printf '1..11\n' +printf '1..16\n' # Catches cleanup treating a missing record as an error or signaling an inferred PID. root=$(make_root absent) @@ -104,12 +178,33 @@ pass 'a nonnumeric PID is rejected' # Catches PID collision signaling an unrelated live process with the wrong command. root=$(make_root wrong-command) setsid /bin/sleep 120 & wrong_pid=$! -TEST_PIDS+=("$wrong_pid") +track_process "$wrong_pid" write_record "$root" anvil "$wrong_pid" "$(start_tick "$wrong_pid")" "$wrong_pid" if demo_stop_recorded "$root" anvil 2>/dev/null; then fail 'wrong command signature was accepted'; fi assert_alive "$wrong_pid" pass 'a live PID with the wrong command signature is never signaled' +# Catches accepting anvil merely because it appears as a non-executable argument. +root=$(make_root false-anvil) +setsid /bin/bash -c 'sleep 120 & wait' anvil --host 127.0.0.1 --port 8545 --chain-id 31337 & false_anvil_pid=$! +track_process "$false_anvil_pid" +write_record "$root" anvil "$false_anvil_pid" "$(start_tick "$false_anvil_pid")" "$false_anvil_pid" +if demo_stop_recorded "$root" anvil 2>/dev/null; then fail 'argument-only anvil signature was accepted'; fi +assert_alive "$false_anvil_pid" +pass 'Anvil identity requires its actual executable and argv zero' + +# Catches accepting a project-local Vite-looking argument under an unrelated executable. +root=$(make_root false-vite) +mkdir -p "$root/web/node_modules/.bin" +printf '#!/usr/bin/env bash\n' >"$root/web/node_modules/.bin/vite" +chmod +x "$root/web/node_modules/.bin/vite" +setsid /bin/bash -c 'sleep 120 & wait' "$root/web/node_modules/.bin/vite" web --host 127.0.0.1 --port 5173 & false_vite_pid=$! +track_process "$false_vite_pid" +write_record "$root" vite "$false_vite_pid" "$(start_tick "$false_vite_pid")" "$false_vite_pid" +if demo_stop_recorded "$root" vite 2>/dev/null; then fail 'argument-only Vite signature was accepted'; fi +assert_alive "$false_vite_pid" +pass 'Vite identity requires the exact project-local entry and interpreter' + # Catches recycled PID ownership being inferred from PID and command alone. root=$(make_root reused) start_owned "$root" anvil @@ -119,6 +214,13 @@ if demo_stop_recorded "$root" anvil 2>/dev/null; then fail 'mismatched start tic assert_alive "$reused_pid" pass 'PID reuse is rejected by recorded process start tick' +# Catches publishing the PID before the complete identity has an atomic commit marker. +root=$(make_root atomic) +start_owned "$root" anvil +[[ $(stat -c '%i' "$root/.demo/anvil.pid") == $(stat -c '%i' "$root/.demo/anvil.pgid") ]] || fail 'PID is not an atomic hard-link commit marker' +demo_stop_recorded "$root" anvil +pass 'the PID commit marker is atomically linked only after start and group metadata' + # Catches a validated project child not being terminated and reaped. root=$(make_root matching) start_owned "$root" anvil @@ -129,31 +231,62 @@ assert_dead "$matching_pid" assert_absent "$root/.demo/anvil.pid" pass 'a matching project-started child is terminated and reaped' -# Catches stopping only the Vite leader and orphaning a child, or broad group signaling. +# Catches treating leader exit as group exit and orphaning a TERM-resistant Vite child. root=$(make_root group) -group_helper="$TEST_ROOT/bin/vite" +mkdir -p "$root/web/node_modules/.bin" +group_helper="$root/web/node_modules/.bin/vite" child_file="$TEST_ROOT/vite-child.pid" cat >"$group_helper" <<'HELPER' #!/usr/bin/env bash -sleep 120 & +bash -c 'trap "" TERM; exec sleep 120' & printf '%s\n' "$!" >"$DEMO_CHILD_PID_FILE" wait HELPER chmod +x "$group_helper" DEMO_CHILD_PID_FILE="$child_file" setsid "$group_helper" web --host 127.0.0.1 --port 5173 & group_pid=$! -TEST_PIDS+=("$group_pid") +track_process "$group_pid" for _ in {1..50}; do [[ -s "$child_file" ]] && break; sleep 0.02; done [[ -s "$child_file" ]] || fail 'Vite helper child did not start' child_pid=$(<"$child_file") setsid /bin/sleep 120 & unrelated_pid=$! -TEST_PIDS+=("$unrelated_pid") +track_process "$unrelated_pid" write_record "$root" vite "$group_pid" "$(start_tick "$group_pid")" "$group_pid" demo_stop_recorded "$root" vite wait "$group_pid" 2>/dev/null || true for _ in {1..50}; do ! kill -0 "$child_pid" 2>/dev/null && break; sleep 0.02; done assert_dead "$child_pid" assert_alive "$unrelated_pid" -pass 'an owned process group is stopped completely while an unrelated group survives' +pass 'bounded KILL removes a TERM-resistant owned child while an unrelated group survives' + +# Catches escalating after the recorded leader changes to a different command identity. +root=$(make_root mutated) +mutating_helper="$TEST_ROOT/bin/anvil" +cat >"$mutating_helper" <<'HELPER' +#!/usr/bin/env bash +trap 'exec -a changed-after-term /bin/sleep 120' TERM + while :; do sleep 0.05; done +HELPER +chmod +x "$mutating_helper" +setsid "$mutating_helper" --host 127.0.0.1 --port 8545 --chain-id 31337 & mutated_pid=$! +track_process "$mutated_pid" +write_record "$root" anvil "$mutated_pid" "$(start_tick "$mutated_pid")" "$mutated_pid" +if demo_stop_recorded "$root" anvil 2>/dev/null; then fail 'changed identity was escalated instead of rejected'; fi +assert_alive "$mutated_pid" +pass 'identity is revalidated and a changed leader is never escalated' + +# Catches launch-record publication failure leaving an independently known child alive. +root=$(make_root partial) +start_owned "$root" anvil +partial_pid=$STARTED_PID +partial_start=$(start_tick "$partial_pid") +rm -f -- "$root/.demo/anvil.pid" "$root/.demo/anvil.start" "$root/.demo/anvil.pgid" +printf '%s\n' "$partial_start" >"$root/.demo/anvil.start" +printf '%s\n' "$partial_pid" >"$root/.demo/anvil.pgid" +demo_stop_launch "$root" anvil "$partial_pid" "$partial_start" "$partial_pid" +assert_dead "$partial_pid" +assert_absent "$root/.demo/anvil.start" +assert_absent "$root/.demo/anvil.pgid" +pass 'in-memory launch identity safely cleans a partial unpublished record' # Catches reset deleting arbitrary neighbors or leaving known reproducible local artifacts. root=$(make_root local-reset) diff --git a/tools/test-scan-project.sh b/tools/test-scan-project.sh new file mode 100755 index 0000000..a9095f4 --- /dev/null +++ b/tools/test-scan-project.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd -P) +TEST_ROOT=$(mktemp -d /tmp/uups-bank-scan-test.XXXXXX) +fixture_name='ANVIL_''TEST_PHRASE' +fixture_value='test test test test test test test test test test test junk' + +cleanup() { + rm -f -- "$TEST_ROOT/script/lib/DemoScript.sol" "$TEST_ROOT/tools/scan-project.sh" "$TEST_ROOT/duplicate.sh" \ + "$TEST_ROOT/.git/index" "$TEST_ROOT/.git/HEAD" "$TEST_ROOT/.git/config" "$TEST_ROOT/.git/description" "$TEST_ROOT/.git/info/exclude" + rmdir -- "$TEST_ROOT/.git/objects/pack" "$TEST_ROOT/.git/objects/info" "$TEST_ROOT/.git/objects" \ + "$TEST_ROOT/.git/refs/tags" "$TEST_ROOT/.git/refs/heads" "$TEST_ROOT/.git/refs" \ + "$TEST_ROOT/.git/branches" "$TEST_ROOT/.git/hooks" "$TEST_ROOT/.git/info" "$TEST_ROOT/.git" \ + "$TEST_ROOT/script/lib" "$TEST_ROOT/script" "$TEST_ROOT/tools" "$TEST_ROOT" 2>/dev/null || true +} +trap cleanup EXIT +fail() { printf 'FAIL: %s\n' "$*" >&2; exit 1; } + +mkdir -p "$TEST_ROOT/script/lib" "$TEST_ROOT/tools" +cp "$ROOT/tools/scan-project.sh" "$TEST_ROOT/tools/scan-project.sh" +git -C "$TEST_ROOT" init -q --template= +printf ' string internal constant %s = "%s";\n' "$fixture_name" "$fixture_value" >"$TEST_ROOT/script/lib/DemoScript.sol" +git -C "$TEST_ROOT" add script/lib/DemoScript.sol tools/scan-project.sh +(cd "$TEST_ROOT" && bash tools/scan-project.sh >/dev/null) || fail 'exact intended fixture assignment was rejected' + +# Catches accepting the known phrase assignment in a duplicate tracked path. +printf '%s="%s"\n' "$fixture_name" "$fixture_value" >"$TEST_ROOT/duplicate.sh" +git -C "$TEST_ROOT" add duplicate.sh +if (cd "$TEST_ROOT" && bash tools/scan-project.sh >/dev/null 2>&1); then fail 'duplicate fixture assignment was accepted'; fi +git -C "$TEST_ROOT" rm -q --cached duplicate.sh +rm -f -- "$TEST_ROOT/duplicate.sh" + +# Catches substring matching that accepts appended content on the intended line. +printf ' string internal constant %s = "%s"; appended\n' "$fixture_name" "$fixture_value" >"$TEST_ROOT/script/lib/DemoScript.sol" +if (cd "$TEST_ROOT" && bash tools/scan-project.sh >/dev/null 2>&1); then fail 'appended fixture assignment was accepted'; fi + +printf '%s\n' 'PASS: scanner fixture exception is exact, unique, and path-anchored' From a28ad76868747d21020c4e7d16a5f22ccb7e4d63 Mon Sep 17 00:00:00 2001 From: golem Date: Fri, 21 Aug 2026 04:57:14 -0600 Subject: [PATCH 18/30] fix: close demo lifecycle races --- docs/LEARNING_GUIDE.md | 12 +-- test/NamedErrorExercises.t.sol | 92 ++++++++++++++++++++++ tools/demo-local.sh | 22 +++++- tools/process-lib.sh | 78 ++++++++++++------- tools/test-process-safety.sh | 138 ++++++++++++++++++++++++++++++--- 5 files changed, 295 insertions(+), 47 deletions(-) create mode 100644 test/NamedErrorExercises.t.sol diff --git a/docs/LEARNING_GUIDE.md b/docs/LEARNING_GUIDE.md index c719ba6..138ad58 100644 --- a/docs/LEARNING_GUIDE.md +++ b/docs/LEARNING_GUIDE.md @@ -106,17 +106,17 @@ forge test --match-test testLegacyParallelActorManifestIsRejected -vv # DemoScript.InvalidActorConfiguration forge test --match-test testAnvilManifestRequiresOwnerAtActorZero -vv -# DemoScript.UnexpectedState example payload: label, expected, actual -cast calldata 'UnexpectedState(string,uint256,uint256)' 'reserves' 1400000000 1399000000 +# DemoScript.UnexpectedState, including label and expected/actual values +forge test --match-test testUnexpectedStateExerciseRevertsThroughAssertionBranch -vv # DemoScript.UnexpectedAddress, including expected/actual implementation addresses forge test --match-test testCheckStateRejectsManifestImplementationMismatch -vv -# CheckState.Insolvent example payload: reserves below ledger liabilities -cast calldata 'Insolvent(uint256,uint256)' 1399000000 1400000000 +# CheckState.Insolvent with reserves below ledger liabilities +forge test --match-test testCheckStateExercisesRevertThroughInsolventAndUnknownStageBranches -vv -# CheckState.UnknownStage example payload -cast calldata 'UnknownStage(string)' 'mystery' +# CheckState.UnknownStage through the real state checker +forge test --match-test testCheckStateExercisesRevertThroughInsolventAndUnknownStageBranches -vv ``` Finish by running `make verify`; it combines unit, fuzz, invariant, script, process-safety, scanner, and web gates. diff --git a/test/NamedErrorExercises.t.sol b/test/NamedErrorExercises.t.sol new file mode 100644 index 0000000..89fec74 --- /dev/null +++ b/test/NamedErrorExercises.t.sol @@ -0,0 +1,92 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.35; + +import {Test} from "forge-std/Test.sol"; +import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import {BankV1} from "../src/BankV1.sol"; +import {MockUSDC} from "../src/MockUSDC.sol"; +import {CheckState} from "../script/CheckState.s.sol"; +import {DemoScript} from "../script/lib/DemoScript.sol"; + +contract NamedErrorHarness is DemoScript { + function assertUint(string calldata label, uint256 expected, uint256 actual) external pure { + _assertUint(label, expected, actual); + } + + function writeManifest(string calldata path, Manifest calldata manifest) external { + _writeManifest(path, manifest); + } +} + +contract NamedErrorExercisesTest is Test { + address internal constant OWNER = 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266; + address internal constant ALICE = 0x70997970C51812dc3A010C7d01b50e0d17dc79C8; + address internal constant BOB = 0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC; + + NamedErrorHarness internal harness; + + function setUp() public { + vm.chainId(31337); + harness = new NamedErrorHarness(); + } + + function testUnexpectedStateExerciseRevertsThroughAssertionBranch() public { + vm.expectRevert( + abi.encodeWithSelector(DemoScript.UnexpectedState.selector, "reserves", uint256(1_400e6), uint256(1_399e6)) + ); + harness.assertUint("reserves", 1_400e6, 1_399e6); + } + + function testCheckStateExercisesRevertThroughInsolventAndUnknownStageBranches() public { + (MockUSDC token, BankV1 bank) = _deployFixture("insolvent.json"); + token.mint(ALICE, 1_400e6); + vm.startPrank(ALICE); + token.approve(address(bank), 1_400e6); + bank.deposit(1_400e6); + vm.stopPrank(); + deal(address(token), address(bank), 1_399e6); + vm.setEnv("DEMO_EXPECTED_STAGE", "invariants"); + CheckState checker = new CheckState(); + + vm.expectRevert(abi.encodeWithSelector(CheckState.Insolvent.selector, uint256(1_399e6), uint256(1_400e6))); + checker.run(); + + _deployFixture("unknown-stage.json"); + vm.setEnv("DEMO_EXPECTED_STAGE", "mystery"); + checker = new CheckState(); + + vm.expectRevert(abi.encodeWithSelector(CheckState.UnknownStage.selector, "mystery")); + checker.run(); + } + + function _deployFixture(string memory fixtureName) internal returns (MockUSDC token, BankV1 bank) { + token = new MockUSDC(address(this)); + BankV1 implementation = new BankV1(); + ERC1967Proxy proxy = + new ERC1967Proxy(address(implementation), abi.encodeCall(BankV1.initialize, (address(token), OWNER))); + bank = BankV1(address(proxy)); + + DemoScript.Actor[] memory actors = new DemoScript.Actor[](3); + actors[0] = DemoScript.Actor({label: "owner", address_: OWNER}); + actors[1] = DemoScript.Actor({label: "Alice", address_: ALICE}); + actors[2] = DemoScript.Actor({label: "Bob", address_: BOB}); + DemoScript.Manifest memory manifest = DemoScript.Manifest({ + schemaVersion: 1, + network: "anvil", + chainId: 31337, + deploymentBlock: 1, + rpcUrl: "http://127.0.0.1:8545", + explorerBaseUrl: "", + token: address(token), + proxy: address(proxy), + implementation: address(implementation), + owner: OWNER, + actors: actors + }); + string memory fixtureDir = string.concat(vm.projectRoot(), "/deployments/test-named-error-exercises"); + vm.createDir(fixtureDir, true); + string memory manifestPath = string.concat(fixtureDir, "/", fixtureName); + harness.writeManifest(manifestPath, manifest); + vm.setEnv("DEPLOYMENT_MANIFEST_PATH", manifestPath); + } +} diff --git a/tools/demo-local.sh b/tools/demo-local.sh index f7cfa3b..e870509 100755 --- a/tools/demo-local.sh +++ b/tools/demo-local.sh @@ -8,6 +8,7 @@ ANVIL_DEV_WORDS=(test test test test test test test test test test test junk) CLEANING=0 ANVIL_PID='' ANVIL_START='' ANVIL_PGID='' VITE_PID='' VITE_START='' VITE_PGID='' +ANVIL_VALIDATED=0 VITE_VALIDATED=0 cleanup() { local status=$? cleanup_status=0 @@ -15,12 +16,14 @@ cleanup() { CLEANING=1 trap - INT TERM EXIT if [[ -n "$VITE_PID" && -n "$VITE_START" && -n "$VITE_PGID" ]]; then - demo_stop_launch "$ROOT" vite "$VITE_PID" "$VITE_START" "$VITE_PGID" || cleanup_status=1 + if ((VITE_VALIDATED == 1)); then demo_stop_launch "$ROOT" vite "$VITE_PID" "$VITE_START" "$VITE_PGID" || cleanup_status=1 + else demo_stop_raw_launch "$ROOT" vite "$VITE_PID" "$VITE_START" "$VITE_PGID" || cleanup_status=1; fi else demo_stop_recorded "$ROOT" vite || cleanup_status=1 fi if [[ -n "$ANVIL_PID" && -n "$ANVIL_START" && -n "$ANVIL_PGID" ]]; then - demo_stop_launch "$ROOT" anvil "$ANVIL_PID" "$ANVIL_START" "$ANVIL_PGID" || cleanup_status=1 + if ((ANVIL_VALIDATED == 1)); then demo_stop_launch "$ROOT" anvil "$ANVIL_PID" "$ANVIL_START" "$ANVIL_PGID" || cleanup_status=1 + else demo_stop_raw_launch "$ROOT" anvil "$ANVIL_PID" "$ANVIL_START" "$ANVIL_PGID" || cleanup_status=1; fi else demo_stop_recorded "$ROOT" anvil || cleanup_status=1 fi @@ -34,10 +37,23 @@ trap cleanup EXIT capture_and_publish() { local kind=$1 pid=$2 start pgid for _ in {1..50}; do - if demo_capture_process "$ROOT" "$kind" "$pid" start pgid; then + if demo_capture_raw_launch "$pid" start pgid; then if [[ "$kind" == anvil ]]; then ANVIL_START=$start; ANVIL_PGID=$pgid else VITE_START=$start; VITE_PGID=$pgid; fi + break + fi + demo_pid_is_running "$pid" || break + sleep 0.02 + done + [[ -n ${start:-} && -n ${pgid:-} ]] || { + printf 'Could not capture raw ownership for launched %s PID %s; no signal will be guessed.\n' "$kind" "$pid" >&2 + return 1 + } + for _ in {1..50}; do + if demo_identity_matches "$ROOT" "$kind" "$pid" "$start" "$pgid"; then demo_publish_process "$ROOT" "$kind" "$pid" "$start" "$pgid" + if [[ "$kind" == anvil ]]; then ANVIL_VALIDATED=1 + else VITE_VALIDATED=1; fi return fi demo_pid_is_running "$pid" || break diff --git a/tools/process-lib.sh b/tools/process-lib.sh index b2eea7d..4267922 100755 --- a/tools/process-lib.sh +++ b/tools/process-lib.sh @@ -89,28 +89,25 @@ demo_interpreter_for_entry() { else demo_resolve_command "${interpreter[0]}"; fi } -demo_has_sequence_from() { +demo_has_exact_tail() { local array_name=$1 begin=$2 local -n argv_ref=$array_name shift 2 local -a wanted=("$@") - local i j - for ((i = begin; i + ${#wanted[@]} <= ${#argv_ref[@]}; i++)); do - for ((j = 0; j < ${#wanted[@]}; j++)); do - [[ ${argv_ref[i+j]} == "${wanted[j]}" ]] || break - done - ((j == ${#wanted[@]})) && return 0 + local index + ((${#argv_ref[@]} - begin == ${#wanted[@]})) || return 1 + for index in "${!wanted[@]}"; do + [[ ${argv_ref[begin+index]} == "${wanted[index]}" ]] || return 1 done - return 1 } demo_command_matches() { local root=$1 pid=$2 kind=$3 expected executable interpreter entry_index local -a arguments=() [[ -r "/proc/$pid/cmdline" && -e "/proc/$pid/exe" ]] || return 1 - mapfile -d '' -t arguments <"/proc/$pid/cmdline" + { mapfile -d '' -t arguments <"/proc/$pid/cmdline"; } 2>/dev/null || return 1 ((${#arguments[@]} > 0)) || return 1 - executable=$(readlink -f -- "/proc/$pid/exe") || return 1 + executable=$(readlink -f -- "/proc/$pid/exe" 2>/dev/null) || return 1 if [[ "$kind" == anvil ]]; then expected=$(demo_resolve_command anvil) || return 1 @@ -128,13 +125,11 @@ demo_command_matches() { fi if [[ "$kind" == anvil ]]; then - demo_has_sequence_from arguments "$((entry_index + 1))" --host 127.0.0.1 || return 1 - demo_has_sequence_from arguments "$((entry_index + 1))" --port 8545 || return 1 - demo_has_sequence_from arguments "$((entry_index + 1))" --chain-id 31337 || return 1 + local dev_phrase='test test test test test test test test test test test junk' + demo_has_exact_tail arguments "$((entry_index + 1))" \ + --host 127.0.0.1 --port 8545 --chain-id 31337 --mnemonic "$dev_phrase" else - local -a expected_tail=(web --host 127.0.0.1 --port 5173) - local -a actual_tail=("${arguments[@]:entry_index+1}") - [[ "${actual_tail[*]}" == "${expected_tail[*]}" && ${#actual_tail[@]} -eq ${#expected_tail[@]} ]] || return 1 + demo_has_exact_tail arguments "$((entry_index + 1))" web --host 127.0.0.1 --port 5173 fi } @@ -146,10 +141,17 @@ demo_identity_matches() { demo_command_matches "$root" "$pid" "$kind" } +demo_capture_raw_launch() { + local pid=$1 start_name=$2 pgid_name=$3 state raw_actual_start raw_actual_pgid sid + demo_read_process_identity "$pid" state raw_actual_start raw_actual_pgid sid || return 1 + [[ "$state" != Z && "$pid" == "$raw_actual_pgid" && "$pid" == "$sid" ]] || return 1 + printf -v "$start_name" '%s' "$raw_actual_start" + printf -v "$pgid_name" '%s' "$raw_actual_pgid" +} + demo_capture_process() { - local root=$1 kind=$2 pid=$3 start_name=$4 pgid_name=$5 state actual_start actual_pgid sid - demo_read_process_identity "$pid" state actual_start actual_pgid sid || return 1 - [[ "$state" != Z && "$pid" == "$actual_pgid" && "$pid" == "$sid" ]] || return 1 + local root=$1 kind=$2 pid=$3 start_name=$4 pgid_name=$5 actual_start actual_pgid + demo_capture_raw_launch "$pid" actual_start actual_pgid || return 1 demo_command_matches "$root" "$pid" "$kind" || return 1 printf -v "$start_name" '%s' "$actual_start" printf -v "$pgid_name" '%s' "$actual_pgid" @@ -201,20 +203,29 @@ demo_collect_group_members() { } demo_anchor_allows_signal() { - local root=$1 kind=$2 leader=$3 expected_start=$4 expected_pgid=$5 state start pgid sid + local root=$1 kind=$2 leader=$3 expected_start=$4 expected_pgid=$5 identity_mode=${6:-validated} state start pgid sid if ! demo_read_process_identity "$leader" state start pgid sid; then return 0; fi [[ "$start" == "$expected_start" && "$pgid" == "$expected_pgid" && "$sid" == "$expected_pgid" ]] || return 1 [[ "$state" == Z ]] && return 0 + [[ "$identity_mode" == raw ]] && return 0 demo_process_has_command_line "$leader" || return 0 - demo_command_matches "$root" "$leader" "$kind" + if demo_command_matches "$root" "$leader" "$kind"; then return 0; fi + if ! demo_read_process_identity "$leader" state start pgid sid; then return 0; fi + [[ "$state" == Z ]] && return 0 + [[ "$start" == "$expected_start" && "$pgid" == "$expected_pgid" && "$sid" == "$expected_pgid" ]] || return 1 + return 1 } demo_signal_member() { - local pid=$1 expected_start=$2 expected_pgid=$3 expected_sid=$4 signal=$5 state start pgid sid + local pid=$1 expected_start=$2 expected_pgid=$3 expected_sid=$4 signal=$5 state start pgid sid signal_status if ! demo_read_process_identity "$pid" state start pgid sid; then return 0; fi [[ "$state" != Z ]] || return 0 [[ "$start" == "$expected_start" && "$pgid" == "$expected_pgid" && "$sid" == "$expected_sid" ]] || return 2 - kill -"$signal" -- "$pid" + if kill -"$signal" -- "$pid"; then return 0; else signal_status=$?; fi + if ! demo_read_process_identity "$pid" state start pgid sid; then return 0; fi + [[ "$state" != Z ]] || return 0 + [[ "$start" == "$expected_start" && "$pgid" == "$expected_pgid" && "$sid" == "$expected_sid" ]] || return 0 + return "$signal_status" } demo_group_has_live_members() { @@ -224,12 +235,13 @@ demo_group_has_live_members() { ((${#members[@]} > 0)) } -demo_stop_launch() { - local root=$1 kind=$2 leader=$3 expected_start=$4 expected_pgid=$5 member member_pid member_start result +demo_stop_owned_session() { + local root=$1 kind=$2 leader=$3 expected_start=$4 expected_pgid=$5 identity_mode=$6 member member_pid member_start result local -a members + [[ "$identity_mode" == validated || "$identity_mode" == raw ]] || return 1 demo_is_process_id "$leader" && demo_is_uint "$expected_start" && demo_is_process_id "$expected_pgid" \ && [[ "$leader" == "$expected_pgid" ]] || return 1 - demo_anchor_allows_signal "$root" "$kind" "$leader" "$expected_start" "$expected_pgid" || { + demo_anchor_allows_signal "$root" "$kind" "$leader" "$expected_start" "$expected_pgid" "$identity_mode" || { printf 'Refusing %s PID %s: process identity changed\n' "$kind" "$leader" >&2 return 1 } @@ -243,13 +255,13 @@ demo_stop_launch() { for member in "${members[@]}"; do IFS=: read -r member_pid member_start <<<"$member" [[ "$member_pid" != "$leader" ]] || continue - demo_anchor_allows_signal "$root" "$kind" "$leader" "$expected_start" "$expected_pgid" || return 1 + demo_anchor_allows_signal "$root" "$kind" "$leader" "$expected_start" "$expected_pgid" "$identity_mode" || return 1 demo_signal_member "$member_pid" "$member_start" "$expected_pgid" "$expected_pgid" TERM || return 1 done for member in "${members[@]}"; do IFS=: read -r member_pid member_start <<<"$member" [[ "$member_pid" == "$leader" ]] || continue - demo_anchor_allows_signal "$root" "$kind" "$leader" "$expected_start" "$expected_pgid" || return 1 + demo_anchor_allows_signal "$root" "$kind" "$leader" "$expected_start" "$expected_pgid" "$identity_mode" || return 1 demo_signal_member "$member_pid" "$member_start" "$expected_pgid" "$expected_pgid" TERM || return 1 done for ((result = 0; result < ${DEMO_TERM_WAIT_ATTEMPTS:-50}; result++)); do @@ -264,7 +276,7 @@ demo_stop_launch() { ((${#members[@]} > 0)) || break for member in "${members[@]}"; do IFS=: read -r member_pid member_start <<<"$member" - if ! demo_anchor_allows_signal "$root" "$kind" "$leader" "$expected_start" "$expected_pgid"; then + if ! demo_anchor_allows_signal "$root" "$kind" "$leader" "$expected_start" "$expected_pgid" "$identity_mode"; then printf 'Refusing KILL escalation: %s leader identity changed\n' "$kind" >&2 return 1 fi @@ -287,6 +299,14 @@ demo_stop_launch() { demo_remove_record "$root" "$kind" } +demo_stop_launch() { + demo_stop_owned_session "$1" "$2" "$3" "$4" "$5" validated +} + +demo_stop_raw_launch() { + demo_stop_owned_session "$1" "$2" "$3" "$4" "$5" raw +} + demo_stop_recorded() { local root=$1 kind=$2 pid start pgid local pid_path="$root/.demo/$kind.pid" start_path="$root/.demo/$kind.start" pgid_path="$root/.demo/$kind.pgid" diff --git a/tools/test-process-safety.sh b/tools/test-process-safety.sh index 1d38c96..09e4b0b 100755 --- a/tools/test-process-safety.sh +++ b/tools/test-process-safety.sh @@ -8,6 +8,8 @@ TEST_ROOT=$(mktemp -d /tmp/uups-bank-process-test.XXXXXX) PATH="$TEST_ROOT/bin:$PATH" export DEMO_TERM_WAIT_ATTEMPTS=5 export DEMO_KILL_WAIT_ATTEMPTS=10 +ANVIL_DEV_PHRASE='test test test test test test test test test test test junk' +ANVIL_EXACT_ARGS=(--host 127.0.0.1 --port 8545 --chain-id 31337 --mnemonic "$ANVIL_DEV_PHRASE") declare -a TEST_IDENTITIES=() PASSED=0 STARTED_PID= @@ -15,7 +17,7 @@ STARTED_PID= cleanup() { local identity root relative for identity in "${TEST_IDENTITIES[@]}"; do test_safe_stop "$identity"; done - for root in absent stale nonnumeric wrong-command false-anvil false-vite reused atomic matching group mutated partial local-reset base-canonical base-active sentinel; do + for root in absent stale nonnumeric wrong-command false-anvil false-vite exact-anvil raw-capture reused atomic matching group mutated partial local-reset base-canonical base-active sentinel; do for relative in \ .demo/anvil.pid .demo/anvil.start .demo/anvil.pgid .demo/anvil.log \ .demo/vite.pid .demo/vite.start .demo/vite.pgid .demo/vite.log \ @@ -29,7 +31,8 @@ cleanup() { "$TEST_ROOT/$root/web" "$TEST_ROOT/$root/deployments" "$TEST_ROOT/$root/tools" "$TEST_ROOT/$root/.demo" \ "$TEST_ROOT/$root" 2>/dev/null || true done - rm -f -- "$TEST_ROOT/bin/anvil" "$TEST_ROOT/bin/vite" "$TEST_ROOT/vite-child.pid" + rm -f -- "$TEST_ROOT/bin/anvil" "$TEST_ROOT/bin/vite" "$TEST_ROOT/bin/leaderless" \ + "$TEST_ROOT/vite-child.pid" "$TEST_ROOT/leaderless-child.pid" rmdir -- "$TEST_ROOT/bin" "$TEST_ROOT" 2>/dev/null || true } trap cleanup EXIT @@ -75,15 +78,17 @@ test_safe_stop() { local identity=$1 leader expected_start expected_pgid expected_sid state start pgid sid signal line member member_start local current_state current_start current_pgid current_sid IFS=: read -r leader expected_start expected_pgid expected_sid <<<"$identity" - if test_process_identity "$leader" state start pgid sid && [[ "$start" != "$expected_start" ]]; then return 0; fi + if test_process_identity "$leader" state start pgid sid \ + && [[ "$start" != "$expected_start" || "$pgid" != "$expected_pgid" || "$sid" != "$expected_sid" ]]; then return 0; fi for signal in TERM KILL; do while read -r member pgid sid state; do [[ "$pgid" == "$expected_pgid" && "$sid" == "$expected_sid" && "$state" != Z ]] || continue test_process_identity "$member" state member_start pgid sid || continue [[ "$pgid" == "$expected_pgid" && "$sid" == "$expected_sid" ]] || continue - test_process_identity "$leader" current_state current_start current_pgid current_sid || continue - [[ "$current_start" == "$expected_start" && "$current_pgid" == "$expected_pgid" \ - && "$current_sid" == "$expected_sid" ]] || continue + if test_process_identity "$leader" current_state current_start current_pgid current_sid; then + [[ "$current_start" == "$expected_start" && "$current_pgid" == "$expected_pgid" \ + && "$current_sid" == "$expected_sid" ]] || continue + fi test_process_identity "$member" current_state current_start current_pgid current_sid || continue [[ "$current_state" != Z && "$current_start" == "$member_start" \ && "$current_pgid" == "$expected_pgid" && "$current_sid" == "$expected_sid" ]] || continue @@ -138,7 +143,7 @@ wait HELPER chmod +x "$helper" if [[ "$kind" == anvil ]]; then - setsid "$helper" --host 127.0.0.1 --port 8545 --chain-id 31337 >/dev/null 2>&1 & + setsid "$helper" "${ANVIL_EXACT_ARGS[@]}" >/dev/null 2>&1 & else setsid "$helper" web --host 127.0.0.1 --port 5173 >/dev/null 2>&1 & fi @@ -152,7 +157,17 @@ HELPER STARTED_PID=$pid } -printf '1..16\n' +assert_anvil_args_rejected() { + local root=$1 label=$2 state start pgid sid pid + shift 2 + setsid "$TEST_ROOT/bin/anvil" "$@" & pid=$! + track_process "$pid" + test_process_identity "$pid" state start pgid sid + if demo_command_matches "$root" "$pid" anvil; then fail "$label Anvil arguments were accepted"; fi + demo_stop_raw_launch "$root" anvil "$pid" "$start" "$pgid" +} + +printf '1..21\n' # Catches cleanup treating a missing record as an error or signaling an inferred PID. root=$(make_root absent) @@ -205,6 +220,81 @@ if demo_stop_recorded "$root" vite 2>/dev/null; then fail 'argument-only Vite si assert_alive "$false_vite_pid" pass 'Vite identity requires the exact project-local entry and interpreter' +# Catches accepting extra or overriding Anvil arguments after a valid-looking prefix. +root=$(make_root exact-anvil) +mkdir -p "$TEST_ROOT/bin" +cat >"$TEST_ROOT/bin/anvil" <<'HELPER' +#!/usr/bin/env bash +sleep 120 & +wait +HELPER +chmod +x "$TEST_ROOT/bin/anvil" +assert_anvil_args_rejected "$root" extra "${ANVIL_EXACT_ARGS[@]}" --silent +assert_anvil_args_rejected "$root" duplicate "${ANVIL_EXACT_ARGS[@]}" --port 9999 +assert_anvil_args_rejected "$root" reordered \ + --port 8545 --host 127.0.0.1 --chain-id 31337 --mnemonic "$ANVIL_DEV_PHRASE" +pass 'Anvil identity requires the exact complete launch argument tail' + +# Catches treating an ESRCH-like signal race as fatal after the captured member has exited. +setsid /bin/bash -c 'trap "exit 0" TERM; while :; do sleep 0.01; done' & vanished_pid=$! +track_process "$vanished_pid" +vanished_state='' vanished_start='' vanished_pgid='' vanished_sid='' +test_process_identity "$vanished_pid" vanished_state vanished_start vanished_pgid vanished_sid +[[ "$vanished_state" != Z ]] || fail 'vanishing race fixture exited before injection' +kill() { + builtin kill -TERM -- "$vanished_pid" 2>/dev/null || true + for _ in {1..50}; do + vanished_state_after='' + if ! test_process_identity "$vanished_pid" vanished_state_after vanished_start_after vanished_pgid_after vanished_sid_after \ + || [[ "$vanished_state_after" == Z ]]; then break; fi + sleep 0.01 + done + return 1 +} +if demo_signal_member "$vanished_pid" "$vanished_start" "$vanished_pgid" "$vanished_sid" TERM; then + vanished_result=0 +else + vanished_result=$? +fi +unset -f kill +wait "$vanished_pid" 2>/dev/null || true +((vanished_result == 0)) || fail 'a vanished member made its raced signal fatal' +pass 'a failed signal is idempotent after the captured member vanishes' + +# Catches swallowing a real signal failure while the exact captured member remains live. +setsid /bin/sleep 120 & unsignaled_pid=$! +track_process "$unsignaled_pid" +unsignaled_state='' unsignaled_start='' unsignaled_pgid='' unsignaled_sid='' +test_process_identity "$unsignaled_pid" unsignaled_state unsignaled_start unsignaled_pgid unsignaled_sid +[[ "$unsignaled_state" != Z ]] || fail 'unchanged signal-failure fixture exited early' +kill() { return 1; } +if demo_signal_member "$unsignaled_pid" "$unsignaled_start" "$unsignaled_pgid" "$unsignaled_sid" TERM; then + unsignaled_result=0 +else + unsignaled_result=$? +fi +unset -f kill +((unsignaled_result != 0)) || fail 'a failed signal to the unchanged live member was accepted' +assert_alive "$unsignaled_pid" +pass 'a failed signal remains fatal for the same live member tuple' + +# Catches command validation failure occurring before launch ownership is retained for cleanup. +root=$(make_root raw-capture) +setsid /bin/bash -c 'sleep 120 & wait' unexpected-entry --not-the-demo & raw_pid=$! +track_process "$raw_pid" +raw_start='' raw_pgid='' +declare -F demo_capture_raw_launch >/dev/null || fail 'missing raw launch identity capture' +declare -F demo_stop_raw_launch >/dev/null || fail 'missing command-independent raw launch cleanup' +for _ in {1..50}; do + if demo_capture_raw_launch "$raw_pid" raw_start raw_pgid; then break; fi + sleep 0.02 +done +[[ -n ${raw_start:-} && "$raw_pgid" == "$raw_pid" ]] || fail 'raw launch identity was not captured' +if demo_command_matches "$root" "$raw_pid" anvil; then fail 'raw test command unexpectedly passed Anvil validation'; fi +demo_stop_raw_launch "$root" anvil "$raw_pid" "$raw_start" "$raw_pgid" +assert_dead "$raw_pid" +pass 'raw launch ownership safely cleans a command-validation failure' + # Catches recycled PID ownership being inferred from PID and command alone. root=$(make_root reused) start_owned "$root" anvil @@ -267,7 +357,7 @@ trap 'exec -a changed-after-term /bin/sleep 120' TERM while :; do sleep 0.05; done HELPER chmod +x "$mutating_helper" -setsid "$mutating_helper" --host 127.0.0.1 --port 8545 --chain-id 31337 & mutated_pid=$! +setsid "$mutating_helper" "${ANVIL_EXACT_ARGS[@]}" & mutated_pid=$! track_process "$mutated_pid" write_record "$root" anvil "$mutated_pid" "$(start_tick "$mutated_pid")" "$mutated_pid" if demo_stop_recorded "$root" anvil 2>/dev/null; then fail 'changed identity was escalated instead of rejected'; fi @@ -288,6 +378,36 @@ assert_absent "$root/.demo/anvil.start" assert_absent "$root/.demo/anvil.pgid" pass 'in-memory launch identity safely cleans a partial unpublished record' +# Catches test cleanup leaking a TERM-resistant session member after its original leader is reaped. +leaderless_helper="$TEST_ROOT/bin/leaderless" +leaderless_child_file="$TEST_ROOT/leaderless-child.pid" +cat >"$leaderless_helper" <<'HELPER' +#!/usr/bin/env bash +bash -c 'trap "" TERM; exec sleep 120' & +printf '%s\n' "$!" >"$DEMO_CHILD_PID_FILE" +sleep 1 +HELPER +chmod +x "$leaderless_helper" +DEMO_CHILD_PID_FILE="$leaderless_child_file" setsid "$leaderless_helper" & leaderless_pid=$! +track_process "$leaderless_pid" +leaderless_identity=${TEST_IDENTITIES[${#TEST_IDENTITIES[@]}-1]} +for _ in {1..50}; do [[ -s "$leaderless_child_file" ]] && break; sleep 0.02; done +[[ -s "$leaderless_child_file" ]] || fail 'leaderless cleanup child did not start' +leaderless_child=$(<"$leaderless_child_file") +child_state='' child_start='' child_pgid='' child_sid='' +test_process_identity "$leaderless_child" child_state child_start child_pgid child_sid +[[ "$child_state" != Z ]] || fail 'leaderless cleanup child exited early' +wait "$leaderless_pid" +test_safe_stop "$leaderless_identity" +if test_process_identity "$leaderless_child" current_state current_start current_pgid current_sid \ + && [[ "$current_state" != Z ]]; then + if [[ "$current_start" == "$child_start" && "$current_pgid" == "$child_pgid" && "$current_sid" == "$child_sid" ]]; then + builtin kill -KILL -- "$leaderless_child" 2>/dev/null || true + fi + fail 'test cleanup leaked a session member after the leader was reaped' +fi +pass 'test cleanup safely stops surviving members after leader reaping' + # Catches reset deleting arbitrary neighbors or leaving known reproducible local artifacts. root=$(make_root local-reset) for kind in anvil vite; do printf 'log\n' >"$root/.demo/$kind.log"; printf '1\n' >"$root/.demo/$kind.start"; printf '1\n' >"$root/.demo/$kind.pgid"; done From eb2fd5245a73b74532f2cec5b018261f4e1cc3b3 Mon Sep 17 00:00:00 2001 From: golem Date: Fri, 21 Aug 2026 05:12:50 -0600 Subject: [PATCH 19/30] fix: revalidate exiting demo anchors --- tools/process-lib.sh | 47 ++++++---- tools/test-process-safety.sh | 168 +++++++++++++++++++++++++++++++++-- 2 files changed, 193 insertions(+), 22 deletions(-) diff --git a/tools/process-lib.sh b/tools/process-lib.sh index 4267922..c41b199 100755 --- a/tools/process-lib.sh +++ b/tools/process-lib.sh @@ -104,10 +104,12 @@ demo_has_exact_tail() { demo_command_matches() { local root=$1 pid=$2 kind=$3 expected executable interpreter entry_index local -a arguments=() - [[ -r "/proc/$pid/cmdline" && -e "/proc/$pid/exe" ]] || return 1 - { mapfile -d '' -t arguments <"/proc/$pid/cmdline"; } 2>/dev/null || return 1 - ((${#arguments[@]} > 0)) || return 1 - executable=$(readlink -f -- "/proc/$pid/exe" 2>/dev/null) || return 1 + # Status 2 means /proc became unreadable while identity was being checked. + # Status 1 remains a definitive command mismatch. + [[ -r "/proc/$pid/cmdline" && -e "/proc/$pid/exe" ]] || return 2 + { mapfile -d '' -t arguments <"/proc/$pid/cmdline"; } 2>/dev/null || return 2 + ((${#arguments[@]} > 0)) || return 2 + executable=$(readlink -f -- "/proc/$pid/exe" 2>/dev/null) || return 2 if [[ "$kind" == anvil ]]; then expected=$(demo_resolve_command anvil) || return 1 @@ -203,16 +205,20 @@ demo_collect_group_members() { } demo_anchor_allows_signal() { - local root=$1 kind=$2 leader=$3 expected_start=$4 expected_pgid=$5 identity_mode=${6:-validated} state start pgid sid - if ! demo_read_process_identity "$leader" state start pgid sid; then return 0; fi - [[ "$start" == "$expected_start" && "$pgid" == "$expected_pgid" && "$sid" == "$expected_pgid" ]] || return 1 - [[ "$state" == Z ]] && return 0 - [[ "$identity_mode" == raw ]] && return 0 - demo_process_has_command_line "$leader" || return 0 - if demo_command_matches "$root" "$leader" "$kind"; then return 0; fi - if ! demo_read_process_identity "$leader" state start pgid sid; then return 0; fi - [[ "$state" == Z ]] && return 0 - [[ "$start" == "$expected_start" && "$pgid" == "$expected_pgid" && "$sid" == "$expected_pgid" ]] || return 1 + local root=$1 kind=$2 leader=$3 expected_start=$4 expected_pgid=$5 identity_mode=${6:-validated} + local state start pgid sid command_status attempt + local attempts=${DEMO_ANCHOR_RECHECK_ATTEMPTS:-10} interval=${DEMO_ANCHOR_RECHECK_INTERVAL:-0.01} + demo_is_uint "$attempts" && ((10#$attempts > 0)) || attempts=10 + [[ "$interval" =~ ^[0-9]+([.][0-9]+)?$ ]] || interval=0.01 + for ((attempt = 0; attempt < attempts; attempt++)); do + if ! demo_read_process_identity "$leader" state start pgid sid; then return 0; fi + [[ "$start" == "$expected_start" && "$pgid" == "$expected_pgid" && "$sid" == "$expected_pgid" ]] || return 1 + [[ "$state" == Z ]] && return 0 + [[ "$identity_mode" == raw ]] && return 0 + if demo_command_matches "$root" "$leader" "$kind"; then return 0; else command_status=$?; fi + ((command_status == 2)) || return 1 + if ((attempt + 1 < attempts)); then sleep "$interval"; fi + done return 1 } @@ -221,10 +227,11 @@ demo_signal_member() { if ! demo_read_process_identity "$pid" state start pgid sid; then return 0; fi [[ "$state" != Z ]] || return 0 [[ "$start" == "$expected_start" && "$pgid" == "$expected_pgid" && "$sid" == "$expected_sid" ]] || return 2 - if kill -"$signal" -- "$pid"; then return 0; else signal_status=$?; fi + if kill -"$signal" -- "$pid" 2>/dev/null; then return 0; else signal_status=$?; fi if ! demo_read_process_identity "$pid" state start pgid sid; then return 0; fi [[ "$state" != Z ]] || return 0 [[ "$start" == "$expected_start" && "$pgid" == "$expected_pgid" && "$sid" == "$expected_sid" ]] || return 0 + printf 'Failed to signal unchanged live PID %s with %s\n' "$pid" "$signal" >&2 return "$signal_status" } @@ -255,13 +262,19 @@ demo_stop_owned_session() { for member in "${members[@]}"; do IFS=: read -r member_pid member_start <<<"$member" [[ "$member_pid" != "$leader" ]] || continue - demo_anchor_allows_signal "$root" "$kind" "$leader" "$expected_start" "$expected_pgid" "$identity_mode" || return 1 + if ! demo_anchor_allows_signal "$root" "$kind" "$leader" "$expected_start" "$expected_pgid" "$identity_mode"; then + printf 'Refusing TERM: %s leader identity changed or remained unverifiable\n' "$kind" >&2 + return 1 + fi demo_signal_member "$member_pid" "$member_start" "$expected_pgid" "$expected_pgid" TERM || return 1 done for member in "${members[@]}"; do IFS=: read -r member_pid member_start <<<"$member" [[ "$member_pid" == "$leader" ]] || continue - demo_anchor_allows_signal "$root" "$kind" "$leader" "$expected_start" "$expected_pgid" "$identity_mode" || return 1 + if ! demo_anchor_allows_signal "$root" "$kind" "$leader" "$expected_start" "$expected_pgid" "$identity_mode"; then + printf 'Refusing TERM: %s leader identity changed or remained unverifiable\n' "$kind" >&2 + return 1 + fi demo_signal_member "$member_pid" "$member_start" "$expected_pgid" "$expected_pgid" TERM || return 1 done for ((result = 0; result < ${DEMO_TERM_WAIT_ATTEMPTS:-50}; result++)); do diff --git a/tools/test-process-safety.sh b/tools/test-process-safety.sh index 09e4b0b..8cc9d9d 100755 --- a/tools/test-process-safety.sh +++ b/tools/test-process-safety.sh @@ -8,6 +8,8 @@ TEST_ROOT=$(mktemp -d /tmp/uups-bank-process-test.XXXXXX) PATH="$TEST_ROOT/bin:$PATH" export DEMO_TERM_WAIT_ATTEMPTS=5 export DEMO_KILL_WAIT_ATTEMPTS=10 +export DEMO_ANCHOR_RECHECK_ATTEMPTS=10 +export DEMO_ANCHOR_RECHECK_INTERVAL=0.01 ANVIL_DEV_PHRASE='test test test test test test test test test test test junk' ANVIL_EXACT_ARGS=(--host 127.0.0.1 --port 8545 --chain-id 31337 --mnemonic "$ANVIL_DEV_PHRASE") declare -a TEST_IDENTITIES=() @@ -17,7 +19,7 @@ STARTED_PID= cleanup() { local identity root relative for identity in "${TEST_IDENTITIES[@]}"; do test_safe_stop "$identity"; done - for root in absent stale nonnumeric wrong-command false-anvil false-vite exact-anvil raw-capture reused atomic matching group mutated partial local-reset base-canonical base-active sentinel; do + for root in absent stale nonnumeric wrong-command false-anvil false-vite exact-anvil raw-capture reused atomic matching group term-refusal anchor-race mutated partial local-reset base-canonical base-active sentinel; do for relative in \ .demo/anvil.pid .demo/anvil.start .demo/anvil.pgid .demo/anvil.log \ .demo/vite.pid .demo/vite.start .demo/vite.pgid .demo/vite.log \ @@ -32,7 +34,9 @@ cleanup() { "$TEST_ROOT/$root" 2>/dev/null || true done rm -f -- "$TEST_ROOT/bin/anvil" "$TEST_ROOT/bin/vite" "$TEST_ROOT/bin/leaderless" \ - "$TEST_ROOT/vite-child.pid" "$TEST_ROOT/leaderless-child.pid" + "$TEST_ROOT/vite-child.pid" "$TEST_ROOT/term-refusal-child.pid" "$TEST_ROOT/anchor-race-child.pid" \ + "$TEST_ROOT/leaderless-child.pid" "$TEST_ROOT/vanished-signal.err" "$TEST_ROOT/unsignaled-signal.err" \ + "$TEST_ROOT/term-refusal.err" rmdir -- "$TEST_ROOT/bin" "$TEST_ROOT" 2>/dev/null || true } trap cleanup EXIT @@ -167,7 +171,7 @@ assert_anvil_args_rejected() { demo_stop_raw_launch "$root" anvil "$pid" "$start" "$pgid" } -printf '1..21\n' +printf '1..26\n' # Catches cleanup treating a missing record as an error or signaling an inferred PID. root=$(make_root absent) @@ -235,6 +239,80 @@ assert_anvil_args_rejected "$root" reordered \ --port 8545 --host 127.0.0.1 --chain-id 31337 --mnemonic "$ANVIL_DEV_PHRASE" pass 'Anvil identity requires the exact complete launch argument tail' +# Catches treating one transient cmdline/exe read failure as a permanent identity mismatch. +setsid /bin/sleep 120 & recovered_anchor_pid=$! +track_process "$recovered_anchor_pid" +recovered_anchor_state='' recovered_anchor_start='' recovered_anchor_pgid='' recovered_anchor_sid='' +test_process_identity "$recovered_anchor_pid" recovered_anchor_state recovered_anchor_start \ + recovered_anchor_pgid recovered_anchor_sid +[[ "$recovered_anchor_state" != Z && "$recovered_anchor_sid" == "$recovered_anchor_pid" ]] \ + || fail 'recovering anchor fixture was not a live session leader' +if ( + anchor_command_attempt=0 + demo_command_matches() { + anchor_command_attempt=$((anchor_command_attempt + 1)) + ((anchor_command_attempt > 1)) && return 0 + return 2 + } + demo_anchor_allows_signal "$ROOT" anvil "$recovered_anchor_pid" "$recovered_anchor_start" \ + "$recovered_anchor_pgid" validated +); then + recovered_anchor_result=0 +else + recovered_anchor_result=$? +fi +((recovered_anchor_result == 0)) || fail 'a transient anchor command-read failure was not retried' +assert_alive "$recovered_anchor_pid" +pass 'a transient anchor command-read failure recovers through exact revalidation' + +# Catches failing shutdown when an unverifiable leader vanishes during bounded observation. +setsid /bin/sleep 120 & vanishing_anchor_pid=$! +track_process "$vanishing_anchor_pid" +vanishing_anchor_state='' vanishing_anchor_start='' vanishing_anchor_pgid='' vanishing_anchor_sid='' +test_process_identity "$vanishing_anchor_pid" vanishing_anchor_state vanishing_anchor_start \ + vanishing_anchor_pgid vanishing_anchor_sid +[[ "$vanishing_anchor_state" != Z && "$vanishing_anchor_sid" == "$vanishing_anchor_pid" ]] \ + || fail 'vanishing anchor fixture was not a live session leader' +( + sleep 0.03 + builtin kill -TERM -- "$vanishing_anchor_pid" 2>/dev/null || true +) & vanishing_anchor_killer=$! +if ( + demo_command_matches() { return 2; } + demo_anchor_allows_signal "$ROOT" anvil "$vanishing_anchor_pid" "$vanishing_anchor_start" \ + "$vanishing_anchor_pgid" validated +); then + vanishing_anchor_result=0 +else + vanishing_anchor_result=$? +fi +wait "$vanishing_anchor_killer" +wait "$vanishing_anchor_pid" 2>/dev/null || true +((vanishing_anchor_result == 0)) || fail 'an exiting unverifiable anchor made shutdown fatal' +pass 'an unverifiable anchor is accepted only after it vanishes or becomes a zombie' + +# Catches accepting a persistent live anchor merely because cmdline/exe cannot be read. +setsid /bin/sleep 120 & unreadable_anchor_pid=$! +track_process "$unreadable_anchor_pid" +unreadable_anchor_state='' unreadable_anchor_start='' unreadable_anchor_pgid='' unreadable_anchor_sid='' +test_process_identity "$unreadable_anchor_pid" unreadable_anchor_state unreadable_anchor_start \ + unreadable_anchor_pgid unreadable_anchor_sid +[[ "$unreadable_anchor_state" != Z && "$unreadable_anchor_sid" == "$unreadable_anchor_pid" ]] \ + || fail 'unreadable anchor fixture was not a live session leader' +if ( + demo_process_has_command_line() { return 1; } + demo_command_matches() { return 2; } + demo_anchor_allows_signal "$ROOT" anvil "$unreadable_anchor_pid" "$unreadable_anchor_start" \ + "$unreadable_anchor_pgid" validated +); then + unreadable_anchor_result=0 +else + unreadable_anchor_result=$? +fi +((unreadable_anchor_result != 0)) || fail 'a stable live anchor with unreadable command identity was accepted' +assert_alive "$unreadable_anchor_pid" +pass 'a stable live anchor remains refused when command identity is unverifiable' + # Catches treating an ESRCH-like signal race as fatal after the captured member has exited. setsid /bin/bash -c 'trap "exit 0" TERM; while :; do sleep 0.01; done' & vanished_pid=$! track_process "$vanished_pid" @@ -251,7 +329,8 @@ kill() { done return 1 } -if demo_signal_member "$vanished_pid" "$vanished_start" "$vanished_pgid" "$vanished_sid" TERM; then +if demo_signal_member "$vanished_pid" "$vanished_start" "$vanished_pgid" "$vanished_sid" TERM \ + 2>"$TEST_ROOT/vanished-signal.err"; then vanished_result=0 else vanished_result=$? @@ -259,6 +338,7 @@ fi unset -f kill wait "$vanished_pid" 2>/dev/null || true ((vanished_result == 0)) || fail 'a vanished member made its raced signal fatal' +[[ ! -s "$TEST_ROOT/vanished-signal.err" ]] || fail 'a benign vanished-member signal race emitted raw stderr' pass 'a failed signal is idempotent after the captured member vanishes' # Catches swallowing a real signal failure while the exact captured member remains live. @@ -268,13 +348,17 @@ unsignaled_state='' unsignaled_start='' unsignaled_pgid='' unsignaled_sid='' test_process_identity "$unsignaled_pid" unsignaled_state unsignaled_start unsignaled_pgid unsignaled_sid [[ "$unsignaled_state" != Z ]] || fail 'unchanged signal-failure fixture exited early' kill() { return 1; } -if demo_signal_member "$unsignaled_pid" "$unsignaled_start" "$unsignaled_pgid" "$unsignaled_sid" TERM; then +if demo_signal_member "$unsignaled_pid" "$unsignaled_start" "$unsignaled_pgid" "$unsignaled_sid" TERM \ + 2>"$TEST_ROOT/unsignaled-signal.err"; then unsignaled_result=0 else unsignaled_result=$? fi unset -f kill ((unsignaled_result != 0)) || fail 'a failed signal to the unchanged live member was accepted' +[[ $(<"$TEST_ROOT/unsignaled-signal.err") == \ + "Failed to signal unchanged live PID $unsignaled_pid with TERM" ]] \ + || fail 'an unchanged-live signal failure lacked its controlled diagnostic' assert_alive "$unsignaled_pid" pass 'a failed signal remains fatal for the same live member tuple' @@ -348,6 +432,80 @@ assert_dead "$child_pid" assert_alive "$unrelated_pid" pass 'bounded KILL removes a TERM-resistant owned child while an unrelated group survives' +# Catches a silent TERM-loop refusal after exact command identity definitively changes. +root=$(make_root term-refusal) +term_refusal_helper="$TEST_ROOT/bin/anvil" +term_refusal_child_file="$TEST_ROOT/term-refusal-child.pid" +cat >"$term_refusal_helper" <<'HELPER' +#!/usr/bin/env bash +sleep 120 & +printf '%s\n' "$!" >"$DEMO_CHILD_PID_FILE" +wait +sleep 120 +HELPER +chmod +x "$term_refusal_helper" +DEMO_CHILD_PID_FILE="$term_refusal_child_file" setsid "$term_refusal_helper" "${ANVIL_EXACT_ARGS[@]}" \ + & term_refusal_pid=$! +track_process "$term_refusal_pid" +for _ in {1..50}; do [[ -s "$term_refusal_child_file" ]] && break; sleep 0.02; done +[[ -s "$term_refusal_child_file" ]] || fail 'TERM-refusal helper child did not start' +term_refusal_start=$(start_tick "$term_refusal_pid") +if ( + anchor_command_attempt=0 + demo_command_matches() { + anchor_command_attempt=$((anchor_command_attempt + 1)) + ((anchor_command_attempt <= 2)) && return 0 + return 1 + } + demo_stop_launch "$root" anvil "$term_refusal_pid" "$term_refusal_start" "$term_refusal_pid" +) 2>"$TEST_ROOT/term-refusal.err"; then + term_refusal_result=0 +else + term_refusal_result=$? +fi +((term_refusal_result != 0)) || fail 'a definitive TERM-loop anchor mismatch was accepted' +[[ $(<"$TEST_ROOT/term-refusal.err") == \ + "Refusing TERM: anvil leader identity changed or remained unverifiable" ]] \ + || fail 'a TERM-loop anchor refusal lacked its controlled diagnostic' +assert_alive "$term_refusal_pid" +pass 'a definitive TERM-loop anchor mismatch is refused with a controlled diagnostic' + +# Catches the child-exit/leader-exit race becoming fatal during transient command-read loss. +root=$(make_root anchor-race) +anchor_race_helper="$TEST_ROOT/bin/anvil" +anchor_race_child_file="$TEST_ROOT/anchor-race-child.pid" +cat >"$anchor_race_helper" <<'HELPER' +#!/usr/bin/env bash +sleep 120 & +printf '%s\n' "$!" >"$DEMO_CHILD_PID_FILE" +wait +sleep 0.05 +HELPER +chmod +x "$anchor_race_helper" +DEMO_CHILD_PID_FILE="$anchor_race_child_file" setsid "$anchor_race_helper" "${ANVIL_EXACT_ARGS[@]}" \ + & anchor_race_pid=$! +track_process "$anchor_race_pid" +for _ in {1..50}; do [[ -s "$anchor_race_child_file" ]] && break; sleep 0.02; done +[[ -s "$anchor_race_child_file" ]] || fail 'anchor-race helper child did not start' +anchor_race_start=$(start_tick "$anchor_race_pid") +if ( + anchor_command_attempt=0 + demo_command_matches() { + anchor_command_attempt=$((anchor_command_attempt + 1)) + ((anchor_command_attempt <= 2)) && return 0 + return 2 + } + demo_stop_launch "$root" anvil "$anchor_race_pid" "$anchor_race_start" "$anchor_race_pid" +); then + anchor_race_result=0 +else + anchor_race_result=$? +fi +wait "$anchor_race_pid" 2>/dev/null || true +((anchor_race_result == 0)) || fail 'child exit made the concurrently exiting leader fatal' +assert_dead "$anchor_race_pid" +pass 'child-exit cleanup tolerates transient identity loss while the leader exits' + # Catches escalating after the recorded leader changes to a different command identity. root=$(make_root mutated) mutating_helper="$TEST_ROOT/bin/anvil" From ca6a99b9133f55b95bc704ab9b51c3db36e2f15a Mon Sep 17 00:00:00 2001 From: golem Date: Fri, 21 Aug 2026 14:58:24 -0600 Subject: [PATCH 20/30] feat: add storage-safe bank V2 --- src/BankV1.sol | 2 +- src/BankV2.sol | 29 +++++ test/BankUpgrade.t.sol | 186 +++++++++++++++++++++++++++ test/BankV2.t.sol | 165 ++++++++++++++++++++++++ test/mocks/IncompatibleBank.sol | 30 +++++ test/mocks/NonUUPSImplementation.sol | 8 ++ 6 files changed, 419 insertions(+), 1 deletion(-) create mode 100644 src/BankV2.sol create mode 100644 test/BankUpgrade.t.sol create mode 100644 test/BankV2.t.sol create mode 100644 test/mocks/IncompatibleBank.sol create mode 100644 test/mocks/NonUUPSImplementation.sol diff --git a/src/BankV1.sol b/src/BankV1.sol index 0f55426..35965fa 100644 --- a/src/BankV1.sol +++ b/src/BankV1.sol @@ -30,7 +30,7 @@ contract BankV1 is Initializable, UUPSUpgradeable, OwnableUpgradeable, PausableU _disableInitializers(); } - function initialize(address asset_, address initialOwner) external initializer { + function initialize(address asset_, address initialOwner) public initializer { if (asset_ == address(0)) { revert InvalidAsset(asset_); } diff --git a/src/BankV2.sol b/src/BankV2.sol new file mode 100644 index 0000000..ec158bc --- /dev/null +++ b/src/BankV2.sol @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.35; + +import {BankV1} from "./BankV1.sol"; + +/// @custom:oz-upgrades-from src/BankV1.sol:BankV1 +contract BankV2 is BankV1 { + event BalanceTransferred(address indexed from, address indexed to, uint256 amount); + + error InvalidRecipient(address recipient); + error SelfTransfer(); + + function transferBalance(address recipient, uint256 amount) external whenNotPaused { + if (amount == 0) revert ZeroAmount(); + if (recipient == address(0)) revert InvalidRecipient(recipient); + if (recipient == msg.sender) revert SelfTransfer(); + uint256 available = _balances[msg.sender]; + if (amount > available) { + revert InsufficientBalance(msg.sender, available, amount); + } + _balances[msg.sender] = available - amount; + _balances[recipient] += amount; + emit BalanceTransferred(msg.sender, recipient, amount); + } + + function contractVersion() public pure override returns (uint256) { + return 2; + } +} diff --git a/test/BankUpgrade.t.sol b/test/BankUpgrade.t.sol new file mode 100644 index 0000000..8f15e65 --- /dev/null +++ b/test/BankUpgrade.t.sol @@ -0,0 +1,186 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.35; + +import {ERC1967Utils} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Utils.sol"; +import {Initializable} from "@openzeppelin/contracts/proxy/utils/Initializable.sol"; +import {OwnableUpgradeable} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; +import {Options, Upgrades} from "openzeppelin-foundry-upgrades/Upgrades.sol"; + +import {BankV1} from "../src/BankV1.sol"; +import {BankV2} from "../src/BankV2.sol"; +import {BankTestBase} from "./helpers/BankTestBase.sol"; +import {IncompatibleBank} from "./mocks/IncompatibleBank.sol"; +import {NonUUPSImplementation} from "./mocks/NonUUPSImplementation.sol"; + +contract UpgradeValidator { + function validate(string memory contractName, Options memory opts) external { + Upgrades.validateUpgrade(contractName, opts); + } +} + +contract BankUpgradeTest is BankTestBase { + struct Snapshot { + address proxyAddress; + address implementationAddress; + address ownerAddress; + address assetAddress; + bool pausedState; + uint256 aliceBalance; + uint256 bobBalance; + uint256 liabilities; + uint256 reserves; + uint256 surplus; + uint256 version; + } + + Snapshot private beforeUpgrade; + + function setUp() public override { + super.setUp(); + _deposit(alice, 1_000e6); + _deposit(bob, 500e6); + + vm.prank(owner); + token.mint(stranger, 75e6); + vm.prank(stranger); + assertTrue(token.transfer(proxy, 75e6)); + + vm.prank(owner); + bank.pause(); + + uint256 liabilities = bank.totalLiabilities(); + uint256 reserves = token.balanceOf(proxy); + beforeUpgrade = Snapshot({ + proxyAddress: proxy, + implementationAddress: Upgrades.getImplementationAddress(proxy), + ownerAddress: bank.owner(), + assetAddress: address(bank.asset()), + pausedState: bank.paused(), + aliceBalance: bank.balanceOf(alice), + bobBalance: bank.balanceOf(bob), + liabilities: liabilities, + reserves: reserves, + surplus: reserves - liabilities, + version: bank.contractVersion() + }); + } + + function testValidatedOwnerUpgradeChangesOnlyImplementationAndVersion() public { + BankV2 upgraded = _validatedOwnerUpgrade(); + address implementationAfter = Upgrades.getImplementationAddress(proxy); + + assertEq(address(upgraded), beforeUpgrade.proxyAddress); + assertNotEq(implementationAfter, beforeUpgrade.implementationAddress); + assertGt(implementationAfter.code.length, 0); + assertEq(beforeUpgrade.version, 1); + assertEq(upgraded.contractVersion(), 2); + _assertSnapshotPreserved(upgraded); + } + + function testEveryV1MutationStillWorksAfterUpgrade() public { + BankV2 upgraded = _validatedOwnerUpgrade(); + + vm.prank(owner); + upgraded.unpause(); + assertFalse(upgraded.paused()); + + _mintAndApprove(stranger, 100e6); + vm.prank(stranger); + upgraded.deposit(100e6); + + vm.prank(alice); + upgraded.withdraw(200e6); + + assertEq(address(upgraded.asset()), address(token)); + assertEq(upgraded.owner(), owner); + assertEq(upgraded.balanceOf(alice), 800e6); + assertEq(upgraded.balanceOf(bob), 500e6); + assertEq(upgraded.balanceOf(stranger), 100e6); + assertEq(upgraded.totalLiabilities(), 1_400e6); + assertEq(token.balanceOf(proxy), 1_475e6); + assertEq(upgraded.contractVersion(), 2); + + vm.prank(owner); + upgraded.pause(); + assertTrue(upgraded.paused()); + vm.prank(owner); + upgraded.unpause(); + assertFalse(upgraded.paused()); + } + + function testNewImplementationCannotBeInitializedDirectly() public { + _validatedOwnerUpgrade(); + address upgradedImplementation = Upgrades.getImplementationAddress(proxy); + + vm.expectRevert(Initializable.InvalidInitialization.selector); + BankV2(upgradedImplementation).initialize(address(token), owner); + } + + function testNonOwnerUpgradeToAndCallRejectsWithUnauthorizedAccount() public { + address candidate = address(new BankV2()); + + vm.prank(stranger); + vm.expectRevert(abi.encodeWithSelector(OwnableUpgradeable.OwnableUnauthorizedAccount.selector, stranger)); + bank.upgradeToAndCall(candidate, ""); + + assertEq(Upgrades.getImplementationAddress(proxy), beforeUpgrade.implementationAddress); + assertEq(bank.contractVersion(), 1); + } + + function testValidateUpgradeRejectsIncompatibleApplicationStorageLayout() public { + Options memory opts; + opts.referenceContract = "BankV1.sol:BankV1"; + UpgradeValidator validator = new UpgradeValidator(); + + try validator.validate("IncompatibleBank.sol:IncompatibleBank", opts) { + fail("incompatible storage layout was accepted"); + } catch Error(string memory reason) { + assertTrue(vm.contains(reason, "Upgrade safety validation failed")); + assertTrue(vm.contains(reason, "Deleted `_asset`")); + assertTrue(vm.contains(reason, "Inserted `_asset`")); + } + } + + function testOwnerUpgradeToAndCallRejectsNonUUPSImplementationAtRuntime() public { + NonUUPSImplementation candidate = new NonUUPSImplementation(); + + vm.prank(owner); + vm.expectRevert(abi.encodeWithSelector(ERC1967Utils.ERC1967InvalidImplementation.selector, address(candidate))); + bank.upgradeToAndCall(address(candidate), ""); + + assertEq(Upgrades.getImplementationAddress(proxy), beforeUpgrade.implementationAddress); + assertEq(bank.contractVersion(), 1); + } + + function _validatedOwnerUpgrade() private returns (BankV2 upgraded) { + Options memory opts; + opts.referenceContract = "BankV1.sol:BankV1"; + Upgrades.upgradeProxy(proxy, "BankV2.sol:BankV2", "", opts, owner); + upgraded = BankV2(proxy); + } + + function _assertSnapshotPreserved(BankV2 upgraded) private view { + assertEq(address(upgraded), beforeUpgrade.proxyAddress); + assertEq(upgraded.owner(), beforeUpgrade.ownerAddress); + assertEq(address(upgraded.asset()), beforeUpgrade.assetAddress); + assertEq(upgraded.paused(), beforeUpgrade.pausedState); + assertEq(upgraded.balanceOf(alice), beforeUpgrade.aliceBalance); + assertEq(upgraded.balanceOf(bob), beforeUpgrade.bobBalance); + assertEq(upgraded.totalLiabilities(), beforeUpgrade.liabilities); + assertEq(token.balanceOf(proxy), beforeUpgrade.reserves); + assertEq(token.balanceOf(proxy) - upgraded.totalLiabilities(), beforeUpgrade.surplus); + } + + function _deposit(address account, uint256 amount) private { + _mintAndApprove(account, amount); + vm.prank(account); + bank.deposit(amount); + } + + function _mintAndApprove(address account, uint256 amount) private { + vm.prank(owner); + token.mint(account, amount); + vm.prank(account); + token.approve(proxy, amount); + } +} diff --git a/test/BankV2.t.sol b/test/BankV2.t.sol new file mode 100644 index 0000000..a95b475 --- /dev/null +++ b/test/BankV2.t.sol @@ -0,0 +1,165 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.35; + +import {PausableUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/PausableUpgradeable.sol"; +import {Options, Upgrades} from "openzeppelin-foundry-upgrades/Upgrades.sol"; + +import {BankV1} from "../src/BankV1.sol"; +import {BankV2} from "../src/BankV2.sol"; +import {BankTestBase} from "./helpers/BankTestBase.sol"; + +event BalanceTransferred(address indexed from, address indexed to, uint256 amount); + +contract BankV2Test is BankTestBase { + uint256 private constant ALICE_DEPOSIT = 1_000e6; + uint256 private constant BOB_DEPOSIT = 500e6; + + BankV2 internal bankV2; + + function setUp() public override { + super.setUp(); + _deposit(alice, ALICE_DEPOSIT); + _deposit(bob, BOB_DEPOSIT); + + Options memory opts; + opts.referenceContract = "BankV1.sol:BankV1"; + Upgrades.upgradeProxy(proxy, "BankV2.sol:BankV2", "", opts, owner); + bankV2 = BankV2(proxy); + } + + function testTransferBalanceMoves250MillionUnitsAndEmitsExactEvent() public { + vm.expectEmit(true, true, false, true, proxy); + emit BalanceTransferred(alice, bob, 250e6); + vm.prank(alice); + bankV2.transferBalance(bob, 250e6); + + assertEq(bankV2.balanceOf(alice), 750e6); + assertEq(bankV2.balanceOf(bob), 750e6); + } + + function testTransferBalanceLeavesLiabilitiesAndTokenReservesUnchanged() public { + uint256 liabilitiesBefore = bankV2.totalLiabilities(); + uint256 reservesBefore = token.balanceOf(proxy); + + vm.prank(alice); + bankV2.transferBalance(bob, 250e6); + + assertEq(bankV2.totalLiabilities(), liabilitiesBefore); + assertEq(token.balanceOf(proxy), reservesBefore); + } + + function testTransferBalanceRejectsZeroAmount() public { + vm.prank(alice); + vm.expectRevert(BankV1.ZeroAmount.selector); + bankV2.transferBalance(bob, 0); + } + + function testTransferBalanceRejectsZeroRecipient() public { + vm.prank(alice); + vm.expectRevert(abi.encodeWithSelector(BankV2.InvalidRecipient.selector, address(0))); + bankV2.transferBalance(address(0), 1); + } + + function testTransferBalanceRejectsSenderAsRecipient() public { + vm.prank(alice); + vm.expectRevert(BankV2.SelfTransfer.selector); + bankV2.transferBalance(alice, 1); + } + + function testTransferBalanceReportsAvailableAndRequestedWhenBalanceIsInsufficient() public { + vm.prank(alice); + vm.expectRevert( + abi.encodeWithSelector(BankV1.InsufficientBalance.selector, alice, ALICE_DEPOSIT, ALICE_DEPOSIT + 1) + ); + bankV2.transferBalance(bob, ALICE_DEPOSIT + 1); + + assertEq(bankV2.balanceOf(alice), ALICE_DEPOSIT); + assertEq(bankV2.balanceOf(bob), BOB_DEPOSIT); + } + + function testTransferBalanceRejectsCallsWhilePaused() public { + vm.prank(owner); + bankV2.pause(); + + vm.prank(alice); + vm.expectRevert(PausableUpgradeable.EnforcedPause.selector); + bankV2.transferBalance(bob, 1); + + assertEq(bankV2.balanceOf(alice), ALICE_DEPOSIT); + assertEq(bankV2.balanceOf(bob), BOB_DEPOSIT); + } + + function testTransferBalanceCreditsRecipientWithNoPreviousBalance() public { + assertEq(bankV2.balanceOf(stranger), 0); + + vm.prank(alice); + bankV2.transferBalance(stranger, 125e6); + + assertEq(bankV2.balanceOf(alice), 875e6); + assertEq(bankV2.balanceOf(stranger), 125e6); + } + + function testFuzzTransferBalanceAcrossTrackedRecipientsAndSenderBoundedAmounts( + uint256 recipientSeed, + uint256 amountSeed + ) public { + address recipient = _trackedRecipient(bound(recipientSeed, 0, 2)); + uint256 amount = bound(amountSeed, 1, ALICE_DEPOSIT); + uint256 recipientBefore = bankV2.balanceOf(recipient); + uint256 liabilitiesBefore = bankV2.totalLiabilities(); + uint256 reservesBefore = token.balanceOf(proxy); + + vm.prank(alice); + bankV2.transferBalance(recipient, amount); + + assertEq(bankV2.balanceOf(alice), ALICE_DEPOSIT - amount); + assertEq(bankV2.balanceOf(recipient), recipientBefore + amount); + assertEq(bankV2.totalLiabilities(), liabilitiesBefore); + assertEq(token.balanceOf(proxy), reservesBefore); + } + + function testV1DepositWithdrawalViewsPauseAndUnpauseStillWorkThroughV2Proxy() public { + _mintAndApprove(stranger, 100e6); + vm.prank(stranger); + bankV2.deposit(100e6); + + vm.prank(alice); + bankV2.withdraw(100e6); + + assertEq(address(bankV2.asset()), address(token)); + assertEq(bankV2.owner(), owner); + assertEq(bankV2.balanceOf(alice), 900e6); + assertEq(bankV2.balanceOf(bob), BOB_DEPOSIT); + assertEq(bankV2.balanceOf(stranger), 100e6); + assertEq(bankV2.totalLiabilities(), 1_500e6); + assertEq(token.balanceOf(proxy), 1_500e6); + assertEq(bankV2.contractVersion(), 2); + + vm.prank(owner); + bankV2.pause(); + assertTrue(bankV2.paused()); + + vm.prank(owner); + bankV2.unpause(); + assertFalse(bankV2.paused()); + } + + function _deposit(address account, uint256 amount) private { + _mintAndApprove(account, amount); + vm.prank(account); + bank.deposit(amount); + } + + function _mintAndApprove(address account, uint256 amount) private { + vm.prank(owner); + token.mint(account, amount); + vm.prank(account); + token.approve(proxy, amount); + } + + function _trackedRecipient(uint256 index) private view returns (address) { + if (index == 0) return bob; + if (index == 1) return stranger; + return owner; + } +} diff --git a/test/mocks/IncompatibleBank.sol b/test/mocks/IncompatibleBank.sol new file mode 100644 index 0000000..56b3d3a --- /dev/null +++ b/test/mocks/IncompatibleBank.sol @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.35; + +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {Initializable} from "@openzeppelin/contracts/proxy/utils/Initializable.sol"; +import {UUPSUpgradeable} from "@openzeppelin/contracts/proxy/utils/UUPSUpgradeable.sol"; +import {ReentrancyGuardTransient} from "@openzeppelin/contracts/utils/ReentrancyGuardTransient.sol"; +import {OwnableUpgradeable} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; +import {PausableUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/PausableUpgradeable.sol"; + +contract IncompatibleBank is + Initializable, + UUPSUpgradeable, + OwnableUpgradeable, + PausableUpgradeable, + ReentrancyGuardTransient +{ + mapping(address account => uint256 balance) internal _balances; + IERC20 internal _asset; + uint256 internal _totalLiabilities; + uint256[47] private __gap; + + function initialize(address asset_, address initialOwner) public initializer { + __Ownable_init(initialOwner); + __Pausable_init(); + _asset = IERC20(asset_); + } + + function _authorizeUpgrade(address) internal override onlyOwner {} +} diff --git a/test/mocks/NonUUPSImplementation.sol b/test/mocks/NonUUPSImplementation.sol new file mode 100644 index 0000000..d377035 --- /dev/null +++ b/test/mocks/NonUUPSImplementation.sol @@ -0,0 +1,8 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.35; + +contract NonUUPSImplementation { + function contractVersion() external pure returns (uint256) { + return 2; + } +} From 94f2ad6e09781c21e89703f60ce2d57a8186dc36 Mon Sep 17 00:00:00 2001 From: golem Date: Fri, 21 Aug 2026 15:31:40 -0600 Subject: [PATCH 21/30] feat: demonstrate state-preserving V2 upgrade --- Makefile | 12 +- script/CheckState.s.sol | 21 +- script/TransferV2Demo.s.sol | 41 ++++ script/UpgradeV2.s.sol | 183 +++++++++++++++++ test/BankInvariant.t.sol | 27 ++- test/ScriptPreflight.t.sol | 251 +++++++++++++++++++++++- test/helpers/BankV2Handler.sol | 32 +++ tools/finalize-manifest.mjs | 202 ++++++++++++++++++- tools/sync-web-artifacts.mjs | 23 ++- tools/test-finalize-manifest.mjs | 217 +++++++++++++++++++- tools/test-sync-web-artifacts.mjs | 26 ++- web/src/App.test.tsx | 28 +++ web/src/components/ActivityTimeline.tsx | 7 + web/src/data/bankClient.test.ts | 31 ++- web/src/data/bankClient.ts | 14 +- web/src/types/dashboard.ts | 3 +- 16 files changed, 1082 insertions(+), 36 deletions(-) create mode 100644 script/TransferV2Demo.s.sol create mode 100644 script/UpgradeV2.s.sol create mode 100644 test/helpers/BankV2Handler.sol diff --git a/Makefile b/Makefile index 598bae8..42b3ca6 100644 --- a/Makefile +++ b/Makefile @@ -3,8 +3,9 @@ SHELL := /bin/bash RPC_LOCAL := http://127.0.0.1:8545 ANVIL_OWNER := 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 +ANVIL_ALICE := 0x70997970C51812dc3A010C7d01b50e0d17dc79C8 -.PHONY: doctor setup demo-local verify check-state reset-local deploy-v1 seed-v1 sync-artifacts sync-artifacts-check sync-abis publish-web-manifest test-finalize-manifest +.PHONY: doctor setup demo-local verify check-state reset-local deploy-v1 seed-v1 upgrade-v2 demo-transfer sync-artifacts sync-artifacts-check sync-abis publish-web-manifest test-finalize-manifest doctor: @bash tools/doctor.sh setup: @@ -51,5 +52,14 @@ deploy-v1: @node tools/select-manifest.mjs anvil seed-v1: @forge script script/SeedV1Demo.s.sol:SeedV1Demo --rpc-url $(RPC_LOCAL) --broadcast --force +upgrade-v2: + @SCRIPT_SENDER=$(ANVIL_OWNER) DEPLOYMENT_MANIFEST_PATH=deployments/upgrade-pending.json npm_config_offline=true forge script script/UpgradeV2.s.sol:UpgradeV2 --rpc-url $(RPC_LOCAL) --sender $(ANVIL_OWNER) --broadcast --force + @node tools/finalize-manifest.mjs upgrade --rpc-url $(RPC_LOCAL) + @DEMO_EXPECTED_STAGE=invariants forge script script/CheckState.s.sol:CheckState --rpc-url $(RPC_LOCAL) --force + @node tools/sync-web-artifacts.mjs + @node tools/publish-web-manifest.mjs +demo-transfer: + @forge script script/TransferV2Demo.s.sol:TransferV2Demo --rpc-url $(RPC_LOCAL) --sender $(ANVIL_ALICE) --broadcast --force + @DEMO_EXPECTED_STAGE=v2 $(MAKE) check-state check-state: @forge script script/CheckState.s.sol:CheckState --rpc-url $(RPC_LOCAL) --force diff --git a/script/CheckState.s.sol b/script/CheckState.s.sol index 1f6c712..0aac173 100644 --- a/script/CheckState.s.sol +++ b/script/CheckState.s.sol @@ -12,11 +12,14 @@ contract CheckState is DemoScript { error UnknownStage(string stage); function run() external view { + _run(_manifestPath(ACTIVE_MANIFEST_PATH), vm.envOr("DEMO_EXPECTED_STAGE", string("v1"))); + } + + function _run(string memory manifestPath, string memory stage) internal view { _requireSupportedChain(block.chainid); _printEducationalWarning(); - string memory stage = vm.envOr("DEMO_EXPECTED_STAGE", string("v1")); bool deployedStage = keccak256(bytes(stage)) == keccak256("deployed"); - Manifest memory manifest = _readManifest(_manifestPath(ACTIVE_MANIFEST_PATH), !deployedStage); + Manifest memory manifest = _readManifest(manifestPath, !deployedStage); BankV1 bank = BankV1(manifest.proxy); MockUSDC token = MockUSDC(manifest.token); @@ -53,6 +56,20 @@ contract CheckState is DemoScript { } else if (stageHash == keccak256("v1")) { _assertV1State(manifest); _assertUint("surplus", 0, surplus); + } else if (stageHash == keccak256("upgraded")) { + _assertUint("Alice internal balance", 900e6, bank.balanceOf(manifest.actors[1].address_)); + _assertUint("Bob internal balance", 500e6, bank.balanceOf(manifest.actors[2].address_)); + _assertUint("liabilities", 1_400e6, liabilities); + _assertUint("reserves", 1_400e6, reserves); + _assertUint("surplus", 0, surplus); + _assertUint("version", 2, bank.contractVersion()); + } else if (stageHash == keccak256("v2")) { + _assertUint("Alice internal balance", 650e6, bank.balanceOf(manifest.actors[1].address_)); + _assertUint("Bob internal balance", 750e6, bank.balanceOf(manifest.actors[2].address_)); + _assertUint("liabilities", 1_400e6, liabilities); + _assertUint("reserves", 1_400e6, reserves); + _assertUint("surplus", 0, surplus); + _assertUint("version", 2, bank.contractVersion()); } else if (stageHash != keccak256("invariants")) { revert UnknownStage(stage); } diff --git a/script/TransferV2Demo.s.sol b/script/TransferV2Demo.s.sol new file mode 100644 index 0000000..8dc4943 --- /dev/null +++ b/script/TransferV2Demo.s.sol @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.35; + +import {BankV2} from "../src/BankV2.sol"; +import {MockUSDC} from "../src/MockUSDC.sol"; +import {DemoScript} from "./lib/DemoScript.sol"; + +contract TransferV2Demo is DemoScript { + function run() external { + _run(_manifestPath(ACTIVE_MANIFEST_PATH)); + } + + function _run(string memory manifestPath) internal { + if (block.chainid != ANVIL_CHAIN_ID) revert UnsupportedChain(block.chainid); + _printEducationalWarning(); + Manifest memory manifest = _readManifest(manifestPath, true); + (uint256 aliceKey, address alice) = _deriveLocalActor(block.chainid, 1); + address bob = manifest.actors[2].address_; + _assertAddress("Alice actor", manifest.actors[1].address_, alice); + + BankV2 bank = BankV2(manifest.proxy); + MockUSDC token = MockUSDC(manifest.token); + _assertUint("version", 2, bank.contractVersion()); + _assertUint("Alice internal balance", 900e6, bank.balanceOf(alice)); + _assertUint("Bob internal balance", 500e6, bank.balanceOf(bob)); + uint256 liabilities = bank.totalLiabilities(); + uint256 reserves = token.balanceOf(manifest.proxy); + _assertUint("liabilities", 1_400e6, liabilities); + _assertUint("reserves", 1_400e6, reserves); + + vm.startBroadcast(aliceKey); + bank.transferBalance(bob, 250e6); + vm.stopBroadcast(); + + _assertUint("Alice internal balance", 650e6, bank.balanceOf(alice)); + _assertUint("Bob internal balance", 750e6, bank.balanceOf(bob)); + _assertUint("liabilities", liabilities, bank.totalLiabilities()); + _assertUint("reserves", reserves, token.balanceOf(manifest.proxy)); + _assertUint("surplus", 0, token.balanceOf(manifest.proxy) - bank.totalLiabilities()); + } +} diff --git a/script/UpgradeV2.s.sol b/script/UpgradeV2.s.sol new file mode 100644 index 0000000..578dc36 --- /dev/null +++ b/script/UpgradeV2.s.sol @@ -0,0 +1,183 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.35; + +import {Options, Upgrades} from "openzeppelin-foundry-upgrades/Upgrades.sol"; +import {console2} from "forge-std/console2.sol"; +import {BankV1} from "../src/BankV1.sol"; +import {MockUSDC} from "../src/MockUSDC.sol"; +import {DemoScript} from "./lib/DemoScript.sol"; + +contract UpgradeV2 is DemoScript { + string internal constant UPGRADE_PENDING_MANIFEST_PATH = "deployments/upgrade-pending.json"; + + error UnexpectedVersion(uint256 version); + + struct Snapshot { + address proxy; + address implementation; + address owner; + address asset; + bool paused; + uint256[] balances; + uint256 liabilities; + uint256 reserves; + uint256 surplus; + uint256 deploymentBlock; + uint256 version; + } + + function run() external returns (bool upgraded, address implementation) { + _requireSupportedChain(block.chainid); + _printEducationalWarning(); + string memory activePath = vm.envOr("UPGRADE_ACTIVE_MANIFEST_PATH", ACTIVE_MANIFEST_PATH); + return _run(activePath, _manifestPath(UPGRADE_PENDING_MANIFEST_PATH), vm.envAddress("SCRIPT_SENDER")); + } + + function _run(string memory activePath, string memory pendingPath, address sender) + internal + returns (bool upgraded, address implementation) + { + Manifest memory manifest = _readManifest(activePath, true); + BankV1 bank = BankV1(manifest.proxy); + + address actualImplementation = Upgrades.getImplementationAddress(manifest.proxy); + _assertAddress("implementation", manifest.implementation, actualImplementation); + _assertAddress("owner", manifest.owner, bank.owner()); + _assertAddress("asset", manifest.token, address(bank.asset())); + _assertAddress("SCRIPT_SENDER", bank.owner(), sender); + + uint256 version = bank.contractVersion(); + if (version == 2) { + implementation = actualImplementation; + _writeNoopMarker(pendingPath, manifest, vm.getNonce(bank.owner()), block.number); + console2.log("BankV2 already active; no upgrade broadcast."); + return (false, implementation); + } + if (version != 1) revert UnexpectedVersion(version); + + Snapshot memory before_ = _snapshot(manifest); + Options memory opts; + opts.referenceContract = "BankV1.sol:BankV1"; + Upgrades.validateUpgrade("BankV2.sol:BankV2", opts); + + if (block.chainid == ANVIL_CHAIN_ID) { + (uint256 ownerKey, address derivedOwner) = _deriveLocalActor(block.chainid, 0); + _assertAddress("local owner", sender, derivedOwner); + vm.startBroadcast(ownerKey); + } else { + vm.startBroadcast(sender); + } + Upgrades.upgradeProxy(manifest.proxy, "BankV2.sol:BankV2", "", opts); + vm.stopBroadcast(); + + Snapshot memory after_ = _snapshot(manifest); + implementation = after_.implementation; + _requireCode("implementation", implementation); + if (implementation == before_.implementation) { + revert UnexpectedAddress("implementation changed", before_.implementation, implementation); + } + _assertUint("version", 2, after_.version); + _assertSnapshotUnchanged(before_, after_); + _writeUpgradeMarker(pendingPath, manifest, before_, implementation); + return (true, implementation); + } + + function _snapshot(Manifest memory manifest) internal view returns (Snapshot memory snapshot) { + BankV1 bank = BankV1(manifest.proxy); + uint256 reserves = MockUSDC(manifest.token).balanceOf(manifest.proxy); + uint256 liabilities = bank.totalLiabilities(); + snapshot.proxy = manifest.proxy; + snapshot.implementation = Upgrades.getImplementationAddress(manifest.proxy); + snapshot.owner = bank.owner(); + snapshot.asset = address(bank.asset()); + snapshot.paused = bank.paused(); + snapshot.balances = new uint256[](manifest.actors.length); + for (uint256 i; i < manifest.actors.length; ++i) { + snapshot.balances[i] = bank.balanceOf(manifest.actors[i].address_); + } + snapshot.liabilities = liabilities; + snapshot.reserves = reserves; + snapshot.surplus = reserves - liabilities; + snapshot.deploymentBlock = manifest.deploymentBlock; + snapshot.version = bank.contractVersion(); + } + + function _assertSnapshotUnchanged(Snapshot memory before_, Snapshot memory after_) internal pure { + _assertAddress("proxy", before_.proxy, after_.proxy); + _assertAddress("owner", before_.owner, after_.owner); + _assertAddress("asset", before_.asset, after_.asset); + _assertUint("paused", before_.paused ? 1 : 0, after_.paused ? 1 : 0); + _assertUint("actor count", before_.balances.length, after_.balances.length); + for (uint256 i; i < before_.balances.length; ++i) { + _assertUint("actor balance", before_.balances[i], after_.balances[i]); + } + _assertUint("liabilities", before_.liabilities, after_.liabilities); + _assertUint("reserves", before_.reserves, after_.reserves); + _assertUint("surplus", before_.surplus, after_.surplus); + _assertUint("deployment block", before_.deploymentBlock, after_.deploymentBlock); + } + + function _writeNoopMarker(string memory path, Manifest memory manifest, uint256 ownerNonce, uint256 observedBlock) + private + { + vm.writeJson( + string.concat( + '{"mode":"noop","chainId":', + vm.toString(manifest.chainId), + ',"observedBlock":', + vm.toString(observedBlock), + ',"ownerNonce":', + vm.toString(ownerNonce), + ',"proxy":"', + vm.toString(manifest.proxy), + '","implementation":"', + vm.toString(manifest.implementation), + '"}' + ), + path + ); + } + + function _writeUpgradeMarker( + string memory path, + Manifest memory manifest, + Snapshot memory before_, + address implementation + ) private { + string memory balances = "["; + for (uint256 i; i < manifest.actors.length; ++i) { + if (i != 0) balances = string.concat(balances, ","); + balances = string.concat( + balances, + '{"address":"', + vm.toString(manifest.actors[i].address_), + '","balance":', + vm.toString(before_.balances[i]), + "}" + ); + } + balances = string.concat(balances, "]"); + string memory snapshot = string.concat('{"proxy":"', vm.toString(before_.proxy), '"'); + snapshot = string.concat(snapshot, ',"implementation":"', vm.toString(before_.implementation), '"'); + snapshot = string.concat(snapshot, ',"owner":"', vm.toString(before_.owner), '"'); + snapshot = string.concat(snapshot, ',"asset":"', vm.toString(before_.asset), '"'); + snapshot = string.concat(snapshot, ',"paused":', vm.toString(before_.paused)); + snapshot = string.concat(snapshot, ',"balances":', balances); + snapshot = string.concat(snapshot, ',"liabilities":', vm.toString(before_.liabilities)); + snapshot = string.concat(snapshot, ',"reserves":', vm.toString(before_.reserves)); + snapshot = string.concat(snapshot, ',"surplus":', vm.toString(before_.surplus)); + snapshot = string.concat(snapshot, ',"deploymentBlock":', vm.toString(before_.deploymentBlock)); + snapshot = string.concat(snapshot, ',"version":', vm.toString(before_.version), "}"); + + string memory json = string.concat('{"mode":"upgrade","network":"', manifest.network, '"'); + json = string.concat(json, ',"chainId":', vm.toString(manifest.chainId)); + json = string.concat(json, ',"token":"', vm.toString(manifest.token), '"'); + json = string.concat(json, ',"proxy":"', vm.toString(manifest.proxy), '"'); + json = string.concat(json, ',"previousImplementation":"', vm.toString(manifest.implementation), '"'); + json = string.concat(json, ',"implementation":"', vm.toString(implementation), '"'); + json = string.concat(json, ',"owner":"', vm.toString(manifest.owner), '"'); + json = string.concat(json, ',"deploymentBlock":', vm.toString(manifest.deploymentBlock)); + json = string.concat(json, ',"snapshot":', snapshot, "}"); + vm.writeJson(json, path); + } +} diff --git a/test/BankInvariant.t.sol b/test/BankInvariant.t.sol index f699902..6140106 100644 --- a/test/BankInvariant.t.sol +++ b/test/BankInvariant.t.sol @@ -2,26 +2,45 @@ pragma solidity 0.8.35; import {BankTestBase} from "./helpers/BankTestBase.sol"; -import {BankHandler} from "./helpers/BankHandler.sol"; +import {BankV2} from "../src/BankV2.sol"; +import {Options, Upgrades} from "openzeppelin-foundry-upgrades/Upgrades.sol"; +import {BankV2Handler} from "./helpers/BankV2Handler.sol"; contract BankInvariantTest is BankTestBase { - BankHandler internal handler; + BankV2Handler internal handler; function setUp() public override { super.setUp(); + Options memory opts; + opts.referenceContract = "BankV1.sol:BankV1"; + Upgrades.upgradeProxy(proxy, "BankV2.sol:BankV2", "", opts, owner); + BankV2 bankV2 = BankV2(proxy); - handler = new BankHandler(token, bank); + handler = new BankV2Handler(token, bankV2); vm.prank(owner); token.transferOwnership(address(handler)); targetContract(address(handler)); - bytes4[] memory selectors = new bytes4[](3); + bytes4[] memory selectors = new bytes4[](4); selectors[0] = handler.deposit.selector; selectors[1] = handler.withdraw.selector; selectors[2] = handler.donate.selector; + selectors[3] = handler.transfer.selector; targetSelector(FuzzSelector({addr: address(handler), selectors: selectors})); } + function testHandlerExecutesDeterministicTrackedTransfer() public { + handler.deposit(0, 40e6); + + handler.transfer(0, 0, 15e6); + + assertEq(bank.balanceOf(handler.actorAt(0)), 25e6); + assertEq(bank.balanceOf(handler.actorAt(1)), 15e6); + assertEq(handler.ghostTransferred(), 15e6); + assertEq(bank.totalLiabilities(), 40e6); + assertEq(token.balanceOf(address(bank)), 40e6); + } + function invariant_liabilitiesEqualTrackedBalances() public view { uint256 sum; for (uint256 i; i < handler.actorCount(); ++i) { diff --git a/test/ScriptPreflight.t.sol b/test/ScriptPreflight.t.sol index 7f1f96b..0aedca0 100644 --- a/test/ScriptPreflight.t.sol +++ b/test/ScriptPreflight.t.sol @@ -2,11 +2,15 @@ pragma solidity 0.8.35; import {Test} from "forge-std/Test.sol"; +import {Upgrades} from "openzeppelin-foundry-upgrades/Upgrades.sol"; import {DemoScript} from "../script/lib/DemoScript.sol"; import {DeployV1} from "../script/DeployV1.s.sol"; import {SeedV1Demo} from "../script/SeedV1Demo.s.sol"; import {CheckState} from "../script/CheckState.s.sol"; +import {UpgradeV2} from "../script/UpgradeV2.s.sol"; +import {TransferV2Demo} from "../script/TransferV2Demo.s.sol"; import {BankV1} from "../src/BankV1.sol"; +import {BankV2} from "../src/BankV2.sol"; import {MockUSDC} from "../src/MockUSDC.sol"; contract ScriptPreflightHarness is DemoScript { @@ -35,12 +39,39 @@ contract ScriptPreflightHarness is DemoScript { } } +contract UpgradeV2Harness is UpgradeV2 { + function assertSnapshotUnchanged(Snapshot calldata before_, Snapshot calldata after_) external pure { + _assertSnapshotUnchanged(before_, after_); + } + + function runWithPaths(string calldata activePath, string calldata pendingPath, address sender) + external + returns (bool upgraded, address implementation) + { + _requireSupportedChain(block.chainid); + return _run(activePath, pendingPath, sender); + } +} + +contract TransferV2DemoHarness is TransferV2Demo { + function runWithPath(string calldata manifestPath) external { + _run(manifestPath); + } +} + +contract CheckStateHarness is CheckState { + function runWithPath(string calldata manifestPath, string calldata stage) external view { + _run(manifestPath, stage); + } +} + contract ScriptPreflightTest is Test { ScriptPreflightHarness internal harness; string internal fixtureDir; address internal constant OWNER = 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266; address internal constant ALICE = 0x70997970C51812dc3A010C7d01b50e0d17dc79C8; + address internal constant BOB = 0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC; address internal constant TOKEN = 0x1000000000000000000000000000000000000001; address internal constant PROXY = 0x2000000000000000000000000000000000000002; address internal constant IMPLEMENTATION = 0x3000000000000000000000000000000000000003; @@ -302,7 +333,7 @@ contract ScriptPreflightTest is Test { } function testSeedV1DemoExecutesExactActOneStateAndCheckStateAcceptsIt() public { - (string memory path, DemoScript.Manifest memory manifest) = _deployFixture(); + (string memory path, DemoScript.Manifest memory manifest) = _deployFixtureNamed("seed-v1"); vm.setEnv("DEPLOYMENT_MANIFEST_PATH", path); new SeedV1Demo().run(); @@ -320,18 +351,176 @@ contract ScriptPreflightTest is Test { } function testCheckStateRejectsManifestImplementationMismatch() public { - (string memory path, DemoScript.Manifest memory manifest) = _deployFixture(); + (string memory path, DemoScript.Manifest memory manifest) = _deployUpgradeFixtureNamed("check-mismatch"); vm.writeJson(string.concat('"', vm.toString(manifest.token), '"'), path, ".implementation"); - vm.setEnv("DEPLOYMENT_MANIFEST_PATH", path); - vm.setEnv("DEMO_EXPECTED_STAGE", "deployed"); - CheckState checker = new CheckState(); + CheckStateHarness checker = new CheckStateHarness(); vm.expectRevert( abi.encodeWithSelector( DemoScript.UnexpectedAddress.selector, "implementation", manifest.token, manifest.implementation ) ); - checker.run(); + checker.runWithPath(path, "deployed"); + } + + function testUpgradeV2RequiresManifestIdentityCodeOwnerSenderAndVersionOne() public { + (string memory path, DemoScript.Manifest memory manifest) = _deployUpgradeFixtureNamed("upgrade-preflight"); + string memory pending = string.concat(fixtureDir, "/upgrade-pending.json"); + UpgradeV2Harness upgrader = new UpgradeV2Harness(); + + vm.expectRevert( + abi.encodeWithSelector(DemoScript.UnexpectedAddress.selector, "SCRIPT_SENDER", manifest.owner, ALICE) + ); + upgrader.runWithPaths(path, pending, ALICE); + + vm.writeJson(string.concat('"', vm.toString(manifest.token), '"'), path, ".implementation"); + vm.expectRevert( + abi.encodeWithSelector( + DemoScript.UnexpectedAddress.selector, "implementation", manifest.token, manifest.implementation + ) + ); + upgrader.runWithPaths(path, pending, OWNER); + } + + function testUpgradeV2RejectsUnsupportedChainMissingProxyCodeWrongOwnerAndUnexpectedVersion() public { + UpgradeV2Harness upgrader = new UpgradeV2Harness(); + vm.chainId(1); + vm.expectRevert(abi.encodeWithSelector(DemoScript.UnsupportedChain.selector, uint256(1))); + upgrader.runWithPaths("unused", "unused", OWNER); + + vm.chainId(31337); + (string memory missingPath,) = _deployUpgradeFixtureNamed("upgrade-missing-proxy"); + vm.writeJson('"0x4000000000000000000000000000000000000004"', missingPath, ".proxy"); + vm.expectRevert( + abi.encodeWithSelector(DemoScript.MissingCode.selector, "proxy", 0x4000000000000000000000000000000000000004) + ); + upgrader.runWithPaths(missingPath, string.concat(fixtureDir, "/missing-pending.json"), OWNER); + + (string memory ownerPath, DemoScript.Manifest memory ownerManifest) = + _deployUpgradeFixtureNamed("upgrade-owner"); + vm.prank(OWNER); + BankV1(ownerManifest.proxy).transferOwnership(ALICE); + vm.expectRevert(abi.encodeWithSelector(DemoScript.UnexpectedAddress.selector, "owner", OWNER, ALICE)); + upgrader.runWithPaths(ownerPath, string.concat(fixtureDir, "/owner-pending.json"), ALICE); + + (string memory versionPath, DemoScript.Manifest memory versionManifest) = + _deployUpgradeFixtureNamed("upgrade-version"); + vm.mockCall( + versionManifest.proxy, abi.encodeWithSelector(BankV1.contractVersion.selector), abi.encode(uint256(3)) + ); + vm.expectRevert(abi.encodeWithSelector(UpgradeV2.UnexpectedVersion.selector, uint256(3))); + upgrader.runWithPaths(versionPath, string.concat(fixtureDir, "/version-pending.json"), OWNER); + } + + function testUpgradeV2PreservesCompleteSnapshotAndWritesOnlyStagingRecord() public { + (string memory path, DemoScript.Manifest memory manifest) = _deployUpgradeFixtureNamed("upgrade-preserve"); + _seedState(manifest); + string memory beforeManifest = vm.readFile(path); + string memory pending = string.concat(fixtureDir, "/upgrade-pending.json"); + + (bool upgraded, address implementation) = new UpgradeV2Harness().runWithPaths(path, pending, OWNER); + + assertTrue(upgraded); + assertNotEq(implementation, manifest.implementation); + assertEq(vm.readFile(path), beforeManifest); + BankV2 bankV2 = BankV2(manifest.proxy); + assertEq(bankV2.contractVersion(), 2); + assertEq(bankV2.owner(), manifest.owner); + assertEq(address(bankV2.asset()), manifest.token); + assertFalse(bankV2.paused()); + assertEq(bankV2.balanceOf(ALICE), 900e6); + assertEq(bankV2.balanceOf(BOB), 500e6); + assertEq(bankV2.totalLiabilities(), 1_400e6); + assertEq(MockUSDC(manifest.token).balanceOf(manifest.proxy), 1_400e6); + string memory marker = vm.readFile(pending); + assertEq(vm.parseJsonString(marker, ".mode"), "upgrade"); + assertEq(vm.parseJsonAddress(marker, ".implementation"), implementation); + assertEq(vm.parseJsonAddress(marker, ".snapshot.proxy"), manifest.proxy); + assertEq(vm.parseJsonUint(marker, ".snapshot.balances[1].balance"), 900e6); + assertEq(vm.parseJsonUint(marker, ".snapshot.balances[2].balance"), 500e6); + } + + function testUpgradeV2AlreadyActiveWritesVerifiableNoopWithoutChangingImplementation() public { + (string memory path, DemoScript.Manifest memory manifest) = _deployUpgradeFixtureNamed("upgrade-noop"); + string memory pending = string.concat(fixtureDir, "/upgrade-pending.json"); + UpgradeV2Harness upgrader = new UpgradeV2Harness(); + (, address implementation) = upgrader.runWithPaths(path, pending, OWNER); + vm.writeJson(string.concat('"', vm.toString(implementation), '"'), path, ".implementation"); + uint256 nonceBefore = vm.getNonce(OWNER); + + (bool upgraded, address observedImplementation) = upgrader.runWithPaths(path, pending, OWNER); + + assertFalse(upgraded); + assertEq(observedImplementation, implementation); + assertEq(vm.getNonce(OWNER), nonceBefore); + string memory marker = vm.readFile(pending); + assertEq(vm.parseJsonString(marker, ".mode"), "noop"); + assertEq(vm.parseJsonUint(marker, ".chainId"), 31337); + assertEq(vm.parseJsonUint(marker, ".observedBlock"), block.number); + assertEq(vm.parseJsonUint(marker, ".ownerNonce"), nonceBefore); + assertEq(vm.parseJsonAddress(marker, ".proxy"), manifest.proxy); + assertEq(vm.parseJsonAddress(marker, ".implementation"), implementation); + } + + function testSnapshotComparisonRejectsApplicationMutationButAllowsImplementationAndVersionChange() public { + UpgradeV2Harness upgradeHarness = new UpgradeV2Harness(); + UpgradeV2.Snapshot memory before_ = _snapshot(); + UpgradeV2.Snapshot memory after_ = _snapshot(); + after_.implementation = address(0x9999); + after_.version = 2; + upgradeHarness.assertSnapshotUnchanged(before_, after_); + + for (uint256 mutation; mutation < 10; ++mutation) { + before_ = _snapshot(); + after_ = _snapshot(); + if (mutation == 0) after_.proxy = address(0x9999); + else if (mutation == 1) after_.owner = address(0x9999); + else if (mutation == 2) after_.asset = address(0x9999); + else if (mutation == 3) after_.paused = true; + else if (mutation == 4) after_.balances = new uint256[](2); + else if (mutation == 5) after_.balances[1] += 1; + else if (mutation == 6) after_.liabilities += 1; + else if (mutation == 7) after_.reserves += 1; + else if (mutation == 8) after_.surplus += 1; + else after_.deploymentBlock += 1; + vm.expectRevert(); + upgradeHarness.assertSnapshotUnchanged(before_, after_); + } + } + + function testTransferV2DemoExecutesExactActThreeAndCheckStateAcceptsBothV2Stages() public { + (string memory path, DemoScript.Manifest memory manifest) = _deployUpgradeFixtureNamed("transfer-v2"); + _seedState(manifest); + string memory pending = string.concat(fixtureDir, "/upgrade-pending.json"); + (, address implementation) = new UpgradeV2Harness().runWithPaths(path, pending, OWNER); + vm.writeJson(string.concat('"', vm.toString(implementation), '"'), path, ".implementation"); + + new CheckStateHarness().runWithPath(path, "upgraded"); + new TransferV2DemoHarness().runWithPath(path); + new CheckStateHarness().runWithPath(path, "v2"); + + BankV2 bankV2 = BankV2(manifest.proxy); + assertEq(bankV2.balanceOf(ALICE), 650e6); + assertEq(bankV2.balanceOf(BOB), 750e6); + assertEq(bankV2.totalLiabilities(), 1_400e6); + assertEq(MockUSDC(manifest.token).balanceOf(manifest.proxy), 1_400e6); + } + + function _snapshot() internal pure returns (UpgradeV2.Snapshot memory snapshot) { + snapshot.proxy = PROXY; + snapshot.implementation = IMPLEMENTATION; + snapshot.owner = OWNER; + snapshot.asset = TOKEN; + snapshot.paused = false; + snapshot.balances = new uint256[](3); + snapshot.balances[0] = 10; + snapshot.balances[1] = 20; + snapshot.balances[2] = 30; + snapshot.liabilities = 60; + snapshot.reserves = 70; + snapshot.surplus = 10; + snapshot.deploymentBlock = 1; + snapshot.version = 1; } function _writeManifest( @@ -365,7 +554,14 @@ contract ScriptPreflightTest is Test { } function _deployFixture() internal returns (string memory path, DemoScript.Manifest memory manifest) { - path = string.concat(fixtureDir, "/deployed.json"); + return _deployFixtureNamed("deployed"); + } + + function _deployFixtureNamed(string memory name) + internal + returns (string memory path, DemoScript.Manifest memory manifest) + { + path = string.concat(fixtureDir, "/", name, ".json"); vm.chainId(31337); vm.setEnv("SCRIPT_SENDER", vm.toString(OWNER)); vm.setEnv("DEPLOYMENT_MANIFEST_PATH", path); @@ -374,6 +570,47 @@ contract ScriptPreflightTest is Test { manifest = harness.readManifest(path, true); } + function _deployUpgradeFixtureNamed(string memory name) + internal + returns (string memory path, DemoScript.Manifest memory manifest) + { + vm.chainId(31337); + MockUSDC deployedToken = new MockUSDC(OWNER); + address deployedProxy = Upgrades.deployUUPSProxy( + "BankV1.sol:BankV1", abi.encodeCall(BankV1.initialize, (address(deployedToken), OWNER)) + ); + manifest.schemaVersion = 1; + manifest.network = "anvil"; + manifest.chainId = 31337; + manifest.deploymentBlock = 1; + manifest.rpcUrl = "http://127.0.0.1:8545"; + manifest.token = address(deployedToken); + manifest.proxy = deployedProxy; + manifest.implementation = Upgrades.getImplementationAddress(deployedProxy); + manifest.owner = OWNER; + manifest.actors = _actors(); + path = string.concat(fixtureDir, "/", name, ".json"); + vm.writeFile(path, harness.serializeManifest(manifest)); + } + + function _seedState(DemoScript.Manifest memory manifest) internal { + MockUSDC deployedToken = MockUSDC(manifest.token); + BankV1 deployedBank = BankV1(manifest.proxy); + vm.startPrank(OWNER); + deployedToken.mint(ALICE, 2_000e6); + deployedToken.mint(BOB, 1_000e6); + vm.stopPrank(); + vm.startPrank(ALICE); + deployedToken.approve(manifest.proxy, 1_000e6); + deployedBank.deposit(1_000e6); + deployedBank.withdraw(100e6); + vm.stopPrank(); + vm.startPrank(BOB); + deployedToken.approve(manifest.proxy, 500e6); + deployedBank.deposit(500e6); + vm.stopPrank(); + } + function _writePublicAnvilManifest() internal returns (string memory path) { path = string.concat(fixtureDir, "/public-anvil-manifest.json"); vm.writeFile( diff --git a/test/helpers/BankV2Handler.sol b/test/helpers/BankV2Handler.sol new file mode 100644 index 0000000..6aa863d --- /dev/null +++ b/test/helpers/BankV2Handler.sol @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.35; + +import {BankV2} from "../../src/BankV2.sol"; +import {MockUSDC} from "../../src/MockUSDC.sol"; +import {BankHandler} from "./BankHandler.sol"; + +contract BankV2Handler is BankHandler { + BankV2 internal immutable bankV2; + + uint256 public ghostTransferred; + + constructor(MockUSDC token_, BankV2 bank_) BankHandler(token_, bank_) { + bankV2 = bank_; + } + + function transfer(uint256 fromSeed, uint256 toSeed, uint256 amount) external { + uint256 count = actorCount(); + uint256 fromIndex = fromSeed % count; + address from = actorAt(fromIndex); + uint256 balance = bankV2.balanceOf(from); + if (balance == 0) return; + + uint256 toIndex = (fromIndex + 1 + (toSeed % (count - 1))) % count; + address to = actorAt(toIndex); + amount = bound(amount, 1, balance); + + vm.prank(from); + bankV2.transferBalance(to, amount); + ghostTransferred += amount; + } +} diff --git a/tools/finalize-manifest.mjs b/tools/finalize-manifest.mjs index 0ac0bb1..3a0109b 100644 --- a/tools/finalize-manifest.mjs +++ b/tools/finalize-manifest.mjs @@ -4,6 +4,7 @@ import { dirname, join, resolve } from "node:path"; import { pathToFileURL } from "node:url"; export const IMPLEMENTATION_SLOT = "0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc"; +export const UPGRADED_TOPIC = "0xbc7cd75a20ee27fd9adebab32041f755214dbc6bffa90cc0225b39da2e5c2d3b"; export const EDUCATIONAL_WARNING = "Educational demo — mock token — never use real funds."; const NETWORKS = { @@ -88,6 +89,201 @@ export async function finalizeDeployment({ root = process.cwd(), rpc }) { return { path, manifest: confirmed }; } +export async function finalizeUpgrade({ root = process.cwd(), rpc }) { + if (typeof rpc !== "function") throw new Error("finalizer requires an RPC function"); + const pendingPath = join(root, "deployments", "upgrade-pending.json"); + const pending = await readJson(pendingPath); + if (pending?.mode !== "upgrade" && pending?.mode !== "noop") throw new Error("unknown upgrade staging mode"); + + const activePath = join(root, "deployments", "active.json"); + const active = await readManifest(activePath); + const spec = networkSpec(active.network); + const canonicalPath = join(root, "deployments", spec.canonical); + const canonical = await readManifest(canonicalPath); + const [activeBytes, canonicalBytes] = await Promise.all([readFile(activePath), readFile(canonicalPath)]); + if (!activeBytes.equals(canonicalBytes) || JSON.stringify(active) !== JSON.stringify(canonical)) { + throw new Error("active and canonical manifest identity must match before upgrade finalization"); + } + + const chainId = parseRpcQuantity(await rpc("eth_chainId", []), "RPC chain ID"); + if (chainId !== active.chainId) throw new Error("RPC chain ID does not match active manifest"); + const methods = await readUpgradeMethods(root); + + if (pending.mode === "noop") { + validateNoopMarker(pending, active); + const latestBlock = parseRpcQuantity(await rpc("eth_blockNumber", []), "latest block number"); + if (latestBlock < pending.observedBlock) throw new Error("live block precedes no-op observation block"); + const nonce = parseRpcQuantity( + await rpc("eth_getTransactionCount", [active.owner, "latest"]), "owner nonce" + ); + if (nonce !== pending.ownerNonce) throw new Error("owner nonce changed after no-op observation"); + await assertLiveVersionAndImplementation({ rpc, active, implementation: pending.implementation, methods }); + await rm(pendingPath); + return { mode: "noop", path: canonicalPath, manifest: active }; + } + + validateUpgradeMarker(pending, active); + const broadcastPath = join(root, "broadcast", "UpgradeV2.s.sol", String(active.chainId), "run-latest.json"); + const broadcast = await readJson(broadcastPath); + if (!Array.isArray(broadcast.transactions)) throw new Error("upgrade broadcast is partial: transactions are missing"); + const hashes = [...new Set(broadcast.transactions.map((transaction) => transaction?.hash) + .filter((hash) => typeof hash === "string"))]; + const resolvedTransactions = await Promise.all(hashes.map(async (hash) => ({ + hash, + transaction: await rpc("eth_getTransactionByHash", [hash]), + }))); + const matching = resolvedTransactions.filter(({ transaction }) => + typeof transaction?.to === "string" && sameAddress(transaction.to, active.proxy)); + if (matching.length !== 1) throw new Error(`expected exactly one live upgrade transaction to proxy, found ${matching.length}`); + const receipt = await rpc("eth_getTransactionReceipt", [matching[0].hash]); + if (!receipt || !isSuccessfulReceipt(receipt.status)) throw new Error("upgrade receipt was not successful"); + if (!Array.isArray(receipt.logs) || !receipt.logs.some((log) => isUpgradeLog(log, active.proxy, pending.implementation))) { + throw new Error("successful upgrade receipt is missing the expected Upgraded event"); + } + + await assertLiveVersionAndImplementation({ rpc, active, implementation: pending.implementation, methods }); + await assertUpgradeSnapshot({ rpc, active, pending, methods }); + const updated = { ...active, implementation: pending.implementation }; + validateManifest(updated); + await atomicWriteJson(canonicalPath, updated); + await atomicWriteJson(activePath, updated); + await rm(pendingPath); + return { mode: "upgrade", path: canonicalPath, manifest: updated }; +} + +function validateNoopMarker(marker, active) { + assertExactKeys(marker, ["mode", "chainId", "observedBlock", "ownerNonce", "proxy", "implementation"], "no-op marker"); + for (const field of ["chainId", "observedBlock", "ownerNonce"]) { + if (!Number.isSafeInteger(marker[field]) || marker[field] < 0) throw new Error(`no-op marker ${field} is invalid`); + } + if (marker.chainId !== active.chainId || !sameAddress(marker.proxy, active.proxy) + || !sameAddress(marker.implementation, active.implementation)) { + throw new Error("no-op marker chain/proxy/implementation identity does not match active manifest"); + } +} + +function validateUpgradeMarker(marker, active) { + assertExactKeys(marker, ["mode", "network", "chainId", "token", "proxy", "previousImplementation", "implementation", "owner", "deploymentBlock", "snapshot"], "upgrade marker"); + for (const field of ["network", "chainId", "deploymentBlock"]) { + if (marker[field] !== active[field]) throw new Error(`upgrade marker ${field} identity does not match active manifest`); + } + for (const field of ["token", "proxy", "previousImplementation", "owner"]) { + const activeField = field === "previousImplementation" ? "implementation" : field; + if (!sameAddress(marker[field], active[activeField])) throw new Error(`upgrade marker ${field} identity does not match active manifest`); + } + assertAddress(marker.implementation, "upgrade implementation"); + if (sameAddress(marker.implementation, active.implementation)) throw new Error("upgrade implementation did not change"); + const snapshot = marker.snapshot; + assertExactKeys(snapshot, ["proxy", "implementation", "owner", "asset", "paused", "balances", "liabilities", "reserves", "surplus", "deploymentBlock", "version"], "upgrade snapshot"); + if (!sameAddress(snapshot.proxy, active.proxy) || snapshot.deploymentBlock !== active.deploymentBlock) { + throw new Error("upgrade snapshot proxy or deployment identity changed"); + } + if (!sameAddress(snapshot.implementation, active.implementation) || !sameAddress(snapshot.owner, active.owner) + || !sameAddress(snapshot.asset, active.token) || snapshot.version !== 1 || typeof snapshot.paused !== "boolean") { + throw new Error("upgrade snapshot identity does not match active manifest"); + } + if (!Array.isArray(snapshot.balances) || snapshot.balances.length !== active.actors.length) { + throw new Error("upgrade snapshot actor count does not match active manifest"); + } + snapshot.balances.forEach((record, index) => { + assertExactKeys(record, ["address", "balance"], `upgrade snapshot actor ${index}`); + if (!sameAddress(record.address, active.actors[index].address)) throw new Error("upgrade snapshot actor identity changed"); + assertNonnegativeInteger(record.balance, "upgrade snapshot actor balance"); + }); + for (const field of ["liabilities", "reserves", "surplus"]) assertNonnegativeInteger(snapshot[field], `upgrade snapshot ${field}`); + if (snapshot.reserves < snapshot.liabilities || snapshot.reserves - snapshot.liabilities !== snapshot.surplus) { + throw new Error("upgrade snapshot accounting is inconsistent"); + } +} + +async function readUpgradeMethods(root) { + const bank = await readJson(join(root, "out", "BankV2.sol", "BankV2.json")); + const token = await readJson(join(root, "out", "MockUSDC.sol", "MockUSDC.json")); + const requiredBank = ["owner()", "asset()", "paused()", "balanceOf(address)", "totalLiabilities()", "contractVersion()"]; + const result = { bank: {}, token: {} }; + for (const signature of requiredBank) result.bank[signature] = methodSelector(bank, signature, "BankV2"); + result.token["balanceOf(address)"] = methodSelector(token, "balanceOf(address)", "MockUSDC"); + return result; +} + +function methodSelector(artifact, signature, contractName) { + const selector = artifact?.methodIdentifiers?.[signature]; + if (typeof selector !== "string" || !/^[0-9a-fA-F]{8}$/.test(selector)) { + throw new Error(`${contractName} artifact is missing method identifier ${signature}`); + } + return `0x${selector.toLowerCase()}`; +} + +async function assertLiveVersionAndImplementation({ rpc, active, implementation, methods }) { + const code = await rpc("eth_getCode", [implementation, "latest"]); + if (typeof code !== "string" || code.length <= 2) throw new Error("upgrade implementation has no live code"); + const version = decodeUint(await rpcCall(rpc, active.proxy, methods.bank["contractVersion()"]), "contract version"); + if (version !== 2) throw new Error("live contract version is not 2"); + const slot = await rpc("eth_getStorageAt", [active.proxy, IMPLEMENTATION_SLOT, "latest"]); + if (slotAddress(slot) !== implementation.toLowerCase()) throw new Error("live proxy implementation slot does not match upgrade marker"); +} + +async function assertUpgradeSnapshot({ rpc, active, pending, methods }) { + const snapshot = pending.snapshot; + const owner = decodeAddress(await rpcCall(rpc, active.proxy, methods.bank["owner()"]), "owner"); + const asset = decodeAddress(await rpcCall(rpc, active.proxy, methods.bank["asset()"]), "asset"); + const paused = decodeBool(await rpcCall(rpc, active.proxy, methods.bank["paused()"]), "paused"); + if (!sameAddress(owner, snapshot.owner)) throw new Error("live owner changed during upgrade"); + if (!sameAddress(asset, snapshot.asset)) throw new Error("live asset changed during upgrade"); + if (paused !== snapshot.paused) throw new Error("live pause state changed during upgrade"); + for (const [index, actor] of snapshot.balances.entries()) { + const balance = decodeUint( + await rpcCall(rpc, active.proxy, `${methods.bank["balanceOf(address)"]}${encodeAddressWord(actor.address)}`), + `actor ${index} balance` + ); + if (balance !== actor.balance) throw new Error(`live actor ${index} balance changed during upgrade`); + } + const liabilities = decodeUint(await rpcCall(rpc, active.proxy, methods.bank["totalLiabilities()"]), "liabilities"); + const reserves = decodeUint( + await rpcCall(rpc, active.token, `${methods.token["balanceOf(address)"]}${encodeAddressWord(active.proxy)}`), + "reserves" + ); + if (liabilities !== snapshot.liabilities) throw new Error("live liabilities changed during upgrade"); + if (reserves !== snapshot.reserves) throw new Error("live reserves changed during upgrade"); + if (reserves - liabilities !== snapshot.surplus) throw new Error("live surplus changed during upgrade"); +} + +function rpcCall(rpc, to, data) { return rpc("eth_call", [{ to, data }, "latest"]); } +function encodeAddressWord(address) { assertAddress(address, "call address"); return address.slice(2).toLowerCase().padStart(64, "0"); } +function decodeUint(value, label) { + if (typeof value !== "string" || !/^0x[0-9a-fA-F]{64}$/.test(value)) throw new Error(`${label} RPC result is invalid`); + const parsed = BigInt(value); + if (parsed > BigInt(Number.MAX_SAFE_INTEGER)) throw new Error(`${label} exceeds JavaScript safe integer range`); + return Number(parsed); +} +function decodeAddress(value, label) { + if (typeof value !== "string" || !/^0x0{24}[0-9a-fA-F]{40}$/.test(value)) throw new Error(`${label} RPC result is invalid`); + return `0x${value.slice(-40)}`; +} +function decodeBool(value, label) { + const decoded = decodeUint(value, label); + if (decoded !== 0 && decoded !== 1) throw new Error(`${label} RPC result is not boolean`); + return decoded === 1; +} +function isUpgradeLog(log, proxy, implementation) { + return typeof log?.address === "string" && sameAddress(log.address, proxy) && Array.isArray(log.topics) + && log.topics.length >= 2 && log.topics[0]?.toLowerCase() === UPGRADED_TOPIC + && slotAddress(log.topics[1]) === implementation.toLowerCase(); +} +function sameAddress(first, second) { + return typeof first === "string" && typeof second === "string" && /^0x[0-9a-fA-F]{40}$/.test(first) + && /^0x[0-9a-fA-F]{40}$/.test(second) && first.toLowerCase() === second.toLowerCase(); +} +function assertExactKeys(value, expected, label) { + if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${label} must be an object`); + const actual = Object.keys(value).sort(); + const wanted = [...expected].sort(); + if (actual.length !== wanted.length || actual.some((key, index) => key !== wanted[index])) throw new Error(`${label} schema is invalid`); +} +function assertNonnegativeInteger(value, label) { + if (!Number.isSafeInteger(value) || value < 0) throw new Error(`${label} must be a nonnegative integer`); +} + export async function readManifest(path, { pending = false } = {}) { const manifest = await readJson(path); validateManifest(manifest, { pending }); @@ -283,7 +479,11 @@ export async function runFinalizeCli(argv, { root = process.cwd(), log = console if (network !== "--rpc-url" || typeof rest[0] !== "string" || rest.length !== 1) throw new Error("usage: finalize-manifest.mjs deploy --rpc-url "); return finalizeDeployment({ root, rpc: fetchRpc(rest[0]) }); } - throw new Error("usage: finalize-manifest.mjs preflight-deploy | deploy --rpc-url "); + if (command === "upgrade") { + if (network !== "--rpc-url" || typeof rest[0] !== "string" || rest.length !== 1) throw new Error("usage: finalize-manifest.mjs upgrade --rpc-url "); + return finalizeUpgrade({ root, rpc: fetchRpc(rest[0]) }); + } + throw new Error("usage: finalize-manifest.mjs preflight-deploy | deploy --rpc-url | upgrade --rpc-url "); } if (process.argv[1] && pathToFileURL(resolve(process.argv[1])).href === import.meta.url) { diff --git a/tools/sync-web-artifacts.mjs b/tools/sync-web-artifacts.mjs index 912e686..889f712 100644 --- a/tools/sync-web-artifacts.mjs +++ b/tools/sync-web-artifacts.mjs @@ -18,12 +18,19 @@ const bankRequirements = [ eventSignature("Upgraded", [parameter("implementation", "address", true)]), ]; const tokenRequirements = [functionSignature("balanceOf", [parameter("account", "address")], ["uint256"], "view")]; +const bankV2Requirements = [ + ...bankRequirements, + functionSignature("transferBalance", [parameter("recipient", "address"), parameter("amount", "uint256")], [], "nonpayable"), + eventSignature("BalanceTransferred", [parameter("from", "address", true), parameter("to", "address", true), parameter("amount", "uint256", false)]), +]; export function extractAbi(artifact, contractName) { if (!artifact || typeof artifact !== "object" || !Array.isArray(artifact.abi)) { throw new Error(`${contractName} artifact must contain an ABI`); } - const requirements = contractName === "BankV1" ? bankRequirements : contractName === "MockUSDC" ? tokenRequirements : null; + const requirements = contractName === "BankV1" ? bankRequirements + : contractName === "BankV2" ? bankV2Requirements + : contractName === "MockUSDC" ? tokenRequirements : null; if (!requirements) throw new Error(`unsupported contract artifact ${contractName}`); for (const requirement of requirements) { const entries = artifact.abi.filter((entry) => entry && entry.type === requirement.type && entry.name === requirement.name); @@ -53,21 +60,27 @@ function matchesParameters(actual, expected) { }); } -export function renderContractsModule(bankV1Abi, mockUsdcAbi) { - return `/* This file is generated by tools/sync-web-artifacts.mjs. Do not edit. */\n\nexport const bankV1Abi = ${JSON.stringify(bankV1Abi, null, 2)} as const;\n\nexport const mockUsdcAbi = ${JSON.stringify(mockUsdcAbi, null, 2)} as const;\n`; +export function renderContractsModule(bankV1Abi, bankV2Abi, mockUsdcAbi) { + return `/* This file is generated by tools/sync-web-artifacts.mjs. Do not edit. */\n\nexport const bankV1Abi = ${JSON.stringify(bankV1Abi, null, 2)} as const;\n\nexport const bankV2Abi = ${JSON.stringify(bankV2Abi, null, 2)} as const;\n\nexport const mockUsdcAbi = ${JSON.stringify(mockUsdcAbi, null, 2)} as const;\n`; } export async function syncArtifacts({ bankArtifactPath = resolve(repositoryRoot, "out/BankV1.sol/BankV1.json"), + bankV2ArtifactPath = resolve(repositoryRoot, "out/BankV2.sol/BankV2.json"), tokenArtifactPath = resolve(repositoryRoot, "out/MockUSDC.sol/MockUSDC.json"), outputPath = resolve(repositoryRoot, "web/src/generated/contracts.ts"), check = false, } = {}) { - const [bankArtifact, tokenArtifact] = await Promise.all([ + const [bankArtifact, bankV2Artifact, tokenArtifact] = await Promise.all([ readArtifact(bankArtifactPath, "BankV1"), + readArtifact(bankV2ArtifactPath, "BankV2"), readArtifact(tokenArtifactPath, "MockUSDC"), ]); - const contents = renderContractsModule(extractAbi(bankArtifact, "BankV1"), extractAbi(tokenArtifact, "MockUSDC")); + const contents = renderContractsModule( + extractAbi(bankArtifact, "BankV1"), + extractAbi(bankV2Artifact, "BankV2"), + extractAbi(tokenArtifact, "MockUSDC") + ); if (check) { let existing; try { existing = await readFile(outputPath, "utf8"); } catch { throw new Error("generated contracts module is stale or missing"); } diff --git a/tools/test-finalize-manifest.mjs b/tools/test-finalize-manifest.mjs index 7b4e1eb..762a028 100644 --- a/tools/test-finalize-manifest.mjs +++ b/tools/test-finalize-manifest.mjs @@ -4,15 +4,18 @@ import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import test from "node:test"; -import { atomicWrite, finalizeDeployment, preflightDeploy, runFinalizeCli, validateManifest } from "./finalize-manifest.mjs"; +import { UPGRADED_TOPIC, atomicWrite, finalizeDeployment, finalizeUpgrade, preflightDeploy, runFinalizeCli, validateManifest } from "./finalize-manifest.mjs"; import { runSelectCli, selectManifest } from "./select-manifest.mjs"; const TOKEN = "0x1000000000000000000000000000000000000001"; const PROXY = "0x2000000000000000000000000000000000000002"; const IMPLEMENTATION = "0x3000000000000000000000000000000000000003"; +const V2_IMPLEMENTATION = "0x4000000000000000000000000000000000000004"; const OWNER = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"; const IMPLEMENTATION_SLOT = "0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc"; const EDUCATIONAL_WARNING = "Educational demo — mock token — never use real funds."; +const DECLARED_CREATE_HASH = "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const DECLARED_CALL_HASH = "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; test("preflight rejects an existing target canonical manifest with the safe recovery command", async () => { await withFixture(async (root) => { @@ -136,6 +139,106 @@ test("finalizer failure leaves an initially absent canonical manifest absent", a }); }); +test("upgrade finalizer verifies receipt, event, slot, artifact-driven state and changes only implementation", async () => { + await withUpgradeFixture(async (root, active) => { + const before = structuredClone(active); + const output = await finalizeUpgrade({ root, rpc: fakeUpgradeRpc() }); + + assert.equal(output.mode, "upgrade"); + assert.deepEqual(output.manifest, { ...before, implementation: V2_IMPLEMENTATION }); + for (const name of ["anvil.json", "active.json"]) { + const confirmed = await readJson(join(root, "deployments", name)); + assert.deepEqual(confirmed, { ...before, implementation: V2_IMPLEMENTATION }); + assert.deepEqual({ ...confirmed, implementation: before.implementation }, before); + } + await assert.rejects(() => access(join(root, "deployments", "upgrade-pending.json"))); + }); +}); + +test("upgrade finalizer rejects proxy, deployment-block, and actor identity mutation without changing confirmed files", async () => { + for (const [name, mutate] of [ + ["proxy", (pending) => { pending.proxy = TOKEN; }], + ["deployment block", (pending) => { pending.deploymentBlock += 1; }], + ["actor", (pending) => { pending.snapshot.balances[1].address = OWNER; }], + ]) { + await withUpgradeFixture(async (root) => { + const pendingPath = join(root, "deployments", "upgrade-pending.json"); + const pending = await readJson(pendingPath); mutate(pending); await writeJson(pendingPath, pending); + const beforeCanonical = await readFile(join(root, "deployments", "anvil.json")); + const beforeActive = await readFile(join(root, "deployments", "active.json")); + await assert.rejects(() => finalizeUpgrade({ root, rpc: fakeUpgradeRpc() }), /proxy|deployment|actor|identity/i, name); + assert.deepEqual(await readFile(join(root, "deployments", "anvil.json")), beforeCanonical); + assert.deepEqual(await readFile(join(root, "deployments", "active.json")), beforeActive); + await access(pendingPath); + }); + } +}); + +test("upgrade finalizer leaves confirmed files untouched for failed receipt, missing event, live mismatch, and unknown mode", async () => { + const cases = [ + ["failed receipt", fakeUpgradeRpc({ receiptStatus: "0x0" }), null, /successful/], + ["missing Upgraded event", fakeUpgradeRpc({ omitUpgradeLog: true }), null, /Upgraded/], + ["wrong live version", fakeUpgradeRpc({ version: 1n }), null, /version/], + ["slot mismatch", fakeUpgradeRpc({ slot: IMPLEMENTATION }), null, /slot/], + ["owner changed", fakeUpgradeRpc({ owner: TOKEN }), null, /owner/], + ["unknown mode", fakeUpgradeRpc(), (pending) => { pending.mode = "mystery"; }, /mode/], + ]; + for (const [name, rpc, mutate, expected] of cases) { + await withUpgradeFixture(async (root) => { + const pendingPath = join(root, "deployments", "upgrade-pending.json"); + if (mutate) { const pending = await readJson(pendingPath); mutate(pending); await writeJson(pendingPath, pending); } + const beforeCanonical = await readFile(join(root, "deployments", "anvil.json")); + const beforeActive = await readFile(join(root, "deployments", "active.json")); + await assert.rejects(() => finalizeUpgrade({ root, rpc }), expected, name); + assert.deepEqual(await readFile(join(root, "deployments", "anvil.json")), beforeCanonical); + assert.deepEqual(await readFile(join(root, "deployments", "active.json")), beforeActive); + await access(pendingPath); + }); + } +}); + +test("noop finalizer ignores stale broadcast history and leaves confirmed manifests byte-for-byte unchanged", async () => { + await withUpgradeFixture(async (root, active) => { + active.implementation = V2_IMPLEMENTATION; + await writeJson(join(root, "deployments", "anvil.json"), active); + await writeJson(join(root, "deployments", "active.json"), active); + await writeJson(join(root, "deployments", "upgrade-pending.json"), { + mode: "noop", chainId: 31337, observedBlock: 80, ownerNonce: 5, + proxy: PROXY, implementation: V2_IMPLEMENTATION, + }); + await writeJson(join(root, "broadcast", "UpgradeV2.s.sol", "31337", "run-latest.json"), { + transactions: [{ hash: "0xstale", transaction: { to: TOKEN } }], receipts: [{ status: "0x0" }], + }); + const beforeCanonical = await readFile(join(root, "deployments", "anvil.json")); + const beforeActive = await readFile(join(root, "deployments", "active.json")); + + const output = await finalizeUpgrade({ root, rpc: fakeUpgradeRpc() }); + + assert.equal(output.mode, "noop"); + assert.deepEqual(await readFile(join(root, "deployments", "anvil.json")), beforeCanonical); + assert.deepEqual(await readFile(join(root, "deployments", "active.json")), beforeActive); + await assert.rejects(() => access(join(root, "deployments", "upgrade-pending.json"))); + }); +}); + +test("noop finalizer rejects a regressed block or changed owner nonce without touching confirmed state", async () => { + for (const [name, rpc] of [["block", fakeUpgradeRpc({ blockNumber: 79 })], ["nonce", fakeUpgradeRpc({ nonce: 6 })]]) { + await withUpgradeFixture(async (root, active) => { + active.implementation = V2_IMPLEMENTATION; + await writeJson(join(root, "deployments", "anvil.json"), active); + await writeJson(join(root, "deployments", "active.json"), active); + await writeJson(join(root, "deployments", "upgrade-pending.json"), { + mode: "noop", chainId: 31337, observedBlock: 80, ownerNonce: 5, + proxy: PROXY, implementation: V2_IMPLEMENTATION, + }); + const before = await readFile(join(root, "deployments", "active.json")); + await assert.rejects(() => finalizeUpgrade({ root, rpc }), /block|nonce/i, name); + assert.deepEqual(await readFile(join(root, "deployments", "active.json")), before); + await access(join(root, "deployments", "upgrade-pending.json")); + }); + } +}); + test("selection atomically replaces active with only a valid named canonical manifest", async () => { await withFixture(async (root) => { const anvil = manifest({ deploymentBlock: 31 }); @@ -266,6 +369,118 @@ function fakeRpc(overrides = {}) { }; } +const selectors = { + "owner()": "11111111", + "asset()": "22222222", + "paused()": "33333333", + "balanceOf(address)": "44444444", + "totalLiabilities()": "55555555", + "contractVersion()": "66666666", +}; + +async function withUpgradeFixture(fn) { + await withFixture(async (root) => { + const active = manifest(); + await writeJson(join(root, "deployments", "anvil.json"), active); + await writeJson(join(root, "deployments", "active.json"), active); + await writeJson(join(root, "deployments", "upgrade-pending.json"), upgradePending(active)); + await writeUpgradeArtifacts(root); + await writeUpgradeBroadcast(root); + await fn(root, active); + }); +} + +function upgradePending(active) { + return { + mode: "upgrade", + network: active.network, + chainId: active.chainId, + token: active.token, + proxy: active.proxy, + previousImplementation: active.implementation, + implementation: V2_IMPLEMENTATION, + owner: active.owner, + deploymentBlock: active.deploymentBlock, + snapshot: { + proxy: active.proxy, + implementation: active.implementation, + owner: active.owner, + asset: active.token, + paused: false, + balances: active.actors.map((actor, index) => ({ address: actor.address, balance: index === 1 ? 900_000_000 : index === 2 ? 500_000_000 : 0 })), + liabilities: 1_400_000_000, + reserves: 1_400_000_000, + surplus: 0, + deploymentBlock: active.deploymentBlock, + version: 1, + }, + }; +} + +async function writeUpgradeArtifacts(root) { + const bankDirectory = join(root, "out", "BankV2.sol"); + const tokenDirectory = join(root, "out", "MockUSDC.sol"); + const { mkdir } = await import("node:fs/promises"); + await mkdir(bankDirectory, { recursive: true }); + await mkdir(tokenDirectory, { recursive: true }); + await writeJson(join(bankDirectory, "BankV2.json"), { methodIdentifiers: selectors }); + await writeJson(join(tokenDirectory, "MockUSDC.json"), { methodIdentifiers: { "balanceOf(address)": selectors["balanceOf(address)"] } }); +} + +async function writeUpgradeBroadcast(root) { + const directory = join(root, "broadcast", "UpgradeV2.s.sol", "31337"); + const { mkdir } = await import("node:fs/promises"); + await mkdir(directory, { recursive: true }); + await writeJson(join(directory, "run-latest.json"), { + transactions: [ + { hash: DECLARED_CREATE_HASH, transactionType: "CREATE", transaction: { to: null } }, + { hash: DECLARED_CALL_HASH, transactionType: "CALL", transaction: { to: PROXY } }, + ], + }); +} + +function fakeUpgradeRpc(overrides = {}) { + const balanceByAddress = new Map([ + [OWNER.toLowerCase(), 0n], + ["0x70997970c51812dc3a010c7d01b50e0d17dc79c8", 900_000_000n], + ["0x3c44cdddb6a900fa2b585dd299e03d12fa4293bc", 500_000_000n], + ]); + return async (method, params) => { + if (method === "eth_chainId") return "0x7a69"; + if (method === "eth_blockNumber") return hexQuantity(overrides.blockNumber ?? 100); + if (method === "eth_getTransactionCount") return hexQuantity(overrides.nonce ?? 5); + if (method === "eth_getCode") return "0x6000"; + if (method === "eth_getStorageAt") return wordForRpc(overrides.slot ?? V2_IMPLEMENTATION); + if (method === "eth_getTransactionByHash") { + return { hash: params[0], to: params[0] === DECLARED_CREATE_HASH ? PROXY : null }; + } + if (method === "eth_getTransactionReceipt") return { + status: overrides.receiptStatus ?? "0x1", + blockNumber: "0x5a", + logs: overrides.omitUpgradeLog || params[0] === DECLARED_CALL_HASH + ? [] : [{ address: PROXY, topics: [UPGRADED_TOPIC, wordForRpc(V2_IMPLEMENTATION)], data: "0x" }], + }; + if (method === "eth_call") { + const call = params[0]; + const selector = call.data.slice(2, 10); + if (selector === selectors["owner()"]) return wordForRpc(overrides.owner ?? OWNER); + if (selector === selectors["asset()"]) return wordForRpc(TOKEN); + if (selector === selectors["paused()"]) return uintWord(0n); + if (selector === selectors["totalLiabilities()"]) return uintWord(1_400_000_000n); + if (selector === selectors["contractVersion()"]) return uintWord(overrides.version ?? 2n); + if (selector === selectors["balanceOf(address)"]) { + if (call.to.toLowerCase() === TOKEN.toLowerCase()) return uintWord(1_400_000_000n); + return uintWord(balanceByAddress.get(`0x${call.data.slice(-40)}`.toLowerCase()) ?? 0n); + } + } + throw new Error(`unexpected upgrade RPC method: ${method}`); + }; +} + +function wordForRpc(address) { return `0x${"0".repeat(24)}${address.slice(2).toLowerCase()}`; } +function uintWord(value) { return `0x${BigInt(value).toString(16).padStart(64, "0")}`; } +function hexQuantity(value) { return `0x${Number(value).toString(16)}`; } + async function writeJson(path, value) { await writeFile(path, `${JSON.stringify(value, null, 2)}\n`); } diff --git a/tools/test-sync-web-artifacts.mjs b/tools/test-sync-web-artifacts.mjs index a5fdc4a..d40471d 100644 --- a/tools/test-sync-web-artifacts.mjs +++ b/tools/test-sync-web-artifacts.mjs @@ -22,6 +22,11 @@ const bankAbi = [ { type: "event", name: "Upgraded", inputs: [{ name: "implementation", type: "address", indexed: true }], anonymous: false }, ]; const tokenAbi = [{ type: "function", name: "balanceOf", inputs: [{ name: "account", type: "address" }], outputs: [{ name: "", type: "uint256" }], stateMutability: "view" }]; +const bankV2Abi = [ + ...bankAbi, + { type: "function", name: "transferBalance", inputs: [{ name: "recipient", type: "address" }, { name: "amount", type: "uint256" }], outputs: [], stateMutability: "nonpayable" }, + { type: "event", name: "BalanceTransferred", inputs: [{ name: "from", type: "address", indexed: true }, { name: "to", type: "address", indexed: true }, { name: "amount", type: "uint256", indexed: false }], anonymous: false }, +]; const manifest = { schemaVersion: 1, network: "anvil", chainId: 31337, deploymentBlock: 3, rpcUrl: "http://127.0.0.1:8545", token: address, proxy: address, @@ -40,13 +45,18 @@ async function expectRejects(action, pattern) { await withFixture(async (root) => { const bank = join(root, "BankV1.json"); + const bankV2 = join(root, "BankV2.json"); const token = join(root, "MockUSDC.json"); const output = join(root, "contracts.ts"); // Catches a production bridge that silently produces an ABI module from incomplete artifacts. - await expectRejects(() => syncArtifacts({ bankArtifactPath: bank, tokenArtifactPath: token, outputPath: output }), /BankV1 artifact/i); + await expectRejects(() => syncArtifacts({ bankArtifactPath: bank, bankV2ArtifactPath: bankV2, tokenArtifactPath: token, outputPath: output }), /BankV1 artifact/i); await writeFile(bank, JSON.stringify({ abi: bankAbi })); - await expectRejects(() => syncArtifacts({ bankArtifactPath: bank, tokenArtifactPath: token, outputPath: output }), /MockUSDC artifact/i); + await writeFile(token, JSON.stringify({ abi: tokenAbi })); + await expectRejects(() => syncArtifacts({ bankArtifactPath: bank, bankV2ArtifactPath: bankV2, tokenArtifactPath: token, outputPath: output }), /BankV2 artifact/i); + await writeFile(bankV2, JSON.stringify({ abi: bankV2Abi })); + await rm(token); + await expectRejects(() => syncArtifacts({ bankArtifactPath: bank, bankV2ArtifactPath: bankV2, tokenArtifactPath: token, outputPath: output }), /MockUSDC artifact/i); await writeFile(token, JSON.stringify({ abi: tokenAbi })); // Catches a bridge that exports an ABI missing a V1 contract function or event. @@ -58,16 +68,20 @@ await withFixture(async (root) => { const wrongEvent = structuredClone(bankAbi); wrongEvent.find((entry) => entry.name === "Deposited").inputs[0].indexed = false; assert.throws(() => extractAbi({ abi: wrongEvent }, "BankV1"), /signature.*Deposited/i); - const rendered = renderContractsModule(extractAbi({ abi: bankAbi }, "BankV1"), extractAbi({ abi: tokenAbi }, "MockUSDC")); + assert.throws(() => extractAbi({ abi: bankV2Abi.filter((entry) => entry.name !== "transferBalance") }, "BankV2"), /transferBalance/); + assert.throws(() => extractAbi({ abi: bankV2Abi.filter((entry) => entry.name !== "BalanceTransferred") }, "BankV2"), /BalanceTransferred/); + const rendered = renderContractsModule(extractAbi({ abi: bankAbi }, "BankV1"), extractAbi({ abi: bankV2Abi }, "BankV2"), extractAbi({ abi: tokenAbi }, "MockUSDC")); assert.match(rendered, /export const bankV1Abi = .* as const;/s); + assert.match(rendered, /export const bankV2Abi = .* as const;/s); assert.match(rendered, /export const mockUsdcAbi = .* as const;/s); - assert.doesNotMatch(rendered, /bankV2Abi|BankV2/); + assert.match(rendered, /transferBalance/); + assert.match(rendered, /BalanceTransferred/); // Catches a bridge that requires an active manifest or reads V2 as part of ABI generation. - await syncArtifacts({ bankArtifactPath: bank, tokenArtifactPath: token, outputPath: output }); + await syncArtifacts({ bankArtifactPath: bank, bankV2ArtifactPath: bankV2, tokenArtifactPath: token, outputPath: output }); assert.equal(await readFile(output, "utf8"), rendered); await writeFile(output, "stale\n"); - await expectRejects(() => syncArtifacts({ bankArtifactPath: bank, tokenArtifactPath: token, outputPath: output, check: true }), /stale/i); + await expectRejects(() => syncArtifacts({ bankArtifactPath: bank, bankV2ArtifactPath: bankV2, tokenArtifactPath: token, outputPath: output, check: true }), /stale/i); }); await withFixture(async (root) => { diff --git a/web/src/App.test.tsx b/web/src/App.test.tsx index db10867..95f3c3d 100644 --- a/web/src/App.test.tsx +++ b/web/src/App.test.tsx @@ -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(); + + 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, diff --git a/web/src/components/ActivityTimeline.tsx b/web/src/components/ActivityTimeline.tsx index 1e05f21..338aff2 100644 --- a/web/src/components/ActivityTimeline.tsx +++ b/web/src/components/ActivityTimeline.tsx @@ -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}`; diff --git a/web/src/data/bankClient.test.ts b/web/src/data/bankClient.test.ts index ed664bb..db5ccaa 100644 --- a/web/src/data/bankClient.test.ts +++ b/web/src/data/bankClient.test.ts @@ -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([[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(); diff --git a/web/src/data/bankClient.ts b/web/src/data/bankClient.ts index 8b3673c..dd0b5e2 100644 --- a/web/src/data/bankClient.ts +++ b/web/src/data/bankClient.ts @@ -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") }; diff --git a/web/src/types/dashboard.ts b/web/src/types/dashboard.ts index 928ee19..9c44b8e 100644 --- a/web/src/types/dashboard.ts +++ b/web/src/types/dashboard.ts @@ -29,6 +29,7 @@ type ActivityIdentity = Readonly<{ export type Activity = | Readonly | Readonly + | Readonly | Readonly | Readonly | Readonly @@ -39,7 +40,7 @@ export type DecodeDiagnostic = Readonly; export type DashboardSnapshot = Readonly<{ blockNumber: bigint; synchronizedAt: Date; - version: 1; + version: 1 | 2; paused: boolean; asset: Address; owner: Address; From 90b0a11b8a3021022ebf09db951e25aae6eb53f2 Mon Sep 17 00:00:00 2001 From: golem Date: Fri, 21 Aug 2026 16:01:35 -0600 Subject: [PATCH 22/30] feat: add guarded Base Sepolia encore --- .env.example | 12 +- Makefile | 36 +++++- README.md | 8 ++ docs/PRESENTER_RUNBOOK.md | 43 +++++++ script/CheckState.s.sol | 6 + script/DeployV1.s.sol | 18 ++- script/SeedBaseSepolia.s.sol | 35 +++++ script/TransferV2Demo.s.sol | 38 +++++- script/UpgradeV2.s.sol | 8 ++ script/lib/DemoScript.sol | 79 +++++++++++- test/ScriptPreflight.t.sol | 228 +++++++++++++++++++++++++++++++-- tools/finalize-manifest.mjs | 18 ++- tools/publish-web-manifest.mjs | 8 ++ tools/require-base-config.sh | 56 ++++++++ tools/select-manifest.mjs | 28 +++- tools/test-base-config.sh | 188 +++++++++++++++++++++++++++ 16 files changed, 784 insertions(+), 25 deletions(-) create mode 100644 script/SeedBaseSepolia.s.sol create mode 100755 tools/require-base-config.sh create mode 100755 tools/test-base-config.sh diff --git a/.env.example b/.env.example index 591c6c2..b47e3bf 100644 --- a/.env.example +++ b/.env.example @@ -1 +1,11 @@ -# Copy this file to .env for local-only configuration. +# Copy this file to .env for local-only public configuration. +# Terminal RPC may be credentialed; never copied into browser artifacts. +BASE_SEPOLIA_RPC_URL= +# Browser RPC is intentionally public and visible to browser users. +BASE_SEPOLIA_PUBLIC_RPC_URL=https://sepolia.base.org +BASE_SEPOLIA_ACCOUNT= +BASE_SEPOLIA_SENDER= +BASE_SEPOLIA_RECIPIENT= + +# Foundry prompts interactively for the named keystore password. Never store a +# signing key, mnemonic, or keystore password in .env. diff --git a/Makefile b/Makefile index 42b3ca6..7846fb9 100644 --- a/Makefile +++ b/Makefile @@ -1,11 +1,14 @@ SHELL := /bin/bash .SHELLFLAGS := -euo pipefail -c +-include .env +export BASE_SEPOLIA_RPC_URL BASE_SEPOLIA_PUBLIC_RPC_URL BASE_SEPOLIA_ACCOUNT BASE_SEPOLIA_SENDER BASE_SEPOLIA_RECIPIENT + RPC_LOCAL := http://127.0.0.1:8545 ANVIL_OWNER := 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 ANVIL_ALICE := 0x70997970C51812dc3A010C7d01b50e0d17dc79C8 -.PHONY: doctor setup demo-local verify check-state reset-local deploy-v1 seed-v1 upgrade-v2 demo-transfer sync-artifacts sync-artifacts-check sync-abis publish-web-manifest test-finalize-manifest +.PHONY: doctor setup demo-local verify check-state reset-local deploy-v1 seed-v1 upgrade-v2 demo-transfer sync-artifacts sync-artifacts-check sync-abis publish-web-manifest test-finalize-manifest deploy-base-sepolia upgrade-base-sepolia transfer-base-sepolia select-anvil select-base-sepolia archive-base-manifest doctor: @bash tools/doctor.sh setup: @@ -23,6 +26,7 @@ verify: @node tools/test-finalize-manifest.mjs @node tools/test-sync-web-artifacts.mjs @bash tools/test-process-safety.sh + @bash tools/test-base-config.sh @bash tools/test-scan-project.sh @bash tools/scan-project.sh @npm --prefix web run lint @@ -63,3 +67,33 @@ demo-transfer: @DEMO_EXPECTED_STAGE=v2 $(MAKE) check-state check-state: @forge script script/CheckState.s.sol:CheckState --rpc-url $(RPC_LOCAL) --force +select-anvil: + @node tools/select-manifest.mjs anvil + @node tools/publish-web-manifest.mjs +select-base-sepolia: + @node tools/select-manifest.mjs baseSepolia + @node tools/publish-web-manifest.mjs +archive-base-manifest: + @node tools/select-manifest.mjs archive baseSepolia +deploy-base-sepolia: + @./tools/require-base-config.sh deploy + @node tools/finalize-manifest.mjs preflight-deploy baseSepolia + @SCRIPT_SENDER="$(BASE_SEPOLIA_SENDER)" DEPLOYMENT_MANIFEST_PATH=deployments/pending.json npm_config_offline=true forge script script/DeployV1.s.sol:DeployV1 --rpc-url "$(BASE_SEPOLIA_RPC_URL)" --account "$(BASE_SEPOLIA_ACCOUNT)" --sender "$(BASE_SEPOLIA_SENDER)" --broadcast --slow --force + @DEPLOYMENT_MANIFEST_PATH=deployments/pending.json DEMO_EXPECTED_STAGE=deployed forge script script/CheckState.s.sol:CheckState --rpc-url "$(BASE_SEPOLIA_RPC_URL)" --force + @node tools/finalize-manifest.mjs deploy --rpc-url "$(BASE_SEPOLIA_RPC_URL)" + @SCRIPT_SENDER="$(BASE_SEPOLIA_SENDER)" DEPLOYMENT_MANIFEST_PATH=deployments/base-sepolia.json forge script script/SeedBaseSepolia.s.sol:SeedBaseSepolia --rpc-url "$(BASE_SEPOLIA_RPC_URL)" --account "$(BASE_SEPOLIA_ACCOUNT)" --sender "$(BASE_SEPOLIA_SENDER)" --broadcast --slow --force + @DEPLOYMENT_MANIFEST_PATH=deployments/base-sepolia.json DEMO_EXPECTED_STAGE=invariants forge script script/CheckState.s.sol:CheckState --rpc-url "$(BASE_SEPOLIA_RPC_URL)" --force + @node tools/select-manifest.mjs baseSepolia + @node tools/sync-web-artifacts.mjs + @node tools/publish-web-manifest.mjs +upgrade-base-sepolia: + @./tools/require-base-config.sh upgrade + @SCRIPT_SENDER="$(BASE_SEPOLIA_SENDER)" DEPLOYMENT_MANIFEST_PATH=deployments/upgrade-pending.json npm_config_offline=true forge script script/UpgradeV2.s.sol:UpgradeV2 --rpc-url "$(BASE_SEPOLIA_RPC_URL)" --account "$(BASE_SEPOLIA_ACCOUNT)" --sender "$(BASE_SEPOLIA_SENDER)" --broadcast --slow --force + @node tools/finalize-manifest.mjs upgrade --rpc-url "$(BASE_SEPOLIA_RPC_URL)" + @DEMO_EXPECTED_STAGE=invariants forge script script/CheckState.s.sol:CheckState --rpc-url "$(BASE_SEPOLIA_RPC_URL)" --force + @node tools/sync-web-artifacts.mjs + @node tools/publish-web-manifest.mjs +transfer-base-sepolia: + @./tools/require-base-config.sh transfer + @SCRIPT_SENDER="$(BASE_SEPOLIA_SENDER)" forge script script/TransferV2Demo.s.sol:TransferV2Demo --rpc-url "$(BASE_SEPOLIA_RPC_URL)" --account "$(BASE_SEPOLIA_ACCOUNT)" --sender "$(BASE_SEPOLIA_SENDER)" --broadcast --slow --force + @DEMO_EXPECTED_STAGE=invariants forge script script/CheckState.s.sol:CheckState --rpc-url "$(BASE_SEPOLIA_RPC_URL)" --force diff --git a/README.md b/README.md index 9d7d30d..c796461 100644 --- a/README.md +++ b/README.md @@ -51,4 +51,12 @@ Foundry scripts are the state-changing control plane; the browser never signs. ` - `make sync-artifacts` — regenerate the ABI module and publish the confirmed active manifest. - `make sync-artifacts-check` — test generation and prove generated ABIs are current. +## Optional Base Sepolia encore + +The local lesson is complete without a wallet, faucet, explorer, or public RPC. An optional Base Sepolia encore is available only after the local V1→V2 demo and `make verify` succeed. It uses a named encrypted Foundry keystore through `--account` plus the same public address through `--sender`; there is no private-key or mnemonic fallback. + +Copy `.env.example` to `.env` and fill only its public configuration. `BASE_SEPOLIA_RPC_URL` is the terminal endpoint and may be credentialed; `BASE_SEPOLIA_PUBLIC_RPC_URL` is intentionally public and is the only RPC serialized for the browser. Foundry requests the keystore password interactively, and the password never belongs in `.env`. + +Use `make archive-base-manifest` before an intentional Base redeployment. The command moves only the Base canonical manifest to a timestamped sibling; it preserves the active browser fallback and all Anvil state. `make select-anvil` and `make select-base-sepolia` explicitly switch the active manifest and republish the browser copy. See the presenter runbook for the exact wallet onboarding, funding boundary, deploy/upgrade/transfer sequence, and recovery rules. + Continue with the [learning guide](docs/LEARNING_GUIDE.md) or rehearse from the [presenter runbook](docs/PRESENTER_RUNBOOK.md). diff --git a/docs/PRESENTER_RUNBOOK.md b/docs/PRESENTER_RUNBOOK.md index 2c71d11..a3761b0 100644 --- a/docs/PRESENTER_RUNBOOK.md +++ b/docs/PRESENTER_RUNBOOK.md @@ -74,6 +74,49 @@ The expected result is the same proxy and a new implementation, version `2`, Ali End the local session with Ctrl-C in the attached terminal, then `make reset-local`. The optional public encore is outside this prepared V1 run: local success does not depend on Base Sepolia, a faucet, an explorer, a wallet, or any external RPC. +## Optional Base Sepolia encore + +Run this only after the completed local demo and only when the presenter explicitly chooses the public encore. Create a named encrypted Foundry keystore before any Base deployment, upgrade, transfer, selection, or archive command: + +```bash +cast wallet import uups-bank-base --interactive +cast wallet address --account uups-bank-base +``` + +Copy the displayed public address to `BASE_SEPOLIA_SENDER`, set `BASE_SEPOLIA_ACCOUNT=uups-bank-base`, and verify the two refer to the same account. Choose a different nonzero public address for `BASE_SEPOLIA_RECIPIENT`. Fund only the displayed Base Sepolia sender address with Base Sepolia test ETH. Never paste the key or password into Codex, shell history, or `.env`; Foundry requests the encrypted-keystore password through its interactive prompt. + +Copy `.env.example` to `.env` and configure: + +```dotenv +# Terminal RPC may be credentialed; never copied into browser artifacts. +BASE_SEPOLIA_RPC_URL= +# Browser RPC is intentionally public and visible to browser users. +BASE_SEPOLIA_PUBLIC_RPC_URL=https://sepolia.base.org +BASE_SEPOLIA_ACCOUNT=uups-bank-base +BASE_SEPOLIA_SENDER= +BASE_SEPOLIA_RECIPIENT= +``` + +The Base commands require HTTPS RPCs, reject browser URL credentials, validate the named account identifier and distinct nonzero actors, and bind both `--account` and `--sender`. They never accept a raw key or mnemonic. First run the offline gate and inspect the fake-value dry run: + +```bash +make verify +bash tools/test-base-config.sh +make -n deploy-base-sepolia BASE_SEPOLIA_RPC_URL=https://terminal.invalid BASE_SEPOLIA_PUBLIC_RPC_URL=https://public.invalid BASE_SEPOLIA_ACCOUNT=demo BASE_SEPOLIA_SENDER=0x1111111111111111111111111111111111111111 BASE_SEPOLIA_RECIPIENT=0x2222222222222222222222222222222222222222 +``` + +The dry run must contain `--account`, `--sender`, and `--slow`, and must not contain a raw-key option. The URLs above are deliberately fake; these checks require no keystore, faucet, or network. Then, only with explicit authorization and Base Sepolia test funds, execute: + +```bash +make deploy-base-sepolia +make upgrade-base-sepolia +make transfer-base-sepolia +``` + +Deployment mints valueless mUSDC only to Presenter and deposits `1,000 mUSDC`; Recipient starts at `0`. The V2 transfer produces Presenter `750 mUSDC` and Recipient `250 mUSDC`, while reserves and liabilities stay `1,000 mUSDC`. Each command runs an invariant-only state check. The confirmed browser manifest contains only the intentionally public browser RPC plus `https://sepolia.basescan.org`; the terminal RPC is never serialized. + +Before an intentional Base redeployment, run `make archive-base-manifest`. It moves only `deployments/base-sepolia.json` to a validated timestamped sibling and leaves `active.json` and Anvil state untouched. A subsequent finalizer may create a new Base canonical manifest; the old active copy remains the browser fallback until `make select-base-sepolia` succeeds. Use `make select-anvil` to return the browser to the local manifest. If a faucet, RPC, or explorer fails, record the optional failure and stop—the completed local demo remains the successful outcome. + ## Closing trust disclosure checklist Read these points while the matching console panel is visible: diff --git a/script/CheckState.s.sol b/script/CheckState.s.sol index 0aac173..6dbc0e9 100644 --- a/script/CheckState.s.sol +++ b/script/CheckState.s.sol @@ -20,6 +20,12 @@ contract CheckState is DemoScript { _printEducationalWarning(); bool deployedStage = keccak256(bytes(stage)) == keccak256("deployed"); Manifest memory manifest = _readManifest(manifestPath, !deployedStage); + if (block.chainid == BASE_SEPOLIA_CHAIN_ID) { + address expectedSender = vm.envAddress("BASE_SEPOLIA_SENDER"); + (address presenter, address recipient,) = _requireBaseConfig(expectedSender); + _assertAddress("Presenter", manifest.actors[0].address_, presenter); + _assertAddress("Recipient", manifest.actors[1].address_, recipient); + } BankV1 bank = BankV1(manifest.proxy); MockUSDC token = MockUSDC(manifest.token); diff --git a/script/DeployV1.s.sol b/script/DeployV1.s.sol index 1fa06b9..5a7891b 100644 --- a/script/DeployV1.s.sol +++ b/script/DeployV1.s.sol @@ -11,6 +11,12 @@ contract DeployV1 is DemoScript { _requireSupportedChain(block.chainid); _printEducationalWarning(); address sender = vm.envAddress("SCRIPT_SENDER"); + address recipient; + string memory publicRpcUrl; + + if (block.chainid == BASE_SEPOLIA_CHAIN_ID) { + (, recipient, publicRpcUrl) = _requireBaseConfig(sender); + } if (block.chainid == ANVIL_CHAIN_ID) { (uint256 ownerKey, address derivedOwner) = _deriveLocalActor(block.chainid, 0); @@ -40,17 +46,18 @@ contract DeployV1 is DemoScript { manifest.network = block.chainid == ANVIL_CHAIN_ID ? "anvil" : "baseSepolia"; manifest.chainId = block.chainid; manifest.deploymentBlock = 0; - manifest.rpcUrl = block.chainid == ANVIL_CHAIN_ID ? "http://127.0.0.1:8545" : ""; + manifest.rpcUrl = block.chainid == ANVIL_CHAIN_ID ? "http://127.0.0.1:8545" : publicRpcUrl; + manifest.explorerBaseUrl = block.chainid == BASE_SEPOLIA_CHAIN_ID ? "https://sepolia.basescan.org" : ""; manifest.token = tokenAddress; manifest.proxy = proxy; manifest.implementation = implementation; manifest.owner = sender; - manifest.actors = _actors(sender); + manifest.actors = _actors(sender, recipient); _writeManifest(_manifestPath(PENDING_MANIFEST_PATH), manifest); } - function _actors(address sender) private view returns (Actor[] memory actors) { + function _actors(address sender, address recipient) private view returns (Actor[] memory actors) { if (block.chainid == ANVIL_CHAIN_ID) { actors = new Actor[](3); actors[0] = Actor({label: "owner", address_: sender}); @@ -59,8 +66,9 @@ contract DeployV1 is DemoScript { actors[1].label = "Alice"; actors[2].label = "Bob"; } else { - actors = new Actor[](1); - actors[0] = Actor({label: "owner", address_: sender}); + actors = new Actor[](2); + actors[0] = Actor({label: "Presenter", address_: sender}); + actors[1] = Actor({label: "Recipient", address_: recipient}); } } } diff --git a/script/SeedBaseSepolia.s.sol b/script/SeedBaseSepolia.s.sol new file mode 100644 index 0000000..1f81fad --- /dev/null +++ b/script/SeedBaseSepolia.s.sol @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.35; + +import {BankV1} from "../src/BankV1.sol"; +import {MockUSDC} from "../src/MockUSDC.sol"; +import {DemoScript} from "./lib/DemoScript.sol"; + +contract SeedBaseSepolia is DemoScript { + uint256 internal constant SEED_AMOUNT = 1_000e6; + + function run() external { + if (block.chainid != BASE_SEPOLIA_CHAIN_ID) revert UnsupportedChain(block.chainid); + _printEducationalWarning(); + address scriptSender = vm.envAddress("SCRIPT_SENDER"); + (address presenter, address recipient,) = _requireBaseConfig(scriptSender); + Manifest memory manifest = _readManifest(_manifestPath(ACTIVE_MANIFEST_PATH), true); + _assertAddress("Presenter", manifest.actors[0].address_, presenter); + _assertAddress("Recipient", manifest.actors[1].address_, recipient); + + BankV1 bank = BankV1(manifest.proxy); + MockUSDC token = MockUSDC(manifest.token); + _assertDeployedState(manifest); + + vm.startBroadcast(presenter); + token.mint(presenter, SEED_AMOUNT); + token.approve(manifest.proxy, SEED_AMOUNT); + bank.deposit(SEED_AMOUNT); + vm.stopBroadcast(); + + _assertUint("Presenter internal balance", SEED_AMOUNT, bank.balanceOf(presenter)); + _assertUint("Recipient internal balance", 0, bank.balanceOf(recipient)); + _assertUint("liabilities", SEED_AMOUNT, bank.totalLiabilities()); + _assertUint("reserves", SEED_AMOUNT, token.balanceOf(manifest.proxy)); + } +} diff --git a/script/TransferV2Demo.s.sol b/script/TransferV2Demo.s.sol index 8dc4943..e00d986 100644 --- a/script/TransferV2Demo.s.sol +++ b/script/TransferV2Demo.s.sol @@ -11,9 +11,18 @@ contract TransferV2Demo is DemoScript { } function _run(string memory manifestPath) internal { - if (block.chainid != ANVIL_CHAIN_ID) revert UnsupportedChain(block.chainid); + _requireSupportedChain(block.chainid); _printEducationalWarning(); Manifest memory manifest = _readManifest(manifestPath, true); + if (block.chainid == BASE_SEPOLIA_CHAIN_ID) { + _runBase(manifest); + return; + } + + _runLocal(manifest); + } + + function _runLocal(Manifest memory manifest) private { (uint256 aliceKey, address alice) = _deriveLocalActor(block.chainid, 1); address bob = manifest.actors[2].address_; _assertAddress("Alice actor", manifest.actors[1].address_, alice); @@ -38,4 +47,31 @@ contract TransferV2Demo is DemoScript { _assertUint("reserves", reserves, token.balanceOf(manifest.proxy)); _assertUint("surplus", 0, token.balanceOf(manifest.proxy) - bank.totalLiabilities()); } + + function _runBase(Manifest memory manifest) private { + address scriptSender = vm.envAddress("SCRIPT_SENDER"); + (address presenter, address recipient,) = _requireBaseConfig(scriptSender); + _assertAddress("Presenter", manifest.actors[0].address_, presenter); + _assertAddress("Recipient", manifest.actors[1].address_, recipient); + + BankV2 bank = BankV2(manifest.proxy); + MockUSDC token = MockUSDC(manifest.token); + _assertUint("version", 2, bank.contractVersion()); + _assertUint("Presenter internal balance", 1_000e6, bank.balanceOf(presenter)); + _assertUint("Recipient internal balance", 0, bank.balanceOf(recipient)); + uint256 liabilities = bank.totalLiabilities(); + uint256 reserves = token.balanceOf(manifest.proxy); + _assertUint("liabilities", 1_000e6, liabilities); + _assertUint("reserves", 1_000e6, reserves); + + vm.startBroadcast(presenter); + bank.transferBalance(recipient, 250e6); + vm.stopBroadcast(); + + _assertUint("Presenter internal balance", 750e6, bank.balanceOf(presenter)); + _assertUint("Recipient internal balance", 250e6, bank.balanceOf(recipient)); + _assertUint("liabilities", liabilities, bank.totalLiabilities()); + _assertUint("reserves", reserves, token.balanceOf(manifest.proxy)); + _assertUint("surplus", 0, token.balanceOf(manifest.proxy) - bank.totalLiabilities()); + } } diff --git a/script/UpgradeV2.s.sol b/script/UpgradeV2.s.sol index 578dc36..88d3b20 100644 --- a/script/UpgradeV2.s.sol +++ b/script/UpgradeV2.s.sol @@ -37,7 +37,15 @@ contract UpgradeV2 is DemoScript { internal returns (bool upgraded, address implementation) { + address configuredRecipient; + if (block.chainid == BASE_SEPOLIA_CHAIN_ID) { + (, configuredRecipient,) = _requireBaseConfig(sender); + } Manifest memory manifest = _readManifest(activePath, true); + if (block.chainid == BASE_SEPOLIA_CHAIN_ID) { + _assertAddress("Presenter", manifest.actors[0].address_, sender); + _assertAddress("Recipient", manifest.actors[1].address_, configuredRecipient); + } BankV1 bank = BankV1(manifest.proxy); address actualImplementation = Upgrades.getImplementationAddress(manifest.proxy); diff --git a/script/lib/DemoScript.sol b/script/lib/DemoScript.sol index 769cd12..9132a60 100644 --- a/script/lib/DemoScript.sol +++ b/script/lib/DemoScript.sol @@ -20,6 +20,7 @@ abstract contract DemoScript is Script { error InvalidDeploymentBlock(); error InvalidManifestSchema(uint256 schemaVersion); error InvalidActorConfiguration(); + error InvalidPublicRpcUrl(); error UnexpectedState(string label, uint256 expected, uint256 actual); error UnexpectedAddress(string label, address expected, address actual); @@ -60,6 +61,72 @@ abstract contract DemoScript is Script { return vm.envOr("DEPLOYMENT_MANIFEST_PATH", defaultPath); } + function _requireBaseSender(address scriptSender) internal view returns (address sender) { + sender = vm.envAddress("BASE_SEPOLIA_SENDER"); + _assertAddress("BASE_SEPOLIA_SENDER", scriptSender, sender); + } + + function _requireBaseConfig(address scriptSender) + internal + view + returns (address sender, address recipient, string memory publicRpcUrl) + { + sender = _requireBaseSender(scriptSender); + recipient = vm.envAddress("BASE_SEPOLIA_RECIPIENT"); + if (sender == address(0) || recipient == address(0) || sender == recipient) revert InvalidActorConfiguration(); + publicRpcUrl = vm.envString("BASE_SEPOLIA_PUBLIC_RPC_URL"); + _requirePublicHttpsUrl(publicRpcUrl); + } + + function _requirePublicHttpsUrl(string memory value) internal pure { + bytes memory url = bytes(value); + bytes memory prefix = bytes("https://"); + if (url.length <= prefix.length) revert InvalidPublicRpcUrl(); + for (uint256 i; i < prefix.length; ++i) { + if (url[i] != prefix[i]) revert InvalidPublicRpcUrl(); + } + + bool query; + uint256 queryStart; + for (uint256 i = prefix.length; i < url.length; ++i) { + bytes1 character = url[i]; + if (!query && character == "@") revert InvalidPublicRpcUrl(); + if (character == "?") { + query = true; + queryStart = i + 1; + break; + } + if (character == "#") break; + } + if (!query) return; + + bytes memory lowered = new bytes(url.length - queryStart); + for (uint256 i; i < lowered.length; ++i) { + uint8 character = uint8(url[queryStart + i]); + lowered[i] = character >= 65 && character <= 90 ? bytes1(character + 32) : bytes1(character); + } + if ( + _containsBytes(lowered, bytes("key=")) || _containsBytes(lowered, bytes("token=")) + || _containsBytes(lowered, bytes("secret=")) || _containsBytes(lowered, bytes("password=")) + || _containsBytes(lowered, bytes("credential=")) + ) revert InvalidPublicRpcUrl(); + } + + function _containsBytes(bytes memory haystack, bytes memory needle) private pure returns (bool) { + if (needle.length > haystack.length) return false; + for (uint256 i; i + needle.length <= haystack.length; ++i) { + bool matches = true; + for (uint256 j; j < needle.length; ++j) { + if (haystack[i + j] != needle[j]) { + matches = false; + break; + } + } + if (matches) return true; + } + return false; + } + function _readManifest(string memory path, bool active) internal view returns (Manifest memory manifest) { string memory json = vm.readFile(path); _assertExactManifestSchema(json); @@ -112,7 +179,7 @@ abstract contract DemoScript is Script { } function _parseActors(string memory json, uint256 chainId) private view returns (Actor[] memory actors) { - uint256 actorCount = chainId == ANVIL_CHAIN_ID ? 3 : 1; + uint256 actorCount = chainId == ANVIL_CHAIN_ID ? 3 : 2; actors = new Actor[](actorCount); for (uint256 i; i < actorCount; ++i) { string memory index = vm.toString(i); @@ -157,9 +224,13 @@ abstract contract DemoScript is Script { } if ( - manifest.chainId != BASE_SEPOLIA_CHAIN_ID || manifest.actors.length != 1 - || !_equals(manifest.network, "baseSepolia") || !_equals(manifest.actors[0].label, "owner") - || manifest.actors[0].address_ != manifest.owner + manifest.chainId != BASE_SEPOLIA_CHAIN_ID || manifest.actors.length != 2 + || !_equals(manifest.network, "baseSepolia") || !_equals(manifest.actors[0].label, "Presenter") + || !_equals(manifest.actors[1].label, "Recipient") || manifest.actors[0].address_ != manifest.owner + ) revert InvalidActorConfiguration(); + if ( + manifest.actors[0].address_ == address(0) || manifest.actors[1].address_ == address(0) + || manifest.actors[0].address_ == manifest.actors[1].address_ ) revert InvalidActorConfiguration(); } diff --git a/test/ScriptPreflight.t.sol b/test/ScriptPreflight.t.sol index 0aedca0..fb36b67 100644 --- a/test/ScriptPreflight.t.sol +++ b/test/ScriptPreflight.t.sol @@ -6,6 +6,7 @@ import {Upgrades} from "openzeppelin-foundry-upgrades/Upgrades.sol"; import {DemoScript} from "../script/lib/DemoScript.sol"; import {DeployV1} from "../script/DeployV1.s.sol"; import {SeedV1Demo} from "../script/SeedV1Demo.s.sol"; +import {SeedBaseSepolia} from "../script/SeedBaseSepolia.s.sol"; import {CheckState} from "../script/CheckState.s.sol"; import {UpgradeV2} from "../script/UpgradeV2.s.sol"; import {TransferV2Demo} from "../script/TransferV2Demo.s.sol"; @@ -88,7 +89,8 @@ contract ScriptPreflightTest is Test { } function testUnsupportedChainsAreRejectedBeforeBroadcast() public { - uint256[3] memory rejected = [uint256(1), uint256(8453), uint256(7777777)]; + uint256[7] memory rejected = + [uint256(1), uint256(10), uint256(56), uint256(137), uint256(8453), uint256(42161), uint256(7777777)]; for (uint256 i; i < rejected.length; ++i) { vm.expectRevert(abi.encodeWithSelector(DemoScript.UnsupportedChain.selector, rejected[i])); harness.requireSupportedChain(rejected[i]); @@ -210,7 +212,7 @@ contract ScriptPreflightTest is Test { harness.readManifest(path, true); } - function testBaseManifestRequiresOnlyOwnerActor() public { + function testBaseManifestRequiresExactPresenterRecipientActors() public { string memory path = _writePublicBaseManifest(); vm.chainId(84532); _etchManifestContracts(); @@ -270,16 +272,18 @@ contract ScriptPreflightTest is Test { harness.readManifest(path, true); } - function testBaseSepoliaManifestUsesCamelCaseAndOmitsUnavailableUrls() public { + function testBaseSepoliaManifestUsesPublicUrlsAndExactActors() public { string memory path = _writePublicBaseManifest(); vm.chainId(84532); _etchManifestContracts(); DemoScript.Manifest memory manifest = harness.readManifest(path, true); assertEq(manifest.network, "baseSepolia"); - assertEq(manifest.rpcUrl, ""); - assertEq(manifest.explorerBaseUrl, ""); - assertEq(manifest.actors[0].label, "owner"); + assertEq(manifest.rpcUrl, "https://public.invalid"); + assertEq(manifest.explorerBaseUrl, "https://sepolia.basescan.org"); + assertEq(manifest.actors[0].label, "Presenter"); assertEq(manifest.actors[0].address_, OWNER); + assertEq(manifest.actors[1].label, "Recipient"); + assertEq(manifest.actors[1].address_, BOB); } function testSerializedManifestContainsPublicAddressesAndNoSecrets() public { @@ -332,6 +336,76 @@ contract ScriptPreflightTest is Test { assertEq(manifest.actors[2].address_, 0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC); } + function testBaseDeployRejectsMismatchedConfiguredSenderBeforeBroadcast() public { + string memory path = string.concat(fixtureDir, "/base-deploy-mismatch.json"); + vm.chainId(84532); + vm.setEnv("SCRIPT_SENDER", vm.toString(OWNER)); + vm.setEnv("BASE_SEPOLIA_SENDER", vm.toString(ALICE)); + vm.setEnv("BASE_SEPOLIA_RECIPIENT", vm.toString(BOB)); + vm.setEnv("BASE_SEPOLIA_PUBLIC_RPC_URL", "https://public.invalid"); + vm.setEnv("DEPLOYMENT_MANIFEST_PATH", path); + uint256 nonceBefore = vm.getNonce(OWNER); + DeployV1 deployer = new DeployV1(); + + vm.expectRevert( + abi.encodeWithSelector(DemoScript.UnexpectedAddress.selector, "BASE_SEPOLIA_SENDER", OWNER, ALICE) + ); + deployer.run(); + + assertEq(vm.getNonce(OWNER), nonceBefore); + } + + function testBaseDeployWritesPublicRpcExplorerAndPresenterRecipientActors() public { + string memory path = string.concat(fixtureDir, "/base-deployed.json"); + vm.chainId(84532); + _setBaseConfig(OWNER, BOB, "https://public.invalid"); + vm.setEnv("DEPLOYMENT_MANIFEST_PATH", path); + + (address token, address proxy, address implementation) = new DeployV1().run(); + + _etchManifestContracts(); + DemoScript.Manifest memory manifest = harness.readManifest(path, false); + assertEq(manifest.token, token); + assertEq(manifest.proxy, proxy); + assertEq(manifest.implementation, implementation); + assertEq(manifest.owner, OWNER); + assertEq(manifest.rpcUrl, "https://public.invalid"); + assertEq(manifest.explorerBaseUrl, "https://sepolia.basescan.org"); + assertEq(manifest.actors.length, 2); + assertEq(manifest.actors[0].label, "Presenter"); + assertEq(manifest.actors[0].address_, OWNER); + assertEq(manifest.actors[1].label, "Recipient"); + assertEq(manifest.actors[1].address_, BOB); + } + + function testBaseDeployRejectsZeroOrSameRecipientBeforeBroadcast() public { + vm.chainId(84532); + DeployV1 deployer = new DeployV1(); + address[2] memory invalidRecipients = [address(0), OWNER]; + for (uint256 i; i < invalidRecipients.length; ++i) { + _setBaseConfig(OWNER, invalidRecipients[i], "https://public.invalid"); + uint256 nonceBefore = vm.getNonce(OWNER); + vm.expectRevert(DemoScript.InvalidActorConfiguration.selector); + deployer.run(); + assertEq(vm.getNonce(OWNER), nonceBefore); + } + } + + function testBaseDeployRejectsCredentialBearingPublicRpcBeforeBroadcast() public { + vm.chainId(84532); + DeployV1 deployer = new DeployV1(); + string[3] memory invalidUrls = + ["http://public.invalid", "https://user@public.invalid", "https://public.invalid/path?api_key=fixture"]; + bytes4 invalidPublicRpcUrl = bytes4(keccak256("InvalidPublicRpcUrl()")); + for (uint256 i; i < invalidUrls.length; ++i) { + _setBaseConfig(OWNER, BOB, invalidUrls[i]); + uint256 nonceBefore = vm.getNonce(OWNER); + vm.expectRevert(invalidPublicRpcUrl); + deployer.run(); + assertEq(vm.getNonce(OWNER), nonceBefore); + } + } + function testSeedV1DemoExecutesExactActOneStateAndCheckStateAcceptsIt() public { (string memory path, DemoScript.Manifest memory manifest) = _deployFixtureNamed("seed-v1"); vm.setEnv("DEPLOYMENT_MANIFEST_PATH", path); @@ -350,6 +424,38 @@ contract ScriptPreflightTest is Test { new CheckState().run(); } + function testSeedBaseSepoliaCreatesExactPresenterDepositState() public { + (string memory path, DemoScript.Manifest memory manifest) = _deployBaseFixtureNamed("seed-base"); + _setBaseConfig(OWNER, BOB, "https://public.invalid"); + vm.setEnv("DEPLOYMENT_MANIFEST_PATH", path); + + new SeedBaseSepolia().run(); + + BankV1 bank = BankV1(manifest.proxy); + MockUSDC token = MockUSDC(manifest.token); + assertEq(bank.balanceOf(OWNER), 1_000e6); + assertEq(bank.balanceOf(BOB), 0); + assertEq(bank.totalLiabilities(), 1_000e6); + assertEq(token.balanceOf(manifest.proxy), 1_000e6); + assertEq(token.balanceOf(OWNER), 0); + } + + function testSeedBaseSepoliaRejectsMismatchedSenderBeforeMintOrBroadcast() public { + (string memory path, DemoScript.Manifest memory manifest) = _deployBaseFixtureNamed("seed-base-mismatch"); + _setBaseConfig(OWNER, BOB, "https://public.invalid"); + vm.setEnv("BASE_SEPOLIA_SENDER", vm.toString(ALICE)); + vm.setEnv("DEPLOYMENT_MANIFEST_PATH", path); + SeedBaseSepolia seeder = new SeedBaseSepolia(); + + vm.expectRevert( + abi.encodeWithSelector(DemoScript.UnexpectedAddress.selector, "BASE_SEPOLIA_SENDER", OWNER, ALICE) + ); + seeder.run(); + + assertEq(MockUSDC(manifest.token).totalSupply(), 0); + assertEq(BankV1(manifest.proxy).totalLiabilities(), 0); + } + function testCheckStateRejectsManifestImplementationMismatch() public { (string memory path, DemoScript.Manifest memory manifest) = _deployUpgradeFixtureNamed("check-mismatch"); vm.writeJson(string.concat('"', vm.toString(manifest.token), '"'), path, ".implementation"); @@ -506,6 +612,73 @@ contract ScriptPreflightTest is Test { assertEq(MockUSDC(manifest.token).balanceOf(manifest.proxy), 1_400e6); } + function testBaseUpgradeAndTransferPreserveAccountingAndMoveExactBalance() public { + (string memory path, DemoScript.Manifest memory manifest) = _deployBaseFixtureNamed("transfer-base"); + _setBaseConfig(OWNER, BOB, "https://public.invalid"); + vm.setEnv("DEPLOYMENT_MANIFEST_PATH", path); + new SeedBaseSepolia().run(); + + string memory pending = string.concat(fixtureDir, "/base-upgrade-pending.json"); + (, address implementation) = new UpgradeV2Harness().runWithPaths(path, pending, OWNER); + vm.writeJson(string.concat('"', vm.toString(implementation), '"'), path, ".implementation"); + + new TransferV2DemoHarness().runWithPath(path); + + BankV2 bank = BankV2(manifest.proxy); + MockUSDC token = MockUSDC(manifest.token); + assertEq(bank.balanceOf(OWNER), 750e6); + assertEq(bank.balanceOf(BOB), 250e6); + assertEq(bank.totalLiabilities(), 1_000e6); + assertEq(token.balanceOf(manifest.proxy), 1_000e6); + new CheckStateHarness().runWithPath(path, "invariants"); + } + + function testBaseUpgradeRejectsMismatchedConfiguredSenderBeforeBroadcast() public { + (string memory path, DemoScript.Manifest memory manifest) = _deployBaseFixtureNamed("upgrade-base-mismatch"); + _setBaseConfig(ALICE, BOB, "https://public.invalid"); + string memory pending = string.concat(fixtureDir, "/base-mismatch-pending.json"); + UpgradeV2Harness upgrader = new UpgradeV2Harness(); + + vm.expectRevert( + abi.encodeWithSelector(DemoScript.UnexpectedAddress.selector, "BASE_SEPOLIA_SENDER", OWNER, ALICE) + ); + upgrader.runWithPaths(path, pending, OWNER); + + assertEq(Upgrades.getImplementationAddress(manifest.proxy), manifest.implementation); + } + + function testBaseCheckStateRejectsConfiguredActorMismatch() public { + (string memory path, DemoScript.Manifest memory manifest) = _deployBaseFixtureNamed("check-base-mismatch"); + _setBaseConfig(OWNER, ALICE, "https://public.invalid"); + CheckStateHarness checker = new CheckStateHarness(); + + vm.expectRevert( + abi.encodeWithSelector( + DemoScript.UnexpectedAddress.selector, "Recipient", manifest.actors[1].address_, ALICE + ) + ); + checker.runWithPath(path, "deployed"); + } + + function testBaseTransferRejectsInvalidRecipientBeforeBroadcast() public { + (string memory path, DemoScript.Manifest memory manifest) = _deployBaseFixtureNamed("transfer-base-invalid"); + _setBaseConfig(OWNER, BOB, "https://public.invalid"); + vm.setEnv("DEPLOYMENT_MANIFEST_PATH", path); + new SeedBaseSepolia().run(); + string memory pending = string.concat(fixtureDir, "/base-transfer-upgrade.json"); + (, address implementation) = new UpgradeV2Harness().runWithPaths(path, pending, OWNER); + vm.writeJson(string.concat('"', vm.toString(implementation), '"'), path, ".implementation"); + _setBaseConfig(OWNER, OWNER, "https://public.invalid"); + TransferV2DemoHarness transfer = new TransferV2DemoHarness(); + + vm.expectRevert(DemoScript.InvalidActorConfiguration.selector); + transfer.runWithPath(path); + + BankV2 bank = BankV2(manifest.proxy); + assertEq(bank.balanceOf(OWNER), 1_000e6); + assertEq(bank.balanceOf(BOB), 0); + } + function _snapshot() internal pure returns (UpgradeV2.Snapshot memory snapshot) { snapshot.proxy = PROXY; snapshot.implementation = IMPLEMENTATION; @@ -593,6 +766,30 @@ contract ScriptPreflightTest is Test { vm.writeFile(path, harness.serializeManifest(manifest)); } + function _deployBaseFixtureNamed(string memory name) + internal + returns (string memory path, DemoScript.Manifest memory manifest) + { + vm.chainId(84532); + MockUSDC deployedToken = new MockUSDC(OWNER); + address deployedProxy = Upgrades.deployUUPSProxy( + "BankV1.sol:BankV1", abi.encodeCall(BankV1.initialize, (address(deployedToken), OWNER)) + ); + manifest.schemaVersion = 1; + manifest.network = "baseSepolia"; + manifest.chainId = 84532; + manifest.deploymentBlock = 1; + manifest.rpcUrl = "https://public.invalid"; + manifest.explorerBaseUrl = "https://sepolia.basescan.org"; + manifest.token = address(deployedToken); + manifest.proxy = deployedProxy; + manifest.implementation = Upgrades.getImplementationAddress(deployedProxy); + manifest.owner = OWNER; + manifest.actors = _baseActors(); + path = string.concat(fixtureDir, "/", name, ".json"); + vm.writeFile(path, harness.serializeManifest(manifest)); + } + function _seedState(DemoScript.Manifest memory manifest) internal { MockUSDC deployedToken = MockUSDC(manifest.token); BankV1 deployedBank = BankV1(manifest.proxy); @@ -689,7 +886,7 @@ contract ScriptPreflightTest is Test { vm.writeFile( path, string.concat( - '{"schemaVersion":1,"network":"baseSepolia","chainId":84532,"deploymentBlock":1,"token":"', + '{"schemaVersion":1,"network":"baseSepolia","chainId":84532,"deploymentBlock":1,"rpcUrl":"https://public.invalid","explorerBaseUrl":"https://sepolia.basescan.org","token":"', vm.toString(TOKEN), '","proxy":"', vm.toString(PROXY), @@ -697,8 +894,10 @@ contract ScriptPreflightTest is Test { vm.toString(IMPLEMENTATION), '","owner":"', vm.toString(OWNER), - '","actors":[{"label":"owner","address":"', + '","actors":[{"label":"Presenter","address":"', vm.toString(OWNER), + '"},{"label":"Recipient","address":"', + vm.toString(BOB), '"}]}' ) ); @@ -717,6 +916,12 @@ contract ScriptPreflightTest is Test { actors[2] = DemoScript.Actor({label: "Bob", address_: 0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC}); } + function _baseActors() internal pure returns (DemoScript.Actor[] memory actors) { + actors = new DemoScript.Actor[](2); + actors[0] = DemoScript.Actor({label: "Presenter", address_: OWNER}); + actors[1] = DemoScript.Actor({label: "Recipient", address_: BOB}); + } + function _contains(string memory haystack, string memory needle) internal pure returns (bool) { bytes memory h = bytes(haystack); bytes memory n = bytes(needle); @@ -733,4 +938,11 @@ contract ScriptPreflightTest is Test { } return false; } + + function _setBaseConfig(address sender, address recipient, string memory publicRpcUrl) internal { + vm.setEnv("SCRIPT_SENDER", vm.toString(sender)); + vm.setEnv("BASE_SEPOLIA_SENDER", vm.toString(sender)); + vm.setEnv("BASE_SEPOLIA_RECIPIENT", vm.toString(recipient)); + vm.setEnv("BASE_SEPOLIA_PUBLIC_RPC_URL", publicRpcUrl); + } } diff --git a/tools/finalize-manifest.mjs b/tools/finalize-manifest.mjs index 3a0109b..18879f0 100644 --- a/tools/finalize-manifest.mjs +++ b/tools/finalize-manifest.mjs @@ -137,6 +137,8 @@ export async function finalizeUpgrade({ root = process.cwd(), rpc }) { if (matching.length !== 1) throw new Error(`expected exactly one live upgrade transaction to proxy, found ${matching.length}`); const receipt = await rpc("eth_getTransactionReceipt", [matching[0].hash]); if (!receipt || !isSuccessfulReceipt(receipt.status)) throw new Error("upgrade receipt was not successful"); + const upgradeBlock = parseRpcQuantity(receipt.blockNumber, "upgrade receipt block number"); + if (upgradeBlock === 0) throw new Error("upgrade receipt block number must be nonzero"); if (!Array.isArray(receipt.logs) || !receipt.logs.some((log) => isUpgradeLog(log, active.proxy, pending.implementation))) { throw new Error("successful upgrade receipt is missing the expected Upgraded event"); } @@ -148,7 +150,7 @@ export async function finalizeUpgrade({ root = process.cwd(), rpc }) { await atomicWriteJson(canonicalPath, updated); await atomicWriteJson(activePath, updated); await rm(pendingPath); - return { mode: "upgrade", path: canonicalPath, manifest: updated }; + return { mode: "upgrade", path: canonicalPath, manifest: updated, upgradeBlock }; } function validateNoopMarker(marker, active) { @@ -377,6 +379,11 @@ function assertPublicUrl(value, field) { if ((url.protocol !== "https:" && url.protocol !== "http:") || 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|password|credential)/i.test(key)) { + throw new Error(`manifest ${field} must not contain credential query parameters`); + } + } } function assertActorConfiguration(manifest) { @@ -390,8 +397,13 @@ function assertActorConfiguration(manifest) { if (actors.length !== expected.length || actors.some((actor, index) => actor.label !== expected[index][0] || actor.address.toLowerCase() !== expected[index][1].toLowerCase())) { throw new Error("manifest anvil actors must match the documented local actor configuration"); } - } else if (actors.length !== 1 || actors[0].label !== "owner") { - throw new Error("manifest baseSepolia must contain only the owner actor"); + } else { + const configured = actors.length === 2 && actors[0].label === "Presenter" && actors[1].label === "Recipient"; + const legacy = actors.length === 1 && actors[0].label === "owner" + && !Object.hasOwn(manifest, "rpcUrl") && !Object.hasOwn(manifest, "explorerBaseUrl"); + if (!configured && !legacy) { + throw new Error("manifest baseSepolia actors must be Presenter and Recipient"); + } } if (actors[0].address.toLowerCase() !== owner.toLowerCase()) throw new Error("manifest owner must be actor zero"); } diff --git a/tools/publish-web-manifest.mjs b/tools/publish-web-manifest.mjs index b79ff5f..d323761 100644 --- a/tools/publish-web-manifest.mjs +++ b/tools/publish-web-manifest.mjs @@ -34,6 +34,14 @@ export function validatePublicManifest(manifest) { for (const field of ["token", "proxy", "implementation", "owner"]) assertAddress(manifest[field], field); assertActors(manifest.actors); for (const field of optionalFields) if (Object.hasOwn(manifest, field)) assertPublicUrl(manifest[field], field); + if (manifest.network === "baseSepolia") { + if (Object.hasOwn(manifest, "rpcUrl") && new URL(manifest.rpcUrl).protocol !== "https:") { + throw new Error("manifest Base Sepolia rpcUrl must use HTTPS"); + } + if (Object.hasOwn(manifest, "explorerBaseUrl") && manifest.explorerBaseUrl !== "https://sepolia.basescan.org") { + throw new Error("manifest Base Sepolia explorerBaseUrl must use BaseScan"); + } + } } function isRecord(value) { return typeof value === "object" && value !== null && !Array.isArray(value); } diff --git a/tools/require-base-config.sh b/tools/require-base-config.sh new file mode 100755 index 0000000..e594d3a --- /dev/null +++ b/tools/require-base-config.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +set -euo pipefail + +fail() { + printf '%s\n' "$1" >&2 + exit 1 +} + +case ${1-} in + deploy|upgrade|transfer) ;; + *) fail 'usage: require-base-config.sh ' ;; +esac + +for variable in \ + BASE_SEPOLIA_RPC_URL \ + BASE_SEPOLIA_PUBLIC_RPC_URL \ + BASE_SEPOLIA_ACCOUNT \ + BASE_SEPOLIA_SENDER \ + BASE_SEPOLIA_RECIPIENT; do + [[ -n ${!variable-} ]] || fail "$variable is required" +done + +[[ $BASE_SEPOLIA_ACCOUNT =~ ^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$ ]] || \ + fail 'BASE_SEPOLIA_ACCOUNT must be a conservative Foundry account identifier' + +address_pattern='^0x[[:xdigit:]]{40}$' +[[ $BASE_SEPOLIA_SENDER =~ $address_pattern ]] || fail 'BASE_SEPOLIA_SENDER must be an address' +[[ $BASE_SEPOLIA_RECIPIENT =~ $address_pattern ]] || fail 'BASE_SEPOLIA_RECIPIENT must be an address' +[[ ${BASE_SEPOLIA_SENDER,,} != 0x0000000000000000000000000000000000000000 ]] || \ + fail 'BASE_SEPOLIA_SENDER must be nonzero' +[[ ${BASE_SEPOLIA_RECIPIENT,,} != 0x0000000000000000000000000000000000000000 ]] || \ + fail 'BASE_SEPOLIA_RECIPIENT must be nonzero' +[[ ${BASE_SEPOLIA_SENDER,,} != "${BASE_SEPOLIA_RECIPIENT,,}" ]] || \ + fail 'BASE_SEPOLIA_SENDER and BASE_SEPOLIA_RECIPIENT must differ' + +[[ $BASE_SEPOLIA_RPC_URL =~ ^https://[^[:space:]]+$ ]] || \ + fail 'BASE_SEPOLIA_RPC_URL must use HTTPS' +[[ $BASE_SEPOLIA_PUBLIC_RPC_URL =~ ^https://[^[:space:]]+$ ]] || \ + fail 'BASE_SEPOLIA_PUBLIC_RPC_URL must use HTTPS' + +public_remainder=${BASE_SEPOLIA_PUBLIC_RPC_URL#https://} +public_authority=${public_remainder%%[/?#]*} +[[ -n $public_authority && $public_authority != *@* ]] || \ + fail 'BASE_SEPOLIA_PUBLIC_RPC_URL must not contain user info' + +if [[ $BASE_SEPOLIA_PUBLIC_RPC_URL == *\?* ]]; then + public_query=${BASE_SEPOLIA_PUBLIC_RPC_URL#*\?} + public_query=${public_query%%#*} + IFS='&' read -r -a parameters <<<"$public_query" + for parameter in "${parameters[@]}"; do + key=${parameter%%=*} + if [[ ${key,,} =~ (key|token|secret|password|credential) ]]; then + fail 'BASE_SEPOLIA_PUBLIC_RPC_URL must not contain credential query parameters' + fi + done +fi diff --git a/tools/select-manifest.mjs b/tools/select-manifest.mjs index f89cb55..bfe7f94 100644 --- a/tools/select-manifest.mjs +++ b/tools/select-manifest.mjs @@ -1,4 +1,4 @@ -import { readFile } from "node:fs/promises"; +import { access, readFile, rename } from "node:fs/promises"; import { join, resolve } from "node:path"; import { pathToFileURL } from "node:url"; @@ -14,9 +14,33 @@ export async function selectManifest({ root = process.cwd(), network }) { return { source, active }; } +export async function archiveManifest({ root = process.cwd(), network, now = new Date() }) { + if (network !== "baseSepolia") throw new Error("only baseSepolia may be archived"); + const spec = networkSpec(network); + const source = join(root, "deployments", spec.canonical); + await readManifest(source); + if (!(now instanceof Date) || Number.isNaN(now.valueOf())) throw new Error("archive timestamp is invalid"); + const timestamp = now.toISOString().replace(/[-:.]/g, ""); + if (!/^\d{8}T\d{9}Z$/.test(timestamp)) throw new Error("archive timestamp is invalid"); + const target = join(root, "deployments", `base-sepolia.${timestamp}.json`); + try { + await access(target); + } catch (error) { + if (error.code === "ENOENT") { + await rename(source, target); + return { source, path: target }; + } + throw error; + } + throw new Error("refusing to overwrite an existing Base Sepolia archive"); +} + export async function runSelectCli(argv, { root = process.cwd(), log = console.log } = {}) { log(EDUCATIONAL_WARNING); - if (argv.length !== 1) throw new Error("usage: select-manifest.mjs "); + if (argv.length === 2 && argv[0] === "archive" && argv[1] === "baseSepolia") { + return archiveManifest({ root, network: argv[1] }); + } + if (argv.length !== 1) throw new Error("usage: select-manifest.mjs | archive baseSepolia"); return selectManifest({ root, network: argv[0] }); } diff --git a/tools/test-base-config.sh b/tools/test-base-config.sh new file mode 100755 index 0000000..1a11a31 --- /dev/null +++ b/tools/test-base-config.sh @@ -0,0 +1,188 @@ +#!/usr/bin/env bash +set -euo pipefail + +repository_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P) +guard="$repository_root/tools/require-base-config.sh" +terminal_url=https://terminal.invalid/rpc +public_url=https://public.invalid/rpc +account=demo-account +sender=0x1111111111111111111111111111111111111111 +recipient=0x2222222222222222222222222222222222222222 + +run_guard() { + local mode=${1-deploy} + shift || true + env -i PATH="$PATH" \ + BASE_SEPOLIA_RPC_URL="$terminal_url" \ + BASE_SEPOLIA_PUBLIC_RPC_URL="$public_url" \ + BASE_SEPOLIA_ACCOUNT="$account" \ + BASE_SEPOLIA_SENDER="$sender" \ + BASE_SEPOLIA_RECIPIENT="$recipient" \ + "$@" bash "$guard" "$mode" +} + +assert_no_config_values() { + local output=$1 + shift + local value + for value in "$terminal_url" "$public_url" "$account" "$sender" "$recipient" "$@"; do + [[ -n $value ]] || continue + if [[ $output == *"$value"* ]]; then + echo "configuration guard leaked a configured value" >&2 + exit 1 + fi + done +} + +expect_rejected() { + local assignment=$1 + local output + if output=$(run_guard deploy "$assignment" 2>&1); then + echo "configuration guard accepted invalid fixture: ${assignment%%=*}" >&2 + exit 1 + fi + assert_no_config_values "$output" "${assignment#*=}" +} + +for mode in deploy upgrade transfer; do + output=$(run_guard "$mode" 2>&1) + [[ -z $output ]] || { echo "configuration guard produced output for valid fixtures" >&2; exit 1; } +done + +for assignment in \ + BASE_SEPOLIA_RPC_URL= \ + BASE_SEPOLIA_PUBLIC_RPC_URL= \ + BASE_SEPOLIA_ACCOUNT= \ + BASE_SEPOLIA_SENDER= \ + BASE_SEPOLIA_RECIPIENT= \ + BASE_SEPOLIA_ACCOUNT='bad account' \ + BASE_SEPOLIA_SENDER=0x1234 \ + BASE_SEPOLIA_RECIPIENT=0x0000000000000000000000000000000000000000 \ + BASE_SEPOLIA_RECIPIENT="$sender" \ + BASE_SEPOLIA_RPC_URL=http://terminal.invalid \ + BASE_SEPOLIA_PUBLIC_RPC_URL=http://public.invalid \ + BASE_SEPOLIA_PUBLIC_RPC_URL=https://user@public.invalid \ + BASE_SEPOLIA_PUBLIC_RPC_URL='https://public.invalid/?api_key=fixture' \ + BASE_SEPOLIA_PUBLIC_RPC_URL='https://public.invalid/?token=fixture'; do + expect_rejected "$assignment" +done + +private_key_name=PRIVATE_$(printf KEY) +mnemonic_name=MNEM$(printf ONIC) +fake_key=0x$(printf 'a%.0s' {1..64}) +output=$( + env -i PATH="$PATH" "$private_key_name=$fake_key" "$mnemonic_name=fixture words only" \ + bash "$guard" deploy 2>&1 || true +) +if [[ $output != *BASE_SEPOLIA_RPC_URL* ]]; then + echo "configuration guard unexpectedly accepted a raw-key or mnemonic fallback" >&2 + exit 1 +fi +assert_no_config_values "$output" + +dry_run=$( + make -n -C "$repository_root" deploy-base-sepolia \ + BASE_SEPOLIA_RPC_URL=https://terminal.invalid \ + BASE_SEPOLIA_PUBLIC_RPC_URL=https://public.invalid \ + BASE_SEPOLIA_ACCOUNT=demo \ + BASE_SEPOLIA_SENDER=0x1111111111111111111111111111111111111111 \ + BASE_SEPOLIA_RECIPIENT=0x2222222222222222222222222222222222222222 +) +[[ $dry_run == *'--account "demo"'* ]] || { echo "Base dry run omitted --account" >&2; exit 1; } +[[ $dry_run == *'--sender "0x1111111111111111111111111111111111111111"'* ]] || \ + { echo "Base dry run omitted --sender" >&2; exit 1; } +[[ $dry_run == *'--slow'* ]] || { echo "Base dry run omitted --slow" >&2; exit 1; } +if [[ ${dry_run,,} == *private-key* || ${dry_run,,} == *mnemonic* ]]; then + echo "Base dry run exposed a raw signing option" >&2 + exit 1 +fi + +node --input-type=module - "$repository_root" <<'NODE' +import assert from "node:assert/strict"; +import { access, mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; + +const repositoryRoot = process.argv[2]; +const { archiveManifest, selectManifest } = await import(pathToFileURL(join(repositoryRoot, "tools/select-manifest.mjs"))); +const { publishManifest } = await import(pathToFileURL(join(repositoryRoot, "tools/publish-web-manifest.mjs"))); +const root = await mkdtemp(join(tmpdir(), "uups-base-config-")); +const deployments = join(root, "deployments"); +const webManifest = join(root, "web/public/deployment.json"); +const owner = "0x1111111111111111111111111111111111111111"; +const recipient = "0x2222222222222222222222222222222222222222"; +const base = { + schemaVersion: 1, + network: "baseSepolia", + chainId: 84532, + deploymentBlock: 99, + rpcUrl: "https://public.invalid/rpc", + explorerBaseUrl: "https://sepolia.basescan.org", + token: "0x3333333333333333333333333333333333333333", + proxy: "0x4444444444444444444444444444444444444444", + implementation: "0x5555555555555555555555555555555555555555", + owner, + actors: [ + { label: "Presenter", address: owner }, + { label: "Recipient", address: recipient }, + ], +}; +const anvil = { + ...base, + network: "anvil", + chainId: 31337, + rpcUrl: "http://127.0.0.1:8545", + explorerBaseUrl: undefined, + actors: [ + { label: "owner", address: "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266" }, + { label: "Alice", address: "0x70997970C51812dc3A010C7d01b50e0d17dc79C8" }, + { label: "Bob", address: "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC" }, + ], + owner: "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", +}; +delete anvil.explorerBaseUrl; + +try { + await mkdir(deployments, { recursive: true }); + await writeFile(join(deployments, "base-sepolia.json"), `${JSON.stringify(base)}\n`); + await writeFile(join(deployments, "anvil.json"), `${JSON.stringify(anvil)}\n`); + await selectManifest({ root, network: "baseSepolia" }); + const activeBefore = await readFile(join(deployments, "active.json")); + const anvilBefore = await readFile(join(deployments, "anvil.json")); + const published = await publishManifest({ + activePath: join(deployments, "active.json"), + outputPath: webManifest, + }); + assert.equal(published.rpcUrl, "https://public.invalid/rpc"); + assert.equal(published.explorerBaseUrl, "https://sepolia.basescan.org"); + assert.equal((await readFile(webManifest, "utf8")).includes("terminal.invalid"), false); + for (const invalid of [ + { ...base, rpcUrl: "http://public.invalid/rpc" }, + { ...base, explorerBaseUrl: "https://example.invalid" }, + ]) { + await writeFile(join(deployments, "active.json"), `${JSON.stringify(invalid)}\n`); + await assert.rejects( + () => publishManifest({ activePath: join(deployments, "active.json"), outputPath: webManifest }), + /HTTPS|BaseScan/, + ); + } + await writeFile(join(deployments, "active.json"), activeBefore); + + const archived = await archiveManifest({ + root, + network: "baseSepolia", + now: new Date("2026-08-21T12:34:56.789Z"), + }); + assert.equal(archived.path, join(deployments, "base-sepolia.20260821T123456789Z.json")); + await access(archived.path); + await assert.rejects(() => access(join(deployments, "base-sepolia.json"))); + assert.deepEqual(await readFile(join(deployments, "active.json")), activeBefore); + assert.deepEqual(await readFile(join(deployments, "anvil.json")), anvilBefore); + await assert.rejects(() => archiveManifest({ root, network: "anvil" }), /baseSepolia/); +} finally { + await rm(root, { recursive: true, force: true }); +} +NODE + +echo "Base configuration and manifest filesystem tests passed" From 6dee960d155c14e9c4689138d64633f5207d2d38 Mon Sep 17 00:00:00 2001 From: golem Date: Fri, 21 Aug 2026 16:16:12 -0600 Subject: [PATCH 23/30] fix: harden Base manifest recovery --- README.md | 2 + docs/PRESENTER_RUNBOOK.md | 2 + tools/finalize-manifest.mjs | 22 +++++++---- tools/publish-web-manifest.mjs | 15 ++++++-- tools/select-manifest.mjs | 20 ++++++---- tools/test-base-config.sh | 64 +++++++++++++++++++++++++++++--- tools/test-finalize-manifest.mjs | 26 +++++++++---- 7 files changed, 119 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index c796461..6c97164 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # UUPS Bank V1 Demo +> Educational demo — mock token — never use real funds. + This repository is the prepared starting point for a live upgradeability lesson. It deploys a local V1 bank that custodies a six-decimal mock ERC-20, records customer balances behind an ERC-1967 proxy, and presents the result in a read-only operations console. > **Trust boundary:** MockUSDC has no value. These contracts are educational and unaudited; real deposits must never be sent here. The owner can pause customer actions and install arbitrary future logic. UUPS mistakes can corrupt state or permanently brick upgradeability. A real custody product requires professional audits, operational key controls, multisig or timelocked governance, incident procedures, legal advice, and jurisdiction-specific compliance work. diff --git a/docs/PRESENTER_RUNBOOK.md b/docs/PRESENTER_RUNBOOK.md index a3761b0..9ac08e0 100644 --- a/docs/PRESENTER_RUNBOOK.md +++ b/docs/PRESENTER_RUNBOOK.md @@ -1,5 +1,7 @@ # Presenter Runbook: Prepared V1 and Live UUPS Upgrade +> Educational demo — mock token — never use real funds. + ## Preflight and rehearsal Rehearse once from a clean disposable branch created at the `demo-start` tag. Allow 25 minutes: 3 minutes for preflight, 5 for Act 1, 10 for the Codex change and verification, 4 for Act 3, and 3 for questions. Keep two terminals visible and open the browser only after Vite reports ready. diff --git a/tools/finalize-manifest.mjs b/tools/finalize-manifest.mjs index 18879f0..b1ab29f 100644 --- a/tools/finalize-manifest.mjs +++ b/tools/finalize-manifest.mjs @@ -363,8 +363,15 @@ function assertManifestUrls(manifest, spec) { if (Object.hasOwn(manifest, "explorerBaseUrl")) throw new Error("manifest anvil must omit explorerBaseUrl"); return; } - for (const field of OPTIONAL_MANIFEST_FIELDS) { - if (Object.hasOwn(manifest, field)) assertPublicUrl(manifest[field], field); + for (const field of ["rpcUrl", "explorerBaseUrl"]) { + if (!Object.hasOwn(manifest, field)) throw new Error(`manifest baseSepolia is missing ${field}`); + assertPublicUrl(manifest[field], field); + } + if (new URL(manifest.rpcUrl).protocol !== "https:") { + throw new Error("manifest Base Sepolia rpcUrl must use HTTPS"); + } + if (manifest.explorerBaseUrl !== "https://sepolia.basescan.org") { + throw new Error("manifest Base Sepolia explorerBaseUrl must use the BaseScan root"); } } @@ -398,14 +405,13 @@ function assertActorConfiguration(manifest) { throw new Error("manifest anvil actors must match the documented local actor configuration"); } } else { - const configured = actors.length === 2 && actors[0].label === "Presenter" && actors[1].label === "Recipient"; - const legacy = actors.length === 1 && actors[0].label === "owner" - && !Object.hasOwn(manifest, "rpcUrl") && !Object.hasOwn(manifest, "explorerBaseUrl"); - if (!configured && !legacy) { - throw new Error("manifest baseSepolia actors must be Presenter and Recipient"); + if (actors.length !== 2 || actors[0].label !== "Presenter" || actors[1].label !== "Recipient") { + throw new Error("manifest baseSepolia actors must contain exactly Presenter and Recipient"); } } - if (actors[0].address.toLowerCase() !== owner.toLowerCase()) throw new Error("manifest owner must be actor zero"); + if (actors[0].address.toLowerCase() !== owner.toLowerCase()) { + throw new Error(`manifest owner must be the ${network === "baseSepolia" ? "Presenter" : "first actor"}`); + } } function rejectProhibitedStringValues(value, path = "") { diff --git a/tools/publish-web-manifest.mjs b/tools/publish-web-manifest.mjs index d323761..2256297 100644 --- a/tools/publish-web-manifest.mjs +++ b/tools/publish-web-manifest.mjs @@ -35,11 +35,20 @@ export function validatePublicManifest(manifest) { assertActors(manifest.actors); for (const field of optionalFields) if (Object.hasOwn(manifest, field)) assertPublicUrl(manifest[field], field); if (manifest.network === "baseSepolia") { - if (Object.hasOwn(manifest, "rpcUrl") && new URL(manifest.rpcUrl).protocol !== "https:") { + for (const field of ["rpcUrl", "explorerBaseUrl"]) { + if (!Object.hasOwn(manifest, field)) throw new Error(`manifest baseSepolia is missing ${field}`); + } + if (new URL(manifest.rpcUrl).protocol !== "https:") { throw new Error("manifest Base Sepolia rpcUrl must use HTTPS"); } - if (Object.hasOwn(manifest, "explorerBaseUrl") && manifest.explorerBaseUrl !== "https://sepolia.basescan.org") { - throw new Error("manifest Base Sepolia explorerBaseUrl must use BaseScan"); + if (manifest.explorerBaseUrl !== "https://sepolia.basescan.org") { + throw new Error("manifest Base Sepolia explorerBaseUrl must use the BaseScan root"); + } + if (manifest.actors.length !== 2 || manifest.actors[0].label !== "Presenter" || manifest.actors[1].label !== "Recipient") { + throw new Error("manifest baseSepolia actors must contain exactly Presenter and Recipient"); + } + if (manifest.owner.toLowerCase() !== manifest.actors[0].address.toLowerCase()) { + throw new Error("manifest owner must be the Presenter"); } } } diff --git a/tools/select-manifest.mjs b/tools/select-manifest.mjs index bfe7f94..62036ad 100644 --- a/tools/select-manifest.mjs +++ b/tools/select-manifest.mjs @@ -1,4 +1,4 @@ -import { access, readFile, rename } from "node:fs/promises"; +import { link, readFile, rm } from "node:fs/promises"; import { join, resolve } from "node:path"; import { pathToFileURL } from "node:url"; @@ -14,7 +14,7 @@ export async function selectManifest({ root = process.cwd(), network }) { return { source, active }; } -export async function archiveManifest({ root = process.cwd(), network, now = new Date() }) { +export async function archiveManifest({ root = process.cwd(), network, now = new Date(), io = {} }) { if (network !== "baseSepolia") throw new Error("only baseSepolia may be archived"); const spec = networkSpec(network); const source = join(root, "deployments", spec.canonical); @@ -23,16 +23,20 @@ export async function archiveManifest({ root = process.cwd(), network, now = new const timestamp = now.toISOString().replace(/[-:.]/g, ""); if (!/^\d{8}T\d{9}Z$/.test(timestamp)) throw new Error("archive timestamp is invalid"); const target = join(root, "deployments", `base-sepolia.${timestamp}.json`); + const operations = { link, rm, ...io }; try { - await access(target); + await operations.link(source, target); } catch (error) { - if (error.code === "ENOENT") { - await rename(source, target); - return { source, path: target }; - } + if (error.code === "EEXIST") throw new Error("refusing to overwrite an existing Base Sepolia archive"); throw error; } - throw new Error("refusing to overwrite an existing Base Sepolia archive"); + try { + await operations.rm(source); + } catch (error) { + await operations.rm(target, { force: true }).catch(() => {}); + throw error; + } + return { source, path: target }; } export async function runSelectCli(argv, { root = process.cwd(), log = console.log } = {}) { diff --git a/tools/test-base-config.sh b/tools/test-base-config.sh index 1a11a31..f8a940a 100755 --- a/tools/test-base-config.sh +++ b/tools/test-base-config.sh @@ -106,7 +106,8 @@ import { pathToFileURL } from "node:url"; const repositoryRoot = process.argv[2]; const { archiveManifest, selectManifest } = await import(pathToFileURL(join(repositoryRoot, "tools/select-manifest.mjs"))); -const { publishManifest } = await import(pathToFileURL(join(repositoryRoot, "tools/publish-web-manifest.mjs"))); +const { validateManifest } = await import(pathToFileURL(join(repositoryRoot, "tools/finalize-manifest.mjs"))); +const { publishManifest, validatePublicManifest } = await import(pathToFileURL(join(repositoryRoot, "tools/publish-web-manifest.mjs"))); const root = await mkdtemp(join(tmpdir(), "uups-base-config-")); const deployments = join(root, "deployments"); const webManifest = join(root, "web/public/deployment.json"); @@ -157,14 +158,29 @@ try { assert.equal(published.rpcUrl, "https://public.invalid/rpc"); assert.equal(published.explorerBaseUrl, "https://sepolia.basescan.org"); assert.equal((await readFile(webManifest, "utf8")).includes("terminal.invalid"), false); - for (const invalid of [ - { ...base, rpcUrl: "http://public.invalid/rpc" }, - { ...base, explorerBaseUrl: "https://example.invalid" }, - ]) { + const without = (field) => { + const manifest = { ...base }; + delete manifest[field]; + return manifest; + }; + const invalidCases = [ + ["missing rpcUrl", without("rpcUrl"), /rpcUrl/], + ["missing explorerBaseUrl", without("explorerBaseUrl"), /explorerBaseUrl/], + ["wrong actor count", { ...base, actors: [base.actors[0]] }, /exactly Presenter and Recipient|actor configuration/], + ["wrong actor labels", { ...base, actors: [{ ...base.actors[0], label: "Owner" }, base.actors[1]] }, /Presenter and Recipient|actor configuration/], + ["owner is not Presenter", { ...base, owner: recipient }, /owner.*Presenter|first actor/], + ["HTTP public RPC", { ...base, rpcUrl: "http://public.invalid/rpc" }, /HTTPS/], + ["credential-bearing public RPC", { ...base, rpcUrl: "https://user@public.invalid/rpc" }, /credentials|prohibited secret/], + ["wrong BaseScan root", { ...base, explorerBaseUrl: "https://example.invalid" }, /BaseScan/], + ]; + for (const [label, invalid, rejection] of invalidCases) { + assert.throws(() => validateManifest(invalid), rejection, `confirmation accepted ${label}`); + assert.throws(() => validatePublicManifest(invalid), rejection, `publication validation accepted ${label}`); await writeFile(join(deployments, "active.json"), `${JSON.stringify(invalid)}\n`); await assert.rejects( () => publishManifest({ activePath: join(deployments, "active.json"), outputPath: webManifest }), - /HTTPS|BaseScan/, + rejection, + `publication accepted ${label}`, ); } await writeFile(join(deployments, "active.json"), activeBefore); @@ -179,6 +195,42 @@ try { await assert.rejects(() => access(join(deployments, "base-sepolia.json"))); assert.deepEqual(await readFile(join(deployments, "active.json")), activeBefore); assert.deepEqual(await readFile(join(deployments, "anvil.json")), anvilBefore); + + const canonicalPath = join(deployments, "base-sepolia.json"); + const canonicalBytes = Buffer.from(`${JSON.stringify(base)}\n`); + const existingBytes = Buffer.from("existing archive bytes\n"); + const collisionDate = new Date("2026-08-21T12:35:56.789Z"); + const collisionPath = join(deployments, "base-sepolia.20260821T123556789Z.json"); + await writeFile(canonicalPath, canonicalBytes); + await writeFile(collisionPath, existingBytes); + await assert.rejects( + () => archiveManifest({ root, network: "baseSepolia", now: collisionDate }), + /existing Base Sepolia archive/, + ); + assert.deepEqual(await readFile(canonicalPath), canonicalBytes); + assert.deepEqual(await readFile(collisionPath), existingBytes); + + const boundaryDate = new Date("2026-08-21T12:36:56.789Z"); + const boundaryPath = join(deployments, "base-sepolia.20260821T123656789Z.json"); + const boundaryBytes = Buffer.from("archive created by racing process\n"); + await assert.rejects( + () => archiveManifest({ + root, + network: "baseSepolia", + now: boundaryDate, + io: { + link: async (_source, target) => { + await writeFile(target, boundaryBytes); + const error = new Error("collision at link boundary"); + error.code = "EEXIST"; + throw error; + }, + }, + }), + /existing Base Sepolia archive/, + ); + assert.deepEqual(await readFile(canonicalPath), canonicalBytes); + assert.deepEqual(await readFile(boundaryPath), boundaryBytes); await assert.rejects(() => archiveManifest({ root, network: "anvil" }), /baseSepolia/); } finally { await rm(root, { recursive: true, force: true }); diff --git a/tools/test-finalize-manifest.mjs b/tools/test-finalize-manifest.mjs index 762a028..bd2c97a 100644 --- a/tools/test-finalize-manifest.mjs +++ b/tools/test-finalize-manifest.mjs @@ -45,7 +45,7 @@ test("direct manifest CLIs print the exact educational warning", async () => { }); }); -test("finalizer confirms a nested public actor manifest and preserves omitted Base URLs", async () => { +test("finalizer confirms the exact Base public URLs and Presenter/Recipient actors", async () => { await withFixture(async (root) => { const pending = manifest({ network: "baseSepolia", chainId: 84532, deploymentBlock: 0 }); await writeJson(join(root, "deployments", "pending.json"), pending); @@ -53,9 +53,12 @@ test("finalizer confirms a nested public actor manifest and preserves omitted Ba const output = await finalizeDeployment({ root, rpc: fakeRpc({ chainId: "0x14a34" }) }); assert.deepEqual(output.manifest, { ...pending, deploymentBlock: 42 }); - assert.equal(Object.hasOwn(output.manifest, "rpcUrl"), false); - assert.equal(Object.hasOwn(output.manifest, "explorerBaseUrl"), false); - assert.deepEqual(output.manifest.actors, [{ label: "owner", address: OWNER }]); + assert.equal(output.manifest.rpcUrl, "https://sepolia.base.org"); + assert.equal(output.manifest.explorerBaseUrl, "https://sepolia.basescan.org"); + assert.deepEqual(output.manifest.actors, [ + { label: "Presenter", address: OWNER }, + { label: "Recipient", address: "0x70997970C51812dc3A010C7d01b50e0d17dc79C8" }, + ]); }); }); @@ -145,6 +148,7 @@ test("upgrade finalizer verifies receipt, event, slot, artifact-driven state and const output = await finalizeUpgrade({ root, rpc: fakeUpgradeRpc() }); assert.equal(output.mode, "upgrade"); + assert.equal(output.upgradeBlock, 90); assert.deepEqual(output.manifest, { ...before, implementation: V2_IMPLEMENTATION }); for (const name of ["anvil.json", "active.json"]) { const confirmed = await readJson(join(root, "deployments", name)); @@ -177,6 +181,7 @@ test("upgrade finalizer rejects proxy, deployment-block, and actor identity muta test("upgrade finalizer leaves confirmed files untouched for failed receipt, missing event, live mismatch, and unknown mode", async () => { const cases = [ ["failed receipt", fakeUpgradeRpc({ receiptStatus: "0x0" }), null, /successful/], + ["zero receipt block", fakeUpgradeRpc({ receiptBlock: "0x0" }), null, /nonzero/], ["missing Upgraded event", fakeUpgradeRpc({ omitUpgradeLog: true }), null, /Upgraded/], ["wrong live version", fakeUpgradeRpc({ version: 1n }), null, /version/], ["slot mismatch", fakeUpgradeRpc({ slot: IMPLEMENTATION }), null, /slot/], @@ -300,12 +305,19 @@ function manifest(overrides = {}) { network: baseSepolia ? "baseSepolia" : "anvil", chainId: baseSepolia ? 84532 : 31337, deploymentBlock: 1, - ...(baseSepolia ? {} : { rpcUrl: "http://127.0.0.1:8545" }), + ...(baseSepolia + ? { rpcUrl: "https://sepolia.base.org", explorerBaseUrl: "https://sepolia.basescan.org" } + : { rpcUrl: "http://127.0.0.1:8545" }), token: TOKEN, proxy: PROXY, implementation: IMPLEMENTATION, owner: OWNER, - actors: baseSepolia ? [{ label: "owner", address: OWNER }] : localActors(), + actors: baseSepolia + ? [ + { label: "Presenter", address: OWNER }, + { label: "Recipient", address: "0x70997970C51812dc3A010C7d01b50e0d17dc79C8" }, + ] + : localActors(), ...overrides, }; } @@ -456,7 +468,7 @@ function fakeUpgradeRpc(overrides = {}) { } if (method === "eth_getTransactionReceipt") return { status: overrides.receiptStatus ?? "0x1", - blockNumber: "0x5a", + blockNumber: overrides.receiptBlock ?? "0x5a", logs: overrides.omitUpgradeLog || params[0] === DECLARED_CALL_HASH ? [] : [{ address: PROXY, topics: [UPGRADED_TOPIC, wordForRpc(V2_IMPLEMENTATION)], data: "0x" }], }; From a701216d46905783df56bf15b5644217f9d3e089 Mon Sep 17 00:00:00 2001 From: golem Date: Fri, 21 Aug 2026 16:23:01 -0600 Subject: [PATCH 24/30] fix: preserve archive on unlink failure --- tools/select-manifest.mjs | 6 ++++-- tools/test-base-config.sh | 22 ++++++++++++++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/tools/select-manifest.mjs b/tools/select-manifest.mjs index 62036ad..1cab274 100644 --- a/tools/select-manifest.mjs +++ b/tools/select-manifest.mjs @@ -33,8 +33,10 @@ export async function archiveManifest({ root = process.cwd(), network, now = new try { await operations.rm(source); } catch (error) { - await operations.rm(target, { force: true }).catch(() => {}); - throw error; + throw new Error( + `archive created at ${target}, but source ${source} could not be removed; both paths were preserved: ${error.message}`, + { cause: error }, + ); } return { source, path: target }; } diff --git a/tools/test-base-config.sh b/tools/test-base-config.sh index f8a940a..fca7dca 100755 --- a/tools/test-base-config.sh +++ b/tools/test-base-config.sh @@ -231,6 +231,28 @@ try { ); assert.deepEqual(await readFile(canonicalPath), canonicalBytes); assert.deepEqual(await readFile(boundaryPath), boundaryBytes); + + const unlinkFailureDate = new Date("2026-08-21T12:37:56.789Z"); + const unlinkFailurePath = join(deployments, "base-sepolia.20260821T123756789Z.json"); + await assert.rejects( + () => archiveManifest({ + root, + network: "baseSepolia", + now: unlinkFailureDate, + io: { + rm: async (path, options) => { + if (path === canonicalPath) { + assert.deepEqual(await readFile(unlinkFailurePath), canonicalBytes); + throw new Error("source unlink failed"); + } + return rm(path, options); + }, + }, + }), + /source unlink failed/, + ); + assert.deepEqual(await readFile(canonicalPath), canonicalBytes); + assert.deepEqual(await readFile(unlinkFailurePath), canonicalBytes); await assert.rejects(() => archiveManifest({ root, network: "anvil" }), /baseSepolia/); } finally { await rm(root, { recursive: true, force: true }); From d29dc86383de5860d42c72cc8ff51d96eb192459 Mon Sep 17 00:00:00 2001 From: golem Date: Sat, 22 Aug 2026 21:00:47 -0600 Subject: [PATCH 25/30] docs: finish UUPS bank demo guide --- README.md | 51 ++++++++---- docs/LEARNING_GUIDE.md | 161 +++++++++++++++++++++++++++----------- docs/PRESENTER_RUNBOOK.md | 119 +++++++++++++++++++++------- 3 files changed, 241 insertions(+), 90 deletions(-) diff --git a/README.md b/README.md index 6c97164..144b7ce 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ -# UUPS Bank V1 Demo +# UUPS Bank V1 → V2 Demo > Educational demo — mock token — never use real funds. -This repository is the prepared starting point for a live upgradeability lesson. It deploys a local V1 bank that custodies a six-decimal mock ERC-20, records customer balances behind an ERC-1967 proxy, and presents the result in a read-only operations console. +This repository is a local-first upgradeability lesson. It deploys a six-decimal mock ERC-20 and a V1 custody ledger behind an ERC-1967 proxy, upgrades the proxy to V2 without losing state, performs a customer-to-customer ledger transfer, and presents the result in a read-only operations console. > **Trust boundary:** MockUSDC has no value. These contracts are educational and unaudited; real deposits must never be sent here. The owner can pause customer actions and install arbitrary future logic. UUPS mistakes can corrupt state or permanently brick upgradeability. A real custody product requires professional audits, operational key controls, multisig or timelocked governance, incident procedures, legal advice, and jurisdiction-specific compliance work. @@ -19,46 +19,63 @@ make doctor ## Ten-minute local quick start +This complete path is local. It needs no Base Sepolia service, browser wallet, faucet, explorer, public RPC, keystore, secret, or real funds. + In the first terminal: ```bash make demo-local ``` -The command performs a scoped reset, starts a deterministic Anvil chain at `http://127.0.0.1:8545`, deploys and seeds V1, checks its exact state, exports public artifacts, and starts the console at `http://127.0.0.1:5173/`. It remains attached so Ctrl-C safely stops only the recorded project process groups. +The command performs a scoped reset, starts deterministic Anvil at `http://127.0.0.1:8545`, deploys and seeds V1, checks its exact state, exports public artifacts, and starts the console at `http://127.0.0.1:5173/`. It remains attached so Ctrl-C safely stops only the recorded project process groups. In a second terminal: ```bash DEMO_EXPECTED_STAGE=v1 make check-state +make upgrade-v2 +make demo-transfer +DEMO_EXPECTED_STAGE=v2 make check-state curl --fail http://127.0.0.1:5173/ ``` -The state check proves Alice has `900 mUSDC`, Bob has `500 mUSDC`, liabilities and reserves are both `1,400 mUSDC`, and the contract version is `1`. After Ctrl-C in the first terminal, run `make reset-local` to remove reproducible local state. +Act 1 proves Alice has `900 mUSDC`, Bob has `500 mUSDC`, liabilities and reserves are both `1,400 mUSDC`, and version is `1`. The upgrade keeps the proxy, owner, asset, pause state, customer balances, liabilities, reserves, surplus, and deployment block unchanged while changing the implementation and version to `2`. The internal `250 mUSDC` transfer then leaves Alice with `650 mUSDC` and Bob with `750 mUSDC`; liabilities and reserves remain `1,400 mUSDC`, and the console decodes `BalanceTransferred` without an ERC-20 reserve transfer. -## Architecture +Stop the attached first terminal with Ctrl-C, then remove only reproducible local state: -Foundry scripts are the state-changing control plane; the browser never signs. `MockUSDC` holds no value. An ERC-1967 proxy keeps the bank address and storage stable while delegating calls to `BankV1`. The proxy itself holds token reserves and the internal ledger records liabilities. A confirmed public deployment manifest and generated ABI connect that on-chain system to a React/Vite console using viem and wagmi for read-only, block-consistent state and event display. +```bash +make reset-local +``` + +## Architecture and guarantees + +Foundry scripts are the state-changing control plane; the browser has no connector, signer, transaction client, or write control. `MockUSDC` holds no value. An ERC-1967 proxy keeps the bank address and storage stable while delegating calls to `BankV1` or `BankV2`. The proxy holds token reserves and the internal ledger records liabilities. Confirmed public manifests and generated ABIs connect that system to the React/Vite console through viem and wagmi. + +OpenZeppelin supplies the UUPS and access-control primitives plus storage/upgrade validation. Foundry supplies compilation, tests, scripts, and the local chain. The project supplies the application logic, tests, manifests, lifecycle tooling, and UI. Passing those checks does not make the demo audited, decentralized, or suitable for custody. ## Command reference -- `make doctor` — read-only prerequisite, dependency, directory, and port checks. +- `make doctor` — check prerequisites, dependencies, runtime locations, configuration, and local ports without changing chain state. - `make setup` — initialize pinned submodules and npm dependencies. -- `make demo-local` — run the complete attached V1 experience. -- `make verify` — run formatting, clean build, artifact checks, upgrade CLI check, Solidity tests, script tests, process tests, project scan, web lint/typecheck/tests, and production build. -- `make check-state` — validate and print the active local V1 state at `http://127.0.0.1:8545`. +- `make demo-local` — run the attached local V1 experience. +- `make verify` — run Solidity format/build/unit/fuzz/invariant/upgrade gates, artifact and shell-safety tests, the project scanner, and web lint/type/tests/build. +- `make check-state` — print and validate the selected stage; use `DEMO_EXPECTED_STAGE=v1` or `v2` for the exact local acts. +- `make upgrade-v2` — validate and perform the owner-authorized local V1-to-V2 upgrade, finalize its manifest, and refresh exported artifacts. +- `make demo-transfer` — perform Alice's local `250 mUSDC` internal transfer to Bob and validate Act 3. - `make reset-local` — validate recorded process identity, stop only owned groups, and remove only known local artifacts. -- `make deploy-v1` — lower-level guarded V1 deployment and manifest finalization. -- `make seed-v1` — lower-level deterministic Act 1 deposits and withdrawal. -- `make sync-artifacts` — regenerate the ABI module and publish the confirmed active manifest. -- `make sync-artifacts-check` — test generation and prove generated ABIs are current. +- `make deploy-v1` and `make seed-v1` — run the lower-level guarded V1 deployment and deterministic Act 1 setup used by `make demo-local`. +- `make sync-artifacts`, `make sync-artifacts-check`, `make sync-abis`, and `make publish-web-manifest` — regenerate or validate the ABI bridge and publish only the confirmed active manifest. +- `make test-finalize-manifest` — exercise manifest finalization in isolation. +- `make select-anvil` and `make select-base-sepolia` — explicitly select and republish an existing confirmed manifest. +- `make archive-base-manifest` — safely archive the Base canonical manifest before an intentional redeployment. +- `make deploy-base-sepolia`, `make upgrade-base-sepolia`, and `make transfer-base-sepolia` — explicit optional-testnet commands with chain, actor, account, and RPC guards. ## Optional Base Sepolia encore -The local lesson is complete without a wallet, faucet, explorer, or public RPC. An optional Base Sepolia encore is available only after the local V1→V2 demo and `make verify` succeed. It uses a named encrypted Foundry keystore through `--account` plus the same public address through `--sender`; there is no private-key or mnemonic fallback. +The local lesson above is complete without a wallet, faucet, explorer, or public RPC. The optional Base Sepolia encore is separate and runs only after the local V1→V2 demo and `make verify` succeed. It uses a named encrypted Foundry keystore through `--account` and the same public address through `--sender`; no supported command accepts a raw private key or mnemonic. -Copy `.env.example` to `.env` and fill only its public configuration. `BASE_SEPOLIA_RPC_URL` is the terminal endpoint and may be credentialed; `BASE_SEPOLIA_PUBLIC_RPC_URL` is intentionally public and is the only RPC serialized for the browser. Foundry requests the keystore password interactively, and the password never belongs in `.env`. +Copy `.env.example` to `.env` and fill only its public configuration. `BASE_SEPOLIA_RPC_URL` is the terminal endpoint and may be credentialed; `BASE_SEPOLIA_PUBLIC_RPC_URL` is intentionally public and is the only RPC serialized for the browser. Foundry requests the keystore password interactively, and that password never belongs in `.env`. -Use `make archive-base-manifest` before an intentional Base redeployment. The command moves only the Base canonical manifest to a timestamped sibling; it preserves the active browser fallback and all Anvil state. `make select-anvil` and `make select-base-sepolia` explicitly switch the active manifest and republish the browser copy. See the presenter runbook for the exact wallet onboarding, funding boundary, deploy/upgrade/transfer sequence, and recovery rules. +Use `make archive-base-manifest` before an intentional Base redeployment. It moves only the Base canonical manifest to a timestamped sibling while preserving the active browser fallback and Anvil state. See the [presenter runbook](docs/PRESENTER_RUNBOOK.md) for the exact optional onboarding, funding boundary, execution sequence, and recovery rules. Continue with the [learning guide](docs/LEARNING_GUIDE.md) or rehearse from the [presenter runbook](docs/PRESENTER_RUNBOOK.md). diff --git a/docs/LEARNING_GUIDE.md b/docs/LEARNING_GUIDE.md index 138ad58..e935bf5 100644 --- a/docs/LEARNING_GUIDE.md +++ b/docs/LEARNING_GUIDE.md @@ -1,76 +1,153 @@ # Learning Guide: Custody Accounting Behind a UUPS Proxy +> Educational demo — mock token — never use real funds. + ## The two addresses that make upgrades possible -Users call the **proxy**, whose address remains stable and whose storage contains the asset address, customer ledger, liabilities, owner, and pause state. The **implementation** contains executable logic. The proxy forwards each application call with `delegatecall`: implementation code runs in the proxy's context, so `address(this)` is the proxy and reads/writes affect proxy storage. Calling the implementation directly is not equivalent and is never the supported application path. +Users and scripts call the **proxy**, whose address remains stable and whose storage contains the asset address, customer ledger, liabilities, owner, and pause state. The **implementation** contains executable logic. The proxy forwards each application call with `delegatecall`: implementation code runs in the proxy's context, so `address(this)` is the proxy and all application reads and writes affect proxy storage. Calling the implementation directly is not an application call. -UUPS places upgrade authorization in the implementation. OpenZeppelin's proxy-context checks ensure upgrade entry points run only through a compatible proxy; `BankV1._authorizeUpgrade` then restricts authorization to the owner. This keeps the proxy small, but makes implementation correctness and storage compatibility critical. +UUPS places upgrade authorization in the implementation. OpenZeppelin's proxy-context checks ensure upgrade entry points run only through a compatible proxy; `BankV1._authorizeUpgrade` then restricts authorization to the owner. This keeps the proxy small, but makes implementation correctness, storage compatibility, and owner security critical. ## Initializers replace constructor state -A normal implementation constructor changes only the implementation's own storage. It cannot initialize the proxy storage used by delegated calls. `initialize(asset, initialOwner)` therefore performs the one-time proxy setup and initializes ownership and pausing. The encoded initializer runs atomically when the proxy is created, avoiding an uninitialized-proxy takeover window. +A normal implementation constructor changes only the implementation's own storage. It cannot initialize the proxy storage used by delegated calls. `initialize(asset, initialOwner)` therefore performs the one-time proxy setup and initializes ownership and pausing. Its public visibility lets the pinned OpenZeppelin upgrades-core tooling recognize the initializer as inherited by `BankV2`; the `initializer` modifier still permits exactly one proxy initialization. The encoded initializer runs atomically when the proxy is created, avoiding an uninitialized-proxy takeover window. -The `BankV1` constructor calls `_disableInitializers()`. That locks the standalone implementation so an outsider cannot initialize it and create a misleading or dangerous separately owned instance. The narrow constructor annotation tells the OpenZeppelin validator why this constructor is intentional; it does not bypass storage-layout or UUPS compatibility checks. Double initialization of the proxy and direct initialization of the implementation both revert with `InvalidInitialization`. +The `BankV1` constructor calls `_disableInitializers()`. That locks the standalone implementation so an outsider cannot initialize it and create a misleading or dangerous separately owned instance. The narrow constructor annotation tells the OpenZeppelin validator why this constructor is intentional; it does not bypass storage-layout, missing-initializer, or UUPS compatibility checks. Double initialization of the proxy and direct initialization of either implementation revert with `InvalidInitialization`. -## Storage layout is an API +`BankV2` needs no initializer or reinitializer because the V1 proxy is already initialized and V2 adds behavior only. It inherits the existing asset, owner, pause state, customer balances, liabilities, and transient reentrancy guard. Adding an initializer when there is no new state would create an unnecessary privileged transition and another state to reason about. -Delegate calls interpret numbered storage slots according to the current implementation. A compatible future implementation preserves every existing declaration in its original order and consumes reserved gap space only when new state is truly required. +## Storage layout is a permanent API -Safe conceptual extension: +Delegate calls interpret storage according to the active implementation's layout. Alongside OpenZeppelin's namespaced base-contract state, the actual V1 application fields occupy these slots in source order: -```solidity -IERC20 internal _asset; // unchanged slot -mapping(address => uint256) internal _balances; // unchanged slot -uint256 internal _totalLiabilities; // unchanged slot -uint256 internal _newValue; // consumes reserved space -uint256[46] private __gap; +```text +BankV1 proxy application storage +├─ slot 0: _asset: IERC20 +├─ slot 1: _balances: mapping(address => uint256) +├─ slot 2: _totalLiabilities: uint256 +└─ slots 3…49: __gap: uint256[47] ``` -Unsafe conceptual extension: +OpenZeppelin upgradeable ownership and pausing use their own namespaced storage. `ReentrancyGuardTransient` uses transient storage rather than adding an initialized persistent field. Those implementation details do not make the application's declaration order optional. -```solidity -uint256 internal _totalLiabilities; // reordered: corrupts interpretation -IERC20 internal _asset; -mapping(address => uint256) internal _balances; +V2 is safe because inheritance preserves that layout byte-for-byte and V2 declares no state: + +```text +BankV2 is BankV1 +├─ slot 0: _asset: IERC20 unchanged +├─ slot 1: _balances: mapping(address => uint256) unchanged +├─ slot 2: _totalLiabilities: uint256 unchanged +├─ slots 3…49: __gap: uint256[47] unchanged +└─ no V2 storage variables ``` -Changing order, type, inheritance order, or removing state can make balances appear as addresses or overwrite control data. A bad implementation can also remove working upgrade machinery and permanently brick future upgrades. Layout validation is necessary, but authorization and implementation behavior still require review. +A conceptual future revision could consume one reserved word only by carefully changing the layout in the base contract that declares the gap and validating every descendant against the prior version: + +```text +Conceptual validated revision of the declaring base layout +├─ slot 0: _asset: IERC20 +├─ slot 1: _balances: mapping(address => uint256) +├─ slot 2: _totalLiabilities: uint256 +├─ slot 3: _newValue: uint256 +└─ slots 4…49: __gap: uint256[46] +``` + +Simply declaring a new `BankV2` field would append it after the already inherited `__gap`; it would not consume that gap. This demo avoids that ambiguity entirely because the actual `BankV2` declares no state. Reordering existing declarations is unsafe: + +```text +Unsafe layout +├─ slot 0: _totalLiabilities: uint256 moved into _asset's old slot +├─ slot 1: _asset: IERC20 moved into the mapping's old slot +├─ slot 2: _balances: mapping(address => uint256) moved into liabilities' old slot +└─ slots 3…49: __gap: uint256[47] +``` + +Changing order, type, inheritance order, or removing state can make balances appear as addresses, overwrite accounting, or damage control state. A bad implementation can also remove working upgrade machinery and brick future upgrades. + +`BankV2` declares `@custom:oz-upgrades-from src/BankV1.sol:BankV1`. The upgrade script sets `BankV1` as the reference contract and calls `Upgrades.validateUpgrade` before broadcasting. Foundry emits AST, build information, and storage layouts; the OpenZeppelin validator compares the inheritance and storage layouts and checks UUPS compatibility. The validation uses no storage or UUPS bypass. It is a compatibility gate, not a business-logic audit: it cannot prove that a future owner-authorized implementation is honest, solvent, or correctly governed. ## Reserves, liabilities, and surplus -**Reserves** are `MockUSDC.balanceOf(proxy)`: tokens actually held by the proxy. **Liabilities** are `totalLiabilities()`: the sum the ledger owes customers. **Surplus** is reserves minus liabilities. The solvency rule is: +**Reserves** are `MockUSDC.balanceOf(proxy)`: tokens actually held by the proxy. **Liabilities** are `totalLiabilities()`: the aggregate amount the ledger owes customers. **Surplus** is reserves minus liabilities. The solvency rule is: ```text reserves >= total liabilities ``` -Equality holds in the prepared Act 1 state. Anyone can transfer mock tokens directly to the proxy without receiving ledger credit, so a surplus is possible and the invariant deliberately uses `>=`. - -## Exact V1 money flows +Equality holds in the prepared local states. Anyone can transfer mock tokens directly to the proxy without receiving ledger credit, so a surplus is possible and the invariant deliberately uses `>=`. For `deposit(amount)`, the bank rejects zero, paused, or reentrant calls; reads reserves; uses `SafeERC20.safeTransferFrom`; measures the exact received delta; and only then credits the sender's internal balance and total liabilities. A fee-on-transfer or otherwise unexpected asset delta reverts the entire transaction. -For `withdraw(amount)`, the bank rejects zero, paused, reentrant, or underfunded ledger calls. It debits the customer's internal balance and total liabilities before `SafeERC20.safeTransfer` sends tokens. That ordering is checks-effects-interactions: validate first, commit internal effects second, interact externally last. `nonReentrant` adds a second boundary against a malicious token callback. A revert from the token rolls the whole transaction back. +For `withdraw(amount)`, the bank rejects zero, paused, reentrant, or underfunded ledger calls. It debits the customer's internal balance and total liabilities before `SafeERC20.safeTransfer` sends tokens. That ordering is checks-effects-interactions: validate first, commit internal effects second, interact externally last. `nonReentrant` adds a second boundary against a malicious token callback. A token revert rolls the whole transaction back. -`SafeERC20` handles ERC-20 implementations that return `false`, omit return values, or revert in different ways. Pausing gives the owner an emergency stop for deposits and withdrawals while views remain available. There is intentionally no owner reserve sweep. +`SafeERC20` handles ERC-20 implementations that return `false`, omit return values, or revert in different ways. Pausing gives the owner an emergency stop for deposits, withdrawals, and V2 internal transfers while views remain available. There is intentionally no owner reserve sweep. -An internal V2 customer transfer, when implemented during the presentation, moves ledger balances only. It must not move ERC-20 reserves or change aggregate liabilities. +## V2 transfer conservation proof -## The owner is a central trust assumption +`transferBalance(recipient, amount)` validates the active pause state, a nonzero amount, a nonzero recipient distinct from the sender, and sufficient sender balance. It then debits the sender, credits the recipient, and emits `BalanceTransferred(from, to, amount)`. It makes no external call, so it needs no reentrancy guard and cannot move ERC-20 tokens. -The owner may pause customer actions and authorize an implementation containing arbitrary future logic. Tests proving today's V1 behavior cannot constrain tomorrow's authorized implementation. Multisig/timelocked governance and operational controls are absent from this educational V1. +The prepared Act 3 transfers `250e6` base units (`250.000000 mUSDC`) from Alice to Bob: + +| Quantity | Before | Delta | After | +|---|---:|---:|---:| +| Alice ledger balance | `900e6` | `-250e6` | `650e6` | +| Bob ledger balance | `500e6` | `+250e6` | `750e6` | +| Tracked balance sum | `1_400e6` | `0` | `1_400e6` | +| Total liabilities | `1_400e6` | `0` | `1_400e6` | +| Proxy token reserves | `1_400e6` | `0` | `1_400e6` | +| Surplus | `0` | `0` | `0` | + +Conservation follows directly from subtracting and adding the same `amount`: `(Alice - amount) + (Bob + amount) = Alice + Bob`. The `BalanceTransferred` log proves the ledger operation. The absence of a MockUSDC `Transfer` log in that transaction, together with equal before/after proxy reserves, proves that no reserve token moved. + +## What the upgrade snapshot proves + +Before broadcasting, `UpgradeV2` records the proxy, old implementation, owner, asset, pause state, every manifest actor balance, liabilities, reserves, surplus, deployment block, and version. After the OpenZeppelin-validated owner upgrade, it takes the same snapshot and requires: + +- the proxy, owner, asset, pause state, actor count and balances, liabilities, reserves, surplus, and deployment block to be identical; +- the implementation address to differ; +- the implementation slot and manifest to identify the new implementation; and +- `contractVersion()` to change from `1` to `2`. + +The finalizer independently matches the successful broadcast and `Upgraded(newImplementation)` log, reads live proxy and token state, and only then atomically updates the confirmed manifests. Thus the upgrade proof covers both storage continuity and the identity of the logic now serving the stable proxy. + +## The owner is a central threat + +The owner can pause every customer mutation and authorize an implementation containing arbitrary future logic. A malicious or compromised owner could install code that changes balances, transfers reserves, removes checks, breaks storage, or prevents later upgrades. Tests proving today's V1 and V2 behavior cannot constrain tomorrow's authorized implementation. + +The demo deliberately uses one owner and has no multisig, timelock, role separation, upgrade delay, monitoring service, emergency governance process, or audited deployment procedure. OpenZeppelin's `onlyOwner` check proves that the configured owner authorized an upgrade; it does not prove the owner made a safe decision. A production threat model must protect the key, constrain and review upgrade proposals, make changes observable, plan incident response, and address legal and regulatory obligations. > **Trust boundary:** MockUSDC has no value. These contracts are educational and unaudited; real deposits must never be sent here. The owner can pause customer actions and install arbitrary future logic. UUPS mistakes can corrupt state or permanently brick upgradeability. A real custody product requires professional audits, operational key controls, multisig or timelocked governance, incident procedures, legal advice, and jurisdiction-specific compliance work. ## Local exercises: observe the named failures -These commands run deterministic tests and do not require a wallet or public RPC. Add `-vvvv` to inspect a revert trace. +These commands run deterministic tests and need no wallet or public RPC. Add `-vvvv` to inspect a revert trace. + +### Every V2 transfer failure + +```bash +# Inherited BankV1.ZeroAmount +forge test --match-test testTransferBalanceRejectsZeroAmount -vv + +# BankV2.InvalidRecipient(address) +forge test --match-test testTransferBalanceRejectsZeroRecipient -vv + +# BankV2.SelfTransfer() +forge test --match-test testTransferBalanceRejectsSenderAsRecipient -vv + +# Inherited BankV1.InsufficientBalance(account, available, requested) +forge test --match-test testTransferBalanceReportsAvailableAndRequestedWhenBalanceIsInsufficient -vv + +# OpenZeppelin EnforcedPause() +forge test --match-test testTransferBalanceRejectsCallsWhilePaused -vv +``` + +### V1, initialization, authorization, and script failures ```bash # BankV1.InvalidAsset forge test --match-test testInitializationChecksZeroAssetBeforeZeroOwner -vv -# BankV1.ZeroAmount on both customer paths +# BankV1.ZeroAmount on both V1 customer paths forge test --match-test 'test(Deposit|Withdraw)RejectsZeroAmount' -vv # BankV1.InsufficientBalance with available/requested values @@ -85,13 +162,13 @@ forge test --match-test 'test(Deposit|Withdraw)RejectsCallsWhilePaused' -vv # OpenZeppelin OwnableUnauthorizedAccount forge test --match-test 'testNonOwnerCannot(Pause|Unpause|AuthorizeUpgrade)' -vv -# OpenZeppelin InvalidInitialization -forge test --match-test 'test(ProxyCannotBeInitializedTwice|ImplementationCannotBeInitialized)' -vv +# OpenZeppelin InvalidInitialization on the proxy and both implementations +forge test --match-test 'test(ProxyCannotBeInitializedTwice|ImplementationCannotBeInitialized|NewImplementationCannotBeInitializedDirectly)' -vv # OpenZeppelin ReentrancyGuardReentrantCall forge test --match-test testDepositPropagatesNestedRevertAtomicallyWhenConfigured -vv -# DemoScript.UnsupportedChain (the test deliberately uses a rejected chain) +# DemoScript.UnsupportedChain forge test --match-test testUnsupportedChainsAreRejectedBeforeBroadcast -vv # DemoScript.ManifestChainMismatch and DemoScript.MissingCode @@ -103,20 +180,14 @@ forge test --match-test testActiveManifestRejectsDeploymentBlockZero -vv # DemoScript.InvalidManifestSchema forge test --match-test testLegacyParallelActorManifestIsRejected -vv -# DemoScript.InvalidActorConfiguration -forge test --match-test testAnvilManifestRequiresOwnerAtActorZero -vv +# DemoScript.InvalidActorConfiguration, including strict local and Base actor schemas +forge test --match-test 'test(AnvilManifestRequiresOwnerAtActorZero|BaseManifestRequiresExactPresenterRecipientActors)' -vv -# DemoScript.UnexpectedState, including label and expected/actual values -forge test --match-test testUnexpectedStateExerciseRevertsThroughAssertionBranch -vv +# DemoScript.UnexpectedState and DemoScript.UnexpectedAddress +forge test --match-test 'test(UnexpectedStateExerciseRevertsThroughAssertionBranch|CheckStateRejectsManifestImplementationMismatch)' -vv -# DemoScript.UnexpectedAddress, including expected/actual implementation addresses -forge test --match-test testCheckStateRejectsManifestImplementationMismatch -vv - -# CheckState.Insolvent with reserves below ledger liabilities -forge test --match-test testCheckStateExercisesRevertThroughInsolventAndUnknownStageBranches -vv - -# CheckState.UnknownStage through the real state checker +# CheckState.Insolvent and CheckState.UnknownStage forge test --match-test testCheckStateExercisesRevertThroughInsolventAndUnknownStageBranches -vv ``` -Finish by running `make verify`; it combines unit, fuzz, invariant, script, process-safety, scanner, and web gates. +Finish with `make verify`; it combines formatting, build, storage/upgrade validation, unit, fuzz, invariant, script, process-safety, scanner, and web gates. diff --git a/docs/PRESENTER_RUNBOOK.md b/docs/PRESENTER_RUNBOOK.md index 9ac08e0..dd89e1f 100644 --- a/docs/PRESENTER_RUNBOOK.md +++ b/docs/PRESENTER_RUNBOOK.md @@ -2,43 +2,56 @@ > Educational demo — mock token — never use real funds. -## Preflight and rehearsal +## Exact preflight -Rehearse once from a clean disposable branch created at the `demo-start` tag. Allow 25 minutes: 3 minutes for preflight, 5 for Act 1, 10 for the Codex change and verification, 4 for Act 3, and 3 for questions. Keep two terminals visible and open the browser only after Vite reports ready. - -Before the audience arrives: +Rehearse from a clean disposable branch or worktree created at the immutable `demo-start` tag. Use two terminals, keep the browser closed until Vite reports ready, and run this before the audience arrives: ```bash -make setup make reset-local +make setup make doctor make verify git diff --check git status --short ``` -The doctor must show Foundry `1.7.1`, Node `24.18.0`, npm `11.17.0`, initialized dependencies, writable runtime paths, and free ports `8545`/`5173`. Verification must exit `0`. No upgrade or transfer command runs until `make verify` passes. +The doctor must report Foundry `1.7.1`, Node `24.18.0`, npm `11.17.0`, initialized dependencies, writable runtime paths, and free ports `8545` and `5173`. `make verify` and `git diff --check` must exit `0`. At `demo-start`, tracked status must be clean. Do not upgrade after a partial or failed gate. -## The three-act story +## Ten-minute rehearsal + +Use this exact local timing once the dependencies are installed: + +| Time | Action | Evidence to say aloud | +|---|---|---| +| `0:00–1:00` | Run the preflight checks and start `make demo-local` in terminal 1. | Anvil is chain `31337`; Vite and Anvil are project-owned and attached. | +| `1:00–3:00` | Run the V1 state check in terminal 2 and open the console. | Stable proxy, distinct implementation, version `1`, Alice `900`, Bob `500`, reserves = liabilities = `1,400 mUSDC`. | +| `3:00–5:30` | Deliver the live Codex prompt and review the prepared Act 2 diff. | V2 inherits V1, adds no storage or initializer, and the browser remains read-only. | +| `5:30–7:00` | Point to the completed `make verify` evidence, then run `make upgrade-v2`. | All gates passed before broadcast; proxy and state remain stable while implementation and version change. | +| `7:00–9:00` | Run `make demo-transfer`, the V2 check, and the HTTP check. | Alice `650`, Bob `750`, reserves and liabilities still `1,400`; one decoded internal transfer and no token transfer. | +| `9:00–10:00` | Read the trust disclosure, Ctrl-C terminal 1, and run `make reset-local`. | The owner remains central; local cleanup targets only validated project processes and artifacts. | + +If a live Codex edit takes longer, treat this as the prepared reference rehearsal and allow a separate coding block. The verification-before-upgrade rule never changes to meet the clock. + +## Two-terminal three-act sequence ### Act 1 — Prepared V1 establishes trust -First terminal: +Terminal 1 remains attached: ```bash make demo-local ``` -Second terminal: +Terminal 2: ```bash DEMO_EXPECTED_STAGE=v1 make check-state curl --fail http://127.0.0.1:5173/ ``` -Open `http://127.0.0.1:5173/`; Anvil is at `http://127.0.0.1:8545`. Call out that every browser read targets the proxy, while the distinct implementation address is shown only to teach delegation. The exact expected state is Alice `900 mUSDC`, Bob `500 mUSDC`, liabilities `1,400 mUSDC`, reserves `1,400 mUSDC`, surplus `0`, and version `1`. Point to deposit, withdrawal, and upgrade/ownership events, then state that the browser is read-only. +Open `http://127.0.0.1:5173/`; Anvil is at `http://127.0.0.1:8545`. Call out that every application read and write targets the proxy. The implementation address is separate and displayed only to explain delegation. The expected state is Alice `900 mUSDC`, Bob `500 mUSDC`, liabilities `1,400 mUSDC`, reserves `1,400 mUSDC`, surplus `0`, pause state `false`, and version `1`. -Explain the initial flow: scripts minted 2,000 mUSDC to Alice and 1,000 to Bob; Alice deposited 1,000 and withdrew 100; Bob deposited 500. The stable proxy holds reserves and storage. Implementation code runs against that storage with `delegatecall`. +The scripts minted `2,000 mUSDC` to Alice and `1,000 mUSDC` to Bob; Alice deposited `1,000` and withdrew `100`; Bob deposited `500`. The stable proxy holds the reserves and application storage. Point to the deposits, withdrawal, initial ownership, and upgrade-to-V1 events, then state that the console is read-only and never signs. ### Act 2 — Codex changes the system live @@ -46,35 +59,85 @@ Give Codex this approved prompt verbatim: > Add `BankV2` with customer-to-customer internal transfers. Preserve the UUPS storage layout and all V1 behavior. Add unit, fuzz, invariant, and upgrade-regression tests; an owner upgrade script; a scripted Alice-to-Bob transfer; exported ABI support; and the read-only console updates needed to show V2 and its transfer event. Explain each security decision. Do not perform an upgrade until all verification passes. -Ask Codex to show the diff and explain storage compatibility, authorization, conservation, error paths, and why the browser remains read-only. The expected prepared change adds transfer-aware source, tests, scripts, ABI output, and console rendering without changing V1 storage. Run: +The bounded local Act 2 diff from `demo-start` consists of these files: + +- Contracts and direct tests: `src/BankV2.sol`, the initializer-visibility compatibility edit in `src/BankV1.sol`, `test/BankV2.t.sol`, `test/BankUpgrade.t.sol`, `test/mocks/IncompatibleBank.sol`, and `test/mocks/NonUUPSImplementation.sol`. +- Upgrade/demo/invariant path: `Makefile`, `script/CheckState.s.sol`, `script/UpgradeV2.s.sol`, `script/TransferV2Demo.s.sol`, `test/BankInvariant.t.sol`, `test/ScriptPreflight.t.sol`, and `test/helpers/BankV2Handler.sol`. +- Manifest and ABI bridge: `tools/finalize-manifest.mjs`, `tools/sync-web-artifacts.mjs`, `tools/test-finalize-manifest.mjs`, and `tools/test-sync-web-artifacts.mjs`. +- Read-only console: `web/src/App.test.tsx`, `web/src/components/ActivityTimeline.tsx`, `web/src/data/bankClient.ts`, `web/src/data/bankClient.test.ts`, and `web/src/types/dashboard.ts`. + +The V1 compatibility edit changes `initialize` visibility from `external` to `public` so the pinned upgrades-core tooling recognizes the initializer as inherited by `BankV2`; it does not change the external ABI, initializer guard, or stored state. Generated ABI and deployment files may appear during execution but are reproducible runtime artifacts, not live source edits. The optional Base Sepolia files are a later encore and are not required for the local Act 2 story. + +Ask Codex to show the diff and explain layout preservation, owner authorization, transfer conservation, error paths, snapshot postconditions, and why the browser remains read-only. Then run: ```bash make verify ``` -If any gate fails, stop and let Codex diagnose it. Do not run an upgrade merely because a partial test command passed. +Point to each successful layer in the output: `forge fmt --check`; clean forced build; generated ABI freshness; the pinned OpenZeppelin upgrades CLI; 102 Solidity unit, fuzz, invariant, upgrade-regression, and script tests; 16 manifest-finalizer tests; 26 process-safety cases; offline Base-config and project-scanner gates; then web lint, TypeScript checking, 55 Vitest tests across six files, and the production Vite build. In the upgrade tests, highlight the validator accepting `BankV1` → `BankV2`, rejecting an incompatible layout, rejecting non-UUPS logic, and preserving the snapshot. If any layer fails, do not upgrade. ### Act 3 — V2 proves continuity -After Codex has implemented these targets and the full gate has passed: +Continue in terminal 2 only after the complete gate passes: ```bash make upgrade-v2 make demo-transfer -make check-state +DEMO_EXPECTED_STAGE=v2 make check-state +curl --fail http://127.0.0.1:5173/ ``` -The expected result is the same proxy and a new implementation, version `2`, Alice `650 mUSDC`, Bob `750 mUSDC`, liabilities `1,400 mUSDC`, reserves `1,400 mUSDC`, and a decoded 250 mUSDC internal transfer event. No ERC-20 transfer should accompany the ledger transfer. Refresh only if the console has not observed the next block; otherwise let its live status prove the change. +At the upgrade boundary, compare the printed identities and state: + +- the proxy address is exactly the Act 1 proxy; +- the implementation address is different; +- owner, asset, pause state, Alice/Bob addresses and balances, liabilities, reserves, surplus, and deployment block are unchanged; +- version changes from `1` to `2`; and +- the upgrade finalizer matches the `Upgraded` event and live ERC-1967 implementation slot before publishing the V2 manifest. + +After the transfer, call out Alice `650 mUSDC`, Bob `750 mUSDC`, liabilities `1,400 mUSDC`, reserves `1,400 mUSDC`, surplus `0`, and version `2`. The timeline must decode `BalanceTransferred(Alice, Bob, 250e6)` as “Alice transferred 250.000000 mUSDC to Bob.” That transaction has no MockUSDC `Transfer` log, and the unchanged reserves prove no ERC-20 left or entered the proxy. Refresh only if the console has not observed the new block; normally its live synchronization should show the change. ## Recovery without broad cleanup -- **Occupied port:** `make demo-local` refuses to claim either port. Use `curl http://127.0.0.1:8545` and `curl http://127.0.0.1:5173/` plus your operating system's process inspection to identify the external owner. Stop it yourself only after proving ownership; the project never uses broad process matching. +- **Failed edit or test:** do not upgrade. Keep the failing command and diff visible, let Codex diagnose on the disposable live branch, or finish with the verified V1. If time expires, create a new disposable branch/worktree from `demo-start`; never reset or overwrite unrelated work. +- **Occupied port:** `make demo-local` refuses to claim either port. Probe with `curl http://127.0.0.1:8545` and `curl http://127.0.0.1:5173/`, then use operating-system process inspection to identify the external owner. Stop it only after proving ownership. The project never uses broad process matching. - **Recorded stale process:** run `make reset-local`. It validates numeric PID, process group, command signature, and Linux start tick before signaling. A mismatched live process is preserved and reset exits nonzero. -- **Stale console:** confirm `DEMO_EXPECTED_STAGE=v1 make check-state`, inspect `.demo/vite.log`, and use `curl --fail http://127.0.0.1:5173/`. The console labels stale/disconnected state and preserves the last good snapshot rather than inventing zeros. -- **Failed test or live edit:** do not upgrade. Save the diff for review, continue diagnosis on the disposable live branch, or start a new disposable branch from `demo-start`; never overwrite unrelated work. The prepared V1 remains the successful ending if time expires. -- **Unexpected child exit:** the attached launcher stops its other validated group. Inspect `.demo/anvil.log` and `.demo/vite.log`, then run `make reset-local` and restart. +- **Stale UI:** run the stage-appropriate check (`DEMO_EXPECTED_STAGE=v1 make check-state` before transfer or `DEMO_EXPECTED_STAGE=v2 make check-state` after it), inspect `.demo/vite.log`, and use `curl --fail http://127.0.0.1:5173/`. A stale/disconnected console preserves the last good snapshot and labels it; it never invents zeros. +- **Partial local manifest:** never copy `pending.json` or `upgrade-pending.json` into a confirmed manifest and never hand-edit `active.json`. If an upgrade broadcast succeeded but a transient finalizer step failed, preserve the staging and broadcast files, diagnose the cause, and rerun `node tools/finalize-manifest.mjs upgrade --rpc-url http://127.0.0.1:8545`; then run `make sync-artifacts`. Otherwise stop the attached process, run `make reset-local`, and restart from a fresh V1. Confirmed manifests remain unchanged on a failed finalization. +- **Unexpected child exit:** the attached launcher stops its other validated group. Inspect `.demo/anvil.log` and `.demo/vite.log`, run `make reset-local`, and restart. +- **Unavailable Base service:** stop the optional encore, record whether the faucet, RPC, or explorer failed, and leave the completed local result untouched. Never substitute another chain or expose a signing secret to rescue an encore. -End the local session with Ctrl-C in the attached terminal, then `make reset-local`. The optional public encore is outside this prepared V1 run: local success does not depend on Base Sepolia, a faucet, an explorer, a wallet, or any external RPC. +End the local session with Ctrl-C in terminal 1, then run: + +```bash +make reset-local +bash tools/test-process-safety.sh +bash tools/test-base-config.sh +git diff --check +git status --short +``` + +### Recover the verified reference without touching current work + +`demo-complete` is a reference/recovery checkpoint, not a reason to reset the current checkout. From the repository root, first verify the tag and inspect current work: + +```bash +git cat-file -t demo-complete +git tag -n99 demo-complete +git rev-parse 'demo-complete^{}' +git status --short +``` + +The type must be `tag`, the annotation must read `Verified reference solution for UUPS bank demo`, and the peeled target is the verified documentation commit. This checks the required annotated tag without implying that it carries a GPG signature. + +Then create a new branch in a new worktree; choose unused names if either example name already exists: + +```bash +git worktree add -b recovery/uups-bank-demo .worktrees/uups-bank-demo-recovery demo-complete +git -C .worktrees/uups-bank-demo-recovery status --short +``` + +This peels the annotated tag into a new branch without switching, resetting, cleaning, stashing, or overwriting the current worktree. Never delete an existing recovery directory or move either demo tag as part of recovery. ## Optional Base Sepolia encore @@ -85,9 +148,9 @@ cast wallet import uups-bank-base --interactive cast wallet address --account uups-bank-base ``` -Copy the displayed public address to `BASE_SEPOLIA_SENDER`, set `BASE_SEPOLIA_ACCOUNT=uups-bank-base`, and verify the two refer to the same account. Choose a different nonzero public address for `BASE_SEPOLIA_RECIPIENT`. Fund only the displayed Base Sepolia sender address with Base Sepolia test ETH. Never paste the key or password into Codex, shell history, or `.env`; Foundry requests the encrypted-keystore password through its interactive prompt. +Copy the displayed public address to `BASE_SEPOLIA_SENDER`, set `BASE_SEPOLIA_ACCOUNT=uups-bank-base`, and choose a different nonzero public address for `BASE_SEPOLIA_RECIPIENT`. The Base manifest schema is exactly two nested actors in order: `Presenter` at the configured sender, then `Recipient` at the configured recipient. Fund only the displayed Base Sepolia sender with Base Sepolia test ETH. Never paste the key or password into Codex, shell history, or `.env`; Foundry requests the encrypted-keystore password interactively. -Copy `.env.example` to `.env` and configure: +Copy `.env.example` to `.env` and configure only public identifiers and endpoints: ```dotenv # Terminal RPC may be credentialed; never copied into browser artifacts. @@ -107,7 +170,7 @@ bash tools/test-base-config.sh make -n deploy-base-sepolia BASE_SEPOLIA_RPC_URL=https://terminal.invalid BASE_SEPOLIA_PUBLIC_RPC_URL=https://public.invalid BASE_SEPOLIA_ACCOUNT=demo BASE_SEPOLIA_SENDER=0x1111111111111111111111111111111111111111 BASE_SEPOLIA_RECIPIENT=0x2222222222222222222222222222222222222222 ``` -The dry run must contain `--account`, `--sender`, and `--slow`, and must not contain a raw-key option. The URLs above are deliberately fake; these checks require no keystore, faucet, or network. Then, only with explicit authorization and Base Sepolia test funds, execute: +The dry run must contain `--account`, `--sender`, and `--slow`, and no raw-key option. The URLs are deliberately fake; these checks need no keystore, faucet, or network. Only with explicit authorization and Base Sepolia test funds may the presenter run: ```bash make deploy-base-sepolia @@ -115,11 +178,11 @@ make upgrade-base-sepolia make transfer-base-sepolia ``` -Deployment mints valueless mUSDC only to Presenter and deposits `1,000 mUSDC`; Recipient starts at `0`. The V2 transfer produces Presenter `750 mUSDC` and Recipient `250 mUSDC`, while reserves and liabilities stay `1,000 mUSDC`. Each command runs an invariant-only state check. The confirmed browser manifest contains only the intentionally public browser RPC plus `https://sepolia.basescan.org`; the terminal RPC is never serialized. +Deployment mints valueless mUSDC only to Presenter and deposits `1,000 mUSDC`; Recipient starts at `0`. The V2 internal transfer produces Presenter `750 mUSDC` and Recipient `250 mUSDC`, while reserves and liabilities remain `1,000 mUSDC`. Each command runs an invariant-only state check. The confirmed browser manifest contains only the intentionally public browser RPC and `https://sepolia.basescan.org`; the terminal RPC is never serialized. -Before an intentional Base redeployment, run `make archive-base-manifest`. It moves only `deployments/base-sepolia.json` to a validated timestamped sibling and leaves `active.json` and Anvil state untouched. A subsequent finalizer may create a new Base canonical manifest; the old active copy remains the browser fallback until `make select-base-sepolia` succeeds. Use `make select-anvil` to return the browser to the local manifest. If a faucet, RPC, or explorer fails, record the optional failure and stop—the completed local demo remains the successful outcome. +Before an intentional Base redeployment, run `make archive-base-manifest`. It moves only `deployments/base-sepolia.json` to a validated timestamped sibling and leaves `active.json` and Anvil state untouched. A subsequent finalizer may create a new Base canonical manifest; the old active copy remains the browser fallback until `make select-base-sepolia` succeeds. Use `make select-anvil` to return the browser to the local manifest. -## Closing trust disclosure checklist +## Closing trust disclosure and tool boundaries Read these points while the matching console panel is visible: @@ -129,4 +192,4 @@ Read these points while the matching console panel is visible: - UUPS mistakes can corrupt state or permanently brick upgradeability. - A real custody product requires professional audits, operational key controls, multisig or timelocked governance, incident procedures, legal advice, and jurisdiction-specific compliance work. -Codex supplies the cross-stack implementation, tests, orchestration, and explanation. OpenZeppelin supplies reviewed contract primitives and upgrade validation; Foundry supplies compilation, tests, scripts, and the local chain. None of those tools turns this teaching artifact into an audited or regulated custody product. +Codex performs the repository-specific work: application and UI implementation, tests, scripts, lifecycle orchestration, manifest handling, and explanation. OpenZeppelin supplies reviewed reusable primitives and a validator that checks declared upgrade/storage compatibility; it does not audit this application's economics, owner choices, or operations. Foundry compiles and executes the declared tests and scripts and provides local Anvil; it guarantees neither test completeness nor production safety. None of Codex, OpenZeppelin, Foundry, or a passing gate turns this teaching artifact into audited, decentralized, or regulated custody software. From eb7c2c18b3173523a68e272a3f64a0c40e2c8554 Mon Sep 17 00:00:00 2001 From: golem Date: Tue, 25 Aug 2026 00:16:20 -0600 Subject: [PATCH 26/30] fix: harden upgrade publication and refresh --- tools/finalize-manifest.mjs | 68 ++++++++++++++++++------- tools/publish-web-manifest.mjs | 20 ++++++-- tools/reset-local.sh | 44 ++++++++++++++++ tools/test-finalize-manifest.mjs | 28 ++++++++++ tools/test-process-safety.sh | 32 ++++++++++-- tools/test-sync-web-artifacts.mjs | 43 ++++++++++++++-- web/src/hooks/useBankDashboard.test.tsx | 35 +++++++++++++ web/src/hooks/useBankDashboard.ts | 29 +++++++++-- 8 files changed, 267 insertions(+), 32 deletions(-) diff --git a/tools/finalize-manifest.mjs b/tools/finalize-manifest.mjs index b1ab29f..9410ce9 100644 --- a/tools/finalize-manifest.mjs +++ b/tools/finalize-manifest.mjs @@ -89,7 +89,7 @@ export async function finalizeDeployment({ root = process.cwd(), rpc }) { return { path, manifest: confirmed }; } -export async function finalizeUpgrade({ root = process.cwd(), rpc }) { +export async function finalizeUpgrade({ root = process.cwd(), rpc, writeManifest = atomicWriteJson }) { if (typeof rpc !== "function") throw new Error("finalizer requires an RPC function"); const pendingPath = join(root, "deployments", "upgrade-pending.json"); const pending = await readJson(pendingPath); @@ -101,15 +101,12 @@ export async function finalizeUpgrade({ root = process.cwd(), rpc }) { const canonicalPath = join(root, "deployments", spec.canonical); const canonical = await readManifest(canonicalPath); const [activeBytes, canonicalBytes] = await Promise.all([readFile(activePath), readFile(canonicalPath)]); - if (!activeBytes.equals(canonicalBytes) || JSON.stringify(active) !== JSON.stringify(canonical)) { - throw new Error("active and canonical manifest identity must match before upgrade finalization"); - } - - const chainId = parseRpcQuantity(await rpc("eth_chainId", []), "RPC chain ID"); - if (chainId !== active.chainId) throw new Error("RPC chain ID does not match active manifest"); - const methods = await readUpgradeMethods(root); if (pending.mode === "noop") { + assertConfirmedManifestPair(active, canonical, activeBytes, canonicalBytes); + const chainId = parseRpcQuantity(await rpc("eth_chainId", []), "RPC chain ID"); + if (chainId !== active.chainId) throw new Error("RPC chain ID does not match active manifest"); + const methods = await readUpgradeMethods(root); validateNoopMarker(pending, active); const latestBlock = parseRpcQuantity(await rpc("eth_blockNumber", []), "latest block number"); if (latestBlock < pending.observedBlock) throw new Error("live block precedes no-op observation block"); @@ -122,8 +119,11 @@ export async function finalizeUpgrade({ root = process.cwd(), rpc }) { return { mode: "noop", path: canonicalPath, manifest: active }; } - validateUpgradeMarker(pending, active); - const broadcastPath = join(root, "broadcast", "UpgradeV2.s.sol", String(active.chainId), "run-latest.json"); + const previous = recoverableUpgradeBaseline(pending, active, canonical, activeBytes, canonicalBytes); + const chainId = parseRpcQuantity(await rpc("eth_chainId", []), "RPC chain ID"); + if (chainId !== previous.chainId) throw new Error("RPC chain ID does not match active manifest"); + const methods = await readUpgradeMethods(root); + const broadcastPath = join(root, "broadcast", "UpgradeV2.s.sol", String(previous.chainId), "run-latest.json"); const broadcast = await readJson(broadcastPath); if (!Array.isArray(broadcast.transactions)) throw new Error("upgrade broadcast is partial: transactions are missing"); const hashes = [...new Set(broadcast.transactions.map((transaction) => transaction?.hash) @@ -133,26 +133,60 @@ export async function finalizeUpgrade({ root = process.cwd(), rpc }) { transaction: await rpc("eth_getTransactionByHash", [hash]), }))); const matching = resolvedTransactions.filter(({ transaction }) => - typeof transaction?.to === "string" && sameAddress(transaction.to, active.proxy)); + typeof transaction?.to === "string" && sameAddress(transaction.to, previous.proxy)); if (matching.length !== 1) throw new Error(`expected exactly one live upgrade transaction to proxy, found ${matching.length}`); const receipt = await rpc("eth_getTransactionReceipt", [matching[0].hash]); if (!receipt || !isSuccessfulReceipt(receipt.status)) throw new Error("upgrade receipt was not successful"); const upgradeBlock = parseRpcQuantity(receipt.blockNumber, "upgrade receipt block number"); if (upgradeBlock === 0) throw new Error("upgrade receipt block number must be nonzero"); - if (!Array.isArray(receipt.logs) || !receipt.logs.some((log) => isUpgradeLog(log, active.proxy, pending.implementation))) { + if (!Array.isArray(receipt.logs) || !receipt.logs.some((log) => isUpgradeLog(log, previous.proxy, pending.implementation))) { throw new Error("successful upgrade receipt is missing the expected Upgraded event"); } - await assertLiveVersionAndImplementation({ rpc, active, implementation: pending.implementation, methods }); - await assertUpgradeSnapshot({ rpc, active, pending, methods }); - const updated = { ...active, implementation: pending.implementation }; + await assertLiveVersionAndImplementation({ rpc, active: previous, implementation: pending.implementation, methods }); + await assertUpgradeSnapshot({ rpc, active: previous, pending, methods }); + const updated = { ...previous, implementation: pending.implementation }; validateManifest(updated); - await atomicWriteJson(canonicalPath, updated); - await atomicWriteJson(activePath, updated); + const updatedBytes = Buffer.from(`${JSON.stringify(updated, null, 2)}\n`); + if (!canonicalBytes.equals(updatedBytes)) await writeManifest(canonicalPath, updated); + if (!activeBytes.equals(updatedBytes)) await writeManifest(activePath, updated); + const [confirmedCanonical, confirmedActive] = await Promise.all([ + readFile(canonicalPath), + readFile(activePath), + ]); + if (!confirmedCanonical.equals(updatedBytes) || !confirmedActive.equals(updatedBytes)) { + throw new Error("active and canonical manifests did not converge after upgrade finalization"); + } await rm(pendingPath); return { mode: "upgrade", path: canonicalPath, manifest: updated, upgradeBlock }; } +function assertConfirmedManifestPair(active, canonical, activeBytes, canonicalBytes) { + if (!activeBytes.equals(canonicalBytes) || JSON.stringify(active) !== JSON.stringify(canonical)) { + throw new Error("active and canonical manifest identity must match before upgrade finalization"); + } +} + +function recoverableUpgradeBaseline(pending, active, canonical, activeBytes, canonicalBytes) { + assertAddress(pending?.previousImplementation, "upgrade previous implementation"); + assertAddress(pending?.implementation, "upgrade implementation"); + const allowedImplementation = (manifest) => sameAddress(manifest.implementation, pending.previousImplementation) + || sameAddress(manifest.implementation, pending.implementation); + if (!allowedImplementation(active) || !allowedImplementation(canonical)) { + throw new Error("active and canonical manifest divergence is not justified by the upgrade marker"); + } + if (sameAddress(active.implementation, canonical.implementation)) { + assertConfirmedManifestPair(active, canonical, activeBytes, canonicalBytes); + } + const previousActive = { ...active, implementation: pending.previousImplementation }; + const previousCanonical = { ...canonical, implementation: pending.previousImplementation }; + if (JSON.stringify(previousActive) !== JSON.stringify(previousCanonical)) { + throw new Error("active and canonical manifest divergence is not justified by the upgrade marker"); + } + validateUpgradeMarker(pending, previousActive); + return previousActive; +} + function validateNoopMarker(marker, active) { assertExactKeys(marker, ["mode", "chainId", "observedBlock", "ownerNonce", "proxy", "implementation"], "no-op marker"); for (const field of ["chainId", "observedBlock", "ownerNonce"]) { diff --git a/tools/publish-web-manifest.mjs b/tools/publish-web-manifest.mjs index 2256297..1858793 100644 --- a/tools/publish-web-manifest.mjs +++ b/tools/publish-web-manifest.mjs @@ -1,5 +1,6 @@ -import { mkdir, readFile, writeFile } from "node:fs/promises"; -import { dirname, resolve } from "node:path"; +import { randomUUID } from "node:crypto"; +import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"; +import { dirname, join, resolve } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); @@ -11,6 +12,7 @@ const secretMarker = /(?:private[_ -]?key|mnemonic|secret|password|credential|ap export async function publishManifest({ activePath = resolve(repositoryRoot, "deployments/active.json"), outputPath = resolve(repositoryRoot, "web/public/deployment.json"), + io, } = {}) { let contents; try { contents = await readFile(activePath, "utf8"); } catch { throw new Error(`active manifest is missing at ${activePath}`); } @@ -18,10 +20,22 @@ export async function publishManifest({ try { manifest = JSON.parse(contents); } catch { throw new Error("active manifest contains invalid JSON"); } validatePublicManifest(manifest); await mkdir(dirname(outputPath), { recursive: true }); - await writeFile(outputPath, `${JSON.stringify(manifest, null, 2)}\n`); + await atomicPublish(outputPath, `${JSON.stringify(manifest, null, 2)}\n`, io); return manifest; } +async function atomicPublish(path, contents, io = {}) { + const temporary = join(dirname(path), `.${randomUUID()}.json`); + const operations = { writeFile, rename, rm, ...io }; + try { + await operations.writeFile(temporary, contents, { mode: 0o600 }); + await operations.rename(temporary, path); + } catch (error) { + await operations.rm(temporary, { force: true }).catch(() => {}); + throw error; + } +} + export function validatePublicManifest(manifest) { if (!isRecord(manifest)) throw new Error("manifest must be an object"); for (const field of requiredFields) if (!Object.hasOwn(manifest, field)) throw new Error(`manifest is missing required field ${field}`); diff --git a/tools/reset-local.sh b/tools/reset-local.sh index e782dc0..44f8b4b 100755 --- a/tools/reset-local.sh +++ b/tools/reset-local.sh @@ -23,6 +23,49 @@ remove_local_manifest() { fi } +remove_local_upgrade_marker() { + local path=$1 + [[ -e "$path" ]] || return 0 + if node -e ' +const fs = require("fs"); +const marker = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); +const record = (value) => value && typeof value === "object" && !Array.isArray(value); +const exact = (value, keys) => record(value) + && Object.keys(value).sort().join("\0") === [...keys].sort().join("\0"); +const uint = (value) => Number.isSafeInteger(value) && value >= 0; +const address = (value) => typeof value === "string" + && /^0x[0-9a-fA-F]{40}$/.test(value) && !/^0x0{40}$/i.test(value); +const same = (first, second) => address(first) && address(second) && first.toLowerCase() === second.toLowerCase(); +const noop = exact(marker, ["mode", "chainId", "observedBlock", "ownerNonce", "proxy", "implementation"]) + && marker.mode === "noop" && marker.chainId === 31337 + && uint(marker.observedBlock) && uint(marker.ownerNonce) + && address(marker.proxy) && address(marker.implementation); +const upgradeKeys = ["mode", "network", "chainId", "token", "proxy", "previousImplementation", "implementation", "owner", "deploymentBlock", "snapshot"]; +const snapshotKeys = ["proxy", "implementation", "owner", "asset", "paused", "balances", "liabilities", "reserves", "surplus", "deploymentBlock", "version"]; +const snapshot = marker?.snapshot; +const balances = Array.isArray(snapshot?.balances) && snapshot.balances.length > 0 + && snapshot.balances.every((balance) => exact(balance, ["address", "balance"]) + && address(balance.address) && uint(balance.balance)); +const upgrade = exact(marker, upgradeKeys) + && marker.mode === "upgrade" && marker.network === "anvil" && marker.chainId === 31337 + && uint(marker.deploymentBlock) && marker.deploymentBlock >= 1 + && [marker.token, marker.proxy, marker.previousImplementation, marker.implementation, marker.owner].every(address) + && !same(marker.previousImplementation, marker.implementation) + && exact(snapshot, snapshotKeys) && typeof snapshot.paused === "boolean" && snapshot.version === 1 + && same(snapshot.proxy, marker.proxy) && same(snapshot.implementation, marker.previousImplementation) + && same(snapshot.owner, marker.owner) && same(snapshot.asset, marker.token) + && snapshot.deploymentBlock === marker.deploymentBlock && balances + && uint(snapshot.liabilities) && uint(snapshot.reserves) && uint(snapshot.surplus) + && snapshot.reserves >= snapshot.liabilities + && snapshot.reserves - snapshot.liabilities === snapshot.surplus; +process.exit(noop || upgrade ? 0 : 1); +' "$path" 2>/dev/null; then + remove_exact "$path" + else + printf 'Preserved non-local or invalid upgrade marker %s\n' "${path#"$ROOT/"}" + fi +} + demo_stop_recorded "$ROOT" vite demo_stop_recorded "$ROOT" anvil @@ -36,6 +79,7 @@ remove_exact "$ROOT/.demo/vite.pgid" remove_exact "$ROOT/.demo/vite.log" remove_local_manifest "$ROOT/deployments/pending.json" +remove_local_upgrade_marker "$ROOT/deployments/upgrade-pending.json" remove_local_manifest "$ROOT/deployments/anvil.json" remove_local_manifest "$ROOT/deployments/active.json" remove_local_manifest "$ROOT/web/public/deployment.json" diff --git a/tools/test-finalize-manifest.mjs b/tools/test-finalize-manifest.mjs index bd2c97a..aafdfd7 100644 --- a/tools/test-finalize-manifest.mjs +++ b/tools/test-finalize-manifest.mjs @@ -159,6 +159,34 @@ test("upgrade finalizer verifies receipt, event, slot, artifact-driven state and }); }); +test("upgrade finalizer retains staging after the second manifest write fails and safely converges on retry", async () => { + await withUpgradeFixture(async (root, active) => { + let writes = 0; + const writeManifest = async (path, value) => { + writes += 1; + if (writes === 2) throw new Error("injected active replacement failure"); + await atomicWrite(path, `${JSON.stringify(value, null, 2)}\n`); + }; + + await assert.rejects( + () => finalizeUpgrade({ root, rpc: fakeUpgradeRpc(), writeManifest }), + /injected active replacement failure/, + ); + assert.equal((await readJson(join(root, "deployments", "anvil.json"))).implementation, V2_IMPLEMENTATION); + assert.equal((await readJson(join(root, "deployments", "active.json"))).implementation, active.implementation); + await access(join(root, "deployments", "upgrade-pending.json")); + + const recovered = await finalizeUpgrade({ root, rpc: fakeUpgradeRpc() }); + + assert.equal(recovered.manifest.implementation, V2_IMPLEMENTATION); + assert.deepEqual( + await readFile(join(root, "deployments", "active.json")), + await readFile(join(root, "deployments", "anvil.json")), + ); + await assert.rejects(() => access(join(root, "deployments", "upgrade-pending.json"))); + }); +}); + test("upgrade finalizer rejects proxy, deployment-block, and actor identity mutation without changing confirmed files", async () => { for (const [name, mutate] of [ ["proxy", (pending) => { pending.proxy = TOKEN; }], diff --git a/tools/test-process-safety.sh b/tools/test-process-safety.sh index 8cc9d9d..45a0c96 100755 --- a/tools/test-process-safety.sh +++ b/tools/test-process-safety.sh @@ -19,12 +19,12 @@ STARTED_PID= cleanup() { local identity root relative for identity in "${TEST_IDENTITIES[@]}"; do test_safe_stop "$identity"; done - for root in absent stale nonnumeric wrong-command false-anvil false-vite exact-anvil raw-capture reused atomic matching group term-refusal anchor-race mutated partial local-reset base-canonical base-active sentinel; do + for root in absent stale nonnumeric wrong-command false-anvil false-vite exact-anvil raw-capture reused atomic matching group term-refusal anchor-race mutated partial local-reset local-noop base-canonical base-active base-upgrade uncertain-upgrade sentinel; do for relative in \ .demo/anvil.pid .demo/anvil.start .demo/anvil.pgid .demo/anvil.log \ .demo/vite.pid .demo/vite.start .demo/vite.pgid .demo/vite.log \ .demo/sentinel .demo/adjacent.keep tools/process-lib.sh tools/reset-local.sh \ - deployments/pending.json deployments/anvil.json deployments/active.json deployments/base-sepolia.json \ + deployments/pending.json deployments/upgrade-pending.json deployments/anvil.json deployments/active.json deployments/base-sepolia.json \ web/public/deployment.json web/src/generated/contracts.ts web/node_modules/.bin/vite; do rm -f -- "$TEST_ROOT/$root/$relative" done @@ -171,7 +171,7 @@ assert_anvil_args_rejected() { demo_stop_raw_launch "$root" anvil "$pid" "$start" "$pgid" } -printf '1..26\n' +printf '1..29\n' # Catches cleanup treating a missing record as an error or signaling an inferred PID. root=$(make_root absent) @@ -573,14 +573,22 @@ printf 'sentinel\n' >"$root/.demo/sentinel" for path in deployments/pending.json deployments/anvil.json deployments/active.json web/public/deployment.json; do printf '{"network":"anvil","chainId":31337}\n' >"$root/$path" done +printf '%s\n' '{"mode":"upgrade","network":"anvil","chainId":31337,"token":"0x1111111111111111111111111111111111111111","proxy":"0x2222222222222222222222222222222222222222","previousImplementation":"0x3333333333333333333333333333333333333333","implementation":"0x4444444444444444444444444444444444444444","owner":"0x5555555555555555555555555555555555555555","deploymentBlock":1,"snapshot":{"proxy":"0x2222222222222222222222222222222222222222","implementation":"0x3333333333333333333333333333333333333333","owner":"0x5555555555555555555555555555555555555555","asset":"0x1111111111111111111111111111111111111111","paused":false,"balances":[{"address":"0x5555555555555555555555555555555555555555","balance":0}],"liabilities":0,"reserves":0,"surplus":0,"deploymentBlock":1,"version":1}}' >"$root/deployments/upgrade-pending.json" printf 'generated ABI\n' >"$root/web/src/generated/contracts.ts" (cd "$root" && bash tools/reset-local.sh >/dev/null) -for path in .demo/anvil.pid .demo/anvil.start .demo/anvil.pgid .demo/anvil.log .demo/vite.pid .demo/vite.start .demo/vite.pgid .demo/vite.log deployments/pending.json deployments/anvil.json deployments/active.json web/public/deployment.json web/src/generated/contracts.ts; do +for path in .demo/anvil.pid .demo/anvil.start .demo/anvil.pgid .demo/anvil.log .demo/vite.pid .demo/vite.start .demo/vite.pgid .demo/vite.log deployments/pending.json deployments/upgrade-pending.json deployments/anvil.json deployments/active.json web/public/deployment.json web/src/generated/contracts.ts; do assert_absent "$root/$path" done assert_exists "$root/.demo/sentinel" pass 'reset removes only explicitly known local runtime and generated files' +# Catches reset leaving a strictly validated local no-op upgrade marker behind. +root=$(make_root local-noop) +printf '%s\n' '{"mode":"noop","chainId":31337,"observedBlock":9,"ownerNonce":3,"proxy":"0x2222222222222222222222222222222222222222","implementation":"0x4444444444444444444444444444444444444444"}' >"$root/deployments/upgrade-pending.json" +(cd "$root" && bash tools/reset-local.sh >/dev/null) +assert_absent "$root/deployments/upgrade-pending.json" +pass 'reset removes a strictly validated local no-op upgrade marker' + # Catches a local reset corrupting a canonical Base Sepolia deployment. root=$(make_root base-canonical) printf '{"network":"baseSepolia","chainId":84532,"marker":"canonical"}\n' >"$root/deployments/base-sepolia.json" @@ -602,6 +610,22 @@ browser_before=$(sha256sum "$root/web/public/deployment.json") [[ "$browser_before" == "$(sha256sum "$root/web/public/deployment.json")" ]] || fail 'Base browser manifest changed' pass 'Base active and browser manifests survive reset byte-for-byte' +# Catches reset deleting a valid Base-shaped upgrade marker that may require public recovery. +root=$(make_root base-upgrade) +printf '%s\n' '{"mode":"upgrade","network":"baseSepolia","chainId":84532,"token":"0x1111111111111111111111111111111111111111","proxy":"0x2222222222222222222222222222222222222222","previousImplementation":"0x3333333333333333333333333333333333333333","implementation":"0x4444444444444444444444444444444444444444","owner":"0x5555555555555555555555555555555555555555","deploymentBlock":1,"snapshot":{"proxy":"0x2222222222222222222222222222222222222222","implementation":"0x3333333333333333333333333333333333333333","owner":"0x5555555555555555555555555555555555555555","asset":"0x1111111111111111111111111111111111111111","paused":false,"balances":[{"address":"0x5555555555555555555555555555555555555555","balance":0}],"liabilities":0,"reserves":0,"surplus":0,"deploymentBlock":1,"version":1}}' >"$root/deployments/upgrade-pending.json" +base_upgrade_before=$(sha256sum "$root/deployments/upgrade-pending.json") +(cd "$root" && bash tools/reset-local.sh >/dev/null) +[[ "$base_upgrade_before" == "$(sha256sum "$root/deployments/upgrade-pending.json")" ]] || fail 'Base upgrade marker changed' +pass 'a Base-shaped upgrade marker survives reset byte-for-byte' + +# Catches reset guessing that malformed or incomplete upgrade staging is safe to delete. +root=$(make_root uncertain-upgrade) +printf '%s\n' '{"mode":"upgrade","chainId":31337}' >"$root/deployments/upgrade-pending.json" +uncertain_before=$(sha256sum "$root/deployments/upgrade-pending.json") +(cd "$root" && bash tools/reset-local.sh >/dev/null) +[[ "$uncertain_before" == "$(sha256sum "$root/deployments/upgrade-pending.json")" ]] || fail 'uncertain upgrade marker changed' +pass 'a malformed or uncertain upgrade marker survives reset byte-for-byte' + # Catches cleanup broadening from exact files to recursive .demo deletion. root=$(make_root sentinel) printf 'keep me\n' >"$root/.demo/adjacent.keep" diff --git a/tools/test-sync-web-artifacts.mjs b/tools/test-sync-web-artifacts.mjs index d40471d..d3bc3a7 100644 --- a/tools/test-sync-web-artifacts.mjs +++ b/tools/test-sync-web-artifacts.mjs @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; -import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { mkdtemp, readFile, readdir, rename, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { basename, dirname, join } from "node:path"; import { extractAbi, renderContractsModule, syncArtifacts } from "./sync-web-artifacts.mjs"; import { publishManifest } from "./publish-web-manifest.mjs"; @@ -89,9 +89,44 @@ await withFixture(async (root) => { const outputPath = join(root, "deployment.json"); await writeFile(activePath, JSON.stringify(manifest)); - // Catches a publisher that copies pending, secret-bearing, or malformed live state into the browser bundle. - await publishManifest({ activePath, outputPath }); + // Catches a publisher that writes the browser destination directly instead of replacing a complete same-directory file. + const operations = []; + await publishManifest({ + activePath, + outputPath, + io: { + writeFile: async (...args) => { operations.push(["write", args[0]]); await writeFile(...args); }, + rename: async (...args) => { operations.push(["rename", ...args]); await rename(...args); }, + rm, + }, + }); assert.deepEqual(JSON.parse(await readFile(outputPath, "utf8")), manifest); + assert.equal(operations.length, 2); + assert.equal(operations[0][0], "write"); + assert.equal(dirname(operations[0][1]), dirname(outputPath)); + assert.notEqual(operations[0][1], outputPath); + assert.deepEqual(operations[1], ["rename", operations[0][1], outputPath]); + + // Catches a failed replacement truncating the published manifest or leaking its staging file. + const preserved = Buffer.from("preserved browser manifest\n"); + await writeFile(outputPath, preserved); + let failedStage; + await assert.rejects( + () => publishManifest({ + activePath, + outputPath, + io: { + writeFile: async (...args) => { failedStage = args[0]; await writeFile(...args); }, + rename: async () => { throw new Error("injected browser replacement failure"); }, + rm, + }, + }), + /injected browser replacement failure/, + ); + assert.deepEqual(await readFile(outputPath), preserved); + assert.equal((await readdir(root)).includes(basename(failedStage)), false); + + // Catches a publisher that copies pending, secret-bearing, or malformed live state into the browser bundle. for (const invalid of [ { ...manifest, deploymentBlock: 0 }, { ...manifest, rpcUrl: "https://token@example.test" }, diff --git a/web/src/hooks/useBankDashboard.test.tsx b/web/src/hooks/useBankDashboard.test.tsx index f72a586..6a62d03 100644 --- a/web/src/hooks/useBankDashboard.test.tsx +++ b/web/src/hooks/useBankDashboard.test.tsx @@ -222,4 +222,39 @@ describe("useBankDashboard", () => { expect(result.current.status === "ready" && result.current.snapshot.blockNumber).toBe(13n); }); }); + + it("refetches a changed manifest on a watched block and isolates its implementation snapshot without remounting", async () => { + const v2ManifestJson = { ...manifestJson, implementation: address("6") }; + const v2Snapshot = { + ...snapshot, + blockNumber: 13n, + version: 2 as const, + implementation: address("6"), + synchronizedAt: new Date("2026-08-21T10:01:00.000Z"), + }; + const pendingV2Snapshot = deferred(); + const loadManifest = vi.fn() + .mockResolvedValueOnce(manifestJson) + .mockResolvedValueOnce(v2ManifestJson); + const loader = vi.fn((current: DeploymentManifest) => current.implementation === manifest.implementation + ? Promise.resolve(snapshot) + : pendingV2Snapshot.promise); + const { result } = renderHook(() => useBankDashboard({ loadManifest, loader }), { + wrapper: createWrapper(), + }); + await waitFor(() => expect(result.current.status).toBe("ready")); + + act(() => notifyBlock?.(13n)); + + await waitFor(() => { + expect(loadManifest).toHaveBeenCalledTimes(2); + expect(loader).toHaveBeenCalledTimes(2); + }); + expect(result.current.status).toBe("loading"); + + pendingV2Snapshot.resolve(v2Snapshot); + await waitFor(() => expect(result.current.status).toBe("ready")); + expect(result.current.status === "ready" && result.current.manifest.implementation).toBe(address("6")); + expect(result.current.status === "ready" && result.current.snapshot).toBe(v2Snapshot); + }); }); diff --git a/web/src/hooks/useBankDashboard.ts b/web/src/hooks/useBankDashboard.ts index 11ee88c..74b2c2e 100644 --- a/web/src/hooks/useBankDashboard.ts +++ b/web/src/hooks/useBankDashboard.ts @@ -43,6 +43,15 @@ function isChainMismatch(error: unknown): boolean { return /endpoint chain ID \d+ does not match manifest chain ID \d+/i.test(message(error)); } +function manifestIdentity(manifest: DeploymentManifest | undefined): string | undefined { + if (!manifest) return undefined; + return JSON.stringify({ + ...manifest, + deploymentBlock: manifest.deploymentBlock.toString(), + actors: manifest.actors.map(({ label, address }) => ({ label, address })), + }); +} + export function useBankDashboard(options: BankDashboardOptions = {}): DashboardState { const loadManifest = options.loadManifest ?? fetchManifest; const loader = options.loader ?? fetchSnapshot; @@ -70,9 +79,10 @@ export function useBankDashboard(options: BankDashboardOptions = {}): DashboardS } }, [manifestQuery.data]); const manifest = parsedManifest && "manifest" in parsedManifest ? parsedManifest.manifest : undefined; + const snapshotIdentity = useMemo(() => manifestIdentity(manifest), [manifest]); const snapshotQueryKey = useMemo( - () => ["bank-dashboard", manifest?.chainId, manifest?.proxy] as const, - [manifest?.chainId, manifest?.proxy], + () => ["bank-dashboard", snapshotIdentity] as const, + [snapshotIdentity], ); const snapshotQuery = useQuery({ @@ -97,8 +107,19 @@ export function useBankDashboard(options: BankDashboardOptions = {}): DashboardS const reconcileBlock = useCallback((blockNumber: bigint) => { if (!manifest || mismatch || latestSnapshot.current?.blockNumber === blockNumber || lastWatchedBlock.current === blockNumber) return; lastWatchedBlock.current = blockNumber; - void queryClient.invalidateQueries({ queryKey: snapshotQueryKey, exact: true }); - }, [manifest, mismatch, queryClient, snapshotQueryKey]); + void manifestQuery.refetch().then((result) => { + if (result.error !== null || result.data === undefined) return; + let refreshedManifest: DeploymentManifest; + try { + refreshedManifest = parseDeploymentManifest(result.data); + } catch { + return; + } + if (manifestIdentity(refreshedManifest) === snapshotIdentity) { + void queryClient.invalidateQueries({ queryKey: snapshotQueryKey, exact: true }); + } + }); + }, [manifest, manifestQuery, mismatch, queryClient, snapshotIdentity, snapshotQueryKey]); useWatchBlockNumber({ chainId: manifest?.chainId, From fb6001174a6af033d846715c2a5f206b4b4911d8 Mon Sep 17 00:00:00 2001 From: golem Date: Tue, 25 Aug 2026 01:18:40 -0600 Subject: [PATCH 27/30] build: adding superpowers working dir to gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index ab5e232..2353243 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +.claude/superpowers/ .superpowers/ .worktrees/ .env From 3ce795e8139e3517e0a001f1b757513c11d4b293 Mon Sep 17 00:00:00 2001 From: golem Date: Tue, 25 Aug 2026 01:28:59 -0600 Subject: [PATCH 28/30] docs: define superpowers artifact ignore policy --- ...perpowers-artifact-ignore-policy-design.md | 82 +++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-25-superpowers-artifact-ignore-policy-design.md diff --git a/docs/superpowers/specs/2026-08-25-superpowers-artifact-ignore-policy-design.md b/docs/superpowers/specs/2026-08-25-superpowers-artifact-ignore-policy-design.md new file mode 100644 index 0000000..b2bdc50 --- /dev/null +++ b/docs/superpowers/specs/2026-08-25-superpowers-artifact-ignore-policy-design.md @@ -0,0 +1,82 @@ +# Superpowers Artifact Ignore Policy Design + +- Status: proposed +- Date: 2026-08-25 +- Scope: repository cleanup for this project plus a machine-wide Git ignore safeguard for future projects + +## Problem + +Superpowers runtime directories contain orchestration state rather than product source. SDD ledgers, task briefs, reports, review diffs, screenshots, generated visual companions, local worktree registrations, command output, and absolute local paths create noisy changes and may expose operational or sensitive context when committed. + +The current repository demonstrates the failure mode: `main` tracks 27 files under `.superpowers/`, totaling roughly 374 KB, and its decision to track those files conflicts with the feature branch's broad `.superpowers/` ignore rule. + +## Policy + +Operational Superpowers and worktree directories are private local state and must not be committed: + +```gitignore +**/.superpowers/ +**/.claude/superpowers/ +**/.worktrees/ +``` + +Intentional design and implementation documents remain normal project source under: + +```text +docs/superpowers/specs/ +docs/superpowers/plans/ +``` + +The dot-directory and documentation-directory policies are deliberately different. `.superpowers/` is generated session state; `docs/superpowers/` contains authored, reviewable project decisions. + +## Defense in Depth + +The policy is enforced in two places: + +1. The machine-wide Git excludes file protects every local repository, including new projects that have not yet added repository rules. +2. Each repository's `.gitignore` carries the same exclusions so collaborators and other machines receive the protection. + +Global-only enforcement would not protect collaborators. Repository-only enforcement would depend on remembering to add the rules to every new project. Both layers are therefore required. + +## Current Repository Cleanup + +The feature branch will merge the current `origin/main`. The `.gitignore` conflict will resolve in favor of the broad operational-directory exclusions plus the existing application build, dependency, secret, deployment, and generated-artifact rules. + +The `.superpowers/` files introduced on `main` will be removed from Git's index and from the resulting repository tree without rewriting history. Local copies will be retained as ignored files when present. The authored files under `docs/superpowers/` remain tracked. + +The existing `demo-start` and `demo-complete` tags remain unchanged. No branch or tag history is rewritten. + +## Global Configuration + +The existing user-level ignore file at `~/.config/git/ignore` will retain its current rules and gain the three operational-directory exclusions. No repository-specific build paths or application secrets belong in the global file. + +The global ignore protects only untracked files. It does not retroactively remove already tracked artifacts, which is why the current repository also needs an index cleanup commit. + +## Safety and Failure Handling + +- Fetch and merge only the named `origin/main` into `feature/uups-bank-demo`. +- If any conflict other than `.gitignore` appears, stop and inspect it rather than applying a broad resolution. +- Remove `.superpowers/` from the index without deleting local copies. +- Do not rewrite published history or force-push. +- Do not move or recreate tags. +- Preserve existing global ignore entries byte-for-byte apart from appending the approved rules once. +- Never print or inspect the contents of operational artifacts as part of cleanup. + +## Verification + +The cleanup is accepted only when all of the following hold: + +1. `git ls-files '.superpowers/**'` returns no paths in the feature result. +2. `git check-ignore` confirms representative `.superpowers/`, `.claude/superpowers/`, and `.worktrees/` paths are ignored by both repository and global policy. +3. `docs/superpowers/specs/` and `docs/superpowers/plans/` remain tracked. +4. The merge commit contains no unexpected path changes beyond the current `main` merge, artifact removal, ignore policy, and this approved policy documentation. +5. `make verify` and `git diff --check` exit successfully. +6. The feature branch pushes normally without force and PR #2 becomes mergeable. + +## Non-goals + +- Purging `.superpowers/` artifacts from existing Git history. +- Rotating credentials; no credential exposure has been established. +- Changing Superpowers runtime behavior or storage locations. +- Ignoring intentional project documents under `docs/superpowers/`. +- Applying repository-specific ignores to unrelated existing repositories automatically. From e856c77f0c07f8cf08835fc4ab5c5118d482225c Mon Sep 17 00:00:00 2001 From: golem Date: Tue, 25 Aug 2026 01:33:32 -0600 Subject: [PATCH 29/30] docs: plan superpowers artifact cleanup --- ...8-25-superpowers-artifact-ignore-policy.md | 240 ++++++++++++++++++ 1 file changed, 240 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-25-superpowers-artifact-ignore-policy.md diff --git a/docs/superpowers/plans/2026-08-25-superpowers-artifact-ignore-policy.md b/docs/superpowers/plans/2026-08-25-superpowers-artifact-ignore-policy.md new file mode 100644 index 0000000..cdfae6f --- /dev/null +++ b/docs/superpowers/plans/2026-08-25-superpowers-artifact-ignore-policy.md @@ -0,0 +1,240 @@ +# Superpowers Artifact Ignore Policy Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Remove operational Superpowers artifacts from the current repository tip, prevent them from being committed in future projects, and resolve PR #2's `.gitignore` merge conflict without rewriting history. + +**Architecture:** Apply defense in depth: the user-level Git excludes file protects every local repository, while this repository's `.gitignore` protects collaborators and other machines. Merge current `origin/main` into `feature/uups-bank-demo`, retain local `.superpowers/` copies as ignored files, remove those artifacts from Git's index, and keep intentional documents under `docs/superpowers/` tracked. + +**Tech Stack:** Git, Bash, Make, the repository's existing Foundry/Node verification gate, and Gitea CLI `tea`. + +**Spec:** `docs/superpowers/specs/2026-08-25-superpowers-artifact-ignore-policy-design.md` + +## Global Constraints + +- Operational Superpowers and worktree directories are private local state and must not be committed: `**/.superpowers/`, `**/.claude/superpowers/`, and `**/.worktrees/`. +- Intentional design and implementation documents remain tracked under `docs/superpowers/specs/` and `docs/superpowers/plans/`. +- Enforce the policy in both the machine-wide Git excludes file and this repository's `.gitignore`. +- Remove `.superpowers/` from the current repository index without deleting local copies. +- Do not rewrite published history or force-push. +- Do not move or recreate `demo-start` or `demo-complete`. +- Preserve existing global ignore entries byte-for-byte apart from appending the approved rules once. +- Never print or inspect operational artifact contents during cleanup. +- If any merge conflict other than `.gitignore` appears, stop and report it rather than applying a broad resolution. +- Run the complete `make verify` gate before publishing the resolved branch. + +## File and Interface Map + +| Area | Files | Responsibility | +| --- | --- | --- | +| Machine policy | `/home/golem/.config/git/ignore` | Ignore operational Superpowers and worktree directories in all local repositories | +| Repository policy | `.gitignore` | Carry the same protection for collaborators while retaining application-specific generated/build exclusions | +| Repository cleanup | `.superpowers/**` index entries introduced by current `origin/main` | Remove generated operational artifacts from the resulting repository tree while retaining local ignored copies | +| Intentional docs | `docs/superpowers/specs/**`, `docs/superpowers/plans/**` | Remain tracked as authored project source | + +--- + +### Task 1: Add the machine-wide Git ignore safeguard + +**Files:** +- Modify: `/home/golem/.config/git/ignore` + +**Interfaces:** +- Consumes: Git's default XDG user excludes file at `/home/golem/.config/git/ignore`. +- Produces: machine-wide ignored-directory behavior for `.superpowers/`, `.claude/superpowers/`, and `.worktrees/` in repositories that have no local `.gitignore` rule. + +- [ ] **Step 1: Verify the current global policy does not yet ignore the three directories** + +Create an isolated repository with no local ignore file: + +```bash +probe_dir=$(mktemp -d /tmp/superpowers-ignore-policy.XXXXXX) +git -C "$probe_dir" init -q +mkdir -p "$probe_dir/.superpowers/sdd" "$probe_dir/.claude/superpowers" "$probe_dir/.worktrees/feature" +touch "$probe_dir/.superpowers/sdd/report.md" "$probe_dir/.claude/superpowers/state.json" "$probe_dir/.worktrees/feature/marker" +git -C "$probe_dir" check-ignore -v .superpowers/sdd/report.md .claude/superpowers/state.json .worktrees/feature/marker +``` + +Expected RED: `git check-ignore` exits `1` and prints no matching ignore rule. If all three paths are already ignored by the user-level file, do not duplicate them; record the existing matching rules and continue to Step 3. + +- [ ] **Step 2: Append the exact global exclusions once** + +Preserve the existing `**/.claude/settings.local.json` line and append exactly: + +```gitignore +**/.superpowers/ +**/.claude/superpowers/ +**/.worktrees/ +``` + +Use `apply_patch` directly when permitted. If the sandbox blocks editing `/home/golem/.config/git/ignore`, copy the file to a uniquely named `/tmp` path, use `apply_patch` on that copy, verify its diff against the original, then request narrowly scoped approval to install that exact prepared file back at `/home/golem/.config/git/ignore`. Do not use an in-place shell append that can duplicate entries. + +- [ ] **Step 3: Verify the global policy in an isolated repository** + +Run against the same `probe_dir`: + +```bash +git -C "$probe_dir" check-ignore -v .superpowers/sdd/report.md .claude/superpowers/state.json .worktrees/feature/marker +``` + +Expected GREEN: exit `0`; all three paths print a matching rule sourced from `/home/golem/.config/git/ignore`. + +- [ ] **Step 4: Verify uniqueness and preserve the existing rule** + +```bash +test "$(rg -n -x -F '**/.superpowers/' /home/golem/.config/git/ignore | wc -l)" -eq 1 +test "$(rg -n -x -F '**/.claude/superpowers/' /home/golem/.config/git/ignore | wc -l)" -eq 1 +test "$(rg -n -x -F '**/.worktrees/' /home/golem/.config/git/ignore | wc -l)" -eq 1 +rg -n -x -F '**/.claude/settings.local.json' /home/golem/.config/git/ignore +``` + +Expected: each new rule occurs exactly once and the pre-existing settings rule remains present. + +- [ ] **Step 5: Remove only the isolated probe** + +Validate the prefix before deleting: + +```bash +case "$probe_dir" in + /tmp/superpowers-ignore-policy.*) rm -rf "$probe_dir" ;; + *) echo "refusing unexpected probe path: $probe_dir" >&2; exit 1 ;; +esac +``` + +No repository commit is created for this user-level configuration task. + +--- + +### Task 2: Merge main, resolve the policy conflict, and untrack runtime artifacts + +**Files:** +- Modify: `.gitignore` +- Remove from index only: `.superpowers/**` +- Preserve tracked: `docs/superpowers/specs/**` +- Preserve tracked: `docs/superpowers/plans/**` + +**Interfaces:** +- Consumes: `origin/main` at the latest fetched commit, `feature/uups-bank-demo`, and the global exclusions produced by Task 1. +- Produces: a normal merge commit whose tree contains no `.superpowers/**` paths, keeps intentional `docs/superpowers/**` documents, and is conflict-free against the fetched `origin/main`. + +- [ ] **Step 1: Confirm the exact pre-merge state** + +```bash +git fetch origin main feature/uups-bank-demo +git status --short --branch +git rev-parse HEAD +git rev-parse origin/main +git rev-parse origin/feature/uups-bank-demo +git merge-tree --write-tree --messages origin/main HEAD +``` + +Expected RED: the branch is clean and named `feature/uups-bank-demo`; `git merge-tree` reports exactly one content conflict, `.gitignore`. If another path conflicts, stop and report it. + +- [ ] **Step 2: Start a non-fast-forward merge without committing** + +```bash +git merge --no-ff --no-commit origin/main +``` + +Expected: Git stops with one unresolved path, `.gitignore`; files newly tracked by `origin/main` may appear under `.superpowers/`. + +- [ ] **Step 3: Resolve `.gitignore` to the exact repository policy** + +Replace the conflicted file with: + +```gitignore +# Local agent/session state +.claude/superpowers/ +.superpowers/ +.worktrees/ + +.env +.env.local +.demo/ +cache/ +out/ +broadcast/ +deployments/*.json +deployments/**/*.json +!deployments/*.example.json +node_modules/ +web/node_modules/ +web/dist/ +web/coverage/ +web/public/deployment.json +web/src/generated/*.ts +!.gitkeep +``` + +Do not retain `main`'s granular `.superpowers/brainstorm/...` rules; the broad `.superpowers/` rule is the approved policy. + +- [ ] **Step 4: Keep operational artifacts locally while removing them from Git's index** + +```bash +git add .gitignore +git rm -r --cached .superpowers +``` + +Expected: `.superpowers/**` paths are staged as deletions from the merge result but remain present locally and ignored. + +- [ ] **Step 5: Verify the resolved merge before committing** + +```bash +test -z "$(git diff --name-only --diff-filter=U)" +test -z "$(git ls-files '.superpowers/**')" +git check-ignore -v --no-index .superpowers/sdd/example.md .claude/superpowers/state.json .worktrees/example/marker +git ls-files 'docs/superpowers/specs/**' 'docs/superpowers/plans/**' +git diff --cached --check +git status --short +``` + +Expected: no unresolved paths; no `.superpowers/**` index entries; all three operational paths ignored by `.gitignore`; the approved design and implementation plan remain tracked; no whitespace errors. + +- [ ] **Step 6: Commit the merge cleanup** + +Inspect the staged path set without opening operational artifact contents, then commit: + +```bash +git diff --cached --name-status +git commit -m "chore: keep superpowers artifacts local" +``` + +Expected: a merge commit with `origin/main` and the former feature HEAD as parents; no history rewrite and no tag movement. + +- [ ] **Step 7: Run focused policy verification** + +```bash +git merge-base --is-ancestor origin/main HEAD +test -z "$(git ls-tree -r --name-only HEAD -- .superpowers)" +git check-ignore -v --no-index .superpowers/sdd/example.md .claude/superpowers/state.json .worktrees/example/marker +git ls-tree -r --name-only HEAD -- docs/superpowers/specs docs/superpowers/plans +git diff --check origin/feature/uups-bank-demo..HEAD +git status --short --branch +``` + +Expected: `origin/main` is an ancestor; the committed tree has no `.superpowers` paths; intentional docs remain; the branch is ahead of `origin/feature/uups-bank-demo` only by the policy/design and merge-cleanup commits; the worktree has no tracked changes. + +- [ ] **Step 8: Run the complete repository gate** + +```bash +export PATH=/tmp/node-v24.18.0-linux-x64/bin:$PATH +export npm_config_cache=/tmp/uups-demo-npm-cache +export npm_config_offline=true +make verify +git diff --check +``` + +Expected GREEN: all Solidity, finalizer, process-safety, Base configuration, scanner, web lint/typecheck/test/build checks exit `0`; `git diff --check` exits `0`. + +--- + +## Final Review and Publication + +After both tasks pass their independent SDD review gates: + +1. Run the whole-plan review over the design commit and merge-cleanup range. +2. Run a fresh controller `make verify` and `git diff --check` at the reviewed HEAD. +3. Confirm `demo-start` and `demo-complete` still peel to their pre-cleanup targets. +4. Push `feature/uups-bank-demo` normally with no force option. +5. Query Gitea PR #2 and require `state: open`, `base: main`, `head: feature/uups-bank-demo`, and `mergeable: true`. +6. Preserve the existing linked worktree for PR feedback. From b862c68af31d8013dc8e288c76a939fb1881980e Mon Sep 17 00:00:00 2001 From: golem Date: Tue, 25 Aug 2026 01:51:29 -0600 Subject: [PATCH 30/30] fix: ignore superpowers artifacts at all depths --- .gitignore | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.gitignore b/.gitignore index ef314af..6c4eb4f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,7 @@ # Local agent/session state -.claude/superpowers/ -.superpowers/ -.worktrees/ +**/.superpowers/ +**/.claude/superpowers/ +**/.worktrees/ .env .env.local