Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion evm/.common.example.env
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# API keys
export API_KEY_INFURA=
# Etherscan V2: one key verifies on every supported chain (mainnet, optimism, arbitrum, base, ...)
export API_KEY_ETHERSCAN=
export API_KEY_OPTIMISTIC_ETHERSCAN=
# Foundry
export FOUNDRY_PROFILE="default"
4 changes: 2 additions & 2 deletions evm/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ fund-deployer:
_deploy:
$(eval include .common.env)
$(eval include $(ENV_FILE))
$(eval ETHSCAN_KEY = $(if $(findstring optimism,$(ENV_FILE)),${API_KEY_OPTIMISTIC_ETHERSCAN},${API_KEY_ETHERSCAN}))
$(eval ETHSCAN_KEY = ${API_KEY_ETHERSCAN})

ifeq ($(IS_ANVIL),)
$(eval RPC_URL = https://${CHAIN_NAME}.infura.io/v3/${API_KEY_INFURA})
Expand Down Expand Up @@ -174,7 +174,7 @@ endif
_p3_deploy:
$(eval include .common.env)
$(eval include $(ENV_FILE))
$(eval ETHSCAN_KEY = $(if $(findstring optimism,$(ENV_FILE)),${API_KEY_OPTIMISTIC_ETHERSCAN},${API_KEY_ETHERSCAN}))
$(eval ETHSCAN_KEY = ${API_KEY_ETHERSCAN})

ifeq ($(IS_ANVIL),)
$(eval RPC_URL = https://${CHAIN_NAME}.infura.io/v3/${API_KEY_INFURA})
Expand Down
39 changes: 30 additions & 9 deletions evm/deployments/10.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,34 @@
}
},
"LockedTokenStakerBackers": {
"address": "0x688CfB3e55fCE2540b5491E923Dc6a9C4f240176"
"address": "0x37BAdde9E3c9E108065Db36dCcce91E00556e334",
"admin": "0x8B79481a2d8EEbe0F4d6Fd1705b0598540875407",
"implementation": "0xB52b4633eA577A143fAa811127CeE7163b023bFb",
"proxy": {
"implementation": "0xB52b4633eA577A143fAa811127CeE7163b023bFb",
"admin": "0x8B79481a2d8EEbe0F4d6Fd1705b0598540875407",
"type": "transparent"
}
},
"LockedTokenStakerReown": {
"address": "0x5f630a47DE14e346fC28deB8fE379833A6F6B9B2"
"address": "0xCcCf4C051C6F5a82D6bD76a1F66fc3cCdE8b3D6b",
"admin": "0x54594f1aF2A6bd876bF0f6d45FB7c3cb54eFB5d5",
"implementation": "0xABB9F4CCb5025c446c7FF2e000c705332d21f760",
"proxy": {
"implementation": "0xABB9F4CCb5025c446c7FF2e000c705332d21f760",
"admin": "0x54594f1aF2A6bd876bF0f6d45FB7c3cb54eFB5d5",
"type": "transparent"
}
},
"LockedTokenStakerWalletConnect": {
"address": "0x8621034C9acD397cc5921d036225f75699c710FA"
"address": "0xb6034870fdd84f09cB63433297C667A1A7771824",
"admin": "0xBa900e340f781A18C34Ff9D73CDb9756329a71d9",
"implementation": "0xEc32c9280163795921caF79205C6850C67023F04",
"proxy": {
"implementation": "0xEc32c9280163795921caF79205C6850C67023F04",
"admin": "0xBa900e340f781A18C34Ff9D73CDb9756329a71d9",
"type": "transparent"
}
},
"ManagerTimelock": {
"address": "0xB5EFe3783Db55B913C79CBdB81C9d2C0a993f5f0"
Expand All @@ -39,29 +60,29 @@
"Pauser": {
"address": "0x9163de7F22A9f3ad261B3dBfbB9A42886816adE7",
"admin": "0x8714E77FA6Aca75A9b21d79295ec7cF04E4821a8",
"implementation": "0xB7D30c8C3bF4b7cb9F06A3D7654E8971021ad58e",
"implementation": "0xf69771dAc1c783F6Ae6ddbC3BF6B4869E7c43a70",
"proxy": {
"implementation": "0xB7D30c8C3bF4b7cb9F06A3D7654E8971021ad58e",
"implementation": "0xf69771dAc1c783F6Ae6ddbC3BF6B4869E7c43a70",
"admin": "0x8714E77FA6Aca75A9b21d79295ec7cF04E4821a8",
"type": "transparent"
}
},
"StakeWeight": {
"address": "0x521B4C065Bbdbe3E20B3727340730936912DfA46",
"admin": "0x9898b105fe3679f2d31c3A06B58757D913D88e5F",
"implementation": "0xC746f9a45a06CBF2ad761442821C91A479151cc3",
"implementation": "0x6845541121e555D1245fe17c6b44273B089C3844",
"proxy": {
"implementation": "0xC746f9a45a06CBF2ad761442821C91A479151cc3",
"implementation": "0x6845541121e555D1245fe17c6b44273B089C3844",
"admin": "0x9898b105fe3679f2d31c3A06B58757D913D88e5F",
"type": "transparent"
}
},
"StakingRewardDistributor": {
"address": "0xF368F535e329c6d08DFf0d4b2dA961C4e7F3fCAF",
"admin": "0x28672bf553c6AB214985868f68A3a491E227aCcB",
"implementation": "0xF6d23184E44f282883c0d145C973442fc7A33AB8",
"implementation": "0x98c27ae4C11F67CDE0E3aFDf90fBba01Bf826238",
"proxy": {
"implementation": "0xF6d23184E44f282883c0d145C973442fc7A33AB8",
"implementation": "0x98c27ae4C11F67CDE0E3aFDf90fBba01Bf826238",
"admin": "0x28672bf553c6AB214985868f68A3a491E227aCcB",
"type": "transparent"
}
Expand Down
10 changes: 8 additions & 2 deletions evm/foundry.toml
Original file line number Diff line number Diff line change
Expand Up @@ -42,15 +42,21 @@ verbosity = 2
tab_width = 4
wrap_comments = true

# Etherscan V2: a single API key covers every supported chain (routed by chainid),
# so all entries share ${API_KEY_ETHERSCAN}. The legacy per-explorer V1 keys are deprecated.
[etherscan]
sepolia = { key = "${API_KEY_ETHERSCAN}" }
mainnet = { key = "${API_KEY_ETHERSCAN}" }
optimism = { key = "${API_KEY_OPTIMISTIC_ETHERSCAN}" }
optimism_sepolia = { key = "${API_KEY_OPTIMISTIC_ETHERSCAN}" }
optimism = { key = "${API_KEY_ETHERSCAN}" }
optimism_sepolia = { key = "${API_KEY_ETHERSCAN}" }
arbitrum = { key = "${API_KEY_ETHERSCAN}", chain = 42161 }
base = { key = "${API_KEY_ETHERSCAN}", chain = 8453 }

[rpc_endpoints]
localhost = "http://localhost:8545"
mainnet = "https://mainnet.infura.io/v3/${API_KEY_INFURA}"
sepolia = "https://sepolia.infura.io/v3/${API_KEY_INFURA}"
optimism = "https://optimism-mainnet.infura.io/v3/${API_KEY_INFURA}"
optimism_sepolia = "https://optimism-sepolia.infura.io/v3/${API_KEY_INFURA}"
arbitrum = "https://arbitrum-mainnet.infura.io/v3/${API_KEY_INFURA}"
base = "https://base-mainnet.infura.io/v3/${API_KEY_INFURA}"
36 changes: 36 additions & 0 deletions evm/script/deploy/TimelockDeploy.s.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
// SPDX-License-Identifier: MIT
pragma solidity >=0.8.25 <0.9.0;

import { console2 } from "forge-std/console2.sol";
import { Timelock } from "src/Timelock.sol";
import { BaseScript } from "script/Base.s.sol";

/// @title TimelockDeploy
/// @notice Deploys a standalone Timelock for chains that lack one (Arbitrum, Base), so the WCT ProxyAdmin
/// owner and token DEFAULT_ADMIN_ROLE can sit behind a timelock rather than the admin multisig
/// directly. The role/ownership hand-off itself is done separately from the multisig — this script
/// ONLY deploys the timelock.
/// @dev Config mirrors EthereumDeploy: 1-week delay, multisig as sole proposer + executor, dedicated
/// canceller. Timelock is self-administered (OZ admin = address(0)), so future role changes require
/// a timelock proposal.
///
/// IMPORTANT: on production chains BaseScript reverts unless ETH_FROM is set, to avoid falling back to the
/// public test mnemonic. ALWAYS pass your real signer. Example:
/// CHAIN_ID=42161 ETH_FROM=<ledger-addr> ADMIN_ADDRESS=<multisig> TIMELOCK_CANCELLER_ADDRESS=<canceller> \
/// forge script script/deploy/TimelockDeploy.s.sol --rpc-url arbitrum --broadcast --ledger --sender $ETH_FROM
/// (repeat with CHAIN_ID=8453 --rpc-url base for Base)
contract TimelockDeploy is BaseScript {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Auto Review Issue: Arbitrum/Base not in BaseScript production-chain guard — test mnemonic fallback risk

Severity: HIGH
Category: security
Tool: Claude Auto Review

Context:

  • Pattern: BaseScript constructor only gates the test-mnemonic fallback for mainnet and optimism. Arbitrum (42161) and Base (8453) are not in isMainnetOrOptimism, so a broadcaster is silently derived from the public test test test…junk mnemonic if ETH_FROM is unset.
  • Risk: The deployed Timelock would be administered by the well-known test key (0xf39F…2266). Anyone with that key could immediately call grantRole/revokeRole on it, subverting the CONT-10 remediation entirely.
  • Impact: Compromised ProxyAdmin ownership and DEFAULT_ADMIN_ROLE for WCT on Arbitrum/Base.
  • Trigger: Operator runs the script without setting ETH_FROM — easy to miss on a new chain.

The script already documents this via an IMPORTANT comment (line 18-20), but documentation doesn't prevent mistakes for a security-critical contract. The fix belongs in BaseScript:

bool isProduction =
    block.chainid == getChain("mainnet").chainId ||
    block.chainid == getChain("optimism").chainId ||
    block.chainid == 42161 || // Arbitrum One
    block.chainid == 8453;    // Base
if (isProduction && specifiedSender == address(0)) {
    revert("You must specify a sender for a production deployment");
}

Fix this →

function run() public broadcast returns (Timelock timelock) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Auto Review Issue: No deployment artifact written for Arbitrum/Base timelock

Severity: MEDIUM
Category: code_quality
Tool: Claude Auto Review

Context:

  • Pattern: EthereumDeploy calls _writeEthereumDeployments + DeploymentJsonWriter.writeEthereumDeploymentsToJson to persist the deployed address. TimelockDeploy only emits console2.log.
  • Risk: The deployed Timelock address on Arbitrum/Base is only visible in broadcast output — not recorded in a machine-readable file.
  • Impact: The CONT-10 hand-off scripts (Safe bundles) that reference the timelock address must be manually copied from broadcast logs; a transcription error would point the ProxyAdmin/admin role at the wrong address.
  • Trigger: Any time the deployment output needs to be referenced programmatically or verified later.

Minimally: gate the JSON write behind BROADCAST/WRITE_JSON env vars like other scripts and use DeploymentJsonWriter (or create the Arbitrum/Base JSON files). At minimum, write the address to a deployment JSON for the corresponding chain ID.

address admin = vm.envAddress("ADMIN_ADDRESS");
address canceller = vm.envAddress("TIMELOCK_CANCELLER_ADDRESS");

// 1 weeks == 604800s, the max allowed by Timelock and matching the Ethereum/Optimism delay.
timelock = new Timelock(1 weeks, _singleAddressArray(admin), _singleAddressArray(admin), canceller);

console2.log("Timelock deployed at:", address(timelock));
console2.log(" min delay (s): ", timelock.getMinDelay());
console2.log(" proposer+executor: ", admin);
console2.log(" canceller: ", canceller);
console2.log("Next: from the multisig, hand off ownership (grant DEFAULT_ADMIN_ROLE + transferOwnership, then renounce).");
}
}
Loading