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..f4f5e6c --- /dev/null +++ b/src/lib/LibHyperCore.sol @@ -0,0 +1,274 @@ +// 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. + /// + /// 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. + 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. + /// + /// 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); + + /// 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; + + 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); + } + } + + /// `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..3669d8e --- /dev/null +++ b/test/src/lib/LibHyperCore.t.sol @@ -0,0 +1,382 @@ +// 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: 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. + /// + /// 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); + } +}