diff --git a/docs/ci-setup.md b/docs/ci-setup.md index b677d97f..fe22aa82 100644 --- a/docs/ci-setup.md +++ b/docs/ci-setup.md @@ -161,6 +161,29 @@ cast wallet address $(cat ~/.agentkeys/heima-deployer-test.key) The script defaults to derivation path `m/44'/60'/0'/0/0` (standard Ethereum BIP-44); pass `--index N` for a different address index. Idempotent — re-running with the same mnemonic prints `skip already-matches`; re-running with a different mnemonic refuses to overwrite (the existing key may own live deployed contracts). +The script also has standalone utility modes (run `--help` for the full reference): + +```bash +# Derive + print the private key to stdout WITHOUT writing anything — safe for +# scanning --index candidates when a derived address doesn't match the key file: +echo "$MNEMONIC" | bash scripts/heima-deployer-from-mnemonic.sh --stdin --print-key --index 3 + +# EVM address → Heima Substrate twin (the account you send HEI to when funding +# an EVM address; Frontier HashedAddressMapping: blake2_256("evm:" || addr)). +# python3-stdlib only, no mnemonic needed, nothing written: +bash scripts/heima-deployer-from-mnemonic.sh --evm-to-substrate 0xdE644936D5B7d5d42032fd08bbA42Fbbfd6663Bc +# → JSON with raw 32-byte hex + SS58 prefix 31 (mainnet) / 131 (Paseo) / 42 (generic) + +# Substrate address → decode to the raw account + SS58 re-encodings. The EVM +# source address CANNOT be computed back (one-way hash) — but you can verify a +# candidate pair (exit 0 = the SS58 IS that EVM address's twin): +bash scripts/heima-deployer-from-mnemonic.sh \ + --substrate-to-evm 47hNCTi9Jrs86atvDj9AhY67X2vQEDzzHAvzapKvUpxXz6EX \ + --verify-evm 0xdE644936D5B7d5d42032fd08bbA42Fbbfd6663Bc +``` + +The mnemonic flow itself also prints the derived address's Substrate twin (`twin:` line), so funding info is at hand immediately after deriving. + ### 2b. Rotating the test deployer key (e.g. after CI exposure) A rotated `TEST_HEIMA_DEPLOYER_KEY` is a **new wallet → new operator omni**, with no diff --git a/docs/spec/deployed-contracts.md b/docs/spec/deployed-contracts.md index 83f61431..4946316c 100644 --- a/docs/spec/deployed-contracts.md +++ b/docs/spec/deployed-contracts.md @@ -17,7 +17,7 @@ Two distinct EVM accounts deploy AgentKeys contracts. They are **different keys* | **Local / prod deploy** | `0xdE644936D5B7d5d42032fd08bbA42Fbbfd6663Bc` | `$HEIMA_DEPLOYER_KEY_FILE` (default `~/.agentkeys/heima-deployer.key`, never committed) | [`scripts/operator-workstation.env`](../../scripts/operator-workstation.env) `HEIMA_DEPLOYER_ADDR_HEIMA` | | **Test / CI deploy** | `0x051eFD0C16943c38a6E95D14fb5Cda78735b475e` | `~/.agentkeys/heima-deployer-test.key` (operator-provided; wired into GitHub Actions secrets via [`scripts/ci-set-github-secrets.sh`](../../scripts/ci-set-github-secrets.sh)) | [`scripts/operator-workstation.test.env`](../../scripts/operator-workstation.test.env) `HEIMA_DEPLOYER_ADDR_HEIMA` | -- The prod deployer's Substrate twin (SS58 prefix 31) is `47NGSq6JE5ZSnymGNa4nFVjWbsuhTfoSKN2jtpk28mUyC1M3` — fund the EVM side via the twin, see [`scripts/evm-to-substrate-address.mjs`](../../scripts/evm-to-substrate-address.mjs). +- The prod deployer's Substrate twin (SS58 prefix 31) is `47hNCTi9Jrs86atvDj9AhY67X2vQEDzzHAvzapKvUpxXz6EX` — fund the EVM side by sending HEI to the twin (Substrate-side `balances.transferKeepAlive`). Compute the twin for any EVM address with `bash scripts/heima-deployer-from-mnemonic.sh --evm-to-substrate <0xADDR>` (python3-stdlib only; replaces the former `evm-to-substrate-address.mjs`, which required never-installed `@polkadot` deps). The reverse direction is a one-way hash (`blake2_256("evm:" ‖ addr)`) — verify a candidate pair with `--substrate-to-evm --verify-evm <0xADDR>`. *(Correction 2026-06-11: this line previously listed `47NGSq6JE5ZSnymGNa4nFVjWbsuhTfoSKN2jtpk28mUyC1M3` as the twin — that account is NOT the hashed twin of `0xdE6449…63Bc` (cross-checked with both the `@polkadot` libs and an independent python implementation; likely it was the mnemonic's sr25519 address, the exact mix-up the old `.mjs` header warned about). HEI sent there does not credit the deployer's EVM balance.)* - Heima Paseo testnet uses its own deployer `0xeBdE9E5F8c0495e87a871BF4f17Fb85e1bFE827F` (`HEIMA_PASEO_DEPLOYER_ADDR`) — currently unused (chain halted, see below). --- diff --git a/scripts/evm-to-substrate-address.mjs b/scripts/evm-to-substrate-address.mjs deleted file mode 100644 index 9ca563ed..00000000 --- a/scripts/evm-to-substrate-address.mjs +++ /dev/null @@ -1,57 +0,0 @@ -#!/usr/bin/env node -// evm-to-substrate-address.mjs — given an EVM address, compute the -// Substrate account it's mapped to under Heima's Frontier setup -// (HashedAddressMapping): -// -// substrate_account = blake2_256("evm:" || eth_address_bytes) -// -// EVM-side `eth_getBalance(0x...)` reads the free balance of that -// substrate account. So to fund a Heima EVM address from a Substrate -// holder, you do a Substrate-side `balances.transferKeepAlive` to -// THAT account (NOT to the SS58 of the same mnemonic's sr25519 key -// — different account entirely). -// -// Usage: -// node scripts/evm-to-substrate-address.mjs <0x_EVM_ADDRESS> -// node scripts/evm-to-substrate-address.mjs 0xdE644936D5B7d5d42032fd08bbA42Fbbfd6663Bc -// -// Output (on stdout): three forms of the same account: -// - raw 32-byte hex -// - SS58 prefix 31 (Heima mainnet — paste this into Polkadot.js Apps) -// - SS58 prefix 131 (Heima Paseo) -// - SS58 prefix 42 (generic substrate) -// -// Output (on stderr): one short explainer line about the mapping. - -import { Keyring } from '@polkadot/keyring'; -import { - blake2AsU8a, - cryptoWaitReady, - encodeAddress, -} from '@polkadot/util-crypto'; -import { hexToU8a, u8aToHex } from '@polkadot/util'; - -await cryptoWaitReady(); - -const evmAddr = process.argv[2]; -if (!evmAddr || !/^0x[0-9a-fA-F]{40}$/.test(evmAddr)) { - console.error('usage: node evm-to-substrate-address.mjs <0xEVM_ADDRESS_40_HEX>'); - process.exit(1); -} -const ethBytes = hexToU8a(evmAddr.toLowerCase()); -const prefix = new TextEncoder().encode('evm:'); -const combined = new Uint8Array(prefix.length + ethBytes.length); -combined.set(prefix, 0); -combined.set(ethBytes, prefix.length); -const substrate32 = blake2AsU8a(combined, 256); - -console.error(`[evm-to-substrate] HashedAddressMapping("evm:" || ${evmAddr}):`); - -const rawHex = u8aToHex(substrate32); -console.log(JSON.stringify({ - evm_address: evmAddr, - substrate_account_hex: rawHex, - ss58_heima_mainnet: encodeAddress(substrate32, 31), - ss58_heima_paseo: encodeAddress(substrate32, 131), - ss58_generic: encodeAddress(substrate32, 42), -}, null, 2)); diff --git a/scripts/heima-deployer-from-mnemonic.sh b/scripts/heima-deployer-from-mnemonic.sh index 75148af6..c65224f2 100755 --- a/scripts/heima-deployer-from-mnemonic.sh +++ b/scripts/heima-deployer-from-mnemonic.sh @@ -12,6 +12,8 @@ # bash scripts/heima-deployer-from-mnemonic.sh --mnemonic-file /path/to/mnemonic.txt # AGENTKEYS_DEPLOYER_MNEMONIC="word1 word2 …" bash scripts/heima-deployer-from-mnemonic.sh # echo "$MNEMONIC" | bash scripts/heima-deployer-from-mnemonic.sh --stdin +# bash scripts/heima-deployer-from-mnemonic.sh --evm-to-substrate 0xdE6449…63Bc +# bash scripts/heima-deployer-from-mnemonic.sh --substrate-to-evm 47NGSq…C1M3 --verify-evm 0xdE6449…63Bc # # Flags: # --test derive the TEST deployer (out path gets -test suffix) @@ -21,8 +23,32 @@ # --path "m/44'/60'/…" full derivation path (overrides --index) # --mnemonic-file read mnemonic from this file (more secure than CLI) # --stdin read mnemonic from stdin +# --print-key print the derived private key to stdout and exit — +# nothing is written to disk, no key-file comparison +# (address + path go to stderr so stdout stays pipeable) +# --evm-to-substrate <0xADDR> +# print the Heima Substrate TWIN of an EVM address and +# exit (no mnemonic needed, nothing written). Heima's +# Frontier HashedAddressMapping maps +# account32 = blake2_256("evm:" || eth_addr_bytes); +# output is JSON: raw 32-byte hex + SS58 prefix 31 +# (Heima mainnet) / 131 (Heima Paseo) / 42 (generic). +# Fund a Heima EVM address by sending HEI to this twin +# via a Substrate-side balances.transferKeepAlive. +# --substrate-to-evm +# decode a Substrate address (SS58 checksum-verified, +# or raw 0x 32-byte hex) to its account + SS58 +# re-encodings, and exit. NOTE: the EVM source address +# CANNOT be computed from the account — the mapping is +# a one-way hash with no inverse. Pass --verify-evm to +# check a candidate EVM address instead. +# --verify-evm <0xADDR> with --substrate-to-evm: exit 0 iff the Substrate +# account IS the Heima twin of this EVM address # --help print this header # +# The mnemonic flow also prints the derived address's Substrate twin (prefix 31) +# when python3 is available — that twin is the account you fund for EVM gas. +# # Output path defaults (matches setup-heima.sh's HEIMA_DEPLOYER_KEY_FILE # resolution: ${HEIMA_DEPLOYER_KEY_FILE:-$HOME/.agentkeys/${AGENTKEYS_CHAIN}-deployer.key}): # prod → ~/.agentkeys/${AGENTKEYS_CHAIN:-heima}-deployer.key @@ -43,6 +69,10 @@ INDEX="0" DERIV_PATH="" MNEMONIC_FILE="" FROM_STDIN=0 +PRINT_KEY=0 +EVM_TO_SUB="" +SUB_TO_EVM="" +VERIFY_EVM="" while [ $# -gt 0 ]; do case "$1" in @@ -52,8 +82,12 @@ while [ $# -gt 0 ]; do --index) INDEX="$2"; shift 2 ;; --path) DERIV_PATH="$2"; shift 2 ;; --mnemonic-file) MNEMONIC_FILE="$2"; shift 2 ;; - --stdin) FROM_STDIN=1; shift ;; - -h|--help) sed -n '2,35p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; + --stdin) FROM_STDIN=1; shift ;; + --print-key) PRINT_KEY=1; shift ;; + --evm-to-substrate) EVM_TO_SUB="$2"; shift 2 ;; + --substrate-to-evm) SUB_TO_EVM="$2"; shift 2 ;; + --verify-evm) VERIFY_EVM="$2"; shift 2 ;; + -h|--help) awk 'NR==1{next} /^#/{sub(/^# ?/,""); print; next} {exit}' "$0"; exit 0 ;; *) echo "unknown arg: $1 (try --help)" >&2; exit 2 ;; esac done @@ -71,6 +105,134 @@ if [ -z "$DERIV_PATH" ]; then DERIV_PATH="m/44'/60'/0'/0/${INDEX}" fi +# --- SS58 / twin-account helpers (python3 stdlib only, no external deps) --- +# Heima (Frontier HashedAddressMapping) maps EVM -> Substrate +# one-way: account32 = blake2_256("evm:" || eth_address_bytes). The reverse +# has no inverse (hash preimage), so `decode` + `verify` are the honest +# equivalents of "substrate-to-evm". +ss58_py() { + python3 - "$@" <<'PYEOF' +import sys, json, hashlib + +ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" + +def b58encode(raw): + n = int.from_bytes(raw, "big") + out = "" + while n: + n, r = divmod(n, 58) + out = ALPHABET[r] + out + pad = len(raw) - len(raw.lstrip(b"\x00")) + return ALPHABET[0] * pad + out + +def b58decode(s): + n = 0 + for c in s: + i = ALPHABET.find(c) + if i < 0: + raise ValueError(f"invalid base58 char {c!r}") + n = n * 58 + i + raw = n.to_bytes((n.bit_length() + 7) // 8, "big") + pad = len(s) - len(s.lstrip(ALPHABET[0])) + return b"\x00" * pad + raw + +def prefix_bytes(p): + if p < 64: + return bytes([p]) + return bytes([((p & 0xFC) >> 2) | 0x40, (p >> 8) | ((p & 0x03) << 6)]) + +def ss58_encode(account32, p): + payload = prefix_bytes(p) + account32 + ck = hashlib.blake2b(b"SS58PRE" + payload, digest_size=64).digest()[:2] + return b58encode(payload + ck) + +def ss58_decode(addr): + raw = b58decode(addr) + if len(raw) < 35: + raise ValueError("address too short for a 32-byte account") + body, ck = raw[:-2], raw[-2:] + if hashlib.blake2b(b"SS58PRE" + body, digest_size=64).digest()[:2] != ck: + raise ValueError("SS58 checksum mismatch -- not a valid address") + if body[0] < 64: + p, account = body[0], body[1:] + else: + p = ((body[0] & 0x3F) << 2) | (body[1] >> 6) | ((body[1] & 0x3F) << 8) + account = body[2:] + if len(account) != 32: + raise ValueError(f"decoded account is {len(account)} bytes, expected 32") + return p, account + +def twin_of(evm_addr): + return hashlib.blake2b(b"evm:" + bytes.fromhex(evm_addr[2:]), digest_size=32).digest() + +def parse_account(arg): + if arg.lower().startswith("0x"): + account = bytes.fromhex(arg[2:]) + if len(account) != 32: + raise ValueError(f"hex account is {len(account)} bytes, expected 32") + return None, account + return ss58_decode(arg) + +def account_json(account32, extra): + return json.dumps({**extra, + "substrate_account_hex": "0x" + account32.hex(), + "ss58_heima_mainnet": ss58_encode(account32, 31), + "ss58_heima_paseo": ss58_encode(account32, 131), + "ss58_generic": ss58_encode(account32, 42), + }, indent=2) + +try: + cmd = sys.argv[1] + if cmd == "twin": + print(account_json(twin_of(sys.argv[2]), {"evm_address": sys.argv[2]})) + elif cmd == "twin-ss58": + print(ss58_encode(twin_of(sys.argv[2]), 31)) + elif cmd == "decode": + p, account = parse_account(sys.argv[2]) + print(account_json(account, {"input": sys.argv[2], "ss58_prefix": p})) + elif cmd == "verify": + _, account = parse_account(sys.argv[2]) + print("true" if account == twin_of(sys.argv[3]) else "false") + else: + sys.exit(f"unknown subcommand: {cmd}") +except ValueError as e: + sys.exit(f"error: {e}") +PYEOF +} + +is_evm_addr() { [[ "$1" =~ ^0x[0-9a-fA-F]{40}$ ]]; } + +if [ -n "$EVM_TO_SUB" ] || [ -n "$SUB_TO_EVM" ]; then + command -v python3 >/dev/null || { echo "python3 required for address conversion" >&2; exit 1; } +fi + +if [ -n "$EVM_TO_SUB" ]; then + is_evm_addr "$EVM_TO_SUB" || { echo "--evm-to-substrate: not a 0x 40-hex EVM address: $EVM_TO_SUB" >&2; exit 1; } + echo "[heima twin] blake2_256(\"evm:\" || $EVM_TO_SUB) — fund the EVM side by sending HEI to this Substrate account:" >&2 + ss58_py twin "$EVM_TO_SUB" + exit 0 +fi + +if [ -n "$SUB_TO_EVM" ]; then + ss58_py decode "$SUB_TO_EVM" + if [ -n "$VERIFY_EVM" ]; then + is_evm_addr "$VERIFY_EVM" || { echo "--verify-evm: not a 0x 40-hex EVM address: $VERIFY_EVM" >&2; exit 1; } + if [ "$(ss58_py verify "$SUB_TO_EVM" "$VERIFY_EVM")" = "true" ]; then + echo "verify: MATCH — $SUB_TO_EVM IS the Heima twin of $VERIFY_EVM" >&2 + exit 0 + fi + echo "verify: NO MATCH — $SUB_TO_EVM is NOT the twin of $VERIFY_EVM" >&2 + exit 1 + fi + cat >&2 <<'NOTE' +note: the EVM source address CANNOT be computed from a Substrate account — +Heima's mapping is one-way (account = blake2_256("evm:" || eth_addr), a hash +with no inverse). If you have a candidate EVM address, re-run with +--verify-evm 0x… to check whether this account is its twin. +NOTE + exit 0 +fi + command -v cast >/dev/null || { echo "cast not found — install Foundry: curl -L https://foundry.paradigm.xyz | bash && foundryup" >&2 exit 1 @@ -116,12 +278,26 @@ if [[ ! "$PRIV" =~ ^0x[0-9a-fA-F]{64}$ ]]; then fi ADDR=$(cast wallet address "$PRIV") +TWIN_SS58="" +if command -v python3 >/dev/null; then + TWIN_SS58=$(ss58_py twin-ss58 "$ADDR" 2>/dev/null || true) +fi +twin_line() { if [ -n "$TWIN_SS58" ]; then echo "${1:-}twin: $TWIN_SS58 (Substrate account to fund, SS58 prefix 31)"; fi; } + +if [ "$PRINT_KEY" = "1" ]; then + echo "address: $ADDR" >&2 + echo "path: $DERIV_PATH" >&2 + twin_line >&2 + printf '%s\n' "$PRIV" + exit 0 +fi if [ -f "$OUT" ]; then EXISTING=$(tr -d '\r\n[:space:]' < "$OUT") if [ "$EXISTING" = "$PRIV" ]; then echo "skip already-matches ($OUT)" echo "address: $ADDR" + twin_line exit 0 fi EXIST_ADDR=$(cast wallet address "$EXISTING" 2>/dev/null || echo "") @@ -153,6 +329,7 @@ echo " chain: $CHAIN" echo " path: $DERIV_PATH" echo " out: $OUT" echo " address: $ADDR" +twin_line " " echo echo "Next: setup-heima.sh will pick this up automatically — for test:" echo " HEIMA_DEPLOYER_KEY_FILE=$OUT MAINNET_CONFIRM=1 \\"