feat: establish UUPS bank V1

This commit is contained in:
golem
2026-08-17 16:54:31 -06:00
parent 1f4175ba72
commit 0664eb4688
4 changed files with 192 additions and 5 deletions
@@ -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 Anvils 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 scanners 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 scanners 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:
+60
View File
@@ -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 {}
}
+94
View File
@@ -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();
}
}
+33
View File
@@ -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);
}
}