PEN token migration to Base - #559
Merged
Merged
Conversation
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
force-pushed
the
feat/pen-to-base-migration
branch
from
August 24, 2026 17:36
a4951c0 to
3c47488
Compare
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.
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.mdanddocs/adr-001-pen-base-migration-approach.md. A holder-facing summary is indocs/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
pallets/token-migrationruntime/pendulumBaseFilterwhitelist entrycontracts/PEN.sol,MigrationVault.sol,PENGovernor.sol, deploy scripts (Foundry, OZ v5.4.0)attestor/monitor/releaser/release()docs/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: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
cargo test -p token-migrationruntime-benchmarks)forge testattestor/monitor/releasercargo check -p pendulum-runtimeis clean with and withoutruntime-benchmarks.Before this can be deployed
Code is complete and reviewable, but merging is not the same as launching. Outstanding:
EARLIEST_SWEEP_TS, attestor addresses, guardian and admin Safesdocs/pen-migration-local-test-plan.md; the hard gate is that the upgrade ships paused against a Chopsticks fork of live mainnet statespec_versionbump and the runtime-upgrade referendumThe migration UI is a companion PR in the portal repo: pendulum-chain/portal#655.