diff --git a/testing/.env.rehearsal.example b/testing/.env.rehearsal.example index 582678760..8ad889679 100644 --- a/testing/.env.rehearsal.example +++ b/testing/.env.rehearsal.example @@ -35,7 +35,8 @@ RELEASER_PRIVATE_KEY= # separately on Anvil in phase 1, where time can be warped. REHEARSAL_DAILY_CAP_PEN=28800 # Must not exceed the daily cap: the vault enforces perReleaseCap <= dailyCap, -# since anything in between could never fit the daily allowance. +# since anything in between could never fit the daily allowance, and the +# harness refuses such a pair at startup. Unset, it equals the daily cap. REHEARSAL_PER_RELEASE_CAP_PEN=28800 # How far ahead the immutable sweep floor sits. Short so the sweep path is diff --git a/testing/src/drills.mjs b/testing/src/drills.mjs index 6559f1578..486448268 100644 --- a/testing/src/drills.mjs +++ b/testing/src/drills.mjs @@ -35,7 +35,9 @@ import { assertTestnet, buildContext, CONVERSION_FACTOR, loadEnv, PEN_12, ROOT, send, TESTING, } from "./rehearsal-env.mjs"; import { deployToSepolia } from "./rehearsal-deploy.mjs"; -import { discoverCollator, generateSpec, killMatching, sleep, spawnNetwork, teardown, waitForFinality } from "./zombienet.mjs"; +import { + discoverCollator, generateSpec, killMatching, monitorStart, sleep, spawnNetwork, teardown, waitForFinality, +} from "./zombienet.mjs"; const flags = new Set(process.argv.slice(2)); const DAEMONS = ["attestor1", "attestor2", "attestor3", "attestor4", ...(MONITOR_DIR ? ["monitor"] : []), "releaser"]; @@ -137,10 +139,13 @@ async function main() { const baseEnv = { BASE_RPC_URL: env.BASE_SEPOLIA_RPC_URL, VAULT_ADDRESS: vault, BASE_CHAIN_ID: "84532", POLL_INTERVAL_MS: "12000", + // Base Sepolia's public RPC serves eth_getLogs for at most 200 blocks. + MAX_BLOCK_RANGE: process.env.MAX_BLOCK_RANGE ?? "200", + BASE_MAX_BLOCK_RANGE: process.env.BASE_MAX_BLOCK_RANGE ?? "200", }; const pendulumHead = (await api.query.system.number()).toString(); const baseHead = String(await ctx.pub.getBlockNumber()); - const monitorStartNonce = (await api.query.tokenMigration.nextNonce()).toString(); + const monitorFrom = await monitorStart(api); const attestorEnv = (i) => ({ ...baseEnv, PENDULUM_WS: pendulumWs, // The Zombienet chain has its own genesis, not Pendulum's. @@ -157,8 +162,7 @@ async function main() { PENDULUM_WS: pendulumWs, GRACE_SECONDS: "300", GUARDIAN_PRIVATE_KEY: env.GUARDIAN_PRIVATE_KEY, - PENDULUM_START_BLOCK: (BigInt(pendulumHead) + 1n).toString(), - PENDULUM_START_NONCE: monitorStartNonce, + ...monitorFrom, BASE_START_BLOCK: baseHead, }); start("releaser", "releaser", { ...baseEnv, RELEASER_PRIVATE_KEY: env.RELEASER_PRIVATE_KEY, START_BLOCK: baseHead }); diff --git a/testing/src/phase3-e2e.mjs b/testing/src/phase3-e2e.mjs index 56238e07d..dc16fc799 100644 --- a/testing/src/phase3-e2e.mjs +++ b/testing/src/phase3-e2e.mjs @@ -248,17 +248,20 @@ await monitorCheck("a real vault deficit alerts and reaches a confirmed automati await monitorCheck("fabricated approvals are reported and pause the vault before they can reach quorum", async () => { // One attestor's approval cannot release on its own, so the monitor reports // it; a second attestor approving the same tuple pauses the vault before a - // third approval could release it. + // third approval could release it. Chopsticks' clock trails Anvil's, so the + // monitor may never see a Pendulum view late enough to prove the tuple + // fabricated; it then reports the approval as unverified once its grace runs + // out and pauses as a precaution. Either verdict passes. const fabricated = { ...V, functionName: "approve", args: [9_999_999n, admin.address, MIN] }; const before = logs("monitor").length; await send(attestors[0], fabricated); - await waitFor(() => logs("monitor").slice(before).includes("MISMATCHED APPROVAL"), { + await waitFor(() => /MISMATCHED APPROVAL|BASE EVENT NOT VERIFIED IN TIME/.test(logs("monitor").slice(before)), { timeoutMs: 30_000, label: "the monitor to report a fabricated approval", }); assertEq(await read(V, "paused"), false, "one fabricated approval leaves the vault running"); await send(attestors[1], fabricated); - await waitFor(() => logs("monitor").slice(before).includes("MIGRATION TUPLE VIOLATION"), { + await waitFor(() => /MIGRATION TUPLE VIOLATION|PRECAUTIONARY PAUSE/.test(logs("monitor").slice(before)), { timeoutMs: 30_000, label: "the monitor to reject the second fabricated approval", }); diff --git a/testing/src/rehearsal-deploy.mjs b/testing/src/rehearsal-deploy.mjs index 5e9d6a227..8e23d930f 100644 --- a/testing/src/rehearsal-deploy.mjs +++ b/testing/src/rehearsal-deploy.mjs @@ -13,15 +13,12 @@ import { execFileSync } from "node:child_process"; import { readFileSync } from "node:fs"; import path from "node:path"; -import { BASE_SEPOLIA_CHAIN_ID, PEN_18, ROOT } from "./rehearsal-env.mjs"; +import { BASE_SEPOLIA_CHAIN_ID, PEN_18, rehearsalCapsPen, ROOT } from "./rehearsal-env.mjs"; const CONTRACTS = path.join(ROOT, "contracts"); export function rehearsalParams(env) { - const dailyCapPen = BigInt(env.REHEARSAL_DAILY_CAP_PEN ?? "28800"); - // Equal to the daily cap: the vault rejects perReleaseCap > dailyCap (that - // band could never release). - const perReleaseCapPen = BigInt(env.REHEARSAL_PER_RELEASE_CAP_PEN ?? "28800"); + const { daily: dailyCapPen, perRelease: perReleaseCapPen } = rehearsalCapsPen(env); return { maxIssuance: 150_000_000n * PEN_18, dailyCap: dailyCapPen * PEN_18, diff --git a/testing/src/rehearsal-env.mjs b/testing/src/rehearsal-env.mjs index 73fab4869..1fb0a40d0 100644 --- a/testing/src/rehearsal-env.mjs +++ b/testing/src/rehearsal-env.mjs @@ -73,9 +73,27 @@ export function loadEnv() { throw new Error(`${key} is not a 32-byte hex private key`); } } + rehearsalCapsPen(env); // refuses inverted caps before anything is spawned or deployed return env; } +/** The rehearsal caps in whole PEN, sized for wall clock (see + * .env.rehearsal.example). The per-release cap defaults to the daily cap. */ +export function rehearsalCapsPen(env) { + const daily = BigInt(env.REHEARSAL_DAILY_CAP_PEN ?? "28800"); + const perRelease = BigInt(env.REHEARSAL_PER_RELEASE_CAP_PEN ?? daily); + // The vault rejects perReleaseCap > dailyCap (CapsInverted): a release in + // between could never fit the daily allowance. + if (perRelease > daily) { + throw new Error( + `REHEARSAL_PER_RELEASE_CAP_PEN (${perRelease}) is above REHEARSAL_DAILY_CAP_PEN (${daily}), ` + + "which the vault rejects (CapsInverted). Set it to at most the daily cap, or leave it unset " + + "to use the daily cap (28800 PEN each by default).", + ); + } + return { daily, perRelease }; +} + export function buildContext(env) { const chain = defineChain({ id: BASE_SEPOLIA_CHAIN_ID, diff --git a/testing/src/rehearsal.mjs b/testing/src/rehearsal.mjs index 7acac0287..5cf2f8855 100644 --- a/testing/src/rehearsal.mjs +++ b/testing/src/rehearsal.mjs @@ -35,7 +35,9 @@ import { assertTestnet, buildContext, CONVERSION_FACTOR, loadEnv, PEN_12, PEN_18, ROOT, send, TESTING, } from "./rehearsal-env.mjs"; import { deployToSepolia, rehearsalParams } from "./rehearsal-deploy.mjs"; -import { discoverCollator, killMatching, generateSpec, sleep, spawnNetwork, teardown, waitForFinality } from "./zombienet.mjs"; +import { + discoverCollator, killMatching, generateSpec, monitorStart, sleep, spawnNetwork, teardown, waitForFinality, +} from "./zombienet.mjs"; const flags = new Set(process.argv.slice(2)); const FUND = flags.has("--fund"); @@ -308,10 +310,13 @@ async function main() { // the starts so they do not align into bursts. In production each // attestor has its own node and this pressure does not arise. POLL_INTERVAL_MS: "12000", + // Base Sepolia's public RPC serves eth_getLogs for at most 200 blocks. + MAX_BLOCK_RANGE: process.env.MAX_BLOCK_RANGE ?? "200", + BASE_MAX_BLOCK_RANGE: process.env.BASE_MAX_BLOCK_RANGE ?? "200", }; const pendulumHead = (await api.query.system.number()).toString(); const baseHead = String(await ctx.pub.getBlockNumber()); - const monitorStartNonce = (await api.query.tokenMigration.nextNonce()).toString(); + const monitorFrom = await monitorStart(api); // The Zombienet chain has its own genesis, not Pendulum's. const pendulumGenesisHash = api.genesisHash.toHex(); for (let i = 0; i < 4; i++) { @@ -327,8 +332,7 @@ async function main() { PENDULUM_WS: pendulumWs, GRACE_SECONDS: "300", GUARDIAN_PRIVATE_KEY: env.GUARDIAN_PRIVATE_KEY, - PENDULUM_START_BLOCK: (BigInt(pendulumHead) + 1n).toString(), - PENDULUM_START_NONCE: monitorStartNonce, + ...monitorFrom, BASE_START_BLOCK: baseHead, }); start("releaser", "releaser", { ...baseEnv, RELEASER_PRIVATE_KEY: env.RELEASER_PRIVATE_KEY, START_BLOCK: baseHead }); diff --git a/testing/src/zombienet.mjs b/testing/src/zombienet.mjs index 7838e48fa..51d8df0a2 100644 --- a/testing/src/zombienet.mjs +++ b/testing/src/zombienet.mjs @@ -15,7 +15,10 @@ import { ROOT, TESTING } from "./rehearsal-env.mjs"; export const CHAIN_BINARIES = [ "zombienet-macos", - "target/testnet/polkadot", + // Zombienet starts the relay validators as `polkadot` from PATH (see + // zombienet.toml), so match their command line wherever the binary lives. + // Their PVF workers exit with them. + "polkadot --chain", "target/release/pendulum-node", ]; @@ -145,3 +148,14 @@ export async function waitForFinality(api, { timeoutMs = 300_000, log } = {}) { } throw new Error("parachain never finalised a block — the relay is not including candidates"); } + +/** Where the monitor starts on the local chain: the block after the finalized + * head, with the migration nonce at that head. The monitor reads finalized + * blocks only, so a start past them (the best block runs a few blocks ahead + * here) has no verified hash until finality catches up, and it pages. */ +export async function monitorStart(api) { + const hash = await api.rpc.chain.getFinalizedHead(); + const header = await api.rpc.chain.getHeader(hash); + const nonce = await (await api.at(hash)).query.tokenMigration.nextNonce(); + return { PENDULUM_START_BLOCK: String(header.number.toNumber() + 1), PENDULUM_START_NONCE: nonce.toString() }; +}