diff --git a/docs/hardfork/README.md b/docs/hardfork/README.md new file mode 100644 index 0000000..cfd964f --- /dev/null +++ b/docs/hardfork/README.md @@ -0,0 +1,142 @@ +# Zero8 (v0.8.0) hardfork — 3 Sep 2026, 15:00 UTC + +Arc testnet upgrades to arc-node v0.8.0 today. This note records what was checked before the +fork, what the fork can and cannot touch in Snapfall, and how to tell afterwards. + +Source: [`circlefin/arc-node/BREAKING_CHANGES.md`](https://github.com/circlefin/arc-node/blob/main/BREAKING_CHANGES.md). +Read 3 Sep 2026 ~08:20 UTC. + +## Files here + +| File | What it is | +|---|---| +| `zero8-pre.txt` | Chain fingerprint taken **before** the fork, at repo `103f0be` | +| `zero8-post.txt` | The same fingerprint taken after — produce it and diff | + +```bash +export ARC_ARCHIVE_RPC=... # optional; an archive endpoint, see §3 +./scripts/hardfork-check > docs/hardfork/zero8-post.txt +diff docs/hardfork/zero8-pre.txt docs/hardfork/zero8-post.txt +``` + +`gasPrice` and the `taken` timestamp are expected to differ. **Everything else differing is a +finding**, because no Snapfall transaction is being submitted in between. + +## 1. What v0.8.0 changes, and whether it reaches us + +Six breaking changes. Five are node-operator only — `--rpc.admin` for peer mutation, mandatory +denylist enforcement, stricter `ARC_*` env validation, a required `--chain` on snapshot +download, and a pruning interval moving from 5000 to 128 blocks. **Snapfall runs no node**, so +none of them apply. + +One is client-facing: + +> **JSON-RPC error messages on insufficient balance.** `eth_call` and `eth_estimateGas` now +> surface revm 38's `OutOfFunds`; EOA transfers report `"gas required exceeds allowance"` where +> they previously said `"Missing or invalid parameters"`. *Action required: update JSON-RPC +> error parsers.* + +**We have no such parser.** The only `strings.Contains` against an error anywhere in the daemon +is a SQLite `"UNIQUE constraint"` match in `daemon/internal/approval/lifecycle.go`. RPC errors +are wrapped and returned, never matched on, so no control flow depends on their text. The +sidecar matches no error strings at all. This change is cosmetic for us. + +Two earlier changes already bind and were re-checked: + +- **v0.7.2 rejects pre-EIP-155 transactions.** `daemon/internal/chain/chain.go` signs with + `types.NewEIP155Signer(chainID)`. Compliant. +- **v0.7.2 caps JSON-RPC gas at 30,000,000.** Our largest transaction is a 1.47M-gas contract + deployment. Two orders of magnitude of headroom. + +## 2. The real exposure is gas accounting, not the itemized list + +Zero8 is described as updating gas accounting and state-clearing semantics at the execution +layer. Neither appears as a numbered breaking change, because neither changes an API — but both +can move what `eth_estimateGas` returns, and our settlement path *clears storage* (an advance +closing zeroes its slots), which is exactly where refund accounting lives. + +The submit path estimates per transaction and adds 20% headroom, so a moderate shift absorbs +silently. No gas limit is hardcoded anywhere; `SubmitWithGas` exists only for the deliberate +revert demo and has no production caller. Assessed as low risk, but it is the thing to watch: +**if anything fails after the fork, compare `gasUsed` in `zero8-post.txt` against the pre file +before looking anywhere else.** + +## 3. Pre-existing finding: the public RPC no longer serves transactions by hash + +Found while building the baseline, **before** the fork — this is not fork damage: + +``` +eth_getTransactionByHash -> null +eth_getTransactionReceipt -> null +eth_getBlockByNumber -> served +eth_getLogs (by block) -> served +``` + +Every transaction hash in `docs/addresses.md` returns `null` on +`rpc.testnet.arc.network` — the deploy transactions, the job-004 lifecycle, all three +`RateChanged` settlements. The blocks themselves are still served, so the chain is intact; the +tx-hash index is pruned. + +**Why this matters beyond today.** `docs/addresses.md` names the RPC as *"the primary +verification path"* precisely because ArcScan has a documented outage on transaction-hash +lookups. Both paths are now dead for hash lookups at once, so as written, the settlement proof +cannot be verified by a reader. + +The evidence itself is intact and still reachable two ways: + +1. **By block, on the public RPC.** `eth_getLogs` over the settlement block returns the full + waterfall. Verified 3 Sep: `logIndex 12` transfers 561000 to FloatPool, `logIndex 15` + transfers 439000 to the operator — pool repaid before the operator is paid, the same + ordering `docs/addresses.md` §4 claims. `scripts/hardfork-check` now asserts this on every + run rather than counting logs. +2. **By hash, on an archive endpoint.** A third-party archive RPC still returns the receipt + with `status 0x1`. Set `ARC_ARCHIVE_RPC` to use it; the script reports it when present. + +Neither path is in `docs/addresses.md` yet. That doc needs its verification commands +reworked before submission — the numbers are right, the way it tells a reader to check them +is not. + +## 4. State that is crossing the fork open + +The pool is **not** quiescent. From `zero8-pre.txt`: + +``` +FloatPool.totalOutstanding = 600000 (0.60 USDC) +FloatPool.orgOutstanding(op) = 600000 +FloatPool.advanceRate(op) = 7000 (70%) +FloatPool.acceptedJobs(op) = 4 +FloatPool.reserve = 8240 +``` + +An advance is live across the upgrade. Note this is ahead of what `docs/addresses.md` records +(rate 6500, three accepted jobs, reserve 5640): a fourth job settled after that page was last +written on 8 Aug, and a fifth advance is open now. + +The invariant to check after the fork is conservation, and the pre-file has every term of it. + +## 5. Verified green before the fork, at `103f0be` + +| Layer | Result | +|---|---| +| `forge build` | clean | +| `forge test` | 119 passed, 0 failed | +| `go build ./...` | clean | +| `go test ./...` | exit 0, 33 packages ok | +| `sidecar: tsc --noEmit` | clean | +| `sidecar: h3-vectors` | 4 assertions pass | +| `sidecar: post-sign` | 5 assertions pass | +| `sidecar: seller-hostile` | 13 assertions pass | +| `sidecar: facilitator` | 18 assertions pass | +| `sidecar: facilitator-wiring` | 7 assertions pass | + +All three layers are green on the pre-fork network. Anything red afterwards is the fork, +not accumulated drift — which is the whole reason to have run them today. + +## 6. After 15:00 UTC + +1. `./scripts/hardfork-check > docs/hardfork/zero8-post.txt`, then diff against the pre file. +2. Confirm `waterfall: pool before operator = YES` still holds. +3. Confirm the FloatPool constants and the one-shot wiring are unchanged. +4. Only then submit anything: run `./scripts/testnet-ops` for wallet balances, and a scaled + `./scripts/spine_run` for a live end-to-end. +5. Record what broke here. If nothing broke, record that too — a clean diff is the result. diff --git a/docs/hardfork/zero8-pre.txt b/docs/hardfork/zero8-pre.txt new file mode 100644 index 0000000..9805517 --- /dev/null +++ b/docs/hardfork/zero8-pre.txt @@ -0,0 +1,67 @@ +# Snapfall chain fingerprint +# taken 2026-09-03T08:53:41Z +# rpc https://rpc.testnet.arc.network +# repo 103f0bea01b68023739c652dc331cb62dd327769 + +## network +chainId = 5042002 +gasPrice = 20240000000 +# blockNumber deliberately omitted — it always differs; it is not a defect signal. + +## code is still live at each address +AuditAnchor codesize = 755 bytes +JobVault codesize = 5841 bytes +FloatPool codesize = 6335 bytes +USDC codesize = 1798 bytes + +## USDC (ERC-20 surface) +USDC.symbol = "USDC" +USDC.decimals = 6 + +## wiring — one-shot, must never change +JobVault.usdc = 0x3600000000000000000000000000000000000000 +JobVault.floatPool = 0xde9F58A997Cf7A3258D09A797Eb5546877dc86E5 +JobVault.admin = 0x99B723eD097721036C08dd9DEe307286Df3A792D +FloatPool.usdc = 0x3600000000000000000000000000000000000000 +FloatPool.jobVault = 0xF3830D7C3B8ca873bB0b277c0e179999e3d52681 +FloatPool.admin = 0x99B723eD097721036C08dd9DEe307286Df3A792D +AuditAnchor.operatorAuthority = 0x99B723eD097721036C08dd9DEe307286Df3A792D + +## FloatPool constants — immutable; any drift is an execution-layer defect +FloatPool.BASE_BPS = 5000 +FloatPool.GROWTH_BPS = 500 +FloatPool.PENALTY_BPS = 1500 +FloatPool.FLOOR_BPS = 3000 +FloatPool.CAP_BPS = 8500 +FloatPool.FEE_BPS = 200 +FloatPool.RESERVE_CUT_BPS = 2000 +FloatPool.ORG_EXPOSURE_CAP_BPS = 1000 +FloatPool.UTILIZATION_CAP_BPS = 8000 + +## FloatPool accounting +FloatPool.totalAssets = 12510400 [1.251e7] +FloatPool.totalOutstanding = 600000 [6e5] +FloatPool.reserve = 8240 +FloatPool.advanceRate(op) = 7000 +FloatPool.acceptedJobs(op) = 4 +FloatPool.writtenOffJobs(op) = 0 +FloatPool.orgOutstanding(op) = 600000 [6e5] + +## custody +USDC.balanceOf(JobVault) = 43000000 [4.3e7] +USDC.balanceOf(FloatPool) = 71918640 [7.191e7] +native.balance(operator) = 96269491671295926814 +native.balance(customer) = 21365367562500000000 +native.balance(lp) = 1495800604500000000 + +## job-004 — the settled lifecycle in docs/addresses.md must read back unchanged +JobVault.jobStatus(job-004) = 4 +FloatPool.openAdvanceOf(job-004) = 550000 [5.5e5] + +## history still readable +waterfall: pool repaid = logIndex 12 -> 0xde9f58a997cf7a3258d09a797eb5546877dc86e5 value 561000 +waterfall: operator paid = logIndex 15 -> 0x99b723ed097721036c08dd9dee307286df3a792d value 439000 +waterfall: pool before operator = YES +settlement block hash = 0xe4dd7fc9d9b189acecbac44b26145ec89b4c8d46f2419f813ee260084da2a12d +public RPC receipt by hash = null (pruned tx-hash index) +archive RPC receipt by hash = served, status 0x1 diff --git a/scripts/hardfork-check b/scripts/hardfork-check new file mode 100755 index 0000000..d57b5bd --- /dev/null +++ b/scripts/hardfork-check @@ -0,0 +1,184 @@ +#!/usr/bin/env bash +# Read-only chain fingerprint of the live Snapfall deployment. +# +# Run it before a network upgrade and again after, then diff the two files: anything that +# moved without a Snapfall transaction in between is the upgrade's doing, not ours. +# +# ./scripts/hardfork-check > /tmp/pre.txt +# ...upgrade... +# ./scripts/hardfork-check > /tmp/post.txt +# diff /tmp/pre.txt /tmp/post.txt +# +# Submits nothing and needs no key. Calls are paced: Arc's public RPC rate-limits a tight +# loop with -32011 (docs/RUNBOOK.md). +set -uo pipefail + +ARC="${ARC_TESTNET_RPC:-https://rpc.testnet.arc.network}" +JOBVAULT=0xF3830D7C3B8ca873bB0b277c0e179999e3d52681 +FLOATPOOL=0xde9F58A997Cf7A3258D09A797Eb5546877dc86E5 +AUDITANCHOR=0x7CDBF8a6D33d4c4C55fb94447E7E90905b3672c6 +USDC=0x3600000000000000000000000000000000000000 +OPERATOR=0x99B723eD097721036C08dd9DEe307286Df3A792D +CUSTOMER=0x9c27EEde9De36cCb0447B87B48BE88739DAf7608 +LP=0x27Ff8025a0Ddc800D58e0F6169Ed5c166406Eb03 +# job-004, the lifecycle documented in docs/addresses.md §3 +JOB=0x736e617066616c6c2d6a6f622d30303400000000000000000000000000000000 +# its settlement tx — §4's waterfall proof +SETTLEMENT=0x108a8f908b368aca286b8011d3dab34fc26c635d32df2689555ffc806ef9de4b + +pace() { sleep 1; } + +# Print "label = value", or "label = ERROR: ..." — a reverting call must be visible in the +# diff, never silently absent. +q() { + local label="$1"; shift + local out + if out=$("$@" --rpc-url "$ARC" 2>&1); then + printf '%-46s = %s\n' "$label" "$(echo "$out" | head -1)" + else + printf '%-46s = ERROR: %s\n' "$label" "$(echo "$out" | tr '\n' ' ' | cut -c1-110)" + fi + pace +} + +echo "# Snapfall chain fingerprint" +echo "# taken $(date -u '+%Y-%m-%dT%H:%M:%SZ')" +echo "# rpc $ARC" +echo "# repo $(git -C "${SNAPFALL_REPO:-$(dirname "${BASH_SOURCE[0]}")/..}" rev-parse HEAD 2>/dev/null || echo 'not a git checkout')" +echo + +echo "## network" +q "chainId" cast chain-id +q "gasPrice" cast gas-price +echo "# blockNumber deliberately omitted — it always differs; it is not a defect signal." +echo + +echo "## code is still live at each address" +for pair in "AuditAnchor:$AUDITANCHOR" "JobVault:$JOBVAULT" "FloatPool:$FLOATPOOL" "USDC:$USDC"; do + name="${pair%%:*}"; addr="${pair#*:}" + code=$(cast code "$addr" --rpc-url "$ARC" 2>/dev/null) + printf '%-46s = %s bytes\n' "$name codesize" "$(( (${#code} - 2) / 2 ))" + pace +done +echo + +echo "## USDC (ERC-20 surface)" +q "USDC.symbol" cast call "$USDC" "symbol()(string)" +q "USDC.decimals" cast call "$USDC" "decimals()(uint8)" +echo + +echo "## wiring — one-shot, must never change" +q "JobVault.usdc" cast call "$JOBVAULT" "usdc()(address)" +q "JobVault.floatPool" cast call "$JOBVAULT" "floatPool()(address)" +q "JobVault.admin" cast call "$JOBVAULT" "admin()(address)" +q "FloatPool.usdc" cast call "$FLOATPOOL" "usdc()(address)" +q "FloatPool.jobVault" cast call "$FLOATPOOL" "jobVault()(address)" +q "FloatPool.admin" cast call "$FLOATPOOL" "admin()(address)" +q "AuditAnchor.operatorAuthority" cast call "$AUDITANCHOR" "operatorAuthority()(address)" +echo + +echo "## FloatPool constants — immutable; any drift is an execution-layer defect" +for f in BASE_BPS GROWTH_BPS PENALTY_BPS FLOOR_BPS CAP_BPS FEE_BPS RESERVE_CUT_BPS ORG_EXPOSURE_CAP_BPS UTILIZATION_CAP_BPS; do + q "FloatPool.$f" cast call "$FLOATPOOL" "$f()(uint16)" +done +echo + +echo "## FloatPool accounting" +q "FloatPool.totalAssets" cast call "$FLOATPOOL" "totalAssets()(uint256)" +q "FloatPool.totalOutstanding" cast call "$FLOATPOOL" "totalOutstanding()(uint256)" +q "FloatPool.reserve" cast call "$FLOATPOOL" "reserve()(uint256)" +q "FloatPool.advanceRate(op)" cast call "$FLOATPOOL" "advanceRate(address)(uint16)" "$OPERATOR" +q "FloatPool.acceptedJobs(op)" cast call "$FLOATPOOL" "acceptedJobs(address)(uint32)" "$OPERATOR" +q "FloatPool.writtenOffJobs(op)" cast call "$FLOATPOOL" "writtenOffJobs(address)(uint32)" "$OPERATOR" +q "FloatPool.orgOutstanding(op)" cast call "$FLOATPOOL" "orgOutstanding(address)(uint256)" "$OPERATOR" +echo + +echo "## custody" +q "USDC.balanceOf(JobVault)" cast call "$USDC" "balanceOf(address)(uint256)" "$JOBVAULT" +q "USDC.balanceOf(FloatPool)" cast call "$USDC" "balanceOf(address)(uint256)" "$FLOATPOOL" +q "native.balance(operator)" cast balance "$OPERATOR" +q "native.balance(customer)" cast balance "$CUSTOMER" +q "native.balance(lp)" cast balance "$LP" +echo + +echo "## job-004 — the settled lifecycle in docs/addresses.md must read back unchanged" +q "JobVault.jobStatus(job-004)" cast call "$JOBVAULT" "jobStatus(bytes32)(uint8)" "$JOB" +q "FloatPool.openAdvanceOf(job-004)" cast call "$FLOATPOOL" "openAdvanceOf(bytes32)(uint256,uint256,bool)" "$JOB" +echo + +echo "## history still readable" +# +# The public RPC no longer serves transactions or receipts BY HASH — verified 3 Sep 2026, +# BEFORE the Zero8 fork, so this is not fork damage. `eth_getTransactionByHash` and +# `eth_getTransactionReceipt` both return null for every hash in docs/addresses.md, while +# the blocks themselves are still served. The tx-hash index is pruned; the chain is not. +# +# Two consequences this section checks: +# 1. logs are still readable BY BLOCK on the public RPC — this is what keeps the +# settlement proof verifiable without an archive provider; +# 2. an archive endpoint, if ARC_ARCHIVE_RPC is set, still resolves the hash. +# +SETTLEMENT_BLOCK=0x33212d8 # 53,613,272 — verify: cast to-hex 53613272 + +# The waterfall claim itself, re-derived from logs rather than trusted: JobVault repays the +# pool at logIndex 12 and pays the operator at 15, on the 6-decimal ERC-20 surface. Pool +# lower than operator is the whole credit claim (docs/addresses.md §4). If a fork reorders +# or re-values these, the submission's central proof is broken and it must be caught here. +logs=$(cast rpc eth_getLogs "{\"fromBlock\":\"$SETTLEMENT_BLOCK\",\"toBlock\":\"$SETTLEMENT_BLOCK\",\"address\":\"$USDC\"}" --rpc-url "$ARC" 2>/dev/null) +pace +if [ -n "$logs" ]; then + echo "$logs" | python3 -c ' +import json,sys +TRANSFER="0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef" +POOL="0xde9f58a997cf7a3258d09a797eb5546877dc86e5" +OPERATOR="0x99b723ed097721036c08dd9dee307286df3a792d" +try: + logs=json.load(sys.stdin) +except Exception as e: + print("%-46s = ERROR: unparseable log response" % "waterfall"); raise SystemExit +by={int(l["logIndex"],16):l for l in logs} +def row(i,label,expect_to,expect_val): + l=by.get(i) + if l is None or l["topics"][0]!=TRANSFER: + print("%-46s = MISSING at logIndex %d" % (label,i)); return None + to="0x"+l["topics"][2][-40:]; val=int(l["data"],16) + ok = (to==expect_to and val==expect_val) + print("%-46s = logIndex %d -> %s value %d%s" % (label,i,to,val,"" if ok else" <<< DEVIATES")) + return i +a=row(12,"waterfall: pool repaid",POOL,561000) +b=row(15,"waterfall: operator paid",OPERATOR,439000) +if a is not None and b is not None: + print("%-46s = %s" % ("waterfall: pool before operator","YES" if a&1) +pace +case "$blk" in + *'"hash"'*) printf '%-46s = %s\n' "settlement block hash" "$(echo "$blk" | sed -n 's/.*"hash":"\([^"]*\)".*/\1/p' | head -1)" ;; + *) printf '%-46s = ERROR: block not served\n' "settlement block hash" ;; +esac + +# By hash: null on the public RPC as of 3 Sep 2026, BEFORE the fork. Recorded so the diff +# shows whether the fork moved it in either direction. +byhash=$(cast rpc eth_getTransactionReceipt "$SETTLEMENT" --rpc-url "$ARC" 2>&1) +pace +case "$byhash" in + *'"status"'*) printf '%-46s = served\n' "public RPC receipt by hash" ;; + *null*) printf '%-46s = null (pruned tx-hash index)\n' "public RPC receipt by hash" ;; + *) printf '%-46s = ERROR: %s\n' "public RPC receipt by hash" "$(echo "$byhash" | tr '\n' ' ' | cut -c1-90)" ;; +esac + +if [ -n "${ARC_ARCHIVE_RPC:-}" ]; then + arch=$(cast rpc eth_getTransactionReceipt "$SETTLEMENT" --rpc-url "$ARC_ARCHIVE_RPC" 2>&1) + pace + case "$arch" in + *'"status":"0x1"'*) printf '%-46s = served, status 0x1\n' "archive RPC receipt by hash" ;; + *null*) printf '%-46s = null\n' "archive RPC receipt by hash" ;; + *) printf '%-46s = ERROR: %s\n' "archive RPC receipt by hash" "$(echo "$arch" | tr '\n' ' ' | cut -c1-90)" ;; + esac +else + printf '%-46s = skipped (ARC_ARCHIVE_RPC unset)\n' "archive RPC receipt by hash" +fi