Skip to content

PEN token migration to Base - #559

Merged
ebma merged 61 commits into
mainfrom
feat/pen-to-base-migration
Sep 8, 2026
Merged

PEN token migration to Base#559
ebma merged 61 commits into
mainfrom
feat/pen-to-base-migration

Conversation

@ebma

@ebma ebma commented Aug 24, 2026

Copy link
Copy Markdown
Member

Implements the one-way migration of the native PEN token from the Pendulum parachain to a fixed-supply ERC-20 on Base.

Design and rationale: docs/pen-base-migration-prd.md and docs/adr-001-pen-base-migration-approach.md. A holder-facing summary is in docs/pen-base-migration-community-overview.md.

How it works

A holder calls tokenMigration.migrate(amount, base_address) on Pendulum. The transferable PEN is burned and an event with a globally unique nonce is emitted. Four attestors — each watching relay-chain-finalized blocks on its own node — independently submit an on-chain approval to the vault on Base; the third matching approval releases the tokens.

The token carries the full 150,000,000 PEN supply from deployment, minted once into the vault. There is no mint function, no owner and no proxy, so totalSupply() is correct for trackers on day one and can never grow. Migration is one-way: no Base → Pendulum path is built.

What's in this PR

Component Contents
pallets/token-migration Burn-and-emit extrinsic, governance-gated treasury path, pause origin, benchmarks
runtime/pendulum Pallet at index 102, origins, BaseFilter whitelist entry
contracts/ PEN.sol, MigrationVault.sol, PENGovernor.sol, deploy scripts (Foundry, OZ v5.4.0)
attestor/ Per-operator daemon: finalized-heads only, crash-safe checkpoint, idempotent approvals
monitor/ Independent watchdog: conservation + liveness, webhook alerts, optional auto-pause
releaser/ Drains cap-deferred releases via the permissionless release()
docs/ Design docs, runbooks, local test plan, review log

Security model

Base cannot cryptographically verify Pendulum state, so this is a trusted, damage-bounded design rather than a trustless one — stated plainly in PRD §8. Releases require 3 of 4 attestors to approve the identical (nonce, recipient, amount) tuple, and the damage a compromised quorum could do is bounded by:

  • a per-release cap and a rolling 24-hour leaky bucket;
  • a guardian that can pause instantly but cannot unpause — so a compromised guardian can only halt, never release;
  • a ≥48h timelock on every parameter change;
  • an independent monitor that verifies every release against a finalized burn and can auto-pause;
  • separation of duties — the guardian and monitor are operated by people who hold no attestor keys. With a team-operated set this is the control that carries the model, not organisational independence.

The pallet also ships paused, so enabling the runtime upgrade and going live are separate governance acts. This prevents holders burning PEN before the Base side is operational.

Tests

Suite Result
cargo test -p token-migration 21 (22 with runtime-benchmarks)
forge test 37, incl. fuzz and a full Governor lifecycle
attestor / monitor / releaser 6 / 7 / 7

cargo check -p pendulum-runtime is clean with and without runtime-benchmarks.

Before this can be deployed

Code is complete and reviewable, but merging is not the same as launching. Outstanding:

  • Final deployment parameters — caps, EARLIEST_SWEEP_TS, attestor addresses, guardian and admin Safes
  • Local validation per docs/pen-migration-local-test-plan.md; the hard gate is that the upgrade ships paused against a Chopsticks fork of live mainnet state
  • Benchmarks on reference hardware to replace the manual weights, then a spec_version bump and the runtime-upgrade referendum
  • Key ceremony and infrastructure, with separation of duties verified
  • Formal governance proposal fixing the migration window and parameters

The migration UI is a companion PR in the portal repo: pendulum-chain/portal#655.

ebma added 9 commits August 24, 2026 19:06
Specifies a one-way migration of the native PEN token from the Pendulum
parachain to a fixed-supply ERC-20 on Base (150,000,000 PEN, 18
decimals), with the full issuance pre-minted into a migration vault and
released as holders migrate.

- pen-base-migration-prd: requirements, decisions, component specs,
  threat model, acceptance criteria and rollout.
- adr-001: why a purpose-built one-way migration over existing bridge
  infrastructure or a snapshot-and-claim, and the sub-decisions within
  it (pre-mint vs mint-on-demand, on-chain approvals, burn vs lock).
- pen-token-contract-standards: which ERC-20 extensions the token
  implements and which are deliberately excluded.
- pen-governance-guide: the post-migration hybrid governance model,
  with worked examples of both tracks and the treasury structure.
- pen-migration-window-analysis: on-chain analysis of vesting, staking
  and governance locks sizing the migration window.
- pen-base-migration-community-overview: holder-facing summary.
Burns transferable native PEN and emits a MigrationInitiated event
carrying a globally unique nonce and the holder's Base address, which
the off-chain attestor set observes to release the equivalent amount
from the vault on Base. The pallet has no knowledge of Base state.

- migrate(amount, base_address): burns from the caller, rejecting
  amounts below a configurable minimum, the zero address, balances made
  non-transferable by staking/vesting/governance locks, and remainders
  that would strand the account below the existential deposit.
- migrate_treasury(amount) and set_treasury_destination(base_address):
  a governance-gated path for the keyless treasury account, which
  cannot use the signed extrinsic. The destination is set once and
  reviewed separately, so the routine call carries no address.
- Migrations ship paused and require an explicit governance
  set_paused(false), so enabling the runtime upgrade and going live are
  separate acts.
- Nonces are globally unique and monotonic across both paths, and
  TotalMigrated is exposed for the invariant monitor.

Includes unit tests and frame-benchmarking v2 benchmarks; weights are
conservative manual estimates pending a run on reference hardware.
Registers the pallet at index 102 with a 1 PEN minimum migration
amount, the treasury account and treasury-migration origin bound to the
existing treasury approval authority (root or 3/5 council), and a pause
origin of root, half the council, or two thirds of the technical
committee for fast incident response.

Adds the pallet to the runtime's exhaustive BaseFilter call whitelist,
without which every migration call would be silently rejected, and to
the benchmark list.
Foundry project (OpenZeppelin v5.4.0) holding the Base side of the
migration.

PEN.sol: fixed-supply ERC20 + ERC20Permit + ERC20Votes on an EIP-6372
timestamp clock. The entire max issuance is minted to the vault in the
constructor; there is no mint function, no owner and no proxy, so
totalSupply is correct for trackers from day one and can never grow.

MigrationVault.sol: holds the unmigrated supply and releases it on the
threshold-th matching on-chain approval from the attestor set, counted
per exact (nonce, recipient, amount) tuple so conflicting tuples never
merge. Nonce consumption is permanent, the 12 to 18 decimal conversion
happens here and nowhere else, and attestor generations ensure a
release threshold can only ever be crossed inside approve().

Releases are bounded by a per-release cap and a rolling 24-hour leaky
bucket; when a cap, a pause or an under-funded vault blocks a release
it is recorded as pending rather than reverted, so the debt stays
tracked and the fleet cannot deadlock. Pending amounts are reserved
against the timelocked end-of-window sweep. A guardian can pause
instantly but only the admin can unpause, so a compromised guardian can
halt but never release.

PENGovernor.sol plus deployment scripts for the vault, token and the
Governor/Timelock handover. 37 tests including fuzz and a full
propose-vote-queue-execute lifecycle.
Run by each attestor operator. Subscribes to relay-chain-finalized
heads on the operator's own Pendulum node -- never a shared or public
RPC, so no single faulty node can feed the whole fleet wrong data --
decodes MigrationInitiated events and submits the matching approval to
the vault on Base.

Blocks are processed strictly in order and the checkpoint advances only
once a block is fully handled, so a crash reprocesses at most one block
and approvals are idempotent. Losing the race to peers is the normal
case and is treated as a benign skip after re-checking on-chain state,
never a fatal error. Tuples the vault would deterministically reject
are skipped with a critical alert rather than retried, since every
attestor would otherwise hit the identical revert and halt the fleet.

Verifies its own membership in the attestor set at startup, and alerts
on decode failures, low gas and unexpected submission errors.
Runs on infrastructure separate from every attestor and reads both
chains independently. Each poll it verifies that nothing has been
released without a corresponding finalized burn on Pendulum, and that
the vault's balance, total released and total swept still account for
the entire supply. Only a deficit alerts: a surplus is a harmless
inbound transfer and must not be able to trip an auto-pause.

Base reads are pinned to a single block so a release landing mid-cycle
cannot produce a false alarm. Liveness tracks migrations that stay
unreleased past a grace period, batching the per-nonce reads through
Multicall3 and incorporating each nonce exactly once so the scan stays
proportional to the pending backlog rather than to all migrations ever
made.

Alerts via webhook and, when configured with a guardian key, pauses the
vault automatically on a conservation violation. That key must be held
by someone who holds no attestor key.
When a migration reaches the attestor threshold but a cap, a pause or
an under-funded vault blocks it, the vault records it pending and emits
ReleasePending instead of reverting. Those conditions heal by
themselves, but the vault does not self-execute and nothing else calls
the permissionless release(): attestors only submit approvals for new
finalized events, and the monitor is deliberately read-only. Without
this service a backlog would sit pending until an operator cleared it
by hand.

Kept as a separate process so the attestor's approve path and the
watchdog's read-only role stay untouched. Its key holds no privilege --
release() can only pay the recipient the attestors already approved,
under the same caps and pause -- so it needs gas and nothing else, and
two instances can run concurrently.

Failures are classified rather than treated alike: cap, pause and
funding reverts retry quietly, a consumed nonce is dropped, and an
amount above the per-release cap alerts because only a governance
change can clear it. Scan checkpoint and pending set are persisted.
- pen-migration-runbooks: procedures for attestor key compromise,
  attestor outage, invariant breach, pause and unpause, Pendulum
  runtime upgrades, attestor rotation, and the window-close sweep.
- pen-migration-local-test-plan: phased runbook for validating the
  whole stack locally, using Anvil for Base, Chopsticks against real
  mainnet state for the runtime upgrade and pallet, and Zombienet for
  the finality-dependent end-to-end path, with pre-mainnet exit
  criteria.
- pen-migration-implementation-overview: what was built and where.
- pen-migration-internal-review: the security-assurance record.
Records the agreed values in the deploy template: 150M max issuance,
1,000,000 PEN soft-launch caps to be raised to 3,000,000 by governance
after the soft launch, and an earliest-sweep floor of 2027-03-01.

Per-release and daily caps are set equal deliberately. A release blocked
by the daily cap heals on its own as the rolling bucket refills and the
releaser retries it; one above the per-release cap can only be cleared
by a governance setCaps behind the timelock. Keeping them equal removes
that permanently-stuck band.

The earliest-sweep floor is deliberately later than the ~3-month window
we intend to advertise. The two are separate numbers: closing the
window, pausing the pallet and shutting down the attestors are all
independent of sweeping, so a later floor costs nothing operationally --
the remainder simply waits in the vault. A shorter floor is the only
irrecoverable choice, since an immutable timestamp cannot be extended
afterwards, and it would downgrade the guarantee to holders from
"impossible by code" to "possible via a governance vote". The docs are
updated to state the target and the floor as distinct figures.

Also marks which values are permanent -- max issuance, the earliest
sweep timestamp and the conversion factor -- versus the addresses, caps
and threshold, which governance can change after deployment.
@ebma
ebma force-pushed the feat/pen-to-base-migration branch from a4951c0 to 3c47488 Compare August 24, 2026 17:36
ebma added 20 commits August 27, 2026 16:31
Automates phase 2 of the local test plan: the Pendulum side against a
Chopsticks fork of live mainnet state, with the new runtime applied as a
wasm override. Exercises what the unit tests cannot -- that the upgrade
applies to real storage, that it ships paused, and that migrate behaves
against genuine holder state including vesting, staking locks and the
real treasury account. Exits non-zero on failure so it can gate a step.

Two Chopsticks behaviours are worked around and documented, because both
produce silently wrong results rather than errors: storage overrides are
applied after extrinsics within a block, so every write gets its own
block; and the human-readable setStorage form treats a falsy value as a
deletion, which for Paused means it reads back as its `true` default, so
that key is written as raw 0x00.

The config sets no `db:` deliberately -- a persisted database carries
forward blocks the harness produced, which would make the ships-paused
assertion pass or fail for the wrong reason. The script also refuses to
run against a chain that is not fresh.
Brings the overview back in line with what is actually built: the
releaser and the local test harness were missing, test counts and
runbook range were stale, and it still referenced the superseded
branches. Verification status is now a table covering every suite,
including the 14/14 Chopsticks run against live mainnet state.

Corrects the documented minimum migration amount to the 100 PEN the
runtime actually ships, with the reason it is set there: it has to
dominate the attestor fleet's per-migration Base gas, or dust spam
becomes an asymmetric gas-drain grief.

Records that Foucoco is not part of validation -- the chain is no longer
live -- so the plan is this local stack plus Base Sepolia for the
contracts, and the discussion post's reference to Foucoco needs
correcting in the formal proposal.
Generated with the benchmark CLI over 50 steps / 20 repeats, replacing
the hand-written estimates. The estimates were conservative rather than
unsafe -- migrate was charged 50ms against a measured 19ms, and 4 reads
/ 4 writes against an actual 3 / 2 -- so nothing was under-charged, but
the real figures also carry proof sizes, which the estimates omitted
entirely.

Restructured to the repo's weights convention (trait, SubstrateWeight
and a () impl) since the generated template omits the trait definition,
and the header records how to regenerate.

Also documents a pre-existing blocker found while doing this:
`--chain pendulum` fails for EVERY pallet in this repo, because
CurrencyId and OracleKey serialise their variants
first-letter-lowercased (`native`, `xCM`, `exchangeRate`) but
deserialise expecting the original casing, so the benchmark CLI cannot
read back the genesis it just built. The workaround -- build-spec,
rewrite the variant names, pass the patched file -- is recorded in the
file header.
The same approve() call executes one of two very different paths
depending on what has landed by the time it is mined: either it merely
records an approval, or it is the one that crosses the threshold and
therefore performs the release, including an ERC-20 transfer. Gas
estimated while the cheap path applied does not cover the expensive one,
and with four attestors racing the same migration that reordering is the
normal case rather than an edge case.

The result was an OutOfGas revert for whichever attestor landed third.
That reads as an unexplained failure, so the daemon alerted and exited --
the same fleet-crash class the earlier race handling was meant to close,
reached by a different route. Under a process manager it would restart,
reprocess the same block and can hit it again.

Found by the end-to-end harness, which is the only place the daemons
race each other; no unit test can reach it.
viem refuses to batch unless the chain definition names a multicall3
address, even when the contract is present on-chain. The releaser's
custom chain definition did not, so every cycle that had anything
pending threw ChainDoesNotSupportContract -- which is precisely the
cycle in which the releaser matters. It would have silently drained
nothing in production while logging a cycle failure each poll.

Declares the canonical Multicall3 address and, as the monitor already
does, degrades to individual reads when the predeploy is absent (a local
devnet). Batching is an optimisation, never a requirement.
Phase 1 deploys with the real Deploy.s.sol -- including its two-step
admin handover -- and asserts against the deployed bytecode: supply,
attestor set, the release path, both caps, guardian asymmetry, the sweep
floor and the conservation identity. 11 checks.

Phase 3 runs the whole system together: four attestors, the monitor and
the releaser against Chopsticks and Anvil. It asserts that a burn on the
Substrate side arrives on Base unattended, that losing the approval race
does not kill a daemon, that the fleet tolerates one attestor down and
stops cleanly at two, that a recovered attestor drains the backlog, and
that a cap-deferred release is drained by the releaser with no manual
step. 7 checks.

ABIs are loaded from the Foundry artifacts rather than hand-maintained,
so viem can decode the vault's custom errors by name -- without that a
revert is a bare selector and every negative assertion is unreadable.

The README records the traps that cost real debugging time: a wasm built
with --features runtime-benchmarks cannot be used as a Chopsticks
override, attestor START_BLOCK must be the chain head rather than 0, and
strays from an aborted run must be killed or they rewrite the checkpoint
files a fresh run just cleared.
Phases 1 and 3 were written as manual procedures and are now scripted,
so the plan and the harness had drifted. Records what each script
asserts, that phase 3 uses Chopsticks rather than Zombienet and why,
that a Zombienet run for genuine finality timing is still outstanding,
and the traps that cost debugging time when running either phase.
`build-spec` serialises CurrencyId and OracleKey variants first-letter
lowercased (`native`, `xCM`, `exchangeRate`) but deserialises expecting the
original casing, so converting a plain spec to raw fails on a file the very
same binary just wrote. This blocks both `benchmark pallet --chain pendulum`
and Zombienet, which performs that conversion internally.

Drive the repair from the node's own error rather than a hardcoded variant
list that would drift: convert, read the rejected variant and its expected
spelling off stderr, rename only exact case-insensitive matches, repeat.
Genuine camelCase fields (chainType, bootNodes, tokenSymbol) are never
touched because the node never complains about them.
Chopsticks finalises every block it authors, so an attestor reading finalized
heads there is indistinguishable from one reading best heads. This brings up a
real relay plus the Pendulum collator, where finalized genuinely lags best.

Two things had to be taken over from Zombienet to make this work. It cannot
build this chain spec itself (build-spec cannot read back its own variant
casings), so the spec is generated here and handed over as chain_spec_path —
which in turn means Zombienet cannot inject the collator's authoring key, so
genesis is repointed at well-known dev keys. Governance membership goes with
them: this chain has no sudo pallet, and the pause origin must be drivable
locally.

The relay validators are named validator01/02 so the name 'alice' is free for
the collator; Zombienet only derives //Alice for a node actually called alice,
and a renamed collator silently gets a key that does not match genesis.

Also swap the runtime the node binary embeds for the artifact we ship. A build
with --features runtime-benchmarks rewrites that embedded wasm, and the result
decompresses past the relay's VALIDATION_CODE_BOMB_LIMIT — the relay then
rejects every candidate as PossibleBomb and the parachain never gets past its
own block #1.
Chopsticks finalises every block it authors, so an attestor reading finalized
heads is indistinguishable there from one reading best heads — the safety
property the whole design rests on was untested by construction. Against a real
relay it is observable: the parachain held a steady ~2-block finality lag, and
the checks assert both that finality advances (the relay is finalising
parachain blocks at all) and that it lags (finality is not instant).

Also re-checks the ships-paused default here, on a chain built from genesis
rather than forked from mainnet state, and asserts the exact subscription the
attestor uses delivers monotonically increasing heads.

The collator RPC is discovered rather than fixed: Zombienet reassigns ports on
every spawn, and the collator also exposes an embedded relay client, so a fixed
port is as likely to report Rococo as Pendulum.
Phase 4 was previously described as an exercise left for before mainnet. It now
exists and passes, so document it as a phase with its own pass criteria, the
measured lag, and the three traps that cost debugging time — chief among them
that a runtime-benchmarks build makes the relay reject every candidate while
looking entirely healthy from the relay's side.

Renumbers the failure drills and exit criteria to 5 and 6, and adds finality
gating to the exit checklist.
Fixed throwaway keys in a gitignored .env.rehearsal, so a rehearsal can be
re-run repeatedly without re-funding from a faucet each time.

The important part is assertTestnet. This script is built to be run casually
and often, with real keys in a real env file, and it deploys contracts and
moves tokens — so the cost of it ever pointing at Base mainnet or at real
Pendulum is unbounded. Both are refused explicitly, before anything is deployed
or signed: chain 8453 outright, anything other than Sepolia without a
deliberate override, and a Substrate endpoint that does not self-report as the
local chain.
Spawn, spec generation, collator discovery, finality readiness and teardown,
so the rehearsal does not restate what phase 4 already worked out.

killMatching reads the process table and filters in-process rather than
shelling out to `pkill -f`. A shell running `pkill -f <pattern>` matches its
own command line, because the pattern is part of it — which is how an earlier
session produced waiter shells that spun forever on a condition that could
never become false. It also skips its own ancestors, so a teardown can never
kill its caller.
Runs the whole system against real infrastructure on both sides at once:
genuine relay finality from Zombienet, and a public EVM with real gas
estimation, block times and RPC behaviour. Both production bugs found during
this work lived exactly there and were unreachable from unit tests.

Contracts are redeployed every run. The local chain is ephemeral and restarts
its nonce sequence at zero on each spawn while the vault's nonceConsumed
mapping is permanent, so a reused vault makes the second run re-emit nonce 0,
every attestor's pre-check answer 'already handled', and the pipeline log skips
while testing nothing. A guard asserts that rather than trusting the
convention.

Migrations are enabled through the technical-committee origin instead of a
storage poke — this chain has no sudo pallet, so the rehearsal drives the same
origin that will unpause mainnet.

Caps are sized for wall clock: there is no evm_increaseTime on a public chain,
so the rolling bucket is tuned to return the minimum migration every ~5 minutes
and the deferred-drain path can be observed unaided.
Base Sepolia faucets are rate-limited per address, so claiming for eight
addresses is slow and tedious. --fund claims-once-distribute-many: top up only
the roles below their minimum, only to their target, so re-running after a few
rehearsals costs nothing and does nothing.

Also right-sizes the funding minimums, which were guesswork before. A whole run
— two deployments plus ~20 approvals — measures at roughly 0.00005 ETH on Base
Sepolia, so the previous 0.056 ETH total carried about three orders of
magnitude more headroom than needed and made the faucet step far more painful
than it had to be. The new figures keep ~100x margin for gas spikes and the L1
data fee while fitting inside a single claim.
Phase 4 grew its own copy of the RPC probe before the rehearsal existed; the
shared module now owns it, so the port-probing and provider-cleanup logic has
one home rather than two that can drift.
Found by the first full rehearsal against Base Sepolia. attestor2 exited
fatally on a benign lost race: replaying its reverted approve one block earlier
succeeds, and it burned 26k of 500k gas, so it was an early custom-error revert
rather than a genuine failure.

The existing race tolerance re-reads the vault to confirm the revert was benign,
but a public endpoint is load-balanced across nodes and offers no
read-after-write consistency. A single read that lands on a lagging node reports
'not handled', which turns the most ordinary event in this system — losing the
k-of-n race — into an unexplained failure and takes the daemon down. Re-check
with backoff before concluding anything is wrong.

This is the same class as the earlier crash-loop and OutOfGas fixes, reached by
a third route, and it was unreachable from Anvil: a single node with instant
inclusion always reads its own writes.

The rehearsal hit the same root cause from the other side, reading pendingAdmin
straight after the deploy set it, so it now waits for that state to be visible
before accepting the handover.
The previous commit defined alreadyHandledSettled but left the catch calling
alreadyHandled, so the backoff never ran and the second rehearsal reproduced
the fatal exit unchanged.

Also stops the rehearsal asserting once against an eventually-consistent RPC.
Base Sepolia's public endpoint is load-balanced, so a read issued right after a
confirmed write can still land on a node that has not imported that block —
which is what failed the admin handover and the guardian pause, both of which
were correct on-chain. Assertions that follow a write now retry.

The restart check asserted totalReleased was unchanged, which was simply wrong:
an earlier migration can settle during the restart window, and that is what the
200 -> 350 PEN move was. Double releases are impossible regardless, since
nonceConsumed is permanent, so it now asserts the daemon rejoins and the total
never goes backwards.
ebma added 27 commits August 31, 2026 10:53
The RB-5 drill now enacts the actual referendum on a Chopsticks fork of
pre-upgrade mainnet: no pallet before, write the spec-26 wasm to :code — which
is what enactment does — and require every attestor's checkpoint to advance
past the upgrade block, including across a full node restart. Advancing is the
proof: the decode path ran to completion on blocks built by the new runtime,
and a decode failure exits the daemon by design. Building the spec-26 wasm for
the drill also confirms the referendum's version bump compiles cleanly.

Chopsticks shaped the script more than expected, and the header records the
traps: it serves metadata and runtime-version RPCs from a runtime cached at
fork time, even across --resume, so a post-upgrade extrinsic built against its
metadata is rejected by the executing runtime as badProof and cannot be
submitted at all — while frame-system's own lastRuntimeUpgrade record proves
execution genuinely upgraded. A restart without --resume silently re-forks at
the remote head and discards the enacted upgrade, and --resume's CLI form only
accepts a block hash.

Also anchors the 50x status-code match in the transient-error classifiers so a
number inside an error's transaction params cannot misclassify a genuine
failure as transient.
The review confirmed the monitor and releaser do not share the attestor's
former die-on-transient defect — both alert and continue at loop level — and
that the recheck backoff cannot turn a permanent failure into a silent skip.
The Foundry suite covers the governance logic; this covers what it cannot
reach. First execution ever of DeployGovernance.s.sol, with the role wiring
asserted on-chain (governor proposes and cancels, execution open to anyone,
the deployer's timelock admin genuinely renounced). The vault-admin ->
timelock handover, whose acceptance is itself a governance proposal. A real
admin action end to end through the timelock, asserting the delay gates from
both sides: execute reverts before the ETA and succeeds after. And the
negatives — below-threshold proposals rejected, the old admin powerless, the
guardian able to pause but nobody able to unpause without a proposal, which
deliberately leaves the run's vault paused: that latency is the design's cost.

No Zombienet and no daemon fleet: voter PEN is released through the real
3-of-4 approve() path driven directly by the attestor keys — which promptly
reproduced the bimodal-gas OutOfGas the attestor daemon pads for, confirming
it is a property of the contract, not the daemon; a direct caller must pad
too. The proposal lifecycle also tolerates lagging RPC nodes, whose state()
reads for a fresh proposal REVERT (GovernorNonexistentProposal) rather than
returning Pending.

Quorum fraction is 0 so mechanics are testable with drill-scale voting power;
quorum sizing and the production timings stay covered by the unit tests.
--manual stops after deploy + funding + wiring and prints a handoff card for
a by-hand walkthrough (Tally / Blockscout / MetaMask).
set_treasury_destination now refuses to overwrite an existing destination
(TreasuryDestinationAlreadySet). Once set, migrate_treasury can only ever
send to that address, so a routine council-majority proposal cannot
redirect treasury migrations; changing it requires root storage surgery.
The weight gains the extra storage read and proof size.
The checkpoint is written to a temporary file, fsynced and renamed into
place (directory fsynced too), so a crash leaves the old or the new
document, never a truncated one that reads as a first run. It records the
Base chain id, vault address and Pendulum genesis hash; a mismatch or a
malformed file is fatal rather than silently resetting progress.
Blocks are processed against the latest Base state, so throughput stays at
submission latency and losing the k-of-n race concludes benign from latest
as before. The durable checkpoint advances separately: only through blocks
whose every releasable event reads as handled at the safe/finalized
boundary. Approvals that refuse to settle (reorged away) are re-submitted
after a timeout with one alert, which also makes reorg recovery automatic.

Transient-error classification is now structural (HttpRequestError
status, TimeoutError, socket codes, walked through the cause chain) with a
word-only text fallback, so a tuple label such as nonce=429 can no longer
reclassify a genuine failure. State reads at the safe block outside a
node's retained window are transient. A watchdog pages when no finalized
head arrives; transient alerts are throttled; webhook payloads redact RPC
URLs and time out. Numeric config is validated at startup.
Same durable-write and identity-binding scheme as the attestor checkpoint:
temporary file, fsync, rename; bound to the Base chain id and vault;
malformed state is fatal rather than a silent reset of the pending set.
A successful release() no longer deletes its entry eagerly: it leaves the
durable pending set only once pruneConsumed sees the nonce consumed at the
safe/finalized block, so a reorged-away release is still ours to retry and
no inline finality wait stalls the drain. A NonceAlreadyConsumed decoded
from a latest-state simulation is treated the same way. A mined revert
(no revert data) is re-evaluated next cycle instead of paging as
unexpected; an amount above dailyCap itself is classified as blocked, not
a refill wait; governance-blocked releases re-page at a bounded interval.
Each log range probes its end block first so a lagging load-balanced node
replays loudly instead of truncating silently.
The Pendulum cursor, the Base cursor, the unresolved canonical tuples with
their finalized-block timestamps, the alert throttles and the source-supply
anchor are written durably and bound to the Base chain, vault and Pendulum
genesis. Malformed or mismatched state is fatal rather than a silent reset
of the security history.
…um burn

Finalized MigrationInitiated events become the canonical records; every
safe Base Approved and Released event must match one exactly (nonce,
recipient, pallet amount, and the converted token amount). A mismatch
against a known nonce pauses immediately. An unknown nonce pages on first
sight, then pauses as soon as it is provable (the finalized source view has
passed the moment the event was first observed, so the burn would already
be ingested) or after a bounded grace if the source view stays behind; a
stalling source view pages on its own beforehand. The pause runs before
the alert webhook, never behind it.

Aggregate conservation stays as independent defence. New guards: Pendulum
totalIssuance + TotalMigrated is anchored and any growth pages (minted PEN
would be honestly released from the fixed-supply vault); liveness also
covers quorum-reached-but-unreleased migrations; unreleasable burns (zero
or vault recipient) are paged once and excluded from the pending count;
log ranges probe their end block; long catch-ups persist as they go.
Quorum was a fraction of the full past total supply, which includes the
vault's unmigrated balance, while only migrated-and-delegated PEN can vote.
Post-handover that could deadlock permanently: a pause (whose unpause is
an admin action behind this governor) freezes releases, so circulating
voting supply can never grow to quorum.

MigrationVault.setToken now one-time-delegates the vault's balance to a
constant dead-address vote sink, which checkpoints the unmigrated supply
in the token's vote history; PENGovernor.quorum() subtracts the sink's
past votes from the denominator and applies an absolute quorumFloor (new
constructor and QUORUM_FLOOR deploy parameter) so early proposals are not
trivially cheap. The two sink constants are asserted equal in the tests;
vault-held PEN provably never votes.
CANCELLER_ROLE was granted only to the Governor, and OZ Governor lets only
the proposer cancel, and only before voting starts. Once a proposal with
quorum-clearing stake had passed, nothing could stop it during the 48h
delay: one executor transaction could batch unpause, attacker attestors,
unbounded caps and fabricated approvals, undoing the guardian's pause
inside the same batch. DeployGovernance takes an optional
TIMELOCK_CANCELLER (intended: the guardian Safe) and grants it the role;
the test proves the veto against a queued proposal.
perReleaseCap > dailyCap opened a band of amounts that pass the per-release
check but can never fit the daily allowance: burned on Pendulum, deferred
forever, clearable only by a timelocked setCaps. The rehearsal config had
exactly that shape. The constructor and setCaps now reject it
(CapsInverted). dailyCap is bounded by MAX_DAILY_CAP: an uncap of
type(uint256).max overflowed _decayedConsumed under checked arithmetic and
would have reverted every threshold-crossing approve() and release() until
a second timelocked setCaps. An earliestSweepTimestamp in the past is
rejected. The rehearsal per-release cap default now equals the daily cap.
Phases 3, 5 and 6 start the monitor with its required start block, start
nonce, Base start block and state file, and clear that state between runs.
Phase 3 gains two drills: a real vault deficit (via Anvil impersonation)
must alert and reach a confirmed auto-pause while a later surplus must
not re-trigger it, and a fabricated approval tuple must be caught before
quorum. The RB-6 drill rewinds the attestor checkpoint by editing
lastProcessedBlock in place, keeping the identity fields the loader
requires. Phase 4 exits non-zero on failure.
Round 8 (durable state, finality gating, governance quorum) and round 9
(multi-agent audit: governance capture, source-chain inflation, cap
semantics, daemon regressions) are logged with findings, resolutions,
refuted claims and the decisions still open. The runbooks gain an alert
vocabulary table and are corrected where they described pre-round-8
behaviour: RB-2 recovery (archive node, never edit the checkpoint), RB-3
source-supply response, RB-6 re-add rewind and the threshold-cut alert,
RB-7 accounting for unreleasable burns.
The migration stack lives outside the Rust workspace, so test-code.yml
never ran it: the contracts and the three daemons were only ever tested
by hand. A green check now means the suites the review log cites ran.
The durability check reads the vault at the safe/finalized block. On a
chain whose boundary still trails the vault's deployment (a fresh
deployment on a testnet, or Anvil, whose safe tag is genesis until the
chain is an epoch deep) that read returns no data, and the attestor
treated it as fatal: every attestor exited on its first checkpoint, with
nothing to read afterwards. A zero-data read at the boundary now means
"nothing can be durable there yet" — the checkpoint simply waits.
Daemon output was kept only in the harness's in-memory ring buffer, so a
silent attestor exit mid-run left no stderr to read. Every daemon's output
is now also written to testing/.logs/<name>.log.

Anvil resolves safe/finalized to genesis until the chain is 32 blocks deep,
which handed the daemons' finality-boundary reads a pre-vault block. The
harness now refuses to run unless Anvil's safe tag tracks latest, and the
documented command is anvil --port 8545 --slots-in-an-epoch 0.
A release writes an ERC20Votes checkpoint for the vault's vote sink, and
whether that is an overwrite or a new entry depends on the block timestamp
the transaction lands in — a state the estimate cannot know. Phase 3 saw an
exact estimate run out of gas in that write. Double it, as the attestor
already does for approve().
Anvil fills a missing gas limit from an estimate taken at the current
wall-clock second; a PEN transfer touching the vault writes a vote-sink
checkpoint keyed by timestamp, so the estimate (overwrite) undershoots the
execution one second later (new entry) and the deficit drill's impersonated
transfer ran out of gas. Every harness transaction now carries an explicit
limit. The cap-deferral drill sets equal caps: the vault rejects
perReleaseCap > dailyCap since round 9.
The upgrade adds the token-migration pallet at index 102 and its BaseFilter
arm — no existing call index, argument or signed extension changes — so
transaction_version stays at 11: signed transactions built against spec 25
remain valid across enactment.
The exit criteria asked for a re-run on reference hardware; the project has
never benchmarked on such hardware and the weights are measured, not hand
estimates. Record the decision where both the checklist and the file
header made the opposite promise.
The bump to 26 belongs to the release PR that follows this one, so that
"spec 26" is pinned to the exact commit the reproducible build runs on
rather than to whatever is on main between merge and release. This reverts
c1a2fdd; the same change is re-applied on release/pendulum-26.
@ebma
ebma merged commit d20e687 into main Sep 8, 2026
4 of 7 checks passed
@ebma
ebma deleted the feat/pen-to-base-migration branch September 8, 2026 13:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant