diff --git a/contracts/src/BeefyClient.sol b/contracts/src/BeefyClient.sol index fd2307fae..59382e210 100644 --- a/contracts/src/BeefyClient.sol +++ b/contracts/src/BeefyClient.sol @@ -179,6 +179,12 @@ contract BeefyClient { /// @dev State of the next validator set ValidatorSetState public nextValidatorSet; + /// @dev Ethereum block.timestamp at which `currentValidatorSet` was last anchored by a + /// witnessed handover (or at construction). Bounds how long the current set may be trusted + /// to authenticate a non-consecutive "skip-ahead" update. Deliberately NOT refreshed by a + /// skip, so that an attacker cannot ratchet the trust window forward. + uint64 public currentSetActivatedAt; + /// @dev Pending tickets for commitment submission mapping(bytes32 ticketID => Ticket) public tickets; @@ -196,6 +202,15 @@ contract BeefyClient { // forge-lint: disable-next-line(unsafe-typecast) bytes2 public constant MMR_ROOT_ID = bytes2("mh"); + /** + * @dev Maximum wall-clock time (measured by Ethereum's block.timestamp) that the current + * validator set may be trusted to authenticate a non-consecutive "skip-ahead" commitment. + * MUST be shorter than the Polkadot unbonding period (28 days), with margin, so that any set + * accepted via a skip is still bonded — which is what preserves the honest-supermajority + * assumption the skip relies on. See the long-form rationale on non-consecutive updates. + */ + uint256 public constant trustingPeriod = 14 days; + /** * @dev Minimum delay in number of blocks that a relayer must wait between calling * submitInitial and commitPrevRandao. In production this should be set to MAX_SEED_LOOKAHEAD: @@ -240,6 +255,7 @@ contract BeefyClient { error PrevRandaoNotCaptured(); error StaleCommitment(); error WaitPeriodNotOver(); + error TrustingPeriodExpired(); constructor( uint256 _randaoCommitDelay, @@ -258,6 +274,8 @@ contract BeefyClient { minNumRequiredSignatures = _minNumRequiredSignatures; fiatShamirRequiredSignatures = _fiatShamirRequiredSignatures; latestBeefyBlock = _initialBeefyBlock; + // The genesis set is trusted by construction; anchor its trusting window now. + currentSetActivatedAt = uint64(block.timestamp); currentValidatorSet.id = _initialValidatorSet.id; currentValidatorSet.length = _initialValidatorSet.length; currentValidatorSet.root = _initialValidatorSet.root; @@ -295,6 +313,12 @@ contract BeefyClient { signatureUsageCount = nextValidatorSet.usageCounters.get(proof.index); nextValidatorSet.usageCounters.set(proof.index, signatureUsageCount.saturatingAdd(1)); vset = nextValidatorSet; + } else if (canSkipAhead(commitment.validatorSetID)) { + // Non-consecutive "skip-ahead": a commitment from a later session within a + // confirmed-stable era, authenticated against the current set's root. + signatureUsageCount = currentValidatorSet.usageCounters.get(proof.index); + currentValidatorSet.usageCounters + .set(proof.index, signatureUsageCount.saturatingAdd(1)); } else { revert InvalidCommitment(); } @@ -401,7 +425,12 @@ contract BeefyClient { is_next_session = true; vset = nextValidatorSet; } else if (commitment.validatorSetID != currentValidatorSet.id) { - revert InvalidCommitment(); + // Non-consecutive skip-ahead, authenticated against the current set (vset stays + // currentValidatorSet). canSkipAhead reverts if the trusting window has closed, + // otherwise reverts InvalidCommitment for an out-of-range id. + if (!canSkipAhead(commitment.validatorSetID)) { + revert InvalidCommitment(); + } } // Validate that all padding bits (beyond vset.length) are zero @@ -429,6 +458,11 @@ contract BeefyClient { nextValidatorSet.length = leaf.nextAuthoritySetLen; nextValidatorSet.root = leaf.nextAuthoritySetRoot; nextValidatorSet.usageCounters = createUint16Array(leaf.nextAuthoritySetLen); + // A witnessed handover is fresh evidence the new current set is active: re-anchor. + currentSetActivatedAt = uint64(block.timestamp); + } else if (commitment.validatorSetID != currentValidatorSet.id) { + // Skip-ahead (id already validated by canSkipAhead above; current id not yet advanced). + applySkip(commitment.validatorSetID, newMMRRoot, leaf, leafProof, leafProofOrder); } latestMMRRoot = newMMRRoot; @@ -508,7 +542,11 @@ contract BeefyClient { if (commitment.validatorSetID == nextValidatorSet.id) { vset = nextValidatorSet; } else if (commitment.validatorSetID != currentValidatorSet.id) { - revert InvalidCommitment(); + // Non-consecutive skip-ahead is authenticated against the current set (vset stays + // currentValidatorSet). canSkipAhead reverts if the trusting window has closed. + if (!canSkipAhead(commitment.validatorSetID)) { + revert InvalidCommitment(); + } } if ( @@ -550,7 +588,11 @@ contract BeefyClient { is_next_session = true; vset = nextValidatorSet; } else if (commitment.validatorSetID != currentValidatorSet.id) { - revert InvalidCommitment(); + // Non-consecutive skip-ahead, authenticated against the current set (vset stays + // currentValidatorSet). canSkipAhead reverts if the trusting window has closed. + if (!canSkipAhead(commitment.validatorSetID)) { + revert InvalidCommitment(); + } } if ( @@ -585,6 +627,11 @@ contract BeefyClient { nextValidatorSet.length = leaf.nextAuthoritySetLen; nextValidatorSet.root = leaf.nextAuthoritySetRoot; nextValidatorSet.usageCounters = createUint16Array(leaf.nextAuthoritySetLen); + // A witnessed handover is fresh evidence the new current set is active: re-anchor. + currentSetActivatedAt = uint64(block.timestamp); + } else if (commitment.validatorSetID != currentValidatorSet.id) { + // Skip-ahead (id already validated by canSkipAhead above; current id not yet advanced). + applySkip(commitment.validatorSetID, newMMRRoot, leaf, leafProof, leafProofOrder); } latestMMRRoot = newMMRRoot; @@ -595,6 +642,65 @@ contract BeefyClient { /* Internal Functions */ + /** + * @dev Returns true if a commitment from `validatorSetID` may be authenticated against the + * current validator set as a non-consecutive skip-ahead update. This is safe only when: + * (a) the id is strictly ahead of the next set (a genuine skip past known sessions), + * (b) the era is confirmed stable (current and next share the same membership root), so the + * signatures legitimately verify against the current root, and + * (c) the current set is still within its trusting period, so it is provably still bonded + * and its honest-supermajority assumption still holds. + * When (a) and (b) hold but the window has closed it reverts TrustingPeriodExpired, giving + * relayers a precise signal (rather than a generic InvalidCommitment) that the light client + * has fallen too far behind and must be advanced via consecutive handovers / governance. + */ + function canSkipAhead(uint64 validatorSetID) internal view returns (bool) { + if ( + validatorSetID <= nextValidatorSet.id + || currentValidatorSet.root != nextValidatorSet.root + ) { + return false; + } + if (block.timestamp >= uint256(currentSetActivatedAt) + trustingPeriod) { + revert TrustingPeriodExpired(); + } + return true; + } + + /** + * @dev Fast-forward the validator set after a verified skip-ahead commitment. Advances the + * current set id to the (now signature-verified) commitment id while keeping the same + * membership root, and loads the next set from the MMR leaf — which may introduce a new era + * (root change) that the following consecutive handover will adopt. Crucially does NOT touch + * currentSetActivatedAt: a skip is authenticated by the existing anchor and must never be + * able to extend the trusting window (no ratchet). + */ + function applySkip( + uint64 validatorSetID, + bytes32 newMMRRoot, + MMRLeaf calldata leaf, + bytes32[] calldata leafProof, + uint256 leafProofOrder + ) internal { + // Validator set ids must remain strictly increasing. + if (leaf.nextAuthoritySetID <= validatorSetID) { + revert InvalidMMRLeaf(); + } + bool leafIsValid = MMRProof.verifyLeafProof( + newMMRRoot, keccak256(encodeMMRLeaf(leaf)), leafProof, leafProofOrder + ); + if (!leafIsValid) { + revert InvalidMMRLeafProof(); + } + // Advance current to the verified id, preserving the (unchanged) membership root and length. + currentValidatorSet.id = validatorSetID; + currentValidatorSet.usageCounters = createUint16Array(currentValidatorSet.length); + nextValidatorSet.id = leaf.nextAuthoritySetID; + nextValidatorSet.length = leaf.nextAuthoritySetLen; + nextValidatorSet.root = leaf.nextAuthoritySetRoot; + nextValidatorSet.usageCounters = createUint16Array(leaf.nextAuthoritySetLen); + } + // Creates a unique ticket ID for a new interactive prover-verifier session function createTicketID(address account, bytes32 commitmentHash) internal diff --git a/contracts/test/BeefyClientSkipAhead.t.sol b/contracts/test/BeefyClientSkipAhead.t.sol new file mode 100644 index 000000000..95f7d88d3 --- /dev/null +++ b/contracts/test/BeefyClientSkipAhead.t.sol @@ -0,0 +1,130 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity 0.8.34; + +import {BeefyClient} from "../src/BeefyClient.sol"; +import {BeefyClientTest} from "./BeefyClient.t.sol"; + +/// @dev Tests for non-consecutive ("skip-ahead") validator set updates, gated by an +/// Ethereum-clock trusting period. Reuses the fixtures and helpers from BeefyClientTest. +/// +/// Fixture facts (test/data): the signed commitment is for validatorSetID == setId, the validator +/// merkle root is `root`, and the MMR leaf attests nextAuthoritySetID == setId + 1 with a +/// *different* root (a genuine era change). So initializing current/next a few sessions behind +/// `setId` (all sharing `root`) turns the same fixture into a skip-ahead within a stable era. +contract BeefyClientSkipAheadTest is BeefyClientTest { + /// @dev Interactive skip-ahead: a commitment from a later session inside a stable era is + /// accepted against the current set, fast-forwards the id (root preserved), loads next from + /// the leaf — and must NOT refresh the trusting-window anchor (no ratchet). + function testSkipAheadInteractiveAdvancesWithoutRatchet() public { + // current = setId-3, next = setId-2, both sharing `root`. Commitment is from setId. + BeefyClient.Commitment memory commitment = initialize(setId - 3); + uint64 anchorBefore = beefyClient.currentSetActivatedAt(); + + // Advance wall-clock (still well within the window) so that an (incorrect) re-anchor + // would be observably different from `anchorBefore`. + vm.warp(block.timestamp + 1 hours); + + beefyClient.submitInitial(commitment, bitfield, finalValidatorProofs[0]); + vm.roll(block.number + randaoCommitDelay); + commitPrevRandao(); + createFinalProofs(); + beefyClient.submitFinal( + commitment, bitfield, finalValidatorProofs, mmrLeaf, mmrLeafProofs, leafProofOrder + ); + + // MMR root delivered. + assertEq(beefyClient.latestBeefyBlock(), blockNumber); + + // Current fast-forwarded to the skipped id, membership root preserved. + (uint128 curId,, bytes32 curRoot,) = beefyClient.currentValidatorSet(); + assertEq(uint256(curId), uint256(setId), "current id advanced to skipped id"); + assertEq(curRoot, root, "current root preserved across skip"); + + // Next loaded from the leaf (here a new era => different root). + (uint128 nextId,, bytes32 nextRoot,) = beefyClient.nextValidatorSet(); + assertEq(uint256(nextId), uint256(mmrLeaf.nextAuthoritySetID), "next id from leaf"); + assertEq(nextRoot, mmrLeaf.nextAuthoritySetRoot, "next root from leaf"); + + // No ratchet: a skip must not move the anchor forward. + assertEq( + beefyClient.currentSetActivatedAt(), anchorBefore, "skip must not re-anchor window" + ); + } + + /// @dev Once the current set is older than the trusting period it may be unbonded, so a skip + /// must be refused with a precise TrustingPeriodExpired signal (Fiat-Shamir path). + function testSkipAheadRevertsWhenTrustingPeriodExpiredFiatShamir() public { + BeefyClient.Commitment memory commitment = initialize(setId - 3); + uint64 anchor = beefyClient.currentSetActivatedAt(); + + // Jump just past the window. + vm.warp(uint256(anchor) + beefyClient.trustingPeriod() + 1); + + vm.expectRevert(BeefyClient.TrustingPeriodExpired.selector); + beefyClient.submitFiatShamir( + commitment, bitfield, fiatShamirValidatorProofs, mmrLeaf, mmrLeafProofs, leafProofOrder + ); + } + + /// @dev Same gate on the interactive path (submitInitial). + function testSkipAheadRevertsWhenTrustingPeriodExpiredInteractive() public { + BeefyClient.Commitment memory commitment = initialize(setId - 3); + uint64 anchor = beefyClient.currentSetActivatedAt(); + + vm.warp(uint256(anchor) + beefyClient.trustingPeriod() + 1); + + vm.expectRevert(BeefyClient.TrustingPeriodExpired.selector); + beefyClient.submitInitial(commitment, bitfield, finalValidatorProofs[0]); + } + + /// @dev A skip is only safe inside a confirmed-stable era. If a root change is already pending + /// (current.root != next.root), the skip is ambiguous and must be rejected. + function testSkipAheadRevertsWhenEraChangePending() public { + BeefyClient.Commitment memory commitment = initialize(setId - 3); + + // Re-seed so a root change is pending between current and next. + beefyClient.initialize_public( + 0, + BeefyClient.ValidatorSet(setId - 3, setSize, root), + BeefyClient.ValidatorSet(setId - 2, setSize, bytes32(uint256(root) + 1)) + ); + + vm.expectRevert(BeefyClient.InvalidCommitment.selector); + beefyClient.submitFiatShamir( + commitment, bitfield, fiatShamirValidatorProofs, mmrLeaf, mmrLeafProofs, leafProofOrder + ); + } + + /// @dev An id at or below the next set is not a skip; it stays an ordinary InvalidCommitment. + function testSkipAheadRejectsIdNotAheadOfNext() public { + // current = setId, next = setId+1; the fixture commitment (== setId) is the normal current + // case, but a stale id below current must revert InvalidCommitment. + BeefyClient.Commitment memory commitment = initialize(setId); + commitment.validatorSetID = setId - 1; + + vm.expectRevert(BeefyClient.InvalidCommitment.selector); + beefyClient.submitFiatShamir( + commitment, bitfield, fiatShamirValidatorProofs, mmrLeaf, mmrLeafProofs, leafProofOrder + ); + } + + /// @dev A genuine (consecutive) handover is fresh evidence the new set is active, so it MUST + /// re-anchor the trusting window — the mechanism that keeps skips available across long eras. + function testHandoverReanchorsTrustingWindow() public { + BeefyClient.Commitment memory commitment = initialize(setId - 1); // handover path + uint64 anchorBefore = beefyClient.currentSetActivatedAt(); + + vm.warp(block.timestamp + 2 hours); + beefyClient.submitFiatShamir( + commitment, bitfield, fiatShamirValidatorProofs, mmrLeaf, mmrLeafProofs, leafProofOrder + ); + + assertEq(beefyClient.latestBeefyBlock(), blockNumber); + assertEq( + beefyClient.currentSetActivatedAt(), + uint64(block.timestamp), + "handover re-anchors to now" + ); + assertGt(beefyClient.currentSetActivatedAt(), anchorBefore, "anchor moved forward"); + } +} diff --git a/contracts/test/mocks/BeefyClientMock.sol b/contracts/test/mocks/BeefyClientMock.sol index e72fa7b2f..d63c09771 100644 --- a/contracts/test/mocks/BeefyClientMock.sol +++ b/contracts/test/mocks/BeefyClientMock.sol @@ -51,6 +51,7 @@ contract BeefyClientMock is BeefyClient { ValidatorSet calldata _nextValidatorSet ) external { latestBeefyBlock = _initialBeefyBlock; + currentSetActivatedAt = uint64(block.timestamp); currentValidatorSet.id = _initialValidatorSet.id; currentValidatorSet.length = _initialValidatorSet.length; currentValidatorSet.root = _initialValidatorSet.root; @@ -61,6 +62,10 @@ contract BeefyClientMock is BeefyClient { nextValidatorSet.usageCounters = createUint16Array(nextValidatorSet.length); } + function setCurrentSetActivatedAt(uint64 _activatedAt) external { + currentSetActivatedAt = _activatedAt; + } + // Used to verify integrity of storage to storage copies function copyCounters() external { currentValidatorSet.usageCounters = createUint16Array(1000); diff --git a/web/packages/operations/package.json b/web/packages/operations/package.json index 1bbfa662b..78af39614 100644 --- a/web/packages/operations/package.json +++ b/web/packages/operations/package.json @@ -16,6 +16,7 @@ "initializeAlarmsFromContainer": "docker run -v $(pwd)/config:/config --rm snowbridge-monitor:latest init", "monitor": "npx ts-node src/main.ts monitor", "fisherman": "npx ts-node src/main.ts fisherman", + "verifyBeefyRotation": "npx ts-node src/verify_beefy_rotation.ts", "historyV2": "npx ts-node src/global_transfer_history_v2.ts", "buildTokenRegistry": "npx ts-node src/build_asset_registry.ts", "transferWethFromPolkadotToKusama": "npx ts-node src/transfer_for_kusama_entry.ts WEthPToK 0 WETH 200000000000000", diff --git a/web/packages/operations/src/verify_beefy_rotation.ts b/web/packages/operations/src/verify_beefy_rotation.ts new file mode 100644 index 000000000..4aa92f5f9 --- /dev/null +++ b/web/packages/operations/src/verify_beefy_rotation.ts @@ -0,0 +1,161 @@ +/** + * Empirically verify the assumption behind BeefyClient's non-consecutive + * "skip-ahead" validator set updates, against a live Polkadot relay chain: + * + * - the BEEFY `validatorSetId` increments every session (~4h), but + * - the validator *membership* root only rotates at era boundaries + * (~24h, i.e. every 6 sessions), so + * - within an era `current.root == next.root` holds for every session + * except the era's last one. + * + * The `cur==next` column below is exactly BeefyClient.canSkipAhead's + * "confirmed-stable era" gate, and `keysetCommitment` is the membership root + * the contract authenticates against (via beefyMmrApi.{authoritySetProof, + * nextAuthoritySetProof} / beefyMmrLeaf.beefy{,Next}Authorities). + * + * Connects to a live relay-chain RPC by default; override with RELAY_WS. + * Note: requires an archive node so historical state is available across the + * sampled window. + * + * Usage: `npx ts-node web/packages/operations/src/verify_beefy_rotation.ts [hours]` + */ + +import { ApiPromise, WsProvider } from "@polkadot/api" + +const RELAY_WS = + process.env.RELAY_WS ?? "wss://polkadot.api.onfinality.io/public-ws" +const HOURS = Number(process.argv[2] ?? 36) + +const short = (h: { toString(): string }): string => { + const s = h.toString() + return s.slice(0, 8) + "…" + s.slice(-4) +} + +interface Sample { + block: number + session: number + vsetId: number + curRoot: string + nextRoot: string +} + +// Structural views over the generic Codec results, for just the fields we read. +type NumCodec = { toNumber(): number } +type HexCodec = { toHex(): string; toString(): string } +type AuthoritySet = { + id: { toString(): string } + len: { toString(): string } + keysetCommitment: HexCodec +} + +// Public RPC nodes rate-limit and can silently drop an in-flight request on a +// reconnect; without a timeout that leaves a query pending forever and the +// sweep appears to hang. Bound every request so a lost one rejects instead. +const RPC_TIMEOUT_MS = 30_000 + +async function main() { + console.log(`Connecting to ${RELAY_WS}`) + const api = await ApiPromise.create({ + provider: new WsProvider(RELAY_WS, 2_500, {}, RPC_TIMEOUT_MS), + }) + + try { + const head = await api.rpc.chain.getFinalizedHead() + const N = (await api.rpc.chain.getHeader(head)).number.toNumber() + console.log(`Finalized block: ${N}`) + + // The runtime calls the contract's authority set is derived from. + const curProof = (await api.call.beefyMmrApi.authoritySetProof()) as unknown as AuthoritySet + const nextProof = (await api.call.beefyMmrApi.nextAuthoritySetProof()) as unknown as AuthoritySet + console.log( + `\nbeefyMmrApi.authoritySetProof() -> id=${curProof.id} len=${curProof.len} root=${short(curProof.keysetCommitment)}`, + ) + console.log( + `beefyMmrApi.nextAuthoritySetProof() -> id=${nextProof.id} len=${nextProof.len} root=${short(nextProof.keysetCommitment)}`, + ) + + // Historical sweep, sampling ~every 30 min to catch every 4h session. + const STEP = 300 // ~30 min at 6s block time + const SPAN = Math.round((HOURS * 3600) / 6) // window in blocks + const total = Math.floor(SPAN / STEP) + 1 + const rows: Sample[] = [] + let pruned = 0 + let i = 0 + console.log(`\nSampling ${total} blocks (every ${STEP} blocks) ...`) + for (let b = N; b >= N - SPAN; b -= STEP) { + process.stdout.write(`\r sample ${++i}/${total} @ block ${b} `) + let at + try { + at = await api.at(await api.rpc.chain.getBlockHash(b)) + } catch { + pruned++ + continue + } + try { + const [sess, vid, cur, nxt] = await Promise.all([ + at.query.session.currentIndex(), + at.query.beefy.validatorSetId(), + at.query.beefyMmrLeaf.beefyAuthorities(), + at.query.beefyMmrLeaf.beefyNextAuthorities(), + ]) + rows.push({ + block: b, + session: (sess as unknown as NumCodec).toNumber(), + vsetId: (vid as unknown as NumCodec).toNumber(), + curRoot: (cur as unknown as AuthoritySet).keysetCommitment.toHex(), + nextRoot: (nxt as unknown as AuthoritySet).keysetCommitment.toHex(), + }) + } catch { + pruned++ + } + } + process.stdout.write("\n") + rows.reverse() + if (pruned) + console.log(`\n(${pruned} samples with unavailable/pruned state skipped)`) + + // Collapse consecutive samples that share the same state. + const key = (r: Sample) => + `${r.session}|${r.vsetId}|${r.curRoot}|${r.nextRoot}` + const collapsed: Sample[] = [] + for (const r of rows) { + const last = collapsed[collapsed.length - 1] + if (last && key(last) === key(r)) continue + collapsed.push({ ...r }) + } + + console.log(`\nPer-session BEEFY state over ~${HOURS}h (oldest -> newest):`) + console.log(" session vsetId curRoot nextRoot cur==next") + let prevRoot: string | null = null + for (const r of collapsed) { + const rootChanged = prevRoot !== null && r.curRoot !== prevRoot + console.log( + ` ${String(r.session).padEnd(7)} ${String(r.vsetId).padEnd(6)} ${short({ toString: () => r.curRoot }).padEnd(13)} ${short({ toString: () => r.nextRoot }).padEnd(13)} ${r.curRoot === r.nextRoot ? "YES" : "no "}${rootChanged ? " <== membership root changed (era rotation)" : ""}`, + ) + prevRoot = r.curRoot + } + + const sessions = [...new Set(collapsed.map((r) => r.session))] + const vsetIds = [...new Set(collapsed.map((r) => r.vsetId))] + const roots = [...new Set(collapsed.map((r) => r.curRoot))] + const lastOfEra = collapsed.filter((r) => r.curRoot !== r.nextRoot) + console.log("\nSummary:") + console.log( + ` sessions observed : ${sessions.length} (${sessions[0]}…${sessions[sessions.length - 1]})`, + ) + console.log( + ` vsetIds consecutive (+1) : ${vsetIds.every((v, i, a) => i === 0 || v === a[i - 1] + 1)}`, + ) + console.log(` distinct membership roots: ${roots.length}`) + console.log( + ` skip-ahead available in : ${collapsed.length - lastOfEra.length} of ${collapsed.length} sessions (refused only in each era's last session)`, + ) + } finally { + await api.disconnect() + } +} + +main().catch((e) => { + console.error(e) + process.exit(1) +})