From d4b8a1a7933abad48b654c75ceafc14f01e6cb7a Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 7 Jul 2026 19:31:29 +0200 Subject: [PATCH 01/23] docs: Add PEN-to-Base migration PRD, ADR-001 and token standards decision record --- docs/adr-001-pen-base-migration-approach.md | 150 +++++++++++++ docs/pen-base-migration-prd.md | 224 ++++++++++++++++++++ docs/pen-token-contract-standards.md | 59 ++++++ 3 files changed, 433 insertions(+) create mode 100644 docs/adr-001-pen-base-migration-approach.md create mode 100644 docs/pen-base-migration-prd.md create mode 100644 docs/pen-token-contract-standards.md diff --git a/docs/adr-001-pen-base-migration-approach.md b/docs/adr-001-pen-base-migration-approach.md new file mode 100644 index 000000000..a25fb2bf6 --- /dev/null +++ b/docs/adr-001-pen-base-migration-approach.md @@ -0,0 +1,150 @@ +# ADR-001: Approach for migrating PEN from Pendulum to Base + +**Status:** Accepted +**Date:** 2026-07-07 +**Deciders:** Pendulum / SatoshiPay team +**Companion doc:** [pen-base-migration-prd.md](pen-base-migration-prd.md) (the full requirements for the chosen approach) + +## Context + +PEN is the native token of the Pendulum parachain (Substrate, Polkadot, 12 decimals, sr25519 accounts). We want to move it to Base as an ERC-20. Hard requirements that shaped the decision: + +1. `totalSupply()` on Base must equal PEN's maximum issuance **from day one**, so trackers (DefiLlama, CoinGecko) never show a confusing supply split across two chains. +2. Users migrate by giving up PEN on Pendulum and receiving it on Base (lock/burn-and-release). +3. This is a **migration**, not a bridge product: finite lifetime, one direction, and the token contract that remains afterwards should be maximally trustless and boring. + +The fundamental constraint behind everything below: **Base cannot cheaply verify Pendulum state.** Real cryptographic verification of Polkadot finality on an EVM chain requires an on-chain light client (BEEFY signature verification, validator-set tracking). That is what Snowbridge and Hyperbridge are, and each took a dedicated team years. Every approach is therefore a different answer to the question: *what do we trust instead, and how do we bound the damage if that trust fails?* + +Additional constraints: sr25519 signatures cannot be affordably verified on the EVM (rules out direct "prove you own this Substrate account" claims on Base); we control the Pendulum runtime (this repo), so adding a pallet is cheap for us; PEN teleport to AssetHub already shipped (#553), so the Snowbridge route is partially paved. + +## Decision + +**Option A — a purpose-built one-way migration:** a small `token-migration` pallet on Pendulum (burn/lock + event with a unique nonce and target H160), a fixed-supply ERC-20 on Base with the entire max issuance pre-minted into a MigrationVault, and a 3-of-5 set of independent attestors that watch relay-finalized Pendulum events and submit **on-chain approvals** to the vault; the third matching approval releases the tokens. + +Post-migration governance is **hybrid** (Option G4 below): OZ Governor + Timelock for Base-side contracts and treasury, Snapshot + executor Safe for off-chain/cross-chain matters, technical committee retained for Pendulum runtime actions. + +## Options considered — migration mechanism + +### Option A: Purpose-built one-way migration (attestor-based) — CHOSEN + +| Dimension | Assessment | +|---|---| +| Complexity | Medium — ~150-line pallet, ~300-line vault, 5 small daemons; 3–6 weeks + audit | +| Trust model | 3-of-5 designated attestors; damage bounded by rate caps + pause + independent monitor | +| Meets supply requirement | Yes, by construction (pre-mint to vault) | +| UX | One extrinsic on Pendulum, tokens arrive on Base automatically | +| Ongoing burden | Attestor ops for the migration window only; nothing permanent | + +**Pros:** +- We control both ends; the design can be exactly as simple as the problem requires. +- The token contract itself ends up with **zero trust assumptions** (no mint function, no owner, no proxy) — the trusted component (vault + attestors) is temporary and rate-limited. +- Pre-minting satisfies the day-one supply requirement trivially. +- One-way design halves the attack surface of a bridge (no Base→Pendulum attestation). +- The on-chain-approvals variant needs no coordination infrastructure at all. + +**Cons:** +- The attestor set is a real trust assumption (mitigated by independence, caps, monitoring, pause — see PRD §8). +- We own the operational burden: key ceremonies, monitoring, runbooks, gas funding. +- Custom code must be audited (though the surface is small and standard). + +### Option B1: Ride existing infrastructure — AssetHub → Snowbridge → Ethereum → Base standard bridge + +| Dimension | Assessment | +|---|---| +| Complexity | Low code, very high integration/UX complexity (4 hops) | +| Trust model | Strongest available (light-client bridges + canonical rollup bridge) | +| Meets supply requirement | **No** | +| UX | Multi-hop, multi-wallet, slow, fee-laden | +| Ongoing burden | Dependent on three external bridge systems | + +**Pros:** trust-minimized end to end; almost no code to write (teleport to AssetHub already shipped); no attestors to operate. + +**Cons — and why it was rejected:** +- The Base token would be a bridge-wrapped representation whose supply reflects only what has been bridged — **fails the day-one total-supply requirement outright.** +- We would not control the Base contract (created by the OP standard bridge), so no `ERC20Votes`, no governance integration, no say in metadata. +- Four-hop UX (Pendulum → AssetHub → Ethereum → Base) is unacceptable for a general holder base, and each hop has its own fees, delays, and failure modes. +- This route is designed for *bridging*, and it is a fine answer to "make PEN reachable"; it is a poor answer to "migrate PEN's home." + +### Option B2: Hyperbridge (ISMP) as message channel + +| Dimension | Assessment | +|---|---| +| Complexity | High — ISMP pallet-stack integration into the runtime, dependency on external relayer economics | +| Trust model | Consensus proofs (BEEFY) — trust-minimized, no attestor set of our own | +| Meets supply requirement | Only with the same pre-mint-to-vault construction as Option A | +| UX | Good (direct Polkadot↔Base messaging) | +| Ongoing burden | Permanent runtime dependency on ISMP pallets across all future SDK upgrades | + +**Pros:** genuinely trust-minimized without building a light client ourselves; direct route to Base; would be the right backbone if we ever wanted a permanent two-way bridge. + +**Cons — and why it was rejected:** +- Heavy runtime integration for a mechanism we intend to run for a bounded migration window, then decommission. +- Adds a permanent maintenance tax: the ISMP pallet stack must survive every Polkadot-SDK upgrade this repo goes through. +- Still needs the vault/pre-mint construction to satisfy the supply requirement, so it replaces only the attestor layer — the most easily bounded part of Option A — at the highest integration cost. +- **Revisit trigger:** if two-way bridging ever becomes a product requirement, re-evaluate Hyperbridge before extending Option A. + +### Option C: Snapshot + Merkle-claim airdrop + +| Dimension | Assessment | +|---|---| +| Complexity | Low on Base (Merkle distributor), but a hard identity problem | +| Trust model | Trustless claims on Base — but only after a trusted registration/snapshot step | +| Meets supply requirement | Yes (pre-mint to distributor) | +| UX | Hard cutover; claim flow; registration prerequisite | +| Ongoing burden | Low | + +**Pros:** the Base side is fully trustless once the Merkle root is set; minimal infrastructure; clean if the chain is being shut down on a fixed date. + +**Cons — and why it was rejected:** +- **sr25519 cannot be verified on the EVM**, so users cannot prove ownership of their Pendulum account in the claim contract. They would have to register a Base address *on Pendulum before the snapshot* — which is already half of Option A's pallet, without its flexibility. +- Forces a hard cutover: balances frozen at block X, one shot at the Merkle root, no way to accommodate late unstakers (staking/vesting locks mean many holders *cannot* be ready at an arbitrary snapshot date). +- Whoever computes the Merkle root is a single trusted party at one critical moment — concentration of the same trust Option A spreads across 5 parties and time. +- Only appropriate for a scheduled chain shutdown, which is not (yet) the plan. + +## Options considered — key sub-decisions within Option A + +### Supply model: pre-mint to vault (chosen) vs. mint-on-demand + +Mint-on-demand is the classic bridge pattern but fails the day-one supply requirement (`totalSupply` grows with migrations) and — worse — requires a live minter privilege on the token forever, making infinite mint the top attack scenario. Pre-minting the max issuance to the vault makes `totalSupply` correct from deployment, lets the token ship with **no mint function at all**, and caps the worst case at the vault's remaining balance. Circulating supply is reported to trackers as `totalSupply − vault balance`. This resolved what initially looked like a conflict between the "full supply visible" and "lock and mint" requirements: migration becomes lock/burn-and-**release**. + +### Attestation transport: on-chain approvals (chosen) vs. off-chain signature aggregation vs. light client + +- **On-chain approvals** (chosen): each attestor sends `approve(nonce, recipient, amount)` directly to the vault; the contract counts distinct-attestor approvals of the identical tuple and executes on the k-th. The chain is the coordinator — no signature-collection service, no API, no gossip; attestors share nothing but the contract address. Cost: k transactions per migration instead of one (cents on Base). Chosen for operational simplicity and attestor independence. +- **Off-chain aggregation** (Wormhole-style): attestors sign EIP-712 payloads, a service collects k signatures, anyone submits one `release(..., sigs[])` transaction. One tx per migration and user-self-serve claims, but requires building and operating a coordination service — rejected as unnecessary at migration volumes. +- **Light client / consensus proofs:** correct in the limit, disproportionate for a one-way migration (see Options B1/B2). + +### Pendulum-side effect: burn vs. lock — OPEN (PRD D1, recommendation: burn) + +Burn keeps the global invariant (`PEN on Pendulum + released on Base = max issuance`) trivially auditable and leaves no honeypot account on the Substrate side. Lock only makes sense if reverse flow is ever plausible — which the one-way decision forecloses. Kept open in the PRD only until the Pendulum chain end-state discussion concludes. + +## Options considered — post-migration governance + +The forcing fact: **migrated PEN cannot vote on Pendulum.** Whether burned or locked, it is invisible to `pallet-democracy`/referenda, so on-chain governance power on Pendulum shrinks to the unmigrated remainder — an adversely-selected and ever-cheaper-to-capture electorate. + +| Option | Pros | Cons | Verdict | +|---|---|---|---| +| **G1: Keep Pendulum as the governance chain** | No new infrastructure; familiar tooling | Governance token has left the chain — legitimacy collapses and capture gets cheaper daily; permanent coretime + collator + SDK-upgrade burden just to host votes | Rejected | +| **G2: Snapshot + executor Safe only** | Free, gasless, fastest to ship; works for any decision scope | Execution is trusted (Safe could ignore votes); weak optics for treasury-scale decisions | Rejected as sole mechanism; retained as a component | +| **G3: Full on-chain Governor + Timelock only** | Trustless execution; the setup investors recognize (Tally) | Only natively controls Base-side things; gas-cost voting UX; overkill for off-chain/cross-chain decisions | Rejected as sole mechanism; retained as a component | +| **G4: Hybrid (G2 + G3 + Pendulum technical committee) — CHOSEN** | Trustless where the assets live (Base treasury, vault parameters); pragmatic everywhere else; technical committee keeps the chain patchable without pretending it is token-governed | Two venues to operate; requires clear scoping of what is decided where | **Chosen** | + +Consequences for the token contract: `ERC20Votes` must be included at deployment (immutable token — cannot be retrofitted). Snapshot quorums must be defined against circulating supply with the vault address excluded, or they are unreachable early in the migration. + +## Trade-off analysis (summary) + +The decisive requirement was **day-one supply correctness**, which only a self-deployed, pre-minted token satisfies — eliminating B1 outright and reducing B2 to "a more expensive attestor replacement." Between A and C, the sr25519 problem means C secretly contains A's registration pallet anyway, while adding a hard-cutover constraint that conflicts with staking/vesting lock realities. Within A, every sub-choice followed one principle: **make the permanent artifact (the token) trustless and boring, and confine all trust into a temporary, rate-limited, monitored, pausable component.** + +## Consequences + +**Easier:** tracker/investor-facing supply story (correct from day one); audits (small, standard surfaces); incident response (caps + pause + single trusted component); eventual decommissioning (turn off attestors, sweep vault per governance vote). + +**Harder:** we own attestor operations (key ceremonies, monitoring, gas funding, external-operator onboarding); users must trust the attestor set during the window (mitigated, not eliminated); no reverse path if anyone regrets migrating. + +**To revisit:** Hyperbridge if two-way bridging ever becomes a requirement; the Pendulum chain end-state (interacts with burn-vs-lock, D1); governance venue consolidation once migration completes. + +## Action items + +1. [ ] Resolve PRD open decisions D1–D6 (see [PRD §4.2](pen-base-migration-prd.md)) +2. [ ] Spec the `token-migration` pallet in this repo +3. [ ] Draft `PEN.sol` + `MigrationVault.sol` and select audit firms (PRD §9) +4. [ ] Open attestor-operator conversations (D4) and exchange coordination (PRD §11) diff --git a/docs/pen-base-migration-prd.md b/docs/pen-base-migration-prd.md new file mode 100644 index 000000000..9eb8a0819 --- /dev/null +++ b/docs/pen-base-migration-prd.md @@ -0,0 +1,224 @@ +# PRD: PEN Token Migration from Pendulum to Base + +| | | +|---|---| +| **Status** | Draft v1 | +| **Date** | 2026-07-07 | +| **Owner** | Pendulum / SatoshiPay team | +| **Scope** | One-way migration of the native PEN token from the Pendulum parachain (Polkadot) to an ERC-20 on Base, plus post-migration governance | + +--- + +## 1. Summary + +We will migrate the PEN token — the native token of the Pendulum Substrate parachain — to Base as a **fixed-supply ERC-20**. The full maximum issuance is pre-minted at deployment into a **MigrationVault** contract; the token contract has **no mint function**. Users migrate by calling a `migrate` extrinsic on Pendulum that removes their PEN from circulation and emits an event carrying their Base address. A **3-of-5 set of independent attestors**, each running their own Pendulum node, observes relay-chain-finalized events and submits matching **on-chain approvals** to the vault on Base; the third matching approval releases the tokens from the vault to the user. + +Post-migration governance is **hybrid**: an OpenZeppelin Governor + Timelock on Base for on-chain control of Base-side contracts and treasury, Snapshot for off-chain/cross-chain decisions, executed by an elected Safe multisig, with a technical committee retained for Pendulum-side runtime actions for as long as the chain runs. + +The migration is **one-way**. No reverse flow (Base → Pendulum) will be built. + +## 2. Background and motivation + +- PEN currently exists only as the native token of the Pendulum parachain (12 decimals, Substrate/sr25519 accounts). +- Liquidity, investor attention, and tooling (DefiLlama, CoinGecko, Etherscan-class explorers, DeFi integrations) are concentrated in EVM ecosystems; Base is the chosen destination. +- A key requirement is that supply statistics on Base are **correct and complete from day one**: `totalSupply()` must equal PEN's maximum issuance so trackers never display a confusing split between two chains. +- Verifying Pendulum state cryptographically on an EVM chain would require an on-chain Polkadot light client (BEEFY verification) — a multi-year effort (cf. Snowbridge, Hyperbridge). For a finite-lifetime, one-way migration, a k-of-n attestation model with strict blast-radius limits is the appropriate engineering trade-off. + +## 3. Goals + +1. A live ERC-20 PEN token on Base whose `totalSupply()` equals the PEN maximum issuance from the moment of deployment. +2. A live MigrationVault on Base holding all unmigrated supply, releasing tokens only on 3-of-5 attestor agreement. +3. A `token-migration` pallet on Pendulum allowing any holder to migrate transferable PEN to a Base address of their choice. +4. Trackers (DefiLlama, CoinGecko, CoinMarketCap) display correct total and circulating supply (vault balance excluded from circulating). +5. Worst-case loss from full attestor-quorum compromise is bounded by rate caps and detected by independent monitoring within minutes. +6. A functioning hybrid governance stack on Base after migration. + +### Non-goals (explicitly out of scope) + +- **Two-way bridging** (Base → Pendulum) — the design is one-way by construction. +- **On-chain light-client verification** of Pendulum state on Base. +- **Cross-chain governance execution** (Base votes cryptographically executing Substrate calls). +- Migration of any token other than native PEN (Spacewalk-wrapped assets, XCM assets, etc. are unaffected). +- Decommissioning plan for the Pendulum chain itself (tracked separately; this PRD only requires that migration works while the chain runs). + +## 4. Decisions + +### 4.1 Locked in + +| Decision | Choice | Rationale | +|---|---|---| +| Supply model on Base | Entire max issuance pre-minted to vault at deployment; token has no mint function, no owner, no upgradeability | Correct tracker stats from day one; eliminates infinite-mint attack surface; worst case bounded by vault balance | +| Migration direction | One-way only | Halves the attack surface; no Base-side event attestation needed | +| Attestation transport | **On-chain approvals**: each attestor sends its own `approve` transaction to the vault; the k-th matching approval executes the release | No off-chain signature-coordination infrastructure; the chain is the coordinator; attestors are fully independent processes | +| Attestor threshold | 3-of-5 | Tolerates 2 offline/compromised attestors without halting or without theft, respectively | +| Governance | Hybrid: OZ Governor + Timelock (Base contracts/treasury) + Snapshot + executor Safe + Pendulum technical committee | On-chain teeth where the assets live; pragmatic elsewhere | +| Token extensions | `ERC20Permit` + `ERC20Votes` included at deployment | `ERC20Votes` cannot be retrofitted into an immutable token; required for future Governor voting | + +### 4.2 Open — must be resolved before implementation freeze + +| # | Decision | Options | Recommendation | +|---|---|---|---| +| D1 | Pendulum-side effect of `migrate` | **Burn** vs. lock in keyless pallet account | **Burn.** Migration is one-way; burning keeps the invariant `PEN on Pendulum + released on Base = max issuance` trivially auditable and leaves no honeypot | +| D2 | Decimals on Base | Keep **12** vs. scale to **18** (×10⁶) | **18** (DeFi convention, avoids integration friction), provided max-issuance ×10⁶ arithmetic is verified exact end-to-end and dust-rounding is impossible by construction (12→18 is exact; only relevant if any 18→12 display path exists) | +| D3 | Exact max issuance figure | Confirm the canonical number from tokenomics (including whether any never-minted allocation counts) | Must match what trackers/documentation state today | +| D4 | Attestor set composition | 5 team-operated keys vs. 3 team + 2 external partners | At least 1–2 external/independent operators | +| D5 | Migration window end policy | Open indefinitely vs. close at date T; disposition of vault remainder (burn / DAO treasury) | Announce ≥ 12-month window; decide remainder disposition via governance vote before T | +| D6 | Encumbered balances policy | Handling of staked (`parachain-staking`), vesting (`vesting-manager`), governance-locked, and sub-ED balances | Require unstake/unlock first (migration accepts only transferable balance); publish this clearly since unstaking delay gates user migration speed | + +## 5. System overview + +``` + PENDULUM (Polkadot parachain) BASE (OP-stack L2) +┌─────────────────────────────┐ ┌──────────────────────────────┐ +│ token-migration pallet │ │ PEN ERC-20 (immutable) │ +│ migrate(amount, h160) │ │ totalSupply = max issuance │ +│ → burn/lock PEN │ │ no mint, no owner │ +│ → event {nonce, h160, amt} │ ├──────────────────────────────┤ +└──────────┬──────────────────┘ │ MigrationVault │ + │ finalized events │ holds unmigrated supply │ + ▼ │ approve(nonce, to, amt) │ + 5 × attestor daemon ──────── Base txs ───────▶ │ 3rd matching approval │ + (own full node each, │ → transfer to user │ + relay-finality only) │ caps · pause · timelock │ + └──────────────────────────────┘ + invariant monitor (independent): Σ burned on Pendulum == Σ released on Base +``` + +**Happy path:** user calls `migrate(amount, base_address)` on Pendulum → PEN burned/locked, event with unique `nonce` emitted → block reaches relay-chain finality → each attestor independently decodes the event and submits `approve(nonce, recipient, amount)` on Base → on the 3rd identical approval the vault transfers `amount` (decimal-adjusted) to `recipient` and marks `nonce` consumed. + +## 6. Component requirements + +### 6.1 Pendulum: `token-migration` pallet + +- **P1** — Extrinsic `migrate(amount: Balance, base_address: H160)`; atomically removes `amount` of transferable native PEN from the caller (per D1: burn or transfer to keyless pallet account) and emits `MigrationInitiated { nonce: u64, base_address: H160, amount: Balance }`. +- **P2** — `nonce` is a monotonically increasing storage counter; globally unique across the pallet's lifetime; never reused, including across runtime upgrades. +- **P3** — Rejects: `amount` below a configurable minimum (dust threshold ≥ existential-deposit-scale), non-transferable balance (staked, vesting-locked, reserved), and `amount` that would leave the caller between 0 and the existential deposit (must migrate to exactly 0 or stay ≥ ED). +- **P4** — Pallet is pausable via a privileged origin (technical committee / root) to halt new migrations during incidents. +- **P5** — Storage exposes cumulative migrated total for monitoring (`TotalMigrated`). +- **P6** — No knowledge of Base state; the pallet is fire-and-forget. Documentation and UI must make irreversibility explicit. +- **P7** — Deployed to Foucoco (testnet runtime) first with identical logic. + +### 6.2 Base: `PEN` ERC-20 + +- **T1** — OpenZeppelin `ERC20` + `ERC20Permit` + `ERC20Votes`. No other custom logic. Decide the EIP-6372 clock mode (block number vs. timestamp) before deployment — the Governor must use the same clock, and it cannot be changed later. +- **T2** — Constructor mints the entire max issuance (per D2/D3) to the MigrationVault address and nothing else. No `mint`/`burn` owner functions; no `Ownable`; **not upgradeable** (no proxy). +- **T3** — `name`/`symbol` consistent with existing branding (`Pendulum`, `PEN`); decimals per D2. + +### 6.3 Base: `MigrationVault` + +- **V1** — `approve(nonce, recipient, amount)` callable only by addresses in the attestor set. Approvals are counted per `keccak256(abi.encode(nonce, recipient, amount))` — attestors must agree on the *identical* tuple. A conflicting tuple for the same nonce counts separately and never merges. +- **V2** — On the k-th (k = 3) distinct-attestor approval of the same tuple: verify nonce unconsumed → verify rate caps → mark nonce consumed → `transfer(recipient, amount)`. Consumed nonces are permanent (mapping, not sequential counter — out-of-order finalization/submission must work). +- **V3** — One approval per attestor per tuple; duplicate approvals from the same attestor revert. +- **V4** — **Rate caps:** per-release maximum and rolling 24h aggregate maximum, both governance-configurable behind the timelock. Initial values sized so a full quorum compromise loses a bounded, pre-agreed amount before pause (target: < 1–2% of vault balance per day). +- **V5** — **Pause:** a guardian role (fast Safe, small threshold) can pause releases instantly. Unpause and all parameter changes (caps, attestor set, guardian) go through a TimelockController with ≥ 48h delay. +- **V6** — Attestor set changes (add/remove/replace key) via timelocked admin only; changing the set must not invalidate pending approvals in a way that permanently strands a legitimate migration (re-approval by the new set must be possible). +- **V7** — Decimal conversion 12 → 18 (if D2 = 18) happens in exactly one place (the vault, at release), as an exact ×10⁶ multiplication. +- **V8** — Not upgradeable. All flexibility comes from parameters + pause. Emits full event history (`Approved`, `Released`, `Paused`, `CapsUpdated`, `AttestorSetUpdated`) for the monitor and for public auditability. +- **V9** — End-of-window handling per D5: a timelocked function to sweep the remainder to a governance-designated destination (or burn), callable only after a hard-coded earliest timestamp. + +### 6.4 Attestor daemon (×5 independent instances) + +- **A1** — Connects **only to its own Pendulum full node** (never public RPC); subscribes to **relay-chain-finalized** heads; decodes `MigrationInitiated` events. Never acts on best/unfinalized blocks. +- **A2** — For each event, submits `approve(nonce, recipient, amount)` to the vault on Base, with idempotent retry (safe to resubmit; duplicates revert harmlessly) and crash-recovery from a persisted checkpoint (last processed finalized block). +- **A3** — Each instance: separate operator, separate infrastructure, separate secp256k1 key (HSM or equivalent isolation), separately funded Base gas wallet with balance alerting. +- **A4** — No shared code paths for event *interpretation* where avoidable is nice-to-have; at minimum, no shared runtime infrastructure or key storage. No communication between attestors — the vault contract is the only coordination point. +- **A5** — Handles runtime upgrades on Pendulum gracefully (metadata refresh) and alerts on decode failures rather than skipping events silently. + +### 6.5 Invariant monitor (independent watchdog) + +- **M1** — Runs on infrastructure separate from all attestors; reads Pendulum (`TotalMigrated`, per-nonce events) and Base (`Released` events, vault balance) independently. +- **M2** — Continuously checks: (a) every released nonce corresponds to exactly one finalized Pendulum event with matching recipient/amount; (b) Σ released ≤ Σ migrated; (c) vault balance + Σ released = max issuance. +- **M3** — On any violation: page on-call immediately and (design decision) optionally hold a guardian key to auto-pause the vault. +- **M4** — Also monitors liveness: alerts if a finalized migration event has < 3 approvals after N minutes (attestor outage detection). + +### 6.6 Migration UI + +- **U1** — Web app: connect Substrate wallet, enter/connect Base address with **EIP-55 checksum validation**, explicit irreversibility confirmation, live status tracking (finalization → approvals 0/3 → released, with Base tx link). +- **U2** — Warn when the destination is a contract address (Safe is fine; other contracts may strand funds); require an extra confirmation. +- **U3** — Surface encumbered-balance state (D6): show staked/vesting amounts and guide the user through unstaking first. +- **U4** — Encourage a small test migration for large holders as a documented pattern. + +### 6.7 Governance stack (hybrid) + +- **G1** — Snapshot space with PEN-on-Base voting strategy. **The vault address must be excluded from voting power and quorum math** — quorum thresholds must be defined against circulating supply, not `totalSupply()`, or they are unreachable early in the migration. +- **G2** — OZ Governor + TimelockController on Base using `ERC20Votes` (delegation-based). The timelock becomes the admin of the MigrationVault parameters (caps, attestor set, end-of-window sweep) after an initial bootstrap period during which a Safe holds admin (see rollout). +- **G3** — Executor Safe (elected signers) carries out Snapshot outcomes that are off-chain or on other chains; optionally hardened later with oSnap/SafeSnap. +- **G4** — Pendulum-side runtime actions remain with the existing technical committee for as long as the chain runs; its mandate post-migration is documented (security patches, pallet pause, no discretionary treasury power). +- **G5** — The pause guardian is **not** the Governor (too slow for incidents); it is a small fast Safe, itself replaceable via timelock. + +## 7. Acceptance criteria + +1. **Supply correctness:** immediately after deployment, `PEN.totalSupply()` on Base equals the confirmed max issuance (D3) and 100% sits in the vault; DefiLlama/CoinGecko display total supply = max issuance and circulating supply excluding the vault. +2. **End-to-end migration:** a user migrating X PEN on Pendulum receives exactly X (decimal-adjusted) PEN on Base after relay finality + 3 approvals, with no manual intervention, on testnet and mainnet. +3. **Conservation invariant:** at all times, Σ burned/locked on Pendulum ≥ Σ released on Base, and vault balance + Σ released = max issuance; the monitor demonstrably alerts (staging drill) on injected violation. +4. **Replay safety:** a consumed nonce can never release twice (unit + fork-test proof); an attestor submitting the same approval twice has no effect. +5. **Quorum safety:** 2 colluding attestors cannot release anything; 2 offline attestors do not halt migrations. +6. **Caps and pause:** releases above per-tx or daily caps revert; guardian pause takes effect in one transaction and blocks all releases; every parameter change is observably delayed ≥ 48h by the timelock. +7. **No mint surface:** verified absence of any code path that increases `totalSupply()` post-constructor (audit assertion). +8. **Governance live:** Snapshot space operational with vault excluded from strategy; Governor + Timelock deployed, delegation working, and admin of the vault transferred per rollout plan. +9. **Audits complete:** all critical/high findings from all audit tracks (see §9) resolved or formally accepted before mainnet vault funding. +10. **Ops readiness:** runbooks exist and have been drill-tested for: attestor key compromise, attestor outage, invariant violation, pause/unpause, and Pendulum runtime upgrade. + +## 8. Security requirements and threat model + +**Trust assumptions:** correctness reduces to (a) ≤ 2 of 5 attestor keys compromised at any time, (b) Polkadot relay finality is honest, (c) the vault contract is correct. There is no cryptographic verification of Pendulum state on Base; the design compensates with independence, caps, monitoring, and pause. + +| Threat | Mitigation | +|---|---| +| Attestor key compromise (< quorum) | 3-of-5 threshold; conflicting tuples never merge; monitor flags approvals without matching Pendulum events | +| Attestor quorum compromise (≥ 3 keys) | Rate caps bound daily loss (V4); independent monitor + guardian pause (M3, V5); key isolation & operator independence (A3, D4) make simultaneous compromise unlikely | +| Fake/reorged Pendulum events | Attestors act only on relay-finalized blocks from their own nodes (A1); post-finality reorgs are not possible on Polkadot | +| Replay / double release | Permanent consumed-nonce mapping (V2); per-attestor per-tuple dedup (V3) | +| Malicious/typo destination address | UI checksum + contract-address warnings (U1, U2); irreversibility messaging; documented test-migration pattern | +| Infinite mint on Base | Structurally impossible — no mint function (T2) | +| Governance capture of vault params | 48h timelock on all changes (V5) gives holders and the monitor time to react; guardian can pause during the window | +| Pallet abuse (griefing with dust, nonce games) | Minimum amount (P3); nonce is pallet-internal, not user-supplied (P2) | +| Attestor gas exhaustion / outage | Funded-wallet alerting (A3); liveness monitoring (M4); 2-of-5 outage tolerance | +| RPC/supply-chain trust | Own full nodes only (A1); pinned dependencies and reproducible builds for daemon and contracts | + +**Standing security requirements:** all privileged Base keys in Safes or HSMs; no single human can both approve and change the attestor set; public disclosure/bug-bounty channel before mainnet; all contracts verified on the Base explorer. + +## 9. Audit scope + +Ranked by where the risk actually lives: + +1. **MigrationVault contract (highest priority):** approval counting and tuple hashing (V1–V3), nonce consumption, cap accounting across the 24h window, pause/timelock/role wiring, attestor-set rotation edge cases (V6), decimal conversion (V7), end-of-window sweep (V9). +2. **`token-migration` pallet:** atomicity of burn/lock + event emission, nonce monotonicity across upgrades, balance-encumbrance checks (P3), pause origin, weight/benchmarking correctness. +3. **Attestor daemon:** event decoding against runtime metadata (including post-upgrade), finality handling (proof that it cannot act pre-finality), checkpoint/crash-recovery correctness, key handling. +4. **End-to-end trust-boundary review:** an adversarial walkthrough of the full pipeline (extrinsic → event → daemon → approval → release), explicitly attempting cross-component exploits that no single-component audit would catch (e.g., decode ambiguity producing divergent tuples). +5. **Operational review (lighter):** key-management ceremony, Safe configurations, timelock parameters, monitor independence. + +**Out of audit scope:** OpenZeppelin library internals, Base/OP-stack infrastructure, Polkadot finality itself, the ERC-20 beyond confirming it is an unmodified OZ composition (a cheap assertion worth paying for). Token + vault ≈ 300–400 lines of Solidity total — solicit fixed bids from 2 firms; the pallet and daemon likely need a Substrate-literate auditor (may be a different firm). + +## 10. Rollout plan + +| Phase | Contents | Gate to next phase | +|---|---|---| +| **0 — Decisions & spec** | Resolve D1–D6; finalize this PRD; publish tokenomics/max-issuance statement | All open decisions signed off | +| **1 — Build & testnet** | Pallet on Foucoco; contracts on Base Sepolia; 5 test attestors; monitor; UI; internal adversarial testing incl. chaos drills (kill attestors, inject bad approvals) | All acceptance criteria pass on testnet | +| **2 — Audits** | §9 tracks in parallel; fix and re-verify; publish reports | No open critical/high findings | +| **3 — Mainnet soft launch** | Deploy token + vault (full supply minted); production attestor ceremony; **conservative caps**; team-only + invited large-holder migrations for 1–2 weeks; vault admin held by bootstrap Safe | Soft-launch volume clean, monitor green | +| **4 — Public launch** | Runtime upgrade enabling `migrate` for all; UI public; raise caps to target; tracker submissions (DefiLlama/CoinGecko: supply endpoints, vault as non-circulating); exchange & community comms | ≥ agreed % supply migrated or T reached | +| **5 — Governance handover** | Snapshot space live from phase 4; deploy Governor + Timelock; transfer vault admin from bootstrap Safe to timelock; elect executor Safe | — | +| **6 — Window close (per D5)** | Governance vote on remainder disposition; execute sweep (V9); decommission attestors; final conservation report published | — | + +## 11. Risks and open questions + +- **Adoption risk:** slow migration leaves circulating supply small and Snapshot quorums awkward — mitigate with a long window, clear comms, and quorum defined on circulating supply (G1). +- **Unstaking delay friction (D6):** staked holders face the staking unbond period before they can migrate; comms must set expectations. +- **Attestor operational maturity:** the honest hard part is ops, not code. External operators (D4) need onboarding, SLAs, and gas-funding agreements. +- **Exchange coordination:** any CEX listing PEN needs a supported path (they migrate custody balances themselves via the same mechanism); start conversations in phase 1. +- **Legal/regulatory review** of the migration mechanics and any public statements about supply — not covered by this PRD, must run in parallel. +- **Pendulum chain end-state** (full sunset vs. minimal maintenance) is deliberately out of scope but interacts with D1 and G4; schedule that decision before phase 6. + +## 12. Deliverables checklist + +- [ ] `token-migration` pallet (+ benchmarks, tests) in this repo, deployed to Foucoco then Pendulum +- [ ] `PEN.sol`, `MigrationVault.sol` (+ Foundry test suite incl. fork tests and invariant tests) +- [ ] Attestor daemon (open-sourced) + deployment guide for external operators +- [ ] Invariant monitor + alerting integration +- [ ] Migration web UI +- [ ] Governor + Timelock deployment scripts; Snapshot space config (vault-excluded strategy) +- [ ] Runbooks: key compromise, attestor outage, invariant breach, pause/unpause, runtime upgrade +- [ ] Audit reports (published) and fix log +- [ ] Tracker submissions and public migration documentation diff --git a/docs/pen-token-contract-standards.md b/docs/pen-token-contract-standards.md new file mode 100644 index 000000000..8fd68ed77 --- /dev/null +++ b/docs/pen-token-contract-standards.md @@ -0,0 +1,59 @@ +# PEN ERC-20 on Base: Token Standards and Extensions + +**Status:** Accepted +**Date:** 2026-07-07 +**Companion docs:** [PRD](pen-base-migration-prd.md) (requirement T1), [ADR-001](adr-001-pen-base-migration-approach.md) + +This document records which token standards and contract extensions the PEN ERC-20 on Base implements, which it deliberately omits, and why. The guiding principle (from ADR-001): the token is the *permanent* artifact of the migration and must be maximally trustless and boring — every line of behavior should be audited OpenZeppelin code, and every capability we exclude is an attack surface or credibility question we never have to answer. + +## Baseline + +- **OpenZeppelin Contracts 5.x** (latest audited release at implementation time), plain inheritance — no proxy, no upgradeability. +- Total composition: `ERC20 + ERC20Permit + ERC20Votes`, roughly 20 lines of custom Solidity (constructor + required overrides). +- Constructor mints the entire max issuance to the MigrationVault; there is no other supply-affecting code path. + +## Included extensions + +| Extension | Standard | Function it provides | Why included | +|---|---|---|---| +| `ERC20` | EIP-20 | Core fungible token | — | +| `ERC20Permit` | EIP-2612 | `permit(owner, spender, value, deadline, v, r, s)`: approvals via off-chain EIP-712 signatures instead of an on-chain `approve` transaction. Enables one-transaction approve-and-swap and relayer-paid (gasless) onboarding. | Table stakes for a modern token; zero added trust assumptions; expected by aggregators and wallets. | +| `ERC20Votes` | EIP-5805 | Checkpointed balance history + delegation (`delegate`, `delegateBySig`, `getVotes`, `getPastVotes`, `getPastTotalSupply`). Required by OZ Governor / Tally. | Governance is a hard requirement (hybrid model, ADR-001) and checkpointing **cannot be retrofitted** into an immutable token. Note: votes only count after delegation — holder docs must tell users to self-delegate. | +| EIP-6372 clock | (part of Votes) | `clock()` / `CLOCK_MODE()`: whether checkpoints are keyed by block number (OZ default) or timestamp. | Must be decided **before deployment** and the Governor must be deployed with the same mode. Leaning: **timestamp** on an L2 (human-legible, robust to block-cadence changes). Tracked in PRD T1. | + +### Interplay notes + +- **Smart-contract wallets and `permit`:** EIP-2612 verifies ECDSA signatures only, so Safe/ERC-4337 accounts (which sign via ERC-1271) cannot use `permit`. This is normal and acceptable: those users fall back to `approve` or to **Permit2** (Uniswap's external allowance contract), which provides signature-based, expiring approvals for *any* ERC-20 with no token-side support required. We implement nothing for Permit2; it simply works alongside. + +## Considered and excluded + +| Extension / standard | What it does | Why excluded | +|---|---|---| +| `ERC20Burnable` | Holders burn their own tokens | Breaks the two core invariants: `totalSupply()` would drift below max issuance (tracker requirement, PRD Goal 1) and the monitor's `vault + released = totalSupply` check (PRD M2) would need carve-outs. Vault-remainder disposition at window close is handled by the vault (V9), not the token. | +| `ERC20FlashMint` (EIP-3156) | Flash loans via temporary mint/burn | Directly contradicts the no-mint, fixed-`totalSupply` guarantee. | +| ERC-1363 | `transferAndCall` / `approveAndCall` — receiver contracts react to transfers atomically | Clean, finalized standard but niche adoption; integrators don't expect it; adds surface without demand. Revisit only if a concrete integration needs it (it can't be added later — acceptable, wrappers exist). | +| ERC-3009 | `transferWithAuthorization` — USDC-style gasless *transfers* with random nonces | Payments-oriented; not in OZ core; only worthwhile if PEN becomes a payments rail. Account abstraction covers the UX need without token support. | +| ERC-2771 | Meta-transactions via a trusted forwarder baked into the token | Adds a *permanent* trusted-forwarder assumption to a credibly-neutral asset. Gasless UX is solved wallet-side (ERC-4337/7702) instead. | +| `ERC20Pausable`, blacklist, fee-on-transfer, rebasing | Transfer restrictions / supply games | Break DeFi integrations and tracker math; red flags for listing teams and sophisticated holders. The *vault* is pausable; the token never is. | +| ERC-777-style transfer hooks | Sender/receiver callbacks on transfer | Reentrancy-prone design; the standard is effectively dead. | +| Upgradeability (proxy) | Post-deployment logic changes | The single biggest credibility cost for a fixed-supply token. All operational flexibility lives in the vault (parameters + pause), never in the token. | + +## The one exclusion that forecloses a future capability: ERC-7802 / SuperchainERC20 + +Because Base is an OP-stack chain, the recent **ERC-7802 (SuperchainERC20)** standard deserves an explicit decision rather than a silent default. It enables native (unwrapped) movement of a token between OP Superchain chains — but it works by granting `crosschainMint` / `crosschainBurn` rights to the SuperchainTokenBridge predeploy. That reintroduces exactly the mint authority this design eliminates, and since the token is immutable, **it cannot be added later**. + +**Decision: excluded.** The no-mint guarantee is the more valuable property for an investor-facing fixed-supply token. If Superchain presence ever matters, a wrapped representation can be built on top without touching PEN itself. + +## Resulting contract shape + +```solidity +contract PEN is ERC20, ERC20Permit, ERC20Votes { + constructor(address vault) ERC20("Pendulum", "PEN") ERC20Permit("Pendulum") { + _mint(vault, MAX_ISSUANCE); + } + // + the small required overrides (_update, nonces) and, + // if timestamp mode is chosen, clock() / CLOCK_MODE() +} +``` + +Every behavioral line is OpenZeppelin's audited code — which is precisely the property that makes the token cheap to audit (PRD §9) and easy for third parties to verify. From ee69f723030272064659d96a9a1d2399169e0ffe Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 7 Jul 2026 19:31:29 +0200 Subject: [PATCH 02/23] pallets: Add token-migration pallet for one-way PEN migration to Base 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. --- Cargo.lock | 16 ++ Cargo.toml | 1 + pallets/token-migration/Cargo.toml | 51 ++++++ .../token-migration/src/default_weights.rs | 28 +++ pallets/token-migration/src/lib.rs | 169 +++++++++++++++++ pallets/token-migration/src/mock.rs | 126 +++++++++++++ pallets/token-migration/src/tests.rs | 173 ++++++++++++++++++ 7 files changed, 564 insertions(+) create mode 100644 pallets/token-migration/Cargo.toml create mode 100644 pallets/token-migration/src/default_weights.rs create mode 100644 pallets/token-migration/src/lib.rs create mode 100644 pallets/token-migration/src/mock.rs create mode 100644 pallets/token-migration/src/tests.rs diff --git a/Cargo.lock b/Cargo.lock index 62d42714d..630a03c27 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -15495,6 +15495,22 @@ dependencies = [ "spacewalk-primitives", ] +[[package]] +name = "token-migration" +version = "1.6.0-d" +dependencies = [ + "frame-benchmarking", + "frame-support", + "frame-system", + "pallet-balances", + "parity-scale-codec", + "scale-info", + "sp-core 21.0.0 (git+https://github.com/pendulum-chain/polkadot-sdk?rev=22dd6dee5148a0879306337bd8619c16224cc07b)", + "sp-io 23.0.0 (git+https://github.com/pendulum-chain/polkadot-sdk?rev=22dd6dee5148a0879306337bd8619c16224cc07b)", + "sp-runtime 24.0.0 (git+https://github.com/pendulum-chain/polkadot-sdk?rev=22dd6dee5148a0879306337bd8619c16224cc07b)", + "sp-std 8.0.0 (git+https://github.com/pendulum-chain/polkadot-sdk?rev=22dd6dee5148a0879306337bd8619c16224cc07b)", +] + [[package]] name = "tokio" version = "1.42.0" diff --git a/Cargo.toml b/Cargo.toml index 3557fcf41..7a9731577 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,7 @@ members = [ "pallets/vesting-manager", "pallets/orml-currencies-allowance-extension", "pallets/orml-tokens-management-extension", + "pallets/token-migration", "pallets/treasury-buyout-extension", "pallets/xcm-teleport", "runtime/common", diff --git a/pallets/token-migration/Cargo.toml b/pallets/token-migration/Cargo.toml new file mode 100644 index 000000000..c4d3436ab --- /dev/null +++ b/pallets/token-migration/Cargo.toml @@ -0,0 +1,51 @@ +[package] +authors = ["Pendulum Chain"] +description = "One-way migration of the native token to Base: burns the migrated amount and emits an event for the attestor set" +edition = "2021" +name = "token-migration" +version = "1.6.0-d" + +[dependencies] +codec = { workspace = true, features = ["derive", "max-encoded-len"] } +scale-info = { workspace = true, features = ["derive"] } + +# Substrate dependencies +frame-support = { workspace = true } +frame-system = { workspace = true } +sp-core = { workspace = true } +sp-runtime = { workspace = true } +sp-std = { workspace = true } + +# benchmarking +frame-benchmarking = { workspace = true, optional = true } + +[dev-dependencies] +sp-io = { workspace = true, default-features = true } +pallet-balances = { workspace = true, default-features = true } + +[features] +default = ["std"] +std = [ + "codec/std", + "scale-info/std", + "frame-support/std", + "frame-system/std", + "sp-core/std", + "sp-runtime/std", + "sp-std/std", + "frame-benchmarking?/std" +] +runtime-benchmarks = [ + "frame-benchmarking", + "frame-benchmarking/runtime-benchmarks", + "frame-support/runtime-benchmarks", + "frame-system/runtime-benchmarks", + "pallet-balances/runtime-benchmarks", + "sp-runtime/runtime-benchmarks" +] +try-runtime = [ + "frame-support/try-runtime", + "frame-system/try-runtime", + "pallet-balances/try-runtime", + "sp-runtime/try-runtime" +] diff --git a/pallets/token-migration/src/default_weights.rs b/pallets/token-migration/src/default_weights.rs new file mode 100644 index 000000000..8922aae3f --- /dev/null +++ b/pallets/token-migration/src/default_weights.rs @@ -0,0 +1,28 @@ +//! Default weights for the token-migration pallet. +//! +//! TODO: replace with generated weights once benchmarks are added; these are +//! conservative manual estimates in the meantime (same approach as the other +//! pallets in this repo). + +use core::marker::PhantomData; +use frame_support::{traits::Get, weights::Weight}; + +pub trait WeightInfo { + fn migrate() -> Weight; + fn set_paused() -> Weight; +} + +pub struct SubstrateWeight(PhantomData); + +impl WeightInfo for SubstrateWeight { + fn migrate() -> Weight { + Weight::from_parts(50_000_000, 0) + .saturating_add(T::DbWeight::get().reads(4_u64)) + .saturating_add(T::DbWeight::get().writes(4_u64)) + } + + fn set_paused() -> Weight { + Weight::from_parts(10_000_000, 0) + .saturating_add(T::DbWeight::get().writes(1_u64)) + } +} diff --git a/pallets/token-migration/src/lib.rs b/pallets/token-migration/src/lib.rs new file mode 100644 index 000000000..535824e9c --- /dev/null +++ b/pallets/token-migration/src/lib.rs @@ -0,0 +1,169 @@ +//! # Token Migration Pallet +//! +//! One-way migration of the native token (PEN) to Base. +//! +//! Holders call [`Pallet::migrate`] with an amount and the Base (EVM) address that +//! should receive the tokens. The amount is burned (total issuance decreases) and a +//! [`Event::MigrationInitiated`] event is emitted carrying a globally unique nonce. +//! An off-chain attestor set observes these events in finalized blocks and approves +//! the corresponding release from the MigrationVault contract on Base. +//! +//! The pallet is fire-and-forget by design: it has no knowledge of Base state and +//! migrations are irreversible. See docs/pen-base-migration-prd.md (§6.1) for the +//! full requirements this pallet implements. + +#![cfg_attr(not(feature = "std"), no_std)] + +pub use pallet::*; + +pub mod default_weights; + +#[cfg(test)] +mod mock; +#[cfg(test)] +mod tests; + +pub use default_weights::WeightInfo; + +use frame_support::traits::{Currency, ExistenceRequirement, WithdrawReasons}; +use sp_core::H160; +use sp_runtime::{ + traits::{CheckedSub, Saturating, Zero}, + ArithmeticError, +}; + +pub(crate) type BalanceOf = + <::Currency as Currency<::AccountId>>::Balance; + +#[frame_support::pallet] +pub mod pallet { + use super::*; + use frame_support::pallet_prelude::*; + use frame_system::pallet_prelude::*; + + #[pallet::pallet] + pub struct Pallet(_); + + #[pallet::config] + pub trait Config: frame_system::Config { + type RuntimeEvent: From> + IsType<::RuntimeEvent>; + + /// The native currency. Migrated amounts are withdrawn and burned, + /// reducing total issuance (PRD decision D1: burn). + type Currency: Currency; + + /// Smallest amount accepted by `migrate`, to keep dust-sized migrations + /// from spamming the attestor pipeline. + #[pallet::constant] + type MinimumMigrationAmount: Get>; + + /// Origin allowed to pause and unpause migrations (incident response). + type PauseOrigin: EnsureOrigin; + + type WeightInfo: WeightInfo; + } + + #[pallet::event] + #[pallet::generate_deposit(pub(super) fn deposit_event)] + pub enum Event { + /// `amount` of the native token was burned for migration to Base. + /// The attestor set releases the equivalent amount to `base_address` + /// on Base, keyed by the globally unique `nonce`. + MigrationInitiated { + nonce: u64, + who: T::AccountId, + base_address: H160, + amount: BalanceOf, + }, + /// Migrations were paused or unpaused by the pause origin. + MigrationPauseSet { paused: bool }, + } + + #[pallet::error] + pub enum Error { + /// Migrations are currently paused. + MigrationsPaused, + /// The amount is below the configured minimum migration amount. + AmountBelowMinimum, + /// The caller's free balance is lower than the requested amount. + InsufficientBalance, + /// The migration would leave a remainder below the existential deposit. + /// Migrate the entire balance or leave at least the existential deposit. + WouldLeaveDust, + } + + /// Nonce of the next migration. Monotonically increasing, never reused; + /// each emitted `MigrationInitiated` event consumes one value. + #[pallet::storage] + pub type NextNonce = StorageValue<_, u64, ValueQuery>; + + /// Cumulative amount burned for migration, for the invariant monitor + /// (PRD M2: vault balance on Base + total released == max issuance). + #[pallet::storage] + pub type TotalMigrated = StorageValue<_, BalanceOf, ValueQuery>; + + /// Whether migrations are paused. + #[pallet::storage] + pub type Paused = StorageValue<_, bool, ValueQuery>; + + #[pallet::call] + impl Pallet { + /// Burn `amount` of the caller's native tokens for migration to Base. + /// + /// The tokens are released to `base_address` on Base by the attestor set. + /// This action is IRREVERSIBLE: a wrong `base_address` means the tokens + /// are lost. The caller must either migrate their entire free balance or + /// leave at least the existential deposit behind. + #[pallet::call_index(0)] + #[pallet::weight(::WeightInfo::migrate())] + pub fn migrate( + origin: OriginFor, + #[pallet::compact] amount: BalanceOf, + base_address: H160, + ) -> DispatchResult { + let who = ensure_signed(origin)?; + ensure!(!Paused::::get(), Error::::MigrationsPaused); + ensure!( + amount >= T::MinimumMigrationAmount::get(), + Error::::AmountBelowMinimum + ); + + let free = T::Currency::free_balance(&who); + let remainder = free.checked_sub(&amount).ok_or(Error::::InsufficientBalance)?; + ensure!( + remainder.is_zero() || remainder >= T::Currency::minimum_balance(), + Error::::WouldLeaveDust + ); + + // Fails if locks or reserves make `amount` non-transferable (staking, + // vesting and governance locks must be cleared before migrating). + let imbalance = T::Currency::withdraw( + &who, + amount, + WithdrawReasons::TRANSFER, + ExistenceRequirement::AllowDeath, + )?; + // Dropping the negative imbalance without offsetting it burns the + // withdrawn amount, i.e. total issuance decreases by `amount`. + drop(imbalance); + + let nonce = NextNonce::::get(); + let next = nonce.checked_add(1).ok_or(ArithmeticError::Overflow)?; + NextNonce::::put(next); + TotalMigrated::::mutate(|total| *total = total.saturating_add(amount)); + + Self::deposit_event(Event::MigrationInitiated { nonce, who, base_address, amount }); + Ok(()) + } + + /// Pause or unpause migrations. Callable by the pause origin only. + #[pallet::call_index(1)] + #[pallet::weight(::WeightInfo::set_paused())] + pub fn set_paused(origin: OriginFor, paused: bool) -> DispatchResult { + T::PauseOrigin::ensure_origin(origin)?; + Paused::::put(paused); + Self::deposit_event(Event::MigrationPauseSet { paused }); + Ok(()) + } + } +} diff --git a/pallets/token-migration/src/mock.rs b/pallets/token-migration/src/mock.rs new file mode 100644 index 000000000..6ce0745db --- /dev/null +++ b/pallets/token-migration/src/mock.rs @@ -0,0 +1,126 @@ +use crate::{self as token_migration, default_weights::SubstrateWeight, Config}; +use frame_support::{ + parameter_types, + traits::{ConstU32, Everything}, +}; +use frame_system::EnsureRoot; +use sp_core::H256; +use sp_runtime::{ + traits::{BlakeTwo256, IdentityLookup}, + BuildStorage, +}; + +type Block = frame_system::mocking::MockBlock; + +pub const UNIT: Balance = 1_000_000_000_000; + +frame_support::construct_runtime!( + pub enum Test + { + System: frame_system, + Balances: pallet_balances, + TokenMigration: token_migration, + } +); + +pub type AccountId = u64; +pub type Balance = u128; +pub type Nonce = u64; + +parameter_types! { + pub const BlockHashCount: u64 = 250; + pub const SS58Prefix: u8 = 42; +} + +impl frame_system::Config for Test { + type Block = Block; + type BaseCallFilter = Everything; + type BlockWeights = (); + type BlockLength = (); + type DbWeight = (); + type RuntimeOrigin = RuntimeOrigin; + type RuntimeCall = RuntimeCall; + type Nonce = Nonce; + type Hash = H256; + type Hashing = BlakeTwo256; + type AccountId = AccountId; + type Lookup = IdentityLookup; + type RuntimeEvent = RuntimeEvent; + type BlockHashCount = BlockHashCount; + type Version = (); + type PalletInfo = PalletInfo; + type AccountData = pallet_balances::AccountData; + type OnNewAccount = (); + type OnKilledAccount = (); + type SystemWeightInfo = (); + type SS58Prefix = SS58Prefix; + type OnSetCode = (); + type MaxConsumers = frame_support::traits::ConstU32<16>; + type RuntimeTask = RuntimeTask; +} + +parameter_types! { + pub const MaxLocks: u32 = 50; + pub const ExistentialDeposit: Balance = 1000; + pub const MaxReserves: u32 = 50; +} + +impl pallet_balances::Config for Test { + type MaxLocks = MaxLocks; + type Balance = Balance; + type RuntimeEvent = RuntimeEvent; + type DustRemoval = (); + type ExistentialDeposit = ExistentialDeposit; + type AccountStore = System; + type WeightInfo = pallet_balances::weights::SubstrateWeight; + type MaxReserves = MaxReserves; + type ReserveIdentifier = (); + type FreezeIdentifier = (); + type MaxFreezes = (); + type MaxHolds = ConstU32<1>; + type RuntimeHoldReason = RuntimeHoldReason; + type RuntimeFreezeReason = RuntimeFreezeReason; +} + +parameter_types! { + pub const MinimumMigrationAmount: Balance = UNIT; +} + +impl Config for Test { + type RuntimeEvent = RuntimeEvent; + type Currency = Balances; + type MinimumMigrationAmount = MinimumMigrationAmount; + type PauseOrigin = EnsureRoot; + type WeightInfo = SubstrateWeight; +} + +// ------- Constants and Genesis Config ------ // + +pub const USER: AccountId = 1; +pub const USER_INITIAL_BALANCE: Balance = 100 * UNIT; + +pub struct ExtBuilder; + +impl ExtBuilder { + pub fn build() -> sp_io::TestExternalities { + let mut storage = frame_system::GenesisConfig::::default().build_storage().unwrap(); + + pallet_balances::GenesisConfig:: { + balances: vec![(USER, USER_INITIAL_BALANCE)], + } + .assimilate_storage(&mut storage) + .unwrap(); + + sp_io::TestExternalities::from(storage) + } +} + +pub fn run_test(test: T) +where + T: FnOnce(), +{ + ExtBuilder::build().execute_with(|| { + System::set_block_number(1); + test(); + }); +} diff --git a/pallets/token-migration/src/tests.rs b/pallets/token-migration/src/tests.rs new file mode 100644 index 000000000..ada92434f --- /dev/null +++ b/pallets/token-migration/src/tests.rs @@ -0,0 +1,173 @@ +use crate::{mock::*, Error, Event, NextNonce, Paused, TotalMigrated}; +use frame_support::{ + assert_noop, assert_ok, + traits::{LockableCurrency, WithdrawReasons}, +}; +use sp_core::H160; +use sp_runtime::DispatchError; + +fn base_address() -> H160 { + H160::from_low_u64_be(0xBEEF) +} + +#[test] +fn migrate_burns_amount_and_emits_event() { + run_test(|| { + let amount = 10 * UNIT; + let issuance_before = Balances::total_issuance(); + + assert_ok!(TokenMigration::migrate( + RuntimeOrigin::signed(USER), + amount, + base_address() + )); + + // The amount is burned, not moved: issuance shrinks by exactly `amount`. + assert_eq!(Balances::total_issuance(), issuance_before - amount); + assert_eq!(Balances::free_balance(USER), USER_INITIAL_BALANCE - amount); + assert_eq!(TotalMigrated::::get(), amount); + + System::assert_last_event( + Event::MigrationInitiated { + nonce: 0, + who: USER, + base_address: base_address(), + amount, + } + .into(), + ); + }); +} + +#[test] +fn nonces_are_unique_and_monotonic() { + run_test(|| { + for expected_nonce in 0u64..3 { + assert_eq!(NextNonce::::get(), expected_nonce); + assert_ok!(TokenMigration::migrate( + RuntimeOrigin::signed(USER), + UNIT, + base_address() + )); + System::assert_last_event( + Event::MigrationInitiated { + nonce: expected_nonce, + who: USER, + base_address: base_address(), + amount: UNIT, + } + .into(), + ); + } + assert_eq!(NextNonce::::get(), 3); + assert_eq!(TotalMigrated::::get(), 3 * UNIT); + }); +} + +#[test] +fn migrate_fails_below_minimum_amount() { + run_test(|| { + assert_noop!( + TokenMigration::migrate(RuntimeOrigin::signed(USER), UNIT - 1, base_address()), + Error::::AmountBelowMinimum + ); + }); +} + +#[test] +fn migrate_fails_with_insufficient_balance() { + run_test(|| { + assert_noop!( + TokenMigration::migrate( + RuntimeOrigin::signed(USER), + USER_INITIAL_BALANCE + 1, + base_address() + ), + Error::::InsufficientBalance + ); + }); +} + +#[test] +fn migrate_fails_if_dust_would_remain() { + run_test(|| { + // Leaves a remainder of ED - 1, which the balances pallet would reap as dust. + let ed = ExistentialDeposit::get(); + assert_noop!( + TokenMigration::migrate( + RuntimeOrigin::signed(USER), + USER_INITIAL_BALANCE - (ed - 1), + base_address() + ), + Error::::WouldLeaveDust + ); + }); +} + +#[test] +fn migrate_entire_balance_works() { + run_test(|| { + assert_ok!(TokenMigration::migrate( + RuntimeOrigin::signed(USER), + USER_INITIAL_BALANCE, + base_address() + )); + assert_eq!(Balances::free_balance(USER), 0); + assert_eq!(Balances::total_issuance(), 0); + assert_eq!(TotalMigrated::::get(), USER_INITIAL_BALANCE); + }); +} + +#[test] +fn migrate_fails_for_locked_funds() { + run_test(|| { + // Lock all but 5 UNIT; migrating more than the usable balance must fail. + Balances::set_lock( + *b"lock1234", + &USER, + USER_INITIAL_BALANCE - 5 * UNIT, + WithdrawReasons::all(), + ); + assert_noop!( + TokenMigration::migrate(RuntimeOrigin::signed(USER), 10 * UNIT, base_address()), + pallet_balances::Error::::LiquidityRestrictions + ); + // Migrating within the usable balance still works. + assert_ok!(TokenMigration::migrate( + RuntimeOrigin::signed(USER), + 5 * UNIT, + base_address() + )); + }); +} + +#[test] +fn pause_blocks_migrations_and_unpause_restores_them() { + run_test(|| { + assert_ok!(TokenMigration::set_paused(RuntimeOrigin::root(), true)); + assert!(Paused::::get()); + System::assert_last_event(Event::MigrationPauseSet { paused: true }.into()); + + assert_noop!( + TokenMigration::migrate(RuntimeOrigin::signed(USER), UNIT, base_address()), + Error::::MigrationsPaused + ); + + assert_ok!(TokenMigration::set_paused(RuntimeOrigin::root(), false)); + assert_ok!(TokenMigration::migrate( + RuntimeOrigin::signed(USER), + UNIT, + base_address() + )); + }); +} + +#[test] +fn set_paused_requires_pause_origin() { + run_test(|| { + assert_noop!( + TokenMigration::set_paused(RuntimeOrigin::signed(USER), true), + DispatchError::BadOrigin + ); + }); +} From 6b5863a5f8d8cade03a52f791f194fdf37bf87c8 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 7 Jul 2026 19:31:29 +0200 Subject: [PATCH 03/23] contracts: Add PEN token and MigrationVault for Base 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. --- .gitmodules | 6 + contracts/.gitignore | 3 + contracts/README.md | 49 ++++ contracts/foundry.lock | 14 ++ contracts/foundry.toml | 12 + contracts/lib/forge-std | 1 + contracts/lib/openzeppelin-contracts | 1 + contracts/src/MigrationVault.sol | 333 +++++++++++++++++++++++++++ contracts/src/PEN.sol | 53 +++++ contracts/test/MigrationVault.t.sol | 289 +++++++++++++++++++++++ contracts/test/PEN.t.sol | 76 ++++++ 11 files changed, 837 insertions(+) create mode 100644 .gitmodules create mode 100644 contracts/.gitignore create mode 100644 contracts/README.md create mode 100644 contracts/foundry.lock create mode 100644 contracts/foundry.toml create mode 160000 contracts/lib/forge-std create mode 160000 contracts/lib/openzeppelin-contracts create mode 100644 contracts/src/MigrationVault.sol create mode 100644 contracts/src/PEN.sol create mode 100644 contracts/test/MigrationVault.t.sol create mode 100644 contracts/test/PEN.t.sol diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 000000000..460a2ec0d --- /dev/null +++ b/.gitmodules @@ -0,0 +1,6 @@ +[submodule "contracts/lib/openzeppelin-contracts"] + path = contracts/lib/openzeppelin-contracts + url = https://github.com/OpenZeppelin/openzeppelin-contracts +[submodule "contracts/lib/forge-std"] + path = contracts/lib/forge-std + url = https://github.com/foundry-rs/forge-std diff --git a/contracts/.gitignore b/contracts/.gitignore new file mode 100644 index 000000000..83f939c72 --- /dev/null +++ b/contracts/.gitignore @@ -0,0 +1,3 @@ +out/ +cache/ +broadcast/ diff --git a/contracts/README.md b/contracts/README.md new file mode 100644 index 000000000..1d063e873 --- /dev/null +++ b/contracts/README.md @@ -0,0 +1,49 @@ +# PEN on Base — Migration Contracts + +Solidity contracts for the one-way migration of PEN from the Pendulum parachain +to Base. Design and requirements: [PRD](../docs/pen-base-migration-prd.md), +[ADR-001](../docs/adr-001-pen-base-migration-approach.md), +[token standards](../docs/pen-token-contract-standards.md). + +## Contracts + +- **`src/PEN.sol`** — fixed-supply ERC-20 (`ERC20 + ERC20Permit + ERC20Votes`, + EIP-6372 timestamp clock). The entire max issuance is minted to the + MigrationVault in the constructor; there is no mint function, no owner and no + proxy. +- **`src/MigrationVault.sol`** — holds the unmigrated supply and releases it on + the 3rd matching on-chain approval from the attestor set (per Pendulum + migration nonce). Includes per-release and daily caps, guardian pause, + timelock-friendly two-step admin, attestor rotation that retroactively + invalidates a removed attestor's approvals, and a time-locked remainder sweep + for the end of the migration window. + +## Deployment order + +The token and vault reference each other, so: + +1. Deploy `MigrationVault(admin, guardian, attestors[5], threshold=3, conversionFactor=1e6, perReleaseCap, dailyCap, earliestSweepTimestamp)` +2. Deploy `PEN(vault, maxIssuance)` — mints the full supply into the vault +3. `vault.setToken(pen)` (admin, one-time; verifies the vault holds 100% of supply) + +`conversionFactor = 1e6` converts 12-decimal pallet amounts to the 18-decimal +token; attestors always submit the raw pallet amount from the +`MigrationInitiated` event — the conversion happens in the vault and nowhere +else (PRD V7). + +## Build & test + +Requires [Foundry](https://getfoundry.sh). Dependencies are git submodules +(`lib/openzeppelin-contracts` v5.4.0, `lib/forge-std`); after a fresh clone run +`git submodule update --init --recursive` or `forge install`. + +```sh +forge build +forge test +``` + +## Open parameters (fixed at deployment, see PRD §4.2) + +- `maxIssuance` — exact figure pending decision D3 +- decimals/`conversionFactor` — 18/1e6 pending decision D2 +- attestor addresses, caps, `earliestSweepTimestamp` — decisions D4/D5 diff --git a/contracts/foundry.lock b/contracts/foundry.lock new file mode 100644 index 000000000..a4cdb73a9 --- /dev/null +++ b/contracts/foundry.lock @@ -0,0 +1,14 @@ +{ + "lib/forge-std": { + "tag": { + "name": "v1.16.2", + "rev": "bf647bd6046f2f7da30d0c2bf435e5c76a780c1b" + } + }, + "lib/openzeppelin-contracts": { + "tag": { + "name": "v5.4.0", + "rev": "c64a1edb67b6e3f4a15cca8909c9482ad33a02b0" + } + } +} \ No newline at end of file diff --git a/contracts/foundry.toml b/contracts/foundry.toml new file mode 100644 index 000000000..9c330ad81 --- /dev/null +++ b/contracts/foundry.toml @@ -0,0 +1,12 @@ +[profile.default] +src = "src" +out = "out" +libs = ["lib"] +test = "test" +solc_version = "0.8.26" +optimizer = true +optimizer_runs = 10_000 +remappings = ["@openzeppelin/=lib/openzeppelin-contracts/"] + +[fuzz] +runs = 512 diff --git a/contracts/lib/forge-std b/contracts/lib/forge-std new file mode 160000 index 000000000..bf647bd60 --- /dev/null +++ b/contracts/lib/forge-std @@ -0,0 +1 @@ +Subproject commit bf647bd6046f2f7da30d0c2bf435e5c76a780c1b diff --git a/contracts/lib/openzeppelin-contracts b/contracts/lib/openzeppelin-contracts new file mode 160000 index 000000000..c64a1edb6 --- /dev/null +++ b/contracts/lib/openzeppelin-contracts @@ -0,0 +1 @@ +Subproject commit c64a1edb67b6e3f4a15cca8909c9482ad33a02b0 diff --git a/contracts/src/MigrationVault.sol b/contracts/src/MigrationVault.sol new file mode 100644 index 000000000..d1eacc505 --- /dev/null +++ b/contracts/src/MigrationVault.sol @@ -0,0 +1,333 @@ +// SPDX-License-Identifier: GPL-3.0-only +pragma solidity 0.8.26; + +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; + +/// @title MigrationVault — releases pre-minted PEN as holders migrate from Pendulum +/// @notice Holds the entire unmigrated PEN supply. Each attestor independently +/// observes finalized `MigrationInitiated` events on the Pendulum +/// parachain and submits an on-chain approval for the exact +/// (nonce, recipient, amount) tuple. The `threshold`-th matching +/// approval releases the tokens (on-chain-approvals model, ADR-001). +/// +/// Trust and blast-radius model (PRD §8): +/// - fewer than `threshold` attestors can release nothing; +/// - a compromised quorum is bounded by `perReleaseCap`/`dailyCap` +/// and can be stopped by the guardian's `pause`; +/// - all parameter changes go through `admin`, expected to be a +/// TimelockController (>= 48h) after the bootstrap phase. +contract MigrationVault { + using SafeERC20 for IERC20; + + // ---------------------------------------------------------------- errors + + error NotAdmin(); + error NotGuardianOrAdmin(); + error NotAttestor(); + error NotPendingAdmin(); + error ZeroAddress(); + error TokenAlreadySet(); + error TokenNotSet(); + error VaultMustHoldFullSupply(); + error InvalidThreshold(); + error DuplicateAttestor(); + error UnknownAttestor(); + error ThresholdWouldExceedAttestors(); + error NonceAlreadyConsumed(uint64 nonce); + error AlreadyApproved(address attestor); + error NotEnoughApprovals(uint256 active, uint256 required); + error EnforcedPause(); + error NotPaused(); + error ZeroAmount(); + error ExceedsPerReleaseCap(uint256 amount, uint256 cap); + error ExceedsDailyCap(uint256 wouldBeReleasedToday, uint256 cap); + error SweepNotYetAllowed(uint256 earliest); + + // ---------------------------------------------------------------- events + + event TokenSet(address indexed token); + event Approved(uint64 indexed nonce, address indexed recipient, uint256 palletAmount, address indexed attestor); + event Released(uint64 indexed nonce, address indexed recipient, uint256 palletAmount, uint256 tokenAmount); + event Paused(address indexed by); + event Unpaused(address indexed by); + event AttestorAdded(address indexed attestor); + event AttestorRemoved(address indexed attestor); + event ThresholdUpdated(uint256 threshold); + event CapsUpdated(uint256 perReleaseCap, uint256 dailyCap); + event GuardianUpdated(address indexed guardian); + event AdminTransferStarted(address indexed pendingAdmin); + event AdminTransferred(address indexed newAdmin); + event RemainderSwept(address indexed to, uint256 amount); + + // ---------------------------------------------------------------- state + + /// @notice The PEN token. Set exactly once, after which the vault must + /// hold the token's entire supply (pre-mint model, ADR-001). + IERC20 public token; + + /// @notice Admin of all parameters; a TimelockController post-bootstrap. + address public admin; + address public pendingAdmin; + + /// @notice Can pause releases instantly (incident response). Unpause is + /// admin-only, so a compromised guardian can at worst halt. + address public guardian; + + mapping(address => bool) public isAttestor; + uint256 public attestorCount; + /// @notice Number of distinct active attestors that must approve the + /// identical (nonce, recipient, amount) tuple to release. + uint256 public threshold; + + /// @notice Multiplier from pallet units (12 decimals on Pendulum) to token + /// units. The decimal conversion happens here and nowhere else + /// (PRD V7); 1e6 for an 18-decimal token. + uint256 public immutable conversionFactor; + + /// @notice Maximum token units released in a single migration. + uint256 public perReleaseCap; + /// @notice Maximum token units released per UTC day. + uint256 public dailyCap; + uint256 public currentDay; + uint256 public releasedToday; + + /// @notice Earliest timestamp at which the admin may sweep the unmigrated + /// remainder (end-of-window handling, PRD V9 / decision D5). + uint256 public immutable earliestSweepTimestamp; + + bool public paused; + + /// @notice Consumed migration nonces; a nonce can never release twice. + mapping(uint64 => bool) public nonceConsumed; + + /// @dev Approvers per payload hash. Approvals are counted at release time + /// against the *current* attestor set, so removing a compromised + /// attestor retroactively invalidates its approvals (PRD V6). + mapping(bytes32 => address[]) internal _approvers; + mapping(bytes32 => mapping(address => bool)) public hasApproved; + + /// @notice Total token units released so far (for the invariant monitor: + /// balanceOf(vault) + totalReleased == totalSupply). + uint256 public totalReleased; + + // ---------------------------------------------------------------- modifiers + + modifier onlyAdmin() { + if (msg.sender != admin) revert NotAdmin(); + _; + } + + modifier onlyAttestor() { + if (!isAttestor[msg.sender]) revert NotAttestor(); + _; + } + + // ---------------------------------------------------------------- setup + + constructor( + address admin_, + address guardian_, + address[] memory attestors_, + uint256 threshold_, + uint256 conversionFactor_, + uint256 perReleaseCap_, + uint256 dailyCap_, + uint256 earliestSweepTimestamp_ + ) { + if (admin_ == address(0) || guardian_ == address(0)) revert ZeroAddress(); + if (threshold_ < 2 || threshold_ > attestors_.length) revert InvalidThreshold(); + if (conversionFactor_ == 0) revert ZeroAmount(); + + admin = admin_; + guardian = guardian_; + threshold = threshold_; + conversionFactor = conversionFactor_; + perReleaseCap = perReleaseCap_; + dailyCap = dailyCap_; + earliestSweepTimestamp = earliestSweepTimestamp_; + + for (uint256 i = 0; i < attestors_.length; i++) { + address attestor = attestors_[i]; + if (attestor == address(0)) revert ZeroAddress(); + if (isAttestor[attestor]) revert DuplicateAttestor(); + isAttestor[attestor] = true; + emit AttestorAdded(attestor); + } + attestorCount = attestors_.length; + } + + /// @notice One-time wiring of the token, required because vault and token + /// reference each other: the vault is deployed first, then PEN + /// mints its full supply here, then the admin calls this. + function setToken(IERC20 token_) external onlyAdmin { + if (address(token) != address(0)) revert TokenAlreadySet(); + if (address(token_) == address(0)) revert ZeroAddress(); + uint256 supply = token_.totalSupply(); + if (supply == 0 || token_.balanceOf(address(this)) != supply) revert VaultMustHoldFullSupply(); + token = token_; + emit TokenSet(address(token_)); + } + + // ---------------------------------------------------------------- attestation + + /// @notice Approve the release for Pendulum migration `nonce`. `palletAmount` + /// is the burned amount in pallet units (12 decimals), exactly as + /// emitted by the `MigrationInitiated` event; the vault converts. + /// Recording approvals stays possible while paused so that releases + /// resume without re-attestation after an unpause. + function approve(uint64 nonce, address recipient, uint256 palletAmount) external onlyAttestor { + if (recipient == address(0)) revert ZeroAddress(); + if (palletAmount == 0) revert ZeroAmount(); + if (nonceConsumed[nonce]) revert NonceAlreadyConsumed(nonce); + + bytes32 payload = payloadHash(nonce, recipient, palletAmount); + if (hasApproved[payload][msg.sender]) revert AlreadyApproved(msg.sender); + hasApproved[payload][msg.sender] = true; + _approvers[payload].push(msg.sender); + emit Approved(nonce, recipient, palletAmount, msg.sender); + + // Opportunistic release: skipped (not reverted) when paused or a cap + // is hit, so the approval is recorded either way. `release` can be + // called by anyone later to retry. + if (!paused && address(token) != address(0) && activeApprovals(payload) >= threshold) { + uint256 tokenAmount = palletAmount * conversionFactor; + if (tokenAmount <= perReleaseCap && _releasedTodayAfterRoll() + tokenAmount <= dailyCap) { + _release(nonce, recipient, palletAmount); + } + } + } + + /// @notice Execute a sufficiently-approved release. Callable by anyone; + /// used to retry releases deferred by pause or caps. + function release(uint64 nonce, address recipient, uint256 palletAmount) external { + if (paused) revert EnforcedPause(); + if (address(token) == address(0)) revert TokenNotSet(); + if (nonceConsumed[nonce]) revert NonceAlreadyConsumed(nonce); + + bytes32 payload = payloadHash(nonce, recipient, palletAmount); + uint256 active = activeApprovals(payload); + if (active < threshold) revert NotEnoughApprovals(active, threshold); + + uint256 tokenAmount = palletAmount * conversionFactor; + if (tokenAmount > perReleaseCap) revert ExceedsPerReleaseCap(tokenAmount, perReleaseCap); + uint256 releasedAfter = _releasedTodayAfterRoll() + tokenAmount; + if (releasedAfter > dailyCap) revert ExceedsDailyCap(releasedAfter, dailyCap); + + _release(nonce, recipient, palletAmount); + } + + /// @dev Caller must have verified pause state, approvals and caps. + function _release(uint64 nonce, address recipient, uint256 palletAmount) internal { + uint256 tokenAmount = palletAmount * conversionFactor; + nonceConsumed[nonce] = true; + releasedToday += tokenAmount; + totalReleased += tokenAmount; + token.safeTransfer(recipient, tokenAmount); + emit Released(nonce, recipient, palletAmount, tokenAmount); + } + + // ---------------------------------------------------------------- views + + function payloadHash(uint64 nonce, address recipient, uint256 palletAmount) public pure returns (bytes32) { + return keccak256(abi.encode(nonce, recipient, palletAmount)); + } + + /// @notice Approvals for a payload counted against the current attestor + /// set. Removed attestors no longer count; re-added ones do. + function activeApprovals(bytes32 payload) public view returns (uint256 count) { + address[] storage approvers = _approvers[payload]; + for (uint256 i = 0; i < approvers.length; i++) { + if (isAttestor[approvers[i]]) count++; + } + } + + function approversOf(bytes32 payload) external view returns (address[] memory) { + return _approvers[payload]; + } + + /// @dev Rolls the daily accounting window forward if a new UTC day started. + function _releasedTodayAfterRoll() internal returns (uint256) { + uint256 day = block.timestamp / 1 days; + if (day != currentDay) { + currentDay = day; + releasedToday = 0; + } + return releasedToday; + } + + // ---------------------------------------------------------------- pause + + function pause() external { + if (msg.sender != guardian && msg.sender != admin) revert NotGuardianOrAdmin(); + if (paused) revert EnforcedPause(); + paused = true; + emit Paused(msg.sender); + } + + function unpause() external onlyAdmin { + if (!paused) revert NotPaused(); + paused = false; + emit Unpaused(msg.sender); + } + + // ---------------------------------------------------------------- admin + + function addAttestor(address attestor) external onlyAdmin { + if (attestor == address(0)) revert ZeroAddress(); + if (isAttestor[attestor]) revert DuplicateAttestor(); + isAttestor[attestor] = true; + attestorCount += 1; + emit AttestorAdded(attestor); + } + + function removeAttestor(address attestor) external onlyAdmin { + if (!isAttestor[attestor]) revert UnknownAttestor(); + if (attestorCount - 1 < threshold) revert ThresholdWouldExceedAttestors(); + isAttestor[attestor] = false; + attestorCount -= 1; + emit AttestorRemoved(attestor); + } + + function setThreshold(uint256 threshold_) external onlyAdmin { + if (threshold_ < 2 || threshold_ > attestorCount) revert InvalidThreshold(); + threshold = threshold_; + emit ThresholdUpdated(threshold_); + } + + function setCaps(uint256 perReleaseCap_, uint256 dailyCap_) external onlyAdmin { + perReleaseCap = perReleaseCap_; + dailyCap = dailyCap_; + emit CapsUpdated(perReleaseCap_, dailyCap_); + } + + function setGuardian(address guardian_) external onlyAdmin { + if (guardian_ == address(0)) revert ZeroAddress(); + guardian = guardian_; + emit GuardianUpdated(guardian_); + } + + function transferAdmin(address newAdmin) external onlyAdmin { + if (newAdmin == address(0)) revert ZeroAddress(); + pendingAdmin = newAdmin; + emit AdminTransferStarted(newAdmin); + } + + function acceptAdmin() external { + if (msg.sender != pendingAdmin) revert NotPendingAdmin(); + admin = msg.sender; + pendingAdmin = address(0); + emit AdminTransferred(msg.sender); + } + + /// @notice Sweep the unmigrated remainder after the migration window + /// closes (destination decided by governance, PRD D5). + function sweepRemainder(address to) external onlyAdmin { + if (block.timestamp < earliestSweepTimestamp) revert SweepNotYetAllowed(earliestSweepTimestamp); + if (to == address(0)) revert ZeroAddress(); + if (address(token) == address(0)) revert TokenNotSet(); + uint256 balance = token.balanceOf(address(this)); + token.safeTransfer(to, balance); + emit RemainderSwept(to, balance); + } +} diff --git a/contracts/src/PEN.sol b/contracts/src/PEN.sol new file mode 100644 index 000000000..4a4908afd --- /dev/null +++ b/contracts/src/PEN.sol @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: GPL-3.0-only +pragma solidity 0.8.26; + +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import {ERC20Permit} from "@openzeppelin/contracts/token/ERC20/extensions/ERC20Permit.sol"; +import {ERC20Votes} from "@openzeppelin/contracts/token/ERC20/extensions/ERC20Votes.sol"; +import {Nonces} from "@openzeppelin/contracts/utils/Nonces.sol"; + +/// @title PEN — the Pendulum token on Base +/// @notice Fixed-supply ERC-20. The entire maximum issuance is minted to the +/// MigrationVault at deployment; there is no mint function, no owner +/// and no upgradeability. Unmigrated supply sits in the vault and is +/// released as holders migrate from the Pendulum parachain. +/// +/// Extensions (see docs/pen-token-contract-standards.md): +/// - ERC20Permit (EIP-2612): signature-based approvals +/// - ERC20Votes (EIP-5805): checkpointed voting power + delegation, +/// with the EIP-6372 clock in timestamp mode +contract PEN is ERC20, ERC20Permit, ERC20Votes { + error ZeroVault(); + error ZeroIssuance(); + + /// @param vault The MigrationVault that receives the full supply. + /// @param maxIssuance The maximum issuance of PEN, in 18-decimal units + /// (PRD open decision D3 fixes the exact figure at deployment). + constructor(address vault, uint256 maxIssuance) ERC20("Pendulum", "PEN") ERC20Permit("Pendulum") { + if (vault == address(0)) revert ZeroVault(); + if (maxIssuance == 0) revert ZeroIssuance(); + _mint(vault, maxIssuance); + } + + /// @dev EIP-6372 clock in timestamp mode (PRD T1). The Governor contract + /// must be deployed with the same clock mode. + function clock() public view override returns (uint48) { + return uint48(block.timestamp); + } + + /// @dev EIP-6372 machine-readable clock description. + // solhint-disable-next-line func-name-mixedcase + function CLOCK_MODE() public pure override returns (string memory) { + return "mode=timestamp"; + } + + // ----- required overrides for the ERC20Permit/ERC20Votes composition ----- + + function _update(address from, address to, uint256 value) internal override(ERC20, ERC20Votes) { + super._update(from, to, value); + } + + function nonces(address owner) public view override(ERC20Permit, Nonces) returns (uint256) { + return super.nonces(owner); + } +} diff --git a/contracts/test/MigrationVault.t.sol b/contracts/test/MigrationVault.t.sol new file mode 100644 index 000000000..f884368ba --- /dev/null +++ b/contracts/test/MigrationVault.t.sol @@ -0,0 +1,289 @@ +// SPDX-License-Identifier: GPL-3.0-only +pragma solidity 0.8.26; + +import {Test} from "forge-std/Test.sol"; +import {PEN} from "../src/PEN.sol"; +import {MigrationVault} from "../src/MigrationVault.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +contract MigrationVaultTest is Test { + uint256 internal constant MAX_ISSUANCE = 160_000_000e18; + // Pallet amounts are 12-decimal; the vault scales to the 18-decimal token. + uint256 internal constant CONVERSION_FACTOR = 1e6; + uint256 internal constant PER_RELEASE_CAP = 1_000_000e18; + uint256 internal constant DAILY_CAP = 2_000_000e18; + + MigrationVault internal vault; + PEN internal pen; + + address internal admin = makeAddr("admin"); + address internal guardian = makeAddr("guardian"); + address internal recipient = makeAddr("recipient"); + address[] internal attestors; + uint256 internal earliestSweep; + + function setUp() public { + for (uint256 i = 0; i < 5; i++) { + attestors.push(makeAddr(string(abi.encodePacked("attestor", i)))); + } + earliestSweep = block.timestamp + 365 days; + + vault = new MigrationVault( + admin, guardian, attestors, 3, CONVERSION_FACTOR, PER_RELEASE_CAP, DAILY_CAP, earliestSweep + ); + pen = new PEN(address(vault), MAX_ISSUANCE); + vm.prank(admin); + vault.setToken(IERC20(address(pen))); + } + + function approveAs(uint256 attestorIndex, uint64 nonce, address to, uint256 palletAmount) internal { + vm.prank(attestors[attestorIndex]); + vault.approve(nonce, to, palletAmount); + } + + // ---------------------------------------------------------------- setup & wiring + + function test_SetTokenOnlyOnce() public { + vm.prank(admin); + vm.expectRevert(MigrationVault.TokenAlreadySet.selector); + vault.setToken(IERC20(address(pen))); + } + + function test_SetTokenRequiresFullSupplyInVault() public { + MigrationVault fresh = new MigrationVault( + admin, guardian, attestors, 3, CONVERSION_FACTOR, PER_RELEASE_CAP, DAILY_CAP, earliestSweep + ); + // PEN was minted to the *other* vault, so this one holds nothing. + vm.prank(admin); + vm.expectRevert(MigrationVault.VaultMustHoldFullSupply.selector); + fresh.setToken(IERC20(address(pen))); + } + + function test_ConstructorRejectsThresholdBelowTwo() public { + vm.expectRevert(MigrationVault.InvalidThreshold.selector); + new MigrationVault(admin, guardian, attestors, 1, CONVERSION_FACTOR, PER_RELEASE_CAP, DAILY_CAP, earliestSweep); + } + + // ---------------------------------------------------------------- happy path + + function test_ThresholdApprovalsRelease() public { + uint256 palletAmount = 5e12; // 5 PEN in 12-decimal pallet units + + approveAs(0, 0, recipient, palletAmount); + approveAs(1, 0, recipient, palletAmount); + assertEq(pen.balanceOf(recipient), 0, "must not release below threshold"); + + approveAs(2, 0, recipient, palletAmount); + assertEq(pen.balanceOf(recipient), 5e18, "decimal conversion 12 -> 18"); + assertTrue(vault.nonceConsumed(0)); + assertEq(vault.totalReleased(), 5e18); + // Invariant the monitor watches: vault balance + released == total supply. + assertEq(pen.balanceOf(address(vault)) + vault.totalReleased(), pen.totalSupply()); + } + + // ---------------------------------------------------------------- replay & dedup + + function test_ConsumedNonceCannotReleaseAgain() public { + uint256 palletAmount = 5e12; + approveAs(0, 0, recipient, palletAmount); + approveAs(1, 0, recipient, palletAmount); + approveAs(2, 0, recipient, palletAmount); + + vm.prank(attestors[3]); + vm.expectRevert(abi.encodeWithSelector(MigrationVault.NonceAlreadyConsumed.selector, 0)); + vault.approve(0, recipient, palletAmount); + + vm.expectRevert(abi.encodeWithSelector(MigrationVault.NonceAlreadyConsumed.selector, 0)); + vault.release(0, recipient, palletAmount); + } + + function test_SameAttestorCannotApproveTwice() public { + approveAs(0, 0, recipient, 5e12); + vm.prank(attestors[0]); + vm.expectRevert(abi.encodeWithSelector(MigrationVault.AlreadyApproved.selector, attestors[0])); + vault.approve(0, recipient, 5e12); + } + + function test_NonAttestorCannotApprove() public { + vm.prank(makeAddr("mallory")); + vm.expectRevert(MigrationVault.NotAttestor.selector); + vault.approve(0, recipient, 5e12); + } + + function test_ConflictingTuplesNeverMerge() public { + address mallory = makeAddr("mallory"); + // Two attestors approve the honest tuple, two approve a conflicting one. + approveAs(0, 0, recipient, 5e12); + approveAs(1, 0, recipient, 5e12); + approveAs(2, 0, mallory, 5e12); + approveAs(3, 0, mallory, 5e12); + assertEq(pen.balanceOf(recipient), 0); + assertEq(pen.balanceOf(mallory), 0); + + // The honest tuple reaches threshold and wins; the nonce is consumed. + approveAs(4, 0, recipient, 5e12); + assertEq(pen.balanceOf(recipient), 5e18); + assertEq(pen.balanceOf(mallory), 0); + } + + // ---------------------------------------------------------------- caps + + function test_PerReleaseCapDefersUntilAdminRaisesIt() public { + // 2M PEN in pallet units converts to 2Me18 > perReleaseCap. + uint256 palletAmount = 2_000_000e12; + approveAs(0, 0, recipient, palletAmount); + approveAs(1, 0, recipient, palletAmount); + approveAs(2, 0, recipient, palletAmount); + assertEq(pen.balanceOf(recipient), 0, "capped release must be deferred, not executed"); + + vm.expectRevert( + abi.encodeWithSelector(MigrationVault.ExceedsPerReleaseCap.selector, 2_000_000e18, PER_RELEASE_CAP) + ); + vault.release(0, recipient, palletAmount); + + vm.prank(admin); + vault.setCaps(3_000_000e18, 3_000_000e18); + vault.release(0, recipient, palletAmount); + assertEq(pen.balanceOf(recipient), 2_000_000e18); + } + + function test_DailyCapRollsOverAtNextDay() public { + uint256 palletAmount = 1_000_000e12; // converts to exactly the per-release cap + + for (uint64 nonce = 0; nonce < 2; nonce++) { + approveAs(0, nonce, recipient, palletAmount); + approveAs(1, nonce, recipient, palletAmount); + approveAs(2, nonce, recipient, palletAmount); + } + // Both releases fit the daily cap exactly. + assertEq(pen.balanceOf(recipient), 2_000_000e18); + + // A third release today is deferred by the daily cap... + approveAs(0, 2, recipient, palletAmount); + approveAs(1, 2, recipient, palletAmount); + approveAs(2, 2, recipient, palletAmount); + assertEq(pen.balanceOf(recipient), 2_000_000e18); + vm.expectRevert( + abi.encodeWithSelector(MigrationVault.ExceedsDailyCap.selector, 3_000_000e18, DAILY_CAP) + ); + vault.release(2, recipient, palletAmount); + + // ...and anyone can retry it the next day. + vm.warp(block.timestamp + 1 days); + vault.release(2, recipient, palletAmount); + assertEq(pen.balanceOf(recipient), 3_000_000e18); + } + + // ---------------------------------------------------------------- pause + + function test_PauseBlocksReleasesButKeepsRecordingApprovals() public { + vm.prank(guardian); + vault.pause(); + + // Approvals are still recorded while paused (no re-attestation needed). + approveAs(0, 0, recipient, 5e12); + approveAs(1, 0, recipient, 5e12); + approveAs(2, 0, recipient, 5e12); + assertEq(pen.balanceOf(recipient), 0); + + vm.expectRevert(MigrationVault.EnforcedPause.selector); + vault.release(0, recipient, 5e12); + + // Guardian cannot unpause; only the admin can. + vm.prank(guardian); + vm.expectRevert(MigrationVault.NotAdmin.selector); + vault.unpause(); + + vm.prank(admin); + vault.unpause(); + vault.release(0, recipient, 5e12); + assertEq(pen.balanceOf(recipient), 5e18); + } + + function test_OnlyGuardianOrAdminCanPause() public { + vm.prank(makeAddr("mallory")); + vm.expectRevert(MigrationVault.NotGuardianOrAdmin.selector); + vault.pause(); + } + + // ---------------------------------------------------------------- attestor rotation + + function test_RemovedAttestorApprovalsStopCounting() public { + approveAs(0, 0, recipient, 5e12); + approveAs(1, 0, recipient, 5e12); + + // Attestor 0 turns out compromised and is removed: its approval must + // no longer count towards the threshold. + vm.prank(admin); + vault.removeAttestor(attestors[0]); + + approveAs(2, 0, recipient, 5e12); + assertEq(pen.balanceOf(recipient), 0, "only 2 active approvals remain"); + + // A replacement attestor is added and completes the quorum. + address replacement = makeAddr("replacement"); + vm.prank(admin); + vault.addAttestor(replacement); + vm.prank(replacement); + vault.approve(0, recipient, 5e12); + assertEq(pen.balanceOf(recipient), 5e18); + } + + function test_CannotRemoveAttestorBelowThreshold() public { + vm.startPrank(admin); + vault.removeAttestor(attestors[0]); + vault.removeAttestor(attestors[1]); + vm.expectRevert(MigrationVault.ThresholdWouldExceedAttestors.selector); + vault.removeAttestor(attestors[2]); + vm.stopPrank(); + } + + // ---------------------------------------------------------------- admin & sweep + + function test_AdminFunctionsRejectNonAdmin() public { + vm.startPrank(makeAddr("mallory")); + vm.expectRevert(MigrationVault.NotAdmin.selector); + vault.setCaps(1, 1); + vm.expectRevert(MigrationVault.NotAdmin.selector); + vault.addAttestor(makeAddr("x")); + vm.expectRevert(MigrationVault.NotAdmin.selector); + vault.sweepRemainder(makeAddr("x")); + vm.stopPrank(); + } + + function test_AdminTransferIsTwoStep() public { + address newAdmin = makeAddr("timelock"); + vm.prank(admin); + vault.transferAdmin(newAdmin); + assertEq(vault.admin(), admin, "no effect before acceptance"); + + vm.prank(newAdmin); + vault.acceptAdmin(); + assertEq(vault.admin(), newAdmin); + } + + function test_SweepOnlyAfterEarliestTimestamp() public { + address treasury = makeAddr("treasury"); + vm.prank(admin); + vm.expectRevert(abi.encodeWithSelector(MigrationVault.SweepNotYetAllowed.selector, earliestSweep)); + vault.sweepRemainder(treasury); + + vm.warp(earliestSweep); + vm.prank(admin); + vault.sweepRemainder(treasury); + assertEq(pen.balanceOf(treasury), MAX_ISSUANCE); + assertEq(pen.balanceOf(address(vault)), 0); + } + + // ---------------------------------------------------------------- fuzz + + function testFuzz_ReleasePreservesSupplyInvariant(uint64 nonce, uint96 palletAmount) public { + palletAmount = uint96(bound(palletAmount, 1, PER_RELEASE_CAP / CONVERSION_FACTOR)); + approveAs(0, nonce, recipient, palletAmount); + approveAs(1, nonce, recipient, palletAmount); + approveAs(2, nonce, recipient, palletAmount); + + assertEq(pen.balanceOf(recipient), uint256(palletAmount) * CONVERSION_FACTOR); + assertEq(pen.balanceOf(address(vault)) + vault.totalReleased(), pen.totalSupply()); + } +} diff --git a/contracts/test/PEN.t.sol b/contracts/test/PEN.t.sol new file mode 100644 index 000000000..8fc698db5 --- /dev/null +++ b/contracts/test/PEN.t.sol @@ -0,0 +1,76 @@ +// SPDX-License-Identifier: GPL-3.0-only +pragma solidity 0.8.26; + +import {Test} from "forge-std/Test.sol"; +import {PEN} from "../src/PEN.sol"; + +contract PENTest is Test { + // Placeholder until PRD decision D3 confirms the exact figure. + uint256 internal constant MAX_ISSUANCE = 160_000_000e18; + + PEN internal pen; + address internal vault = makeAddr("vault"); + address internal alice; + uint256 internal alicePk; + + function setUp() public { + (alice, alicePk) = makeAddrAndKey("alice"); + pen = new PEN(vault, MAX_ISSUANCE); + } + + function test_FullSupplyMintedToVault() public view { + assertEq(pen.totalSupply(), MAX_ISSUANCE); + assertEq(pen.balanceOf(vault), MAX_ISSUANCE); + assertEq(pen.decimals(), 18); + } + + function test_RevertWhen_ZeroVaultOrZeroIssuance() public { + vm.expectRevert(PEN.ZeroVault.selector); + new PEN(address(0), MAX_ISSUANCE); + vm.expectRevert(PEN.ZeroIssuance.selector); + new PEN(vault, 0); + } + + function test_ClockIsTimestampMode() public { + vm.warp(1_900_000_000); + assertEq(pen.clock(), uint48(1_900_000_000)); + assertEq(pen.CLOCK_MODE(), "mode=timestamp"); + } + + function test_PermitSetsAllowance() public { + address spender = makeAddr("spender"); + uint256 value = 123e18; + uint256 deadline = block.timestamp + 1 hours; + + bytes32 structHash = keccak256( + abi.encode( + keccak256("Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)"), + alice, + spender, + value, + pen.nonces(alice), + deadline + ) + ); + bytes32 digest = keccak256(abi.encodePacked("\x19\x01", pen.DOMAIN_SEPARATOR(), structHash)); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(alicePk, digest); + + pen.permit(alice, spender, value, deadline, v, r, s); + assertEq(pen.allowance(alice, spender), value); + } + + function test_VotesRequireDelegation() public { + vm.prank(vault); + pen.transfer(alice, 1_000e18); + + assertEq(pen.getVotes(alice), 0); + vm.prank(alice); + pen.delegate(alice); + assertEq(pen.getVotes(alice), 1_000e18); + + // Checkpoints are queryable by past timestamp (EIP-6372 timestamp mode). + uint256 before = block.timestamp; + vm.warp(before + 1 days); + assertEq(pen.getPastVotes(alice, before), 1_000e18); + } +} From f772c31199a7c1225bc4149cefd8b6517265f5ef Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 7 Jul 2026 19:39:53 +0200 Subject: [PATCH 04/23] runtime: Wire token-migration pallet into the Pendulum runtime 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. --- Cargo.lock | 1 + runtime/pendulum/Cargo.toml | 4 ++++ runtime/pendulum/src/lib.rs | 20 ++++++++++++++++++++ 3 files changed, 25 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index 630a03c27..e531dec37 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9029,6 +9029,7 @@ dependencies = [ "stellar-relay", "substrate-wasm-builder 5.0.0-dev (git+https://github.com/paritytech//polkadot-sdk?branch=release-polkadot-v1.6.0)", "token-chain-extension", + "token-migration", "treasury-buyout-extension", "vault-registry", "vesting-manager", diff --git a/runtime/pendulum/Cargo.toml b/runtime/pendulum/Cargo.toml index 4657b3d8e..cb52dab7e 100644 --- a/runtime/pendulum/Cargo.toml +++ b/runtime/pendulum/Cargo.toml @@ -121,6 +121,7 @@ dia-oracle-runtime-api = { workspace = true } # Pendulum Pallets vesting-manager = { path = "../../pallets/vesting-manager", default-features = false } pallet-xcm-teleport = { path = "../../pallets/xcm-teleport", default-features = false } +token-migration = { path = "../../pallets/token-migration", default-features = false } # Polkadot pallet-xcm = { workspace = true } @@ -257,6 +258,7 @@ std = [ "parachain-staking/std", "vesting-manager/std", "pallet-xcm-teleport/std", + "token-migration/std", "price-chain-extension/std", "token-chain-extension/std", "treasury-buyout-extension/std", @@ -333,6 +335,7 @@ runtime-benchmarks = [ "staking/runtime-benchmarks", "vesting-manager/runtime-benchmarks", "pallet-xcm-teleport/runtime-benchmarks", + "token-migration/runtime-benchmarks", ] try-runtime = [ @@ -390,6 +393,7 @@ try-runtime = [ "orml-currencies-allowance-extension/try-runtime", "vesting-manager/try-runtime", "pallet-xcm-teleport/try-runtime", + "token-migration/try-runtime", "bifrost-farming/try-runtime", "zenlink-protocol/try-runtime", "treasury-buyout-extension/try-runtime", diff --git a/runtime/pendulum/src/lib.rs b/runtime/pendulum/src/lib.rs index cc11b442a..251011120 100644 --- a/runtime/pendulum/src/lib.rs +++ b/runtime/pendulum/src/lib.rs @@ -372,6 +372,7 @@ impl Contains for BaseFilter { | RuntimeCall::CumulusXcm(_) | RuntimeCall::VaultStaking(_) | RuntimeCall::XcmTeleport(_) + | RuntimeCall::TokenMigration(_) | RuntimeCall::MessageQueue(_) => true, // All pallets are allowed, but exhaustive match is defensive // in the case of adding new pallets. } @@ -1116,6 +1117,23 @@ impl pallet_xcm_teleport::Config for Runtime { type TreasuryAccount = PendulumTreasuryAccount; } +parameter_types! { + // 1 PEN; keeps dust-sized migrations from spamming the attestor pipeline. + pub const MinimumMigrationAmount: Balance = UNIT; +} + +impl token_migration::Config for Runtime { + type RuntimeEvent = RuntimeEvent; + type Currency = Balances; + type MinimumMigrationAmount = MinimumMigrationAmount; + // Root/half-council, or 2/3 of the technical committee for fast incident response. + type PauseOrigin = EitherOfDiverse< + EnsureRootOrHalfCouncil, + pallet_collective::EnsureProportionAtLeast, + >; + type WeightInfo = token_migration::default_weights::SubstrateWeight; +} + const fn deposit(items: u32, bytes: u32) -> Balance { (items as Balance * UNIT + (bytes as Balance) * (5 * MILLIUNIT / 100)) / 10 } @@ -1692,6 +1710,8 @@ construct_runtime!( XcmTeleport: pallet_xcm_teleport = 101, + TokenMigration: token_migration = 102, + MessageQueue: pallet_message_queue = 110, } ); From d25ce9a63fcfd41b029d76ffc5760de4a746ba58 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 7 Jul 2026 19:39:53 +0200 Subject: [PATCH 05/23] contracts: Add governance stack and deployment scripts 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. --- contracts/script/Deploy.s.sol | 66 ++++++++++++++ contracts/script/DeployGovernance.s.sol | 59 +++++++++++++ contracts/src/PENGovernor.sol | 112 ++++++++++++++++++++++++ contracts/test/PENGovernor.t.sol | 107 ++++++++++++++++++++++ 4 files changed, 344 insertions(+) create mode 100644 contracts/script/Deploy.s.sol create mode 100644 contracts/script/DeployGovernance.s.sol create mode 100644 contracts/src/PENGovernor.sol create mode 100644 contracts/test/PENGovernor.t.sol diff --git a/contracts/script/Deploy.s.sol b/contracts/script/Deploy.s.sol new file mode 100644 index 000000000..256588f37 --- /dev/null +++ b/contracts/script/Deploy.s.sol @@ -0,0 +1,66 @@ +// SPDX-License-Identifier: GPL-3.0-only +pragma solidity 0.8.26; + +import {Script, console} from "forge-std/Script.sol"; +import {PEN} from "../src/PEN.sol"; +import {MigrationVault} from "../src/MigrationVault.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +/// @notice Deploys the migration stack on Base (PRD rollout phase 3): +/// 1. MigrationVault with the deployer as interim admin +/// 2. PEN, minting the full max issuance into the vault +/// 3. vault.setToken (verifies the vault holds 100% of supply) +/// 4. hands vault admin to the bootstrap Safe (two-step; the Safe +/// must call acceptAdmin() to complete) +/// +/// Environment: +/// ADMIN_SAFE bootstrap Safe that becomes vault admin +/// GUARDIAN_SAFE fast pause guardian +/// ATTESTOR_1..5 attestor transaction-sender addresses +/// MAX_ISSUANCE max issuance in 18-decimal units (decision D3) +/// PER_RELEASE_CAP initial per-release cap, 18-decimal units +/// DAILY_CAP initial daily cap, 18-decimal units +/// EARLIEST_SWEEP_TS unix timestamp before which no remainder sweep (D5) +contract Deploy is Script { + // 12-decimal pallet amounts -> 18-decimal token amounts (decision D2). + uint256 internal constant CONVERSION_FACTOR = 1e6; + uint256 internal constant THRESHOLD = 3; + + function run() external { + address adminSafe = vm.envAddress("ADMIN_SAFE"); + address guardianSafe = vm.envAddress("GUARDIAN_SAFE"); + uint256 maxIssuance = vm.envUint("MAX_ISSUANCE"); + uint256 perReleaseCap = vm.envUint("PER_RELEASE_CAP"); + uint256 dailyCap = vm.envUint("DAILY_CAP"); + uint256 earliestSweepTs = vm.envUint("EARLIEST_SWEEP_TS"); + + address[] memory attestors = new address[](5); + attestors[0] = vm.envAddress("ATTESTOR_1"); + attestors[1] = vm.envAddress("ATTESTOR_2"); + attestors[2] = vm.envAddress("ATTESTOR_3"); + attestors[3] = vm.envAddress("ATTESTOR_4"); + attestors[4] = vm.envAddress("ATTESTOR_5"); + + vm.startBroadcast(); + + MigrationVault vault = new MigrationVault( + msg.sender, // interim admin for setToken; handed over below + guardianSafe, + attestors, + THRESHOLD, + CONVERSION_FACTOR, + perReleaseCap, + dailyCap, + earliestSweepTs + ); + PEN pen = new PEN(address(vault), maxIssuance); + vault.setToken(IERC20(address(pen))); + vault.transferAdmin(adminSafe); + + vm.stopBroadcast(); + + console.log("PEN: ", address(pen)); + console.log("MigrationVault: ", address(vault)); + console.log("NEXT STEP: the bootstrap Safe must call vault.acceptAdmin()"); + } +} diff --git a/contracts/script/DeployGovernance.s.sol b/contracts/script/DeployGovernance.s.sol new file mode 100644 index 000000000..f84fdc3c7 --- /dev/null +++ b/contracts/script/DeployGovernance.s.sol @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: GPL-3.0-only +pragma solidity 0.8.26; + +import {Script, console} from "forge-std/Script.sol"; +import {TimelockController} from "@openzeppelin/contracts/governance/TimelockController.sol"; +import {PENGovernor} from "../src/PENGovernor.sol"; +import {IVotes} from "@openzeppelin/contracts/governance/utils/IVotes.sol"; + +/// @notice Deploys the on-chain governance stack (PRD rollout phase 5): +/// TimelockController + PENGovernor, wired so that only the Governor +/// can propose to the timelock and anyone can execute after the delay. +/// The deployer's temporary timelock admin role is renounced at the +/// end, leaving the timelock self-administered. +/// +/// After this script: transfer MigrationVault admin to the timelock +/// (bootstrap Safe calls vault.transferAdmin(timelock), then a +/// governance proposal calls vault.acceptAdmin()). +/// +/// Environment: +/// PEN_TOKEN deployed PEN address +/// TIMELOCK_DELAY seconds (PRD V5: >= 48h = 172800) +/// VOTING_DELAY seconds before voting starts (timestamp clock) +/// VOTING_PERIOD seconds of voting +/// PROPOSAL_THRESHOLD token units needed to propose +/// QUORUM_FRACTION percent of total supply (start low; vault balance +/// counts toward total supply, see PRD G1) +contract DeployGovernance is Script { + function run() external { + address token = vm.envAddress("PEN_TOKEN"); + uint256 timelockDelay = vm.envUint("TIMELOCK_DELAY"); + uint48 votingDelay = uint48(vm.envUint("VOTING_DELAY")); + uint32 votingPeriod = uint32(vm.envUint("VOTING_PERIOD")); + uint256 proposalThreshold = vm.envUint("PROPOSAL_THRESHOLD"); + uint256 quorumFraction = vm.envUint("QUORUM_FRACTION"); + + vm.startBroadcast(); + + // Deployer is temporary admin so the roles below can be wired. + address[] memory empty = new address[](0); + TimelockController timelock = + new TimelockController(timelockDelay, empty, empty, msg.sender); + + PENGovernor governor = new PENGovernor( + IVotes(token), timelock, votingDelay, votingPeriod, proposalThreshold, quorumFraction + ); + + // Only the Governor proposes/cancels; anyone may execute after the delay. + timelock.grantRole(timelock.PROPOSER_ROLE(), address(governor)); + timelock.grantRole(timelock.CANCELLER_ROLE(), address(governor)); + timelock.grantRole(timelock.EXECUTOR_ROLE(), address(0)); + // Leave the timelock self-administered: changes require a proposal. + timelock.renounceRole(timelock.DEFAULT_ADMIN_ROLE(), msg.sender); + + vm.stopBroadcast(); + + console.log("TimelockController: ", address(timelock)); + console.log("PENGovernor: ", address(governor)); + } +} diff --git a/contracts/src/PENGovernor.sol b/contracts/src/PENGovernor.sol new file mode 100644 index 000000000..4c4c43dec --- /dev/null +++ b/contracts/src/PENGovernor.sol @@ -0,0 +1,112 @@ +// SPDX-License-Identifier: GPL-3.0-only +pragma solidity 0.8.26; + +import {Governor} from "@openzeppelin/contracts/governance/Governor.sol"; +import {GovernorSettings} from "@openzeppelin/contracts/governance/extensions/GovernorSettings.sol"; +import {GovernorCountingSimple} from "@openzeppelin/contracts/governance/extensions/GovernorCountingSimple.sol"; +import {GovernorVotes} from "@openzeppelin/contracts/governance/extensions/GovernorVotes.sol"; +import {GovernorVotesQuorumFraction} from + "@openzeppelin/contracts/governance/extensions/GovernorVotesQuorumFraction.sol"; +import {GovernorTimelockControl} from "@openzeppelin/contracts/governance/extensions/GovernorTimelockControl.sol"; +import {TimelockController} from "@openzeppelin/contracts/governance/TimelockController.sol"; +import {IVotes} from "@openzeppelin/contracts/governance/utils/IVotes.sol"; + +/// @title PENGovernor — on-chain governance for the Base-side PEN contracts +/// @notice Standard OZ Governor composition (hybrid governance model, ADR-001): +/// token-holder votes execute through a TimelockController, which is +/// the admin of the MigrationVault and the treasury after the +/// bootstrap phase. PEN uses the EIP-6372 timestamp clock, so all +/// Governor periods below are in seconds. +/// +/// Quorum caveat (PRD G1): quorum is a fraction of *total* supply, +/// which includes the unmigrated balance held by the vault. Start +/// with a low fraction while migration is in progress and raise it +/// via governance as circulating supply grows. +contract PENGovernor is + Governor, + GovernorSettings, + GovernorCountingSimple, + GovernorVotes, + GovernorVotesQuorumFraction, + GovernorTimelockControl +{ + constructor( + IVotes token, + TimelockController timelock, + uint48 votingDelay_, // seconds (timestamp clock) + uint32 votingPeriod_, // seconds + uint256 proposalThreshold_, // token units + uint256 quorumFraction // percent of total supply + ) + Governor("PENGovernor") + GovernorSettings(votingDelay_, votingPeriod_, proposalThreshold_) + GovernorVotes(token) + GovernorVotesQuorumFraction(quorumFraction) + GovernorTimelockControl(timelock) + {} + + // ----- required overrides for the Governor composition ----- + + function votingDelay() public view override(Governor, GovernorSettings) returns (uint256) { + return super.votingDelay(); + } + + function votingPeriod() public view override(Governor, GovernorSettings) returns (uint256) { + return super.votingPeriod(); + } + + function proposalThreshold() public view override(Governor, GovernorSettings) returns (uint256) { + return super.proposalThreshold(); + } + + function state(uint256 proposalId) + public + view + override(Governor, GovernorTimelockControl) + returns (ProposalState) + { + return super.state(proposalId); + } + + function proposalNeedsQueuing(uint256 proposalId) + public + view + override(Governor, GovernorTimelockControl) + returns (bool) + { + return super.proposalNeedsQueuing(proposalId); + } + + function _queueOperations( + uint256 proposalId, + address[] memory targets, + uint256[] memory values, + bytes[] memory calldatas, + bytes32 descriptionHash + ) internal override(Governor, GovernorTimelockControl) returns (uint48) { + return super._queueOperations(proposalId, targets, values, calldatas, descriptionHash); + } + + function _executeOperations( + uint256 proposalId, + address[] memory targets, + uint256[] memory values, + bytes[] memory calldatas, + bytes32 descriptionHash + ) internal override(Governor, GovernorTimelockControl) { + super._executeOperations(proposalId, targets, values, calldatas, descriptionHash); + } + + function _cancel( + address[] memory targets, + uint256[] memory values, + bytes[] memory calldatas, + bytes32 descriptionHash + ) internal override(Governor, GovernorTimelockControl) returns (uint256) { + return super._cancel(targets, values, calldatas, descriptionHash); + } + + function _executor() internal view override(Governor, GovernorTimelockControl) returns (address) { + return super._executor(); + } +} diff --git a/contracts/test/PENGovernor.t.sol b/contracts/test/PENGovernor.t.sol new file mode 100644 index 000000000..efffe69ef --- /dev/null +++ b/contracts/test/PENGovernor.t.sol @@ -0,0 +1,107 @@ +// SPDX-License-Identifier: GPL-3.0-only +pragma solidity 0.8.26; + +import {Test} from "forge-std/Test.sol"; +import {TimelockController} from "@openzeppelin/contracts/governance/TimelockController.sol"; +import {IGovernor} from "@openzeppelin/contracts/governance/IGovernor.sol"; +import {IVotes} from "@openzeppelin/contracts/governance/utils/IVotes.sol"; +import {PEN} from "../src/PEN.sol"; +import {PENGovernor} from "../src/PENGovernor.sol"; +import {MigrationVault} from "../src/MigrationVault.sol"; + +contract PENGovernorTest is Test { + uint256 internal constant MAX_ISSUANCE = 160_000_000e18; + uint256 internal constant TIMELOCK_DELAY = 2 days; + uint48 internal constant VOTING_DELAY = 1 days; + uint32 internal constant VOTING_PERIOD = 5 days; + + PEN internal pen; + PENGovernor internal governor; + TimelockController internal timelock; + MigrationVault internal vault; + + address internal alice = makeAddr("alice"); + address internal guardian = makeAddr("guardian"); + + function setUp() public { + // Token held by alice directly so she has voting power without going + // through a migration flow; the vault under governance is separate. + pen = new PEN(alice, MAX_ISSUANCE); + + address[] memory empty = new address[](0); + timelock = new TimelockController(TIMELOCK_DELAY, empty, empty, address(this)); + + governor = new PENGovernor( + IVotes(address(pen)), timelock, VOTING_DELAY, VOTING_PERIOD, 1_000e18, 4 + ); + + timelock.grantRole(timelock.PROPOSER_ROLE(), address(governor)); + timelock.grantRole(timelock.CANCELLER_ROLE(), address(governor)); + timelock.grantRole(timelock.EXECUTOR_ROLE(), address(0)); + timelock.renounceRole(timelock.DEFAULT_ADMIN_ROLE(), address(this)); + + // A vault administered by the timelock, as after governance handover. + address[] memory attestors = new address[](3); + attestors[0] = makeAddr("a0"); + attestors[1] = makeAddr("a1"); + attestors[2] = makeAddr("a2"); + vault = new MigrationVault( + address(timelock), guardian, attestors, 2, 1e6, 1e24, 2e24, block.timestamp + 365 days + ); + + vm.prank(alice); + pen.delegate(alice); + // Advance the clock so the delegation checkpoint is in the past. + vm.warp(block.timestamp + 1); + } + + function test_TimelockIsSelfAdministered() public view { + assertFalse(timelock.hasRole(timelock.DEFAULT_ADMIN_ROLE(), address(this))); + assertTrue(timelock.hasRole(timelock.PROPOSER_ROLE(), address(governor))); + assertTrue(timelock.hasRole(timelock.EXECUTOR_ROLE(), address(0))); + } + + function test_GovernorUsesTimestampClock() public view { + assertEq(governor.clock(), uint48(block.timestamp)); + assertEq(governor.CLOCK_MODE(), "mode=timestamp"); + } + + function test_FullProposalLifecycle_SetVaultCaps() public { + address[] memory targets = new address[](1); + targets[0] = address(vault); + uint256[] memory values = new uint256[](1); + bytes[] memory calldatas = new bytes[](1); + calldatas[0] = abi.encodeCall(MigrationVault.setCaps, (5e24, 9e24)); + string memory description = "Raise migration vault caps"; + + vm.prank(alice); + uint256 proposalId = governor.propose(targets, values, calldatas, description); + + vm.warp(block.timestamp + VOTING_DELAY + 1); + vm.prank(alice); + governor.castVote(proposalId, 1); // For + + vm.warp(block.timestamp + VOTING_PERIOD + 1); + assertEq(uint256(governor.state(proposalId)), uint256(IGovernor.ProposalState.Succeeded)); + + governor.queue(targets, values, calldatas, keccak256(bytes(description))); + vm.warp(block.timestamp + TIMELOCK_DELAY + 1); + governor.execute(targets, values, calldatas, keccak256(bytes(description))); + + assertEq(vault.perReleaseCap(), 5e24); + assertEq(vault.dailyCap(), 9e24); + } + + function test_ProposalBelowThresholdReverts() public { + address pleb = makeAddr("pleb"); + address[] memory targets = new address[](1); + targets[0] = address(vault); + uint256[] memory values = new uint256[](1); + bytes[] memory calldatas = new bytes[](1); + calldatas[0] = abi.encodeCall(MigrationVault.setCaps, (1, 1)); + + vm.prank(pleb); + vm.expectRevert(); + governor.propose(targets, values, calldatas, "no voting power"); + } +} From 9b27a99b71ac42681982fd491b564b8f1d246ce9 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 7 Jul 2026 19:39:53 +0200 Subject: [PATCH 06/23] attestor: Add attestor daemon for the PEN migration 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. --- attestor/.gitignore | 3 + attestor/README.md | 76 +++ attestor/package-lock.json | 1123 ++++++++++++++++++++++++++++++++++++ attestor/package.json | 20 + attestor/src/config.ts | 31 + attestor/src/main.ts | 217 +++++++ attestor/src/vaultAbi.ts | 38 ++ attestor/tsconfig.json | 14 + 8 files changed, 1522 insertions(+) create mode 100644 attestor/.gitignore create mode 100644 attestor/README.md create mode 100644 attestor/package-lock.json create mode 100644 attestor/package.json create mode 100644 attestor/src/config.ts create mode 100644 attestor/src/main.ts create mode 100644 attestor/src/vaultAbi.ts create mode 100644 attestor/tsconfig.json diff --git a/attestor/.gitignore b/attestor/.gitignore new file mode 100644 index 000000000..9865bc15b --- /dev/null +++ b/attestor/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +dist/ +checkpoint.json diff --git a/attestor/README.md b/attestor/README.md new file mode 100644 index 000000000..1ac36df8f --- /dev/null +++ b/attestor/README.md @@ -0,0 +1,76 @@ +# PEN Migration Attestor + +Daemon run by each of the five independent attestor operators (PRD §6.4). +Watches **relay-finalized** blocks on the operator's **own** Pendulum node for +`tokenMigration.MigrationInitiated` events and submits the matching +`approve(nonce, recipient, palletAmount)` transaction to the MigrationVault on +Base. The vault releases the tokens on the 3rd matching approval; attestors +never communicate with each other — the contract is the only coordination +point. + +## Non-negotiable operational rules (PRD A1–A5) + +1. **Run your own Pendulum full node** and point `PENDULUM_WS` at it. Using a + public RPC means trusting that RPC with release authority. +2. **Key isolation:** the attestor key signs only vault `approve` calls. Keep + it in an HSM/KMS signer where possible; never reuse it elsewhere. The same + address pays gas — keep it funded with Base ETH (the daemon alerts below + `MIN_GAS_BALANCE_WEI`). +3. **Separate infrastructure per operator** — different hosting, different + credentials, nothing shared with other attestors or with the monitor. +4. The daemon **exits on any decode or processing error** instead of skipping + events. Run it under a process manager (systemd example below) and page a + human when it restart-loops: a stuck attestor on a runtime upgrade usually + means the metadata changed and the daemon needs updating. + +## Configuration (environment) + +| Variable | Meaning | +|---|---| +| `PENDULUM_WS` | WebSocket of your own Pendulum node, e.g. `ws://127.0.0.1:9944` | +| `BASE_RPC_URL` | Base JSON-RPC endpoint | +| `VAULT_ADDRESS` | MigrationVault address on Base | +| `ATTESTOR_PRIVATE_KEY` | This attestor's signing key (0x-prefixed) | +| `CHECKPOINT_FILE` | Path persisting the last processed block (default `./checkpoint.json`) | +| `START_BLOCK` | First Pendulum block to scan on the very first run | +| `MIN_GAS_BALANCE_WEI` | Low-gas alert threshold (default 0.01 ETH) | +| `ALERT_WEBHOOK_URL` | Optional webhook receiving JSON alerts | +| `BASE_CHAIN_ID` | Default 8453 (Base mainnet) | + +## Run + +```sh +npm install +npm run build +npm start +``` + +### systemd example + +```ini +[Unit] +Description=PEN migration attestor +After=network-online.target + +[Service] +EnvironmentFile=/etc/pen-attestor/env +WorkingDirectory=/opt/pen-attestor +ExecStart=/usr/bin/node dist/main.js +Restart=always +RestartSec=10 + +[Install] +WantedBy=multi-user.target +``` + +## Behavior details + +- Blocks are processed strictly in order; the checkpoint advances only after + every event in a block is handled. A crash re-processes at most one block — + safe, because approvals are idempotent (`nonceConsumed`/`hasApproved` are + checked first, and duplicate submissions revert harmlessly). +- The daemon verifies at startup that its address is in the vault's attestor + set and refuses to run otherwise. +- After a Pendulum **runtime upgrade**, verify event decoding against the new + metadata on a staging node before letting the fleet advance past the + upgrade block (see docs/pen-migration-runbooks.md). diff --git a/attestor/package-lock.json b/attestor/package-lock.json new file mode 100644 index 000000000..e2acb7104 --- /dev/null +++ b/attestor/package-lock.json @@ -0,0 +1,1123 @@ +{ + "name": "pen-migration-attestor", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "pen-migration-attestor", + "version": "0.1.0", + "dependencies": { + "@polkadot/api": "^11.3.1", + "viem": "^2.21.0" + }, + "devDependencies": { + "@types/node": "^20.14.0", + "typescript": "^5.5.0" + } + }, + "node_modules/@adraffy/ens-normalize": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@adraffy/ens-normalize/-/ens-normalize-1.11.1.tgz", + "integrity": "sha512-nhCBV3quEgesuf7c7KYfperqSS14T8bYuvJ8PcLJp6znkZpFc0AuW4qBtr8eKVyPPe/8RSr7sglCWPU5eaxwKQ==", + "license": "MIT" + }, + "node_modules/@noble/ciphers": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz", + "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/curves": { + "version": "1.9.7", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz", + "integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.8.0" + }, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@polkadot-api/json-rpc-provider": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/@polkadot-api/json-rpc-provider/-/json-rpc-provider-0.0.1.tgz", + "integrity": "sha512-/SMC/l7foRjpykLTUTacIH05H3mr9ip8b5xxfwXlVezXrNVLp3Cv0GX6uItkKd+ZjzVPf3PFrDF2B2/HLSNESA==", + "license": "MIT", + "optional": true + }, + "node_modules/@polkadot-api/json-rpc-provider-proxy": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/@polkadot-api/json-rpc-provider-proxy/-/json-rpc-provider-proxy-0.0.1.tgz", + "integrity": "sha512-gmVDUP8LpCH0BXewbzqXF2sdHddq1H1q+XrAW2of+KZj4woQkIGBRGTJHeBEVHe30EB+UejR1N2dT4PO/RvDdg==", + "license": "MIT", + "optional": true + }, + "node_modules/@polkadot-api/metadata-builders": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/@polkadot-api/metadata-builders/-/metadata-builders-0.0.1.tgz", + "integrity": "sha512-GCI78BHDzXAF/L2pZD6Aod/yl82adqQ7ftNmKg51ixRL02JpWUA+SpUKTJE5MY1p8kiJJIo09P2um24SiJHxNA==", + "license": "MIT", + "optional": true, + "dependencies": { + "@polkadot-api/substrate-bindings": "0.0.1", + "@polkadot-api/utils": "0.0.1" + } + }, + "node_modules/@polkadot-api/observable-client": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@polkadot-api/observable-client/-/observable-client-0.1.0.tgz", + "integrity": "sha512-GBCGDRztKorTLna/unjl/9SWZcRmvV58o9jwU2Y038VuPXZcr01jcw/1O3x+yeAuwyGzbucI/mLTDa1QoEml3A==", + "license": "MIT", + "optional": true, + "dependencies": { + "@polkadot-api/metadata-builders": "0.0.1", + "@polkadot-api/substrate-bindings": "0.0.1", + "@polkadot-api/substrate-client": "0.0.1", + "@polkadot-api/utils": "0.0.1" + }, + "peerDependencies": { + "rxjs": ">=7.8.0" + } + }, + "node_modules/@polkadot-api/substrate-bindings": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/@polkadot-api/substrate-bindings/-/substrate-bindings-0.0.1.tgz", + "integrity": "sha512-bAe7a5bOPnuFVmpv7y4BBMRpNTnMmE0jtTqRUw/+D8ZlEHNVEJQGr4wu3QQCl7k1GnSV1wfv3mzIbYjErEBocg==", + "license": "MIT", + "optional": true, + "dependencies": { + "@noble/hashes": "^1.3.1", + "@polkadot-api/utils": "0.0.1", + "@scure/base": "^1.1.1", + "scale-ts": "^1.6.0" + } + }, + "node_modules/@polkadot-api/substrate-client": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/@polkadot-api/substrate-client/-/substrate-client-0.0.1.tgz", + "integrity": "sha512-9Bg9SGc3AwE+wXONQoW8GC00N3v6lCZLW74HQzqB6ROdcm5VAHM4CB/xRzWSUF9CXL78ugiwtHx3wBcpx4H4Wg==", + "license": "MIT", + "optional": true + }, + "node_modules/@polkadot-api/utils": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/@polkadot-api/utils/-/utils-0.0.1.tgz", + "integrity": "sha512-3j+pRmlF9SgiYDabSdZsBSsN5XHbpXOAce1lWj56IEEaFZVjsiCaxDOA7C9nCcgfVXuvnbxqqEGQvnY+QfBAUw==", + "license": "MIT", + "optional": true + }, + "node_modules/@polkadot/api": { + "version": "11.3.1", + "resolved": "https://registry.npmjs.org/@polkadot/api/-/api-11.3.1.tgz", + "integrity": "sha512-q4kFIIHTLvKxM24b0Eo8hJevsPMme+aITJGrDML9BgdZYTRN14+cu5nXiCsQvaEamdyYj+uCXWe2OV9X7pPxsA==", + "license": "Apache-2.0", + "dependencies": { + "@polkadot/api-augment": "11.3.1", + "@polkadot/api-base": "11.3.1", + "@polkadot/api-derive": "11.3.1", + "@polkadot/keyring": "^12.6.2", + "@polkadot/rpc-augment": "11.3.1", + "@polkadot/rpc-core": "11.3.1", + "@polkadot/rpc-provider": "11.3.1", + "@polkadot/types": "11.3.1", + "@polkadot/types-augment": "11.3.1", + "@polkadot/types-codec": "11.3.1", + "@polkadot/types-create": "11.3.1", + "@polkadot/types-known": "11.3.1", + "@polkadot/util": "^12.6.2", + "@polkadot/util-crypto": "^12.6.2", + "eventemitter3": "^5.0.1", + "rxjs": "^7.8.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@polkadot/api-augment": { + "version": "11.3.1", + "resolved": "https://registry.npmjs.org/@polkadot/api-augment/-/api-augment-11.3.1.tgz", + "integrity": "sha512-Yj+6rb6h0WwY3yJ+UGhjGW+tyMRFUMsKQuGw+eFsXdjiNU9UoXsAqA2dG7Q1F+oeX/g+y2gLGBezNoCwbl6HfA==", + "license": "Apache-2.0", + "dependencies": { + "@polkadot/api-base": "11.3.1", + "@polkadot/rpc-augment": "11.3.1", + "@polkadot/types": "11.3.1", + "@polkadot/types-augment": "11.3.1", + "@polkadot/types-codec": "11.3.1", + "@polkadot/util": "^12.6.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@polkadot/api-base": { + "version": "11.3.1", + "resolved": "https://registry.npmjs.org/@polkadot/api-base/-/api-base-11.3.1.tgz", + "integrity": "sha512-b8UkNL00NN7+3QaLCwL5cKg+7YchHoKCAhwKusWHNBZkkO6Oo2BWilu0dZkPJOyqV9P389Kbd9+oH+SKs9u2VQ==", + "license": "Apache-2.0", + "dependencies": { + "@polkadot/rpc-core": "11.3.1", + "@polkadot/types": "11.3.1", + "@polkadot/util": "^12.6.2", + "rxjs": "^7.8.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@polkadot/api-derive": { + "version": "11.3.1", + "resolved": "https://registry.npmjs.org/@polkadot/api-derive/-/api-derive-11.3.1.tgz", + "integrity": "sha512-9dopzrh4cRuft1nANmBvMY/hEhFDu0VICMTOGxQLOl8NMfcOFPTLAN0JhSBUoicGZhV+c4vpv01NBx/7/IL1HA==", + "license": "Apache-2.0", + "dependencies": { + "@polkadot/api": "11.3.1", + "@polkadot/api-augment": "11.3.1", + "@polkadot/api-base": "11.3.1", + "@polkadot/rpc-core": "11.3.1", + "@polkadot/types": "11.3.1", + "@polkadot/types-codec": "11.3.1", + "@polkadot/util": "^12.6.2", + "@polkadot/util-crypto": "^12.6.2", + "rxjs": "^7.8.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@polkadot/keyring": { + "version": "12.6.2", + "resolved": "https://registry.npmjs.org/@polkadot/keyring/-/keyring-12.6.2.tgz", + "integrity": "sha512-O3Q7GVmRYm8q7HuB3S0+Yf/q/EB2egKRRU3fv9b3B7V+A52tKzA+vIwEmNVaD1g5FKW9oB97rmpggs0zaKFqHw==", + "license": "Apache-2.0", + "dependencies": { + "@polkadot/util": "12.6.2", + "@polkadot/util-crypto": "12.6.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@polkadot/util": "12.6.2", + "@polkadot/util-crypto": "12.6.2" + } + }, + "node_modules/@polkadot/networks": { + "version": "12.6.2", + "resolved": "https://registry.npmjs.org/@polkadot/networks/-/networks-12.6.2.tgz", + "integrity": "sha512-1oWtZm1IvPWqvMrldVH6NI2gBoCndl5GEwx7lAuQWGr7eNL+6Bdc5K3Z9T0MzFvDGoi2/CBqjX9dRKo39pDC/w==", + "license": "Apache-2.0", + "dependencies": { + "@polkadot/util": "12.6.2", + "@substrate/ss58-registry": "^1.44.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@polkadot/rpc-augment": { + "version": "11.3.1", + "resolved": "https://registry.npmjs.org/@polkadot/rpc-augment/-/rpc-augment-11.3.1.tgz", + "integrity": "sha512-2PaDcKNju4QYQpxwVkWbRU3M0t340nMX9cMo+8awgvgL1LliV/fUDZueMKLuSS910JJMTPQ7y2pK4eQgMt08gQ==", + "license": "Apache-2.0", + "dependencies": { + "@polkadot/rpc-core": "11.3.1", + "@polkadot/types": "11.3.1", + "@polkadot/types-codec": "11.3.1", + "@polkadot/util": "^12.6.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@polkadot/rpc-core": { + "version": "11.3.1", + "resolved": "https://registry.npmjs.org/@polkadot/rpc-core/-/rpc-core-11.3.1.tgz", + "integrity": "sha512-KKNepsDd/mpmXcA6v/h14eFFPEzLGd7nrvx2UUXUxoZ0Fq2MH1hplP3s93k1oduNY/vOXJR2K9S4dKManA6GVQ==", + "license": "Apache-2.0", + "dependencies": { + "@polkadot/rpc-augment": "11.3.1", + "@polkadot/rpc-provider": "11.3.1", + "@polkadot/types": "11.3.1", + "@polkadot/util": "^12.6.2", + "rxjs": "^7.8.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@polkadot/rpc-provider": { + "version": "11.3.1", + "resolved": "https://registry.npmjs.org/@polkadot/rpc-provider/-/rpc-provider-11.3.1.tgz", + "integrity": "sha512-pqERChoHo45hd3WAgW8UuzarRF+G/o/eXEbl0PXLubiayw4X4qCmIzmtntUcKYgxGNcYGZaG87ZU8OjN97m6UA==", + "license": "Apache-2.0", + "dependencies": { + "@polkadot/keyring": "^12.6.2", + "@polkadot/types": "11.3.1", + "@polkadot/types-support": "11.3.1", + "@polkadot/util": "^12.6.2", + "@polkadot/util-crypto": "^12.6.2", + "@polkadot/x-fetch": "^12.6.2", + "@polkadot/x-global": "^12.6.2", + "@polkadot/x-ws": "^12.6.2", + "eventemitter3": "^5.0.1", + "mock-socket": "^9.3.1", + "nock": "^13.5.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@substrate/connect": "0.8.10" + } + }, + "node_modules/@polkadot/types": { + "version": "11.3.1", + "resolved": "https://registry.npmjs.org/@polkadot/types/-/types-11.3.1.tgz", + "integrity": "sha512-5c7uRFXQTT11Awi6T0yFIdAfD6xGDAOz06Kp7M5S9OGNZY28wSPk5x6BYfNphWPaIBmHHewYJB5qmnrdYQAWKQ==", + "license": "Apache-2.0", + "dependencies": { + "@polkadot/keyring": "^12.6.2", + "@polkadot/types-augment": "11.3.1", + "@polkadot/types-codec": "11.3.1", + "@polkadot/types-create": "11.3.1", + "@polkadot/util": "^12.6.2", + "@polkadot/util-crypto": "^12.6.2", + "rxjs": "^7.8.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@polkadot/types-augment": { + "version": "11.3.1", + "resolved": "https://registry.npmjs.org/@polkadot/types-augment/-/types-augment-11.3.1.tgz", + "integrity": "sha512-eR3HVpvUmB3v7q2jTWVmVfAVfb1/kuNn7ij94Zqadg/fuUq0pKqIOKwkUj3OxRM3A/5BnW3MbgparjKD3r+fyw==", + "license": "Apache-2.0", + "dependencies": { + "@polkadot/types": "11.3.1", + "@polkadot/types-codec": "11.3.1", + "@polkadot/util": "^12.6.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@polkadot/types-codec": { + "version": "11.3.1", + "resolved": "https://registry.npmjs.org/@polkadot/types-codec/-/types-codec-11.3.1.tgz", + "integrity": "sha512-i7IiiuuL+Z/jFoKTA9xeh4wGQnhnNNjMT0+1ohvlOvnFsoKZKFQQOaDPPntGJVL1JDCV+KjkN2uQKZSeW8tguQ==", + "license": "Apache-2.0", + "dependencies": { + "@polkadot/util": "^12.6.2", + "@polkadot/x-bigint": "^12.6.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@polkadot/types-create": { + "version": "11.3.1", + "resolved": "https://registry.npmjs.org/@polkadot/types-create/-/types-create-11.3.1.tgz", + "integrity": "sha512-pBXtpz5FehcRJ6j5MzFUIUN8ZWM7z6HbqK1GxBmYbJVRElcGcOg7a/rL2pQVphU0Rx1E8bSO4thzGf4wUxSX7w==", + "license": "Apache-2.0", + "dependencies": { + "@polkadot/types-codec": "11.3.1", + "@polkadot/util": "^12.6.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@polkadot/types-known": { + "version": "11.3.1", + "resolved": "https://registry.npmjs.org/@polkadot/types-known/-/types-known-11.3.1.tgz", + "integrity": "sha512-3BIof7u6tn9bk3ZCIxA07iNoQ3uj4+vn3DTOjCKECozkRlt6V+kWRvqh16Hc0SHMg/QjcMb2fIu/WZhka1McUQ==", + "license": "Apache-2.0", + "dependencies": { + "@polkadot/networks": "^12.6.2", + "@polkadot/types": "11.3.1", + "@polkadot/types-codec": "11.3.1", + "@polkadot/types-create": "11.3.1", + "@polkadot/util": "^12.6.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@polkadot/types-support": { + "version": "11.3.1", + "resolved": "https://registry.npmjs.org/@polkadot/types-support/-/types-support-11.3.1.tgz", + "integrity": "sha512-jTFz1GKyF7nI29yIOq4v0NiWTOf5yX4HahJNeFD8TcxoLhF+6tH/XXqrUXJEfbaTlSrRWiW1LZYlb+snctqKHA==", + "license": "Apache-2.0", + "dependencies": { + "@polkadot/util": "^12.6.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@polkadot/util": { + "version": "12.6.2", + "resolved": "https://registry.npmjs.org/@polkadot/util/-/util-12.6.2.tgz", + "integrity": "sha512-l8TubR7CLEY47240uki0TQzFvtnxFIO7uI/0GoWzpYD/O62EIAMRsuY01N4DuwgKq2ZWD59WhzsLYmA5K6ksdw==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@polkadot/x-bigint": "12.6.2", + "@polkadot/x-global": "12.6.2", + "@polkadot/x-textdecoder": "12.6.2", + "@polkadot/x-textencoder": "12.6.2", + "@types/bn.js": "^5.1.5", + "bn.js": "^5.2.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@polkadot/util-crypto": { + "version": "12.6.2", + "resolved": "https://registry.npmjs.org/@polkadot/util-crypto/-/util-crypto-12.6.2.tgz", + "integrity": "sha512-FEWI/dJ7wDMNN1WOzZAjQoIcCP/3vz3wvAp5QQm+lOrzOLj0iDmaIGIcBkz8HVm3ErfSe/uKP0KS4jgV/ib+Mg==", + "license": "Apache-2.0", + "dependencies": { + "@noble/curves": "^1.3.0", + "@noble/hashes": "^1.3.3", + "@polkadot/networks": "12.6.2", + "@polkadot/util": "12.6.2", + "@polkadot/wasm-crypto": "^7.3.2", + "@polkadot/wasm-util": "^7.3.2", + "@polkadot/x-bigint": "12.6.2", + "@polkadot/x-randomvalues": "12.6.2", + "@scure/base": "^1.1.5", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@polkadot/util": "12.6.2" + } + }, + "node_modules/@polkadot/wasm-bridge": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/@polkadot/wasm-bridge/-/wasm-bridge-7.5.4.tgz", + "integrity": "sha512-6xaJVvoZbnbgpQYXNw9OHVNWjXmtcoPcWh7hlwx3NpfiLkkjljj99YS+XGZQlq7ks2fVCg7FbfknkNb8PldDaA==", + "license": "Apache-2.0", + "dependencies": { + "@polkadot/wasm-util": "7.5.4", + "tslib": "^2.7.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@polkadot/util": "*", + "@polkadot/x-randomvalues": "*" + } + }, + "node_modules/@polkadot/wasm-crypto": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/@polkadot/wasm-crypto/-/wasm-crypto-7.5.4.tgz", + "integrity": "sha512-1seyClxa7Jd7kQjfnCzTTTfYhTa/KUTDUaD3DMHBk5Q4ZUN1D1unJgX+v1aUeXSPxmzocdZETPJJRZjhVOqg9g==", + "license": "Apache-2.0", + "dependencies": { + "@polkadot/wasm-bridge": "7.5.4", + "@polkadot/wasm-crypto-asmjs": "7.5.4", + "@polkadot/wasm-crypto-init": "7.5.4", + "@polkadot/wasm-crypto-wasm": "7.5.4", + "@polkadot/wasm-util": "7.5.4", + "tslib": "^2.7.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@polkadot/util": "*", + "@polkadot/x-randomvalues": "*" + } + }, + "node_modules/@polkadot/wasm-crypto-asmjs": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/@polkadot/wasm-crypto-asmjs/-/wasm-crypto-asmjs-7.5.4.tgz", + "integrity": "sha512-ZYwxQHAJ8pPt6kYk9XFmyuFuSS+yirJLonvP+DYbxOrARRUHfN4nzp4zcZNXUuaFhpbDobDSFn6gYzye6BUotA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.7.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@polkadot/util": "*" + } + }, + "node_modules/@polkadot/wasm-crypto-init": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/@polkadot/wasm-crypto-init/-/wasm-crypto-init-7.5.4.tgz", + "integrity": "sha512-U6s4Eo2rHs2n1iR01vTz/sOQ7eOnRPjaCsGWhPV+ZC/20hkVzwPAhiizu/IqMEol4tO2yiSheD4D6bn0KxUJhg==", + "license": "Apache-2.0", + "dependencies": { + "@polkadot/wasm-bridge": "7.5.4", + "@polkadot/wasm-crypto-asmjs": "7.5.4", + "@polkadot/wasm-crypto-wasm": "7.5.4", + "@polkadot/wasm-util": "7.5.4", + "tslib": "^2.7.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@polkadot/util": "*", + "@polkadot/x-randomvalues": "*" + } + }, + "node_modules/@polkadot/wasm-crypto-wasm": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/@polkadot/wasm-crypto-wasm/-/wasm-crypto-wasm-7.5.4.tgz", + "integrity": "sha512-PsHgLsVTu43eprwSvUGnxybtOEuHPES6AbApcs7y5ZbM2PiDMzYbAjNul098xJK/CPtrxZ0ePDFnaQBmIJyTFw==", + "license": "Apache-2.0", + "dependencies": { + "@polkadot/wasm-util": "7.5.4", + "tslib": "^2.7.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@polkadot/util": "*" + } + }, + "node_modules/@polkadot/wasm-util": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/@polkadot/wasm-util/-/wasm-util-7.5.4.tgz", + "integrity": "sha512-hqPpfhCpRAqCIn/CYbBluhh0TXmwkJnDRjxrU9Bnqtw9nMNa97D8JuOjdd2pi0rxm+eeLQ/f1rQMp71RMM9t4w==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.7.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@polkadot/util": "*" + } + }, + "node_modules/@polkadot/x-bigint": { + "version": "12.6.2", + "resolved": "https://registry.npmjs.org/@polkadot/x-bigint/-/x-bigint-12.6.2.tgz", + "integrity": "sha512-HSIk60uFPX4GOFZSnIF7VYJz7WZA7tpFJsne7SzxOooRwMTWEtw3fUpFy5cYYOeLh17/kHH1Y7SVcuxzVLc74Q==", + "license": "Apache-2.0", + "dependencies": { + "@polkadot/x-global": "12.6.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@polkadot/x-fetch": { + "version": "12.6.2", + "resolved": "https://registry.npmjs.org/@polkadot/x-fetch/-/x-fetch-12.6.2.tgz", + "integrity": "sha512-8wM/Z9JJPWN1pzSpU7XxTI1ldj/AfC8hKioBlUahZ8gUiJaOF7K9XEFCrCDLis/A1BoOu7Ne6WMx/vsJJIbDWw==", + "license": "Apache-2.0", + "dependencies": { + "@polkadot/x-global": "12.6.2", + "node-fetch": "^3.3.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@polkadot/x-global": { + "version": "12.6.2", + "resolved": "https://registry.npmjs.org/@polkadot/x-global/-/x-global-12.6.2.tgz", + "integrity": "sha512-a8d6m+PW98jmsYDtAWp88qS4dl8DyqUBsd0S+WgyfSMtpEXu6v9nXDgPZgwF5xdDvXhm+P0ZfVkVTnIGrScb5g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@polkadot/x-randomvalues": { + "version": "12.6.2", + "resolved": "https://registry.npmjs.org/@polkadot/x-randomvalues/-/x-randomvalues-12.6.2.tgz", + "integrity": "sha512-Vr8uG7rH2IcNJwtyf5ebdODMcr0XjoCpUbI91Zv6AlKVYOGKZlKLYJHIwpTaKKB+7KPWyQrk4Mlym/rS7v9feg==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@polkadot/x-global": "12.6.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@polkadot/util": "12.6.2", + "@polkadot/wasm-util": "*" + } + }, + "node_modules/@polkadot/x-textdecoder": { + "version": "12.6.2", + "resolved": "https://registry.npmjs.org/@polkadot/x-textdecoder/-/x-textdecoder-12.6.2.tgz", + "integrity": "sha512-M1Bir7tYvNappfpFWXOJcnxUhBUFWkUFIdJSyH0zs5LmFtFdbKAeiDXxSp2Swp5ddOZdZgPac294/o2TnQKN1w==", + "license": "Apache-2.0", + "dependencies": { + "@polkadot/x-global": "12.6.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@polkadot/x-textencoder": { + "version": "12.6.2", + "resolved": "https://registry.npmjs.org/@polkadot/x-textencoder/-/x-textencoder-12.6.2.tgz", + "integrity": "sha512-4N+3UVCpI489tUJ6cv3uf0PjOHvgGp9Dl+SZRLgFGt9mvxnvpW/7+XBADRMtlG4xi5gaRK7bgl5bmY6OMDsNdw==", + "license": "Apache-2.0", + "dependencies": { + "@polkadot/x-global": "12.6.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@polkadot/x-ws": { + "version": "12.6.2", + "resolved": "https://registry.npmjs.org/@polkadot/x-ws/-/x-ws-12.6.2.tgz", + "integrity": "sha512-cGZWo7K5eRRQCRl2LrcyCYsrc3lRbTlixZh3AzgU8uX4wASVGRlNWi/Hf4TtHNe1ExCDmxabJzdIsABIfrr7xw==", + "license": "Apache-2.0", + "dependencies": { + "@polkadot/x-global": "12.6.2", + "tslib": "^2.6.2", + "ws": "^8.15.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@scure/base": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.2.6.tgz", + "integrity": "sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip32": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-1.7.0.tgz", + "integrity": "sha512-E4FFX/N3f4B80AKWp5dP6ow+flD1LQZo/w8UnLGYZO674jS6YnYeepycOOksv+vLPSpgN35wgKgy+ybfTb2SMw==", + "license": "MIT", + "dependencies": { + "@noble/curves": "~1.9.0", + "@noble/hashes": "~1.8.0", + "@scure/base": "~1.2.5" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip39": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.6.0.tgz", + "integrity": "sha512-+lF0BbLiJNwVlev4eKelw1WWLaiKXw7sSl8T6FvBlWkdX+94aGJ4o8XjUdlyhTCjd8c+B3KT3JfS8P0bLRNU6A==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "~1.8.0", + "@scure/base": "~1.2.5" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@substrate/connect": { + "version": "0.8.10", + "resolved": "https://registry.npmjs.org/@substrate/connect/-/connect-0.8.10.tgz", + "integrity": "sha512-DIyQ13DDlXqVFnLV+S6/JDgiGowVRRrh18kahieJxhgvzcWicw5eLc6jpfQ0moVVLBYkO7rctB5Wreldwpva8w==", + "deprecated": "versions below 1.x are no longer maintained", + "license": "GPL-3.0-only", + "optional": true, + "dependencies": { + "@substrate/connect-extension-protocol": "^2.0.0", + "@substrate/connect-known-chains": "^1.1.4", + "@substrate/light-client-extension-helpers": "^0.0.6", + "smoldot": "2.0.22" + } + }, + "node_modules/@substrate/connect-extension-protocol": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/@substrate/connect-extension-protocol/-/connect-extension-protocol-2.2.2.tgz", + "integrity": "sha512-t66jwrXA0s5Goq82ZtjagLNd7DPGCNjHeehRlE/gcJmJ+G56C0W+2plqOMRicJ8XGR1/YFnUSEqUFiSNbjGrAA==", + "license": "GPL-3.0-only", + "optional": true + }, + "node_modules/@substrate/connect-known-chains": { + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/@substrate/connect-known-chains/-/connect-known-chains-1.10.3.tgz", + "integrity": "sha512-OJEZO1Pagtb6bNE3wCikc2wrmvEU5x7GxFFLqqbz1AJYYxSlrPCGu4N2og5YTExo4IcloNMQYFRkBGue0BKZ4w==", + "license": "GPL-3.0-only", + "optional": true + }, + "node_modules/@substrate/light-client-extension-helpers": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@substrate/light-client-extension-helpers/-/light-client-extension-helpers-0.0.6.tgz", + "integrity": "sha512-girltEuxQ1BvkJWmc8JJlk4ZxnlGXc/wkLcNguhY+UoDEMBK0LsdtfzQKIfrIehi4QdeSBlFEFBoI4RqPmsZzA==", + "license": "MIT", + "optional": true, + "dependencies": { + "@polkadot-api/json-rpc-provider": "0.0.1", + "@polkadot-api/json-rpc-provider-proxy": "0.0.1", + "@polkadot-api/observable-client": "0.1.0", + "@polkadot-api/substrate-client": "0.0.1", + "@substrate/connect-extension-protocol": "^2.0.0", + "@substrate/connect-known-chains": "^1.1.4", + "rxjs": "^7.8.1" + }, + "peerDependencies": { + "smoldot": "2.x" + } + }, + "node_modules/@substrate/ss58-registry": { + "version": "1.51.0", + "resolved": "https://registry.npmjs.org/@substrate/ss58-registry/-/ss58-registry-1.51.0.tgz", + "integrity": "sha512-TWDurLiPxndFgKjVavCniytBIw+t4ViOi7TYp9h/D0NMmkEc9klFTo+827eyEJ0lELpqO207Ey7uGxUa+BS1jQ==", + "license": "Apache-2.0" + }, + "node_modules/@types/bn.js": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@types/bn.js/-/bn.js-5.2.0.tgz", + "integrity": "sha512-DLbJ1BPqxvQhIGbeu8VbUC1DiAiahHtAYvA0ZEAa4P31F7IaArc8z3C3BRQdWX4mtLQuABG4yzp76ZrS02Ui1Q==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/node": { + "version": "20.19.43", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", + "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/abitype": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/abitype/-/abitype-1.2.3.tgz", + "integrity": "sha512-Ofer5QUnuUdTFsBRwARMoWKOH1ND5ehwYhJ3OJ/BQO+StkwQjHw0XyVh4vDttzHB7QOFhPHa/o413PJ82gU/Tg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/wevm" + }, + "peerDependencies": { + "typescript": ">=5.0.4", + "zod": "^3.22.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/bn.js": { + "version": "5.2.4", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.4.tgz", + "integrity": "sha512-QL7sb18rJ1PbdsKsqPA0guxL563vIMwRHgzNrW/uzQuRGN1Cjqd/wonUBAVqHox9KwzHA6vCbM0lXx3k4iQMow==", + "license": "MIT" + }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/isows": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/isows/-/isows-1.0.7.tgz", + "integrity": "sha512-I1fSfDCZL5P0v33sVqeTDSpcstAg/N+wF5HS033mogOVIp4B+oHC7oOCsA3axAbBSGTJ8QubbNmnIRN/h8U7hg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/wevm" + } + ], + "license": "MIT", + "peerDependencies": { + "ws": "*" + } + }, + "node_modules/json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", + "license": "ISC" + }, + "node_modules/mock-socket": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/mock-socket/-/mock-socket-9.3.1.tgz", + "integrity": "sha512-qxBgB7Qa2sEQgHFjj0dSigq7fX4k6Saisd5Nelwp2q8mlbAFh5dHV9JTTlF8viYJLSSWgMCZFUom8PJcMNBoJw==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nock": { + "version": "13.5.6", + "resolved": "https://registry.npmjs.org/nock/-/nock-13.5.6.tgz", + "integrity": "sha512-o2zOYiCpzRqSzPj0Zt/dQ/DqZeYoaQ7TUonc/xUPjCGl9WeHpNbxgVvOquXYAaJzI0M9BXV3HTzG0p8IUAbBTQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.1.0", + "json-stringify-safe": "^5.0.1", + "propagate": "^2.0.0" + }, + "engines": { + "node": ">= 10.13" + } + }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/ox": { + "version": "0.14.30", + "resolved": "https://registry.npmjs.org/ox/-/ox-0.14.30.tgz", + "integrity": "sha512-LI11uu+8iiM1B3CLckgd++YF1a0A2k5wDoM9ZeQMiL21BOzQs6L//BLS6hb1HSEKCyycdDIQLsVQx9MjpcC0hA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/wevm" + } + ], + "license": "MIT", + "dependencies": { + "@adraffy/ens-normalize": "^1.11.0", + "@noble/ciphers": "^1.3.0", + "@noble/curves": "1.9.1", + "@noble/hashes": "^1.8.0", + "@scure/bip32": "^1.7.0", + "@scure/bip39": "^1.6.0", + "abitype": "^1.2.3", + "eventemitter3": "5.0.1" + }, + "peerDependencies": { + "typescript": ">=5.4.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/ox/node_modules/@noble/curves": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.1.tgz", + "integrity": "sha512-k11yZxZg+t+gWvBbIswW0yoJlu8cHOC7dhunwOzoWH/mXGBiYyR4YY6hAEK/3EUs4UpB8la1RfdRpeGsFHkWsA==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.8.0" + }, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/ox/node_modules/eventemitter3": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", + "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", + "license": "MIT" + }, + "node_modules/propagate": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/propagate/-/propagate-2.0.1.tgz", + "integrity": "sha512-vGrhOavPSTz4QVNuBNdcNXePNdNMaO1xj9yBeH1ScQPjk/rhg9sSlCXPhMkFuaNNW/syTvYqsnbIJxMBfRbbag==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/scale-ts": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/scale-ts/-/scale-ts-1.6.1.tgz", + "integrity": "sha512-PBMc2AWc6wSEqJYBDPcyCLUj9/tMKnLX70jLOSndMtcUoLQucP/DM0vnQo1wJAYjTrQiq8iG9rD0q6wFzgjH7g==", + "license": "MIT", + "optional": true + }, + "node_modules/smoldot": { + "version": "2.0.22", + "resolved": "https://registry.npmjs.org/smoldot/-/smoldot-2.0.22.tgz", + "integrity": "sha512-B50vRgTY6v3baYH6uCgL15tfaag5tcS2o/P5q1OiXcKGv1axZDfz2dzzMuIkVpyMR2ug11F6EAtQlmYBQd292g==", + "license": "GPL-3.0-or-later WITH Classpath-exception-2.0", + "optional": true, + "peer": true, + "dependencies": { + "ws": "^8.8.1" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "license": "Apache-2.0", + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" + }, + "node_modules/viem": { + "version": "2.54.6", + "resolved": "https://registry.npmjs.org/viem/-/viem-2.54.6.tgz", + "integrity": "sha512-OfybECKJYVmhiNqz+SHhed+O2h6niQ+0Wjg9J0b4bV+/QrvLgjxhfKO7hZqsuK1YtZ/0BErBKy708Zp+cU5T0Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/wevm" + } + ], + "license": "MIT", + "dependencies": { + "@noble/curves": "1.9.1", + "@noble/hashes": "1.8.0", + "@scure/bip32": "1.7.0", + "@scure/bip39": "1.6.0", + "abitype": "1.2.3", + "isows": "1.0.7", + "ox": "0.14.30", + "ws": "8.21.0" + }, + "peerDependencies": { + "typescript": ">=5.0.4" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/viem/node_modules/@noble/curves": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.1.tgz", + "integrity": "sha512-k11yZxZg+t+gWvBbIswW0yoJlu8cHOC7dhunwOzoWH/mXGBiYyR4YY6hAEK/3EUs4UpB8la1RfdRpeGsFHkWsA==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.8.0" + }, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/attestor/package.json b/attestor/package.json new file mode 100644 index 000000000..470c999e8 --- /dev/null +++ b/attestor/package.json @@ -0,0 +1,20 @@ +{ + "name": "pen-migration-attestor", + "version": "0.1.0", + "private": true, + "description": "Attestor daemon for the PEN migration: watches finalized MigrationInitiated events on Pendulum and submits on-chain approvals to the MigrationVault on Base", + "type": "module", + "scripts": { + "build": "tsc", + "start": "node dist/main.js", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@polkadot/api": "^11.3.1", + "viem": "^2.21.0" + }, + "devDependencies": { + "@types/node": "^20.14.0", + "typescript": "^5.5.0" + } +} diff --git a/attestor/src/config.ts b/attestor/src/config.ts new file mode 100644 index 000000000..deb81d6b1 --- /dev/null +++ b/attestor/src/config.ts @@ -0,0 +1,31 @@ +function required(name: string): string { + const value = process.env[name]; + if (!value) { + throw new Error(`Missing required environment variable ${name}`); + } + return value; +} + +export const config = { + /** WebSocket endpoint of THIS OPERATOR'S OWN Pendulum full node (PRD A1). + * Never point this at a public RPC: the attestor would inherit its honesty. */ + pendulumWs: required("PENDULUM_WS"), + /** Base JSON-RPC endpoint. */ + baseRpcUrl: required("BASE_RPC_URL"), + /** MigrationVault contract address on Base. */ + vaultAddress: required("VAULT_ADDRESS") as `0x${string}`, + /** This attestor's transaction-signing key (0x-prefixed, 32 bytes). + * Isolate per operator; fund with Base ETH for gas (PRD A3). */ + attestorPrivateKey: required("ATTESTOR_PRIVATE_KEY") as `0x${string}`, + /** File persisting the last fully processed finalized block (PRD A2). */ + checkpointFile: process.env.CHECKPOINT_FILE ?? "./checkpoint.json", + /** Pendulum block to start from on the very first run (the block of the + * runtime upgrade that added the token-migration pallet). */ + startBlock: Number(process.env.START_BLOCK ?? "0"), + /** Alert when the gas wallet drops below this balance (wei). */ + minGasBalanceWei: BigInt(process.env.MIN_GAS_BALANCE_WEI ?? "10000000000000000"), // 0.01 ETH + /** Optional webhook that receives JSON alerts (low gas, fatal errors). */ + alertWebhookUrl: process.env.ALERT_WEBHOOK_URL, + /** Base chain id: 8453 mainnet. */ + baseChainId: Number(process.env.BASE_CHAIN_ID ?? "8453"), +}; diff --git a/attestor/src/main.ts b/attestor/src/main.ts new file mode 100644 index 000000000..27ddc0b49 --- /dev/null +++ b/attestor/src/main.ts @@ -0,0 +1,217 @@ +/** + * PEN migration attestor daemon (PRD §6.4). + * + * Watches RELAY-FINALIZED blocks on the operator's own Pendulum node for + * `tokenMigration.MigrationInitiated` events and submits the matching + * `approve(nonce, recipient, palletAmount)` transaction to the MigrationVault + * on Base. The vault releases the tokens on the threshold-th approval. + * + * Design invariants: + * - Only finalized blocks are read; blocks are processed strictly in order. + * - The checkpoint file is advanced only after every event in a block has + * been handled, so a crash re-processes at most one block (idempotent: + * duplicate approvals revert harmlessly and are skipped by the pre-check). + * - A decode failure is FATAL by design (PRD A5): the daemon alerts and + * exits rather than silently skipping an event; the checkpoint keeps the + * failing block next in line for after the operator intervenes. + */ + +import { readFileSync, writeFileSync } from "node:fs"; +import { ApiPromise, WsProvider } from "@polkadot/api"; +import { + createPublicClient, + createWalletClient, + defineChain, + encodeAbiParameters, + http, + keccak256, +} from "viem"; +import { privateKeyToAccount } from "viem/accounts"; +import { config } from "./config.js"; +import { vaultAbi } from "./vaultAbi.js"; + +interface Checkpoint { + lastProcessedBlock: number; +} + +interface MigrationEvent { + nonce: bigint; + recipient: `0x${string}`; + palletAmount: bigint; +} + +const baseChain = defineChain({ + id: config.baseChainId, + name: "base", + nativeCurrency: { name: "Ether", symbol: "ETH", decimals: 18 }, + rpcUrls: { default: { http: [config.baseRpcUrl] } }, +}); + +const account = privateKeyToAccount(config.attestorPrivateKey); +const publicClient = createPublicClient({ chain: baseChain, transport: http(config.baseRpcUrl) }); +const walletClient = createWalletClient({ + account, + chain: baseChain, + transport: http(config.baseRpcUrl), +}); + +function log(message: string, extra?: unknown): void { + console.log(`${new Date().toISOString()} ${message}`, extra ?? ""); +} + +async function alert(subject: string, detail: unknown): Promise { + console.error(`${new Date().toISOString()} ALERT: ${subject}`, detail); + if (!config.alertWebhookUrl) return; + try { + await fetch(config.alertWebhookUrl, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ service: "pen-attestor", attestor: account.address, subject, detail: `${detail}` }), + }); + } catch (webhookError) { + console.error("alert webhook failed", webhookError); + } +} + +function loadCheckpoint(): Checkpoint { + try { + return JSON.parse(readFileSync(config.checkpointFile, "utf8")) as Checkpoint; + } catch { + return { lastProcessedBlock: config.startBlock - 1 }; + } +} + +function saveCheckpoint(checkpoint: Checkpoint): void { + writeFileSync(config.checkpointFile, JSON.stringify(checkpoint)); +} + +function payloadHash(event: MigrationEvent): `0x${string}` { + return keccak256( + encodeAbiParameters( + [{ type: "uint64" }, { type: "address" }, { type: "uint256" }], + [event.nonce, event.recipient, event.palletAmount], + ), + ); +} + +/** Extract MigrationInitiated events from one finalized Pendulum block. */ +async function migrationEventsInBlock(api: ApiPromise, blockNumber: number): Promise { + const blockHash = await api.rpc.chain.getBlockHash(blockNumber); + const apiAt = await api.at(blockHash); + const records = (await apiAt.query.system.events()) as unknown as { + event: { section: string; method: string; data: unknown[] }; + }[]; + + const events: MigrationEvent[] = []; + for (const record of records) { + const { section, method, data } = record.event; + if (section !== "tokenMigration" || method !== "MigrationInitiated") continue; + // Event shape: { nonce: u64, who: AccountId, base_address: H160, amount: u128 } + const [nonce, , baseAddress, amount] = data as [ + { toBigInt(): bigint }, + unknown, + { toHex(): string }, + { toBigInt(): bigint }, + ]; + const recipient = baseAddress.toHex() as `0x${string}`; + if (!/^0x[0-9a-fA-F]{40}$/.test(recipient)) { + throw new Error(`cannot decode base_address in block ${blockNumber}: ${recipient}`); + } + events.push({ nonce: nonce.toBigInt(), recipient, palletAmount: amount.toBigInt() }); + } + return events; +} + +/** Submit the approval for one migration event, skipping work already done. */ +async function approve(event: MigrationEvent): Promise { + const label = `nonce=${event.nonce} recipient=${event.recipient} amount=${event.palletAmount}`; + + const consumed = await publicClient.readContract({ + address: config.vaultAddress, + abi: vaultAbi, + functionName: "nonceConsumed", + args: [event.nonce], + }); + if (consumed) { + log(`skip (already released): ${label}`); + return; + } + + const alreadyApproved = await publicClient.readContract({ + address: config.vaultAddress, + abi: vaultAbi, + functionName: "hasApproved", + args: [payloadHash(event), account.address], + }); + if (alreadyApproved) { + log(`skip (already approved by us): ${label}`); + return; + } + + // simulate first: turns approvals raced by other paths into clean skips + const { request } = await publicClient.simulateContract({ + account, + address: config.vaultAddress, + abi: vaultAbi, + functionName: "approve", + args: [event.nonce, event.recipient, event.palletAmount], + }); + const txHash = await walletClient.writeContract(request); + const receipt = await publicClient.waitForTransactionReceipt({ hash: txHash }); + if (receipt.status !== "success") { + throw new Error(`approve transaction reverted: ${txHash} (${label})`); + } + log(`approved: ${label} tx=${txHash}`); +} + +async function checkGasBalance(): Promise { + const balance = await publicClient.getBalance({ address: account.address }); + if (balance < config.minGasBalanceWei) { + await alert("gas balance low", `${account.address} holds ${balance} wei`); + } +} + +async function main(): Promise { + const isAttestor = await publicClient.readContract({ + address: config.vaultAddress, + abi: vaultAbi, + functionName: "isAttestor", + args: [account.address], + }); + if (!isAttestor) { + throw new Error(`${account.address} is not an attestor of ${config.vaultAddress}`); + } + await checkGasBalance(); + + const api = await ApiPromise.create({ provider: new WsProvider(config.pendulumWs) }); + const checkpoint = loadCheckpoint(); + log(`attestor ${account.address} starting after block ${checkpoint.lastProcessedBlock}`); + + let processing = Promise.resolve(); + await api.rpc.chain.subscribeFinalizedHeads((head) => { + const finalized = head.number.toNumber(); + // Serialize: a slow Base transaction must not let block processing overlap. + processing = processing.then(async () => { + for (let block = checkpoint.lastProcessedBlock + 1; block <= finalized; block++) { + const events = await migrationEventsInBlock(api, block); + for (const event of events) { + await approve(event); + } + checkpoint.lastProcessedBlock = block; + saveCheckpoint(checkpoint); + } + }).catch(async (error) => { + // PRD A5: never skip an event silently. Alert and exit; the process + // manager restarts us and the checkpoint retries the failing block. + await alert("fatal error, exiting", error); + process.exit(1); + }); + }); + + setInterval(() => void checkGasBalance().catch(() => {}), 10 * 60 * 1000); +} + +main().catch(async (error) => { + await alert("startup failed", error); + process.exit(1); +}); diff --git a/attestor/src/vaultAbi.ts b/attestor/src/vaultAbi.ts new file mode 100644 index 000000000..4ea200986 --- /dev/null +++ b/attestor/src/vaultAbi.ts @@ -0,0 +1,38 @@ +/** Minimal MigrationVault ABI: only what the attestor needs. */ +export const vaultAbi = [ + { + type: "function", + name: "approve", + stateMutability: "nonpayable", + inputs: [ + { name: "nonce", type: "uint64" }, + { name: "recipient", type: "address" }, + { name: "palletAmount", type: "uint256" }, + ], + outputs: [], + }, + { + type: "function", + name: "nonceConsumed", + stateMutability: "view", + inputs: [{ name: "nonce", type: "uint64" }], + outputs: [{ type: "bool" }], + }, + { + type: "function", + name: "hasApproved", + stateMutability: "view", + inputs: [ + { name: "payload", type: "bytes32" }, + { name: "attestor", type: "address" }, + ], + outputs: [{ type: "bool" }], + }, + { + type: "function", + name: "isAttestor", + stateMutability: "view", + inputs: [{ name: "attestor", type: "address" }], + outputs: [{ type: "bool" }], + }, +] as const; diff --git a/attestor/tsconfig.json b/attestor/tsconfig.json new file mode 100644 index 000000000..59b02f6e5 --- /dev/null +++ b/attestor/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src"] +} From db66f0dbcd8733f57edc306c95adef5f7f967687 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 7 Jul 2026 19:39:54 +0200 Subject: [PATCH 07/23] monitor: Add independent invariant monitor for the PEN migration 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. --- monitor/.gitignore | 2 + monitor/README.md | 39 ++ monitor/package-lock.json | 1123 +++++++++++++++++++++++++++++++++++++ monitor/package.json | 20 + monitor/src/main.ts | 208 +++++++ monitor/tsconfig.json | 14 + 6 files changed, 1406 insertions(+) create mode 100644 monitor/.gitignore create mode 100644 monitor/README.md create mode 100644 monitor/package-lock.json create mode 100644 monitor/package.json create mode 100644 monitor/src/main.ts create mode 100644 monitor/tsconfig.json diff --git a/monitor/.gitignore b/monitor/.gitignore new file mode 100644 index 000000000..b94707787 --- /dev/null +++ b/monitor/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +dist/ diff --git a/monitor/README.md b/monitor/README.md new file mode 100644 index 000000000..6d13fc240 --- /dev/null +++ b/monitor/README.md @@ -0,0 +1,39 @@ +# PEN Migration Invariant Monitor + +Independent watchdog for the PEN migration (PRD §6.5). **Must run on +infrastructure separate from every attestor** — its whole value is being an +independent pair of eyes on both chains. + +Checks every poll: + +| Check | Meaning | Reaction | +|---|---|---| +| M2a: `totalReleased <= TotalMigrated × conversionFactor` | Tokens may never leave the vault without a corresponding finalized burn on Pendulum. A violation is the signature of attestor-quorum compromise. | Alert + auto-pause the vault (if `GUARDIAN_PRIVATE_KEY` is set) | +| M2b: `balanceOf(vault) + totalReleased == totalSupply` | Vault-internal conservation. | Alert + auto-pause | +| M4: every nonce older than `GRACE_SECONDS` is consumed on Base | Liveness of the attestor fleet (outage, cap deferral, pause). | Alert | + +## Configuration (environment) + +| Variable | Meaning | +|---|---| +| `PENDULUM_WS` | WebSocket of the monitor's own Pendulum node | +| `BASE_RPC_URL` | Base JSON-RPC endpoint (ideally a different provider than the attestors use) | +| `VAULT_ADDRESS` | MigrationVault address on Base | +| `POLL_INTERVAL_MS` | Poll cadence (default 60s) | +| `GRACE_SECONDS` | Liveness alert threshold (default 30 min) | +| `ALERT_WEBHOOK_URL` | Webhook receiving JSON alerts — wire this to paging | +| `GUARDIAN_PRIVATE_KEY` | Optional: a guardian key enabling automatic `pause()` on conservation violations | +| `BASE_CHAIN_ID` | Default 8453 | + +## Run + +```sh +npm install +npm run build +npm start +``` + +Run it under a process manager and treat "monitor down" itself as a paging +condition: an unwatched migration is the risk model failing silently. Note the +liveness state (`nonceFirstSeen`) is in-memory — after a restart, grace timers +restart from zero, which can only delay (never lose) a liveness alert. diff --git a/monitor/package-lock.json b/monitor/package-lock.json new file mode 100644 index 000000000..a48e1d8d5 --- /dev/null +++ b/monitor/package-lock.json @@ -0,0 +1,1123 @@ +{ + "name": "pen-migration-monitor", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "pen-migration-monitor", + "version": "0.1.0", + "dependencies": { + "@polkadot/api": "^11.3.1", + "viem": "^2.21.0" + }, + "devDependencies": { + "@types/node": "^20.14.0", + "typescript": "^5.5.0" + } + }, + "node_modules/@adraffy/ens-normalize": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@adraffy/ens-normalize/-/ens-normalize-1.11.1.tgz", + "integrity": "sha512-nhCBV3quEgesuf7c7KYfperqSS14T8bYuvJ8PcLJp6znkZpFc0AuW4qBtr8eKVyPPe/8RSr7sglCWPU5eaxwKQ==", + "license": "MIT" + }, + "node_modules/@noble/ciphers": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz", + "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/curves": { + "version": "1.9.7", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz", + "integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.8.0" + }, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@polkadot-api/json-rpc-provider": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/@polkadot-api/json-rpc-provider/-/json-rpc-provider-0.0.1.tgz", + "integrity": "sha512-/SMC/l7foRjpykLTUTacIH05H3mr9ip8b5xxfwXlVezXrNVLp3Cv0GX6uItkKd+ZjzVPf3PFrDF2B2/HLSNESA==", + "license": "MIT", + "optional": true + }, + "node_modules/@polkadot-api/json-rpc-provider-proxy": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/@polkadot-api/json-rpc-provider-proxy/-/json-rpc-provider-proxy-0.0.1.tgz", + "integrity": "sha512-gmVDUP8LpCH0BXewbzqXF2sdHddq1H1q+XrAW2of+KZj4woQkIGBRGTJHeBEVHe30EB+UejR1N2dT4PO/RvDdg==", + "license": "MIT", + "optional": true + }, + "node_modules/@polkadot-api/metadata-builders": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/@polkadot-api/metadata-builders/-/metadata-builders-0.0.1.tgz", + "integrity": "sha512-GCI78BHDzXAF/L2pZD6Aod/yl82adqQ7ftNmKg51ixRL02JpWUA+SpUKTJE5MY1p8kiJJIo09P2um24SiJHxNA==", + "license": "MIT", + "optional": true, + "dependencies": { + "@polkadot-api/substrate-bindings": "0.0.1", + "@polkadot-api/utils": "0.0.1" + } + }, + "node_modules/@polkadot-api/observable-client": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@polkadot-api/observable-client/-/observable-client-0.1.0.tgz", + "integrity": "sha512-GBCGDRztKorTLna/unjl/9SWZcRmvV58o9jwU2Y038VuPXZcr01jcw/1O3x+yeAuwyGzbucI/mLTDa1QoEml3A==", + "license": "MIT", + "optional": true, + "dependencies": { + "@polkadot-api/metadata-builders": "0.0.1", + "@polkadot-api/substrate-bindings": "0.0.1", + "@polkadot-api/substrate-client": "0.0.1", + "@polkadot-api/utils": "0.0.1" + }, + "peerDependencies": { + "rxjs": ">=7.8.0" + } + }, + "node_modules/@polkadot-api/substrate-bindings": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/@polkadot-api/substrate-bindings/-/substrate-bindings-0.0.1.tgz", + "integrity": "sha512-bAe7a5bOPnuFVmpv7y4BBMRpNTnMmE0jtTqRUw/+D8ZlEHNVEJQGr4wu3QQCl7k1GnSV1wfv3mzIbYjErEBocg==", + "license": "MIT", + "optional": true, + "dependencies": { + "@noble/hashes": "^1.3.1", + "@polkadot-api/utils": "0.0.1", + "@scure/base": "^1.1.1", + "scale-ts": "^1.6.0" + } + }, + "node_modules/@polkadot-api/substrate-client": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/@polkadot-api/substrate-client/-/substrate-client-0.0.1.tgz", + "integrity": "sha512-9Bg9SGc3AwE+wXONQoW8GC00N3v6lCZLW74HQzqB6ROdcm5VAHM4CB/xRzWSUF9CXL78ugiwtHx3wBcpx4H4Wg==", + "license": "MIT", + "optional": true + }, + "node_modules/@polkadot-api/utils": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/@polkadot-api/utils/-/utils-0.0.1.tgz", + "integrity": "sha512-3j+pRmlF9SgiYDabSdZsBSsN5XHbpXOAce1lWj56IEEaFZVjsiCaxDOA7C9nCcgfVXuvnbxqqEGQvnY+QfBAUw==", + "license": "MIT", + "optional": true + }, + "node_modules/@polkadot/api": { + "version": "11.3.1", + "resolved": "https://registry.npmjs.org/@polkadot/api/-/api-11.3.1.tgz", + "integrity": "sha512-q4kFIIHTLvKxM24b0Eo8hJevsPMme+aITJGrDML9BgdZYTRN14+cu5nXiCsQvaEamdyYj+uCXWe2OV9X7pPxsA==", + "license": "Apache-2.0", + "dependencies": { + "@polkadot/api-augment": "11.3.1", + "@polkadot/api-base": "11.3.1", + "@polkadot/api-derive": "11.3.1", + "@polkadot/keyring": "^12.6.2", + "@polkadot/rpc-augment": "11.3.1", + "@polkadot/rpc-core": "11.3.1", + "@polkadot/rpc-provider": "11.3.1", + "@polkadot/types": "11.3.1", + "@polkadot/types-augment": "11.3.1", + "@polkadot/types-codec": "11.3.1", + "@polkadot/types-create": "11.3.1", + "@polkadot/types-known": "11.3.1", + "@polkadot/util": "^12.6.2", + "@polkadot/util-crypto": "^12.6.2", + "eventemitter3": "^5.0.1", + "rxjs": "^7.8.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@polkadot/api-augment": { + "version": "11.3.1", + "resolved": "https://registry.npmjs.org/@polkadot/api-augment/-/api-augment-11.3.1.tgz", + "integrity": "sha512-Yj+6rb6h0WwY3yJ+UGhjGW+tyMRFUMsKQuGw+eFsXdjiNU9UoXsAqA2dG7Q1F+oeX/g+y2gLGBezNoCwbl6HfA==", + "license": "Apache-2.0", + "dependencies": { + "@polkadot/api-base": "11.3.1", + "@polkadot/rpc-augment": "11.3.1", + "@polkadot/types": "11.3.1", + "@polkadot/types-augment": "11.3.1", + "@polkadot/types-codec": "11.3.1", + "@polkadot/util": "^12.6.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@polkadot/api-base": { + "version": "11.3.1", + "resolved": "https://registry.npmjs.org/@polkadot/api-base/-/api-base-11.3.1.tgz", + "integrity": "sha512-b8UkNL00NN7+3QaLCwL5cKg+7YchHoKCAhwKusWHNBZkkO6Oo2BWilu0dZkPJOyqV9P389Kbd9+oH+SKs9u2VQ==", + "license": "Apache-2.0", + "dependencies": { + "@polkadot/rpc-core": "11.3.1", + "@polkadot/types": "11.3.1", + "@polkadot/util": "^12.6.2", + "rxjs": "^7.8.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@polkadot/api-derive": { + "version": "11.3.1", + "resolved": "https://registry.npmjs.org/@polkadot/api-derive/-/api-derive-11.3.1.tgz", + "integrity": "sha512-9dopzrh4cRuft1nANmBvMY/hEhFDu0VICMTOGxQLOl8NMfcOFPTLAN0JhSBUoicGZhV+c4vpv01NBx/7/IL1HA==", + "license": "Apache-2.0", + "dependencies": { + "@polkadot/api": "11.3.1", + "@polkadot/api-augment": "11.3.1", + "@polkadot/api-base": "11.3.1", + "@polkadot/rpc-core": "11.3.1", + "@polkadot/types": "11.3.1", + "@polkadot/types-codec": "11.3.1", + "@polkadot/util": "^12.6.2", + "@polkadot/util-crypto": "^12.6.2", + "rxjs": "^7.8.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@polkadot/keyring": { + "version": "12.6.2", + "resolved": "https://registry.npmjs.org/@polkadot/keyring/-/keyring-12.6.2.tgz", + "integrity": "sha512-O3Q7GVmRYm8q7HuB3S0+Yf/q/EB2egKRRU3fv9b3B7V+A52tKzA+vIwEmNVaD1g5FKW9oB97rmpggs0zaKFqHw==", + "license": "Apache-2.0", + "dependencies": { + "@polkadot/util": "12.6.2", + "@polkadot/util-crypto": "12.6.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@polkadot/util": "12.6.2", + "@polkadot/util-crypto": "12.6.2" + } + }, + "node_modules/@polkadot/networks": { + "version": "12.6.2", + "resolved": "https://registry.npmjs.org/@polkadot/networks/-/networks-12.6.2.tgz", + "integrity": "sha512-1oWtZm1IvPWqvMrldVH6NI2gBoCndl5GEwx7lAuQWGr7eNL+6Bdc5K3Z9T0MzFvDGoi2/CBqjX9dRKo39pDC/w==", + "license": "Apache-2.0", + "dependencies": { + "@polkadot/util": "12.6.2", + "@substrate/ss58-registry": "^1.44.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@polkadot/rpc-augment": { + "version": "11.3.1", + "resolved": "https://registry.npmjs.org/@polkadot/rpc-augment/-/rpc-augment-11.3.1.tgz", + "integrity": "sha512-2PaDcKNju4QYQpxwVkWbRU3M0t340nMX9cMo+8awgvgL1LliV/fUDZueMKLuSS910JJMTPQ7y2pK4eQgMt08gQ==", + "license": "Apache-2.0", + "dependencies": { + "@polkadot/rpc-core": "11.3.1", + "@polkadot/types": "11.3.1", + "@polkadot/types-codec": "11.3.1", + "@polkadot/util": "^12.6.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@polkadot/rpc-core": { + "version": "11.3.1", + "resolved": "https://registry.npmjs.org/@polkadot/rpc-core/-/rpc-core-11.3.1.tgz", + "integrity": "sha512-KKNepsDd/mpmXcA6v/h14eFFPEzLGd7nrvx2UUXUxoZ0Fq2MH1hplP3s93k1oduNY/vOXJR2K9S4dKManA6GVQ==", + "license": "Apache-2.0", + "dependencies": { + "@polkadot/rpc-augment": "11.3.1", + "@polkadot/rpc-provider": "11.3.1", + "@polkadot/types": "11.3.1", + "@polkadot/util": "^12.6.2", + "rxjs": "^7.8.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@polkadot/rpc-provider": { + "version": "11.3.1", + "resolved": "https://registry.npmjs.org/@polkadot/rpc-provider/-/rpc-provider-11.3.1.tgz", + "integrity": "sha512-pqERChoHo45hd3WAgW8UuzarRF+G/o/eXEbl0PXLubiayw4X4qCmIzmtntUcKYgxGNcYGZaG87ZU8OjN97m6UA==", + "license": "Apache-2.0", + "dependencies": { + "@polkadot/keyring": "^12.6.2", + "@polkadot/types": "11.3.1", + "@polkadot/types-support": "11.3.1", + "@polkadot/util": "^12.6.2", + "@polkadot/util-crypto": "^12.6.2", + "@polkadot/x-fetch": "^12.6.2", + "@polkadot/x-global": "^12.6.2", + "@polkadot/x-ws": "^12.6.2", + "eventemitter3": "^5.0.1", + "mock-socket": "^9.3.1", + "nock": "^13.5.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@substrate/connect": "0.8.10" + } + }, + "node_modules/@polkadot/types": { + "version": "11.3.1", + "resolved": "https://registry.npmjs.org/@polkadot/types/-/types-11.3.1.tgz", + "integrity": "sha512-5c7uRFXQTT11Awi6T0yFIdAfD6xGDAOz06Kp7M5S9OGNZY28wSPk5x6BYfNphWPaIBmHHewYJB5qmnrdYQAWKQ==", + "license": "Apache-2.0", + "dependencies": { + "@polkadot/keyring": "^12.6.2", + "@polkadot/types-augment": "11.3.1", + "@polkadot/types-codec": "11.3.1", + "@polkadot/types-create": "11.3.1", + "@polkadot/util": "^12.6.2", + "@polkadot/util-crypto": "^12.6.2", + "rxjs": "^7.8.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@polkadot/types-augment": { + "version": "11.3.1", + "resolved": "https://registry.npmjs.org/@polkadot/types-augment/-/types-augment-11.3.1.tgz", + "integrity": "sha512-eR3HVpvUmB3v7q2jTWVmVfAVfb1/kuNn7ij94Zqadg/fuUq0pKqIOKwkUj3OxRM3A/5BnW3MbgparjKD3r+fyw==", + "license": "Apache-2.0", + "dependencies": { + "@polkadot/types": "11.3.1", + "@polkadot/types-codec": "11.3.1", + "@polkadot/util": "^12.6.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@polkadot/types-codec": { + "version": "11.3.1", + "resolved": "https://registry.npmjs.org/@polkadot/types-codec/-/types-codec-11.3.1.tgz", + "integrity": "sha512-i7IiiuuL+Z/jFoKTA9xeh4wGQnhnNNjMT0+1ohvlOvnFsoKZKFQQOaDPPntGJVL1JDCV+KjkN2uQKZSeW8tguQ==", + "license": "Apache-2.0", + "dependencies": { + "@polkadot/util": "^12.6.2", + "@polkadot/x-bigint": "^12.6.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@polkadot/types-create": { + "version": "11.3.1", + "resolved": "https://registry.npmjs.org/@polkadot/types-create/-/types-create-11.3.1.tgz", + "integrity": "sha512-pBXtpz5FehcRJ6j5MzFUIUN8ZWM7z6HbqK1GxBmYbJVRElcGcOg7a/rL2pQVphU0Rx1E8bSO4thzGf4wUxSX7w==", + "license": "Apache-2.0", + "dependencies": { + "@polkadot/types-codec": "11.3.1", + "@polkadot/util": "^12.6.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@polkadot/types-known": { + "version": "11.3.1", + "resolved": "https://registry.npmjs.org/@polkadot/types-known/-/types-known-11.3.1.tgz", + "integrity": "sha512-3BIof7u6tn9bk3ZCIxA07iNoQ3uj4+vn3DTOjCKECozkRlt6V+kWRvqh16Hc0SHMg/QjcMb2fIu/WZhka1McUQ==", + "license": "Apache-2.0", + "dependencies": { + "@polkadot/networks": "^12.6.2", + "@polkadot/types": "11.3.1", + "@polkadot/types-codec": "11.3.1", + "@polkadot/types-create": "11.3.1", + "@polkadot/util": "^12.6.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@polkadot/types-support": { + "version": "11.3.1", + "resolved": "https://registry.npmjs.org/@polkadot/types-support/-/types-support-11.3.1.tgz", + "integrity": "sha512-jTFz1GKyF7nI29yIOq4v0NiWTOf5yX4HahJNeFD8TcxoLhF+6tH/XXqrUXJEfbaTlSrRWiW1LZYlb+snctqKHA==", + "license": "Apache-2.0", + "dependencies": { + "@polkadot/util": "^12.6.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@polkadot/util": { + "version": "12.6.2", + "resolved": "https://registry.npmjs.org/@polkadot/util/-/util-12.6.2.tgz", + "integrity": "sha512-l8TubR7CLEY47240uki0TQzFvtnxFIO7uI/0GoWzpYD/O62EIAMRsuY01N4DuwgKq2ZWD59WhzsLYmA5K6ksdw==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@polkadot/x-bigint": "12.6.2", + "@polkadot/x-global": "12.6.2", + "@polkadot/x-textdecoder": "12.6.2", + "@polkadot/x-textencoder": "12.6.2", + "@types/bn.js": "^5.1.5", + "bn.js": "^5.2.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@polkadot/util-crypto": { + "version": "12.6.2", + "resolved": "https://registry.npmjs.org/@polkadot/util-crypto/-/util-crypto-12.6.2.tgz", + "integrity": "sha512-FEWI/dJ7wDMNN1WOzZAjQoIcCP/3vz3wvAp5QQm+lOrzOLj0iDmaIGIcBkz8HVm3ErfSe/uKP0KS4jgV/ib+Mg==", + "license": "Apache-2.0", + "dependencies": { + "@noble/curves": "^1.3.0", + "@noble/hashes": "^1.3.3", + "@polkadot/networks": "12.6.2", + "@polkadot/util": "12.6.2", + "@polkadot/wasm-crypto": "^7.3.2", + "@polkadot/wasm-util": "^7.3.2", + "@polkadot/x-bigint": "12.6.2", + "@polkadot/x-randomvalues": "12.6.2", + "@scure/base": "^1.1.5", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@polkadot/util": "12.6.2" + } + }, + "node_modules/@polkadot/wasm-bridge": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/@polkadot/wasm-bridge/-/wasm-bridge-7.5.4.tgz", + "integrity": "sha512-6xaJVvoZbnbgpQYXNw9OHVNWjXmtcoPcWh7hlwx3NpfiLkkjljj99YS+XGZQlq7ks2fVCg7FbfknkNb8PldDaA==", + "license": "Apache-2.0", + "dependencies": { + "@polkadot/wasm-util": "7.5.4", + "tslib": "^2.7.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@polkadot/util": "*", + "@polkadot/x-randomvalues": "*" + } + }, + "node_modules/@polkadot/wasm-crypto": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/@polkadot/wasm-crypto/-/wasm-crypto-7.5.4.tgz", + "integrity": "sha512-1seyClxa7Jd7kQjfnCzTTTfYhTa/KUTDUaD3DMHBk5Q4ZUN1D1unJgX+v1aUeXSPxmzocdZETPJJRZjhVOqg9g==", + "license": "Apache-2.0", + "dependencies": { + "@polkadot/wasm-bridge": "7.5.4", + "@polkadot/wasm-crypto-asmjs": "7.5.4", + "@polkadot/wasm-crypto-init": "7.5.4", + "@polkadot/wasm-crypto-wasm": "7.5.4", + "@polkadot/wasm-util": "7.5.4", + "tslib": "^2.7.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@polkadot/util": "*", + "@polkadot/x-randomvalues": "*" + } + }, + "node_modules/@polkadot/wasm-crypto-asmjs": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/@polkadot/wasm-crypto-asmjs/-/wasm-crypto-asmjs-7.5.4.tgz", + "integrity": "sha512-ZYwxQHAJ8pPt6kYk9XFmyuFuSS+yirJLonvP+DYbxOrARRUHfN4nzp4zcZNXUuaFhpbDobDSFn6gYzye6BUotA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.7.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@polkadot/util": "*" + } + }, + "node_modules/@polkadot/wasm-crypto-init": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/@polkadot/wasm-crypto-init/-/wasm-crypto-init-7.5.4.tgz", + "integrity": "sha512-U6s4Eo2rHs2n1iR01vTz/sOQ7eOnRPjaCsGWhPV+ZC/20hkVzwPAhiizu/IqMEol4tO2yiSheD4D6bn0KxUJhg==", + "license": "Apache-2.0", + "dependencies": { + "@polkadot/wasm-bridge": "7.5.4", + "@polkadot/wasm-crypto-asmjs": "7.5.4", + "@polkadot/wasm-crypto-wasm": "7.5.4", + "@polkadot/wasm-util": "7.5.4", + "tslib": "^2.7.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@polkadot/util": "*", + "@polkadot/x-randomvalues": "*" + } + }, + "node_modules/@polkadot/wasm-crypto-wasm": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/@polkadot/wasm-crypto-wasm/-/wasm-crypto-wasm-7.5.4.tgz", + "integrity": "sha512-PsHgLsVTu43eprwSvUGnxybtOEuHPES6AbApcs7y5ZbM2PiDMzYbAjNul098xJK/CPtrxZ0ePDFnaQBmIJyTFw==", + "license": "Apache-2.0", + "dependencies": { + "@polkadot/wasm-util": "7.5.4", + "tslib": "^2.7.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@polkadot/util": "*" + } + }, + "node_modules/@polkadot/wasm-util": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/@polkadot/wasm-util/-/wasm-util-7.5.4.tgz", + "integrity": "sha512-hqPpfhCpRAqCIn/CYbBluhh0TXmwkJnDRjxrU9Bnqtw9nMNa97D8JuOjdd2pi0rxm+eeLQ/f1rQMp71RMM9t4w==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.7.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@polkadot/util": "*" + } + }, + "node_modules/@polkadot/x-bigint": { + "version": "12.6.2", + "resolved": "https://registry.npmjs.org/@polkadot/x-bigint/-/x-bigint-12.6.2.tgz", + "integrity": "sha512-HSIk60uFPX4GOFZSnIF7VYJz7WZA7tpFJsne7SzxOooRwMTWEtw3fUpFy5cYYOeLh17/kHH1Y7SVcuxzVLc74Q==", + "license": "Apache-2.0", + "dependencies": { + "@polkadot/x-global": "12.6.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@polkadot/x-fetch": { + "version": "12.6.2", + "resolved": "https://registry.npmjs.org/@polkadot/x-fetch/-/x-fetch-12.6.2.tgz", + "integrity": "sha512-8wM/Z9JJPWN1pzSpU7XxTI1ldj/AfC8hKioBlUahZ8gUiJaOF7K9XEFCrCDLis/A1BoOu7Ne6WMx/vsJJIbDWw==", + "license": "Apache-2.0", + "dependencies": { + "@polkadot/x-global": "12.6.2", + "node-fetch": "^3.3.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@polkadot/x-global": { + "version": "12.6.2", + "resolved": "https://registry.npmjs.org/@polkadot/x-global/-/x-global-12.6.2.tgz", + "integrity": "sha512-a8d6m+PW98jmsYDtAWp88qS4dl8DyqUBsd0S+WgyfSMtpEXu6v9nXDgPZgwF5xdDvXhm+P0ZfVkVTnIGrScb5g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@polkadot/x-randomvalues": { + "version": "12.6.2", + "resolved": "https://registry.npmjs.org/@polkadot/x-randomvalues/-/x-randomvalues-12.6.2.tgz", + "integrity": "sha512-Vr8uG7rH2IcNJwtyf5ebdODMcr0XjoCpUbI91Zv6AlKVYOGKZlKLYJHIwpTaKKB+7KPWyQrk4Mlym/rS7v9feg==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@polkadot/x-global": "12.6.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@polkadot/util": "12.6.2", + "@polkadot/wasm-util": "*" + } + }, + "node_modules/@polkadot/x-textdecoder": { + "version": "12.6.2", + "resolved": "https://registry.npmjs.org/@polkadot/x-textdecoder/-/x-textdecoder-12.6.2.tgz", + "integrity": "sha512-M1Bir7tYvNappfpFWXOJcnxUhBUFWkUFIdJSyH0zs5LmFtFdbKAeiDXxSp2Swp5ddOZdZgPac294/o2TnQKN1w==", + "license": "Apache-2.0", + "dependencies": { + "@polkadot/x-global": "12.6.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@polkadot/x-textencoder": { + "version": "12.6.2", + "resolved": "https://registry.npmjs.org/@polkadot/x-textencoder/-/x-textencoder-12.6.2.tgz", + "integrity": "sha512-4N+3UVCpI489tUJ6cv3uf0PjOHvgGp9Dl+SZRLgFGt9mvxnvpW/7+XBADRMtlG4xi5gaRK7bgl5bmY6OMDsNdw==", + "license": "Apache-2.0", + "dependencies": { + "@polkadot/x-global": "12.6.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@polkadot/x-ws": { + "version": "12.6.2", + "resolved": "https://registry.npmjs.org/@polkadot/x-ws/-/x-ws-12.6.2.tgz", + "integrity": "sha512-cGZWo7K5eRRQCRl2LrcyCYsrc3lRbTlixZh3AzgU8uX4wASVGRlNWi/Hf4TtHNe1ExCDmxabJzdIsABIfrr7xw==", + "license": "Apache-2.0", + "dependencies": { + "@polkadot/x-global": "12.6.2", + "tslib": "^2.6.2", + "ws": "^8.15.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@scure/base": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.2.6.tgz", + "integrity": "sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip32": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-1.7.0.tgz", + "integrity": "sha512-E4FFX/N3f4B80AKWp5dP6ow+flD1LQZo/w8UnLGYZO674jS6YnYeepycOOksv+vLPSpgN35wgKgy+ybfTb2SMw==", + "license": "MIT", + "dependencies": { + "@noble/curves": "~1.9.0", + "@noble/hashes": "~1.8.0", + "@scure/base": "~1.2.5" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip39": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.6.0.tgz", + "integrity": "sha512-+lF0BbLiJNwVlev4eKelw1WWLaiKXw7sSl8T6FvBlWkdX+94aGJ4o8XjUdlyhTCjd8c+B3KT3JfS8P0bLRNU6A==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "~1.8.0", + "@scure/base": "~1.2.5" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@substrate/connect": { + "version": "0.8.10", + "resolved": "https://registry.npmjs.org/@substrate/connect/-/connect-0.8.10.tgz", + "integrity": "sha512-DIyQ13DDlXqVFnLV+S6/JDgiGowVRRrh18kahieJxhgvzcWicw5eLc6jpfQ0moVVLBYkO7rctB5Wreldwpva8w==", + "deprecated": "versions below 1.x are no longer maintained", + "license": "GPL-3.0-only", + "optional": true, + "dependencies": { + "@substrate/connect-extension-protocol": "^2.0.0", + "@substrate/connect-known-chains": "^1.1.4", + "@substrate/light-client-extension-helpers": "^0.0.6", + "smoldot": "2.0.22" + } + }, + "node_modules/@substrate/connect-extension-protocol": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/@substrate/connect-extension-protocol/-/connect-extension-protocol-2.2.2.tgz", + "integrity": "sha512-t66jwrXA0s5Goq82ZtjagLNd7DPGCNjHeehRlE/gcJmJ+G56C0W+2plqOMRicJ8XGR1/YFnUSEqUFiSNbjGrAA==", + "license": "GPL-3.0-only", + "optional": true + }, + "node_modules/@substrate/connect-known-chains": { + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/@substrate/connect-known-chains/-/connect-known-chains-1.10.3.tgz", + "integrity": "sha512-OJEZO1Pagtb6bNE3wCikc2wrmvEU5x7GxFFLqqbz1AJYYxSlrPCGu4N2og5YTExo4IcloNMQYFRkBGue0BKZ4w==", + "license": "GPL-3.0-only", + "optional": true + }, + "node_modules/@substrate/light-client-extension-helpers": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@substrate/light-client-extension-helpers/-/light-client-extension-helpers-0.0.6.tgz", + "integrity": "sha512-girltEuxQ1BvkJWmc8JJlk4ZxnlGXc/wkLcNguhY+UoDEMBK0LsdtfzQKIfrIehi4QdeSBlFEFBoI4RqPmsZzA==", + "license": "MIT", + "optional": true, + "dependencies": { + "@polkadot-api/json-rpc-provider": "0.0.1", + "@polkadot-api/json-rpc-provider-proxy": "0.0.1", + "@polkadot-api/observable-client": "0.1.0", + "@polkadot-api/substrate-client": "0.0.1", + "@substrate/connect-extension-protocol": "^2.0.0", + "@substrate/connect-known-chains": "^1.1.4", + "rxjs": "^7.8.1" + }, + "peerDependencies": { + "smoldot": "2.x" + } + }, + "node_modules/@substrate/ss58-registry": { + "version": "1.51.0", + "resolved": "https://registry.npmjs.org/@substrate/ss58-registry/-/ss58-registry-1.51.0.tgz", + "integrity": "sha512-TWDurLiPxndFgKjVavCniytBIw+t4ViOi7TYp9h/D0NMmkEc9klFTo+827eyEJ0lELpqO207Ey7uGxUa+BS1jQ==", + "license": "Apache-2.0" + }, + "node_modules/@types/bn.js": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@types/bn.js/-/bn.js-5.2.0.tgz", + "integrity": "sha512-DLbJ1BPqxvQhIGbeu8VbUC1DiAiahHtAYvA0ZEAa4P31F7IaArc8z3C3BRQdWX4mtLQuABG4yzp76ZrS02Ui1Q==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/node": { + "version": "20.19.43", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", + "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/abitype": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/abitype/-/abitype-1.2.3.tgz", + "integrity": "sha512-Ofer5QUnuUdTFsBRwARMoWKOH1ND5ehwYhJ3OJ/BQO+StkwQjHw0XyVh4vDttzHB7QOFhPHa/o413PJ82gU/Tg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/wevm" + }, + "peerDependencies": { + "typescript": ">=5.0.4", + "zod": "^3.22.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/bn.js": { + "version": "5.2.4", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.4.tgz", + "integrity": "sha512-QL7sb18rJ1PbdsKsqPA0guxL563vIMwRHgzNrW/uzQuRGN1Cjqd/wonUBAVqHox9KwzHA6vCbM0lXx3k4iQMow==", + "license": "MIT" + }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/isows": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/isows/-/isows-1.0.7.tgz", + "integrity": "sha512-I1fSfDCZL5P0v33sVqeTDSpcstAg/N+wF5HS033mogOVIp4B+oHC7oOCsA3axAbBSGTJ8QubbNmnIRN/h8U7hg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/wevm" + } + ], + "license": "MIT", + "peerDependencies": { + "ws": "*" + } + }, + "node_modules/json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", + "license": "ISC" + }, + "node_modules/mock-socket": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/mock-socket/-/mock-socket-9.3.1.tgz", + "integrity": "sha512-qxBgB7Qa2sEQgHFjj0dSigq7fX4k6Saisd5Nelwp2q8mlbAFh5dHV9JTTlF8viYJLSSWgMCZFUom8PJcMNBoJw==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nock": { + "version": "13.5.6", + "resolved": "https://registry.npmjs.org/nock/-/nock-13.5.6.tgz", + "integrity": "sha512-o2zOYiCpzRqSzPj0Zt/dQ/DqZeYoaQ7TUonc/xUPjCGl9WeHpNbxgVvOquXYAaJzI0M9BXV3HTzG0p8IUAbBTQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.1.0", + "json-stringify-safe": "^5.0.1", + "propagate": "^2.0.0" + }, + "engines": { + "node": ">= 10.13" + } + }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/ox": { + "version": "0.14.30", + "resolved": "https://registry.npmjs.org/ox/-/ox-0.14.30.tgz", + "integrity": "sha512-LI11uu+8iiM1B3CLckgd++YF1a0A2k5wDoM9ZeQMiL21BOzQs6L//BLS6hb1HSEKCyycdDIQLsVQx9MjpcC0hA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/wevm" + } + ], + "license": "MIT", + "dependencies": { + "@adraffy/ens-normalize": "^1.11.0", + "@noble/ciphers": "^1.3.0", + "@noble/curves": "1.9.1", + "@noble/hashes": "^1.8.0", + "@scure/bip32": "^1.7.0", + "@scure/bip39": "^1.6.0", + "abitype": "^1.2.3", + "eventemitter3": "5.0.1" + }, + "peerDependencies": { + "typescript": ">=5.4.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/ox/node_modules/@noble/curves": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.1.tgz", + "integrity": "sha512-k11yZxZg+t+gWvBbIswW0yoJlu8cHOC7dhunwOzoWH/mXGBiYyR4YY6hAEK/3EUs4UpB8la1RfdRpeGsFHkWsA==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.8.0" + }, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/ox/node_modules/eventemitter3": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", + "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", + "license": "MIT" + }, + "node_modules/propagate": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/propagate/-/propagate-2.0.1.tgz", + "integrity": "sha512-vGrhOavPSTz4QVNuBNdcNXePNdNMaO1xj9yBeH1ScQPjk/rhg9sSlCXPhMkFuaNNW/syTvYqsnbIJxMBfRbbag==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/scale-ts": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/scale-ts/-/scale-ts-1.6.1.tgz", + "integrity": "sha512-PBMc2AWc6wSEqJYBDPcyCLUj9/tMKnLX70jLOSndMtcUoLQucP/DM0vnQo1wJAYjTrQiq8iG9rD0q6wFzgjH7g==", + "license": "MIT", + "optional": true + }, + "node_modules/smoldot": { + "version": "2.0.22", + "resolved": "https://registry.npmjs.org/smoldot/-/smoldot-2.0.22.tgz", + "integrity": "sha512-B50vRgTY6v3baYH6uCgL15tfaag5tcS2o/P5q1OiXcKGv1axZDfz2dzzMuIkVpyMR2ug11F6EAtQlmYBQd292g==", + "license": "GPL-3.0-or-later WITH Classpath-exception-2.0", + "optional": true, + "peer": true, + "dependencies": { + "ws": "^8.8.1" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "license": "Apache-2.0", + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" + }, + "node_modules/viem": { + "version": "2.54.6", + "resolved": "https://registry.npmjs.org/viem/-/viem-2.54.6.tgz", + "integrity": "sha512-OfybECKJYVmhiNqz+SHhed+O2h6niQ+0Wjg9J0b4bV+/QrvLgjxhfKO7hZqsuK1YtZ/0BErBKy708Zp+cU5T0Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/wevm" + } + ], + "license": "MIT", + "dependencies": { + "@noble/curves": "1.9.1", + "@noble/hashes": "1.8.0", + "@scure/bip32": "1.7.0", + "@scure/bip39": "1.6.0", + "abitype": "1.2.3", + "isows": "1.0.7", + "ox": "0.14.30", + "ws": "8.21.0" + }, + "peerDependencies": { + "typescript": ">=5.0.4" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/viem/node_modules/@noble/curves": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.1.tgz", + "integrity": "sha512-k11yZxZg+t+gWvBbIswW0yoJlu8cHOC7dhunwOzoWH/mXGBiYyR4YY6hAEK/3EUs4UpB8la1RfdRpeGsFHkWsA==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.8.0" + }, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/monitor/package.json b/monitor/package.json new file mode 100644 index 000000000..7faf5a29a --- /dev/null +++ b/monitor/package.json @@ -0,0 +1,20 @@ +{ + "name": "pen-migration-monitor", + "version": "0.1.0", + "private": true, + "description": "Independent invariant monitor for the PEN migration: verifies conservation between Pendulum burns and Base releases, watches liveness, and can auto-pause the vault", + "type": "module", + "scripts": { + "build": "tsc", + "start": "node dist/main.js", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@polkadot/api": "^11.3.1", + "viem": "^2.21.0" + }, + "devDependencies": { + "@types/node": "^20.14.0", + "typescript": "^5.5.0" + } +} diff --git a/monitor/src/main.ts b/monitor/src/main.ts new file mode 100644 index 000000000..ee6d1b5e6 --- /dev/null +++ b/monitor/src/main.ts @@ -0,0 +1,208 @@ +/** + * PEN migration invariant monitor (PRD §6.5). + * + * Runs on infrastructure SEPARATE from every attestor and reads both chains + * independently. Each poll it checks, at the finalized head of Pendulum and + * the latest Base block: + * + * (M2a) totalReleased on Base <= TotalMigrated on Pendulum * conversionFactor + * (a violation means tokens were released that were never burned — + * the strongest possible signal of attestor compromise) + * (M2b) balanceOf(vault) + totalReleased == totalSupply + * (conservation inside the vault itself) + * (M4) liveness: every migration nonce older than GRACE_SECONDS is consumed + * on Base (detects a stalled attestor fleet) + * + * On an M2a violation the monitor alerts AND — when GUARDIAN_PRIVATE_KEY is + * configured (design option in PRD M3) — pauses the vault immediately. + */ + +import { ApiPromise, WsProvider } from "@polkadot/api"; +import { createPublicClient, createWalletClient, defineChain, http } from "viem"; +import { privateKeyToAccount } from "viem/accounts"; + +const vaultAbi = [ + { type: "function", name: "totalReleased", stateMutability: "view", inputs: [], outputs: [{ type: "uint256" }] }, + { type: "function", name: "conversionFactor", stateMutability: "view", inputs: [], outputs: [{ type: "uint256" }] }, + { type: "function", name: "token", stateMutability: "view", inputs: [], outputs: [{ type: "address" }] }, + { type: "function", name: "paused", stateMutability: "view", inputs: [], outputs: [{ type: "bool" }] }, + { + type: "function", + name: "nonceConsumed", + stateMutability: "view", + inputs: [{ name: "nonce", type: "uint64" }], + outputs: [{ type: "bool" }], + }, + { type: "function", name: "pause", stateMutability: "nonpayable", inputs: [], outputs: [] }, +] as const; + +const erc20Abi = [ + { type: "function", name: "totalSupply", stateMutability: "view", inputs: [], outputs: [{ type: "uint256" }] }, + { + type: "function", + name: "balanceOf", + stateMutability: "view", + inputs: [{ name: "owner", type: "address" }], + outputs: [{ type: "uint256" }], + }, +] as const; + +function required(name: string): string { + const value = process.env[name]; + if (!value) throw new Error(`Missing required environment variable ${name}`); + return value; +} + +const config = { + pendulumWs: required("PENDULUM_WS"), + baseRpcUrl: required("BASE_RPC_URL"), + vaultAddress: required("VAULT_ADDRESS") as `0x${string}`, + pollIntervalMs: Number(process.env.POLL_INTERVAL_MS ?? "60000"), + /** Seconds a migration may stay unreleased before a liveness alert (M4). */ + graceSeconds: Number(process.env.GRACE_SECONDS ?? "1800"), + alertWebhookUrl: process.env.ALERT_WEBHOOK_URL, + /** Optional: enables auto-pause on a conservation violation (M3). */ + guardianPrivateKey: process.env.GUARDIAN_PRIVATE_KEY as `0x${string}` | undefined, + baseChainId: Number(process.env.BASE_CHAIN_ID ?? "8453"), +}; + +const baseChain = defineChain({ + id: config.baseChainId, + name: "base", + nativeCurrency: { name: "Ether", symbol: "ETH", decimals: 18 }, + rpcUrls: { default: { http: [config.baseRpcUrl] } }, +}); +const publicClient = createPublicClient({ chain: baseChain, transport: http(config.baseRpcUrl) }); + +function log(message: string): void { + console.log(`${new Date().toISOString()} ${message}`); +} + +async function alert(subject: string, detail: string): Promise { + console.error(`${new Date().toISOString()} ALERT: ${subject} — ${detail}`); + if (!config.alertWebhookUrl) return; + try { + await fetch(config.alertWebhookUrl, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ service: "pen-monitor", subject, detail }), + }); + } catch (webhookError) { + console.error("alert webhook failed", webhookError); + } +} + +async function pauseVault(): Promise { + if (!config.guardianPrivateKey) { + await alert("AUTO-PAUSE UNAVAILABLE", "no GUARDIAN_PRIVATE_KEY configured; pause manually NOW"); + return; + } + const guardian = privateKeyToAccount(config.guardianPrivateKey); + const walletClient = createWalletClient({ account: guardian, chain: baseChain, transport: http(config.baseRpcUrl) }); + try { + const txHash = await walletClient.writeContract({ + address: config.vaultAddress, + abi: vaultAbi, + functionName: "pause", + }); + await alert("vault auto-paused", `tx=${txHash}`); + } catch (pauseError) { + await alert("AUTO-PAUSE FAILED", `${pauseError}; pause manually NOW`); + } +} + +/** Timestamps (ms) at which the monitor first saw each pallet nonce count. */ +const nonceFirstSeen = new Map(); + +async function check(api: ApiPromise): Promise { + // --- Pendulum side, at the finalized head --- + const finalizedHash = await api.rpc.chain.getFinalizedHead(); + const apiAt = await api.at(finalizedHash); + const totalMigrated = BigInt((await apiAt.query.tokenMigration.totalMigrated()).toString()); + const nextNonce = BigInt((await apiAt.query.tokenMigration.nextNonce()).toString()); + + // --- Base side --- + const [totalReleased, conversionFactor, tokenAddress] = await Promise.all([ + publicClient.readContract({ address: config.vaultAddress, abi: vaultAbi, functionName: "totalReleased" }), + publicClient.readContract({ address: config.vaultAddress, abi: vaultAbi, functionName: "conversionFactor" }), + publicClient.readContract({ address: config.vaultAddress, abi: vaultAbi, functionName: "token" }), + ]); + const [totalSupply, vaultBalance] = await Promise.all([ + publicClient.readContract({ address: tokenAddress, abi: erc20Abi, functionName: "totalSupply" }), + publicClient.readContract({ + address: tokenAddress, + abi: erc20Abi, + functionName: "balanceOf", + args: [config.vaultAddress], + }), + ]); + + // (M2a) Nothing may leave the vault that was not burned on Pendulum. + // totalMigrated lags totalReleased only via finality delay, never the + // other way around: releases require attestations of finalized burns. + const migratedInTokenUnits = totalMigrated * conversionFactor; + if (totalReleased > migratedInTokenUnits) { + await alert( + "CONSERVATION VIOLATION", + `released ${totalReleased} > migrated ${migratedInTokenUnits} (token units)`, + ); + await pauseVault(); + return; + } + + // (M2b) Vault-internal conservation. + if (vaultBalance + totalReleased !== totalSupply) { + await alert( + "VAULT BALANCE MISMATCH", + `balance ${vaultBalance} + released ${totalReleased} != supply ${totalSupply}`, + ); + await pauseVault(); + return; + } + + // (M4) Liveness: nonces the monitor has known about for longer than the + // grace period must be consumed on Base. + const now = Date.now(); + for (let nonce = 0n; nonce < nextNonce; nonce++) { + if (!nonceFirstSeen.has(nonce)) nonceFirstSeen.set(nonce, now); + } + for (const [nonce, firstSeen] of nonceFirstSeen) { + const consumed = await publicClient.readContract({ + address: config.vaultAddress, + abi: vaultAbi, + functionName: "nonceConsumed", + args: [nonce], + }); + if (consumed) { + nonceFirstSeen.delete(nonce); + } else if (now - firstSeen > config.graceSeconds * 1000) { + await alert( + "LIVENESS: migration not released", + `nonce ${nonce} unreleased for over ${config.graceSeconds}s — attestor outage, cap deferral or pause?`, + ); + } + } + + log( + `ok: migrated=${totalMigrated} released=${totalReleased} pending=${nonceFirstSeen.size} ` + + `vaultBalance=${vaultBalance}`, + ); +} + +async function main(): Promise { + const api = await ApiPromise.create({ provider: new WsProvider(config.pendulumWs) }); + log(`monitor started, polling every ${config.pollIntervalMs}ms`); + for (;;) { + try { + await check(api); + } catch (checkError) { + await alert("monitor check failed", `${checkError}`); + } + await new Promise((resolve) => setTimeout(resolve, config.pollIntervalMs)); + } +} + +main().catch(async (error) => { + await alert("monitor startup failed", `${error}`); + process.exit(1); +}); diff --git a/monitor/tsconfig.json b/monitor/tsconfig.json new file mode 100644 index 000000000..59b02f6e5 --- /dev/null +++ b/monitor/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src"] +} From da0fb79b010506df706dabc4fcf17ba8057f6559 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 7 Jul 2026 19:39:54 +0200 Subject: [PATCH 08/23] docs: Add PEN migration operational runbooks (key compromise, outage, invariant breach, pause, runtime upgrade, attestor rotation) --- docs/pen-migration-runbooks.md | 125 +++++++++++++++++++++++++++++++++ 1 file changed, 125 insertions(+) create mode 100644 docs/pen-migration-runbooks.md diff --git a/docs/pen-migration-runbooks.md b/docs/pen-migration-runbooks.md new file mode 100644 index 000000000..40ecf39ff --- /dev/null +++ b/docs/pen-migration-runbooks.md @@ -0,0 +1,125 @@ +# PEN Migration — Operational Runbooks + +Runbooks required by PRD acceptance criterion 10. Each must be drill-tested +before mainnet launch. Contact points, Safe addresses and paging channels are +filled in during the attestor onboarding ceremony (decision D4). + +**Shared prerequisites:** access to the alerting channel; read access to a +Pendulum node and a Base RPC; the on-call sheet mapping attestor index → +operator → contact. Escalation path everywhere: on-call engineer → migration +tech lead → guardian Safe signers → admin Safe signers. + +--- + +## RB-1: Suspected attestor key compromise + +**Trigger:** an `Approved` event from an attestor for a tuple that does not +match any finalized Pendulum `MigrationInitiated` event (monitor M2a alert, or +manual observation), or an operator reports infrastructure compromise. + +1. **Pause first, investigate second.** Any guardian signer (or the monitor's + auto-pause) calls `vault.pause()`. Releases stop; approvals keep recording. +2. Confirm the mismatch: compare the suspicious `Approved(nonce, recipient, + palletAmount, attestor)` event against the pallet's `MigrationInitiated` + events at the finalized head (`tokenMigration` section). +3. If confirmed compromised: admin Safe executes `vault.removeAttestor(x)`. + Removal retroactively invalidates all of that attestor's recorded + approvals — no released funds can result from them afterwards. +4. Operator rotates infrastructure and generates a **new** key; admin Safe + executes `vault.addAttestor(newKey)`. Never re-add a possibly-leaked key. +5. Reconcile: verify `totalReleased <= TotalMigrated × conversionFactor` and + that every consumed nonce maps 1:1 to a pallet event. If value was lost, + follow the incident-disclosure policy before unpausing. +6. Admin Safe executes `vault.unpause()`. Deferred releases can be executed by + anyone via `vault.release(nonce, recipient, palletAmount)`. + +**Rollback:** none needed; pausing is side-effect-free. + +## RB-2: Attestor outage (no key compromise) + +**Trigger:** monitor M4 liveness alert (nonce unreleased past the grace +period) or an attestor's own restart-loop/low-gas alerts. + +1. Determine how many attestors are down. With ≤ 2 of 5 down, releases + continue — treat as routine ops. With 3+ down, migrations queue up + harmlessly (approvals missing, nothing to roll back) — escalate to the + affected operators. +2. Common causes, in order of frequency: Base gas wallet empty (fund it; the + daemon logs the address), Pendulum node not synced/finalizing, checkpoint + file pointing at a pruned block (re-point `START_BLOCK` at a block the node + still has, never past unprocessed migrations), daemon restart-loop after a + runtime upgrade (see RB-5). +3. After recovery the daemon catches up from its checkpoint automatically; + duplicate approvals are impossible (pre-checks + contract dedup). +4. Verify recovery: the queued nonces release as approvals arrive; monitor + goes back to `ok`. + +## RB-3: Conservation invariant violation + +**Trigger:** monitor alert `CONSERVATION VIOLATION` or `VAULT BALANCE +MISMATCH`. This is the highest-severity alert the system can produce. + +1. Auto-pause should already have fired; **verify `vault.paused() == true`** + and pause manually if not. Do not unpause until step 5. +2. Rule out monitor error: recompute both sides by hand from independent RPC + endpoints (`TotalMigrated` at the finalized head; `totalReleased`, + `balanceOf(vault)`, `totalSupply` on Base). +3. If real: identify the offending `Released` events (those whose nonce has no + matching pallet event) and the attestors who approved them → continue with + RB-1 steps 3–5 for every implicated attestor. Assume quorum compromise: + rotate **all** keys unless positively excluded. +4. Quantify the loss (sum of unmatched releases) and follow the disclosure + policy. Consider whether caps need lowering before resumption. +5. Unpause only with sign-off from the admin Safe quorum and a written + incident report. + +## RB-4: Pause / unpause (routine procedure) + +**Pause** (guardian Safe, any authorized signer, or admin): +`vault.pause()` — instant; releases stop, approvals keep recording, `migrate` +on Pendulum is unaffected (pause that separately if needed, see below). + +**Pendulum-side pause** (for pallet-level incidents or coordinated stops): +`tokenMigration.setPaused(true)` via root/half-council, or 2/3 technical +committee for fast response. This stops new burns at the source. + +**Unpause:** admin Safe executes `vault.unpause()` (or `setPaused(false)` on +the pallet via governance). After a vault unpause, deferred releases are +retried permissionlessly via `vault.release(...)` — the attestor fleet does +not need to do anything. + +**Order in a coordinated stop:** pause the pallet first (stop new burns), then +the vault. Resume in reverse order. + +## RB-5: Pendulum runtime upgrade + +**Risk:** a runtime upgrade can change event encoding or the pallet's index, +which would make attestor daemons exit on decode failure (by design, PRD A5). + +Before the upgrade is enacted: +1. Check the diff for changes to `pallets/token-migration`, its event type or + its `construct_runtime` index. No changes → notify operators, no action. +2. If the event shape changed: update and release a new daemon version; + operators deploy it **before** the upgrade block. +3. Nonce monotonicity across upgrades is a pallet invariant (P2) — any + migration touching `NextNonce` storage must preserve it; reject one that + doesn't. + +After enactment: +4. Watch the fleet: all five daemons progressing past the upgrade block, test + migration of a small amount end-to-end, monitor `ok` lines resuming. +5. If daemons exit on the upgrade block: they hold position (checkpoint stays + put) — fix decoding, redeploy, they resume without loss. + +## RB-6: Attestor set / threshold change (planned) + +1. Admin Safe (timelocked post-handover: expect the configured delay between + scheduling and execution) calls `addAttestor` / `removeAttestor` / + `setThreshold`. Invariants enforced on-chain: threshold ≥ 2 and ≤ attestor + count. +2. Sequence for replacing an operator: `addAttestor(new)` first, wait for + their daemon to be live and approving, then `removeAttestor(old)`. +3. Removed attestors' recorded approvals stop counting immediately; pending + migrations that relied on them simply need approvals from the remaining + set (the new attestor's daemon backfills from its `START_BLOCK` — set it + to a block before the oldest unreleased migration). From f2dcea693f2c90a3f0a719b4f667351461249673 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 7 Jul 2026 22:34:50 +0200 Subject: [PATCH 09/23] pallets: Add benchmarks for the token-migration pallet 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. --- pallets/token-migration/src/benchmarking.rs | 47 +++++++++++++++++++++ pallets/token-migration/src/lib.rs | 3 ++ runtime/pendulum/src/lib.rs | 1 + 3 files changed, 51 insertions(+) create mode 100644 pallets/token-migration/src/benchmarking.rs diff --git a/pallets/token-migration/src/benchmarking.rs b/pallets/token-migration/src/benchmarking.rs new file mode 100644 index 000000000..32b33d93a --- /dev/null +++ b/pallets/token-migration/src/benchmarking.rs @@ -0,0 +1,47 @@ +//! Benchmarks for the token-migration pallet. +//! +//! Run against the Pendulum runtime on reference hardware to generate +//! production weights, e.g.: +//! `cargo run --release --features runtime-benchmarks -- benchmark pallet \ +//! --chain pendulum --pallet token-migration --extrinsic '*' --steps 50 --repeat 20` + +#![cfg(feature = "runtime-benchmarks")] + +use super::*; +use frame_benchmarking::v2::*; +use frame_support::traits::{EnsureOrigin, Get}; +use frame_system::RawOrigin; +use sp_runtime::traits::Bounded; + +#[benchmarks] +mod benchmarks { + use super::*; + + #[benchmark] + fn migrate() { + let caller: T::AccountId = whitelisted_caller(); + T::Currency::make_free_balance_be(&caller, BalanceOf::::max_value() / 2u32.into()); + let amount = T::MinimumMigrationAmount::get().saturating_mul(10u32.into()); + let base_address = H160::repeat_byte(0xBE); + + #[extrinsic_call] + migrate(RawOrigin::Signed(caller.clone()), amount, base_address); + + assert_eq!(TotalMigrated::::get(), amount); + assert_eq!(NextNonce::::get(), 1); + } + + #[benchmark] + fn set_paused() -> Result<(), BenchmarkError> { + let origin = + T::PauseOrigin::try_successful_origin().map_err(|_| BenchmarkError::Weightless)?; + + #[extrinsic_call] + set_paused(origin as T::RuntimeOrigin, true); + + assert!(Paused::::get()); + Ok(()) + } + + impl_benchmark_test_suite!(Pallet, crate::mock::ExtBuilder::build(), crate::mock::Test); +} diff --git a/pallets/token-migration/src/lib.rs b/pallets/token-migration/src/lib.rs index 535824e9c..22e8fe8af 100644 --- a/pallets/token-migration/src/lib.rs +++ b/pallets/token-migration/src/lib.rs @@ -18,6 +18,9 @@ pub use pallet::*; pub mod default_weights; +#[cfg(feature = "runtime-benchmarks")] +pub mod benchmarking; + #[cfg(test)] mod mock; #[cfg(test)] diff --git a/runtime/pendulum/src/lib.rs b/runtime/pendulum/src/lib.rs index 251011120..1cc388ad4 100644 --- a/runtime/pendulum/src/lib.rs +++ b/runtime/pendulum/src/lib.rs @@ -1745,6 +1745,7 @@ mod benches { [orml_currencies_allowance_extension, TokenAllowance] [treasury_buyout_extension, TreasuryBuyoutExtension] + [token_migration, TokenMigration] [dia_oracle, DiaOracleModule] ); From 74b0cc66e8f91ba210267f810bb297992cdfc6e6 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 7 Jul 2026 22:34:51 +0200 Subject: [PATCH 10/23] security: Fix internal-review findings across vault, attestor and monitor - 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. --- attestor/src/main.ts | 74 +++++++++++++++++---------- contracts/src/MigrationVault.sol | 55 ++++++++++++++++---- contracts/test/MigrationVault.t.sol | 70 +++++++++++++++++++++++++ docs/pen-migration-internal-review.md | 69 +++++++++++++++++++++++++ monitor/src/main.ts | 13 +++-- 5 files changed, 241 insertions(+), 40 deletions(-) create mode 100644 docs/pen-migration-internal-review.md diff --git a/attestor/src/main.ts b/attestor/src/main.ts index 27ddc0b49..1df649254 100644 --- a/attestor/src/main.ts +++ b/attestor/src/main.ts @@ -106,7 +106,12 @@ async function migrationEventsInBlock(api: ApiPromise, blockNumber: number): Pro for (const record of records) { const { section, method, data } = record.event; if (section !== "tokenMigration" || method !== "MigrationInitiated") continue; - // Event shape: { nonce: u64, who: AccountId, base_address: H160, amount: u128 } + // Event shape: { nonce: u64, who: AccountId, base_address: H160, amount: u128 }. + // Guard the shape explicitly: a runtime upgrade changing the event must + // fail loudly (PRD A5), not decode garbage positionally. + if (data.length !== 4) { + throw new Error(`MigrationInitiated in block ${blockNumber} has ${data.length} fields, expected 4`); + } const [nonce, , baseAddress, amount] = data as [ { toBigInt(): bigint }, unknown, @@ -122,46 +127,58 @@ async function migrationEventsInBlock(api: ApiPromise, blockNumber: number): Pro return events; } -/** Submit the approval for one migration event, skipping work already done. */ -async function approve(event: MigrationEvent): Promise { - const label = `nonce=${event.nonce} recipient=${event.recipient} amount=${event.palletAmount}`; - +/** True when this migration no longer needs our approval (released, or we + * already approved). Rechecked after failures: with 5 independent attestors + * racing to the same event, losing the race is the NORMAL case, not an error. */ +async function alreadyHandled(event: MigrationEvent): Promise { const consumed = await publicClient.readContract({ address: config.vaultAddress, abi: vaultAbi, functionName: "nonceConsumed", args: [event.nonce], }); - if (consumed) { - log(`skip (already released): ${label}`); - return; - } - - const alreadyApproved = await publicClient.readContract({ + if (consumed) return true; + return publicClient.readContract({ address: config.vaultAddress, abi: vaultAbi, functionName: "hasApproved", args: [payloadHash(event), account.address], }); - if (alreadyApproved) { - log(`skip (already approved by us): ${label}`); +} + +/** Submit the approval for one migration event, skipping work already done. */ +async function approve(event: MigrationEvent): Promise { + const label = `nonce=${event.nonce} recipient=${event.recipient} amount=${event.palletAmount}`; + + if (await alreadyHandled(event)) { + log(`skip (already released or approved): ${label}`); return; } - // simulate first: turns approvals raced by other paths into clean skips - const { request } = await publicClient.simulateContract({ - account, - address: config.vaultAddress, - abi: vaultAbi, - functionName: "approve", - args: [event.nonce, event.recipient, event.palletAmount], - }); - const txHash = await walletClient.writeContract(request); - const receipt = await publicClient.waitForTransactionReceipt({ hash: txHash }); - if (receipt.status !== "success") { - throw new Error(`approve transaction reverted: ${txHash} (${label})`); + try { + const { request } = await publicClient.simulateContract({ + account, + address: config.vaultAddress, + abi: vaultAbi, + functionName: "approve", + args: [event.nonce, event.recipient, event.palletAmount], + }); + const txHash = await walletClient.writeContract(request); + const receipt = await publicClient.waitForTransactionReceipt({ hash: txHash }); + if (receipt.status !== "success") { + throw new Error(`approve transaction reverted: ${txHash} (${label})`); + } + log(`approved: ${label} tx=${txHash}`); + } catch (error) { + // Expected race: the release landed (or our own retried tx landed) + // between our pre-check and the transaction. Benign — anything else + // is a genuine failure and propagates to the fatal handler. + if (await alreadyHandled(event)) { + log(`skip (raced, resolved on-chain): ${label}`); + return; + } + throw error; } - log(`approved: ${label} tx=${txHash}`); } async function checkGasBalance(): Promise { @@ -208,7 +225,10 @@ async function main(): Promise { }); }); - setInterval(() => void checkGasBalance().catch(() => {}), 10 * 60 * 1000); + setInterval( + () => void checkGasBalance().catch((error) => console.error("gas balance check failed", error)), + 10 * 60 * 1000, + ); } main().catch(async (error) => { diff --git a/contracts/src/MigrationVault.sol b/contracts/src/MigrationVault.sol index d1eacc505..5d85a8963 100644 --- a/contracts/src/MigrationVault.sol +++ b/contracts/src/MigrationVault.sol @@ -43,6 +43,7 @@ contract MigrationVault { error ExceedsPerReleaseCap(uint256 amount, uint256 cap); error ExceedsDailyCap(uint256 wouldBeReleasedToday, uint256 cap); error SweepNotYetAllowed(uint256 earliest); + error PendingNotStale(); // ---------------------------------------------------------------- events @@ -59,6 +60,8 @@ contract MigrationVault { event AdminTransferStarted(address indexed pendingAdmin); event AdminTransferred(address indexed newAdmin); event RemainderSwept(address indexed to, uint256 amount); + event ReleasePending(uint64 indexed nonce, address indexed recipient, uint256 tokenAmount); + event StalePendingCleared(bytes32 indexed payload, uint256 tokenAmount); // ---------------------------------------------------------------- state @@ -111,6 +114,12 @@ contract MigrationVault { /// balanceOf(vault) + totalReleased == totalSupply). uint256 public totalReleased; + /// @notice Token units owed to threshold-approved payloads whose release + /// was deferred (pause or caps). Excluded from `sweepRemainder` + /// so a sweep can never strand an already-earned release. + uint256 public pendingApprovedAmount; + mapping(bytes32 => bool) public pendingRelease; + // ---------------------------------------------------------------- modifiers modifier onlyAdmin() { @@ -190,10 +199,18 @@ contract MigrationVault { // Opportunistic release: skipped (not reverted) when paused or a cap // is hit, so the approval is recorded either way. `release` can be // called by anyone later to retry. - if (!paused && address(token) != address(0) && activeApprovals(payload) >= threshold) { + if (activeApprovals(payload) >= threshold) { uint256 tokenAmount = palletAmount * conversionFactor; - if (tokenAmount <= perReleaseCap && _releasedTodayAfterRoll() + tokenAmount <= dailyCap) { - _release(nonce, recipient, palletAmount); + bool releasable = !paused && address(token) != address(0) && tokenAmount <= perReleaseCap + && _releasedTodayAfterRoll() + tokenAmount <= dailyCap; + if (releasable) { + _release(nonce, recipient, palletAmount, payload); + } else if (!pendingRelease[payload]) { + // Threshold reached but deferred: account for the owed amount + // so `sweepRemainder` cannot strand it. + pendingRelease[payload] = true; + pendingApprovedAmount += tokenAmount; + emit ReleasePending(nonce, recipient, tokenAmount); } } } @@ -214,19 +231,37 @@ contract MigrationVault { uint256 releasedAfter = _releasedTodayAfterRoll() + tokenAmount; if (releasedAfter > dailyCap) revert ExceedsDailyCap(releasedAfter, dailyCap); - _release(nonce, recipient, palletAmount); + _release(nonce, recipient, palletAmount, payload); } /// @dev Caller must have verified pause state, approvals and caps. - function _release(uint64 nonce, address recipient, uint256 palletAmount) internal { + function _release(uint64 nonce, address recipient, uint256 palletAmount, bytes32 payload) internal { uint256 tokenAmount = palletAmount * conversionFactor; nonceConsumed[nonce] = true; + if (pendingRelease[payload]) { + pendingRelease[payload] = false; + pendingApprovedAmount -= tokenAmount; + } releasedToday += tokenAmount; totalReleased += tokenAmount; token.safeTransfer(recipient, tokenAmount); emit Released(nonce, recipient, palletAmount, tokenAmount); } + /// @notice Clear the pending-release accounting of a payload whose nonce + /// was released via a DIFFERENT (conflicting) tuple. Restricted to + /// consumed nonces: an unconsumed pending payload is still owed to + /// its migrator and must never be cleared. + function clearStalePending(uint64 nonce, address recipient, uint256 palletAmount) external onlyAdmin { + if (!nonceConsumed[nonce]) revert PendingNotStale(); + bytes32 payload = payloadHash(nonce, recipient, palletAmount); + if (!pendingRelease[payload]) revert PendingNotStale(); + pendingRelease[payload] = false; + uint256 tokenAmount = palletAmount * conversionFactor; + pendingApprovedAmount -= tokenAmount; + emit StalePendingCleared(payload, tokenAmount); + } + // ---------------------------------------------------------------- views function payloadHash(uint64 nonce, address recipient, uint256 palletAmount) public pure returns (bytes32) { @@ -321,13 +356,15 @@ contract MigrationVault { } /// @notice Sweep the unmigrated remainder after the migration window - /// closes (destination decided by governance, PRD D5). + /// closes (destination decided by governance, PRD D5). Amounts + /// owed to threshold-approved-but-deferred releases are excluded, + /// so a sweep can never strand a burned-but-unreleased migration. function sweepRemainder(address to) external onlyAdmin { if (block.timestamp < earliestSweepTimestamp) revert SweepNotYetAllowed(earliestSweepTimestamp); if (to == address(0)) revert ZeroAddress(); if (address(token) == address(0)) revert TokenNotSet(); - uint256 balance = token.balanceOf(address(this)); - token.safeTransfer(to, balance); - emit RemainderSwept(to, balance); + uint256 sweepable = token.balanceOf(address(this)) - pendingApprovedAmount; + token.safeTransfer(to, sweepable); + emit RemainderSwept(to, sweepable); } } diff --git a/contracts/test/MigrationVault.t.sol b/contracts/test/MigrationVault.t.sol index f884368ba..d5fab2973 100644 --- a/contracts/test/MigrationVault.t.sol +++ b/contracts/test/MigrationVault.t.sol @@ -275,6 +275,76 @@ contract MigrationVaultTest is Test { assertEq(pen.balanceOf(address(vault)), 0); } + // ---------------------------------------------------------------- pending-release accounting + + function test_SweepExcludesPendingApprovedReleases() public { + // A migration larger than the per-release cap reaches quorum but is + // deferred; its owed amount must survive a remainder sweep. + uint256 palletAmount = 2_000_000e12; // > perReleaseCap after conversion + approveAs(0, 0, recipient, palletAmount); + approveAs(1, 0, recipient, palletAmount); + approveAs(2, 0, recipient, palletAmount); + assertEq(vault.pendingApprovedAmount(), 2_000_000e18); + + address treasury = makeAddr("treasury"); + vm.warp(earliestSweep); + vm.prank(admin); + vault.sweepRemainder(treasury); + assertEq(pen.balanceOf(treasury), MAX_ISSUANCE - 2_000_000e18); + assertEq(pen.balanceOf(address(vault)), 2_000_000e18, "owed amount stays in the vault"); + + // After governance raises the cap, the deferred release still succeeds. + vm.prank(admin); + vault.setCaps(3_000_000e18, 3_000_000e18); + vault.release(0, recipient, palletAmount); + assertEq(pen.balanceOf(recipient), 2_000_000e18); + assertEq(vault.pendingApprovedAmount(), 0); + } + + function test_PendingAccountingClearsOnRelease() public { + vm.prank(guardian); + vault.pause(); + + approveAs(0, 0, recipient, 5e12); + approveAs(1, 0, recipient, 5e12); + approveAs(2, 0, recipient, 5e12); + assertEq(vault.pendingApprovedAmount(), 5e18, "deferred by pause -> pending"); + + vm.prank(admin); + vault.unpause(); + vault.release(0, recipient, 5e12); + assertEq(vault.pendingApprovedAmount(), 0); + assertFalse(vault.pendingRelease(vault.payloadHash(0, recipient, 5e12))); + } + + function test_ClearStalePendingOnlyForConsumedNonce() public { + address mallory = makeAddr("mallory"); + vm.prank(guardian); + vault.pause(); + + // Both a legitimate and a conflicting tuple for nonce 0 reach quorum + // while paused (attestors may approve two different tuples). + for (uint256 i = 0; i < 3; i++) { + approveAs(i, 0, recipient, 5e12); + approveAs(i, 0, mallory, 5e12); + } + assertEq(vault.pendingApprovedAmount(), 10e18); + + // The stale (unreleased, unconsumed) pending cannot be cleared yet. + vm.prank(admin); + vm.expectRevert(MigrationVault.PendingNotStale.selector); + vault.clearStalePending(0, mallory, 5e12); + + vm.prank(admin); + vault.unpause(); + vault.release(0, recipient, 5e12); + + // Now the conflicting tuple's pending entry is stale and clearable. + vm.prank(admin); + vault.clearStalePending(0, mallory, 5e12); + assertEq(vault.pendingApprovedAmount(), 0); + } + // ---------------------------------------------------------------- fuzz function testFuzz_ReleasePreservesSupplyInvariant(uint64 nonce, uint96 palletAmount) public { diff --git a/docs/pen-migration-internal-review.md b/docs/pen-migration-internal-review.md new file mode 100644 index 000000000..05c6b817b --- /dev/null +++ b/docs/pen-migration-internal-review.md @@ -0,0 +1,69 @@ +# PEN Migration — Internal Security Review (pre-audit) + +**Date:** 2026-07-07 +**Scope:** MigrationVault.sol, PEN.sol, PENGovernor.sol, deploy scripts, +token-migration pallet, attestor daemon, invariant monitor. +**Method:** adversarial review by an independent reviewer agent against the +PRD requirements (P1–P9, V1–V9, A1–A5, M1–M4), cross-checked against the test +suite. This is an *internal* pass — it precedes and does not replace the +external audits (PRD §9). + +## Findings and resolutions + +### 1. HIGH — Attestor daemon treated the normal 3-of-5 race as fatal +With five independent attestors racing to approve the same event, the two +whose transactions land after the third matching approval revert with +`NonceAlreadyConsumed`. The daemon treated any revert as fatal (alert + +exit), meaning two attestors would crash-loop on nearly every migration — +alert fatigue that could mask real incidents. + +**Resolution (fixed):** on any submission failure the daemon re-checks +`nonceConsumed`/`hasApproved`; if the migration is resolved on-chain the race +is logged as benign and processing continues. Unexplained failures still +alert and exit (PRD A5 preserved). + +### 2. HIGH — Monitor reads were not pinned to one block +`totalReleased` and `balanceOf(vault)`/`totalSupply` were read in separate +batches without a block tag. A release landing between the batches would +produce a false `VAULT BALANCE MISMATCH` and — with a guardian key configured +— an unjustified auto-pause (48h+ to undo post-handover, since unpause is +timelocked). + +**Resolution (fixed):** all Base-side reads in a check cycle are pinned to a +single `blockNumber`. The Pendulum-then-Base read ordering of the M2a check +was confirmed safe by construction (burns finalize strictly before releases). + +### 3. MEDIUM — `sweepRemainder` could strand approved-but-deferred releases +A payload can reach quorum while its release is deferred (pause or caps); the +owed tokens still sit in the vault balance. Sweeping the full balance at +window close would leave such a release permanently unexecutable — the user +already burned on Pendulum. + +**Resolution (fixed):** the vault now tracks `pendingApprovedAmount` +(payloads that crossed the threshold without releasing; cleared on release). +`sweepRemainder` transfers `balance − pendingApprovedAmount`. A timelocked +`clearStalePending` exists for pending entries of *consumed* nonces only +(conflicting tuples that lost the race); unconsumed pending entries are owed +to their migrator and can never be cleared. Covered by three new tests. + +### Minor (fixed in the same pass) +- Attestor event decoding now asserts the 4-field event shape explicitly, so + a runtime upgrade that changes the event fails loudly instead of decoding + positionally into garbage. +- The attestor's periodic gas-balance check no longer swallows RPC errors. + +## Explicitly verified as not vulnerable +- **Replay/double-release:** `nonceConsumed` gates both `approve` and + `release` and is set before the transfer; conflicting tuples never merge. +- **Reentrancy:** checks-effects-interactions ordering in `_release`; the + token is hook-free OZ code. +- **Admin takeover / role wiring:** two-step admin transfer; deploy scripts + leave no dangling deployer privileges; timelock self-administered. + +## Follow-ups for the external audit +- The cap-accounting window (`currentDay` bucketing) and attestor-rotation + edge cases around `pendingRelease` marking (threshold crossed via + `addAttestor` re-adding a prior approver is not marked pending) deserve + focused auditor attention. +- The attestor's positional event decode is shape-checked but still assumes + field order; re-verify against metadata after any runtime upgrade (RB-5). diff --git a/monitor/src/main.ts b/monitor/src/main.ts index ee6d1b5e6..2340c0860 100644 --- a/monitor/src/main.ts +++ b/monitor/src/main.ts @@ -122,18 +122,23 @@ async function check(api: ApiPromise): Promise { const nextNonce = BigInt((await apiAt.query.tokenMigration.nextNonce()).toString()); // --- Base side --- + // All reads are pinned to one block: a release landing between unpinned + // reads would skew totalReleased vs. vaultBalance and trigger a false + // conservation alert (and auto-pause). + const blockNumber = await publicClient.getBlockNumber(); const [totalReleased, conversionFactor, tokenAddress] = await Promise.all([ - publicClient.readContract({ address: config.vaultAddress, abi: vaultAbi, functionName: "totalReleased" }), - publicClient.readContract({ address: config.vaultAddress, abi: vaultAbi, functionName: "conversionFactor" }), - publicClient.readContract({ address: config.vaultAddress, abi: vaultAbi, functionName: "token" }), + publicClient.readContract({ address: config.vaultAddress, abi: vaultAbi, functionName: "totalReleased", blockNumber }), + publicClient.readContract({ address: config.vaultAddress, abi: vaultAbi, functionName: "conversionFactor", blockNumber }), + publicClient.readContract({ address: config.vaultAddress, abi: vaultAbi, functionName: "token", blockNumber }), ]); const [totalSupply, vaultBalance] = await Promise.all([ - publicClient.readContract({ address: tokenAddress, abi: erc20Abi, functionName: "totalSupply" }), + publicClient.readContract({ address: tokenAddress, abi: erc20Abi, functionName: "totalSupply", blockNumber }), publicClient.readContract({ address: tokenAddress, abi: erc20Abi, functionName: "balanceOf", args: [config.vaultAddress], + blockNumber, }), ]); From 087d094c6c350249d3dc62b40b0e8c01c0a4a9ab Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 7 Jul 2026 22:34:51 +0200 Subject: [PATCH 11/23] contracts: Add .env.example documenting deployment parameters --- contracts/.env.example | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 contracts/.env.example diff --git a/contracts/.env.example b/contracts/.env.example new file mode 100644 index 000000000..9e33783e9 --- /dev/null +++ b/contracts/.env.example @@ -0,0 +1,28 @@ +# --- Deploy.s.sol (migration stack, PRD rollout phase 3) --- +# forge script script/Deploy.s.sol --rpc-url $BASE_RPC_URL --broadcast --verify + +# Bootstrap Safe that becomes vault admin (accepts via vault.acceptAdmin()) +ADMIN_SAFE= +# Fast pause guardian (small-threshold Safe) +GUARDIAN_SAFE= +# The five attestor transaction-sender addresses (decision D4) +ATTESTOR_1= +ATTESTOR_2= +ATTESTOR_3= +ATTESTOR_4= +ATTESTOR_5= +# Max issuance in 18-decimal units (decision D3), e.g. 160000000000000000000000000 for 160M +MAX_ISSUANCE= +# Initial caps in 18-decimal units (PRD V4: target < 1-2% of vault per day) +PER_RELEASE_CAP= +DAILY_CAP= +# Unix timestamp before which the remainder cannot be swept (decision D5) +EARLIEST_SWEEP_TS= + +# --- DeployGovernance.s.sol (phase 5) --- +PEN_TOKEN= +TIMELOCK_DELAY=172800 # 48h (PRD V5) +VOTING_DELAY=86400 # 1 day, seconds (timestamp clock) +VOTING_PERIOD=432000 # 5 days +PROPOSAL_THRESHOLD= # token units required to propose +QUORUM_FRACTION=4 # percent of total supply; start low (PRD G1) From 1f61407776004a77dc0379c41d5f1d5e1fae161c Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 7 Jul 2026 22:46:15 +0200 Subject: [PATCH 12/23] docs: Add implementation overview of the PEN migration work across both repos --- docs/pen-migration-implementation-overview.md | 88 +++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 docs/pen-migration-implementation-overview.md diff --git a/docs/pen-migration-implementation-overview.md b/docs/pen-migration-implementation-overview.md new file mode 100644 index 000000000..448b962aa --- /dev/null +++ b/docs/pen-migration-implementation-overview.md @@ -0,0 +1,88 @@ +# PEN → Base Migration — Implementation Overview + +**Date:** 2026-07-07 +**Branches:** `feat/pen-base-migration` in this repo and in the portal repo +(`~/Documents/portal`, based on the React 19 branch `fix-issues-with-new-ss58format`; +note: `origin/staging` there is still the older Preact codebase). + +This document is the map of everything built for the migration. Design and +requirements live in the [PRD](pen-base-migration-prd.md); the approach +rationale in [ADR-001](adr-001-pen-base-migration-approach.md); the token +extension decisions in [token standards](pen-token-contract-standards.md). + +## Architecture recap (one paragraph) + +PEN holders call `tokenMigration.migrate(amount, base_address)` on Pendulum; +the amount is burned and a `MigrationInitiated` event with a unique nonce is +emitted. Five independent attestor daemons watch relay-finalized blocks (each +on its own node) and submit `approve(nonce, recipient, amount)` to the +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 — worst-case loss is bounded by the vault's rate caps, +watched by an independent monitor that can auto-pause. One-way by design. + +## Components delivered + +### Pendulum repo (`feat/pen-base-migration`) + +| Component | Location | Status | +|---|---|---| +| `token-migration` pallet | `pallets/token-migration/` | Burn-and-emit extrinsic, unique nonces, dust/ED + lock handling, pause origin; 10 unit tests + benchmark test suite (frame-benchmarking v2) | +| Runtime wiring | `runtime/pendulum/src/lib.rs` | Pallet index 102, min amount 1 PEN, pause = root/half-council or 2/3 technical committee, added to `BaseFilter` whitelist and `define_benchmarks`; compiles with and without `runtime-benchmarks` (Foucoco intentionally skipped — production-direct decision) | +| `PEN.sol` | `contracts/src/` | Fixed-supply `ERC20 + ERC20Permit + ERC20Votes`, EIP-6372 timestamp clock, full supply minted to vault, no owner/mint/proxy | +| `MigrationVault.sol` | `contracts/src/` | 3-of-5 on-chain approvals per exact tuple, permanent nonce consumption, 12→18 decimal conversion in one place, per-release + daily caps (defer, not kill), guardian pause (approvals recorded while paused), rotation retroactively invalidates removed attestors, two-step admin, pending-release accounting protecting the timelocked remainder sweep | +| `PENGovernor.sol` | `contracts/src/` | OZ Governor composition through a TimelockController (hybrid governance, timestamp clock) | +| Deploy scripts | `contracts/script/` | `Deploy.s.sol` (vault→token→setToken dance, admin handover to bootstrap Safe), `DeployGovernance.s.sol` (timelock+governor role wiring, deployer admin renounced); parameters documented in `contracts/.env.example` | +| Contract tests | `contracts/test/` | 30 Foundry tests incl. fuzz (supply invariant), full Governor proposal lifecycle, replay/race/rotation/caps/pause/sweep-pending scenarios | +| Attestor daemon | `attestor/` | TypeScript; finalized-heads-only, strictly ordered blocks, crash-safe checkpoint, idempotent + race-tolerant approvals, fail-fast on decode errors (4-field shape asserted), startup set-membership check, low-gas/webhook alerts; ops guide in its README | +| Invariant monitor | `monitor/` | Independent watchdog: conservation checks (block-pinned reads) + per-nonce liveness; webhook alerts; optional guardian auto-pause | +| Runbooks | `docs/pen-migration-runbooks.md` | RB-1…RB-6: key compromise, outage, invariant breach, pause/unpause, runtime upgrade, attestor rotation | +| Internal security review | `docs/pen-migration-internal-review.md` | Independent adversarial pass; 2 high + 1 medium findings, all fixed (see below) | + +### Portal repo (`feat/pen-base-migration`) + +| Component | Location | Status | +|---|---|---| +| Migration page | `src/pages/migration/` | Amount validation (transferable, minimum, migrate-all-or-leave-ED), EIP-55 address validation with checksummed preview, `eth_getCode` contract-destination warning + extra confirmation, irreversibility confirmation, pause banner, locked-balance hint, post-finalization release tracking (approvals x/3 → released, BaseScan link) | +| Pallet hook | `src/hooks/migration/useMigrationPallet.tsx` | Extrinsic submission resolving at finality with the emitted nonce; pause query; on-chain constants | +| Base status hook | `src/hooks/migration/useBaseReleaseStatus.ts` | Polls the vault over plain JSON-RPC (no EVM dependency; selectors precomputed, keccak via `@polkadot/util-crypto`) | +| EVM helpers | `src/helpers/ethereum.ts` | EIP-55 checksum, payload-hash mirroring the vault's `abi.encode`, minimal `eth_call`/`eth_getCode` client | +| Config | `src/constants/migration.ts` | Vault address via `VITE_MIGRATION_VAULT_ADDRESS`, Base RPC via `VITE_BASE_RPC_URL`; page degrades gracefully when unset | +| Routing/nav | `src/app.tsx`, `src/components/Layout/links.tsx` | `/pendulum/migration`; nav item hidden on other tenants | + +## Internal security review — summary + +Adversarial review of the whole stack found and fixed: (1) attestor daemons +crash-looping on the *normal* 3-of-5 approval race — now a benign re-checked +skip; (2) monitor reads not pinned to one block — could false-positive a +conservation alert and auto-pause the vault; (3) `sweepRemainder` could +strand quorum-approved-but-deferred releases — now excluded via +`pendingApprovedAmount` accounting with a timelocked `clearStalePending` +restricted to consumed nonces. Details and verified-not-vulnerable list in +[pen-migration-internal-review.md](pen-migration-internal-review.md). + +## Verification status + +- Pallet: `cargo test -p token-migration` 10/10; with `runtime-benchmarks` 11/11. +- Runtime: `cargo check -p pendulum-runtime` clean, both feature sets. +- Contracts: `forge test` 30/30 (incl. 512-run fuzz). +- Attestor & monitor: `tsc --noEmit` clean. +- Portal: `tsc --noEmit` and ESLint clean; committed through lint-staged. + +## Commit map (this repo) + +`docs → pallet → contracts(core) → runtime wiring → contracts(governance) → +attestor → monitor → runbooks → benchmarks → security fixes → env template` +— see `git log` on the branch for hashes. + +## Still open (cannot be done from the repo) + +1. **Decisions D1–D6** (PRD §4.2) — most urgently max issuance (D3) and + decimals (D2), both baked into immutable contracts at deployment. +2. External audits (PRD §9) — the internal review doc is the starting brief. +3. Benchmark run on reference hardware → replace manual weights. +4. Attestor operator onboarding + key ceremonies; Safe setups (D4). +5. Exchange coordination, DefiLlama/CoinGecko supply endpoints, comms. +6. Portal deploy config: set `VITE_MIGRATION_VAULT_ADDRESS` once deployed; + decide whether the portal feature must be ported to the Preact `staging` + branch or ships with the React 19 codebase. From 5e7e322e3900b465fa7ceac2f4c15bc5817d16f6 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 7 Jul 2026 23:01:16 +0200 Subject: [PATCH 13/23] security: Fix round-2 audit findings (zero-address DoS, rotation bypass) 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). --- attestor/src/main.ts | 16 +++++++++++ contracts/src/MigrationVault.sol | 41 +++++++++++++++++++++++---- contracts/test/MigrationVault.t.sol | 26 +++++++++++++++++ docs/pen-migration-internal-review.md | 39 +++++++++++++++++++++++++ pallets/token-migration/src/lib.rs | 7 +++++ pallets/token-migration/src/tests.rs | 10 +++++++ 6 files changed, 133 insertions(+), 6 deletions(-) diff --git a/attestor/src/main.ts b/attestor/src/main.ts index 1df649254..913b1e295 100644 --- a/attestor/src/main.ts +++ b/attestor/src/main.ts @@ -146,10 +146,26 @@ async function alreadyHandled(event: MigrationEvent): Promise { }); } +const ZERO_ADDRESS = "0x0000000000000000000000000000000000000000"; + +/** True when the vault will deterministically reject this tuple, no matter + * who submits it or when. Such an event must be skipped (with a critical + * alert), never retried: crash-looping on it would halt the entire fleet at + * this block and block every migration behind it. The pallet rejects these + * inputs, so seeing one here means a pallet/vault validation mismatch. */ +function isUnreleasable(event: MigrationEvent): boolean { + return event.recipient.toLowerCase() === ZERO_ADDRESS || event.palletAmount === 0n; +} + /** Submit the approval for one migration event, skipping work already done. */ async function approve(event: MigrationEvent): Promise { const label = `nonce=${event.nonce} recipient=${event.recipient} amount=${event.palletAmount}`; + if (isUnreleasable(event)) { + await alert("CRITICAL: unreleasable migration event skipped permanently", label); + return; + } + if (await alreadyHandled(event)) { log(`skip (already released or approved): ${label}`); return; diff --git a/contracts/src/MigrationVault.sol b/contracts/src/MigrationVault.sol index 5d85a8963..119a38036 100644 --- a/contracts/src/MigrationVault.sol +++ b/contracts/src/MigrationVault.sol @@ -108,7 +108,16 @@ contract MigrationVault { /// against the *current* attestor set, so removing a compromised /// attestor retroactively invalidates its approvals (PRD V6). mapping(bytes32 => address[]) internal _approvers; - mapping(bytes32 => mapping(address => bool)) public hasApproved; + mapping(bytes32 => mapping(address => bool)) internal _inApprovers; + + /// @dev Generation of each attestor address, bumped on every addAttestor. + /// An approval only counts while its recorded generation matches the + /// attestor's current one, so approvals from before a removal can + /// never count again after a re-add — the threshold can only ever be + /// crossed inside approve(), which maintains the pending-release + /// accounting that protects sweepRemainder. + mapping(address => uint64) public attestorGeneration; + mapping(bytes32 => mapping(address => uint64)) internal _approvalGeneration; /// @notice Total token units released so far (for the invariant monitor: /// balanceOf(vault) + totalReleased == totalSupply). @@ -161,6 +170,7 @@ contract MigrationVault { if (attestor == address(0)) revert ZeroAddress(); if (isAttestor[attestor]) revert DuplicateAttestor(); isAttestor[attestor] = true; + attestorGeneration[attestor] = 1; emit AttestorAdded(attestor); } attestorCount = attestors_.length; @@ -191,9 +201,13 @@ contract MigrationVault { if (nonceConsumed[nonce]) revert NonceAlreadyConsumed(nonce); bytes32 payload = payloadHash(nonce, recipient, palletAmount); - if (hasApproved[payload][msg.sender]) revert AlreadyApproved(msg.sender); - hasApproved[payload][msg.sender] = true; - _approvers[payload].push(msg.sender); + uint64 generation = attestorGeneration[msg.sender]; + if (_approvalGeneration[payload][msg.sender] == generation) revert AlreadyApproved(msg.sender); + _approvalGeneration[payload][msg.sender] = generation; + if (!_inApprovers[payload][msg.sender]) { + _inApprovers[payload][msg.sender] = true; + _approvers[payload].push(msg.sender); + } emit Approved(nonce, recipient, palletAmount, msg.sender); // Opportunistic release: skipped (not reverted) when paused or a cap @@ -269,14 +283,26 @@ contract MigrationVault { } /// @notice Approvals for a payload counted against the current attestor - /// set. Removed attestors no longer count; re-added ones do. + /// set and generation. Removed attestors no longer count, and a + /// re-added attestor must approve again (its pre-removal approval + /// belongs to an older generation). function activeApprovals(bytes32 payload) public view returns (uint256 count) { address[] storage approvers = _approvers[payload]; for (uint256 i = 0; i < approvers.length; i++) { - if (isAttestor[approvers[i]]) count++; + address approver = approvers[i]; + if (isAttestor[approver] && _approvalGeneration[payload][approver] == attestorGeneration[approver]) { + count++; + } } } + /// @notice Whether `attestor` holds a currently-valid approval for the + /// payload (i.e. one from its current generation). + function hasApproved(bytes32 payload, address attestor) public view returns (bool) { + uint64 generation = attestorGeneration[attestor]; + return generation != 0 && _approvalGeneration[payload][attestor] == generation; + } + function approversOf(bytes32 payload) external view returns (address[] memory) { return _approvers[payload]; } @@ -312,6 +338,9 @@ contract MigrationVault { if (attestor == address(0)) revert ZeroAddress(); if (isAttestor[attestor]) revert DuplicateAttestor(); isAttestor[attestor] = true; + // New generation: any approvals this address recorded before a prior + // removal stop counting, so this call can never cross a threshold. + attestorGeneration[attestor] += 1; attestorCount += 1; emit AttestorAdded(attestor); } diff --git a/contracts/test/MigrationVault.t.sol b/contracts/test/MigrationVault.t.sol index d5fab2973..2c795f3ac 100644 --- a/contracts/test/MigrationVault.t.sol +++ b/contracts/test/MigrationVault.t.sol @@ -229,6 +229,32 @@ contract MigrationVaultTest is Test { assertEq(pen.balanceOf(recipient), 5e18); } + function test_ReaddingAttestorNeverCrossesThresholdSilently() public { + // Two approvals, then the first approver is removed and later re-added. + approveAs(0, 0, recipient, 5e12); + approveAs(1, 0, recipient, 5e12); + + vm.startPrank(admin); + vault.removeAttestor(attestors[0]); + vault.addAttestor(attestors[0]); + vm.stopPrank(); + + // The re-add must NOT resurrect the pre-removal approval: crossing the + // threshold outside approve() would bypass pending-release accounting + // and let sweepRemainder strand the migration. + bytes32 payload = vault.payloadHash(0, recipient, 5e12); + assertEq(vault.activeApprovals(payload), 1, "old-generation approval must not count"); + assertFalse(vault.hasApproved(payload, attestors[0])); + + // The re-added attestor approves again (new generation) — allowed, and + // together with a third attestor the release executes through approve(). + approveAs(0, 0, recipient, 5e12); + assertEq(vault.activeApprovals(payload), 2); + approveAs(2, 0, recipient, 5e12); + assertEq(pen.balanceOf(recipient), 5e18); + assertEq(vault.pendingApprovedAmount(), 0); + } + function test_CannotRemoveAttestorBelowThreshold() public { vm.startPrank(admin); vault.removeAttestor(attestors[0]); diff --git a/docs/pen-migration-internal-review.md b/docs/pen-migration-internal-review.md index 05c6b817b..8535ab6ca 100644 --- a/docs/pen-migration-internal-review.md +++ b/docs/pen-migration-internal-review.md @@ -60,6 +60,45 @@ to their migrator and can never be cleared. Covered by three new tests. - **Admin takeover / role wiring:** two-step admin transfer; deploy scripts leave no dangling deployer privileges; timelock self-administered. +## Round 2 (2026-07-07, second independent reviewer over the full diff of both repos) + +### C1. CRITICAL — Zero-address migration deadlocked the entire attestor fleet +`migrate(amount, H160::zero())` was accepted by the pallet and the portal, but +the vault deterministically rejects a zero recipient. Every attestor would hit +the same permanent revert at the same block, alert, exit, and — because the +checkpoint only advances after a block fully processes — crash-loop forever. +One 1-PEN transaction could halt every migration behind it for all five +operators simultaneously. + +**Resolution (fixed, defense in depth):** the pallet rejects +`H160::zero()` (`InvalidBaseAddress`, with test); the portal validator rejects +the zero address; the attestor statically detects vault-unreleasable tuples +(zero recipient/amount), raises a distinct CRITICAL alert, and skips past the +event instead of crash-looping — such an event can now only mean a +pallet/vault validation mismatch. + +### H1. HIGH — Re-adding a removed attestor could cross a threshold outside `approve()` +`activeApprovals` counted historical approvals against the current attestor +set, so `addAttestor` re-adding an address with stale recorded approvals could +push a payload over the threshold without running the pending-release +accounting in `approve()` — re-opening the sweep-stranding hole of round-1 +finding 3 through a rotation side door. + +**Resolution (fixed structurally):** attestor **generations**. Every +`addAttestor` bumps the address's generation and approvals only count while +their recorded generation matches — a re-added attestor must approve again, so +the threshold can only ever be crossed inside `approve()`. The public +`hasApproved` view now means "holds a currently-valid approval" (same ABI, so +the daemon keeps working and correctly re-approves after a re-add). Covered by +a regression test. + +### Round 2 explicitly verified as not vulnerable +Portal EIP-55 implementation and keccak string semantics; portal/attestor +payload-hash construction exactly mirroring the vault's `abi.encode`; +pallet↔attestor↔vault↔portal event-field alignment; replay/reentrancy/ +conflicting-tuple logic (re-confirmed); monitor block-pinning fix; +`clearStalePending` restrictions; deploy-script role wiring. + ## Follow-ups for the external audit - The cap-accounting window (`currentDay` bucketing) and attestor-rotation edge cases around `pendingRelease` marking (threshold crossed via diff --git a/pallets/token-migration/src/lib.rs b/pallets/token-migration/src/lib.rs index 22e8fe8af..21b85c7f6 100644 --- a/pallets/token-migration/src/lib.rs +++ b/pallets/token-migration/src/lib.rs @@ -93,6 +93,10 @@ pub mod pallet { /// The migration would leave a remainder below the existential deposit. /// Migrate the entire balance or leave at least the existential deposit. WouldLeaveDust, + /// The Base address is structurally invalid (e.g. the zero address). + /// The vault on Base would reject the release, permanently stranding + /// the burned tokens and stalling the attestor pipeline. + InvalidBaseAddress, } /// Nonce of the next migration. Monotonically increasing, never reused; @@ -130,6 +134,9 @@ pub mod pallet { amount >= T::MinimumMigrationAmount::get(), Error::::AmountBelowMinimum ); + // The vault contract rejects the zero address; burning towards it + // would emit an event no attestor can ever execute. + ensure!(base_address != H160::zero(), Error::::InvalidBaseAddress); let free = T::Currency::free_balance(&who); let remainder = free.checked_sub(&amount).ok_or(Error::::InsufficientBalance)?; diff --git a/pallets/token-migration/src/tests.rs b/pallets/token-migration/src/tests.rs index ada92434f..39ea664dd 100644 --- a/pallets/token-migration/src/tests.rs +++ b/pallets/token-migration/src/tests.rs @@ -74,6 +74,16 @@ fn migrate_fails_below_minimum_amount() { }); } +#[test] +fn migrate_fails_for_zero_base_address() { + run_test(|| { + assert_noop!( + TokenMigration::migrate(RuntimeOrigin::signed(USER), UNIT, H160::zero()), + Error::::InvalidBaseAddress + ); + }); +} + #[test] fn migrate_fails_with_insufficient_balance() { run_test(|| { From 420b02466f55b1ce0fe44f26f133ee1ca8b86c58 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 7 Jul 2026 23:44:23 +0200 Subject: [PATCH 14/23] security: Fix round-3 audit findings (sweep vs in-flight migrations) 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). --- contracts/src/MigrationVault.sol | 49 ++++++++++++++----- contracts/test/MigrationVault.t.sol | 69 +++++++++++++++++++++++++-- docs/pen-migration-internal-review.md | 60 +++++++++++++++++++++++ docs/pen-migration-runbooks.md | 36 ++++++++++++++ monitor/src/main.ts | 17 ++++--- 5 files changed, 208 insertions(+), 23 deletions(-) diff --git a/contracts/src/MigrationVault.sol b/contracts/src/MigrationVault.sol index 119a38036..760e9395a 100644 --- a/contracts/src/MigrationVault.sol +++ b/contracts/src/MigrationVault.sol @@ -44,6 +44,8 @@ contract MigrationVault { error ExceedsDailyCap(uint256 wouldBeReleasedToday, uint256 cap); error SweepNotYetAllowed(uint256 earliest); error PendingNotStale(); + error InsufficientVaultBalance(); + error ExceedsSweepable(uint256 requested, uint256 sweepable); // ---------------------------------------------------------------- events @@ -129,6 +131,12 @@ contract MigrationVault { uint256 public pendingApprovedAmount; mapping(bytes32 => bool) public pendingRelease; + /// @notice Total token units swept out via `sweepRemainder`. Tracked so + /// the invariant monitor's conservation check stays exact after a + /// window-close sweep: balanceOf(vault) + totalReleased + + /// totalSwept == totalSupply at all times. + uint256 public totalSwept; + // ---------------------------------------------------------------- modifiers modifier onlyAdmin() { @@ -215,8 +223,15 @@ contract MigrationVault { // called by anyone later to retry. if (activeApprovals(payload) >= threshold) { uint256 tokenAmount = palletAmount * conversionFactor; + // Insufficient balance is included here deliberately: if the vault + // was over-swept, the release is deferred (marked pending) rather + // than reverting. A revert here would roll back this approval and, + // because every attestor hits it identically, permanently + // crash-loop the fleet on this block. Deferral keeps the debt + // tracked and recoverable once the vault is refunded. bool releasable = !paused && address(token) != address(0) && tokenAmount <= perReleaseCap - && _releasedTodayAfterRoll() + tokenAmount <= dailyCap; + && _releasedTodayAfterRoll() + tokenAmount <= dailyCap + && token.balanceOf(address(this)) >= tokenAmount; if (releasable) { _release(nonce, recipient, palletAmount, payload); } else if (!pendingRelease[payload]) { @@ -244,6 +259,7 @@ contract MigrationVault { if (tokenAmount > perReleaseCap) revert ExceedsPerReleaseCap(tokenAmount, perReleaseCap); uint256 releasedAfter = _releasedTodayAfterRoll() + tokenAmount; if (releasedAfter > dailyCap) revert ExceedsDailyCap(releasedAfter, dailyCap); + if (token.balanceOf(address(this)) < tokenAmount) revert InsufficientVaultBalance(); _release(nonce, recipient, palletAmount, payload); } @@ -297,10 +313,11 @@ contract MigrationVault { } /// @notice Whether `attestor` holds a currently-valid approval for the - /// payload (i.e. one from its current generation). + /// payload — i.e. it is a current attestor and its approval is + /// from its current generation. Mirrors the conditions + /// `activeApprovals` counts, so a removed attestor reports false. function hasApproved(bytes32 payload, address attestor) public view returns (bool) { - uint64 generation = attestorGeneration[attestor]; - return generation != 0 && _approvalGeneration[payload][attestor] == generation; + return isAttestor[attestor] && _approvalGeneration[payload][attestor] == attestorGeneration[attestor]; } function approversOf(bytes32 payload) external view returns (address[] memory) { @@ -384,16 +401,24 @@ contract MigrationVault { emit AdminTransferred(msg.sender); } - /// @notice Sweep the unmigrated remainder after the migration window - /// closes (destination decided by governance, PRD D5). Amounts - /// owed to threshold-approved-but-deferred releases are excluded, - /// so a sweep can never strand a burned-but-unreleased migration. - function sweepRemainder(address to) external onlyAdmin { + /// @notice Sweep up to `amount` of the unmigrated remainder after the + /// migration window closes (destination decided by governance, + /// PRD D5). The caller must pass an explicit amount, bounded by + /// `balance − pendingApprovedAmount`, forcing a conscious + /// reconciliation against the monitor's outstanding-nonce count + /// (runbook RB-7) rather than blindly sweeping everything — + /// `pendingApprovedAmount` only reserves threshold-approved + /// releases, not migrations still gathering approvals. + function sweepRemainder(address to, uint256 amount) external onlyAdmin { if (block.timestamp < earliestSweepTimestamp) revert SweepNotYetAllowed(earliestSweepTimestamp); if (to == address(0)) revert ZeroAddress(); if (address(token) == address(0)) revert TokenNotSet(); - uint256 sweepable = token.balanceOf(address(this)) - pendingApprovedAmount; - token.safeTransfer(to, sweepable); - emit RemainderSwept(to, sweepable); + uint256 balanceHeld = token.balanceOf(address(this)); + // Saturating: a prior over-sweep can leave pending > balance; never revert on underflow. + uint256 sweepable = balanceHeld > pendingApprovedAmount ? balanceHeld - pendingApprovedAmount : 0; + if (amount > sweepable) revert ExceedsSweepable(amount, sweepable); + totalSwept += amount; + token.safeTransfer(to, amount); + emit RemainderSwept(to, amount); } } diff --git a/contracts/test/MigrationVault.t.sol b/contracts/test/MigrationVault.t.sol index 2c795f3ac..406e8f1c7 100644 --- a/contracts/test/MigrationVault.t.sol +++ b/contracts/test/MigrationVault.t.sol @@ -273,7 +273,7 @@ contract MigrationVaultTest is Test { vm.expectRevert(MigrationVault.NotAdmin.selector); vault.addAttestor(makeAddr("x")); vm.expectRevert(MigrationVault.NotAdmin.selector); - vault.sweepRemainder(makeAddr("x")); + vault.sweepRemainder(makeAddr("x"), 1); vm.stopPrank(); } @@ -292,13 +292,14 @@ contract MigrationVaultTest is Test { address treasury = makeAddr("treasury"); vm.prank(admin); vm.expectRevert(abi.encodeWithSelector(MigrationVault.SweepNotYetAllowed.selector, earliestSweep)); - vault.sweepRemainder(treasury); + vault.sweepRemainder(treasury, MAX_ISSUANCE); vm.warp(earliestSweep); vm.prank(admin); - vault.sweepRemainder(treasury); + vault.sweepRemainder(treasury, MAX_ISSUANCE); assertEq(pen.balanceOf(treasury), MAX_ISSUANCE); assertEq(pen.balanceOf(address(vault)), 0); + assertEq(vault.totalSwept(), MAX_ISSUANCE); } // ---------------------------------------------------------------- pending-release accounting @@ -314,8 +315,15 @@ contract MigrationVaultTest is Test { address treasury = makeAddr("treasury"); vm.warp(earliestSweep); + // The pending (owed) amount is not sweepable. vm.prank(admin); - vault.sweepRemainder(treasury); + vm.expectRevert( + abi.encodeWithSelector(MigrationVault.ExceedsSweepable.selector, MAX_ISSUANCE, MAX_ISSUANCE - 2_000_000e18) + ); + vault.sweepRemainder(treasury, MAX_ISSUANCE); + + vm.prank(admin); + vault.sweepRemainder(treasury, MAX_ISSUANCE - 2_000_000e18); assertEq(pen.balanceOf(treasury), MAX_ISSUANCE - 2_000_000e18); assertEq(pen.balanceOf(address(vault)), 2_000_000e18, "owed amount stays in the vault"); @@ -371,6 +379,53 @@ contract MigrationVaultTest is Test { assertEq(vault.pendingApprovedAmount(), 0); } + // A migration that was still gathering approvals when the vault was + // over-swept must NOT crash the attestor fleet, and must stay recoverable. + function test_OverSweptInFlightMigrationDefersAndRecovers() public { + // Bob's migration has 2 of 3 approvals — sub-threshold, so nothing is + // reserved in pendingApprovedAmount yet. + approveAs(0, 42, recipient, 5e12); + approveAs(1, 42, recipient, 5e12); + assertEq(vault.pendingApprovedAmount(), 0); + + // Admin sweeps the entire (unreserved) balance at window close. + address treasury = makeAddr("treasury"); + vm.warp(earliestSweep); + vm.prank(admin); + vault.sweepRemainder(treasury, MAX_ISSUANCE); + assertEq(pen.balanceOf(address(vault)), 0); + + // The 3rd approval crosses the threshold with an empty vault. This must + // NOT revert (which would crash-loop every attestor); it defers instead. + approveAs(2, 42, recipient, 5e12); + assertFalse(vault.nonceConsumed(42)); + assertEq(vault.pendingApprovedAmount(), 5e18, "owed amount now tracked as pending"); + + // A standalone release attempt reverts cleanly (distinct error). + vm.expectRevert(MigrationVault.InsufficientVaultBalance.selector); + vault.release(42, recipient, 5e12); + + // Governance refunds the vault; the release then completes — recoverable. + vm.prank(treasury); + pen.transfer(address(vault), 5e18); + vault.release(42, recipient, 5e12); + assertEq(pen.balanceOf(recipient), 5e18); + assertEq(vault.pendingApprovedAmount(), 0); + } + + function test_HasApprovedFalseForRemovedAttestor() public { + bytes32 payload = vault.payloadHash(0, recipient, 5e12); + approveAs(0, 0, recipient, 5e12); + assertTrue(vault.hasApproved(payload, attestors[0])); + + // Removed and never re-added (the standard RB-1 response): hasApproved + // must agree with activeApprovals and report false. + vm.prank(admin); + vault.removeAttestor(attestors[0]); + assertFalse(vault.hasApproved(payload, attestors[0])); + assertEq(vault.activeApprovals(payload), 0); + } + // ---------------------------------------------------------------- fuzz function testFuzz_ReleasePreservesSupplyInvariant(uint64 nonce, uint96 palletAmount) public { @@ -380,6 +435,10 @@ contract MigrationVaultTest is Test { approveAs(2, nonce, recipient, palletAmount); assertEq(pen.balanceOf(recipient), uint256(palletAmount) * CONVERSION_FACTOR); - assertEq(pen.balanceOf(address(vault)) + vault.totalReleased(), pen.totalSupply()); + // Conservation incl. the sweep accumulator (monitor's M2b formula). + assertEq( + pen.balanceOf(address(vault)) + vault.totalReleased() + vault.totalSwept(), + pen.totalSupply() + ); } } diff --git a/docs/pen-migration-internal-review.md b/docs/pen-migration-internal-review.md index 8535ab6ca..46364d4b6 100644 --- a/docs/pen-migration-internal-review.md +++ b/docs/pen-migration-internal-review.md @@ -99,6 +99,66 @@ pallet↔attestor↔vault↔portal event-field alignment; replay/reentrancy/ conflicting-tuple logic (re-confirmed); monitor block-pinning fix; `clearStalePending` restrictions; deploy-script role wiring. +## Round 3 (2026-07-07, third independent reviewer, focused on the round-1/2 fixes) + +All three findings trace to the round-1 `sweepRemainder`/`pendingApprovedAmount` +mechanism never being re-verified against *sub-threshold* in-flight migrations +or against the monitor's conservation formula. + +### C1(r3). CRITICAL — `sweepRemainder` could strand an in-flight migration and crash-loop the fleet +`pendingApprovedAmount` reserves only payloads that have already crossed the +threshold. A migration with 1–2 approvals at sweep time reserved nothing, so +`sweepRemainder` (which swept `balance − pendingApprovedAmount`) could remove +its tokens. When the remaining attestors then crossed the threshold, the +inline release in `approve()` reverted on insufficient balance — rolling back +the approval, and, because every attestor hit it identically, permanently +crash-looping 3-of-5 daemons and halting all future migrations. + +**Resolution (fixed):** +- `approve()` now includes vault balance in its `releasable` check, so an + under-funded release **defers** (marks pending) instead of reverting — the + fleet can never crash-loop on it, and the debt stays tracked and recoverable + after a governance refund. (`release()` gained a matching + `InsufficientVaultBalance` guard.) +- `sweepRemainder(to, amount)` now takes an explicit amount bounded by + `balance − pendingApprovedAmount` (saturating, so a prior over-sweep can't + cause an underflow revert), forcing conscious reconciliation. +- New runbook **RB-7** (window close) mandates pausing the pallet and + confirming zero outstanding nonces via the monitor before sweeping. +- Tests: `test_OverSweptInFlightMigrationDefersAndRecovers` proves the fleet + stays up and the migration recovers; sweep tests updated to the new + signature. + +### H1(r3). HIGH — Monitor's M2b check ignored `sweepRemainder` +`sweepRemainder` moved tokens out without touching `totalReleased`, so the +monitor's `balance + totalReleased == totalSupply` check would fire a +guaranteed false `VAULT BALANCE MISMATCH` — and auto-pause — on the first +legitimate window-close sweep. + +**Resolution (fixed):** the vault now tracks `totalSwept` (incremented in +`sweepRemainder`); the monitor checks `balance + totalReleased + totalSwept == +totalSupply`. RB-7 also notes a mismatch coinciding with a `RemainderSwept` +event is expected, not a compromise signal. The fuzz invariant test now +includes `totalSwept`. + +### M1(r3). MEDIUM — `hasApproved` disagreed with `activeApprovals` +`hasApproved` checked only the generation, not `isAttestor`, so it reported +`true` for an attestor removed and never re-added (the standard RB-1 outcome), +while that approval counts 0 toward releases. Low live impact (only the +attestor self-check reads it) but wrong on a public view meant for +auditability. + +**Resolution (fixed):** `hasApproved` now requires `isAttestor` too, mirroring +`activeApprovals`. Covered by `test_HasApprovedFalseForRemovedAttestor`. + +### Round 3 explicitly verified as not vulnerable +The H1 generation mechanism itself (no double-count, no stale re-match, no +double-increment of pending); C1's `isUnreleasable` completeness for the +`approve` revert set; reentrancy; governance cannot bypass the attestor quorum +or move the immutable sweep timestamp; deploy-script role wiring against the +actual vendored OZ v5.4.0 `TimelockController`; unbounded-`_approvers` +gas-griefing (not practically reachable). + ## Follow-ups for the external audit - The cap-accounting window (`currentDay` bucketing) and attestor-rotation edge cases around `pendingRelease` marking (threshold crossed via diff --git a/docs/pen-migration-runbooks.md b/docs/pen-migration-runbooks.md index 40ecf39ff..2b520e382 100644 --- a/docs/pen-migration-runbooks.md +++ b/docs/pen-migration-runbooks.md @@ -111,6 +111,42 @@ After enactment: 5. If daemons exit on the upgrade block: they hold position (checkpoint stays put) — fix decoding, redeploy, they resume without loss. +## RB-7: Window close and remainder sweep + +**Trigger:** the migration window is closing (per decision D5) and governance +wants to sweep the unmigrated remainder to its designated destination. + +**Why this needs care:** `sweepRemainder` reserves only *threshold-approved* +pending releases (`pendingApprovedAmount`). A migration that was burned on +Pendulum but is still gathering attestor approvals is **not** reserved — sweep +it and, while the attestor fleet no longer crash-loops (the release simply +defers and stays recoverable), that user's tokens must be restored by a +governance refund before they can be released. Avoid this by reconciling +first. + +1. **Stop new burns:** pause the pallet — `tokenMigration.setPaused(true)` via + governance/technical committee (RB-4). No new migrations can start. +2. **Wait a finality + processing buffer** (at least the relay finality window + plus a generous attestor-processing margin; hours, not minutes) so every + already-finalized migration reaches the vault and is released. +3. **Reconcile via the monitor:** confirm `TotalMigrated × conversionFactor == + totalReleased + pendingApprovedAmount` and that the monitor reports **zero** + outstanding/unreleased nonces for a sustained window. Resolve any pending + or deferred releases (raise caps / unpause / `release`) before proceeding. +4. **Compute the sweep amount** off-chain: `balance − pendingApprovedAmount`, + and sanity-check it against expected unmigrated supply. Do not sweep more. +5. **Sweep:** admin (timelock) calls `sweepRemainder(destination, amount)`. + The call reverts (`ExceedsSweepable`) if the amount exceeds + `balance − pendingApprovedAmount`, as a last-line guard. +6. **Verify:** `totalSwept` increased by `amount`; the monitor's conservation + check (`balance + totalReleased + totalSwept == totalSupply`) still holds + and does **not** alert (it accounts for `totalSwept`). + +**If a still-in-flight migration was swept anyway:** its release defers with +`InsufficientVaultBalance` and is marked pending. To make the user whole, +governance transfers the owed token amount back to the vault, then anyone +calls `release(nonce, recipient, palletAmount)`. + ## RB-6: Attestor set / threshold change (planned) 1. Admin Safe (timelocked post-handover: expect the configured delay between diff --git a/monitor/src/main.ts b/monitor/src/main.ts index 2340c0860..4ba2706e1 100644 --- a/monitor/src/main.ts +++ b/monitor/src/main.ts @@ -23,6 +23,7 @@ import { privateKeyToAccount } from "viem/accounts"; const vaultAbi = [ { type: "function", name: "totalReleased", stateMutability: "view", inputs: [], outputs: [{ type: "uint256" }] }, + { type: "function", name: "totalSwept", stateMutability: "view", inputs: [], outputs: [{ type: "uint256" }] }, { type: "function", name: "conversionFactor", stateMutability: "view", inputs: [], outputs: [{ type: "uint256" }] }, { type: "function", name: "token", stateMutability: "view", inputs: [], outputs: [{ type: "address" }] }, { type: "function", name: "paused", stateMutability: "view", inputs: [], outputs: [{ type: "bool" }] }, @@ -126,8 +127,9 @@ async function check(api: ApiPromise): Promise { // reads would skew totalReleased vs. vaultBalance and trigger a false // conservation alert (and auto-pause). const blockNumber = await publicClient.getBlockNumber(); - const [totalReleased, conversionFactor, tokenAddress] = await Promise.all([ + const [totalReleased, totalSwept, conversionFactor, tokenAddress] = await Promise.all([ publicClient.readContract({ address: config.vaultAddress, abi: vaultAbi, functionName: "totalReleased", blockNumber }), + publicClient.readContract({ address: config.vaultAddress, abi: vaultAbi, functionName: "totalSwept", blockNumber }), publicClient.readContract({ address: config.vaultAddress, abi: vaultAbi, functionName: "conversionFactor", blockNumber }), publicClient.readContract({ address: config.vaultAddress, abi: vaultAbi, functionName: "token", blockNumber }), ]); @@ -155,11 +157,14 @@ async function check(api: ApiPromise): Promise { return; } - // (M2b) Vault-internal conservation. - if (vaultBalance + totalReleased !== totalSupply) { + // (M2b) Vault-internal conservation. totalSwept accounts for the intended + // end-of-window sweep, which moves tokens out without touching + // totalReleased — omitting it would fire a guaranteed false positive (and + // auto-pause) on the first legitimate sweep. + if (vaultBalance + totalReleased + totalSwept !== totalSupply) { await alert( "VAULT BALANCE MISMATCH", - `balance ${vaultBalance} + released ${totalReleased} != supply ${totalSupply}`, + `balance ${vaultBalance} + released ${totalReleased} + swept ${totalSwept} != supply ${totalSupply}`, ); await pauseVault(); return; @@ -189,8 +194,8 @@ async function check(api: ApiPromise): Promise { } log( - `ok: migrated=${totalMigrated} released=${totalReleased} pending=${nonceFirstSeen.size} ` + - `vaultBalance=${vaultBalance}`, + `ok: migrated=${totalMigrated} released=${totalReleased} swept=${totalSwept} ` + + `pending=${nonceFirstSeen.size} vaultBalance=${vaultBalance}`, ); } From c4f0a2d31c9ac8ec83d69e2eaf6f3571197da3b1 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 7 Jul 2026 23:44:40 +0200 Subject: [PATCH 15/23] docs: Update overview with round-3 review and current test counts --- docs/pen-migration-implementation-overview.md | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/docs/pen-migration-implementation-overview.md b/docs/pen-migration-implementation-overview.md index 448b962aa..0e21cf59f 100644 --- a/docs/pen-migration-implementation-overview.md +++ b/docs/pen-migration-implementation-overview.md @@ -63,12 +63,18 @@ restricted to consumed nonces. Details and verified-not-vulnerable list in ## Verification status -- Pallet: `cargo test -p token-migration` 10/10; with `runtime-benchmarks` 11/11. +- Pallet: `cargo test -p token-migration` 11/11 (incl. benchmark suite). - Runtime: `cargo check -p pendulum-runtime` clean, both feature sets. -- Contracts: `forge test` 30/30 (incl. 512-run fuzz). +- Contracts: `forge test` 33/33 (incl. 512-run fuzz). - Attestor & monitor: `tsc --noEmit` clean. - Portal: `tsc --noEmit` and ESLint clean; committed through lint-staged. +Three internal audit rounds have run; each found real issues (including in the +prior round's fixes), all fixed and recorded in +[pen-migration-internal-review.md](pen-migration-internal-review.md). This +convergence pattern is why the external audit (PRD §9) remains a hard gate +before mainnet. + ## Commit map (this repo) `docs → pallet → contracts(core) → runtime wiring → contracts(governance) → From 9d8299ea5011a01d0a0a709787f91cd5c924168e Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Wed, 8 Jul 2026 10:15:42 +0200 Subject: [PATCH 16/23] security: Fix round-4 audit findings (threshold-cut strand, cap window) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- contracts/src/MigrationVault.sol | 60 +++++++++++---- contracts/test/MigrationVault.t.sol | 77 +++++++++++++++++-- docs/pen-migration-implementation-overview.md | 17 ++-- docs/pen-migration-internal-review.md | 43 ++++++++++- docs/pen-migration-runbooks.md | 9 +++ 5 files changed, 170 insertions(+), 36 deletions(-) diff --git a/contracts/src/MigrationVault.sol b/contracts/src/MigrationVault.sol index 760e9395a..454e55942 100644 --- a/contracts/src/MigrationVault.sol +++ b/contracts/src/MigrationVault.sol @@ -41,11 +41,12 @@ contract MigrationVault { error NotPaused(); error ZeroAmount(); error ExceedsPerReleaseCap(uint256 amount, uint256 cap); - error ExceedsDailyCap(uint256 wouldBeReleasedToday, uint256 cap); + error ExceedsDailyCap(uint256 requested, uint256 available); error SweepNotYetAllowed(uint256 earliest); error PendingNotStale(); error InsufficientVaultBalance(); error ExceedsSweepable(uint256 requested, uint256 sweepable); + error SweepSettlingAfterThresholdCut(uint256 allowedFrom); // ---------------------------------------------------------------- events @@ -92,10 +93,15 @@ contract MigrationVault { /// @notice Maximum token units released in a single migration. uint256 public perReleaseCap; - /// @notice Maximum token units released per UTC day. + /// @notice Maximum token units released in any rolling 24h window (PRD V4). + /// Enforced as a leaky bucket of capacity `dailyCap` that refills + /// linearly at `dailyCap` per day: a burst is capped at `dailyCap` + /// and a second burst must wait for the bucket to refill. There is + /// no instant reset at a calendar boundary. uint256 public dailyCap; - uint256 public currentDay; - uint256 public releasedToday; + /// @dev Consumed allowance recorded at `windowUpdatedAt`, before decay. + uint256 public windowConsumed; + uint256 public windowUpdatedAt; /// @notice Earliest timestamp at which the admin may sweep the unmigrated /// remainder (end-of-window handling, PRD V9 / decision D5). @@ -137,6 +143,17 @@ contract MigrationVault { /// totalSwept == totalSupply at all times. uint256 public totalSwept; + /// @notice Timestamp of the last threshold *decrease*. `sweepRemainder` is + /// blocked for `SWEEP_SETTLING_PERIOD` afterwards: lowering the + /// threshold can retroactively make a sub-threshold payload + /// releasable without registering it in `pendingApprovedAmount` + /// (that accounting is maintained only inside `approve()`), so the + /// delay gives the monitor and a permissionless `release()` time to + /// settle any newly-qualifying payload before a sweep could strand + /// it. See runbooks RB-6/RB-7. + uint256 public thresholdReducedAt; + uint256 public constant SWEEP_SETTLING_PERIOD = 7 days; + // ---------------------------------------------------------------- modifiers modifier onlyAdmin() { @@ -230,7 +247,7 @@ contract MigrationVault { // crash-loop the fleet on this block. Deferral keeps the debt // tracked and recoverable once the vault is refunded. bool releasable = !paused && address(token) != address(0) && tokenAmount <= perReleaseCap - && _releasedTodayAfterRoll() + tokenAmount <= dailyCap + && tokenAmount <= availableDailyAllowance() && token.balanceOf(address(this)) >= tokenAmount; if (releasable) { _release(nonce, recipient, palletAmount, payload); @@ -257,8 +274,8 @@ contract MigrationVault { uint256 tokenAmount = palletAmount * conversionFactor; if (tokenAmount > perReleaseCap) revert ExceedsPerReleaseCap(tokenAmount, perReleaseCap); - uint256 releasedAfter = _releasedTodayAfterRoll() + tokenAmount; - if (releasedAfter > dailyCap) revert ExceedsDailyCap(releasedAfter, dailyCap); + uint256 available = availableDailyAllowance(); + if (tokenAmount > available) revert ExceedsDailyCap(tokenAmount, available); if (token.balanceOf(address(this)) < tokenAmount) revert InsufficientVaultBalance(); _release(nonce, recipient, palletAmount, payload); @@ -272,7 +289,8 @@ contract MigrationVault { pendingRelease[payload] = false; pendingApprovedAmount -= tokenAmount; } - releasedToday += tokenAmount; + windowConsumed = _decayedConsumed() + tokenAmount; + windowUpdatedAt = block.timestamp; totalReleased += tokenAmount; token.safeTransfer(recipient, tokenAmount); emit Released(nonce, recipient, palletAmount, tokenAmount); @@ -324,14 +342,16 @@ contract MigrationVault { return _approvers[payload]; } - /// @dev Rolls the daily accounting window forward if a new UTC day started. - function _releasedTodayAfterRoll() internal returns (uint256) { - uint256 day = block.timestamp / 1 days; - if (day != currentDay) { - currentDay = day; - releasedToday = 0; - } - return releasedToday; + /// @dev Consumed allowance after linear refill since the last release. + function _decayedConsumed() internal view returns (uint256) { + uint256 refilled = ((block.timestamp - windowUpdatedAt) * dailyCap) / 1 days; + return windowConsumed > refilled ? windowConsumed - refilled : 0; + } + + /// @notice Token units releasable right now under the rolling daily cap. + function availableDailyAllowance() public view returns (uint256) { + uint256 consumed = _decayedConsumed(); + return dailyCap > consumed ? dailyCap - consumed : 0; } // ---------------------------------------------------------------- pause @@ -372,6 +392,11 @@ contract MigrationVault { function setThreshold(uint256 threshold_) external onlyAdmin { if (threshold_ < 2 || threshold_ > attestorCount) revert InvalidThreshold(); + // A decrease can retroactively qualify a sub-threshold payload without + // routing through approve() (which maintains pendingApprovedAmount); + // gate sweeps for a settling period so it can be detected and released + // first (round-4 finding). + if (threshold_ < threshold) thresholdReducedAt = block.timestamp; threshold = threshold_; emit ThresholdUpdated(threshold_); } @@ -411,6 +436,9 @@ contract MigrationVault { /// releases, not migrations still gathering approvals. function sweepRemainder(address to, uint256 amount) external onlyAdmin { if (block.timestamp < earliestSweepTimestamp) revert SweepNotYetAllowed(earliestSweepTimestamp); + if (block.timestamp < thresholdReducedAt + SWEEP_SETTLING_PERIOD) { + revert SweepSettlingAfterThresholdCut(thresholdReducedAt + SWEEP_SETTLING_PERIOD); + } if (to == address(0)) revert ZeroAddress(); if (address(token) == address(0)) revert TokenNotSet(); uint256 balanceHeld = token.balanceOf(address(this)); diff --git a/contracts/test/MigrationVault.t.sol b/contracts/test/MigrationVault.t.sol index 406e8f1c7..dcd4db115 100644 --- a/contracts/test/MigrationVault.t.sol +++ b/contracts/test/MigrationVault.t.sol @@ -147,33 +147,57 @@ contract MigrationVaultTest is Test { assertEq(pen.balanceOf(recipient), 2_000_000e18); } - function test_DailyCapRollsOverAtNextDay() public { + function test_DailyCapRefillsGraduallyOverRollingWindow() public { uint256 palletAmount = 1_000_000e12; // converts to exactly the per-release cap + // Consume the full daily cap (2 × 1M = DAILY_CAP). for (uint64 nonce = 0; nonce < 2; nonce++) { approveAs(0, nonce, recipient, palletAmount); approveAs(1, nonce, recipient, palletAmount); approveAs(2, nonce, recipient, palletAmount); } - // Both releases fit the daily cap exactly. assertEq(pen.balanceOf(recipient), 2_000_000e18); + assertEq(vault.availableDailyAllowance(), 0); - // A third release today is deferred by the daily cap... + // A third release is deferred: the bucket is empty. approveAs(0, 2, recipient, palletAmount); approveAs(1, 2, recipient, palletAmount); approveAs(2, 2, recipient, palletAmount); assertEq(pen.balanceOf(recipient), 2_000_000e18); - vm.expectRevert( - abi.encodeWithSelector(MigrationVault.ExceedsDailyCap.selector, 3_000_000e18, DAILY_CAP) - ); + vm.expectRevert(abi.encodeWithSelector(MigrationVault.ExceedsDailyCap.selector, 1_000_000e18, 0)); vault.release(2, recipient, palletAmount); - // ...and anyone can retry it the next day. - vm.warp(block.timestamp + 1 days); + // Half a day later, exactly half the cap has refilled. + vm.warp(block.timestamp + 12 hours); + assertEq(vault.availableDailyAllowance(), 1_000_000e18); vault.release(2, recipient, palletAmount); assertEq(pen.balanceOf(recipient), 3_000_000e18); } + // The exploit the leaky bucket fixes: the old calendar-day bucket reset to + // zero at the UTC boundary, letting a compromised quorum release 2× the cap + // seconds apart. The rolling window must NOT refill instantly. + function test_DailyCapHasNoInstantResetAtBoundary() public { + // Sit one second before a UTC day boundary and consume the full cap. + vm.warp(10 days - 1); + uint256 palletAmount = 1_000_000e12; + for (uint64 nonce = 0; nonce < 2; nonce++) { + approveAs(0, nonce, recipient, palletAmount); + approveAs(1, nonce, recipient, palletAmount); + approveAs(2, nonce, recipient, palletAmount); + } + assertEq(pen.balanceOf(recipient), 2_000_000e18); + + // Cross the boundary by two seconds — negligible refill. The old + // calendar-day bucket would have fully reset to the cap here. + vm.warp(10 days + 1); + assertLt(vault.availableDailyAllowance(), 1_000e18); + approveAs(0, 2, recipient, palletAmount); + approveAs(1, 2, recipient, palletAmount); + approveAs(2, 2, recipient, palletAmount); + assertEq(pen.balanceOf(recipient), 2_000_000e18, "no instant reset at the day boundary"); + } + // ---------------------------------------------------------------- pause function test_PauseBlocksReleasesButKeepsRecordingApprovals() public { @@ -426,6 +450,43 @@ contract MigrationVaultTest is Test { assertEq(vault.activeApprovals(payload), 0); } + // Lowering the threshold can retroactively qualify a sub-threshold payload + // outside approve(), so a sweep is blocked for a settling period afterwards + // — giving ops time to release the now-qualifying payload first. + function test_ThresholdCutBlocksSweepDuringSettling() public { + uint256 settle = vault.SWEEP_SETTLING_PERIOD(); + address treasury = makeAddr("treasury"); + + // A payload sits at 2 approvals under threshold 3 — sub-threshold, so + // nothing is reserved in pendingApprovedAmount. + approveAs(0, 5, recipient, 5e12); + approveAs(1, 5, recipient, 5e12); + assertEq(vault.pendingApprovedAmount(), 0); + + // Reach the sweep window, then lower the threshold within it. + vm.warp(earliestSweep); + vm.prank(admin); + vault.setThreshold(2); + uint256 reducedAt = block.timestamp; + + // The sweep is blocked during settling, even though earliestSweep passed. + vm.prank(admin); + vm.expectRevert( + abi.encodeWithSelector(MigrationVault.SweepSettlingAfterThresholdCut.selector, reducedAt + settle) + ); + vault.sweepRemainder(treasury, 1e18); + + // Ops release the now-qualifying payload during settling (permissionless). + vault.release(5, recipient, 5e12); + assertEq(pen.balanceOf(recipient), 5e18); + + // After settling, the sweep proceeds normally. + vm.warp(reducedAt + settle); + vm.prank(admin); + vault.sweepRemainder(treasury, 1e18); + assertEq(pen.balanceOf(treasury), 1e18); + } + // ---------------------------------------------------------------- fuzz function testFuzz_ReleasePreservesSupplyInvariant(uint64 nonce, uint96 palletAmount) public { diff --git a/docs/pen-migration-implementation-overview.md b/docs/pen-migration-implementation-overview.md index 0e21cf59f..bdc10e824 100644 --- a/docs/pen-migration-implementation-overview.md +++ b/docs/pen-migration-implementation-overview.md @@ -65,15 +65,16 @@ restricted to consumed nonces. Details and verified-not-vulnerable list in - Pallet: `cargo test -p token-migration` 11/11 (incl. benchmark suite). - Runtime: `cargo check -p pendulum-runtime` clean, both feature sets. -- Contracts: `forge test` 33/33 (incl. 512-run fuzz). +- Contracts: `forge test` 35/35 (incl. 512-run fuzz). - Attestor & monitor: `tsc --noEmit` clean. -- Portal: `tsc --noEmit` and ESLint clean; committed through lint-staged. - -Three internal audit rounds have run; each found real issues (including in the -prior round's fixes), all fixed and recorded in -[pen-migration-internal-review.md](pen-migration-internal-review.md). This -convergence pattern is why the external audit (PRD §9) remains a hard gate -before mainnet. +- Portal: `yarn build` (tsc + vite) clean against `main`; committed through lint-staged. + +Four internal audit rounds have run; each found real issues (twice in a prior +round's own fix), all fixed and recorded in +[pen-migration-internal-review.md](pen-migration-internal-review.md). The +round-4 fixes touch the fund-release path and have not had a subsequent +internal round — the external audit (PRD §9) is the right next step and a hard +gate before mainnet, not more internal iteration. ## Commit map (this repo) diff --git a/docs/pen-migration-internal-review.md b/docs/pen-migration-internal-review.md index 46364d4b6..6db063489 100644 --- a/docs/pen-migration-internal-review.md +++ b/docs/pen-migration-internal-review.md @@ -159,10 +159,45 @@ or move the immutable sweep timestamp; deploy-script role wiring against the actual vendored OZ v5.4.0 `TimelockController`; unbounded-`_approvers` gas-griefing (not practically reachable). +## Round 4 (2026-07-08, resumed first reviewer, full-diff pass incl. the portal UI) + +Verified all eight round-1/2/3 fixes are correctly implemented and cleared the +portal migration UI (EIP-55, payload-hash parity, finality-gated submission). +Two novel findings, both in the same class as prior rounds — a threshold +crossed outside `approve()`, and the cap backstop: + +### H1(r4). HIGH — `setThreshold` decrease could retroactively strand a payload +Lowering the threshold can make a sub-threshold payload releasable without +routing through `approve()`, so its amount is never added to +`pendingApprovedAmount`; a later `sweepRemainder` could then sweep it, and its +`release()` reverts `InsufficientVaultBalance` until governance refunds. + +**Resolution (fixed):** `setThreshold` records the time of any decrease; +`sweepRemainder` is blocked for `SWEEP_SETTLING_PERIOD` (7 days) afterwards, +giving the monitor and a permissionless `release()` time to settle any +newly-qualifying payload first. Runbook RB-6 updated. Recoverable and +detectable even absent the guard (RB-7 reconciliation shows the shortfall). +Covered by `test_ThresholdCutBlocksSweepDuringSettling`. + +### H2(r4). HIGH — Daily cap was a fixed calendar-day bucket, not a rolling window +The `currentDay` bucket reset to zero at the UTC boundary, letting a +compromised quorum release `dailyCap` at 23:59 and again at 00:00 — 2× the +intended blast-radius bound (PRD V4 specifies a *rolling* 24h maximum). + +**Resolution (fixed):** replaced with a leaky-bucket rolling limiter — +`dailyCap` capacity refilling linearly at `dailyCap`/day +(`availableDailyAllowance()`), so a burst is capped at `dailyCap` and a second +burst must wait ~24h for the bucket to refill. No instant reset at any +boundary. Covered by `test_DailyCapRefillsGraduallyOverRollingWindow` and +`test_DailyCapHasNoInstantResetAtBoundary`. Residual: over a *rolling* 24h a +full bucket plus full refill still totals up to ~2× `dailyCap`, but spread over +24h rather than instantaneous — size `dailyCap` accordingly. + ## Follow-ups for the external audit -- The cap-accounting window (`currentDay` bucketing) and attestor-rotation - edge cases around `pendingRelease` marking (threshold crossed via - `addAttestor` re-adding a prior approver is not marked pending) deserve - focused auditor attention. +- These round-4 fixes touch the fund-release path and have **not** had a + subsequent internal round; they are the first thing the external audit should + re-derive. Four internal rounds have each found an issue (twice in a prior + round's own fix) — continued internal iteration shows diminishing returns + against real external review. - The attestor's positional event decode is shape-checked but still assumes field order; re-verify against metadata after any runtime upgrade (RB-5). diff --git a/docs/pen-migration-runbooks.md b/docs/pen-migration-runbooks.md index 2b520e382..6b364bf90 100644 --- a/docs/pen-migration-runbooks.md +++ b/docs/pen-migration-runbooks.md @@ -159,3 +159,12 @@ calls `release(nonce, recipient, palletAmount)`. migrations that relied on them simply need approvals from the remaining set (the new attestor's daemon backfills from its `START_BLOCK` — set it to a block before the oldest unreleased migration). +4. **Lowering the threshold** (`setThreshold` to a smaller value) can make a + payload that was one approval short suddenly releasable, *without* routing + through `approve()` — so its owed amount is not registered in + `pendingApprovedAmount`. The contract guards this: `sweepRemainder` is + blocked for `SWEEP_SETTLING_PERIOD` (7 days) after any threshold decrease. + During that window, call `release(...)` on every migration that the new, + lower threshold now satisfies (the M4 liveness monitor lists them), so each + is properly released or re-registered before the next sweep. Never sweep + right after cutting the threshold. From 06b16c6f7a64cda6df4bb967c7ebca66f533e06e Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Thu, 9 Jul 2026 09:48:38 +0200 Subject: [PATCH 17/23] docs: Add holder-facing governance guide with two worked examples --- docs/pen-governance-guide.md | 149 +++++++++++++++++++++++++++++++++++ 1 file changed, 149 insertions(+) create mode 100644 docs/pen-governance-guide.md diff --git a/docs/pen-governance-guide.md b/docs/pen-governance-guide.md new file mode 100644 index 000000000..c64aeb800 --- /dev/null +++ b/docs/pen-governance-guide.md @@ -0,0 +1,149 @@ +# PEN Governance After the Base Migration — How It Works + +| | | +|---|---| +| **Status** | Explainer (companion to the PRD/ADR) | +| **Audience** | PEN holders, contributors, prospective voters | +| **Related** | [PRD §6.7 / G1–G5](pen-base-migration-prd.md), [ADR-001 governance section](adr-001-pen-base-migration-approach.md), `contracts/src/PENGovernor.sol` | + +This is a plain-language walkthrough of the hybrid governance model, with two +real-world examples. It describes *how a decision gets made and executed* after +PEN has migrated to Base. + +## The three organs + +The whole model rests on one idea: **a decision routes to the organ that can +actually execute it.** + +- **PEN holders** are the electorate. Voting power is the delegated + `ERC20Votes` balance of PEN on Base. One practical catch: a holder has **zero + voting power until they delegate** (to themselves or a representative). + Holding tokens isn't voting; delegating is. +- **The on-chain track** — `PENGovernor` + a `TimelockController` — handles + anything that is a deterministic call to a Base contract the timelock + controls: the `MigrationVault` parameters (caps, attestor set, threshold, + guardian, remainder sweep) and any Base-side treasury the timelock owns. + Binding and trustless: nothing but a passed vote can move it. +- **The off-chain track** — Snapshot + an elected executor Safe — handles + decisions that aren't a single on-chain call: discretionary, multi-step, or + living off Base (including on the Pendulum parachain). Snapshot signals + intent gaslessly; the Safe carries it out. + +A **guardian Safe** (instant pause, no vote) and the **Pendulum technical +committee** (runtime and security actions on the parachain) sit outside the +token vote — they exist because some actions must be fast, or must run on a +chain the Base Governor can't reach. + +```mermaid +flowchart TD + H["PEN holders
delegated voting power"] + H --> A["On-chain track · binding"] + H --> B["Off-chain track · signaled"] + + A --> A1["Propose
target: vault.setCaps(…)"] + A1 --> A2["Vote · 5 days
quorum + majority"] + A2 --> A3["Queue → timelock
48-hour public delay"] + A3 --> A4["Execute
timelock calls the vault"] + A4 --> AX(["Example 1 · raise the daily cap"]) + + B --> B1["Snapshot proposal
gasless · vault excluded"] + B1 --> B2["Vote · ~7 days
PEN-on-Base holders sign"] + B2 --> B3["Executor Safe acts
elected multisig"] + B3 --> BX(["Example 2 · fund a liquidity program"]) + + G["Guardian Safe — emergency pause, no vote"] + T["Pendulum committee — runtime & security"] +``` + +The Governor's timing parameters are the deployment defaults and are themselves +governable: voting delay ~1 day, voting period 5 days, timelock delay 48 hours, +quorum a low fraction of supply at launch (raised as circulating supply grows), +plus a proposal threshold of voting power required to open a proposal. + +## Example 1 — raising the migration vault's daily cap (on-chain track) + +**The situation:** migration is live with deliberately conservative caps. +Volume picks up and legitimate migrations start getting deferred by the rolling +daily cap. The community wants to raise `dailyCap` and `perReleaseCap`. This +belongs on the on-chain track because it is exactly one deterministic action — +a call to `vault.setCaps(...)` — and the timelock is the vault's admin, so no +human needs discretion or custody. + +**How it plays out:** + +1. A delegate holding at least the proposal threshold of voting power calls + `propose(...)` with a single action: target `MigrationVault`, calldata + `setCaps(newPerRelease, newDaily)`, and a human-readable description. The + proposal appears on Tally in the pending state. +2. After the voting delay (~1 day — this is also when the voting-power snapshot + is taken, so buying tokens afterward can't influence the vote), voting opens + for five days. Delegates cast for, against, or abstain. To pass, the + proposal needs quorum and more for than against. +3. On success, anyone calls `queue(...)`, scheduling the action inside the + `TimelockController` behind a 48-hour delay. This delay is the safety valve: + for two days the exact pending change is public, and if it looks wrong the + guardian can pause the vault while the community reacts. +4. After the 48 hours, anyone calls `execute(...)`. The timelock — as the + vault's admin — makes the `setCaps` call. The cap is now raised. + +Start to finish is roughly **eight days**, and at no point does a trusted party +decide anything. The timelock is the only address that can call `setCaps`, and +it only ever acts on a vote that already passed. Attestor rotation, threshold +changes, and the end-of-window remainder sweep all run through this identical +path. + +## Example 2 — funding a liquidity and market-making program (off-chain track) + +**The situation:** the DAO wants to bootstrap PEN/USDC liquidity and retain a +market maker for six months, funded with, say, 2,000,000 PEN from the community +treasury. This does not reduce to one on-chain call — it means choosing a +market maker, negotiating terms, moving funds (possibly across venues or +chains), and exercising judgment over six months. That is what the off-chain +track is for. + +**How it plays out:** + +1. A holder posts the proposal to the forum for discussion, then creates a + Snapshot proposal. The voting strategy reads PEN balances on Base at a + snapshot block, with the `MigrationVault` address **excluded** — so the + large unmigrated balance in the vault can't vote, and quorum is measured + against circulating supply rather than total supply. +2. Voting runs for roughly a week and is **gasless**: holders sign messages, + they don't pay gas or even need to delegate. Choices can be a simple + for/against or several funding options. +3. If it passes, the elected executor Safe carries out the mandate — transfers + the PEN, contracts the market maker, and manages the engagement over its + lifetime. + +The honest trade-off: Snapshot itself is a *signal*, not an on-chain +instruction, so this track trusts the Safe signers to honor the result. That is +why the signers are elected and why, if you want to harden it, **oSnap** (UMA's +optimistic oracle) can post the Snapshot outcome on-chain so that — if +unchallenged — it becomes directly executable by the Safe, turning "the Safe +should comply" into an economic guarantee rather than a social one. + +## The routing rule, and three things that trip people up + +The whole model collapses to one question: **can the decision be expressed as a +deterministic call to a Base contract the timelock owns?** If yes, it goes +on-chain and executes trustlessly (Example 1). If it needs discretion, multiple +steps, or lives off Base — including anything on the Pendulum parachain, which +the Base Governor cannot reach — it goes to Snapshot and the Safe (Example 2), +or for runtime and security matters, to the Pendulum committee. + +Three practical notes that matter in real use: + +- **Delegation is a prerequisite for the on-chain track.** A holder with a + large balance but no delegation has no voting power and can't even meet the + proposal threshold. This surprises people constantly; launch communications + should make "delegate to yourself" a first-class step. +- **Emergencies deliberately skip governance.** The guardian Safe can pause the + vault in one transaction, with no vote, precisely because a 48-hour timelock + is the wrong tool for an active incident. Governance then decides the actual + fix through the slow, deliberate path. The guardian is intentionally not the + Governor. +- **Quorum is tuned for a migration in progress.** Because the vault holds most + of the supply early on, on-chain quorum is set to a low fraction at launch + and raised by governance as circulating supply grows, and Snapshot excludes + the vault outright. Otherwise quorum measured against total supply would be + unreachable in the early months. From 6194b5cb11b8d359c7b0d9314f98d97c9163f481 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Thu, 9 Jul 2026 10:30:06 +0200 Subject: [PATCH 18/23] pallets: Add governance-gated migrate_treasury to token-migration 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. --- pallets/token-migration/src/benchmarking.rs | 30 ++++ .../token-migration/src/default_weights.rs | 13 ++ pallets/token-migration/src/lib.rs | 100 ++++++++++++- pallets/token-migration/src/mock.rs | 10 +- pallets/token-migration/src/tests.rs | 135 +++++++++++++++++- runtime/pendulum/src/lib.rs | 3 + 6 files changed, 281 insertions(+), 10 deletions(-) diff --git a/pallets/token-migration/src/benchmarking.rs b/pallets/token-migration/src/benchmarking.rs index 32b33d93a..b6aa71405 100644 --- a/pallets/token-migration/src/benchmarking.rs +++ b/pallets/token-migration/src/benchmarking.rs @@ -43,5 +43,35 @@ mod benchmarks { Ok(()) } + #[benchmark] + fn set_treasury_destination() -> Result<(), BenchmarkError> { + let origin = T::TreasuryMigrateOrigin::try_successful_origin() + .map_err(|_| BenchmarkError::Weightless)?; + let base_address = H160::repeat_byte(0xBE); + + #[extrinsic_call] + set_treasury_destination(origin as T::RuntimeOrigin, base_address); + + assert_eq!(TreasuryDestination::::get(), Some(base_address)); + Ok(()) + } + + #[benchmark] + fn migrate_treasury() -> Result<(), BenchmarkError> { + let treasury = T::TreasuryAccount::get(); + T::Currency::make_free_balance_be(&treasury, BalanceOf::::max_value() / 2u32.into()); + let amount = T::MinimumMigrationAmount::get().saturating_mul(10u32.into()); + TreasuryDestination::::put(H160::repeat_byte(0xBE)); + let origin = T::TreasuryMigrateOrigin::try_successful_origin() + .map_err(|_| BenchmarkError::Weightless)?; + + #[extrinsic_call] + migrate_treasury(origin as T::RuntimeOrigin, amount); + + assert_eq!(TotalMigrated::::get(), amount); + assert_eq!(NextNonce::::get(), 1); + Ok(()) + } + impl_benchmark_test_suite!(Pallet, crate::mock::ExtBuilder::build(), crate::mock::Test); } diff --git a/pallets/token-migration/src/default_weights.rs b/pallets/token-migration/src/default_weights.rs index 8922aae3f..eb7b7bd6a 100644 --- a/pallets/token-migration/src/default_weights.rs +++ b/pallets/token-migration/src/default_weights.rs @@ -10,6 +10,8 @@ use frame_support::{traits::Get, weights::Weight}; pub trait WeightInfo { fn migrate() -> Weight; fn set_paused() -> Weight; + fn set_treasury_destination() -> Weight; + fn migrate_treasury() -> Weight; } pub struct SubstrateWeight(PhantomData); @@ -25,4 +27,15 @@ impl WeightInfo for SubstrateWeight { Weight::from_parts(10_000_000, 0) .saturating_add(T::DbWeight::get().writes(1_u64)) } + + fn set_treasury_destination() -> Weight { + Weight::from_parts(12_000_000, 0) + .saturating_add(T::DbWeight::get().writes(1_u64)) + } + + fn migrate_treasury() -> Weight { + Weight::from_parts(50_000_000, 0) + .saturating_add(T::DbWeight::get().reads(5_u64)) + .saturating_add(T::DbWeight::get().writes(4_u64)) + } } diff --git a/pallets/token-migration/src/lib.rs b/pallets/token-migration/src/lib.rs index 21b85c7f6..1fb47dc2d 100644 --- a/pallets/token-migration/src/lib.rs +++ b/pallets/token-migration/src/lib.rs @@ -63,6 +63,14 @@ pub mod pallet { /// Origin allowed to pause and unpause migrations (incident response). type PauseOrigin: EnsureOrigin; + /// The keyless treasury account whose funds `migrate_treasury` moves to + /// Base. Set in the runtime to the treasury pallet account. + type TreasuryAccount: Get; + + /// Origin allowed to set the treasury's Base destination and trigger a + /// treasury migration (root or a council majority in the runtime). + type TreasuryMigrateOrigin: EnsureOrigin; + type WeightInfo: WeightInfo; } @@ -80,6 +88,8 @@ pub mod pallet { }, /// Migrations were paused or unpaused by the pause origin. MigrationPauseSet { paused: bool }, + /// The fixed Base destination for treasury migrations was set. + TreasuryDestinationSet { base_address: H160 }, } #[pallet::error] @@ -97,6 +107,9 @@ pub mod pallet { /// The vault on Base would reject the release, permanently stranding /// the burned tokens and stalling the attestor pipeline. InvalidBaseAddress, + /// A treasury migration was attempted before the Base destination was + /// set via `set_treasury_destination`. + NoTreasuryDestination, } /// Nonce of the next migration. Monotonically increasing, never reused; @@ -113,6 +126,11 @@ pub mod pallet { #[pallet::storage] pub type Paused = StorageValue<_, bool, ValueQuery>; + /// The fixed Base destination for treasury migrations. `migrate_treasury` + /// always sends here; `None` until set by `set_treasury_destination`. + #[pallet::storage] + pub type TreasuryDestination = StorageValue<_, H160, OptionQuery>; + #[pallet::call] impl Pallet { /// Burn `amount` of the caller's native tokens for migration to Base. @@ -157,13 +175,9 @@ pub mod pallet { // withdrawn amount, i.e. total issuance decreases by `amount`. drop(imbalance); - let nonce = NextNonce::::get(); - let next = nonce.checked_add(1).ok_or(ArithmeticError::Overflow)?; - NextNonce::::put(next); - TotalMigrated::::mutate(|total| *total = total.saturating_add(amount)); - - Self::deposit_event(Event::MigrationInitiated { nonce, who, base_address, amount }); - Ok(()) + // Shared accounting + event (identical to a treasury migration, so + // the attestor set decodes both the same way). + Self::emit_migration(who, base_address, amount) } /// Pause or unpause migrations. Callable by the pause origin only. @@ -175,5 +189,77 @@ pub mod pallet { Self::deposit_event(Event::MigrationPauseSet { paused }); Ok(()) } + + /// Set the fixed Base destination for treasury migrations. + /// + /// Callable by the treasury-migrate origin (root or a council majority). + /// This is the single security anchor for treasury migrations: once set, + /// `migrate_treasury` always sends here, so the routine call carries no + /// address and cannot be sent to the wrong place by a typo. + #[pallet::call_index(2)] + #[pallet::weight(::WeightInfo::set_treasury_destination())] + pub fn set_treasury_destination(origin: OriginFor, base_address: H160) -> DispatchResult { + T::TreasuryMigrateOrigin::ensure_origin(origin)?; + ensure!(base_address != H160::zero(), Error::::InvalidBaseAddress); + TreasuryDestination::::put(base_address); + Self::deposit_event(Event::TreasuryDestinationSet { base_address }); + Ok(()) + } + + /// Burn `amount` of the treasury's native tokens for migration to the + /// pre-set Base destination. + /// + /// Callable by the treasury-migrate origin. Requires a destination to + /// have been set. Emits the same `MigrationInitiated` event as a user + /// migration (with `who` = the treasury account), so the attestor set, + /// vault and monitor process it identically. + #[pallet::call_index(3)] + #[pallet::weight(::WeightInfo::migrate_treasury())] + pub fn migrate_treasury( + origin: OriginFor, + #[pallet::compact] amount: BalanceOf, + ) -> DispatchResult { + T::TreasuryMigrateOrigin::ensure_origin(origin)?; + ensure!(!Paused::::get(), Error::::MigrationsPaused); + ensure!( + amount >= T::MinimumMigrationAmount::get(), + Error::::AmountBelowMinimum + ); + let base_address = + TreasuryDestination::::get().ok_or(Error::::NoTreasuryDestination)?; + + let treasury = T::TreasuryAccount::get(); + ensure!( + T::Currency::free_balance(&treasury) >= amount, + Error::::InsufficientBalance + ); + + // KeepAlive: the treasury is a persistent system account and must + // never be reaped by a migration. + let imbalance = T::Currency::withdraw( + &treasury, + amount, + WithdrawReasons::TRANSFER, + ExistenceRequirement::KeepAlive, + )?; + drop(imbalance); + + Self::emit_migration(treasury, base_address, amount) + } + } + + impl Pallet { + /// Shared tail of every migration: consume a unique nonce, update the + /// cumulative total, and emit `MigrationInitiated`. The caller must have + /// already burned `amount` from `who`. + fn emit_migration(who: T::AccountId, base_address: H160, amount: BalanceOf) -> DispatchResult { + let nonce = NextNonce::::get(); + let next = nonce.checked_add(1).ok_or(ArithmeticError::Overflow)?; + NextNonce::::put(next); + TotalMigrated::::mutate(|total| *total = total.saturating_add(amount)); + + Self::deposit_event(Event::MigrationInitiated { nonce, who, base_address, amount }); + Ok(()) + } } } diff --git a/pallets/token-migration/src/mock.rs b/pallets/token-migration/src/mock.rs index 6ce0745db..075ff5851 100644 --- a/pallets/token-migration/src/mock.rs +++ b/pallets/token-migration/src/mock.rs @@ -84,6 +84,7 @@ impl pallet_balances::Config for Test { parameter_types! { pub const MinimumMigrationAmount: Balance = UNIT; + pub const TreasuryAccount: AccountId = 999; } impl Config for Test { @@ -91,6 +92,8 @@ impl Config for Test { type Currency = Balances; type MinimumMigrationAmount = MinimumMigrationAmount; type PauseOrigin = EnsureRoot; + type TreasuryAccount = TreasuryAccount; + type TreasuryMigrateOrigin = EnsureRoot; type WeightInfo = SubstrateWeight; } @@ -98,6 +101,8 @@ impl Config for Test { pub const USER: AccountId = 1; pub const USER_INITIAL_BALANCE: Balance = 100 * UNIT; +pub const TREASURY: AccountId = 999; +pub const TREASURY_INITIAL_BALANCE: Balance = 1000 * UNIT; pub struct ExtBuilder; @@ -106,7 +111,10 @@ impl ExtBuilder { let mut storage = frame_system::GenesisConfig::::default().build_storage().unwrap(); pallet_balances::GenesisConfig:: { - balances: vec![(USER, USER_INITIAL_BALANCE)], + balances: vec![ + (USER, USER_INITIAL_BALANCE), + (TREASURY, TREASURY_INITIAL_BALANCE), + ], } .assimilate_storage(&mut storage) .unwrap(); diff --git a/pallets/token-migration/src/tests.rs b/pallets/token-migration/src/tests.rs index 39ea664dd..df83f63cc 100644 --- a/pallets/token-migration/src/tests.rs +++ b/pallets/token-migration/src/tests.rs @@ -1,4 +1,4 @@ -use crate::{mock::*, Error, Event, NextNonce, Paused, TotalMigrated}; +use crate::{mock::*, Error, Event, NextNonce, Paused, TotalMigrated, TreasuryDestination}; use frame_support::{ assert_noop, assert_ok, traits::{LockableCurrency, WithdrawReasons}, @@ -123,7 +123,8 @@ fn migrate_entire_balance_works() { base_address() )); assert_eq!(Balances::free_balance(USER), 0); - assert_eq!(Balances::total_issuance(), 0); + // Only the treasury's balance remains in issuance after the user's is burned. + assert_eq!(Balances::total_issuance(), TREASURY_INITIAL_BALANCE); assert_eq!(TotalMigrated::::get(), USER_INITIAL_BALANCE); }); } @@ -181,3 +182,133 @@ fn set_paused_requires_pause_origin() { ); }); } + +// ---------------------------------------------------------------- treasury migration + +#[test] +fn set_treasury_destination_stores_and_emits() { + run_test(|| { + assert_ok!(TokenMigration::set_treasury_destination(RuntimeOrigin::root(), base_address())); + assert_eq!(TreasuryDestination::::get(), Some(base_address())); + System::assert_last_event(Event::TreasuryDestinationSet { base_address: base_address() }.into()); + }); +} + +#[test] +fn set_treasury_destination_rejects_zero_and_bad_origin() { + run_test(|| { + assert_noop!( + TokenMigration::set_treasury_destination(RuntimeOrigin::root(), H160::zero()), + Error::::InvalidBaseAddress + ); + assert_noop!( + TokenMigration::set_treasury_destination(RuntimeOrigin::signed(USER), base_address()), + DispatchError::BadOrigin + ); + }); +} + +#[test] +fn migrate_treasury_burns_from_treasury_and_emits_same_event_shape() { + run_test(|| { + let amount = 10 * UNIT; + let issuance_before = Balances::total_issuance(); + assert_ok!(TokenMigration::set_treasury_destination(RuntimeOrigin::root(), base_address())); + + assert_ok!(TokenMigration::migrate_treasury(RuntimeOrigin::root(), amount)); + + // Burned from the treasury account, issuance down by exactly `amount`. + assert_eq!(Balances::total_issuance(), issuance_before - amount); + assert_eq!(Balances::free_balance(TREASURY), TREASURY_INITIAL_BALANCE - amount); + assert_eq!(Balances::free_balance(USER), USER_INITIAL_BALANCE); + assert_eq!(TotalMigrated::::get(), amount); + assert_eq!(NextNonce::::get(), 1); + + // Same event shape as a user migration, with who = the treasury account. + System::assert_last_event( + Event::MigrationInitiated { nonce: 0, who: TREASURY, base_address: base_address(), amount }.into(), + ); + }); +} + +#[test] +fn treasury_and_user_migrations_share_the_nonce_space() { + run_test(|| { + assert_ok!(TokenMigration::set_treasury_destination(RuntimeOrigin::root(), base_address())); + assert_ok!(TokenMigration::migrate(RuntimeOrigin::signed(USER), UNIT, base_address())); + assert_ok!(TokenMigration::migrate_treasury(RuntimeOrigin::root(), UNIT)); + assert_ok!(TokenMigration::migrate(RuntimeOrigin::signed(USER), UNIT, base_address())); + + // Nonces are globally unique across both paths. + assert_eq!(NextNonce::::get(), 3); + assert_eq!(TotalMigrated::::get(), 3 * UNIT); + }); +} + +#[test] +fn migrate_treasury_fails_without_destination() { + run_test(|| { + assert_noop!( + TokenMigration::migrate_treasury(RuntimeOrigin::root(), UNIT), + Error::::NoTreasuryDestination + ); + }); +} + +#[test] +fn migrate_treasury_requires_authorized_origin() { + run_test(|| { + assert_ok!(TokenMigration::set_treasury_destination(RuntimeOrigin::root(), base_address())); + assert_noop!( + TokenMigration::migrate_treasury(RuntimeOrigin::signed(USER), UNIT), + DispatchError::BadOrigin + ); + }); +} + +#[test] +fn migrate_treasury_validates_amount() { + run_test(|| { + assert_ok!(TokenMigration::set_treasury_destination(RuntimeOrigin::root(), base_address())); + // Below the minimum. + assert_noop!( + TokenMigration::migrate_treasury(RuntimeOrigin::root(), UNIT - 1), + Error::::AmountBelowMinimum + ); + // More than the treasury holds. + assert_noop!( + TokenMigration::migrate_treasury(RuntimeOrigin::root(), TREASURY_INITIAL_BALANCE + 1), + Error::::InsufficientBalance + ); + }); +} + +#[test] +fn migrate_treasury_keeps_treasury_alive() { + run_test(|| { + assert_ok!(TokenMigration::set_treasury_destination(RuntimeOrigin::root(), base_address())); + // Draining the whole balance would reap the account; KeepAlive rejects it. + assert_noop!( + TokenMigration::migrate_treasury(RuntimeOrigin::root(), TREASURY_INITIAL_BALANCE), + pallet_balances::Error::::Expendability + ); + // Leaving at least the existential deposit works. + let keep = TREASURY_INITIAL_BALANCE - ExistentialDeposit::get(); + assert_ok!(TokenMigration::migrate_treasury(RuntimeOrigin::root(), keep)); + assert_eq!(Balances::free_balance(TREASURY), ExistentialDeposit::get()); + }); +} + +#[test] +fn migrate_treasury_respects_pause() { + run_test(|| { + assert_ok!(TokenMigration::set_treasury_destination(RuntimeOrigin::root(), base_address())); + assert_ok!(TokenMigration::set_paused(RuntimeOrigin::root(), true)); + assert_noop!( + TokenMigration::migrate_treasury(RuntimeOrigin::root(), UNIT), + Error::::MigrationsPaused + ); + // Setting the destination is still allowed while paused (configuration). + assert_ok!(TokenMigration::set_treasury_destination(RuntimeOrigin::root(), base_address())); + }); +} diff --git a/runtime/pendulum/src/lib.rs b/runtime/pendulum/src/lib.rs index 1cc388ad4..aaba072ae 100644 --- a/runtime/pendulum/src/lib.rs +++ b/runtime/pendulum/src/lib.rs @@ -1131,6 +1131,9 @@ impl token_migration::Config for Runtime { EnsureRootOrHalfCouncil, pallet_collective::EnsureProportionAtLeast, >; + type TreasuryAccount = PendulumTreasuryAccount; + // Same authority that approves treasury spends: root or 3/5 council. + type TreasuryMigrateOrigin = TreasuryApproveOrigin; type WeightInfo = token_migration::default_weights::SubstrateWeight; } From e57306333333478b0e8e53146447f1c109afeace Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Thu, 9 Jul 2026 10:30:07 +0200 Subject: [PATCH 19/23] docs: Record migrate_treasury and the treasury governance structure - 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). --- docs/pen-governance-guide.md | 75 +++++++++++++++++++ docs/pen-migration-implementation-overview.md | 2 +- 2 files changed, 76 insertions(+), 1 deletion(-) diff --git a/docs/pen-governance-guide.md b/docs/pen-governance-guide.md index c64aeb800..90a02f8ae 100644 --- a/docs/pen-governance-guide.md +++ b/docs/pen-governance-guide.md @@ -122,6 +122,81 @@ optimistic oracle) can post the Snapshot outcome on-chain so that — if unchallenged — it becomes directly executable by the Safe, turning "the Safe should comply" into an economic guarantee rather than a social one. +## The treasury: where the money lives and how it's spent + +Example 2 was a treasury spend, so it's worth making the treasury structure +explicit — because the most common wrong assumption is that "the Base treasury" +is a smart contract you build and wire into the others. It isn't. + +**A treasury on Base is just an address that holds tokens.** PEN is a plain +ERC-20 — nothing gets registered or connected to it; whoever holds a balance +spends it by calling `transfer`. So you don't author a treasury contract. You +already have the right address: the `TimelockController`. In the OpenZeppelin +Governor pattern the timelock *is* the treasury and the executor at once — it +holds the reserve, and a passed proposal makes it call `transfer`. + +The recommended shape is a **split, tiered treasury** that maps straight onto +the three organs: + +| Money for… | Lives on | Held / spent by | How | +|---|---|---|---| +| Running the parachain (collators, coretime, Pendulum ops) | Pendulum | `py/trsry`, 3/5 council | existing treasury proposal, pays a Pendulum account in PEN | +| Strategic / large Base spends (reserves, partnerships, big LP) | Base | the `TimelockController` | token-holder proposal → 48h timelock → `transfer` (trustless) | +| Routine Base payouts (grants, MM retainer, small ops) | Base | an elected operating Safe with a delegated budget | Safe multisig tx, optionally Snapshot-signaled (fast) | + +The important discipline: **don't route every payout through the full +Governor.** A 48-hour timelocked proposal for a 3,000-PEN contributor grant is +governance theater. Instead, governance grants the operating Safe a periodic +budget in one action ("500k PEN + 200k USDC this quarter"); day-to-day grants +are then Safe transactions inside that mandate, and governance tops it up (or +claws it back) as needed. For recurring payments, fund a stream (Sablier or +Superfluid) so it doesn't need repeated approvals. None of these are bespoke +contracts — the Safe and the streaming tools are standard, audited, and created +through their own apps, not written by us. + +### Getting the treasury's PEN to Base + +The Pendulum treasury (`py/trsry`) is a keyless account, and the user-facing +`migrate` extrinsic needs a signed origin — so the treasury can't migrate +itself the ordinary way. The `token-migration` pallet therefore has a +governance-gated path, built as two deliberately separate steps: + +1. `set_treasury_destination(base_address)` — sets the fixed Base destination + (the timelock) **once**, as its own reviewed governance action. This is the + single security anchor: the routine migration call carries no address and so + cannot be sent to the wrong place by a typo. +2. `migrate_treasury(amount)` — burns `amount` from the treasury account and + emits the **same** `MigrationInitiated` event as a user migration (with + `who` = the treasury), so the attestors, vault and monitor handle it + identically. It always goes to the pre-set destination. + +Both are gated by the same authority that already approves treasury spends +(root or 3/5 council), and `migrate_treasury` respects the pallet pause and +keeps the treasury account alive (it can't accidentally reap itself). + +Three operational notes when you actually run it: + +- **Mind the daily cap.** A large treasury tranche competes with user + migrations for the vault's rolling daily-cap headroom and may be deferred + (delayed, never lost — it's marked pending and recoverable). Migrate in + tranches, or pass a proposal to temporarily raise the cap. +- **Circulating supply doesn't move.** Pendulum-treasury and Base-treasury are + both non-circulating, so shifting reserves between them changes nothing for + DefiLlama/CoinGecko — just add the Base treasury address to their excluded + list alongside the vault. +- **Do it inside the migration window**, coordinated, so the reserve isn't + stranded if the window later closes. + +### The quietly big win + +Once the reserve is on Base, the treasury can hold and pay **USDC and other +Base-native assets**, not just PEN — which is what contributors and market +makers usually want to be paid in, and which the Pendulum treasury structurally +cannot do today (it is native-PEN-only). You also gain the option to park +reserves in Base DeFi or provide protocol-owned liquidity. That, more than the +payout mechanics, is the real reason to move the strategic reserve to Base +rather than bridging per payout. + ## The routing rule, and three things that trip people up The whole model collapses to one question: **can the decision be expressed as a diff --git a/docs/pen-migration-implementation-overview.md b/docs/pen-migration-implementation-overview.md index bdc10e824..c1622e9b7 100644 --- a/docs/pen-migration-implementation-overview.md +++ b/docs/pen-migration-implementation-overview.md @@ -27,7 +27,7 @@ watched by an independent monitor that can auto-pause. One-way by design. | Component | Location | Status | |---|---|---| -| `token-migration` pallet | `pallets/token-migration/` | Burn-and-emit extrinsic, unique nonces, dust/ED + lock handling, pause origin; 10 unit tests + benchmark test suite (frame-benchmarking v2) | +| `token-migration` pallet | `pallets/token-migration/` | Burn-and-emit `migrate` (user) + `migrate_treasury`/`set_treasury_destination` (governance, fixed Base destination) extrinsics sharing one nonce space and event; unique nonces, dust/ED + lock handling, KeepAlive treasury withdraw, pause origin; 20 unit tests + benchmark test suite (frame-benchmarking v2) | | Runtime wiring | `runtime/pendulum/src/lib.rs` | Pallet index 102, min amount 1 PEN, pause = root/half-council or 2/3 technical committee, added to `BaseFilter` whitelist and `define_benchmarks`; compiles with and without `runtime-benchmarks` (Foucoco intentionally skipped — production-direct decision) | | `PEN.sol` | `contracts/src/` | Fixed-supply `ERC20 + ERC20Permit + ERC20Votes`, EIP-6372 timestamp clock, full supply minted to vault, no owner/mint/proxy | | `MigrationVault.sol` | `contracts/src/` | 3-of-5 on-chain approvals per exact tuple, permanent nonce consumption, 12→18 decimal conversion in one place, per-release + daily caps (defer, not kill), guardian pause (approvals recorded while paused), rotation retroactively invalidates removed attestors, two-step admin, pending-release accounting protecting the timelocked remainder sweep | From 4e1df1238a830f1be27e6b44f5e404b9d3102dbe Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Thu, 9 Jul 2026 10:57:52 +0200 Subject: [PATCH 20/23] security: Fix round-5 audit findings (monitor surplus DoS, liveness batching) 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. --- contracts/src/MigrationVault.sol | 8 ++ contracts/test/MigrationVault.t.sol | 26 +++++ docs/pen-migration-internal-review.md | 46 +++++++++ docs/pen-migration-runbooks.md | 14 ++- monitor/README.md | 4 +- monitor/package.json | 3 +- monitor/src/checks.test.ts | 51 ++++++++++ monitor/src/checks.ts | 55 +++++++++++ monitor/src/main.ts | 133 +++++++++++++++++++------- 9 files changed, 297 insertions(+), 43 deletions(-) create mode 100644 monitor/src/checks.test.ts create mode 100644 monitor/src/checks.ts diff --git a/contracts/src/MigrationVault.sol b/contracts/src/MigrationVault.sol index 454e55942..acabd7bee 100644 --- a/contracts/src/MigrationVault.sol +++ b/contracts/src/MigrationVault.sol @@ -27,6 +27,7 @@ contract MigrationVault { error NotAttestor(); error NotPendingAdmin(); error ZeroAddress(); + error RecipientIsVault(); error TokenAlreadySet(); error TokenNotSet(); error VaultMustHoldFullSupply(); @@ -222,6 +223,13 @@ contract MigrationVault { /// resume without re-attestation after an unpause. function approve(uint64 nonce, address recipient, uint256 palletAmount) external onlyAttestor { if (recipient == address(0)) revert ZeroAddress(); + // Releasing to the vault itself is a self-transfer: it leaves + // balanceOf(this) unchanged while bumping totalReleased, permanently + // breaking the monitor's conservation identity + // (balance + released + swept == totalSupply) and, with auto-pause on, + // wedging releases. It is never a legitimate migration target, so + // reject it at the single point where approvals are recorded. + if (recipient == address(this)) revert RecipientIsVault(); if (palletAmount == 0) revert ZeroAmount(); if (nonceConsumed[nonce]) revert NonceAlreadyConsumed(nonce); diff --git a/contracts/test/MigrationVault.t.sol b/contracts/test/MigrationVault.t.sol index dcd4db115..146a25b10 100644 --- a/contracts/test/MigrationVault.t.sol +++ b/contracts/test/MigrationVault.t.sol @@ -437,6 +437,32 @@ contract MigrationVaultTest is Test { assertEq(vault.pendingApprovedAmount(), 0); } + // Releasing to the vault itself is a self-transfer that would break the + // monitor's conservation identity (balance unchanged, totalReleased bumped) + // and, with auto-pause on, wedge releases. It must be rejected at approve. + function test_ApproveRejectsVaultRecipient() public { + vm.prank(attestors[0]); + vm.expectRevert(MigrationVault.RecipientIsVault.selector); + vault.approve(0, address(vault), 5e12); + } + + // Guarding at approve() is sufficient: no approvals can ever accrue for a + // vault-recipient tuple, so release() can never satisfy the threshold and + // the conservation identity the monitor watches is preserved. + function test_VaultRecipientNeverReleasesAndPreservesInvariant() public { + for (uint256 i = 0; i < 3; i++) { + vm.prank(attestors[i]); + vm.expectRevert(MigrationVault.RecipientIsVault.selector); + vault.approve(7, address(vault), 5e12); + } + assertFalse(vault.nonceConsumed(7)); + assertEq( + pen.balanceOf(address(vault)) + vault.totalReleased() + vault.totalSwept(), + pen.totalSupply(), + "conservation identity intact" + ); + } + function test_HasApprovedFalseForRemovedAttestor() public { bytes32 payload = vault.payloadHash(0, recipient, 5e12); approveAs(0, 0, recipient, 5e12); diff --git a/docs/pen-migration-internal-review.md b/docs/pen-migration-internal-review.md index 6db063489..9601cbba1 100644 --- a/docs/pen-migration-internal-review.md +++ b/docs/pen-migration-internal-review.md @@ -193,6 +193,52 @@ boundary. Covered by `test_DailyCapRefillsGraduallyOverRollingWindow` and full bucket plus full refill still totals up to ~2× `dailyCap`, but spread over 24h rather than instantaneous — size `dailyCap` accordingly. +## Round 5 (2026-07-09, in-depth review focused on bricking the pipeline) + +Adversarial pass over the whole stack asking specifically where an outsider +could brick releases. The on-chain fund path (rounds 1–4) held up; the finding +was in the off-chain monitor. + +### H1(r5). HIGH — A dust PEN transfer to the vault permanently tripped the monitor's M2b check +The M2b conservation check used strict equality +(`balance + totalReleased + totalSwept != totalSupply`). Under all legitimate +contract logic that sum is *exactly* `totalSupply`, so equality could only ever +break *upward* — via tokens arriving in the vault outside the release path. +Anyone could do that permissionlessly: `PEN.transfer(vault, 1 wei)`, or a +`migrate(_, )` whose 3rd approval self-transfers into the vault. +The break is permanent (the surplus can only leave via the post-window +`sweepRemainder`), so every poll re-fired the highest-severity alert and — +with `GUARDIAN_PRIVATE_KEY` set — re-paused the vault every cycle, wedging all +releases for the rest of the window while burns kept accruing on Pendulum. + +**Resolution (fixed):** +- M2b now alerts only on a **deficit** (`balance + released + swept < + totalSupply`); a surplus is ignored. A deficit is the only direction that can + signal real loss (a genuine unauthorized release keeps the sum equal and is + caught by M2a). Alert renamed `VAULT BALANCE DEFICIT`. +- Defense in depth on-chain: `MigrationVault.approve` rejects `recipient == + address(this)` (`RecipientIsVault`), closing the self-migration variant at the + single point approvals are recorded. +- The conservation/liveness predicates were extracted to `monitor/src/checks.ts` + and unit-tested (`checks.test.ts`): surplus-does-not-fire, deficit-fires, + exact-holds. Vault side covered by `test_ApproveRejectsVaultRecipient` and + `test_VaultRecipientNeverReleasesAndPreservesInvariant`. + +### M1(r5). MEDIUM — Monitor liveness scan could starve the conservation checks +M4 read `nonceConsumed` one nonce at a time, sequentially, every poll. During a +pause or cap-deferral every migration stays unconsumed, so the scan grew with +the backlog and could push a cycle past the poll interval — starving the M2a/M2b +checks exactly when they matter most. **Fixed:** per-nonce reads are batched +through Multicall3. + +### L1(r5). LOW — M2a read ordering made safe by construction, not just by latency +The monitor read Pendulum `totalMigrated` before the Base totals. A burn +finalizing between the two reads and released before the Base read could momentarily +show `released > migrated`. It was unreachable in practice (release latency ≫ the +read gap) but is now removed outright: Base is read first, then the +monotonically-growing `totalMigrated` at a strictly-later snapshot, so M2a cannot +false-positive on an in-flight burn. + ## Follow-ups for the external audit - These round-4 fixes touch the fund-release path and have **not** had a subsequent internal round; they are the first thing the external audit should diff --git a/docs/pen-migration-runbooks.md b/docs/pen-migration-runbooks.md index 6b364bf90..8648d8257 100644 --- a/docs/pen-migration-runbooks.md +++ b/docs/pen-migration-runbooks.md @@ -57,7 +57,14 @@ period) or an attestor's own restart-loop/low-gas alerts. ## RB-3: Conservation invariant violation **Trigger:** monitor alert `CONSERVATION VIOLATION` or `VAULT BALANCE -MISMATCH`. This is the highest-severity alert the system can produce. +DEFICIT`. This is the highest-severity alert the system can produce. + +**Note:** the monitor only alerts on a *deficit* (tokens missing), never on a +surplus. A plain inbound transfer of PEN into the vault (a donation, or a +migration whose recipient is the vault) raises the balance above the +conservation identity but is harmless and is deliberately ignored — it can no +longer false-trigger an auto-pause (round-5 fix). The vault also rejects the +vault address as a release recipient at `approve`. 1. Auto-pause should already have fired; **verify `vault.paused() == true`** and pause manually if not. Do not unpause until step 5. @@ -139,8 +146,9 @@ first. The call reverts (`ExceedsSweepable`) if the amount exceeds `balance − pendingApprovedAmount`, as a last-line guard. 6. **Verify:** `totalSwept` increased by `amount`; the monitor's conservation - check (`balance + totalReleased + totalSwept == totalSupply`) still holds - and does **not** alert (it accounts for `totalSwept`). + check (`balance + totalReleased + totalSwept >= totalSupply`, alerting only + on a deficit) still holds and does **not** alert (it accounts for + `totalSwept`). **If a still-in-flight migration was swept anyway:** its release defers with `InsufficientVaultBalance` and is marked pending. To make the user whole, diff --git a/monitor/README.md b/monitor/README.md index 6d13fc240..2cf6c7323 100644 --- a/monitor/README.md +++ b/monitor/README.md @@ -9,8 +9,8 @@ Checks every poll: | Check | Meaning | Reaction | |---|---|---| | M2a: `totalReleased <= TotalMigrated × conversionFactor` | Tokens may never leave the vault without a corresponding finalized burn on Pendulum. A violation is the signature of attestor-quorum compromise. | Alert + auto-pause the vault (if `GUARDIAN_PRIVATE_KEY` is set) | -| M2b: `balanceOf(vault) + totalReleased == totalSupply` | Vault-internal conservation. | Alert + auto-pause | -| M4: every nonce older than `GRACE_SECONDS` is consumed on Base | Liveness of the attestor fleet (outage, cap deferral, pause). | Alert | +| M2b: `balanceOf(vault) + totalReleased + totalSwept >= totalSupply` | Vault-internal conservation. Only a **deficit** alerts: a surplus is a harmless inbound transfer (donation, or a migration whose recipient is the vault) and is ignored, so it cannot false-trigger a pause. | Alert + auto-pause on a deficit | +| M4: every nonce older than `GRACE_SECONDS` is consumed on Base | Liveness of the attestor fleet (outage, cap deferral, pause). Per-nonce reads are batched via Multicall3 so a large backlog cannot starve the checks above. | Alert | ## Configuration (environment) diff --git a/monitor/package.json b/monitor/package.json index 7faf5a29a..f1a8b893d 100644 --- a/monitor/package.json +++ b/monitor/package.json @@ -7,7 +7,8 @@ "scripts": { "build": "tsc", "start": "node dist/main.js", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "test": "tsc && node --test dist/checks.test.js" }, "dependencies": { "@polkadot/api": "^11.3.1", diff --git a/monitor/src/checks.test.ts b/monitor/src/checks.test.ts new file mode 100644 index 000000000..abfc5d310 --- /dev/null +++ b/monitor/src/checks.test.ts @@ -0,0 +1,51 @@ +/** + * Unit tests for the monitor's conservation/liveness predicates. + * + * Run with `npm test` (compiles, then `node --test`). These lock in the + * round-5 fixes: the M2b check must ignore a token surplus (donation / vault + * recipient) and only fire on a real deficit. + */ + +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { isStale, releasedExceedsMigrated, vaultConservationDeficit } from "./checks.js"; + +const CF = 1_000_000n; // 12 -> 18 decimals +const SUPPLY = 160_000_000n * 10n ** 18n; + +test("M2a: released within migrated does not fire", () => { + assert.equal(releasedExceedsMigrated(5n * CF, 5n, CF), false); + assert.equal(releasedExceedsMigrated(5n * CF, 10n, CF), false); // migrated lags releases via finality: fine +}); + +test("M2a: released exceeding migrated fires", () => { + assert.equal(releasedExceedsMigrated(6n * CF, 5n, CF), true); +}); + +test("M2b: exact conservation is not a deficit", () => { + // balance + released + swept == totalSupply + assert.equal(vaultConservationDeficit(SUPPLY - 30n, 20n, 10n, SUPPLY), false); +}); + +test("M2b: a surplus (donation / vault-recipient self-transfer) must NOT fire", () => { + // Someone transferred dust into the vault: balance is higher than the + // identity predicts, so the sum EXCEEDS totalSupply. This is harmless and + // must never trip the check (previously a strict `!=` paused the vault here). + const donation = 5n * 10n ** 18n; + assert.equal(vaultConservationDeficit(SUPPLY - 30n + donation, 20n, 10n, SUPPLY), false); + // Even a 1-wei donation must not fire. + assert.equal(vaultConservationDeficit(SUPPLY + 1n, 0n, 0n, SUPPLY), false); +}); + +test("M2b: a real deficit (tokens vanished without a counter update) fires", () => { + // balance too low for the recorded released+swept: genuine loss. + assert.equal(vaultConservationDeficit(SUPPLY - 100n, 20n, 10n, SUPPLY), true); +}); + +test("M4: staleness respects the grace period", () => { + const grace = 1800; // seconds + const now = 10_000_000; + assert.equal(isStale(now - 1000, now, grace), false); // 1s old + assert.equal(isStale(now - 1800 * 1000, now, grace), false); // exactly at grace: not yet stale + assert.equal(isStale(now - 1801 * 1000, now, grace), true); // just past grace +}); diff --git a/monitor/src/checks.ts b/monitor/src/checks.ts new file mode 100644 index 000000000..31560a4f4 --- /dev/null +++ b/monitor/src/checks.ts @@ -0,0 +1,55 @@ +/** + * Pure conservation/liveness predicates for the invariant monitor. + * + * These are isolated from the chain-client plumbing in `main.ts` so they can be + * unit-tested exhaustively (see checks.test.ts). Every subtlety that has bitten + * a review round lives here. + */ + +/** + * (M2a) Nothing may leave the vault that was not burned on Pendulum. + * + * `totalReleased` (Base) must never exceed the burned total converted to token + * units. It lags `totalMigrated` only via finality delay, never the other way, + * because a release requires attestations of an already-finalized burn — so any + * excess is the strongest possible signal of attestor compromise. + */ +export function releasedExceedsMigrated( + totalReleased: bigint, + totalMigrated: bigint, + conversionFactor: bigint, +): boolean { + return totalReleased > totalMigrated * conversionFactor; +} + +/** + * (M2b) Vault-internal conservation. + * + * Under all legitimate contract logic `balance + released + swept` is *exactly* + * `totalSupply`: every path that removes tokens bumps a counter in lockstep + * (`_release`→`totalReleased`, `sweepRemainder`→`totalSwept`). Real loss can + * therefore only ever show up as a DEFICIT. + * + * A SURPLUS (sum > totalSupply) is harmless and must NOT trip the check: it can + * be produced permissionlessly by anyone transferring PEN into the vault (a + * plain donation, or a migration whose recipient is the vault address). Treating + * a surplus as a violation would let a single dust transfer pause the vault on + * every poll — unrecoverable until the window-close sweep, and it would train + * on-call to ignore the highest-severity alert. Hence the strict `<`. + */ +export function vaultConservationDeficit( + vaultBalance: bigint, + totalReleased: bigint, + totalSwept: bigint, + totalSupply: bigint, +): boolean { + return vaultBalance + totalReleased + totalSwept < totalSupply; +} + +/** + * (M4) Liveness: a migration the monitor has known about for longer than the + * grace period, and which is still not consumed on Base, is stalled. + */ +export function isStale(firstSeenMs: number, nowMs: number, graceSeconds: number): boolean { + return nowMs - firstSeenMs > graceSeconds * 1000; +} diff --git a/monitor/src/main.ts b/monitor/src/main.ts index 4ba2706e1..d103d7a52 100644 --- a/monitor/src/main.ts +++ b/monitor/src/main.ts @@ -20,6 +20,11 @@ import { ApiPromise, WsProvider } from "@polkadot/api"; import { createPublicClient, createWalletClient, defineChain, http } from "viem"; import { privateKeyToAccount } from "viem/accounts"; +import { isStale, releasedExceedsMigrated, vaultConservationDeficit } from "./checks.js"; + +// Canonical Multicall3 deployment (same address on Base and every major chain), +// used to batch the per-nonce liveness reads into a handful of RPC round-trips. +const MULTICALL3_ADDRESS = "0xcA11bde05977b3631167028862bE2a173976CA11" as const; const vaultAbi = [ { type: "function", name: "totalReleased", stateMutability: "view", inputs: [], outputs: [{ type: "uint256" }] }, @@ -72,6 +77,7 @@ const baseChain = defineChain({ name: "base", nativeCurrency: { name: "Ether", symbol: "ETH", decimals: 18 }, rpcUrls: { default: { http: [config.baseRpcUrl] } }, + contracts: { multicall3: { address: MULTICALL3_ADDRESS } }, }); const publicClient = createPublicClient({ chain: baseChain, transport: http(config.baseRpcUrl) }); @@ -115,17 +121,65 @@ async function pauseVault(): Promise { /** Timestamps (ms) at which the monitor first saw each pallet nonce count. */ const nonceFirstSeen = new Map(); -async function check(api: ApiPromise): Promise { - // --- Pendulum side, at the finalized head --- - const finalizedHash = await api.rpc.chain.getFinalizedHead(); - const apiAt = await api.at(finalizedHash); - const totalMigrated = BigInt((await apiAt.query.tokenMigration.totalMigrated()).toString()); - const nextNonce = BigInt((await apiAt.query.tokenMigration.nextNonce()).toString()); +let multicallUnavailable = false; + +/** Read `nonceConsumed` for many nonces, batched through Multicall3. + * + * Falls back to plain concurrent reads if the batch call fails — e.g. on a + * chain where Multicall3 is not deployed at the canonical address, or a + * provider that rejects the batch. The fallback still works (just chattier), + * so a misconfigured multicall degrades liveness detection rather than + * crashing the whole check cycle and blinding the conservation alerts. */ +async function readNonceConsumed(nonces: bigint[]): Promise { + if (!multicallUnavailable) { + try { + return (await publicClient.multicall({ + allowFailure: false, + contracts: nonces.map((nonce) => ({ + address: config.vaultAddress, + abi: vaultAbi, + functionName: "nonceConsumed", + args: [nonce], + })), + })) as boolean[]; + } catch (multicallError) { + // Latch so we do not re-attempt (and re-log) the batch every poll. + multicallUnavailable = true; + await alert( + "multicall unavailable, using per-nonce reads", + `liveness reads fall back to individual calls; verify Multicall3 at ${MULTICALL3_ADDRESS}: ${multicallError}`, + ); + } + } + + // Fallback: read in bounded concurrent batches to avoid a request storm. + const CHUNK = 100; + const flags: boolean[] = []; + for (let start = 0; start < nonces.length; start += CHUNK) { + const chunk = nonces.slice(start, start + CHUNK); + const chunkFlags = await Promise.all( + chunk.map((nonce) => + publicClient.readContract({ + address: config.vaultAddress, + abi: vaultAbi, + functionName: "nonceConsumed", + args: [nonce], + }), + ), + ); + flags.push(...chunkFlags); + } + return flags; +} - // --- Base side --- - // All reads are pinned to one block: a release landing between unpinned - // reads would skew totalReleased vs. vaultBalance and trigger a false - // conservation alert (and auto-pause). +async function check(api: ApiPromise): Promise { + // --- Base side, pinned to one block --- + // Read Base FIRST, then Pendulum's monotonically-growing totalMigrated at a + // strictly-later snapshot: this guarantees totalMigrated >= what any Base + // release could have been attested against, so the M2a check can never + // false-positive on a burn that finalized between the two reads. Pinning + // every Base read to one block keeps totalReleased and vaultBalance from + // skewing against each other (a release landing mid-cycle). const blockNumber = await publicClient.getBlockNumber(); const [totalReleased, totalSwept, conversionFactor, tokenAddress] = await Promise.all([ publicClient.readContract({ address: config.vaultAddress, abi: vaultAbi, functionName: "totalReleased", blockNumber }), @@ -144,52 +198,57 @@ async function check(api: ApiPromise): Promise { }), ]); + // --- Pendulum side, at the finalized head (read after Base, see above) --- + const finalizedHash = await api.rpc.chain.getFinalizedHead(); + const apiAt = await api.at(finalizedHash); + const totalMigrated = BigInt((await apiAt.query.tokenMigration.totalMigrated()).toString()); + const nextNonce = BigInt((await apiAt.query.tokenMigration.nextNonce()).toString()); + // (M2a) Nothing may leave the vault that was not burned on Pendulum. - // totalMigrated lags totalReleased only via finality delay, never the - // other way around: releases require attestations of finalized burns. - const migratedInTokenUnits = totalMigrated * conversionFactor; - if (totalReleased > migratedInTokenUnits) { + if (releasedExceedsMigrated(totalReleased, totalMigrated, conversionFactor)) { await alert( "CONSERVATION VIOLATION", - `released ${totalReleased} > migrated ${migratedInTokenUnits} (token units)`, + `released ${totalReleased} > migrated ${totalMigrated * conversionFactor} (token units)`, ); await pauseVault(); return; } - // (M2b) Vault-internal conservation. totalSwept accounts for the intended - // end-of-window sweep, which moves tokens out without touching - // totalReleased — omitting it would fire a guaranteed false positive (and - // auto-pause) on the first legitimate sweep. - if (vaultBalance + totalReleased + totalSwept !== totalSupply) { + // (M2b) Vault-internal conservation. Only a DEFICIT signals real loss; a + // surplus is a harmless inbound transfer (donation, or a migration whose + // recipient is the vault) and must not trip the check — otherwise a dust + // transfer would pause the vault every poll until the window-close sweep. + // totalSwept accounts for the intended end-of-window sweep. + if (vaultConservationDeficit(vaultBalance, totalReleased, totalSwept, totalSupply)) { await alert( - "VAULT BALANCE MISMATCH", - `balance ${vaultBalance} + released ${totalReleased} + swept ${totalSwept} != supply ${totalSupply}`, + "VAULT BALANCE DEFICIT", + `balance ${vaultBalance} + released ${totalReleased} + swept ${totalSwept} < supply ${totalSupply}`, ); await pauseVault(); return; } // (M4) Liveness: nonces the monitor has known about for longer than the - // grace period must be consumed on Base. + // grace period must be consumed on Base. Batch the per-nonce reads through + // Multicall3 so a large release backlog (e.g. during a pause) cannot make a + // cycle outrun the poll interval and starve the conservation checks above. const now = Date.now(); for (let nonce = 0n; nonce < nextNonce; nonce++) { if (!nonceFirstSeen.has(nonce)) nonceFirstSeen.set(nonce, now); } - for (const [nonce, firstSeen] of nonceFirstSeen) { - const consumed = await publicClient.readContract({ - address: config.vaultAddress, - abi: vaultAbi, - functionName: "nonceConsumed", - args: [nonce], - }); - if (consumed) { - nonceFirstSeen.delete(nonce); - } else if (now - firstSeen > config.graceSeconds * 1000) { - await alert( - "LIVENESS: migration not released", - `nonce ${nonce} unreleased for over ${config.graceSeconds}s — attestor outage, cap deferral or pause?`, - ); + const pendingNonces = [...nonceFirstSeen.keys()]; + if (pendingNonces.length > 0) { + const consumedFlags = await readNonceConsumed(pendingNonces); + for (let i = 0; i < pendingNonces.length; i++) { + const nonce = pendingNonces[i]; + if (consumedFlags[i]) { + nonceFirstSeen.delete(nonce); + } else if (isStale(nonceFirstSeen.get(nonce) ?? now, now, config.graceSeconds)) { + await alert( + "LIVENESS: migration not released", + `nonce ${nonce} unreleased for over ${config.graceSeconds}s — attestor outage, cap deferral or pause?`, + ); + } } } From a2de8d9c2657505b2bd012dadb60d2b5aa2171c7 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Thu, 9 Jul 2026 11:23:15 +0200 Subject: [PATCH 21/23] security: Fix round-6 audit findings (attestor vault-recipient DoS, min amount, liveness) Adversarial review focused on outsider bricking of the off-chain fleet. - CRITICAL: a migrate(_, ) 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. --- attestor/package.json | 3 +- attestor/src/checks.test.ts | 48 +++++++++++++++++++++ attestor/src/checks.ts | 35 ++++++++++++++++ attestor/src/main.ts | 14 +------ docs/pen-migration-internal-review.md | 60 +++++++++++++++++++++++++++ monitor/src/main.ts | 24 +++++++++-- runtime/pendulum/src/lib.rs | 9 +++- 7 files changed, 174 insertions(+), 19 deletions(-) create mode 100644 attestor/src/checks.test.ts create mode 100644 attestor/src/checks.ts diff --git a/attestor/package.json b/attestor/package.json index 470c999e8..cd1f06d9a 100644 --- a/attestor/package.json +++ b/attestor/package.json @@ -7,7 +7,8 @@ "scripts": { "build": "tsc", "start": "node dist/main.js", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "test": "tsc && node --test dist/checks.test.js" }, "dependencies": { "@polkadot/api": "^11.3.1", diff --git a/attestor/src/checks.test.ts b/attestor/src/checks.test.ts new file mode 100644 index 000000000..6068d09e9 --- /dev/null +++ b/attestor/src/checks.test.ts @@ -0,0 +1,48 @@ +/** + * Unit tests for the attestor's tuple-classification predicate. + * + * Run with `npm test` (compiles, then `node --test`). These lock in the round-6 + * fix: `isUnreleasable` must flag BOTH the zero address and the vault's own + * address (case-insensitively), so a `migrate(_, )` event is + * skipped with a critical alert instead of crash-looping the whole attestor + * fleet on the vault's `RecipientIsVault` revert. + */ + +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { isUnreleasable, ZERO_ADDRESS } from "./checks.js"; + +const VAULT = "0x1111111111111111111111111111111111111111"; +const NORMAL = "0x00000000000000000000000000000000deadbeef"; +const AMOUNT = 5_000_000_000_000n; // 5 PEN in 12-decimal pallet units + +test("a normal recipient with a non-zero amount is releasable", () => { + assert.equal(isUnreleasable(NORMAL, AMOUNT, VAULT), false); +}); + +test("the zero address is unreleasable (vault reverts ZeroAddress)", () => { + assert.equal(isUnreleasable(ZERO_ADDRESS, AMOUNT, VAULT), true); +}); + +test("the vault's own address is unreleasable (vault reverts RecipientIsVault)", () => { + // The round-6 finding: without this, a 1-PEN migration to the vault address + // crash-loops the entire fleet, since the pallet cannot reject it. + assert.equal(isUnreleasable(VAULT, AMOUNT, VAULT), true); +}); + +test("the vault address is matched case-insensitively", () => { + // The event decodes the H160 lower-cased; the configured vault address may + // be EIP-55 checksummed. The comparison must not depend on casing either way. + const vaultChecksummed = "0xAbCdEf0000000000000000000000000000000001"; + assert.equal(isUnreleasable(vaultChecksummed.toLowerCase(), AMOUNT, vaultChecksummed), true); + assert.equal(isUnreleasable(vaultChecksummed, AMOUNT, vaultChecksummed.toLowerCase()), true); +}); + +test("a zero amount is unreleasable (vault reverts ZeroAmount)", () => { + assert.equal(isUnreleasable(NORMAL, 0n, VAULT), true); +}); + +test("an unreleasable condition still wins when combined with a normal one", () => { + assert.equal(isUnreleasable(ZERO_ADDRESS, 0n, VAULT), true); + assert.equal(isUnreleasable(VAULT, 0n, VAULT), true); +}); diff --git a/attestor/src/checks.ts b/attestor/src/checks.ts new file mode 100644 index 000000000..3f5bceb13 --- /dev/null +++ b/attestor/src/checks.ts @@ -0,0 +1,35 @@ +/** + * Pure tuple-classification predicates for the attestor. + * + * Isolated from the chain-client plumbing in `main.ts` so they can be unit + * tested exhaustively (see checks.test.ts). The subtlety that has bitten a + * review round — which (nonce, recipient, amount) tuples the vault will + * *deterministically* reject — lives here, and must stay in exact lockstep with + * the input reverts at the top of `MigrationVault.approve()`. + */ + +export const ZERO_ADDRESS = "0x0000000000000000000000000000000000000000"; + +/** + * True when the vault will deterministically reject this tuple, no matter who + * submits it or when. Such an event must be SKIPPED (with a critical alert), + * never retried: because every attestor hits the identical revert at the same + * finalized block, crash-looping on it would halt the entire fleet and block + * every migration behind it (the round-2 zero-address DoS class). + * + * `MigrationVault.approve` reverts up-front on exactly three input conditions: + * - a zero recipient (`ZeroAddress`) + * - the vault's own address (`RecipientIsVault` — a self-transfer would + * break the monitor's conservation identity) + * - a zero amount (`ZeroAmount`) + * + * The pallet rejects the zero address and sub-minimum amounts before an event + * is ever emitted, but it CANNOT know the vault's Base address (it has no + * knowledge of Base state), so a `migrate(_, )` reaches the + * attestor as a well-formed event. This gate is therefore the ONLY line of + * defence against a vault-recipient event bricking the fleet. + */ +export function isUnreleasable(recipient: string, palletAmount: bigint, vaultAddress: string): boolean { + const to = recipient.toLowerCase(); + return to === ZERO_ADDRESS || to === vaultAddress.toLowerCase() || palletAmount === 0n; +} diff --git a/attestor/src/main.ts b/attestor/src/main.ts index 913b1e295..917ca6259 100644 --- a/attestor/src/main.ts +++ b/attestor/src/main.ts @@ -27,6 +27,7 @@ import { keccak256, } from "viem"; import { privateKeyToAccount } from "viem/accounts"; +import { isUnreleasable } from "./checks.js"; import { config } from "./config.js"; import { vaultAbi } from "./vaultAbi.js"; @@ -146,22 +147,11 @@ async function alreadyHandled(event: MigrationEvent): Promise { }); } -const ZERO_ADDRESS = "0x0000000000000000000000000000000000000000"; - -/** True when the vault will deterministically reject this tuple, no matter - * who submits it or when. Such an event must be skipped (with a critical - * alert), never retried: crash-looping on it would halt the entire fleet at - * this block and block every migration behind it. The pallet rejects these - * inputs, so seeing one here means a pallet/vault validation mismatch. */ -function isUnreleasable(event: MigrationEvent): boolean { - return event.recipient.toLowerCase() === ZERO_ADDRESS || event.palletAmount === 0n; -} - /** Submit the approval for one migration event, skipping work already done. */ async function approve(event: MigrationEvent): Promise { const label = `nonce=${event.nonce} recipient=${event.recipient} amount=${event.palletAmount}`; - if (isUnreleasable(event)) { + if (isUnreleasable(event.recipient, event.palletAmount, config.vaultAddress)) { await alert("CRITICAL: unreleasable migration event skipped permanently", label); return; } diff --git a/docs/pen-migration-internal-review.md b/docs/pen-migration-internal-review.md index 9601cbba1..93422c2f1 100644 --- a/docs/pen-migration-internal-review.md +++ b/docs/pen-migration-internal-review.md @@ -239,6 +239,66 @@ read gap) but is now removed outright: Base is read first, then the monotonically-growing `totalMigrated` at a strictly-later snapshot, so M2a cannot false-positive on an in-flight burn. +## Round 6 (2026-07-09, review focused on outsider bricking of the off-chain fleet) + +The on-chain fund path (rounds 1–5) held up. The headline finding is the +round-5 fix re-opening the round-2 DoS class one layer out, in the attestor. + +### C1(r6). CRITICAL — A 1-PEN migration to the vault address crash-loops the whole attestor fleet +Round 5 made `MigrationVault.approve` revert `RecipientIsVault` on a +vault-recipient tuple (closing the monitor surplus-wedge). But the attestor's +`isUnreleasable` gate — which skips permanently-reverting tuples so the fleet +never crash-loops on them — was not updated in lockstep: it flagged only the +zero address and zero amount. The pallet cannot reject the vault address (it has +no knowledge of Base state), so `migrate(1 PEN, )` reaches every +attestor as a well-formed event, its `approve` reverts deterministically, the +daemon treats the revert as fatal and exits, and — the checkpoint never having +advanced past the block — reprocesses the same block on restart, forever. All +five attestors hit the same finalized block and crash-loop together, halting +every migration behind it for the price of one 1-PEN transaction. Same class as +round-2 C1 (zero-address DoS). + +**Resolution (fixed):** `isUnreleasable` now also flags `recipient == +vaultAddress` (case-insensitively), so a vault-recipient event is skipped with a +distinct CRITICAL alert instead of crash-looping — the vault-aware attestor is +the *only* component that can defend against this, since the pallet can't see +the Base address and the portal check is bypassable by calling the extrinsic +directly. The predicate was extracted to `attestor/src/checks.ts` and +unit-tested (`attestor/src/checks.test.ts`, mirroring the monitor's `checks.ts`); +the attestor previously had no unit tests, which is why the coupling gap slipped +through. **Architectural note for the external audit:** the set of deterministic +`approve` reverts and the attestor's `isUnreleasable` set must stay in exact +lockstep — this is the third round a vault-side "reject bad tuple" change +reopened a fleet-crash hole. A shared, tested enumeration of the reverting +preconditions would end this recurrence. + +### M1(r6). MEDIUM — Minimum migration amount was below the fleet's per-migration gas cost +`MinimumMigrationAmount` was 1 PEN (~$0.0086 at $0.00858/PEN), below the ~3 Base +`approve` txs (~$0.01–$1 depending on Base gas) the fleet spends per migration, +making dust-spam a cheap asymmetric gas-drain grief on all five operators. +**Fixed:** raised to 100 PEN (~$0.86), which dominates fleet gas cost across +normal Base conditions while staying negligible for real holders. Tunable via +runtime upgrade; revisit if the PEN price or Base gas regime shifts materially. + +### L1(r6). LOW — Monitor re-fired liveness alerts every poll for a stalled or unreleasable nonce +The M4 liveness check paged for every past-grace unconsumed nonce on every poll, +so a pause backlog — or a burn to a structurally-unreleasable address (zero or +the vault, which never consumes) — produced an unbounded alert storm, training +on-call to ignore the highest-severity page. **Fixed:** liveness re-alerts are +throttled to at most once per grace period per nonce (cleared on consumption), +preserving outage visibility without the storm. + +### Round 6 explicitly verified as not vulnerable +Monitor auto-pause cannot be weaponised by an outsider (M2b fires only on a +deficit, unreachable without stealing from the vault; M2a can't false-positive +given the Base-first read ordering); the vault's opportunistic-release path +defers rather than reverts on pause/caps/insufficient-balance, so only a +vault-side *input* revert (now fully enumerated in `isUnreleasable`) can reach +the attestor's fatal path; `pendingApprovedAmount` is not inflatable by an +outsider (pending entries require three real attestations of real burns); +`migrate` correctly refuses locked/staked/vesting balance and the dust/ED +remainder check is sound. + ## Follow-ups for the external audit - These round-4 fixes touch the fund-release path and have **not** had a subsequent internal round; they are the first thing the external audit should diff --git a/monitor/src/main.ts b/monitor/src/main.ts index d103d7a52..6ec8f3763 100644 --- a/monitor/src/main.ts +++ b/monitor/src/main.ts @@ -121,6 +121,13 @@ async function pauseVault(): Promise { /** Timestamps (ms) at which the monitor first saw each pallet nonce count. */ const nonceFirstSeen = new Map(); +/** Timestamp (ms) of the last liveness alert per nonce. Re-alerts are throttled + * to at most once per grace period, so a pause backlog — or a burn to a + * structurally-unreleasable address (zero / the vault, which never consumes) — + * does not re-fire the highest-severity page on every single poll and train + * on-call to ignore it. Cleared when the nonce is finally consumed. */ +const livenessAlertedAt = new Map(); + let multicallUnavailable = false; /** Read `nonceConsumed` for many nonces, batched through Multicall3. @@ -243,11 +250,20 @@ async function check(api: ApiPromise): Promise { const nonce = pendingNonces[i]; if (consumedFlags[i]) { nonceFirstSeen.delete(nonce); + livenessAlertedAt.delete(nonce); } else if (isStale(nonceFirstSeen.get(nonce) ?? now, now, config.graceSeconds)) { - await alert( - "LIVENESS: migration not released", - `nonce ${nonce} unreleased for over ${config.graceSeconds}s — attestor outage, cap deferral or pause?`, - ); + // Throttle: page immediately on first staleness, then at most once + // per grace period, so an ongoing outage stays visible without + // storming (a permanently-unreleasable nonce would otherwise page + // every poll forever). + const lastAlerted = livenessAlertedAt.get(nonce); + if (lastAlerted === undefined || now - lastAlerted >= config.graceSeconds * 1000) { + livenessAlertedAt.set(nonce, now); + await alert( + "LIVENESS: migration not released", + `nonce ${nonce} unreleased for over ${config.graceSeconds}s — attestor outage, cap deferral, pause, or a burn to an unreleasable address?`, + ); + } } } } diff --git a/runtime/pendulum/src/lib.rs b/runtime/pendulum/src/lib.rs index aaba072ae..725577577 100644 --- a/runtime/pendulum/src/lib.rs +++ b/runtime/pendulum/src/lib.rs @@ -1118,8 +1118,13 @@ impl pallet_xcm_teleport::Config for Runtime { } parameter_types! { - // 1 PEN; keeps dust-sized migrations from spamming the attestor pipeline. - pub const MinimumMigrationAmount: Balance = UNIT; + // 100 PEN (~$0.86 at $0.00858/PEN). The minimum must exceed the marginal + // Base-gas cost the five-attestor fleet spends per migration (~3 `approve` + // txs, roughly $0.01–$1 depending on Base gas), or spamming dust migrations + // becomes a cheap asymmetric gas-drain grief on every operator. 100 PEN + // dominates that cost across normal Base conditions while staying negligible + // for any real holder. Tunable via runtime upgrade. + pub const MinimumMigrationAmount: Balance = 100 * UNIT; } impl token_migration::Config for Runtime { From 7052c09c5c6012681b3b6c9f6ad0d07bb0408748 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Fri, 10 Jul 2026 09:37:17 +0200 Subject: [PATCH 22/23] docs: Add migration-window analysis (6 months is sufficient) Live-chain analysis showing all genuinely-vesting PEN unlocks within ~4.5 months at the current measured ~23.6s block time (2.3 months at the 12s target), so a 6-month migration window clears every locked token with ~1.5 months of margin. Only a 30k-PEN permanent sentinel tail is stranded under any finite window. Recommends earliestSweepTimestamp ~= 6 months to preserve the early-scaledown option. --- docs/pen-migration-window-analysis.md | 111 ++++++++++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 docs/pen-migration-window-analysis.md diff --git a/docs/pen-migration-window-analysis.md b/docs/pen-migration-window-analysis.md new file mode 100644 index 000000000..aef6a3d0b --- /dev/null +++ b/docs/pen-migration-window-analysis.md @@ -0,0 +1,111 @@ +# PEN Migration: is a 6-month window enough? + +| | | +|---|---| +| **Question** | Can the migration window be 6 months instead of 12, so attestor + monitoring infrastructure can be scaled down sooner? | +| **Answer** | **Yes — with ~1.5 months of margin, even at Pendulum's current degraded block time.** | +| **Data source** | Pendulum mainnet, live query at ~block 7,384,000 (2026-07-10) via `rpc-pendulum.prd.pendulumchain.tech` | + +## TL;DR + +The only thing that can *force* a longer window is genuinely time-locked +vesting, because everything else can be unlocked on demand. **All real vesting +completes in ~4.5 months at the current block rate**, so a 6-month window +clears every locked token with room to spare. The window length is really an +*adoption* question, not a lock question — and 6 months is a healthy buffer. + +## The supply, and why locks are the only calendar constraint + +Supply is **fixed at 149.93M PEN** (confirmed: no inflation). At the snapshot: + +| Bucket | Amount | How it becomes migratable | Calendar-gated? | +|---|---|---|---| +| Freely transferable | ~110.45M | already is | no — day one | +| Vesting lock, **already vested** (stale) | ~23.9M | one `vest()` call | no — instant | +| Staked (`parachain-staking`) | ~3.75M | unstake, ~8h unbond at current block time | no — hours | +| Governance (democracy locks) | ~1.16M | remove vote / conviction expiry | holder-managed, short | +| Reserved (identity/proxy deposits) | ~0.0005M (484) | release the deposit | no — instant | +| **Vesting, genuinely still vesting** | **~11.87M** | **wait for the schedule** | **yes — the only real constraint** | + +(Locks overlap — an account is frozen by the *max* of its locks, not the sum — +so the net non-transferable figure is ~39.48M, and the rows above are gross.) + +Only the last row is time-locked. So the whole "6 vs 12 months" decision +reduces to: **how long until that ~11.87M of vesting finishes?** + +## The vesting timeline — computed at the *actual* block time + +Vesting releases **per block**, not per wall-clock second, so the answer +depends on block time. Pendulum's target is 12s, but it is **currently running +at a measured ~23.6s** (averaged over the last 10,000 blocks). We compute at +that slower, real rate so the argument is conservative — a faster block time +only shortens these numbers. + +Still-vesting balance remaining, in wall-clock months at the measured ~23.6s: + +| Horizon | Still vesting | +|---|---| +| +1 month | ~11.13M | +| +2 months | ~9.39M | +| +3 months | ~8.64M | +| +4 months | **~0.40M** | +| +5 months | **0** | +| +6 months | **0** | + +The last real vesting schedule ends at **+505,375 blocks from now**, which is: + +- **~4.5 months** at the current measured ~23.6s block time, +- ~3.8 months at 20s, +- ~2.3 months at the 12s target. + +So even at today's degraded rate, **every vesting schedule finishes roughly +1.5 months before a 6-month window would close.** If block production recovers +toward target, the margin only grows. The block-time concern does not threaten +the conclusion across its entire plausible range (12s → 24s). + +## The one permanent exception (irrelevant to 6 vs 12) + +**30,000 PEN** sits in **6 vesting schedules of 5,000 PEN each with a +`u32::MAX` start block** — "never-starts" locks that don't vest under *any* +finite window. They are stranded whether the window is 6 months or 12. That's +0.02% of supply; handle those 6 accounts case-by-case (contact the holders, or +accept them as a permanent Pendulum-side residue). They are not an argument for +a longer window. + +## The real constraint is adoption, not locks + +Because locks clear in ~4.5 months, the window length is fundamentally about +giving **holders, exchanges, and custodians** time to *act* — unstake, +`vest()`, and migrate. A 6-month window is ~4.5 months of unlocking plus ~1.5 +months of buffer, which is ample for a well-communicated migration. + +And a 6-month target is **low-risk**, because the close is operational, not a +hard cliff: + +- Set the vault's `earliestSweepTimestamp` to **~6 months**. It is immutable + and marks the *earliest* the remainder can be swept, so 6 months is exactly + what *preserves the option to wind down early*. Setting it to 12 would force + the remainder to stay locked until then even if migration finishes fast. +- The actual close is a runbook, driven by migration progress: pause the + pallet (stop new burns) → let in-flight migrations settle → sweep the + remainder → shut down the 4 attestors + monitor. +- If adoption lags, you keep the infrastructure running longer — nothing forces + you to stop at 6 months. You are setting a target, not signing a contract. + +## Recommendation + +- **Plan a 6-month window** and size the attestor + monitoring commitment to + ~6 months. +- Set `earliestSweepTimestamp ≈ deploy + 6 months`. +- Actively manage the two non-lock items: a **comms plan** so holders migrate + in time, and outreach to the **6 permanent-lock holders**. + +Nothing in the on-chain lock data argues for 12 months. + +## Caveats + +- This is a live snapshot from a public RPC at one block; the vesting curve + shifts slightly as schedules progress. Re-run against an internal node at + deploy time for an exact, fresh figure — but the *shape* (all real vesting + done by ~4.5 months at current block time) is stable. +- Numbers are rounded; treat them as decision-grade, not accounting-grade. From cad0027fc9165778df44cac399a58d22ef738c90 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Fri, 10 Jul 2026 11:09:15 +0200 Subject: [PATCH 23/23] docs: Adopt the 3-month migration window (D5 decided) Rewrites the window analysis around the decided 3-month target: at the 12s block-time target all real vesting completes in ~2.3 months (fits, ~0.7 months margin); at today's measured ~23.6s it takes ~4.5 months (does not fit). Break-even is a ~15.6s window-average block time, i.e. the block-time improvement must land within ~6 weeks of the window opening. Fallback either way: a referendum via the existing root-gated vesting-manager.remove_vesting_schedule force-unlocks any residue (and the 30k PEN permanent sentinel locks, which need it regardless). Also records D5 in the PRD, notes the new throughput constraint (a 3-month window needs ~1.7M PEN/day average release throughput, putting early governance cap raises on the critical path), and points EARLIEST_SWEEP_TS at deploy + 3 months. --- contracts/.env.example | 3 +- docs/pen-base-migration-prd.md | 4 +- docs/pen-migration-implementation-overview.md | 7 +- docs/pen-migration-window-analysis.md | 196 +++++++++--------- 4 files changed, 109 insertions(+), 101 deletions(-) diff --git a/contracts/.env.example b/contracts/.env.example index 9e33783e9..45281d9fb 100644 --- a/contracts/.env.example +++ b/contracts/.env.example @@ -16,7 +16,8 @@ MAX_ISSUANCE= # Initial caps in 18-decimal units (PRD V4: target < 1-2% of vault per day) PER_RELEASE_CAP= DAILY_CAP= -# Unix timestamp before which the remainder cannot be swept (decision D5) +# Unix timestamp before which the remainder cannot be swept. +# Decision D5: ~ deploy + 3 months (see docs/pen-migration-window-analysis.md) EARLIEST_SWEEP_TS= # --- DeployGovernance.s.sol (phase 5) --- diff --git a/docs/pen-base-migration-prd.md b/docs/pen-base-migration-prd.md index 9eb8a0819..8e021caa9 100644 --- a/docs/pen-base-migration-prd.md +++ b/docs/pen-base-migration-prd.md @@ -62,7 +62,7 @@ The migration is **one-way**. No reverse flow (Base → Pendulum) will be built. | D2 | Decimals on Base | Keep **12** vs. scale to **18** (×10⁶) | **18** (DeFi convention, avoids integration friction), provided max-issuance ×10⁶ arithmetic is verified exact end-to-end and dust-rounding is impossible by construction (12→18 is exact; only relevant if any 18→12 display path exists) | | D3 | Exact max issuance figure | Confirm the canonical number from tokenomics (including whether any never-minted allocation counts) | Must match what trackers/documentation state today | | D4 | Attestor set composition | 5 team-operated keys vs. 3 team + 2 external partners | At least 1–2 external/independent operators | -| D5 | Migration window end policy | Open indefinitely vs. close at date T; disposition of vault remainder (burn / DAO treasury) | Announce ≥ 12-month window; decide remainder disposition via governance vote before T | +| D5 | Migration window end policy | Open indefinitely vs. close at date T; disposition of vault remainder (burn / DAO treasury) | **DECIDED: 3-month window** (`earliestSweepTimestamp ≈ deploy + 3 months`), conditional on the planned block-time improvement toward 12s; a referendum (`vesting-manager.remove_vesting_schedule`) force-unlocks any vesting residue and the permanent sentinel locks before close. Remainder disposition via governance vote before T. See [window analysis](pen-migration-window-analysis.md) | | D6 | Encumbered balances policy | Handling of staked (`parachain-staking`), vesting (`vesting-manager`), governance-locked, and sub-ED balances | Require unstake/unlock first (migration accepts only transferable balance); publish this clearly since unstaking delay gates user migration speed | ## 5. System overview @@ -204,7 +204,7 @@ Ranked by where the risk actually lives: ## 11. Risks and open questions -- **Adoption risk:** slow migration leaves circulating supply small and Snapshot quorums awkward — mitigate with a long window, clear comms, and quorum defined on circulating supply (G1). +- **Adoption risk:** slow migration leaves circulating supply small and Snapshot quorums awkward — heightened by the 3-month window (D5): mitigate with front-loaded comms, early governance cap raises (≥ ~1.7M PEN/day average throughput is required arithmetic), quorum defined on circulating supply (G1), and the option to run the infrastructure a few weeks longer if needed. - **Unstaking delay friction (D6):** staked holders face the staking unbond period before they can migrate; comms must set expectations. - **Attestor operational maturity:** the honest hard part is ops, not code. External operators (D4) need onboarding, SLAs, and gas-funding agreements. - **Exchange coordination:** any CEX listing PEN needs a supported path (they migrate custody balances themselves via the same mechanism); start conversations in phase 1. diff --git a/docs/pen-migration-implementation-overview.md b/docs/pen-migration-implementation-overview.md index c1622e9b7..3d04233da 100644 --- a/docs/pen-migration-implementation-overview.md +++ b/docs/pen-migration-implementation-overview.md @@ -84,8 +84,11 @@ attestor → monitor → runbooks → benchmarks → security fixes → env temp ## Still open (cannot be done from the repo) -1. **Decisions D1–D6** (PRD §4.2) — most urgently max issuance (D3) and - decimals (D2), both baked into immutable contracts at deployment. +1. **Decisions D1–D6** (PRD §4.2) — D5 is decided (3-month window, + `earliestSweepTimestamp ≈ deploy + 3 months`, block-time improvement + + referendum fallback; see the [window analysis](pen-migration-window-analysis.md)); + the others are answered in principle but not yet recorded/wired (notably + the 4-attestor set, D4). 2. External audits (PRD §9) — the internal review doc is the starting brief. 3. Benchmark run on reference hardware → replace manual weights. 4. Attestor operator onboarding + key ceremonies; Safe setups (D4). diff --git a/docs/pen-migration-window-analysis.md b/docs/pen-migration-window-analysis.md index aef6a3d0b..5ccd27769 100644 --- a/docs/pen-migration-window-analysis.md +++ b/docs/pen-migration-window-analysis.md @@ -1,111 +1,115 @@ -# PEN Migration: is a 6-month window enough? +# PEN Migration: the 3-month window — analysis and conditions | | | |---|---| -| **Question** | Can the migration window be 6 months instead of 12, so attestor + monitoring infrastructure can be scaled down sooner? | -| **Answer** | **Yes — with ~1.5 months of margin, even at Pendulum's current degraded block time.** | +| **Decision** | Migration window target: **3 months**. Block time will be improved toward the 12s target; if any vesting hasn't finished by close, a **referendum force-unlocks the rest**. | +| **Verdict of this analysis** | Workable — but *conditional*. At the 12s target, all vesting finishes in ~2.3 months (fits). At today's measured ~23.6s it takes ~4.5 months (does not fit). The deciding variable is when the block-time fix lands; the referendum is the safety net that makes the plan sound either way. | | **Data source** | Pendulum mainnet, live query at ~block 7,384,000 (2026-07-10) via `rpc-pendulum.prd.pendulumchain.tech` | -## TL;DR +## Why the window length is a vesting question -The only thing that can *force* a longer window is genuinely time-locked -vesting, because everything else can be unlocked on demand. **All real vesting -completes in ~4.5 months at the current block rate**, so a 6-month window -clears every locked token with room to spare. The window length is really an -*adoption* question, not a lock question — and 6 months is a healthy buffer. - -## The supply, and why locks are the only calendar constraint - -Supply is **fixed at 149.93M PEN** (confirmed: no inflation). At the snapshot: +Supply is **fixed at 149.93M PEN** (no inflation, confirmed). Every locked +bucket except one can be freed *on demand*, with no calendar dependency: | Bucket | Amount | How it becomes migratable | Calendar-gated? | |---|---|---|---| | Freely transferable | ~110.45M | already is | no — day one | | Vesting lock, **already vested** (stale) | ~23.9M | one `vest()` call | no — instant | -| Staked (`parachain-staking`) | ~3.75M | unstake, ~8h unbond at current block time | no — hours | +| Staked (`parachain-staking`) | ~3.75M | unstake, 2-round unbond (hours) | no | | Governance (democracy locks) | ~1.16M | remove vote / conviction expiry | holder-managed, short | -| Reserved (identity/proxy deposits) | ~0.0005M (484) | release the deposit | no — instant | -| **Vesting, genuinely still vesting** | **~11.87M** | **wait for the schedule** | **yes — the only real constraint** | - -(Locks overlap — an account is frozen by the *max* of its locks, not the sum — -so the net non-transferable figure is ~39.48M, and the rows above are gross.) - -Only the last row is time-locked. So the whole "6 vs 12 months" decision -reduces to: **how long until that ~11.87M of vesting finishes?** - -## The vesting timeline — computed at the *actual* block time - -Vesting releases **per block**, not per wall-clock second, so the answer -depends on block time. Pendulum's target is 12s, but it is **currently running -at a measured ~23.6s** (averaged over the last 10,000 blocks). We compute at -that slower, real rate so the argument is conservative — a faster block time -only shortens these numbers. - -Still-vesting balance remaining, in wall-clock months at the measured ~23.6s: - -| Horizon | Still vesting | -|---|---| -| +1 month | ~11.13M | -| +2 months | ~9.39M | -| +3 months | ~8.64M | -| +4 months | **~0.40M** | -| +5 months | **0** | -| +6 months | **0** | - -The last real vesting schedule ends at **+505,375 blocks from now**, which is: - -- **~4.5 months** at the current measured ~23.6s block time, -- ~3.8 months at 20s, -- ~2.3 months at the 12s target. - -So even at today's degraded rate, **every vesting schedule finishes roughly -1.5 months before a 6-month window would close.** If block production recovers -toward target, the margin only grows. The block-time concern does not threaten -the conclusion across its entire plausible range (12s → 24s). - -## The one permanent exception (irrelevant to 6 vs 12) - -**30,000 PEN** sits in **6 vesting schedules of 5,000 PEN each with a -`u32::MAX` start block** — "never-starts" locks that don't vest under *any* -finite window. They are stranded whether the window is 6 months or 12. That's -0.02% of supply; handle those 6 accounts case-by-case (contact the holders, or -accept them as a permanent Pendulum-side residue). They are not an argument for -a longer window. - -## The real constraint is adoption, not locks - -Because locks clear in ~4.5 months, the window length is fundamentally about -giving **holders, exchanges, and custodians** time to *act* — unstake, -`vest()`, and migrate. A 6-month window is ~4.5 months of unlocking plus ~1.5 -months of buffer, which is ample for a well-communicated migration. - -And a 6-month target is **low-risk**, because the close is operational, not a -hard cliff: - -- Set the vault's `earliestSweepTimestamp` to **~6 months**. It is immutable - and marks the *earliest* the remainder can be swept, so 6 months is exactly - what *preserves the option to wind down early*. Setting it to 12 would force - the remainder to stay locked until then even if migration finishes fast. -- The actual close is a runbook, driven by migration progress: pause the - pallet (stop new burns) → let in-flight migrations settle → sweep the - remainder → shut down the 4 attestors + monitor. -- If adoption lags, you keep the infrastructure running longer — nothing forces - you to stop at 6 months. You are setting a target, not signing a contract. - -## Recommendation - -- **Plan a 6-month window** and size the attestor + monitoring commitment to - ~6 months. -- Set `earliestSweepTimestamp ≈ deploy + 6 months`. -- Actively manage the two non-lock items: a **comms plan** so holders migrate - in time, and outreach to the **6 permanent-lock holders**. - -Nothing in the on-chain lock data argues for 12 months. +| Reserved (identity/proxy deposits) | ~484 PEN | release the deposit | no — instant | +| **Vesting, genuinely still vesting** | **~11.87M** | **wait for the schedule (per block)** | **yes — the only real constraint** | + +So "is 3 months enough?" reduces to: does the ~11.87M of genuine vesting +finish within 3 months? + +## The block-time dependency, quantified + +Vesting releases **per block**, so wall-clock completion depends directly on +block time. The last real schedule completes **505,375 blocks** from the +snapshot. That is: + +| Average block time over the window | Vesting completes in | Fits 3 months? | +|---|---|---| +| 12s (target) | **~2.3 months** | yes, ~0.7 months margin | +| ~15.6s | ~3.0 months | exactly the break-even | +| 20s | ~3.8 months | no | +| ~23.6s (measured today, 10k-block avg) | ~4.5 months | no | + +Two concrete planning numbers fall out of this: + +- **Break-even: the window-average block time must be ≤ ~15.6s.** +- **If the chain jumps from today's ~23.6s straight to 12s, the fix must be + live within ~6 weeks of the window opening** for vesting to finish inside + 3 months (each week at the slow rate consumes roughly half a week of the + margin). + +So: improve block time *early* in the window, not toward the end. + +## The fallback that makes 3 months safe anyway + +If block production doesn't recover fast enough, some residue of the 11.87M is +still vesting at close. This is covered — with on-chain machinery that +**already exists in this runtime**: + +- The `vesting-manager` pallet exposes a **root-gated + `remove_vesting_schedule(who, index)`**. A referendum (or the same + governance track that authorizes the window close) can remove the remaining + schedules, which unlocks the tokens immediately; holders then migrate + normally before the final sweep. +- The same mechanism cleanly handles the **~30,000 PEN in 6 permanent + "never-starts" schedules** (`u32::MAX` start block) that would otherwise be + stranded under *any* finite window — 3, 6, or 12 months alike. These 6 + accounts need the referendum (or case-by-case outreach) regardless of the + window length, so they are not an argument for a longer window. + +And structurally, the close is **operational, not a hard cliff**: the sequence +is pause the pallet → settle in-flight migrations → reconcile → sweep +(runbook RB-7). If adoption or vesting lags, the infrastructure simply runs a +few weeks longer — a 3-month *target* with the option to extend, not a +contract. + +## What the shorter window changes operationally + +1. **`earliestSweepTimestamp` ≈ deploy + 3 months.** It is immutable and marks + the *earliest* allowed sweep — setting it at 3 months preserves the option + to wind down on schedule while never forcing it. +2. **Daily-cap throughput now matters.** Migrating ~150M PEN within ~90 days + needs an *average* release throughput of ~1.7M PEN/day. The PRD's + initial-cap guidance (~1–2% of vault per day = 1.5–3M/day) is compatible, + but the deliberately conservative soft-launch caps must be **raised + promptly via governance** once the launch is verified — cap raises are on + the critical path of a 3-month plan in a way they weren't at 6–12 months. +3. **Comms compress.** Holders' checklist (call `vest()`, unstake ~hours, + remove governance votes, migrate) is quick per holder, but exchanges and + passive holders need the announcement, reminders, and deadline pressure + inside a much shorter arc. The ~23.9M of *already-vested-but-stale* locks + (holders who never called `vest()`) is the strongest evidence that passive + holders exist and need active prodding. +4. **Referendum lead time counts against the window.** A Pendulum referendum + has voting + enactment periods (weeks). If block time hasn't recovered by + ~month 2, *start the unlock referendum then* — don't wait until the window + ends to begin a multi-week governance process. + +## Recommendation (under the 3-month decision) + +- Set `earliestSweepTimestamp ≈ deploy + 3 months`. +- Land the block-time improvement **within the first ~6 weeks** of the window; + track window-average block time against the ~15.6s break-even. +- Pre-draft the vesting-unlock referendum so it can be submitted at ~month 2 + if vesting is projected to overrun — it is also the vehicle for the 30k + sentinel-lock tail either way. +- Verify launch caps quickly and raise `dailyCap` early; ~1.7M PEN/day average + throughput is required arithmetic, not an optimization. +- Size the attestor + monitoring commitment to ~3 months with a soft option to + extend a few weeks. ## Caveats -- This is a live snapshot from a public RPC at one block; the vesting curve - shifts slightly as schedules progress. Re-run against an internal node at - deploy time for an exact, fresh figure — but the *shape* (all real vesting - done by ~4.5 months at current block time) is stable. -- Numbers are rounded; treat them as decision-grade, not accounting-grade. +- Live snapshot from a public RPC at one block; re-run against an internal + node at deploy time (the shape is stable, exact figures drift as schedules + progress). +- Block-time projections assume the improvement is a step change to ~12s; a + gradual ramp lands between the table rows. The break-even framing + (window-average ≤ ~15.6s) is the robust way to track it. +- Numbers are decision-grade, not accounting-grade.