From baf6b8830942250da65335cf7cd96066f58d4b6c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 22 Aug 2026 07:37:29 +0000 Subject: [PATCH 1/2] Credit the deployer's HyperCore account from HyperEVM A deployer funded only for EVM gas cannot opt into HyperEVM's big blocks: `evmUserModify` is accepted only from an address HyperCore already knows, and an address becomes one by holding a Core asset. HYPE is HyperEVM's native gas token and value sent to the system contract at 0x2222222222222222222222222222222222222222 is credited on Core to the sender, so the deployer credits itself out of the balance it already has, with no external bridge in it. `LibHyperCore` carries the mechanism and every guard; `CreditHyperCore` is the entry point, run by hand with an amount in `HYPERCORE_CREDIT_WEI`. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 48 +++++ script/CreditHyperCore.sol | 84 ++++++++ src/lib/LibHyperCore.sol | 277 +++++++++++++++++++++++++ test/src/lib/LibHyperCore.t.sol | 355 ++++++++++++++++++++++++++++++++ 4 files changed, 764 insertions(+) create mode 100644 script/CreditHyperCore.sol create mode 100644 src/lib/LibHyperCore.sol create mode 100644 test/src/lib/LibHyperCore.t.sol diff --git a/README.md b/README.md index 07edc1f..a22a237 100644 --- a/README.md +++ b/README.md @@ -406,6 +406,54 @@ published under the previous merge-driven lifecycle predates that record and has none, so `src/generated/` holds no directory for it; those versions stay published, and consumers pin exact versions and are unaffected. +## Crediting the deployer's HyperCore account + +HyperEVM interleaves small fast blocks with large slow ones, and a deployment +too big for the fast block's gas cap has to go in a big block. Big blocks are +opted into with an `evmUserModify` action, which HyperCore accepts only from an +address it already knows — one that holds a Core asset. A deployer funded purely +to pay EVM gas is not that address, so the deploy above cannot reach HyperEVM at +all until something puts an asset on Core for it. + +Nothing external has to. HYPE is HyperEVM's native gas token rather than an +ERC20, and value sent to the system contract at +`0x2222222222222222222222222222222222222222` is credited on Core to whoever sent +it. The deployer already holds HYPE, because that is what it pays gas in, so it +credits itself: + +```sh +HYPERCORE_CREDIT_WEI=10000000000000000 DEPLOYMENT_KEY=0x... \ + nix develop -c forge script script/CreditHyperCore.sol:CreditHyperCore --legacy +``` + +Run exactly that first, without `--broadcast`: it is a dry run against a fork of +HyperEVM that executes every guard and the transfer itself and sends nothing, so +anything the real run would refuse is refused there for free. Add `--broadcast` +to send it. `--legacy` for the same reason the deploy workflow carries a +`legacy` input — HyperEVM's RPC rejects the fee-history ranges EIP-1559 +estimation asks for. No `--rpc-url`: the script forks the `hyperevm` alias +itself. + +`HYPERCORE_CREDIT_WEI` is EVM wei, and required — an amount of real money is not +something to default. It has to be a whole number of Core wei, which is +`10 ** 10` EVM wei, because HYPE has 8 wei decimals on Core against 18 on the +EVM and the remainder is **burned** rather than credited. An amount smaller than +that is burned in full: the transfer succeeds, the HYPE is gone, and the address +is still not a HyperCore user. `LibHyperCore` refuses it rather than rounding. + +It also refuses to run anywhere but chain 999. That address is a system contract +on HyperEVM and an ordinary unowned address on every other chain, where value +sent to it is not rejected, just unrecoverable — so the chain id is checked +before anything else, and the code hash at the system address is checked against +a pin straight after, because a chain id alone does not say the contract behind +it is the one that emits the log Core credits from. + +This is not on `Manual sol artifacts`. That workflow exports `DEPLOYMENT_SUITE`, +`DEPLOYMENT_NETWORK` and `DEPLOYMENT_KEY` and nothing else, and an amount of +money travelling under one of those names would be worse than a hand-run script. +It is run once per deployer address and never again: a HyperCore user does not +stop being one, so a second run is more money for no further effect. + ## Install Via [soldeer](https://soldeer.xyz): diff --git a/script/CreditHyperCore.sol b/script/CreditHyperCore.sol new file mode 100644 index 0000000..9566085 --- /dev/null +++ b/script/CreditHyperCore.sol @@ -0,0 +1,84 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity =0.8.25; + +import {Script} from "forge-std-1.16.2/src/Script.sol"; + +import {LibHyperCore} from "../src/lib/LibHyperCore.sol"; + +/// @title CreditHyperCore +/// @notice Makes the deployer a HyperCore user, by sending it some of its own +/// HYPE. +/// +/// Deploying anything sizeable to HyperEVM needs big blocks, which are opted +/// into with an `evmUserModify` action that HyperCore accepts only from an +/// address that is already a HyperCore user. An address becomes one by holding +/// a Core asset, and HYPE sent to the system contract from HyperEVM arrives on +/// Core for the same address — so the deployer, which holds HYPE already +/// because it pays gas in it, credits itself. `LibHyperCore` carries the +/// mechanism and every guard; see there for what each one is for. +/// +/// This is a sibling of `script/Deploy.sol` rather than a part of it. It moves +/// the deployer's own funds instead of deploying anything, it touches exactly +/// one network where the deploy touches all of them, and it is run ONCE per +/// deployer address for the lifetime of that address — a second run is more +/// money for no further effect, because a HyperCore user does not stop being +/// one. +/// +/// ## Running it +/// +/// Not on `Manual sol artifacts`. That workflow exports `DEPLOYMENT_SUITE`, +/// `DEPLOYMENT_NETWORK` and `DEPLOYMENT_KEY` and nothing else, so the amount +/// has no way through it, and an amount squeezed into one of those names would +/// be a real-money argument travelling under a name that means something else. +/// It is run by hand instead: +/// +/// ```sh +/// HYPERCORE_CREDIT_WEI=10000000000000000 DEPLOYMENT_KEY=0x... \ +/// forge script script/CreditHyperCore.sol:CreditHyperCore --legacy +/// ``` +/// +/// Without `--broadcast` that is a dry run against a fork of HyperEVM, which +/// executes every guard and the transfer itself and sends nothing. Do that +/// first: it is the same code path, so anything it refuses is something the +/// real run would have refused after paying for it. `--legacy` because +/// HyperEVM's RPC rejects the fee-history ranges EIP-1559 estimation asks for, +/// which is the same reason the deploy workflow carries a `legacy` input. No +/// `--rpc-url`: the fork comes from the `hyperevm` alias in `foundry.toml`. +/// +/// ## The amount +/// +/// `HYPERCORE_CREDIT_WEI` is EVM wei — 18 decimals, the units the deployer's +/// gas balance is in — and it is required rather than defaulted. A default +/// would be an amount of real money nobody typed, and how much a deployer +/// should hold on Core is a decision about that deployer rather than a fact +/// about this mechanism. It has to be a whole number of Core wei; `10 ** 10` +/// EVM wei is one of them, and `LibHyperCore.CreditNotRound` says why anything +/// else is refused. +/// +/// ## Why the body is three lines and no test drives it +/// +/// Everything with behaviour is in `LibHyperCore` and is covered there, without +/// an env var in sight. What is left here is two reads by name, and driving +/// them from a test would mean writing `DEPLOYMENT_KEY` — a process-wide +/// variable that `rainix-sol-test` exports onto the job and that +/// `RainDeployBroadcastTest` already sequences its own writes of. Forge runs +/// test contracts concurrently, so a second writer of that name is a race +/// against a suite that is currently green, which is a worse trade than this +/// buys. +/// +/// So the body is written to make its own mistake impossible rather than +/// caught. Both env reads are `uint256`, and a key and an amount transposed +/// between them would send a private key's worth of HYPE — so the key is turned +/// into an `address` on its own line first, and the call takes that address. +/// The two arguments no longer have the same type, and the transposition that +/// no test is watching for does not compile. +contract CreditHyperCore is Script { + /// Credits the `DEPLOYMENT_KEY` deployer's HyperCore account with + /// `HYPERCORE_CREDIT_WEI` of its own HYPE. + function run() external { + address deployer = vm.rememberKey(vm.envUint("DEPLOYMENT_KEY")); + uint256 amount = vm.envUint("HYPERCORE_CREDIT_WEI"); + LibHyperCore.creditCoreOnHyperEvm(vm, deployer, amount); + } +} diff --git a/src/lib/LibHyperCore.sol b/src/lib/LibHyperCore.sol new file mode 100644 index 0000000..c92479a --- /dev/null +++ b/src/lib/LibHyperCore.sol @@ -0,0 +1,277 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity ^0.8.25; + +import {Vm} from "forge-std-1.16.2/src/Vm.sol"; +import {console2} from "forge-std-1.16.2/src/console2.sol"; + +import {LibRainDeploy} from "./LibRainDeploy.sol"; + +/// @title LibHyperCore +/// @notice Moves HYPE from an address on HyperEVM to the SAME address on +/// HyperCore, so that a deployer which already holds HYPE for gas can make +/// itself a HyperCore user without an external bridge. +/// +/// ## Why a deploy repo has this at all +/// +/// Deploying anything sizeable to HyperEVM needs big blocks, and big blocks are +/// opted into with a `{"type": "evmUserModify", "usingBigBlocks": true}` action +/// signed by the deployer. HyperCore only accepts that action from an address +/// that is ALREADY a HyperCore user, and an address becomes one by holding a +/// Core asset. So a freshly funded EVM deployer — which holds HYPE, because it +/// pays gas in it — cannot opt in, and the usual answer is to bridge something +/// in from somewhere else. +/// +/// It does not have to be. HYPE is the native gas token on HyperEVM rather than +/// an ERC20, and value sent to the system contract on the EVM side arrives on +/// Core for the sender. The deployer therefore credits ITSELF out of the +/// balance it already has, and the whole prerequisite collapses into one +/// value transfer with no third party in it. +/// +/// ## The mechanism +/// +/// `HYPE_SYSTEM_ADDRESS` is a payable contract whose `receive()` emits +/// `Received(address indexed user, uint256 amount)` and does nothing else. +/// HyperCore watches that log and credits `user` — which is `msg.sender` of the +/// EVM call — with `amount`, converted into Core's wei decimals. Nothing is +/// called, nothing is approved and no calldata is sent: a bare value transfer +/// is the entire interface, which is why an EOA can do this as readily as a +/// contract. +/// +/// The direction is one way. This sends EVM -> Core; the return leg is a Core +/// action and has nothing to do with this library. +library LibHyperCore { + /// Thrown when the selected chain is not HyperEVM. + /// + /// This is the guard that matters most here and it is checked first, before + /// anything else is read. `HYPE_SYSTEM_ADDRESS` is a system contract on + /// HyperEVM and an ordinary, almost certainly empty, address everywhere + /// else — nobody holds its key, and no chain but HyperEVM is watching it. + /// Value sent to it on any other chain is not refused, it is simply gone, + /// and gone at an address that looks deliberate enough that the mistake is + /// easy to make twice. + /// + /// A chain id rather than an RPC alias, because the alias is not the fact. + /// `HYPEREVM_RPC_URL` is bound at run time by rainix's rpc-preflight to + /// whichever candidate endpoint is reachable, so the alias says which + /// endpoint answered and the chain id says what it answered as. + /// @param expected `HYPEREVM_CHAIN_ID`. + /// @param actual The chain id of the selected fork. + error UnexpectedChainId(uint256 expected, uint256 actual); + + /// Thrown when the code at `HYPE_SYSTEM_ADDRESS` is not the code this + /// library was written against. + /// + /// The chain id says the endpoint answered as HyperEVM. This says the + /// contract that will receive the value is the one whose `receive()` emits + /// the log Core credits from. Both, because a fork of HyperEVM reports + /// HyperEVM's chain id while holding whatever state its operator put there, + /// and because a system contract that has been replaced is a mechanism that + /// may have changed under a library that would otherwise carry on sending + /// real value into it. + /// + /// Also the zero hash, which is what an address with no code at all + /// reports, so a chain that merely does not have this contract fails here + /// rather than accepting the transfer into a hole. + /// @param expected `HYPE_SYSTEM_CODEHASH`. + /// @param actual The code hash actually at `HYPE_SYSTEM_ADDRESS`. + error SystemContractChanged(bytes32 expected, bytes32 actual); + + /// Thrown when the amount to credit is zero. + /// + /// A zero transfer emits `Received(user, 0)` and credits nothing, so it + /// spends gas to leave the deployer exactly as much of a non-user as it was + /// — while reporting success, which is the shape of failure this whole + /// script exists to avoid. + error ZeroCredit(); + + /// Thrown when the amount would lose value to Core's coarser wei decimals. + /// + /// Core carries HYPE in `HYPE_CORE_DECIMALS` wei decimals and the EVM + /// carries it in `HYPE_EVM_DECIMALS`, so a credit is the EVM amount divided + /// by `HYPE_EVM_WEI_PER_CORE_WEI` and the remainder is BURNED rather than + /// credited or returned. + /// + /// Refused rather than rounded, because the sharp end of it is not the + /// dust. An amount smaller than one Core wei has no non-remainder part at + /// all: every bit of it is burned, nothing whatsoever reaches Core, the + /// deployer does not become a HyperCore user, and the transfer succeeds. A + /// caller that meant to send that amount wants to hear about it, and one + /// that meant to send more has a typo worth catching. + /// @param amount The amount that was asked for. + /// @param evmWeiPerCoreWei `HYPE_EVM_WEI_PER_CORE_WEI`, which `amount` has + /// to be a whole multiple of. + error CreditNotRound(uint256 amount, uint256 evmWeiPerCoreWei); + + /// Thrown when the account cannot cover the credit and the gas to send it. + /// + /// Strictly greater than, not at least: gas on HyperEVM is paid in HYPE out + /// of this same balance, so an account holding exactly `amount` cannot send + /// `amount`. How much more it needs is the gas price at the time, which is + /// not knowable here, but zero more is knowably not enough. + /// @param account The account that would send the credit. + /// @param balance Its HYPE balance on HyperEVM. + /// @param amount The amount asked for. + error InsufficientBalance(address account, uint256 balance, uint256 amount); + + /// Thrown when the value transfer to the system contract reverts. + /// @param account The account the credit was sent from. + /// @param amount The amount sent. + /// @param returnData The revert data from the system contract. + error CreditFailed(address account, uint256 amount, bytes returnData); + + /// Thrown when the system contract's balance did not rise by exactly the + /// amount sent. A successful call that did not move the value is not a + /// credit, and Core reads the transfer rather than the return status. + /// @param expected The balance before, plus the amount. + /// @param actual The balance after. + error UnexpectedSystemBalance(uint256 expected, uint256 actual); + + /// Thrown when the sending account's balance did not fall by at least the + /// amount sent. + /// + /// Core credits the log's `user`, which is the EVM `msg.sender`, so the + /// account that PAID is the account that gets credited, and this is what + /// says the payer was the account asked for rather than some intermediary + /// whose Core account nobody controls. + /// + /// At least, rather than exactly: whether gas is deducted from the balance + /// in a given simulation is forge's business, and the direction is the + /// whole of what is being asserted. + /// @param account The account the credit was sent from. + /// @param maximum The balance before, minus the amount. + /// @param actual The balance after. + error UnexpectedAccountBalance(address account, uint256 maximum, uint256 actual); + + /// HyperEVM's chain id. The only chain any of this means anything on. + uint256 constant HYPEREVM_CHAIN_ID = 999; + + /// The HyperEVM system contract for native HYPE. Value sent here is + /// credited to the sender's HyperCore account. + /// + /// HYPE is a special case among the assets that cross this boundary, + /// because on the EVM side it is the native gas token rather than an ERC20. + /// Spot tokens each get their own system address derived from their token + /// index and move by `transfer`; HYPE moves as transaction value into this + /// one. + address constant HYPE_SYSTEM_ADDRESS = 0x2222222222222222222222222222222222222222; + + /// Runtime bytecode of the HYPE system contract, for `vm.etch`. + /// + /// It is a `receive()` and nothing else: any call carrying calldata + /// reverts, and a call carrying none emits + /// `Received(address indexed user, uint256 amount)` with `user` the caller + /// and `amount` the value. That is the whole contract, which is why pinning + /// it is cheap and why the pin is worth having. + bytes constant HYPE_SYSTEM_BYTECODE = + hex"608060405236603f5760405134815233907f88a5966d370b9919b20f3e2c13ff65706f196a4e32cc2c12bf57088f885258749060200160405180910390a2005b600080fdfea2646970667358221220ca425db50898ac19f9e4676e86e8ebed9853baa048942f6306fe8a86b8d4abb964736f6c63430008090033"; + + /// Code hash of `HYPE_SYSTEM_BYTECODE`, checked against the live contract + /// before any value is sent. + bytes32 constant HYPE_SYSTEM_CODEHASH = 0xf79e9de95af9d7ada36fd11ff7da9308976f47f441a8acea9dcfaa8ab703baf2; + + /// Wei decimals HYPE has on HyperEVM, where it is the native gas token. + uint256 constant HYPE_EVM_DECIMALS = 18; + + /// Wei decimals HYPE has on HyperCore. + uint256 constant HYPE_CORE_DECIMALS = 8; + + /// EVM wei in one Core wei of HYPE. A credit is the EVM amount divided by + /// this, and the remainder is burned, so an amount below it credits nothing + /// at all. + uint256 constant HYPE_EVM_WEI_PER_CORE_WEI = 10 ** (HYPE_EVM_DECIMALS - HYPE_CORE_DECIMALS); + + /// Etches the HYPE system contract's runtime code at its address, so that + /// the transfer can be exercised somewhere that is not HyperEVM. + /// + /// For tests. The chain guard is not etchable, so a test that wants past it + /// pairs this with `vm.chainId(HYPEREVM_CHAIN_ID)`, and what it then drives + /// is the real contract's real bytecode rather than a mock that agrees with + /// whatever this library expects. + /// @param vm The Vm instance to etch with. + function etchHypeSystemContract(Vm vm) internal { + vm.etch(HYPE_SYSTEM_ADDRESS, HYPE_SYSTEM_BYTECODE); + } + + /// Sends `amount` HYPE from `account` to the system contract on the + /// SELECTED chain, crediting `account`'s HyperCore account. + /// + /// Every guard runs before the broadcast, in the order where, what, who: + /// the chain and the contract that will receive the value, then the amount, + /// then the funds to send it. The first two are the ones that decide + /// whether the value is recoverable at all, so they are the ones that go + /// first. + /// + /// Takes the account rather than its key. The caller's own + /// `vm.rememberKey` is what turns a key into an address, and an address + /// here means a caller cannot hand the amount and the key to each other by + /// mistake — they are not the same type, so it does not compile. + /// @param vm The Vm instance to broadcast with. + /// @param account The account to send from, and therefore the HyperCore + /// account credited. + /// @param amount The amount of HYPE to send, in EVM wei. + function creditCore(Vm vm, address account, uint256 amount) internal { + if (block.chainid != HYPEREVM_CHAIN_ID) { + revert UnexpectedChainId(HYPEREVM_CHAIN_ID, block.chainid); + } + if (HYPE_SYSTEM_ADDRESS.codehash != HYPE_SYSTEM_CODEHASH) { + revert SystemContractChanged(HYPE_SYSTEM_CODEHASH, HYPE_SYSTEM_ADDRESS.codehash); + } + if (amount == 0) { + revert ZeroCredit(); + } + if (amount % HYPE_EVM_WEI_PER_CORE_WEI != 0) { + revert CreditNotRound(amount, HYPE_EVM_WEI_PER_CORE_WEI); + } + if (account.balance <= amount) { + revert InsufficientBalance(account, account.balance, amount); + } + + uint256 systemBalanceBefore = HYPE_SYSTEM_ADDRESS.balance; + uint256 accountBalanceBefore = account.balance; + + console2.log("Crediting HyperCore account:", account); + console2.log(" - HYPE system contract:", HYPE_SYSTEM_ADDRESS); + console2.log(" - EVM wei sent:", amount); + console2.log(" - Core wei credited:", amount / HYPE_EVM_WEI_PER_CORE_WEI); + + vm.broadcast(account); + // A bare value transfer with no calldata is the entire interface, so + // there is no function to call and no interface to call it through. + // Excluded at the site rather than repo-wide so a low-level call added + // anywhere else is still reported. + // slither-disable-next-line low-level-calls + (bool success, bytes memory returnData) = HYPE_SYSTEM_ADDRESS.call{value: amount}(""); + if (!success) { + revert CreditFailed(account, amount, returnData); + } + + if (HYPE_SYSTEM_ADDRESS.balance != systemBalanceBefore + amount) { + revert UnexpectedSystemBalance(systemBalanceBefore + amount, HYPE_SYSTEM_ADDRESS.balance); + } + if (account.balance > accountBalanceBefore - amount) { + revert UnexpectedAccountBalance(account, accountBalanceBefore - amount, account.balance); + } + } + + /// `creditCore` against the `hyperevm` RPC alias. + /// + /// The fork is taken here rather than left to a `--rpc-url` on the command + /// line, which is how `LibRainDeploy` reaches a network too: the alias is + /// the repo's own declaration of what HyperEVM is, and a script that + /// depended on a flag would send wherever the flag pointed. The chain guard + /// in `creditCore` is then a real check rather than a formality — it is + /// what says the alias resolved to HyperEVM and not to whatever else was + /// reachable. + /// @param vm The Vm instance to fork and broadcast with. + /// @param account The account to send from, and therefore the HyperCore + /// account credited. + /// @param amount The amount of HYPE to send, in EVM wei. + function creditCoreOnHyperEvm(Vm vm, address account, uint256 amount) internal { + // createSelectFork returns a fork id that is not needed here; bind and + // reference it so the unused-return lint stays satisfied. + uint256 forkId = vm.createSelectFork(LibRainDeploy.HYPEREVM); + (forkId); + creditCore(vm, account, amount); + } +} diff --git a/test/src/lib/LibHyperCore.t.sol b/test/src/lib/LibHyperCore.t.sol new file mode 100644 index 0000000..7b2c169 --- /dev/null +++ b/test/src/lib/LibHyperCore.t.sol @@ -0,0 +1,355 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity =0.8.25; + +import {Test, Vm} from "forge-std-1.16.2/src/Test.sol"; + +import {LibHyperCore} from "../../../src/lib/LibHyperCore.sol"; +import {LibRainDeploy} from "../../../src/lib/LibRainDeploy.sol"; + +/// @title LibHyperCoreTest +/// @notice The EVM -> Core credit, driven both against the system contract's +/// own bytecode on a local chain and against the live contract on a HyperEVM +/// fork. +/// +/// None of it costs anything. `vm.broadcast` under `forge test` executes the +/// transfer against the selected state and records a transaction it never +/// sends, and every account it moves value between is either dealt into +/// existence here or the system contract itself — the same way +/// `RainDeployBroadcastTest` drives the deploy broadcast. +/// +/// The local legs `vm.etch` the REAL runtime bytecode rather than a mock, which +/// is the point of pinning it: what they drive is the contract that will +/// receive the money, so the log they observe is the log HyperCore credits +/// from, and a mock that agreed with this library instead would assert nothing +/// about either. +contract LibHyperCoreTest is Test { + /// The event topic HyperCore credits from. Spelled as a signature the + /// compiler hashes, not as a hash copied out of the deployed bytecode: + /// agreeing with the bytes on chain is what the pinned code hash is for, + /// and a topic transcribed by hand would agree with the transcription. + bytes32 constant RECEIVED_TOPIC = keccak256("Received(address,uint256)"); + + /// A round, non-zero, comfortably affordable credit for the legs that are + /// about something other than the amount. One hundredth of a HYPE. + uint256 constant VALID_CREDIT = 1e16; + + /// The account legs that do not fuzz it send from. + address constant ACCOUNT = address(uint160(uint256(keccak256("hypercore credit account")))); + + /// `creditCore` reached through a call, so `vm.expectRevert` has an + /// external call to attach to. + /// @param account The account to credit. + /// @param amount The amount in EVM wei. + function externalCreditCore(address account, uint256 amount) external { + LibHyperCore.creditCore(vm, account, amount); + } + + /// `creditCoreOnHyperEvm` reached through a call, for the same reason. + /// @param account The account to credit. + /// @param amount The amount in EVM wei. + function externalCreditCoreOnHyperEvm(address account, uint256 amount) external { + LibHyperCore.creditCoreOnHyperEvm(vm, account, amount); + } + + /// An account that can be dealt to and broadcast from without colliding + /// with the transfer's own counterparties or with forge's own addresses. + /// @param account The fuzzed account. + function assumeCreditableAccount(address account) internal pure { + assumeNotPrecompile(account); + assumeNotForgeAddress(account); + // The sender and the recipient are the two balances every assertion + // here is about, so an account that is also the recipient would make + // both sides of the transfer one number. + vm.assume(account != LibHyperCore.HYPE_SYSTEM_ADDRESS); + vm.assume(account != address(0)); + } + + /// Puts the selected state where a credit is expected to WORK: HyperEVM's + /// chain id, the real system contract at the system address, and an account + /// that can pay. + /// @param account The account to fund. + /// @param amount The amount it is about to send. + function arrangeCreditableChain(address account, uint256 amount) internal { + vm.chainId(LibHyperCore.HYPEREVM_CHAIN_ID); + LibHyperCore.etchHypeSystemContract(vm); + vm.deal(account, amount + 1); + } + + /// PROPERTY: the pinned code hash is the hash of the pinned bytecode. + /// + /// Two constants describing one contract, and the guard reads only the + /// hash while `etchHypeSystemContract` writes only the bytes — so a hash + /// that did not belong to those bytes would make every local leg here + /// exercise a contract the guard would refuse on chain, and the suite would + /// not notice. + function testHypeSystemBytecodeHashesToThePinnedCodehash() external pure { + assertEq(keccak256(LibHyperCore.HYPE_SYSTEM_BYTECODE), LibHyperCore.HYPE_SYSTEM_CODEHASH); + } + + /// PROPERTY: one Core wei is `10 ** 10` EVM wei, which is the gap between + /// HYPE's 18 wei decimals on the EVM and its 8 on Core. + /// + /// The constant is what the round-amount guard divides the world into, and + /// it is derived from two other constants rather than written down, so this + /// pins the derivation against the number it has to produce. + function testOneCoreWeiIsTheDecimalGap() external pure { + assertEq(LibHyperCore.HYPE_EVM_WEI_PER_CORE_WEI, 1e10); + assertEq(LibHyperCore.HYPE_EVM_DECIMALS - LibHyperCore.HYPE_CORE_DECIMALS, 10); + } + + /// PROPERTY: a credit is REFUSED on every chain but HyperEVM, and nothing + /// moves when it is refused. + /// + /// The system address is a system contract on HyperEVM and an ordinary + /// address everywhere else. Value sent to it on another chain is not + /// refused by anything, it is simply unrecoverable, so this is the guard + /// the whole script is arranged around. + /// + /// Everything else is arranged to SUCCEED — the real bytecode is etched at + /// the system address, the amount is round and non-zero, the account can + /// pay — so the chain id is the only thing left that can be refusing it. + /// Without that the same revert would arrive from a chain that simply had + /// no system contract on it, which is a different check. + function testCreditRefusesEveryChainButHyperEvm(uint64 chainId, address account, uint256 amount) external { + assumeCreditableAccount(account); + vm.assume(chainId != LibHyperCore.HYPEREVM_CHAIN_ID); + vm.assume(chainId != 0); + amount = bound(amount, 1, 1e14) * LibHyperCore.HYPE_EVM_WEI_PER_CORE_WEI; + + arrangeCreditableChain(account, amount); + vm.chainId(chainId); + + uint256 systemBalanceBefore = LibHyperCore.HYPE_SYSTEM_ADDRESS.balance; + uint256 accountBalanceBefore = account.balance; + + vm.expectRevert( + abi.encodeWithSelector(LibHyperCore.UnexpectedChainId.selector, LibHyperCore.HYPEREVM_CHAIN_ID, chainId) + ); + this.externalCreditCore(account, amount); + + assertEq(LibHyperCore.HYPE_SYSTEM_ADDRESS.balance, systemBalanceBefore); + assertEq(account.balance, accountBalanceBefore); + } + + /// PROPERTY: a credit is REFUSED unless the system address holds the exact + /// contract this library was written against. + /// + /// Both ways it can fail: no code at all, which is a chain reporting + /// HyperEVM's id without HyperEVM's state, and other code, which is a + /// system contract that has been replaced under a library still sending + /// real value into it. Neither is distinguishable from the real thing by + /// chain id alone, which is why there are two guards and not one. + function testCreditRefusesASystemContractThatIsNotTheOne(bytes memory code, address account) external { + assumeCreditableAccount(account); + vm.chainId(LibHyperCore.HYPEREVM_CHAIN_ID); + vm.deal(account, VALID_CREDIT + 1); + + bytes32 absent = LibHyperCore.HYPE_SYSTEM_ADDRESS.codehash; + assertNotEq(absent, LibHyperCore.HYPE_SYSTEM_CODEHASH); + vm.expectRevert( + abi.encodeWithSelector( + LibHyperCore.SystemContractChanged.selector, LibHyperCore.HYPE_SYSTEM_CODEHASH, absent + ) + ); + this.externalCreditCore(account, VALID_CREDIT); + + vm.assume(code.length > 0); + vm.assume(keccak256(code) != LibHyperCore.HYPE_SYSTEM_CODEHASH); + vm.etch(LibHyperCore.HYPE_SYSTEM_ADDRESS, code); + vm.expectRevert( + abi.encodeWithSelector( + LibHyperCore.SystemContractChanged.selector, LibHyperCore.HYPE_SYSTEM_CODEHASH, keccak256(code) + ) + ); + this.externalCreditCore(account, VALID_CREDIT); + } + + /// PROPERTY: a zero credit is REFUSED. + /// + /// Zero is a whole number of Core wei, so the round-amount guard lets it + /// through, and the transfer itself would succeed — emitting + /// `Received(user, 0)`, crediting nothing, and leaving the deployer exactly + /// as unable to opt into big blocks as it was, with the run reporting + /// success. + function testCreditRefusesZero(address account) external { + assumeCreditableAccount(account); + arrangeCreditableChain(account, 0); + + vm.expectRevert(abi.encodeWithSelector(LibHyperCore.ZeroCredit.selector)); + this.externalCreditCore(account, 0); + } + + /// PROPERTY: an amount that is not a whole number of Core wei is REFUSED. + /// + /// Core carries HYPE in 8 wei decimals against the EVM's 18, so the low ten + /// digits of an EVM amount are burned rather than credited. Rounding them + /// away silently is the wrong answer for the same reason the zero case is: + /// the caller asked for an amount, and some of it would not arrive. + function testCreditRefusesAnAmountThatIsNotAWholeCoreWei(uint256 amount, address account) external { + assumeCreditableAccount(account); + amount = bound(amount, 1, type(uint128).max); + vm.assume(amount % LibHyperCore.HYPE_EVM_WEI_PER_CORE_WEI != 0); + + arrangeCreditableChain(account, amount); + + vm.expectRevert( + abi.encodeWithSelector(LibHyperCore.CreditNotRound.selector, amount, LibHyperCore.HYPE_EVM_WEI_PER_CORE_WEI) + ); + this.externalCreditCore(account, amount); + } + + /// PROPERTY: an amount below ONE Core wei is REFUSED — the case the + /// round-amount guard is really there for. + /// + /// Everything under `10 ** 10` EVM wei is remainder, so all of it is burned + /// and NOTHING arrives on Core. The transfer succeeds, the HYPE is gone, + /// and the address is still not a HyperCore user. Covered by the fuzz above + /// as a subset, and separately here because it is the outcome that made the + /// guard worth having. + function testCreditRefusesAnAmountBelowOneCoreWei(uint256 amount, address account) external { + assumeCreditableAccount(account); + amount = bound(amount, 1, LibHyperCore.HYPE_EVM_WEI_PER_CORE_WEI - 1); + + arrangeCreditableChain(account, amount); + + vm.expectRevert( + abi.encodeWithSelector(LibHyperCore.CreditNotRound.selector, amount, LibHyperCore.HYPE_EVM_WEI_PER_CORE_WEI) + ); + this.externalCreditCore(account, amount); + } + + /// PROPERTY: an account that cannot cover the credit AND leave something + /// for gas is REFUSED. + /// + /// Exactly the amount is the boundary and it is refused: gas on HyperEVM is + /// paid in HYPE out of this same balance, so an account holding precisely + /// what it is sending cannot send it. One wei more is the smallest balance + /// this can accept, and the happy path below sends from exactly that. + function testCreditRefusesAnAccountThatCannotAlsoPayGas(address account, uint256 amount, uint256 shortfall) + external + { + assumeCreditableAccount(account); + amount = bound(amount, 1, 1e14) * LibHyperCore.HYPE_EVM_WEI_PER_CORE_WEI; + shortfall = bound(shortfall, 0, amount); + + vm.chainId(LibHyperCore.HYPEREVM_CHAIN_ID); + LibHyperCore.etchHypeSystemContract(vm); + vm.deal(account, amount - shortfall); + + vm.expectRevert( + abi.encodeWithSelector(LibHyperCore.InsufficientBalance.selector, account, amount - shortfall, amount) + ); + this.externalCreditCore(account, amount); + } + + /// PROPERTY: a credit moves the amount from the account to the system + /// contract and emits the log HyperCore credits THAT ACCOUNT from. + /// + /// The balances say the value moved. They do not say WHOSE Core account it + /// lands in, and that is the part with no undo: Core credits the `user` + /// topic of the `Received` log, which is the EVM `msg.sender`, so a + /// transfer that arrived by way of anything other than the account itself + /// would fund a Core account nobody has the key to. + /// + /// So the log is read back and matched whole — emitter, topic, user and + /// amount — against the account that was asked for. It is emitted by the + /// system contract's own etched bytecode, so what is being observed is the + /// real log and not this test's idea of one. + function testCreditSendsTheAmountAndCreditsTheSendingAccount(address account, uint256 amount) external { + assumeCreditableAccount(account); + amount = bound(amount, 1, 1e14) * LibHyperCore.HYPE_EVM_WEI_PER_CORE_WEI; + + arrangeCreditableChain(account, amount); + uint256 systemBalanceBefore = LibHyperCore.HYPE_SYSTEM_ADDRESS.balance; + + vm.recordLogs(); + this.externalCreditCore(account, amount); + Vm.Log[] memory logs = vm.getRecordedLogs(); + + assertEq(LibHyperCore.HYPE_SYSTEM_ADDRESS.balance, systemBalanceBefore + amount); + // Dealt `amount + 1`, so exactly the one wei that could not be sent is + // what is left. Gas is not deducted from the balance in this + // simulation, which is why the library bounds the sender's side rather + // than fixing it. + assertEq(account.balance, 1); + + assertEq(logs.length, 1); + assertEq(logs[0].emitter, LibHyperCore.HYPE_SYSTEM_ADDRESS); + assertEq(logs[0].topics.length, 2); + assertEq(logs[0].topics[0], RECEIVED_TOPIC); + assertEq(logs[0].topics[1], bytes32(uint256(uint160(account)))); + assertEq(abi.decode(logs[0].data, (uint256)), amount); + } + + /// PROPERTY: the `hyperevm` alias resolves to HyperEVM, and the contract + /// live at the system address there is the one both constants describe. + /// + /// This is the test that goes red if Hyperliquid ever replaces the system + /// contract. That is the intended outcome rather than a nuisance: the + /// mechanism this library sends real value into would have changed, and the + /// guard refusing the transfer on chain would be the first anyone heard of + /// it otherwise. + function testTheLiveSystemContractIsWhatIsPinned() external { + vm.createSelectFork(LibRainDeploy.HYPEREVM); + + assertEq(block.chainid, LibHyperCore.HYPEREVM_CHAIN_ID); + assertEq(LibHyperCore.HYPE_SYSTEM_ADDRESS.code, LibHyperCore.HYPE_SYSTEM_BYTECODE); + assertEq(LibHyperCore.HYPE_SYSTEM_ADDRESS.codehash, LibHyperCore.HYPE_SYSTEM_CODEHASH); + } + + /// PROPERTY: the credit works against the LIVE system contract, on a fork + /// of the chain it will really run on. + /// + /// The local legs prove the mechanism against etched bytecode. This proves + /// the etched bytecode is not the reason they pass — same assertions, same + /// library, real chain state underneath. + /// + /// One account and one amount, where the local legs fuzz both. A fuzzed + /// body that forks takes a fresh fork per run and reads every account it + /// touches over the wire, which is a few hundred round trips to say what + /// the local legs already say across the whole input space. What is being + /// asked here is only whether the real contract behaves as its pinned + /// bytecode does, and one credit answers that. + function testCreditOnAHyperEvmFork() external { + vm.createSelectFork(LibRainDeploy.HYPEREVM); + vm.deal(ACCOUNT, VALID_CREDIT + 1); + uint256 systemBalanceBefore = LibHyperCore.HYPE_SYSTEM_ADDRESS.balance; + + vm.recordLogs(); + this.externalCreditCore(ACCOUNT, VALID_CREDIT); + Vm.Log[] memory logs = vm.getRecordedLogs(); + + assertEq(LibHyperCore.HYPE_SYSTEM_ADDRESS.balance, systemBalanceBefore + VALID_CREDIT); + assertEq(ACCOUNT.balance, 1); + + assertEq(logs.length, 1); + assertEq(logs[0].emitter, LibHyperCore.HYPE_SYSTEM_ADDRESS); + assertEq(logs[0].topics.length, 2); + assertEq(logs[0].topics[0], RECEIVED_TOPIC); + assertEq(logs[0].topics[1], bytes32(uint256(uint160(ACCOUNT)))); + assertEq(abi.decode(logs[0].data, (uint256)), VALID_CREDIT); + } + + /// PROPERTY: `creditCoreOnHyperEvm` takes the fork itself, from the + /// `hyperevm` alias, and everything after it runs against that chain. + /// + /// The script's whole body is this one call, so the fork it takes is what + /// decides where the money goes; a version that forked nothing would send + /// on whatever chain the caller happened to be on. + /// + /// Observed through an UNFUNDED account. Without the fork the chain id is + /// the test EVM's and the chain guard refuses first, so an + /// `InsufficientBalance` — the guard that comes after both the chain id and + /// the live code hash — is only reachable by having actually arrived on + /// HyperEVM with the real system contract in front of it. + /// + /// Not fuzzed, for the reason the leg above is not. + function testCreditOnHyperEvmForksTheAliasBeforeItsGuards() external { + assertNotEq(block.chainid, LibHyperCore.HYPEREVM_CHAIN_ID); + assertEq(ACCOUNT.balance, 0); + + vm.expectRevert(abi.encodeWithSelector(LibHyperCore.InsufficientBalance.selector, ACCOUNT, 0, VALID_CREDIT)); + this.externalCreditCoreOnHyperEvm(ACCOUNT, VALID_CREDIT); + } +} From f5a6a523d31931110cb3a92bbfcfd3b9340c5df3 Mon Sep 17 00:00:00 2001 From: baku-ccron Date: Sat, 22 Aug 2026 07:54:08 +0000 Subject: [PATCH 2/2] Keep one post-transfer assertion, and test the input that reaches it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A direct value transfer moves the sender's balance and the recipient's together or moves neither, so of the two post-transfer checks only one can ever fire: the only input that gets past every guard and still moves nothing is one where the two balances are the same balance, which is `account` being the system contract itself. Whichever check is written first is the one that fires there, and the second is unreachable by construction — a line no mutation of it can be killed on. So `UnexpectedAccountBalance` and the sender-side check go, and the system side stays, because that is the side Core reads the credit from. `testCreditThatMovesNothingIsRefused` sends from the system contract to the system contract, which is the one input that reaches the remaining assertion, and is worth refusing on its own terms: a run that credited the system contract's own Core account and reported success is the silent success this library is arranged against. The `CreditFailed` and `UnexpectedSystemBalance` NatSpec now say why each is there, including that `CreditFailed` is unreachable through the pins and is kept anyway. Co-Authored-By: Claude Opus 5 (1M context) --- src/lib/LibHyperCore.sol | 37 +++++++++++++++------------------ test/src/lib/LibHyperCore.t.sol | 27 ++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 20 deletions(-) diff --git a/src/lib/LibHyperCore.sol b/src/lib/LibHyperCore.sol index c92479a..f4f5e6c 100644 --- a/src/lib/LibHyperCore.sol +++ b/src/lib/LibHyperCore.sol @@ -115,6 +115,14 @@ library LibHyperCore { error InsufficientBalance(address account, uint256 balance, uint256 amount); /// Thrown when the value transfer to the system contract reverts. + /// + /// Not reachable through the guards above: the pinned bytecode reverts only + /// on a call carrying calldata, this one carries none, and the balance + /// guard has already funded it. It is here because an unchecked low-level + /// call is a defect on its own terms, and because loosening either pin + /// would make it reachable — and because without it a failed transfer would + /// be reported as `UnexpectedSystemBalance`, which is the wrong diagnosis + /// for it. /// @param account The account the credit was sent from. /// @param amount The amount sent. /// @param returnData The revert data from the system contract. @@ -123,26 +131,19 @@ library LibHyperCore { /// Thrown when the system contract's balance did not rise by exactly the /// amount sent. A successful call that did not move the value is not a /// credit, and Core reads the transfer rather than the return status. + /// + /// The one assertion after the transfer, and the SYSTEM side of it rather + /// than the sender's, because only one of the two can ever be reached. A + /// direct value transfer moves both balances or neither, so the only input + /// that reaches either is one where they are the same balance — `account` + /// being the system contract itself, which sends to itself and moves + /// nothing. Whichever check is written first is the one that fires, and the + /// other is unreachable by construction. This is the side that says what + /// Core reads. /// @param expected The balance before, plus the amount. /// @param actual The balance after. error UnexpectedSystemBalance(uint256 expected, uint256 actual); - /// Thrown when the sending account's balance did not fall by at least the - /// amount sent. - /// - /// Core credits the log's `user`, which is the EVM `msg.sender`, so the - /// account that PAID is the account that gets credited, and this is what - /// says the payer was the account asked for rather than some intermediary - /// whose Core account nobody controls. - /// - /// At least, rather than exactly: whether gas is deducted from the balance - /// in a given simulation is forge's business, and the direction is the - /// whole of what is being asserted. - /// @param account The account the credit was sent from. - /// @param maximum The balance before, minus the amount. - /// @param actual The balance after. - error UnexpectedAccountBalance(address account, uint256 maximum, uint256 actual); - /// HyperEVM's chain id. The only chain any of this means anything on. uint256 constant HYPEREVM_CHAIN_ID = 999; @@ -228,7 +229,6 @@ library LibHyperCore { } uint256 systemBalanceBefore = HYPE_SYSTEM_ADDRESS.balance; - uint256 accountBalanceBefore = account.balance; console2.log("Crediting HyperCore account:", account); console2.log(" - HYPE system contract:", HYPE_SYSTEM_ADDRESS); @@ -249,9 +249,6 @@ library LibHyperCore { if (HYPE_SYSTEM_ADDRESS.balance != systemBalanceBefore + amount) { revert UnexpectedSystemBalance(systemBalanceBefore + amount, HYPE_SYSTEM_ADDRESS.balance); } - if (account.balance > accountBalanceBefore - amount) { - revert UnexpectedAccountBalance(account, accountBalanceBefore - amount, account.balance); - } } /// `creditCore` against the `hyperevm` RPC alias. diff --git a/test/src/lib/LibHyperCore.t.sol b/test/src/lib/LibHyperCore.t.sol index 7b2c169..3669d8e 100644 --- a/test/src/lib/LibHyperCore.t.sol +++ b/test/src/lib/LibHyperCore.t.sol @@ -282,6 +282,33 @@ contract LibHyperCoreTest is Test { assertEq(abi.decode(logs[0].data, (uint256)), amount); } + /// PROPERTY: a credit that moved nothing is REFUSED, rather than reported + /// as a credit. + /// + /// Sending from the system contract to the system contract is the only + /// input that gets past every guard and still moves no value — a direct + /// transfer moves both balances or neither, so the two are the same balance + /// only when they are the same account. It is worth refusing on its own + /// terms, because a run that credited the system contract's own Core + /// account and said it had worked is the silent success this library is + /// arranged against. + /// + /// It is also the ONLY input that reaches the assertion after the transfer, + /// which is why there is one of those and not two: whichever side is + /// checked first is the side that fires here, and the other side would be + /// unreachable by construction and killed by no mutation. + function testCreditThatMovesNothingIsRefused() external { + arrangeCreditableChain(LibHyperCore.HYPE_SYSTEM_ADDRESS, VALID_CREDIT); + uint256 systemBalanceBefore = LibHyperCore.HYPE_SYSTEM_ADDRESS.balance; + + vm.expectRevert( + abi.encodeWithSelector( + LibHyperCore.UnexpectedSystemBalance.selector, systemBalanceBefore + VALID_CREDIT, systemBalanceBefore + ) + ); + this.externalCreditCore(LibHyperCore.HYPE_SYSTEM_ADDRESS, VALID_CREDIT); + } + /// PROPERTY: the `hyperevm` alias resolves to HyperEVM, and the contract /// live at the system address there is the one both constants describe. ///