fix: harden V1 deployment manifests

This commit is contained in:
golem
2026-08-21 02:36:17 -06:00
parent 52935acf5e
commit be8f01ea4e
8 changed files with 252 additions and 50 deletions
+1
View File
@@ -13,6 +13,7 @@ contract CheckState is DemoScript {
function run() external view { function run() external view {
_requireSupportedChain(block.chainid); _requireSupportedChain(block.chainid);
_printEducationalWarning();
string memory stage = vm.envOr("DEMO_EXPECTED_STAGE", string("v1")); string memory stage = vm.envOr("DEMO_EXPECTED_STAGE", string("v1"));
bool deployedStage = keccak256(bytes(stage)) == keccak256("deployed"); bool deployedStage = keccak256(bytes(stage)) == keccak256("deployed");
Manifest memory manifest = _readManifest(_manifestPath(ACTIVE_MANIFEST_PATH), !deployedStage); Manifest memory manifest = _readManifest(_manifestPath(ACTIVE_MANIFEST_PATH), !deployedStage);
+2 -1
View File
@@ -9,6 +9,7 @@ import {DemoScript} from "./lib/DemoScript.sol";
contract DeployV1 is DemoScript { contract DeployV1 is DemoScript {
function run() external returns (address tokenAddress, address proxy, address implementation) { function run() external returns (address tokenAddress, address proxy, address implementation) {
_requireSupportedChain(block.chainid); _requireSupportedChain(block.chainid);
_printEducationalWarning();
address sender = vm.envAddress("SCRIPT_SENDER"); address sender = vm.envAddress("SCRIPT_SENDER");
if (block.chainid == ANVIL_CHAIN_ID) { if (block.chainid == ANVIL_CHAIN_ID) {
@@ -50,7 +51,7 @@ contract DeployV1 is DemoScript {
_writeManifest(_manifestPath(PENDING_MANIFEST_PATH), manifest); _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) { if (block.chainid == ANVIL_CHAIN_ID) {
labels = new string[](3); labels = new string[](3);
actors = new address[](3); actors = new address[](3);
+1 -1
View File
@@ -8,8 +8,8 @@ import {DemoScript} from "./lib/DemoScript.sol";
contract SeedV1Demo is DemoScript { contract SeedV1Demo is DemoScript {
function run() external { function run() external {
if (block.chainid != ANVIL_CHAIN_ID) revert UnsupportedChain(block.chainid); if (block.chainid != ANVIL_CHAIN_ID) revert UnsupportedChain(block.chainid);
_printEducationalWarning();
Manifest memory manifest = _readManifest(_manifestPath(ACTIVE_MANIFEST_PATH), true); 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 ownerKey, address owner) = _deriveLocalActor(block.chainid, 0);
(uint256 aliceKey, address alice) = _deriveLocalActor(block.chainid, 1); (uint256 aliceKey, address alice) = _deriveLocalActor(block.chainid, 1);
+47 -1
View File
@@ -2,6 +2,7 @@
pragma solidity 0.8.35; pragma solidity 0.8.35;
import {Script} from "forge-std/Script.sol"; import {Script} from "forge-std/Script.sol";
import {console2} from "forge-std/console2.sol";
import {BankV1} from "../../src/BankV1.sol"; import {BankV1} from "../../src/BankV1.sol";
import {MockUSDC} from "../../src/MockUSDC.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 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 PENDING_MANIFEST_PATH = "deployments/pending.json";
string internal constant ACTIVE_MANIFEST_PATH = "deployments/active.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 UnsupportedChain(uint256 chainId);
error ManifestChainMismatch(uint256 expected, uint256 actual); error ManifestChainMismatch(uint256 expected, uint256 actual);
error MissingCode(string label, address target); error MissingCode(string label, address target);
error InvalidDeploymentBlock(); error InvalidDeploymentBlock();
error InvalidManifestSchema(uint256 schemaVersion); error InvalidManifestSchema(uint256 schemaVersion);
error InvalidActorConfiguration();
error UnexpectedState(string label, uint256 expected, uint256 actual); error UnexpectedState(string label, uint256 expected, uint256 actual);
error UnexpectedAddress(string label, address expected, address 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); 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); if (chainId != ANVIL_CHAIN_ID) revert UnsupportedChain(chainId);
privateKey = vm.deriveKey(ANVIL_TEST_PHRASE, index); privateKey = vm.deriveKey(ANVIL_TEST_PHRASE, index);
actor = vm.addr(privateKey); actor = vm.addr(privateKey);
@@ -67,6 +74,7 @@ abstract contract DemoScript is Script {
if (manifest.schemaVersion != 1) revert InvalidManifestSchema(manifest.schemaVersion); if (manifest.schemaVersion != 1) revert InvalidManifestSchema(manifest.schemaVersion);
if (manifest.chainId != block.chainid) revert ManifestChainMismatch(block.chainid, manifest.chainId); if (manifest.chainId != block.chainid) revert ManifestChainMismatch(block.chainid, manifest.chainId);
if (active && manifest.deploymentBlock == 0) revert InvalidDeploymentBlock(); if (active && manifest.deploymentBlock == 0) revert InvalidDeploymentBlock();
_validateActorConfiguration(manifest);
_requireCode("token", manifest.token); _requireCode("token", manifest.token);
_requireCode("proxy", manifest.proxy); _requireCode("proxy", manifest.proxy);
_requireCode("implementation", manifest.implementation); _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); 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) { function _serializeManifest(Manifest memory manifest) internal returns (string memory json) {
string memory objectKey = "demo-manifest"; string memory objectKey = "demo-manifest";
vm.serializeUint(objectKey, "schemaVersion", manifest.schemaVersion); vm.serializeUint(objectKey, "schemaVersion", manifest.schemaVersion);
+75 -1
View File
@@ -14,7 +14,7 @@ contract ScriptPreflightHarness is DemoScript {
_requireSupportedChain(chainId); _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); (, actor) = _deriveLocalActor(chainId, index);
} }
@@ -29,6 +29,10 @@ contract ScriptPreflightHarness is DemoScript {
function serializeManifest(Manifest calldata manifest) external returns (string memory) { function serializeManifest(Manifest calldata manifest) external returns (string memory) {
return _serializeManifest(manifest); return _serializeManifest(manifest);
} }
function educationalWarning() external pure returns (string memory) {
return _educationalWarning();
}
} }
contract ScriptPreflightTest is Test { contract ScriptPreflightTest is Test {
@@ -121,6 +125,50 @@ contract ScriptPreflightTest is Test {
harness.readManifest(path, true); 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 { function testSerializedManifestContainsPublicAddressesAndNoSecrets() public {
DemoScript.Manifest memory manifest = DemoScript.Manifest({ DemoScript.Manifest memory manifest = DemoScript.Manifest({
schemaVersion: 1, schemaVersion: 1,
@@ -245,6 +293,32 @@ contract ScriptPreflightTest is Test {
manifest = harness.readManifest(path, true); 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) { function _labels() internal pure returns (string[] memory labels) {
labels = new string[](3); labels = new string[](3);
labels[0] = "owner"; labels[0] = "owner";
+65 -34
View File
@@ -1,15 +1,26 @@
import { randomUUID } from "node:crypto"; 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 { dirname, join, resolve } from "node:path";
import { pathToFileURL } from "node:url"; import { pathToFileURL } from "node:url";
export const IMPLEMENTATION_SLOT = "0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc"; export const IMPLEMENTATION_SLOT = "0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc";
export const EDUCATIONAL_WARNING = "Educational demo — mock token — never use real funds.";
const NETWORKS = { const NETWORKS = {
anvil: { name: "anvil", chainId: 31337, canonical: "anvil.json", recovery: "make reset-local" }, anvil: {
"base-sepolia": { name: "base-sepolia", chainId: 84532, canonical: "base-sepolia.json", recovery: "make archive-base-manifest" }, 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) { export function networkSpec(network) {
const normalized = network === "baseSepolia" ? "base-sepolia" : network; const normalized = network === "baseSepolia" ? "base-sepolia" : network;
const spec = NETWORKS[normalized]; 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"); throw new Error("proxy implementation slot does not match pending manifest implementation");
} }
const confirmed = { ...pending, deploymentBlock }; const confirmed = publicManifest(pending, deploymentBlock);
validateManifest(confirmed); validateManifest(confirmed);
const path = join(root, "deployments", spec.canonical); const path = join(root, "deployments", spec.canonical);
await atomicWriteJson(path, confirmed); await atomicWriteJson(path, confirmed);
@@ -87,15 +98,17 @@ export async function readManifest(path, { pending = false } = {}) {
export function validateManifest(manifest, { 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"); 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"); if (manifest.schemaVersion !== 1) throw new Error("manifest schemaVersion must be 1");
const spec = networkSpec(manifest.network); const spec = networkSpec(manifest.network);
if (manifest.chainId !== spec.chainId) throw new Error(`manifest chain ID does not match ${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)) { if (!Number.isSafeInteger(manifest.deploymentBlock) || manifest.deploymentBlock < (pending ? 0 : 1)) {
throw new Error(`manifest deploymentBlock must be ${pending ? "a nonnegative integer" : "at least 1"}`); throw new Error(`manifest deploymentBlock must be ${pending ? "a nonnegative integer" : "at least 1"}`);
} }
assertPublicUrl(manifest.rpcUrl, "rpcUrl", false); if (manifest.rpcUrl !== spec.rpcUrl || manifest.explorerUrl !== spec.explorerUrl) {
assertPublicUrl(manifest.explorerUrl, "explorerUrl", true); 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); 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) { 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"); 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 }) { export async function atomicWrite(path, contents, io = { writeFile, rename }) {
const temporary = join(dirname(path), `.${randomUUID()}.tmp`); const temporary = join(dirname(path), `.${randomUUID()}.json`);
await io.writeFile(temporary, contents, { mode: 0o600 }); const operations = { writeFile, rename, rm, ...io };
await io.rename(temporary, path); 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) { function assertAddress(value, label) {
@@ -129,39 +148,50 @@ function assertAddress(value, label) {
} }
} }
function assertPublicUrl(value, label, allowEmpty) { function assertExactSchema(manifest) {
if (allowEmpty && value === "") return; for (const field of MANIFEST_FIELDS) {
if (typeof value !== "string") throw new Error(`manifest ${label} must be a URL`); if (!Object.hasOwn(manifest, field)) throw new Error(`manifest is missing required field ${field}`);
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`);
} }
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)) { if (Array.isArray(value)) {
value.forEach((item, index) => rejectSecretBearingContent(item, `${path}[${index}]`)); value.forEach((item, index) => rejectProhibitedStringValues(item, `${path}[${index}]`));
return; return;
} }
if (!value || typeof value !== "object") return; if (!value || typeof value !== "object") return;
for (const [key, nested] of Object.entries(value)) { for (const [key, nested] of Object.entries(value)) {
const nestedPath = path ? `${path}.${key}` : key; const nestedPath = path ? `${path}.${key}` : key;
if (/(?:private[_ -]?key|mnemonic|secret|password|credential|api[_ -]?key)/i.test(key)) { rejectProhibitedStringValues(nested, nestedPath);
throw new Error(`manifest contains secret-bearing field ${nestedPath}`);
}
rejectSecretBearingContent(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) { function isSuccessfulReceipt(status) {
return status === "0x1" || status === 1 || status === "1"; 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; 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 (command === "deploy") {
if (network !== "--rpc-url" || typeof rest[0] !== "string" || rest.length !== 1) throw new Error("usage: finalize-manifest.mjs deploy --rpc-url <url>"); if (network !== "--rpc-url" || typeof rest[0] !== "string" || rest.length !== 1) throw new Error("usage: finalize-manifest.mjs deploy --rpc-url <url>");
return finalizeDeployment({ rpc: fetchRpc(rest[0]) }); return finalizeDeployment({ root, rpc: fetchRpc(rest[0]) });
} }
throw new Error("usage: finalize-manifest.mjs preflight-deploy <anvil|base-sepolia> | deploy --rpc-url <url>"); throw new Error("usage: finalize-manifest.mjs preflight-deploy <anvil|base-sepolia> | deploy --rpc-url <url>");
} }
if (process.argv[1] && pathToFileURL(resolve(process.argv[1])).href === import.meta.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); console.error(error.message);
process.exitCode = 1; process.exitCode = 1;
}); });
+5 -4
View File
@@ -2,7 +2,7 @@ import { readFile } from "node:fs/promises";
import { join, resolve } from "node:path"; import { join, resolve } from "node:path";
import { pathToFileURL } from "node:url"; 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 }) { export async function selectManifest({ root = process.cwd(), network }) {
const spec = networkSpec(network); const spec = networkSpec(network);
@@ -14,13 +14,14 @@ export async function selectManifest({ root = process.cwd(), network }) {
return { source, active }; 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 <anvil|base-sepolia>"); if (argv.length !== 1) throw new Error("usage: select-manifest.mjs <anvil|base-sepolia>");
return selectManifest({ network: argv[0] }); return selectManifest({ root, network: argv[0] });
} }
if (process.argv[1] && pathToFileURL(resolve(process.argv[1])).href === import.meta.url) { 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); console.error(error.message);
process.exitCode = 1; process.exitCode = 1;
}); });
+56 -8
View File
@@ -1,17 +1,18 @@
import assert from "node:assert/strict"; 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 { tmpdir } from "node:os";
import { dirname, join } from "node:path"; import { dirname, join } from "node:path";
import test from "node:test"; import test from "node:test";
import { atomicWrite, finalizeDeployment, preflightDeploy } from "./finalize-manifest.mjs"; import { atomicWrite, finalizeDeployment, preflightDeploy, runFinalizeCli } from "./finalize-manifest.mjs";
import { selectManifest } from "./select-manifest.mjs"; import { runSelectCli, selectManifest } from "./select-manifest.mjs";
const TOKEN = "0x1000000000000000000000000000000000000001"; const TOKEN = "0x1000000000000000000000000000000000000001";
const PROXY = "0x2000000000000000000000000000000000000002"; const PROXY = "0x2000000000000000000000000000000000000002";
const IMPLEMENTATION = "0x3000000000000000000000000000000000000003"; const IMPLEMENTATION = "0x3000000000000000000000000000000000000003";
const OWNER = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"; const OWNER = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266";
const IMPLEMENTATION_SLOT = "0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc"; 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 () => { test("preflight rejects an existing target canonical manifest with the safe recovery command", async () => {
await withFixture(async (root) => { 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 () => { test("finalizer writes only a receipt-confirmed manifest and preserves active until selection", async () => {
await withFixture(async (root) => { await withFixture(async (root) => {
const pending = manifest({ deploymentBlock: 0 }); 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/], ["wrong chain", { rpc: fakeRpc({ chainId: "0x14a34" }) }, /chain ID/],
["missing code", { rpc: fakeRpc({ missingCode: TOKEN }) }, /has no code/], ["missing code", { rpc: fakeRpc({ missingCode: TOKEN }) }, /has no code/],
["implementation slot mismatch", { rpc: fakeRpc({ slot: TOKEN }) }, /implementation slot/], ["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) { 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 () => { test("selection atomically replaces active with only a valid named canonical manifest", async () => {
await withFixture(async (root) => { await withFixture(async (root) => {
const anvil = manifest({ deploymentBlock: 31 }); 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" }); await selectManifest({ root, network: "base-sepolia" });
assert.deepEqual(await readFile(join(root, "deployments", "anvil.json")), anvilBytes); assert.deepEqual(await readFile(join(root, "deployments", "anvil.json")), anvilBytes);
assert.deepEqual(await readJson(join(root, "deployments", "active.json")), base); 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: "base-sepolia", chainId: 84532, deploymentBlock: 0 }));
const beforeActive = await readFile(join(root, "deployments", "active.json")); 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(calls[0][0], "write");
assert.equal(dirname(calls[0][1]), dirname(target)); assert.equal(dirname(calls[0][1]), dirname(target));
assert.notEqual(calls[0][1], target); assert.notEqual(calls[0][1], target);
assert.match(calls[0][1], /\.json$/);
assert.deepEqual(calls[1], ["rename", calls[0][1], target]); 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 = {}) { function manifest(overrides = {}) {
const baseSepolia = overrides.network === "base-sepolia";
return { return {
schemaVersion: 1, schemaVersion: 1,
network: "anvil", network: baseSepolia ? "base-sepolia" : "anvil",
chainId: 31337, chainId: baseSepolia ? 84532 : 31337,
deploymentBlock: 1, deploymentBlock: 1,
rpcUrl: "http://127.0.0.1:8545", rpcUrl: baseSepolia ? "https://sepolia.base.org" : "http://127.0.0.1:8545",
explorerUrl: "", explorerUrl: baseSepolia ? "https://sepolia.basescan.org" : "",
token: TOKEN, token: TOKEN,
proxy: PROXY, proxy: PROXY,
implementation: IMPLEMENTATION, implementation: IMPLEMENTATION,