Skip to content

PEN → Base token migration (pallet, contracts, attestor, monitor, governance)#558

Draft
ebma wants to merge 21 commits into
mainfrom
feat/pen-base-migration
Draft

PEN → Base token migration (pallet, contracts, attestor, monitor, governance)#558
ebma wants to merge 21 commits into
mainfrom
feat/pen-base-migration

Conversation

@ebma

@ebma ebma commented Jul 8, 2026

Copy link
Copy Markdown
Member

Overview

Implements the one-way migration of the native PEN token from the Pendulum parachain to Base, per the design in docs/pen-base-migration-prd.md and docs/adr-001-pen-base-migration-approach.md.

Users call tokenMigration.migrate(amount, base_address) on Pendulum; the amount is burned and a MigrationInitiated event with a unique nonce is emitted. Five independent attestors (own nodes, relay-finalized blocks only) each submit an on-chain approve(...) to a MigrationVault on Base; the 3rd matching approval releases pre-minted tokens. The PEN ERC-20 has its entire max issuance minted to the vault at deployment and no mint function, so total supply is correct from day one and the worst case is bounded by the vault's rate caps.

What's in this branch

  • pallets/token-migration/ — burn-and-emit extrinsic, unique nonces, dust/ED + lock handling, zero-address rejection, pause origin; unit tests + benchmarks.
  • runtime/pendulum/ — pallet wired at index 102 (Foucoco intentionally skipped — production-direct), BaseFilter whitelist, pause = root/½-council or ⅔ technical committee.
  • contracts/ (Foundry, OZ v5.4.0) — PEN.sol (fixed-supply ERC20 + Permit + Votes), MigrationVault.sol (3-of-5 on-chain approvals, replay-safe nonces, caps, guardian pause, attestor rotation with generations, pending-release accounting, explicit-amount timelocked sweep), PENGovernor.sol (OZ Governor + Timelock), deploy scripts.
  • attestor/ — TypeScript daemon (finalized-heads-only, crash-safe checkpoint, race-tolerant, fail-fast on decode errors).
  • monitor/ — independent conservation + liveness watchdog with optional guardian auto-pause.
  • docs/ — PRD, ADR, token-standards decision record, runbooks (RB-1…RB-7), and the internal security-review log.

Security

Three internal adversarial review rounds were run; each found real issues (including in the prior round's fixes), all fixed and recorded in docs/pen-migration-internal-review.md. An external professional audit remains a hard gate before mainnet vault funding.

Tests

Pallet 11/11 (incl. benchmark suite), contracts 33/33 (incl. fuzz), runtime cargo check clean (both feature sets), attestor/monitor typecheck clean.

⚠️ Not ready to merge — open gates

  • Decisions D1–D6 (PRD §4.2), most urgently max issuance (D3) and decimals (D2) — both baked into immutable contracts at deploy.
  • External audit (PRD §9).
  • Benchmark run on reference hardware to replace manual weights.
  • Attestor operator onboarding + Safe/key ceremonies.

Companion frontend (portal repo) tracked separately.

ebma added 15 commits July 7, 2026 19:31
Implements PRD P1-P6: migrate(amount, base_address) burns the amount
(total issuance decreases, decision D1) and emits MigrationInitiated
with a globally unique monotonic nonce for the attestor set. Enforces a
minimum migration amount, the migrate-all-or-leave-ED rule, and rejects
locked/reserved funds. Pausable via a configurable PauseOrigin.
PEN.sol: fixed-supply ERC20+Permit+Votes (EIP-6372 timestamp clock),
entire max issuance minted to the vault at deployment; no mint function,
no owner, no proxy.

MigrationVault.sol: releases pre-minted supply on the threshold-th
matching on-chain attestor approval per Pendulum migration nonce
(3-of-5). Replay-safe nonce consumption, 12->18 decimal conversion in
one place, per-release and daily caps that defer rather than kill a
release, guardian pause with approvals still recorded, attestor rotation
that retroactively invalidates removed attestors, two-step admin and a
time-locked remainder sweep (PRD V1-V9).

Foundry project with OpenZeppelin v5.4.0; 23 tests including fuzz.
Pallet index 102, minimum migration amount 1 PEN, pause origin
root/half-council or 2/3 technical committee for fast incident
response. Adds the pallet to the exhaustive BaseFilter call whitelist.
PENGovernor: standard OZ Governor composition (Settings, CountingSimple,
Votes, QuorumFraction, TimelockControl) on the token's timestamp clock,
executing through a TimelockController per the hybrid governance model.

Deploy.s.sol handles the vault->token->setToken deployment dance and
hands vault admin to the bootstrap Safe (two-step). DeployGovernance.s.sol
deploys Timelock+Governor with proposer/executor roles wired and the
deployer admin role renounced. Governor tests include a full
propose->vote->queue->execute lifecycle against the vault.
Watches relay-finalized blocks on the operator's own Pendulum node for
tokenMigration.MigrationInitiated events and submits approve() to the
MigrationVault on Base (PRD A1-A5): strictly ordered block processing
with a crash-safe checkpoint, idempotent approvals, fail-fast on decode
errors, startup attestor-set verification, low-gas and webhook alerting.
Checks conservation (released <= migrated, vault balance + released ==
total supply) and attestor liveness every poll (PRD M1-M4); alerts via
webhook and can auto-pause the vault with an optional guardian key on a
conservation violation.
… invariant breach, pause, runtime upgrade, attestor rotation)
frame-benchmarking v2 benchmarks for migrate and set_paused, with the
benchmark test suite wired to the mock runtime and the pallet registered
in the Pendulum runtime's define_benchmarks list. Weights stay manual
until the benchmarks are run on reference hardware.
…itor

- MigrationVault: track pendingApprovedAmount for threshold-approved but
  deferred releases (pause/caps) and exclude it from sweepRemainder, so
  a window-close sweep can never strand a burned-but-unreleased
  migration; timelocked clearStalePending for conflicting tuples of
  already-consumed nonces (3 new tests)
- attestor: losing the normal 3-of-5 approval race is now a benign,
  logged skip (re-checks on-chain state after failures) instead of a
  fatal crash-loop; event decode asserts the 4-field shape; gas-check
  errors are logged instead of swallowed
- monitor: all Base reads pinned to a single block number to prevent
  false conservation alerts and unjustified auto-pauses

Findings and resolutions recorded in
docs/pen-migration-internal-review.md.
C1 (critical): a migration to the zero address passed the pallet but
deterministically reverts in the vault, permanently crash-looping all
five attestor daemons at that block. Defense in depth: the pallet now
rejects H160::zero() (InvalidBaseAddress), and the attestor statically
detects vault-unreleasable tuples, raises a distinct CRITICAL alert and
skips past them instead of halting the fleet.

H1 (high): re-adding a previously removed attestor could cross a
payload's threshold via addAttestor, bypassing the pending-release
accounting that protects sweepRemainder. Fixed structurally with
attestor generations: addAttestor bumps the address's generation and
approvals only count while their generation matches, so a threshold can
only ever be crossed inside approve(). hasApproved keeps its ABI but now
means "currently-valid approval", so daemons re-approve after a re-add.

Both findings and resolutions recorded in
docs/pen-migration-internal-review.md (round 2).
All three trace to sweepRemainder/pendingApprovedAmount never being
checked against sub-threshold in-flight migrations or the monitor's
conservation formula.

C1 (critical): a sweep could remove tokens owed to a migration still
gathering approvals; when it later crossed the threshold, the inline
release in approve() reverted on insufficient balance, rolling back the
approval and crash-looping all attestors identically -> permanent fleet
halt. Fix: approve() now defers (marks pending) instead of reverting
when the vault is under-funded, so the fleet stays up and the debt is
recoverable via a governance refund; release() gained a matching
InsufficientVaultBalance guard; sweepRemainder(to, amount) now takes an
explicit amount bounded by balance - pendingApprovedAmount (saturating);
new runbook RB-7 mandates reconciliation before sweeping.

H1 (high): monitor's M2b conservation check ignored sweepRemainder,
guaranteeing a false VAULT BALANCE MISMATCH + auto-pause on the first
legitimate sweep. Fix: vault tracks totalSwept; monitor checks
balance + totalReleased + totalSwept == totalSupply.

M1 (medium): hasApproved omitted the isAttestor check activeApprovals
requires, reporting true for removed-and-never-re-added attestors. Fixed
to mirror activeApprovals.

33 contract tests pass (2 new); monitor/attestor typecheck clean.
Findings recorded in docs/pen-migration-internal-review.md (round 3).
ebma added 4 commits July 8, 2026 10:15
Two novel High findings from the resumed first-round reviewer; both the
same class as prior rounds (a threshold crossed outside approve(), and
the cap backstop).

H1: lowering the threshold via setThreshold can retroactively make a
sub-threshold payload releasable without routing through approve(), so
its amount is never registered in pendingApprovedAmount and a later
sweep could strand it. Fix: setThreshold records any decrease;
sweepRemainder is blocked for SWEEP_SETTLING_PERIOD (7 days) afterwards
so the monitor + a permissionless release() can settle newly-qualifying
payloads first. Runbook RB-6 updated.

H2: the daily cap was a fixed UTC-calendar-day bucket that reset at the
boundary, letting a compromised quorum release 2x dailyCap seconds apart
(23:59 + 00:00). PRD V4 specifies a rolling window. Fix: replaced with a
leaky-bucket rolling limiter (availableDailyAllowance) of dailyCap
capacity refilling at dailyCap/day — no instant boundary reset.

35 contract tests (2 new: rolling refill + no-instant-boundary;
threshold-cut settling). Findings recorded in the internal review doc
(round 4), which notes these fund-path fixes have had no subsequent
internal round and should be re-derived by the external audit.
Lets governance move the Pendulum treasury's PEN to a Base treasury,
which the keyless treasury account can't do via the signed `migrate`.

- set_treasury_destination(base_address): TreasuryMigrateOrigin (root or
  3/5 council) sets a fixed, pre-vetted Base destination; rejects the
  zero address. The routine migrate call carries no address, removing
  per-call wrong-destination risk.
- migrate_treasury(amount): same origin; burns from the treasury account
  (KeepAlive so it's never reaped) and emits the SAME MigrationInitiated
  event as a user migration (who = treasury) via a shared emit_migration
  helper, so the attestor set, vault and monitor need zero changes.
- Respects pause; validates min amount and treasury balance; shares the
  global nonce space and TotalMigrated so the conservation invariant
  holds across user and treasury migrations.
- Runtime: TreasuryAccount = PendulumTreasuryAccount, TreasuryMigrateOrigin
  = TreasuryApproveOrigin. Weights + benchmarks for both extrinsics.

20 pallet tests (21 with runtime-benchmarks); runtime compiles both
feature sets.
- pen-migration-implementation-overview: update the pallet row and test
  count for the new treasury-migration extrinsics.
- pen-governance-guide: add the "where the money lives and how it's
  spent" treasury section (split, tiered treasury on the existing
  Timelock + operating Safe; migrate_treasury moves the funds over).
@ebma ebma force-pushed the feat/pen-base-migration branch from 3ae134e to e573063 Compare July 9, 2026 08:30
ebma added 2 commits July 9, 2026 10:57
…atching)

M2b conservation check used strict equality, so any inbound PEN transfer to
the vault (dust donation, or a migrate() whose recipient is the vault) pushed
the sum above totalSupply and permanently re-fired the top-severity alert /
re-paused the vault every poll until the window-close sweep.

- Monitor M2b now alerts only on a deficit (balance+released+swept < supply);
  a surplus is harmless and ignored. Predicates extracted to checks.ts with
  unit tests (surplus-does-not-fire, deficit-fires, exact-holds).
- Vault approve() rejects recipient == address(this) (RecipientIsVault),
  closing the self-migration variant at the source.
- M4 liveness reads batched via Multicall3 with a graceful per-nonce fallback,
  so a large backlog cannot starve the conservation checks.
- M2a reads Base first, then the monotonic totalMigrated, removing the
  in-flight-burn false-positive by construction.

Tests: 37 forge (2 new), 6 monitor unit tests. Docs: round-5 review section,
RB-3/RB-7 and monitor README updated.
…in amount, liveness)

Adversarial review focused on outsider bricking of the off-chain fleet.

- CRITICAL: a migrate(_, <vault address>) event crash-looped the whole
  attestor fleet. Round 5 added a RecipientIsVault revert to the vault's
  approve() but the attestor's isUnreleasable gate was not updated in
  lockstep; the pallet cannot reject the vault address (no knowledge of Base
  state), so every attestor hit the same deterministic revert, treated it as
  fatal, and reprocessed the block forever. isUnreleasable now also flags the
  vault address (case-insensitive), extracted to checks.ts with a unit-test
  suite (the attestor previously had none).
- MEDIUM: raise MinimumMigrationAmount from 1 to 100 PEN so dust-spam cannot
  cheaply out-cost the fleet's per-migration Base gas.
- LOW: throttle the monitor's liveness re-alerts to once per grace period so a
  backlog or an unreleasable nonce does not storm on-call every poll.

Recorded as round 6 in docs/pen-migration-internal-review.md.
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