Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 109 additions & 3 deletions contracts/src/BeefyClient.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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:
Expand Down Expand Up @@ -240,6 +255,7 @@ contract BeefyClient {
error PrevRandaoNotCaptured();
error StaleCommitment();
error WaitPeriodNotOver();
error TrustingPeriodExpired();

constructor(
uint256 _randaoCommitDelay,
Expand All @@ -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;
Expand Down Expand Up @@ -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();
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 (
Expand Down Expand Up @@ -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 (
Expand Down Expand Up @@ -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;
Expand All @@ -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
Expand Down
130 changes: 130 additions & 0 deletions contracts/test/BeefyClientSkipAhead.t.sol
Original file line number Diff line number Diff line change
@@ -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");
}
}
5 changes: 5 additions & 0 deletions contracts/test/mocks/BeefyClientMock.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);
Expand Down
1 change: 1 addition & 0 deletions web/packages/operations/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading
Loading