Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
142 changes: 142 additions & 0 deletions docs/hardfork/README.md
Original file line number Diff line number Diff line change
@@ -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.
67 changes: 67 additions & 0 deletions docs/hardfork/zero8-pre.txt
Original file line number Diff line number Diff line change
@@ -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
184 changes: 184 additions & 0 deletions scripts/hardfork-check
Original file line number Diff line number Diff line change
@@ -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<b else "NO <<< ORDERING BROKEN"))
'
else
printf '%-46s = ERROR: eth_getLogs returned nothing\n' "waterfall"
fi

blk=$(cast rpc eth_getBlockByNumber "$SETTLEMENT_BLOCK" false --rpc-url "$ARC" 2>&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
Loading