From 8c371df9aff5de92513ffc2afa542f2093fc32ce Mon Sep 17 00:00:00 2001 From: baku-ccron Date: Sat, 22 Aug 2026 08:29:50 +0000 Subject: [PATCH] Run CreditHyperCore from an admin-gated manual workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `script/CreditHyperCore.sol` merged runnable only by hand, because the one dispatchable path — `Manual sol artifacts` — exports `DEPLOYMENT_SUITE`, `DEPLOYMENT_NETWORK` and `DEPLOYMENT_KEY` and nothing else, so a real-money amount had no honest way through it. That was an argument against smuggling the amount, not against a workflow: a purpose-built `workflow_dispatch` with the amount as a typed input under its own name is the honest path, so this adds one. `Manual credit hypercore` takes `credit-wei` as a required typed string and `broadcast` as a boolean defaulting to false; every dispatch executes the dry run, and the broadcast step is unreachable unless the input was set and the dry run passed. The key comes from the same `PRIVATE_KEY` secret the deploy uses and is never an input. GitHub gates `workflow_dispatch` at write access and nothing finer, so the first step asks GitHub for each actor's permission on this repo — the dispatcher and, on a re-run, the re-runner — and fails the run unless every answer is exactly `admin`, logging who dispatched and what they held. Fail closed: an API error is a refusal, and `maintain` collapses to `write` in the `permission` field, so a maintainer is refused too. The job is assembled from the same rainix composites `rainix-manual-sol-artifacts` uses, pinned to the same `RAINIX_SHA`, rather than calling that reusable, which always passes `--broadcast`. The README section and the script's NatSpec now name the workflow as the canonical entry and keep the `read -rs` hand-run form as the fallback for when the workflow itself is what is broken. Co-Authored-By: Claude Fable 5 --- .../workflows/manual-credit-hypercore.yaml | 162 ++++++++++++++++++ CLAUDE.md | 5 +- README.md | 27 ++- script/CreditHyperCore.sol | 11 +- 4 files changed, 195 insertions(+), 10 deletions(-) create mode 100644 .github/workflows/manual-credit-hypercore.yaml diff --git a/.github/workflows/manual-credit-hypercore.yaml b/.github/workflows/manual-credit-hypercore.yaml new file mode 100644 index 0000000..7db07d4 --- /dev/null +++ b/.github/workflows/manual-credit-hypercore.yaml @@ -0,0 +1,162 @@ +name: Manual credit hypercore +# `script/CreditHyperCore.sol`, dispatched by hand: it sends the deployer's own +# HYPE to the HyperCore system contract, so the deployer becomes a HyperCore +# user and can opt into the big blocks a sizeable HyperEVM deployment needs. +# Run once per deployer address: a HyperCore user does not stop being one, so a +# second run is more money for no further effect. +# +# Not a dispatch of `Manual sol artifacts`, and not a caller of +# `rainix-manual-sol-artifacts` either. That path exports `DEPLOYMENT_SUITE`, +# `DEPLOYMENT_NETWORK` and `DEPLOYMENT_KEY` and nothing else, and it always +# passes `--broadcast`. An amount of real money needs a typed input under its +# own name and a dry run that is not optional, so this workflow carries its own +# job, assembled from the same rainix composites the reusable is. +# +# Deliberately `workflow_dispatch` only, like the deploy: broadcasting is key +# custody and real money, and no merge, tag or schedule may be given a path to +# it. +on: + workflow_dispatch: + inputs: + credit-wei: + type: string + required: true + description: | + Exported as `HYPERCORE_CREDIT_WEI`: how much of the deployer's own + HYPE to credit to its HyperCore account, in EVM wei (18 decimals). + Required with no default, because an amount of real money nobody + typed is not an amount to send. It has to be a whole multiple of + `10 ** 10` — one Core wei — because HYPE carries 8 decimals on Core + against 18 on the EVM and the remainder would be BURNED; + `LibHyperCore.CreditNotRound` refuses anything else. A string rather + than a `number` input: number inputs pass through JSON number + parsing, and an amount of money does not go near float precision. + broadcast: + type: boolean + required: false + default: false + description: | + `false` — the default — dispatches the dry run only: a fork of + HyperEVM executes every guard and the transfer itself and sends + nothing. `true` broadcasts for real, and still runs that same dry + run first, so anything the real run would refuse is refused before + anything is paid for. +# The default token scoped down to the checkout inside nix-cachix-setup. The +# admin gate's permission lookup needs only metadata read, which every token +# carries implicitly. +permissions: + contents: read +env: + # The rainix commit whose `#sol-shell` runs forge, pinned exactly as the + # rainix reusables pin it — this is the SHA `rainix-manual-sol-artifacts` + # carries today, so the deploy and the credit run on the same toolchain. + RAINIX_SHA: c4cf22d9b76600a4ad33b5552f4083f80d9b83de +jobs: + credit: + runs-on: ubuntu-latest + steps: + # GitHub's own gate on `workflow_dispatch` is write access and nothing + # finer, so admin-only is enforced here, first, before any other step + # runs. Both the original dispatcher (`github.actor`) and whoever + # pressed re-run (`github.triggering_actor`) have to hold `admin`: a + # re-run reuses the original run's inputs, so a re-run by a non-admin + # would be a dispatch by a non-admin. Fail closed: any answer that is + # not exactly `admin` — including no answer, because a failed API call + # fails the step under `set -euo pipefail` — refuses the run. The + # `permission` field is read rather than `role_name` because it + # collapses `maintain` to `write`, so a maintainer is refused too. + - name: Fail unless every actor is an admin + env: + GH_TOKEN: ${{ github.token }} + # Actor names travel as env rather than inline `${{ }}` in the + # script body, so they are data to the shell, never syntax. + DISPATCHER: ${{ github.actor }} + RERUNNER: ${{ github.triggering_actor }} + run: | + set -euo pipefail + for actor in $(printf '%s\n%s\n' "$DISPATCHER" "$RERUNNER" | sort -u); do + permission="$(gh api "repos/${GITHUB_REPOSITORY}/collaborators/${actor}/permission" --jq .permission)" + echo "${actor} holds '${permission}' on ${GITHUB_REPOSITORY}." + if [[ "$permission" != "admin" ]]; then + echo "::error::${actor} is not an admin of ${GITHUB_REPOSITORY}; refusing to run." + exit 1 + fi + done + # Everything about the VALUE — zero, roundness, whether the deployer can + # still pay gas — is `LibHyperCore`'s to refuse, deliberately not + # re-implemented here. This only refuses an amount that is not a decimal + # integer at all, so a typo fails in seconds instead of after the whole + # nix preamble. + - name: Fail unless the amount is a decimal integer + env: + CREDIT_WEI: ${{ inputs.credit-wei }} + run: | + set -euo pipefail + if [[ ! "$CREDIT_WEI" =~ ^[0-9]+$ ]]; then + echo "::error::credit-wei must be a decimal integer in EVM wei, got '${CREDIT_WEI}'." + exit 1 + fi + # Shared nix + cachix CI preamble (checkout, nix-quick-install, Cachix, + # cache-nix-action) — pinned action SHAs live in the composite. + - uses: rainlanguage/rainix/.github/actions/nix-cachix-setup@main + with: + cachix-auth-token: ${{ secrets.CACHIX_AUTH_TOKEN }} + # Cache Foundry's incremental compilation cache + artifacts so unchanged + # contracts aren't recompiled (forge build is the dominant CI cost). + - name: Cache Foundry build + uses: rainlanguage/rainix/.github/actions/cache@main + with: + path: | + cache + out + key: foundry-full-${{ runner.os }}-${{ hashFiles('src/**/*.sol', 'test/**/*.sol', 'script/**/*.sol', 'foundry.toml', 'soldeer.lock', 'remappings.txt') }} + restore-keys: | + foundry-full-${{ runner.os }}- + - name: Install soldeer dependencies + run: nix develop github:rainlanguage/rainix/${{ env.RAINIX_SHA }}#sol-shell -c forge soldeer install + # archive: false — this only ever reads head state. The preflight probes + # every network this repo's files mention, not just the one this script + # forks, and a probed network whose whole candidate pool is unhealthy + # fails the job — so every network's candidates are passed, exactly as + # `rainix-manual-sol-artifacts` passes them. Candidate URLs travel as + # env, never as `with:` inputs, because GitHub renders a composite's + # resolved inputs into the log. + - name: RPC preflight + uses: rainlanguage/rainix/.github/actions/rpc-preflight@main + with: + archive: 'false' + env: + RAINIX_RPC_SECRET_ARBITRUM: ${{ secrets.RPC_URL_ARBITRUM_FORK }} + RAINIX_RPC_VARS_ARBITRUM: ${{ vars.RPC_URL_ARBITRUM_FORK }} + RAINIX_RPC_SECRET_BASE: ${{ secrets.RPC_URL_BASE_FORK }} + RAINIX_RPC_VARS_BASE: ${{ vars.RPC_URL_BASE_FORK }} + RAINIX_RPC_SECRET_BASE_SEPOLIA: ${{ secrets.RPC_URL_BASE_SEPOLIA_FORK }} + RAINIX_RPC_VARS_BASE_SEPOLIA: ${{ vars.RPC_URL_BASE_SEPOLIA_FORK }} + RAINIX_RPC_SECRET_ETHEREUM: ${{ secrets.RPC_URL_ETHEREUM_FORK }} + RAINIX_RPC_VARS_ETHEREUM: ${{ vars.RPC_URL_ETHEREUM_FORK }} + RAINIX_RPC_SECRET_FLARE: ${{ secrets.RPC_URL_FLARE_FORK }} + RAINIX_RPC_VARS_FLARE: ${{ vars.RPC_URL_FLARE_FORK }} + RAINIX_RPC_SECRET_HYPEREVM: ${{ secrets.RPC_URL_HYPEREVM_FORK }} + RAINIX_RPC_VARS_HYPEREVM: ${{ vars.RPC_URL_HYPEREVM_FORK }} + RAINIX_RPC_SECRET_POLYGON: ${{ secrets.RPC_URL_POLYGON_FORK }} + RAINIX_RPC_VARS_POLYGON: ${{ vars.RPC_URL_POLYGON_FORK }} + # Always, even when broadcasting: it is the same code path, so anything + # the real run would refuse is refused here for free. No `--rpc-url`: + # the script forks the `hyperevm` alias the preflight above bound. + # `--legacy` because HyperEVM's RPC rejects the fee-history ranges + # EIP-1559 estimation asks for — the same reason the deploy workflow + # carries a `legacy` input. + - name: Dry run + run: nix develop github:rainlanguage/rainix/${{ env.RAINIX_SHA }}#sol-shell -c forge script script/CreditHyperCore.sol:CreditHyperCore -vvvvv --legacy + env: + DEPLOYMENT_KEY: ${{ secrets.PRIVATE_KEY }} + HYPERCORE_CREDIT_WEI: ${{ inputs.credit-wei }} + # The one step that spends, and it is unreachable unless the `broadcast` + # input was set AND the dry run above passed. `--slow` as the deploy + # passes it: wait for each receipt before the next send. + - name: Broadcast + if: ${{ inputs.broadcast }} + run: nix develop github:rainlanguage/rainix/${{ env.RAINIX_SHA }}#sol-shell -c forge script script/CreditHyperCore.sol:CreditHyperCore -vvvvv --slow --broadcast --legacy + env: + DEPLOYMENT_KEY: ${{ secrets.PRIVATE_KEY }} + HYPERCORE_CREDIT_WEI: ${{ inputs.credit-wei }} diff --git a/CLAUDE.md b/CLAUDE.md index bad8085..448448d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -11,8 +11,9 @@ absent (rainlanguage/rainix#298). This is the repo's ONE agent file: no relocated. - **Broadcasting is key custody and real money.** Nothing automatic ever - broadcasts: `Manual sol artifacts` is `workflow_dispatch` only, and no merge, - tag or schedule may be given a path to it. + broadcasts: `Manual sol artifacts` and `Manual credit hypercore` are + `workflow_dispatch` only, and no merge, tag or schedule may be given a path + to either. - **A `sol-v*` tag is the sole release trigger.** `[external.package].version` in `foundry.toml` is the version of the LAST Soldeer publish, not a next-version slot, so an ordinary PR does not bump it. diff --git a/README.md b/README.md index eea268a..78117f1 100644 --- a/README.md +++ b/README.md @@ -419,7 +419,24 @@ 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: +credits itself. + +The canonical way to run it is the +[`Manual credit hypercore`](.github/workflows/manual-credit-hypercore.yaml) +workflow. The amount is its `credit-wei` input, typed under its own name — +which is why this is not a `Manual sol artifacts` dispatch: that workflow +exports `DEPLOYMENT_SUITE`, `DEPLOYMENT_NETWORK` and `DEPLOYMENT_KEY` and +nothing else, so an amount of real money could only travel through it under a +name that means something else. Every dispatch executes the dry run; +broadcasting takes the `broadcast` input flipped to true as well, and even then +the dry run still runs and refuses first. Dispatching is admin-gated: GitHub +itself gates `workflow_dispatch` at write access and nothing finer, so the +workflow's first step asks GitHub what permission the dispatching actor holds +on this repo and fails the run unless the answer is `admin` — who dispatched +and what they held is in the run log either way. + +The fallback, for when the workflow itself is what is broken, is the same +script by hand: ```sh read -rs DEPLOYMENT_KEY && export DEPLOYMENT_KEY @@ -453,11 +470,9 @@ 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. +However it is run, 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 diff --git a/script/CreditHyperCore.sol b/script/CreditHyperCore.sol index e85890f..1fe2493 100644 --- a/script/CreditHyperCore.sol +++ b/script/CreditHyperCore.sol @@ -27,11 +27,18 @@ import {LibHyperCore} from "../src/lib/LibHyperCore.sol"; /// /// ## Running it /// -/// Not on `Manual sol artifacts`. That workflow exports `DEPLOYMENT_SUITE`, +/// The canonical entry is the `Manual credit hypercore` workflow +/// (`.github/workflows/manual-credit-hypercore.yaml`): `workflow_dispatch` +/// only, gated to repo admins by its first step, the amount as a typed input +/// under its own name, and a dry run on every dispatch with the broadcast +/// behind a separate input that defaults to off. +/// +/// Not `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: +/// +/// The fallback, for when the workflow itself is what is broken, is by hand: /// /// ```sh /// read -rs DEPLOYMENT_KEY && export DEPLOYMENT_KEY