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/Cargo.lock b/Cargo.lock index 62d42714d..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", @@ -15495,6 +15496,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/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..cd1f06d9a --- /dev/null +++ b/attestor/package.json @@ -0,0 +1,21 @@ +{ + "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", + "test": "tsc && node --test dist/checks.test.js" + }, + "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/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/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..917ca6259 --- /dev/null +++ b/attestor/src/main.ts @@ -0,0 +1,243 @@ +/** + * 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 { isUnreleasable } from "./checks.js"; +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 }. + // 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, + { 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; +} + +/** 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) return true; + return publicClient.readContract({ + address: config.vaultAddress, + abi: vaultAbi, + functionName: "hasApproved", + args: [payloadHash(event), account.address], + }); +} + +/** 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.recipient, event.palletAmount, config.vaultAddress)) { + await alert("CRITICAL: unreleasable migration event skipped permanently", label); + return; + } + + if (await alreadyHandled(event)) { + log(`skip (already released or approved): ${label}`); + return; + } + + 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; + } +} + +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((error) => console.error("gas balance check failed", error)), + 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"] +} diff --git a/contracts/.env.example b/contracts/.env.example new file mode 100644 index 000000000..45281d9fb --- /dev/null +++ b/contracts/.env.example @@ -0,0 +1,29 @@ +# --- 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: ~ deploy + 3 months (see docs/pen-migration-window-analysis.md) +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) 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/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/MigrationVault.sol b/contracts/src/MigrationVault.sol new file mode 100644 index 000000000..acabd7bee --- /dev/null +++ b/contracts/src/MigrationVault.sol @@ -0,0 +1,460 @@ +// 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 RecipientIsVault(); + 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 requested, uint256 available); + error SweepNotYetAllowed(uint256 earliest); + error PendingNotStale(); + error InsufficientVaultBalance(); + error ExceedsSweepable(uint256 requested, uint256 sweepable); + error SweepSettlingAfterThresholdCut(uint256 allowedFrom); + + // ---------------------------------------------------------------- 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); + event ReleasePending(uint64 indexed nonce, address indexed recipient, uint256 tokenAmount); + event StalePendingCleared(bytes32 indexed payload, uint256 tokenAmount); + + // ---------------------------------------------------------------- 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 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; + /// @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). + 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)) 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). + 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; + + /// @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; + + /// @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() { + 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; + attestorGeneration[attestor] = 1; + 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(); + // 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); + + bytes32 payload = payloadHash(nonce, recipient, palletAmount); + 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 + // is hit, so the approval is recorded either way. `release` can be + // 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 + && tokenAmount <= availableDailyAllowance() + && token.balanceOf(address(this)) >= tokenAmount; + 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); + } + } + } + + /// @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 available = availableDailyAllowance(); + if (tokenAmount > available) revert ExceedsDailyCap(tokenAmount, available); + if (token.balanceOf(address(this)) < tokenAmount) revert InsufficientVaultBalance(); + + _release(nonce, recipient, palletAmount, payload); + } + + /// @dev Caller must have verified pause state, approvals and caps. + 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; + } + windowConsumed = _decayedConsumed() + tokenAmount; + windowUpdatedAt = block.timestamp; + 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) { + return keccak256(abi.encode(nonce, recipient, palletAmount)); + } + + /// @notice Approvals for a payload counted against the current attestor + /// 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++) { + 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. 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) { + return isAttestor[attestor] && _approvalGeneration[payload][attestor] == attestorGeneration[attestor]; + } + + function approversOf(bytes32 payload) external view returns (address[] memory) { + return _approvers[payload]; + } + + /// @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 + + 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; + // 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); + } + + 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(); + // 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_); + } + + 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 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 (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)); + // 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/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/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/MigrationVault.t.sol b/contracts/test/MigrationVault.t.sol new file mode 100644 index 000000000..146a25b10 --- /dev/null +++ b/contracts/test/MigrationVault.t.sol @@ -0,0 +1,531 @@ +// 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_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); + } + assertEq(pen.balanceOf(recipient), 2_000_000e18); + assertEq(vault.availableDailyAllowance(), 0); + + // 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, 1_000_000e18, 0)); + vault.release(2, recipient, palletAmount); + + // 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 { + 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_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]); + 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"), 1); + 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, MAX_ISSUANCE); + + vm.warp(earliestSweep); + vm.prank(admin); + 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 + + 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); + // The pending (owed) amount is not sweepable. + vm.prank(admin); + 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"); + + // 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); + } + + // 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); + } + + // 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); + 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); + } + + // 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 { + 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); + // Conservation incl. the sweep accumulator (monitor's M2b formula). + assertEq( + pen.balanceOf(address(vault)) + vault.totalReleased() + vault.totalSwept(), + 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); + } +} 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"); + } +} 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..8e021caa9 --- /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) | **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 + +``` + 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 — 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. +- **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-governance-guide.md b/docs/pen-governance-guide.md new file mode 100644 index 000000000..90a02f8ae --- /dev/null +++ b/docs/pen-governance-guide.md @@ -0,0 +1,224 @@ +# 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 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 +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. diff --git a/docs/pen-migration-implementation-overview.md b/docs/pen-migration-implementation-overview.md new file mode 100644 index 000000000..3d04233da --- /dev/null +++ b/docs/pen-migration-implementation-overview.md @@ -0,0 +1,98 @@ +# 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 `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 | +| `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` 11/11 (incl. benchmark suite). +- Runtime: `cargo check -p pendulum-runtime` clean, both feature sets. +- Contracts: `forge test` 35/35 (incl. 512-run fuzz). +- Attestor & monitor: `tsc --noEmit` clean. +- 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) + +`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) — 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). +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. diff --git a/docs/pen-migration-internal-review.md b/docs/pen-migration-internal-review.md new file mode 100644 index 000000000..93422c2f1 --- /dev/null +++ b/docs/pen-migration-internal-review.md @@ -0,0 +1,309 @@ +# 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. + +## 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. + +## 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). + +## 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. + +## 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. + +## 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 + 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 new file mode 100644 index 000000000..8648d8257 --- /dev/null +++ b/docs/pen-migration-runbooks.md @@ -0,0 +1,178 @@ +# 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 +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. +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-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`, 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, +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 + 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). +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. diff --git a/docs/pen-migration-window-analysis.md b/docs/pen-migration-window-analysis.md new file mode 100644 index 000000000..5ccd27769 --- /dev/null +++ b/docs/pen-migration-window-analysis.md @@ -0,0 +1,115 @@ +# PEN Migration: the 3-month window — analysis and conditions + +| | | +|---|---| +| **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` | + +## Why the window length is a vesting question + +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, 2-round unbond (hours) | no | +| Governance (democracy locks) | ~1.16M | remove vote / conviction expiry | holder-managed, short | +| 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 + +- 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. 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. 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..2cf6c7323 --- /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 + 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) + +| 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..f1a8b893d --- /dev/null +++ b/monitor/package.json @@ -0,0 +1,21 @@ +{ + "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", + "test": "tsc && node --test dist/checks.test.js" + }, + "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/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 new file mode 100644 index 000000000..6ec8f3763 --- /dev/null +++ b/monitor/src/main.ts @@ -0,0 +1,293 @@ +/** + * 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"; +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" }] }, + { 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" }] }, + { + 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] } }, + contracts: { multicall3: { address: MULTICALL3_ADDRESS } }, +}); +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(); + +/** 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. + * + * 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; +} + +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 }), + 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 }), + ]); + const [totalSupply, vaultBalance] = await Promise.all([ + publicClient.readContract({ address: tokenAddress, abi: erc20Abi, functionName: "totalSupply", blockNumber }), + publicClient.readContract({ + address: tokenAddress, + abi: erc20Abi, + functionName: "balanceOf", + args: [config.vaultAddress], + blockNumber, + }), + ]); + + // --- 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. + if (releasedExceedsMigrated(totalReleased, totalMigrated, conversionFactor)) { + await alert( + "CONSERVATION VIOLATION", + `released ${totalReleased} > migrated ${totalMigrated * conversionFactor} (token units)`, + ); + await pauseVault(); + return; + } + + // (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 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. 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); + } + 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); + livenessAlertedAt.delete(nonce); + } else if (isStale(nonceFirstSeen.get(nonce) ?? now, now, config.graceSeconds)) { + // 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?`, + ); + } + } + } + } + + log( + `ok: migrated=${totalMigrated} released=${totalReleased} swept=${totalSwept} ` + + `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"] +} 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/benchmarking.rs b/pallets/token-migration/src/benchmarking.rs new file mode 100644 index 000000000..b6aa71405 --- /dev/null +++ b/pallets/token-migration/src/benchmarking.rs @@ -0,0 +1,77 @@ +//! 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(()) + } + + #[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 new file mode 100644 index 000000000..eb7b7bd6a --- /dev/null +++ b/pallets/token-migration/src/default_weights.rs @@ -0,0 +1,41 @@ +//! 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; + fn set_treasury_destination() -> Weight; + fn migrate_treasury() -> 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)) + } + + 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 new file mode 100644 index 000000000..1fb47dc2d --- /dev/null +++ b/pallets/token-migration/src/lib.rs @@ -0,0 +1,265 @@ +//! # 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(feature = "runtime-benchmarks")] +pub mod benchmarking; + +#[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; + + /// 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; + } + + #[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 }, + /// The fixed Base destination for treasury migrations was set. + TreasuryDestinationSet { base_address: H160 }, + } + + #[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, + /// 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, + /// 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; + /// 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>; + + /// 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. + /// + /// 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 + ); + // 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)?; + 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); + + // 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. + #[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(()) + } + + /// 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 new file mode 100644 index 000000000..075ff5851 --- /dev/null +++ b/pallets/token-migration/src/mock.rs @@ -0,0 +1,134 @@ +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; + pub const TreasuryAccount: AccountId = 999; +} + +impl Config for Test { + type RuntimeEvent = RuntimeEvent; + type Currency = Balances; + type MinimumMigrationAmount = MinimumMigrationAmount; + type PauseOrigin = EnsureRoot; + type TreasuryAccount = TreasuryAccount; + type TreasuryMigrateOrigin = EnsureRoot; + type WeightInfo = SubstrateWeight; +} + +// ------- Constants and Genesis Config ------ // + +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; + +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), + (TREASURY, TREASURY_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..df83f63cc --- /dev/null +++ b/pallets/token-migration/src/tests.rs @@ -0,0 +1,314 @@ +use crate::{mock::*, Error, Event, NextNonce, Paused, TotalMigrated, TreasuryDestination}; +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_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(|| { + 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); + // 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); + }); +} + +#[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 + ); + }); +} + +// ---------------------------------------------------------------- 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/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..725577577 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,31 @@ impl pallet_xcm_teleport::Config for Runtime { type TreasuryAccount = PendulumTreasuryAccount; } +parameter_types! { + // 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 { + 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 TreasuryAccount = PendulumTreasuryAccount; + // Same authority that approves treasury spends: root or 3/5 council. + type TreasuryMigrateOrigin = TreasuryApproveOrigin; + 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 +1718,8 @@ construct_runtime!( XcmTeleport: pallet_xcm_teleport = 101, + TokenMigration: token_migration = 102, + MessageQueue: pallet_message_queue = 110, } ); @@ -1725,6 +1753,7 @@ mod benches { [orml_currencies_allowance_extension, TokenAllowance] [treasury_buyout_extension, TreasuryBuyoutExtension] + [token_migration, TokenMigration] [dia_oracle, DiaOracleModule] );