From 90b0a11b8a3021022ebf09db951e25aae6eb53f2 Mon Sep 17 00:00:00 2001 From: golem Date: Fri, 21 Aug 2026 16:01:35 -0600 Subject: [PATCH] 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"