feat: deploy Uniswap to HyperEVM (999) - #164
Conversation
|
Review the following changes in direct dependencies. Learn more about Socket for GitHub.
|
|
All alerts resolved. Learn more about Socket for GitHub. This PR previously contained dependency changes with security issues that have been resolved, removed, or ignored. Ignoring alerts on:
|
| if admin: | ||
| entry["proxyAdmin"] = admin[0]["address"].lower() | ||
| if t["transactionType"] == "CREATE": | ||
| entry["initcodeHash"] = cast("keccak", t["transaction"]["input"])[2:] |
There was a problem hiding this comment.
Three deviations from what forge-chronicles writes, and this file gets copied into the briefcase repo on merge. initcodeHash is only recorded for CREATE txs, chronicles records it for CREATE2 too (5042.json PoolManager has one). For CREATE2 through the factory the input is salt plus initcode, so hash input[32:]. For the two proxies the hash covers the proxy creation input, chronicles hashes the implementation tx (extractor.js:203), so the NonfungibleTokenPositionDescriptor hash means something different than in every other registry. The Permit2 entry has no deploymentTxn or timestamp, which renders as "Invalid Date" in the generated markdown. V4SmokeTest reads .latest.Permit2.address, so keep the entry, in the 10143.json shape: deploymentTxn 0xb53a183abfae0bd1348b73e6505371d95be836bc6c6f5249b7d63f539eb2260b, timestamp 1739894100000.
There was a problem hiding this comment.
Fixed all three. One tweak on the CREATE2 hash: chronicles hashes the whole tx input, salt included, so I matched that. Proxies now hash the impl tx. Permit2 has the tx/timestamp you gave, verified onchain.
| | 4326 | Chain 4326 | [View Deployment](./4326.md) | | ||
| | 4663 | Chain 4663 | [View Deployment](./4663.md) | | ||
| | 5042 | Chain 5042 | [View Deployment](./5042.md) | | ||
| | 999 | HyperEVM Mainnet | [View Deployment](./999.md) | |
There was a problem hiding this comment.
This links to deployments/999.md, which is not in the PR. No CI generates markdown for real chains, only the Tenderly workflow runs chronicles. The other chains committed theirs by hand. It builds from the JSON locally with node lib/forge-chronicles -c 999 --skip-json -e https://hyperevmscan.io. Two things first: chains.json in lib/forge-chronicles names 999 "Wanchain Testnet" and sorts it after 480, and the Permit2 entry renders as Invalid Date until the registry point is fixed.
There was a problem hiding this comment.
Generated with your command. Two things chronicles gets wrong (the heading with an ssh remote, and chainid.network still calling 999 Wanchain) are fixed at the source in forge-chronicles#7; until that merges the driver patches them by hand.
|
|
||
| ### If the broadcast halts midway | ||
|
|
||
| Do not restart from scratch. Mark each landed contract `deploy:false` + `address` in |
There was a problem hiding this comment.
Marking UniswapV3Factory deploy:false with an address skips the two calls that follow it in Deploy-all, enableFeeAmount(100, 1) and setOwner. If the run halts between the factory creation and setOwner, the deployer stays owner and the recovery run does not repair it. Worth a line here. Also every protocol returns early on its protocol-level flag (view-quoter-v3, mixed-quoter, universal-router, swap-proxy), not only swap-router-contracts, so the same caveat applies to those.
There was a problem hiding this comment.
Good catch, added. Also noted that the protocol-level early return applies to view-quoter, mixed-quoter, UR and swap-proxy.
|
|
||
| bigblocks-on|bigblocks-off|bigblocks-status) | ||
| MODE=${STEP#bigblocks-} | ||
| if [[ $MODE == status ]]; then PRIVATE_KEY=0x$(openssl rand -hex 32) $PY script/hyperevm/toggle_big_blocks.py status $HLFLAG; |
There was a problem hiding this comment.
toggle_big_blocks.py derives the address from PRIVATE_KEY, so status with a random key queries a random address and always prints NONE. Pass the deployer address for status, or use the real key here too.
There was a problem hiding this comment.
Yep, that was always going to print NONE. Status now takes the deployer address, no key needed.
|
|
||
|
|
||
| def sh(*a, **k): | ||
| return subprocess.run(a, capture_output=True, text=True, env=ENV, **k).stdout.strip() |
There was a problem hiding this comment.
sh() drops the exit code. If BriefcaseHashes fails to compile, ref is empty, the loop runs over nothing and the script prints ALL REPRODUCIBLE with exit 0. Same for individual hashes missing from ref. This is the pre-deploy gate, so use check=True and assert ref covers FQ. The README says 24/24, FQ has 23 entries, and the v3 NonfungibleTokenPositionDescriptor implementation is not covered because of the library link.
There was a problem hiding this comment.
Fixed: hard-fails on compile errors or missing hashes. Count is 23 briefcase deployers plus the descriptor impl with the NFTDescriptor placeholder linked before hashing, SwapProxy is frozen and excluded.
| v FeeCollector $(a FeeCollector) src/pkgs/util-contracts/src/FeeCollector.sol:FeeCollector 0.8.19 200 paris no "$(enc 'constructor(address,address,address,address)' $FC_OWNER $UR $PERMIT2 $USDC)" | ||
|
|
||
| # ---- briefcase-pinned sources: UR + PermissionsAdapterFactory only reproduce at the briefcase-era submodule pins | ||
| pin() { local sm=$1 ref=$2; local t=$(git ls-tree $ref $sm | awk '{print $3}'); (cd $sm && git checkout -q $t && git submodule update --init --recursive -q); echo "$sm -> ${t:0:8}"; } |
There was a problem hiding this comment.
pin ends with echo, so a failed checkout returns 0, and the script runs without errexit, so verification continues on whatever revision is checked out. No trap either, so an interruption between line 90 and 95 leaves both submodules on the briefcase-era commits. verify_swapproxy.sh has the same shape, it exits 0 after six failed polls or a Sourcify none.
There was a problem hiding this comment.
Added errexit, a trap that restores both pins, and real exit codes in both scripts.
| | canonical | SwapProxy, ReservesLens | frozen / via-ir | Etherscan links by bytecode match once verified on any chain | | ||
|
|
||
| Fallbacks: indexer lag (wait 60s, rerun with the contract name filter); `--guess-constructor-args` if an arg | ||
| is wrong; Sourcify partial match means metadata drift, re-run reproducibility check for that contract. |
There was a problem hiding this comment.
foundry.toml sets bytecode_hash = none, so there is no metadata hash to match and Sourcify can only return a partial match. Right now 25 of 26 are partial, SwapProxy is the only exact one because it was compiled elsewhere. Partial is the expected result here, not a drift signal.
There was a problem hiding this comment.
Right, bytecode_hash = none means partial is the ceiling. Reworded.
| # drop contracts the task file did not deploy (e.g. ReservesLens already at its canonical address on 998) | ||
| order = [n for n in ORDER if not (n == "ReservesLens" and not P["v4"]["contracts"]["ReservesLens"]["deploy"])] | ||
| receipts = {r["transactionHash"]: r for r in b["receipts"]} | ||
| if len(creates) != len(order): |
There was a problem hiding this comment.
With the NFTDescriptorDeployer change, a run where the library already exists (998, the case that motivated it) has no NFTDescriptor create tx and this count check fails. Only ReservesLens has an exclusion. The recovery flow in the README produces a shorter broadcast too.
There was a problem hiding this comment.
Now derives the expected list from every deploy:true flag and drops NFTDescriptor when no create landed at its address, so 998-style and recovery runs work.
| @@ -0,0 +1,706 @@ | |||
| { | |||
There was a problem hiding this comment.
The other deployed chains do not keep task-pending.json after the deploy, only the timestamped copy. Deploy-all reads this file by chain id, so its presence reads as a pending 999 deploy. A rerun fails at the PoolManager CREATE2 collision in simulation, so nothing lands, but hyperevm.sh, verify.sh and build_registry.py all read task-pending.json, so removing it means pointing them at task-1789499742.json.
There was a problem hiding this comment.
Removed, scripts default to the newest timestamped task file.
| /// same address on every chain. NFTDescriptor has no constructor args, so if code already exists there | ||
| /// (e.g. a prior deploy on the same chain) it is byte-identical and is reused instead of reverting with | ||
| /// CreateCollision. Observed on HyperEVM testnet (998). | ||
| address private constant CREATE2_FACTORY = 0x4e59b44847b379578588920cA78FbF26c0B4956C; |
There was a problem hiding this comment.
The lookup is pinned to 0x4e59, the fallback is a raw CREATE2 from the caller. In forge test, or with --create2-deployer set to another address, the two disagree: an existing canonical library gets reused although this deploy would not have created it there, and repeated calls still collide at the real address. Fine for the script path this repo uses, the comment should say the reuse only holds under the default factory.
There was a problem hiding this comment.
Updated to say it only holds under the default factory.
|
@SocketSecurity ignore-all |
| UniswapInterfaceMulticall and PositionManager show fork labels (Huskey/Claw) there, byte-identical code. | ||
|
|
||
| ### If the broadcast halts midway | ||
|
|
There was a problem hiding this comment.
This paragraph is unchanged. The reply on the earlier thread says the setOwner caveat was added, but it is not in the diff. Marking UniswapV3Factory deploy:false with an address skips enableFeeAmount(100, 1) and setOwner, so a halt between the factory creation and setOwner leaves the deployer as owner and the recovery run does not repair it. Same for the protocol-level early return on view-quoter-v3, mixed-quoter, universal-router and swap-proxy.
There was a problem hiding this comment.
Still unchanged on 8e6d061, the paragraph is now at line 108.
There was a problem hiding this comment.
You're right, my edit didn't land and I said it had. Fixed now, sorry about that.
There was a problem hiding this comment.
Fixed in the latest commit.
| @@ -0,0 +1,86 @@ | |||
| { | |||
| "_comment": "Contracts on chain 999 that the registry records outside the original Deploy-all broadcast, plus Deploy-all outputs to drop. latest.UniversalRouter is the re-cut 2.2.0 release (tag 2.2.0 @ 64027f3, includes the #499 sweep fix, v4-periphery a7af5b34) deployed 2026-09-18 from Uniswap/universal-router script/deployParameters/DeployHyperEVM.s.sol. The 2.1.2 router deployed 2026-09-17 stays as UniversalRouter#v2.1.2. Deploy-all's first-cut 2.2.0 router (0x0549...492A) and its FeeCollector (0xda70...EeA3) are superseded and not recorded; the FeeCollector recorded is the 2026-09-18 redeploy wired to the new router. Permit2 is the pre-existing canonical instance.", | |||
There was a problem hiding this comment.
script/deployParameters/DeployHyperEVM.s.sol does not exist at 64027f3 (tag 2.2.0), and I could not find it on any branch or PR of universal-router. The parameters for the canonical router on 999 are then only recorded here. Worth committing that file to universal-router so the deploy is reproducible from the source repo.
There was a problem hiding this comment.
Agreed, committed it upstream as two draft PRs (main and the 2.1.x branch).
| else PRIVATE_KEY=$(cast wallet private-key --account $ACCOUNT) $PY script/hyperevm/toggle_big_blocks.py $MODE $HLFLAG; fi;; | ||
|
|
||
| dry-run) # Deploy-all reads task-pending.json; the committed record is the timestamped copy, so stage it | ||
| cp "$(ls -t script/deploy/tasks/$CHAIN/task-*.json | head -1)" script/deploy/tasks/$CHAIN/task-pending.json |
There was a problem hiding this comment.
task-*.json also matches task-pending.json. On a second dry-run the newest file is the pending copy itself, cp fails on a self-copy and set -e aborts. build_registry has the same glob and sorts by name, where task-pending.json sorts after every task-17…json, so it picks the pending file whenever one is present. Exclude task-pending.json from the glob in all three places.
There was a problem hiding this comment.
Good catch, the glob now skips the pending file everywhere.
| e = {"address": addr if x.get("preexisting") else addr.lower(), "proxy": False, "deploymentTxn": tx, "initcodeHash": h} | ||
| latest[x["name"]] = {**e, "timestamp": ts, "commitHash": commit, **({"note": "pre-existing canonical deployment; not deployed by this repo"} if x.get("preexisting") else {"note": x["run"]})} | ||
| if x.get("preexisting"): | ||
| run1[x["name"]] = {**e, "input": x["input"]} |
There was a problem hiding this comment.
Permit2 is folded into the Deploy-all history entry, so the markdown shows it under the September run with this repo's commit although it was deployed in February 2025 by someone else. Keep it in latest and drop it from history, or give it its own entry with its real timestamp.
There was a problem hiding this comment.
Permit2 now has its own history entry with its real date and no repo commit.
| } | ||
| } | ||
| ], | ||
| "skip": ["UniversalRouter", "FeeCollector"] |
There was a problem hiding this comment.
Per our chat, dropped both again. The README keeps the never-allowlist warning for the first-cut router address.
9202889 to
e8b4b91
Compare
bbcaa9e to
8828135
Compare
| } | ||
| } | ||
| }, | ||
| "NonfungibleTokenPositionDescriptor": { |
There was a problem hiding this comment.
NonfungibleTokenPositionDescriptor and SwapProxy were created in the 2026-09-15 run (broadcast 1789501501114, e34ba78) but sit here in the 2026-09-18 FeeCollector redeploy entry, and their latest.commitHash is 58a1e67 instead of e34ba78. merge_extra_deploys.py line 77 takes the first history entry whose commitHash is on main. History is newest first, and once a previous merge has rewritten the FeeCollector run to 58a1e67, that entry wins. Select the original run explicitly (broadcast timestamp, or the oldest entry) and regenerate this file.
There was a problem hiding this comment.
Right, newest-first bit me. The original run is now selected by its broadcast timestamp (pinned in extra-deploys.json) and both entries sit under e34ba78 again.
| txj = json.loads(sh("cast", "tx", tx, "--json", "--rpc-url", rpc)) | ||
| data = txj["input"] | ||
| salt, initcode = "0x" + data[2:66], "0x" + data[66:] | ||
| created = sh("cast", "create2", "--deployer", txj["to"], "--salt", salt, "--init-code", initcode).split()[-1].lower() |
There was a problem hiding this comment.
cast create2 prints address and salt on one line (1.8.4 prints "
\t"), so split()[-1] is the salt and the SwapProxy check fails with "created 0xd003..., not 0x0000000085e1...". The version that produced the committed file must have printed the address last. Compute the address in Python (keccak of 0xff, deployer, salt, keccak(initcode)) or take the first token, so the script does not depend on the cast output format.There was a problem hiding this comment.
Good catch, my cast version prints the address alone. Now computed from keccak(0xff, deployer, salt, keccak(initcode)) with no dependence on cast's output format.
| # commitHash cites the main commit this branch is based on (chronicles convention; squash-safe). Override with COMMIT=. | ||
| for b in $(ls broadcast/Deploy-all.s.sol/$CHAIN/run-1*.json | sort); do | ||
| cp "$b" broadcast/Deploy-all.s.sol/$CHAIN/run-latest.json | ||
| node lib/forge-chronicles Deploy-all.s.sol -c $CHAIN -e https://hyperevmscan.io --force |
There was a problem hiding this comment.
Rerunning $H registry on the committed file does not reproduce it. The chronicles call has no -r $RPC, and on a replay PositionDescriptor is already in latest as a proxy while the broadcast carries its implementation address, so extractor.js line 323 aborts with "No RPC URL provided". With the RPC added, chronicles dedupes against history contents, and drop_addresses removed the first-cut FeeCollector from history, so every replay re-adds it under a new tag before the merge drops it again, and history grows each run. Since this loop already replays every broadcast, deleting deployments/json/999.json at the start of the step and rebuilding from scratch fixes both, and removes the ordering dependency behind the misfiled entries.
There was a problem hiding this comment.
Agreed, the step now deletes the JSON first and rebuilds from every broadcast. A replay on the committed file is byte-identical.
| latest["commitHash"] = target["commitHash"] | ||
| else: | ||
| if not x.get("nocommit"): | ||
| latest["commitHash"] = commit |
There was a problem hiding this comment.
After this PR merges, git merge-base HEAD origin/main is HEAD itself, so a later rerun rewrites the router and UnsupportedProtocol entries from 58a1e67 to whatever main is at that moment although nothing was redeployed. If the convention is "the main commit the deploy was based on", record it once per run in extra-deploys.json instead of deriving it at merge time.
There was a problem hiding this comment.
Yep, derived at merge time was wrong. The base commit is pinned in extra-deploys.json now.
| echo "deployer occurrences in constructor args (expect 0): $(jq -r '.transactions[] | select(.transactionType!="CALL") | .transaction.input' broadcast/Deploy-all.s.sol/$CHAIN/dry-run/run-latest.json | grep -oic ${DEPLOYER#0x})";; | ||
|
|
||
| deploy) # Deploy-all reads task-pending.json and, with rename:true, archives it as task-<ts>.json afterwards | ||
| [ -f script/deploy/tasks/$CHAIN/task-pending.json ] || { echo "no task-pending.json: write the exact task you intend to deploy first (make_feecollector_task.py, or copy a task-1*.json), never guess"; exit 1; } |
There was a problem hiding this comment.
make_feecollector_task.py is deleted in this PR.
| "deploymentTxn": "0xbc5218850f9c611b5835ed396f8f619e77510d7b2bf8550f4d7866c4f3eee5cb", | ||
| "initcodeHash": "cc8a477ccda73a74197ccac91cfe41adf7fa0d91131a84fc8c196f30fc387baf", | ||
| "timestamp": 1789678080000, | ||
| "commitHash": "58a1e67", |
There was a problem hiding this comment.
Merged entries carry block timestamps in latest (UnsupportedProtocol 1789678080000) but their history run carries 1789678140000. Chronicles gives latest and history the same run timestamp, so the markdown shows two dates for one run. Minor.
There was a problem hiding this comment.
Fixed, merged entries use the run timestamp in latest like chronicles does.
8828135 to
0b203a2
Compare
Tooling for a dual-block chain (HyperEVM, 999) in script/hyperevm/: step driver, HyperCore big-block toggle, explorer verification for hyperevmscan + Sourcify, and merge_extra_deploys.py, which adds to the forge-chronicles registry the contracts chronicles cannot see (unnamed CREATE2 deploys, pre-existing Permit2, routers deployed from the universal-router repo). test/HyperEVMDeploy.t.sol: fork test asserting governance ownership (Wormhole receiver), deployer owns nothing, and fake-token pools with swaps on v2/v3/v4 through the routers and UniversalRouter. Runs via hyperevm.sh test (*.t.sol is skipped in foundry.toml). NFTDescriptorDeployer reuses the library if it already sits at its CREATE2 address (collision seen on 998); pragma >=0.8.0 for address.code. foundry.toml skips src/pkgs/**/node_modules/** so a local yarn install inside a submodule no longer breaks forge builds or forge-chronicles. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Full stack on HyperEVM with governance ownership from block one: every owner/admin role is the Uniswap Wormhole receiver 0x2d09d0c2f82c59b19b3c65a48ce2c550bf0921f9; FeeCollector is owned by the ops KMS EOA. UniversalRouter is the re-cut 2.2.0 (universal-router tag 2.2.0 @ 64027f3, #499 sweep fix, v4-periphery #564 in / #584 reverted) at 0x9aFe3C497e19501DB228F28CdBdD29bC98F65DBa, deployed from the universal-router repo with the Across SpokePool and PermissionsAdapterFactory wired; FeeCollector redeployed pointing at it. The 2.1.2 router (0xeD27...d6bF) is recorded as UniversalRouter#v2.1.2. Deploy-all's first-cut 2.2.0 router and FeeCollector are superseded and not recorded (agreed in review); never allowlist that router in a permissioned hook. All 27 recorded contracts verified on hyperevmscan and Sourcify. Registry written by forge-chronicles plus merge_extra_deploys.py; deployments/999.md rendered from it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
0b203a2 to
e27e338
Compare
| runs = {} | ||
| for x in cfg["extra"]: | ||
| addr, tx = x["address"], x["deploymentTxn"] | ||
| rc = json.loads(sh("cast", "receipt", tx, "--json", "--rpc-url", rpc)) |
There was a problem hiding this comment.
Fixing in a follow-up PR: switching to the field accessors (cast receipt contractAddress / blockNumber, cast tx to / input), no --json left.
There was a problem hiding this comment.
Switched to the field accessors (cast receipt contractAddress / blockNumber, cast tx to / input), no --json left.
| forge build >/dev/null || exit 1 | ||
| # never lose the committed registry if a step fails: work on a backup and restore on any error | ||
| cp "$J" "$J.bak" 2>/dev/null || true | ||
| trap '[ -f "$J.bak" ] && { echo "registry step failed, restoring $J"; mv "$J.bak" "$J"; }; cp broadcast/Deploy-all.s.sol/'$CHAIN'/run-1*.json /dev/null 2>&1' ERR |
There was a problem hiding this comment.
You're right, that cp was broken. Follow-up PR makes the trap copy the last file of the sorted list to run-latest.json explicitly.
There was a problem hiding this comment.
You're right, that cp was broken. The trap now copies the last file of the sorted list to run-latest.json explicitly.
Deploys the full Uniswap stack to HyperEVM (chain 999): v2, v3, v4, quoters, SwapRouter02, UniversalRouter, SwapProxy, FeeOnTransferDetector, FeeCollector. 27 contracts, all verified on hyperevmscan and Sourcify.
Router and FeeCollector: the recorded UniversalRouter is the re-cut 2.2.0 release (tag
2.2.0@64027f3, release, includes the #499 permissioned-sweep fix and v4-periphery #564; #584 was reverted upstream and is not included) at0x9aFe3C497e19501DB228F28CdBdD29bC98F65DBa, deployed from the universal-router repo with the Across SpokePool and PermissionsAdapterFactory wired. FeeCollector was redeployed pointing at it:0xf8a6dee153dfe5c43f66564252b6df7885fa6165. The first-cut 2.2.0 router (0x05498c32f8F4825BCD4e5c6325134431B12d492A) and FeeCollector (0xda7030C3A45EdF79421E8e7F8CcF4d7A3Fa4EeA3) that Deploy-all produced on 2026-09-15 are superseded and not recorded (agreed in review); the liveUniversalRouterandFeeCollectorentries carry anotenaming the superseded address (rendered into999.mdonce forge-chronicles #7 lands), the README's "Superseded deployments" section lists both, and the router must never be added to a permissioned hook allowlist. The 2.1.2 router deployed 2026-09-17 is kept asUniversalRouter#v2.1.2.Ownership is governance from block one. Every protocol owner/admin role (v2 feeToSetter, v3 owner, PoolManager owner, both descriptor ProxyAdmins) is the Uniswap Wormhole receiver
0x2d09d0c2f82c59b19b3c65a48ce2c550bf0921f9. The one exception, same as every other chain: FeeCollector is owned by the ops KMS EOA0xbE84…1b55because fee sweeps need a hot key. No handover step later.HyperEVM specifics
Also in here
script/hyperevm/: runbook + driver, HyperCore big-block toggle, both-explorer verify script, andmerge_extra_deploys.pyfor the few contracts forge-chronicles cannot record (unnamed CREATE2 deploys, pre-existing Permit2, routers deployed from the universal-router repo)test/HyperEVMDeploy.t.sol: fork test, ownership + fake pools with swaps on v2/v3/v4 (7/7 against mainnet; not in CI since*.t.solis skipped in foundry.toml, run via the driver)NFTDescriptorDeployer: reuses the library if it already sits at its CREATE2 address instead of revertingfoundry.toml: skipssrc/pkgs/**/node_modules/**; a stale localyarn installinside the universal-router submodule was what broke local forge builds and made chronicles look unusableCompanion PRs: universal-router #517 (2.2.0
DeployHyperEVM.s.sol, the file the canonical router was deployed from), #518 (same onrelease/2.1.xfor the 2.1.2 router), forge-chronicles #7 (chain-name override for 999, ssh-remote heading fix).Registry:
deployments/json/999.jsonwritten by forge-chronicles over the two Deploy-all broadcasts, thenmerge_extra_deploys.py(idempotent) adds the six entries chronicles cannot see and the PermissionsAdapterFactory constructor arg forge left undecoded;deployments/999.mdrendered by chronicles. Two commits: tooling, then the deployment record. EverycommitHashcites a commit already onmain(chronicles' convention: the code the deploy ran from, never the record commit), so any merge mode is safe.🤖 Generated with Claude Code