From 240975149a3e9098fbda1c044bf490fc2aded9ef Mon Sep 17 00:00:00 2001 From: LHerskind Date: Fri, 13 Dec 2024 10:59:13 +0000 Subject: [PATCH] feat: slasher --- l1-contracts/src/core/Leonidas.sol | 8 +- l1-contracts/src/core/Rollup.sol | 12 +- l1-contracts/src/core/interfaces/ISlasher.sol | 9 + l1-contracts/src/core/staking/Slasher.sol | 37 ++ .../src/core/staking/SlashingProposer.sol | 35 ++ l1-contracts/src/core/staking/Staking.sol | 16 +- l1-contracts/src/governance/CoinIssuer.sol | 2 +- .../interfaces/IGovernanceProposer.sol | 6 +- .../src/governance/libraries/Errors.sol | 18 +- .../EmpireBase.sol} | 32 +- .../proposer/GovernanceProposer.sol | 36 ++ l1-contracts/src/periphery/SlashFactory.sol | 72 ++++ l1-contracts/src/periphery/SlashPayload.sol | 37 ++ .../periphery/interfaces/ISlashFactory.sol | 18 + l1-contracts/terraform/main.tf | 11 +- l1-contracts/test/Rollup.t.sol | 3 +- l1-contracts/test/fees/FeeRollup.t.sol | 4 +- .../test/governance/coin-issuer/mint.t.sol | 2 +- .../governance/governance-proposer/Base.t.sol | 2 +- .../governance-proposer/constructor.t.sol | 16 +- .../governance-proposer/pushProposal.t.sol | 10 +- .../governance/governance-proposer/vote.t.sol | 7 +- .../test/governance/governance/base.t.sol | 2 +- .../scenario/NewGovernanceProposerPayload.sol | 2 +- .../UpgradeGovernanceProposerTest.t.sol | 2 +- .../scenario/slashing/Slashing.t.sol | 121 ++++++ l1-contracts/test/harnesses/Leonidas.sol | 5 +- l1-contracts/test/harnesses/Rollup.sol | 4 +- l1-contracts/test/harnesses/TestConstants.sol | 2 + l1-contracts/test/sparta/Sparta.t.sol | 68 ++- l1-contracts/test/staking/StakingCheater.sol | 9 +- l1-contracts/test/staking/base.t.sol | 7 +- .../files/config/config-prover-env.sh | 2 + .../files/config/config-validator-env.sh | 2 + .../files/config/deploy-l1-contracts.sh | 2 + .../aztec-node/src/aztec-node/server.ts | 7 +- .../aztec.js/src/contract/contract.test.ts | 1 + .../cli/src/cmds/infrastructure/sequencers.ts | 10 +- .../cli/src/cmds/l1/deploy_l1_contracts.ts | 1 + .../cli/src/cmds/l1/update_l1_validators.ts | 9 +- .../end-to-end/scripts/e2e_test_config.yml | 2 + .../native-network/deploy-l1-contracts.sh | 2 + .../end-to-end/src/e2e_p2p/p2p_network.ts | 50 ++- .../end-to-end/src/e2e_p2p/slashing.test.ts | 278 ++++++++++++ .../src/fixtures/snapshot_manager.ts | 2 +- yarn-project/ethereum/src/config.ts | 47 +- yarn-project/ethereum/src/constants.ts | 1 - .../ethereum/src/deploy_l1_contracts.ts | 31 +- .../ethereum/src/l1_contract_addresses.ts | 7 + yarn-project/foundation/src/config/env_var.ts | 6 + .../scripts/generate-artifacts.sh | 4 + .../src/pxe_service/test/pxe_service.test.ts | 1 + yarn-project/sequencer-client/package.json | 1 + .../src/client/sequencer-client.ts | 4 + yarn-project/sequencer-client/src/index.ts | 1 + .../src/publisher/l1-publisher.ts | 163 ++++--- .../src/sequencer/sequencer.test.ts | 4 + .../src/sequencer/sequencer.ts | 13 +- .../sequencer-client/src/slasher/index.ts | 24 ++ .../src/slasher/slasher_client.test.ts | 120 ++++++ .../src/slasher/slasher_client.ts | 408 ++++++++++++++++++ yarn-project/sequencer-client/tsconfig.json | 3 + yarn-project/yarn.lock | 1 + 63 files changed, 1663 insertions(+), 159 deletions(-) create mode 100644 l1-contracts/src/core/interfaces/ISlasher.sol create mode 100644 l1-contracts/src/core/staking/Slasher.sol create mode 100644 l1-contracts/src/core/staking/SlashingProposer.sol rename l1-contracts/src/governance/{GovernanceProposer.sol => proposer/EmpireBase.sol} (86%) create mode 100644 l1-contracts/src/governance/proposer/GovernanceProposer.sol create mode 100644 l1-contracts/src/periphery/SlashFactory.sol create mode 100644 l1-contracts/src/periphery/SlashPayload.sol create mode 100644 l1-contracts/src/periphery/interfaces/ISlashFactory.sol create mode 100644 l1-contracts/test/governance/scenario/slashing/Slashing.t.sol create mode 100644 yarn-project/end-to-end/src/e2e_p2p/slashing.test.ts create mode 100644 yarn-project/sequencer-client/src/slasher/index.ts create mode 100644 yarn-project/sequencer-client/src/slasher/slasher_client.test.ts create mode 100644 yarn-project/sequencer-client/src/slasher/slasher_client.ts diff --git a/l1-contracts/src/core/Leonidas.sol b/l1-contracts/src/core/Leonidas.sol index 77244bec6284..213b6da39258 100644 --- a/l1-contracts/src/core/Leonidas.sol +++ b/l1-contracts/src/core/Leonidas.sol @@ -42,13 +42,17 @@ contract Leonidas is Staking, TimeFns, ILeonidas { LeonidasStorage private leonidasStore; constructor( - address _ares, IERC20 _stakingAsset, uint256 _minimumStake, + uint256 _slashingQuorum, + uint256 _roundSize, uint256 _slotDuration, uint256 _epochDuration, uint256 _targetCommitteeSize - ) Staking(_ares, _stakingAsset, _minimumStake) TimeFns(_slotDuration, _epochDuration) { + ) + Staking(_stakingAsset, _minimumStake, _slashingQuorum, _roundSize) + TimeFns(_slotDuration, _epochDuration) + { GENESIS_TIME = Timestamp.wrap(block.timestamp); SLOT_DURATION = _slotDuration; EPOCH_DURATION = _epochDuration; diff --git a/l1-contracts/src/core/Rollup.sol b/l1-contracts/src/core/Rollup.sol index 11ee424ba4dd..e9bee0d0b51b 100644 --- a/l1-contracts/src/core/Rollup.sol +++ b/l1-contracts/src/core/Rollup.sol @@ -52,6 +52,8 @@ struct Config { uint256 targetCommitteeSize; uint256 aztecEpochProofClaimWindowInL2Slots; uint256 minimumStake; + uint256 slashingQuorum; + uint256 slashingRoundSize; } /** @@ -108,15 +110,15 @@ contract Rollup is EIP712("Aztec Rollup", "1"), Ownable, Leonidas, IRollup, ITes ) Ownable(_ares) Leonidas( - _ares, _stakingAsset, _config.minimumStake, + _config.slashingQuorum, + _config.slashingRoundSize, _config.aztecSlotDuration, _config.aztecEpochDuration, _config.targetCommitteeSize ) { - rollupStore.epochProofVerifier = new MockVerifier(); FEE_JUICE_PORTAL = _fpcJuicePortal; REWARD_DISTRIBUTOR = _rewardDistributor; ASSET = _fpcJuicePortal.UNDERLYING(); @@ -125,14 +127,16 @@ contract Rollup is EIP712("Aztec Rollup", "1"), Ownable, Leonidas, IRollup, ITes ); INBOX = IInbox(address(new Inbox(address(this), Constants.L1_TO_L2_MSG_SUBTREE_HEIGHT))); OUTBOX = IOutbox(address(new Outbox(address(this)))); - rollupStore.vkTreeRoot = _vkTreeRoot; - rollupStore.protocolContractTreeRoot = _protocolContractTreeRoot; VERSION = 1; L1_BLOCK_AT_GENESIS = block.number; CLAIM_DURATION_IN_L2_SLOTS = _config.aztecEpochProofClaimWindowInL2Slots; IS_FOUNDRY_TEST = VM_ADDRESS.code.length > 0; + rollupStore.epochProofVerifier = new MockVerifier(); + rollupStore.vkTreeRoot = _vkTreeRoot; + rollupStore.protocolContractTreeRoot = _protocolContractTreeRoot; + // Genesis block rollupStore.blocks[0] = BlockLog({ feeHeader: FeeHeader({ diff --git a/l1-contracts/src/core/interfaces/ISlasher.sol b/l1-contracts/src/core/interfaces/ISlasher.sol new file mode 100644 index 000000000000..6ad8c6957191 --- /dev/null +++ b/l1-contracts/src/core/interfaces/ISlasher.sol @@ -0,0 +1,9 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2024 Aztec Labs. +pragma solidity >=0.8.27; + +import {IPayload} from "@aztec/governance/interfaces/IPayload.sol"; + +interface ISlasher { + function slash(IPayload _payload) external returns (bool); +} diff --git a/l1-contracts/src/core/staking/Slasher.sol b/l1-contracts/src/core/staking/Slasher.sol new file mode 100644 index 000000000000..39e44791ff2e --- /dev/null +++ b/l1-contracts/src/core/staking/Slasher.sol @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2024 Aztec Labs. +pragma solidity >=0.8.27; + +import {ISlasher} from "@aztec/core/interfaces/ISlasher.sol"; +import {SlashingProposer} from "@aztec/core/staking/SlashingProposer.sol"; +import {IPayload} from "@aztec/governance/interfaces/IPayload.sol"; + +contract Slasher is ISlasher { + SlashingProposer public immutable PROPOSER; + + event SlashFailed(address target, bytes data, bytes returnData); + + error Slasher__CallerNotProposer(address caller, address proposer); // 0x44c1f74f + + constructor(uint256 _n, uint256 _m) { + PROPOSER = new SlashingProposer(msg.sender, this, _n, _m); + } + + function slash(IPayload _payload) external override(ISlasher) returns (bool) { + require( + msg.sender == address(PROPOSER), Slasher__CallerNotProposer(msg.sender, address(PROPOSER)) + ); + + IPayload.Action[] memory actions = _payload.getActions(); + + for (uint256 i = 0; i < actions.length; i++) { + // Allow failure of individual calls but emit the failure! + (bool success, bytes memory returnData) = actions[i].target.call(actions[i].data); + if (!success) { + emit SlashFailed(actions[i].target, actions[i].data, returnData); + } + } + + return true; + } +} diff --git a/l1-contracts/src/core/staking/SlashingProposer.sol b/l1-contracts/src/core/staking/SlashingProposer.sol new file mode 100644 index 000000000000..dfd445af9379 --- /dev/null +++ b/l1-contracts/src/core/staking/SlashingProposer.sol @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2024 Aztec Labs. +pragma solidity >=0.8.27; + +import {ISlasher} from "@aztec/core/interfaces/ISlasher.sol"; +import {IGovernanceProposer} from "@aztec/governance/interfaces/IGovernanceProposer.sol"; +import {IPayload} from "@aztec/governance/interfaces/IPayload.sol"; +import {EmpireBase} from "@aztec/governance/proposer/EmpireBase.sol"; + +/** + * @notice A SlashingProposer implementation following the empire model + */ +contract SlashingProposer is IGovernanceProposer, EmpireBase { + address public immutable INSTANCE; + ISlasher public immutable SLASHER; + + constructor(address _instance, ISlasher _slasher, uint256 _slashingQuorum, uint256 _roundSize) + EmpireBase(_slashingQuorum, _roundSize) + { + INSTANCE = _instance; + SLASHER = _slasher; + } + + function getExecutor() public view override(EmpireBase, IGovernanceProposer) returns (address) { + return address(SLASHER); + } + + function getInstance() public view override(EmpireBase, IGovernanceProposer) returns (address) { + return INSTANCE; + } + + function _execute(IPayload _proposal) internal override(EmpireBase) returns (bool) { + return SLASHER.slash(_proposal); + } +} diff --git a/l1-contracts/src/core/staking/Staking.sol b/l1-contracts/src/core/staking/Staking.sol index 0d75e74e1c1a..5e928f64c568 100644 --- a/l1-contracts/src/core/staking/Staking.sol +++ b/l1-contracts/src/core/staking/Staking.sol @@ -12,6 +12,7 @@ import { } from "@aztec/core/interfaces/IStaking.sol"; import {Errors} from "@aztec/core/libraries/Errors.sol"; import {Timestamp} from "@aztec/core/libraries/TimeMath.sol"; +import {Slasher} from "@aztec/core/staking/Slasher.sol"; import {IERC20} from "@oz/token/ERC20/IERC20.sol"; import {SafeERC20} from "@oz/token/ERC20/utils/SafeERC20.sol"; import {EnumerableSet} from "@oz/utils/structs/EnumerableSet.sol"; @@ -23,14 +24,19 @@ contract Staking is IStaking { // Constant pulled out of the ass Timestamp public constant EXIT_DELAY = Timestamp.wrap(60 * 60 * 24); - address public immutable SLASHER; + Slasher public immutable SLASHER; IERC20 public immutable STAKING_ASSET; uint256 public immutable MINIMUM_STAKE; StakingStorage internal stakingStore; - constructor(address _slasher, IERC20 _stakingAsset, uint256 _minimumStake) { - SLASHER = _slasher; + constructor( + IERC20 _stakingAsset, + uint256 _minimumStake, + uint256 _slashingQuorum, + uint256 _roundSize + ) { + SLASHER = new Slasher(_slashingQuorum, _roundSize); STAKING_ASSET = _stakingAsset; MINIMUM_STAKE = _minimumStake; } @@ -57,7 +63,9 @@ contract Staking is IStaking { } function slash(address _attester, uint256 _amount) external override(IStaking) { - require(msg.sender == SLASHER, Errors.Staking__NotSlasher(SLASHER, msg.sender)); + require( + msg.sender == address(SLASHER), Errors.Staking__NotSlasher(address(SLASHER), msg.sender) + ); ValidatorInfo storage validator = stakingStore.info[_attester]; require(validator.status != Status.NONE, Errors.Staking__NoOneToSlash(_attester)); diff --git a/l1-contracts/src/governance/CoinIssuer.sol b/l1-contracts/src/governance/CoinIssuer.sol index 37ac8f18b4df..33a0c06df0ed 100644 --- a/l1-contracts/src/governance/CoinIssuer.sol +++ b/l1-contracts/src/governance/CoinIssuer.sol @@ -33,7 +33,7 @@ contract CoinIssuer is ICoinIssuer, Ownable { */ function mint(address _to, uint256 _amount) external override(ICoinIssuer) onlyOwner { uint256 maxMint = mintAvailable(); - require(_amount <= maxMint, Errors.CoinIssuer__InssuficientMintAvailable(maxMint, _amount)); + require(_amount <= maxMint, Errors.CoinIssuer__InsufficientMintAvailable(maxMint, _amount)); timeOfLastMint = block.timestamp; ASSET.mint(_to, _amount); } diff --git a/l1-contracts/src/governance/interfaces/IGovernanceProposer.sol b/l1-contracts/src/governance/interfaces/IGovernanceProposer.sol index 7539446a1de9..dc63068a4819 100644 --- a/l1-contracts/src/governance/interfaces/IGovernanceProposer.sol +++ b/l1-contracts/src/governance/interfaces/IGovernanceProposer.sol @@ -3,19 +3,19 @@ pragma solidity >=0.8.27; import {Slot} from "@aztec/core/libraries/TimeMath.sol"; -import {IGovernance} from "@aztec/governance/interfaces/IGovernance.sol"; import {IPayload} from "@aztec/governance/interfaces/IPayload.sol"; interface IGovernanceProposer { event VoteCast(IPayload indexed proposal, uint256 indexed round, address indexed voter); event ProposalPushed(IPayload indexed proposal, uint256 indexed round); - function vote(IPayload _proposa) external returns (bool); + function vote(IPayload _proposal) external returns (bool); function pushProposal(uint256 _roundNumber) external returns (bool); function yeaCount(address _instance, uint256 _round, IPayload _proposal) external view returns (uint256); function computeRound(Slot _slot) external view returns (uint256); - function getGovernance() external view returns (IGovernance); + function getInstance() external view returns (address); + function getExecutor() external view returns (address); } diff --git a/l1-contracts/src/governance/libraries/Errors.sol b/l1-contracts/src/governance/libraries/Errors.sol index fb835660287d..263749a198cd 100644 --- a/l1-contracts/src/governance/libraries/Errors.sol +++ b/l1-contracts/src/governance/libraries/Errors.sol @@ -45,20 +45,20 @@ library Errors { error Governance__ProposalLib__ZeroYeaVotesNeeded(); error Governance__ProposalLib__MoreYeaVoteThanExistNeeded(); - error GovernanceProposer__CanOnlyPushProposalInPast(); // 0x49fdf611" - error GovernanceProposer__FailedToPropose(IPayload proposal); // 0x6ca2a2ed - error GovernanceProposer__InstanceHaveNoCode(address instance); // 0x20a3b441 - error GovernanceProposer__InsufficientVotes(); // 0xba1e05ef + error GovernanceProposer__CanOnlyPushProposalInPast(); // 0x84a5b5ae + error GovernanceProposer__FailedToPropose(IPayload proposal); // 0x8d94fbfc + error GovernanceProposer__InstanceHaveNoCode(address instance); // 0x5fa92625 + error GovernanceProposer__InsufficientVotes(uint256 votesCast, uint256 votesNeeded); // 0xd4ad89c2 error GovernanceProposer__InvalidNAndMValues(uint256 n, uint256 m); // 0x520d9704 error GovernanceProposer__NCannotBeLargerTHanM(uint256 n, uint256 m); // 0x2fdfc063 error GovernanceProposer__OnlyProposerCanVote(address caller, address proposer); // 0xba27df38 error GovernanceProposer__ProposalAlreadyExecuted(uint256 roundNumber); // 0x7aeacb17 - error GovernanceProposer__ProposalCannotBeAddressZero(); // 0xdb3e4b6e - error GovernanceProposer__ProposalHaveNoCode(IPayload proposal); // 0xdce0615b - error GovernanceProposer__ProposalTooOld(uint256 roundNumber, uint256 currentRoundNumber); //0x02283b1a - error GovernanceProposer__VoteAlreadyCastForSlot(Slot slot); //0xc2201452 + error GovernanceProposer__ProposalCannotBeAddressZero(); // 0x16ac1942 + error GovernanceProposer__ProposalHaveNoCode(IPayload proposal); // 0xb69440a1 + error GovernanceProposer__ProposalTooOld(uint256 roundNumber, uint256 currentRoundNumber); // 0xc3d7aa4f + error GovernanceProposer__VoteAlreadyCastForSlot(Slot slot); // 0x3a6150ca - error CoinIssuer__InssuficientMintAvailable(uint256 available, uint256 needed); // 0xf268b931 + error CoinIssuer__InsufficientMintAvailable(uint256 available, uint256 needed); // 0xa1cc8799 error Registry__RollupAlreadyRegistered(address rollup); // 0x3c34eabf error Registry__RollupNotRegistered(address rollup); // 0xa1fee4cf diff --git a/l1-contracts/src/governance/GovernanceProposer.sol b/l1-contracts/src/governance/proposer/EmpireBase.sol similarity index 86% rename from l1-contracts/src/governance/GovernanceProposer.sol rename to l1-contracts/src/governance/proposer/EmpireBase.sol index 7e665ee3aa82..70a43c18b0c9 100644 --- a/l1-contracts/src/governance/GovernanceProposer.sol +++ b/l1-contracts/src/governance/proposer/EmpireBase.sol @@ -4,10 +4,8 @@ pragma solidity >=0.8.27; import {ILeonidas} from "@aztec/core/interfaces/ILeonidas.sol"; import {Slot, SlotLib} from "@aztec/core/libraries/TimeMath.sol"; -import {IGovernance} from "@aztec/governance/interfaces/IGovernance.sol"; import {IGovernanceProposer} from "@aztec/governance/interfaces/IGovernanceProposer.sol"; import {IPayload} from "@aztec/governance/interfaces/IPayload.sol"; -import {IRegistry} from "@aztec/governance/interfaces/IRegistry.sol"; import {Errors} from "@aztec/governance/libraries/Errors.sol"; /** @@ -17,7 +15,7 @@ import {Errors} from "@aztec/governance/libraries/Errors.sol"; * This also means that the implementation here will need to be "updated" if * the interfaces of the sequencer selection changes, for example going optimistic. */ -contract GovernanceProposer is IGovernanceProposer { +abstract contract EmpireBase is IGovernanceProposer { using SlotLib for Slot; struct RoundAccounting { @@ -29,14 +27,12 @@ contract GovernanceProposer is IGovernanceProposer { uint256 public constant LIFETIME_IN_ROUNDS = 5; - IRegistry public immutable REGISTRY; uint256 public immutable N; uint256 public immutable M; mapping(address instance => mapping(uint256 roundNumber => RoundAccounting)) public rounds; - constructor(IRegistry _registry, uint256 _n, uint256 _m) { - REGISTRY = _registry; + constructor(uint256 _n, uint256 _m) { N = _n; M = _m; @@ -57,11 +53,12 @@ contract GovernanceProposer is IGovernanceProposer { * @return True if executed successfully, false otherwise */ function vote(IPayload _proposal) external override(IGovernanceProposer) returns (bool) { - require( + // For now, skipping this as the check is not really needed but there were not full agreement + /*require( address(_proposal).code.length > 0, Errors.GovernanceProposer__ProposalHaveNoCode(_proposal) - ); + );*/ - address instance = REGISTRY.getRollup(); + address instance = getInstance(); require(instance.code.length > 0, Errors.GovernanceProposer__InstanceHaveNoCode(instance)); ILeonidas selection = ILeonidas(instance); @@ -102,7 +99,7 @@ contract GovernanceProposer is IGovernanceProposer { */ function pushProposal(uint256 _roundNumber) external override(IGovernanceProposer) returns (bool) { // Need to ensure that the round is not active. - address instance = REGISTRY.getRollup(); + address instance = getInstance(); require(instance.code.length > 0, Errors.GovernanceProposer__InstanceHaveNoCode(instance)); ILeonidas selection = ILeonidas(instance); @@ -120,16 +117,14 @@ contract GovernanceProposer is IGovernanceProposer { require( round.leader != IPayload(address(0)), Errors.GovernanceProposer__ProposalCannotBeAddressZero() ); - require(round.yeaCount[round.leader] >= N, Errors.GovernanceProposer__InsufficientVotes()); + uint256 votesCast = round.yeaCount[round.leader]; + require(votesCast >= N, Errors.GovernanceProposer__InsufficientVotes(votesCast, N)); round.executed = true; emit ProposalPushed(round.leader, _roundNumber); - require( - getGovernance().propose(round.leader), - Errors.GovernanceProposer__FailedToPropose(round.leader) - ); + require(_execute(round.leader), Errors.GovernanceProposer__FailedToPropose(round.leader)); return true; } @@ -162,7 +157,8 @@ contract GovernanceProposer is IGovernanceProposer { return _slot.unwrap() / M; } - function getGovernance() public view override(IGovernanceProposer) returns (IGovernance) { - return IGovernance(REGISTRY.getGovernance()); - } + // Virtual functions + function getInstance() public view virtual override(IGovernanceProposer) returns (address); + function getExecutor() public view virtual override(IGovernanceProposer) returns (address); + function _execute(IPayload _proposal) internal virtual returns (bool); } diff --git a/l1-contracts/src/governance/proposer/GovernanceProposer.sol b/l1-contracts/src/governance/proposer/GovernanceProposer.sol new file mode 100644 index 000000000000..734a42172e53 --- /dev/null +++ b/l1-contracts/src/governance/proposer/GovernanceProposer.sol @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2024 Aztec Labs. +pragma solidity >=0.8.27; + +import {IGovernance} from "@aztec/governance/interfaces/IGovernance.sol"; +import {IGovernanceProposer} from "@aztec/governance/interfaces/IGovernanceProposer.sol"; +import {IPayload} from "@aztec/governance/interfaces/IPayload.sol"; +import {IRegistry} from "@aztec/governance/interfaces/IRegistry.sol"; +import {EmpireBase} from "./EmpireBase.sol"; + +/** + * @notice A GovernanceProposer implementation following the empire model + * Beware that while governance generally do not care about the implementation + * this implementation will since it is dependent on the sequencer selection. + * This also means that the implementation here will need to be "updated" if + * the interfaces of the sequencer selection changes, for example going optimistic. + */ +contract GovernanceProposer is IGovernanceProposer, EmpireBase { + IRegistry public immutable REGISTRY; + + constructor(IRegistry _registry, uint256 _n, uint256 _m) EmpireBase(_n, _m) { + REGISTRY = _registry; + } + + function getExecutor() public view override(EmpireBase, IGovernanceProposer) returns (address) { + return REGISTRY.getGovernance(); + } + + function getInstance() public view override(EmpireBase, IGovernanceProposer) returns (address) { + return REGISTRY.getRollup(); + } + + function _execute(IPayload _proposal) internal override(EmpireBase) returns (bool) { + return IGovernance(getExecutor()).propose(_proposal); + } +} diff --git a/l1-contracts/src/periphery/SlashFactory.sol b/l1-contracts/src/periphery/SlashFactory.sol new file mode 100644 index 000000000000..14904c1f62e0 --- /dev/null +++ b/l1-contracts/src/periphery/SlashFactory.sol @@ -0,0 +1,72 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2024 Aztec Labs. +pragma solidity >=0.8.27; + +import {ILeonidas} from "@aztec/core/interfaces/ILeonidas.sol"; +import {Epoch} from "@aztec/core/libraries/TimeMath.sol"; +import {IPayload} from "@aztec/governance/interfaces/IPayload.sol"; +import {ISlashFactory} from "./interfaces/ISlashFactory.sol"; +import {SlashPayload} from "./SlashPayload.sol"; + +contract SlashFactory is ISlashFactory { + ILeonidas public immutable LEONIDAS; + + constructor(ILeonidas _leonidas) { + LEONIDAS = _leonidas; + } + + function createSlashPayload(Epoch _epoch, uint256 _amount) + external + override(ISlashFactory) + returns (IPayload) + { + (address predictedAddress, bool isDeployed) = getAddressAndIsDeployed(_epoch, _amount); + + if (isDeployed) { + return IPayload(predictedAddress); + } + + SlashPayload payload = + new SlashPayload{salt: bytes32(Epoch.unwrap(_epoch))}(_epoch, LEONIDAS, _amount); + + emit SlashPayloadCreated(address(payload), _epoch, _amount); + return IPayload(address(payload)); + } + + function getAddressAndIsDeployed(Epoch _epoch, uint256 _amount) + public + view + override(ISlashFactory) + returns (address, bool) + { + address predictedAddress = _computeSlashPayloadAddress(_epoch, _amount); + bool isDeployed = predictedAddress.code.length > 0; + return (predictedAddress, isDeployed); + } + + function _computeSlashPayloadAddress(Epoch _epoch, uint256 _amount) + internal + view + returns (address) + { + bytes32 salt = bytes32(Epoch.unwrap(_epoch)); + return address( + uint160( + uint256( + keccak256( + abi.encodePacked( + bytes1(0xff), + address(this), + salt, + keccak256( + abi.encodePacked( + type(SlashPayload).creationCode, abi.encode(_epoch, LEONIDAS, _amount) + ) + ) + ) + ) + ) + ) + ); + } +} diff --git a/l1-contracts/src/periphery/SlashPayload.sol b/l1-contracts/src/periphery/SlashPayload.sol new file mode 100644 index 000000000000..4410cfe0ae9d --- /dev/null +++ b/l1-contracts/src/periphery/SlashPayload.sol @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2024 Aztec Labs. +pragma solidity >=0.8.27; + +import {ILeonidas} from "@aztec/core/interfaces/ILeonidas.sol"; +import {IStaking} from "@aztec/core/interfaces/IStaking.sol"; +import {Epoch} from "@aztec/core/libraries/TimeMath.sol"; +import {IPayload} from "@aztec/governance/interfaces/IPayload.sol"; + +/** + * @notice The simplest payload that you can find, slash all attesters for an epoch. + */ +contract SlashPayload is IPayload { + Epoch public immutable EPOCH; + ILeonidas public immutable LEONIDAS; + uint256 public immutable AMOUNT; + + constructor(Epoch _epoch, ILeonidas _leonidas, uint256 _amount) { + EPOCH = _epoch; + LEONIDAS = _leonidas; + AMOUNT = _amount; + } + + function getActions() external view override(IPayload) returns (IPayload.Action[] memory) { + address[] memory attesters = ILeonidas(LEONIDAS).getEpochCommittee(EPOCH); + IPayload.Action[] memory actions = new IPayload.Action[](attesters.length); + + for (uint256 i = 0; i < attesters.length; i++) { + actions[i] = IPayload.Action({ + target: address(LEONIDAS), + data: abi.encodeWithSelector(IStaking.slash.selector, attesters[i], AMOUNT) + }); + } + + return actions; + } +} diff --git a/l1-contracts/src/periphery/interfaces/ISlashFactory.sol b/l1-contracts/src/periphery/interfaces/ISlashFactory.sol new file mode 100644 index 000000000000..7300cfbba846 --- /dev/null +++ b/l1-contracts/src/periphery/interfaces/ISlashFactory.sol @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2024 Aztec Labs. +pragma solidity >=0.8.27; + +import {Epoch} from "@aztec/core/libraries/TimeMath.sol"; +import {IPayload} from "@aztec/governance/interfaces/IPayload.sol"; + +interface ISlashFactory { + event SlashPayloadCreated( + address indexed payloadAddress, Epoch indexed epoch, uint256 indexed amount + ); + + function createSlashPayload(Epoch _epoch, uint256 _amount) external returns (IPayload); + function getAddressAndIsDeployed(Epoch _epoch, uint256 _amount) + external + view + returns (address, bool); +} diff --git a/l1-contracts/terraform/main.tf b/l1-contracts/terraform/main.tf index d619a827877f..a9b9b4a3faa0 100644 --- a/l1-contracts/terraform/main.tf +++ b/l1-contracts/terraform/main.tf @@ -109,4 +109,13 @@ variable "GOVERNANCE_CONTRACT_ADDRESS" { output "GOVERNANCE_CONTRACT_ADDRESS" { value = var.GOVERNANCE_CONTRACT_ADDRESS -} \ No newline at end of file +} + +variable "SLASH_FACTORY_CONTRACT_ADDRESS" { + type = string + default = "" +} + +output "SLASH_FACTORY_CONTRACT_ADDRESS" { + value = var.SLASH_FACTORY_CONTRACT_ADDRESS +} diff --git a/l1-contracts/test/Rollup.t.sol b/l1-contracts/test/Rollup.t.sol index 504db52ae570..b5552b0036df 100644 --- a/l1-contracts/test/Rollup.t.sol +++ b/l1-contracts/test/Rollup.t.sol @@ -77,9 +77,10 @@ contract RollupTest is DecoderBase, TimeFns { testERC20 = new TestERC20("test", "TEST", address(this)); leo = new Leonidas( - address(1), testERC20, TestConstants.AZTEC_MINIMUM_STAKE, + TestConstants.AZTEC_SLASHING_QUORUM, + TestConstants.AZTEC_SLASHING_ROUND_SIZE, TestConstants.AZTEC_SLOT_DURATION, TestConstants.AZTEC_EPOCH_DURATION, TestConstants.AZTEC_TARGET_COMMITTEE_SIZE diff --git a/l1-contracts/test/fees/FeeRollup.t.sol b/l1-contracts/test/fees/FeeRollup.t.sol index 8331d66d7eed..f6282f33b211 100644 --- a/l1-contracts/test/fees/FeeRollup.t.sol +++ b/l1-contracts/test/fees/FeeRollup.t.sol @@ -131,7 +131,9 @@ contract FeeRollupTest is FeeModelTestPoints, DecoderBase { aztecEpochDuration: EPOCH_DURATION, targetCommitteeSize: 48, aztecEpochProofClaimWindowInL2Slots: 16, - minimumStake: 100 ether + minimumStake: TestConstants.AZTEC_MINIMUM_STAKE, + slashingQuorum: TestConstants.AZTEC_SLASHING_QUORUM, + slashingRoundSize: TestConstants.AZTEC_SLASHING_ROUND_SIZE }) ); fakeCanonical.setCanonicalRollup(address(rollup)); diff --git a/l1-contracts/test/governance/coin-issuer/mint.t.sol b/l1-contracts/test/governance/coin-issuer/mint.t.sol index 29304fbb5893..229d2f52d91d 100644 --- a/l1-contracts/test/governance/coin-issuer/mint.t.sol +++ b/l1-contracts/test/governance/coin-issuer/mint.t.sol @@ -35,7 +35,7 @@ contract MintTest is CoinIssuerBase { // it reverts uint256 amount = bound(_amount, maxMint + 1, type(uint256).max); vm.expectRevert( - abi.encodeWithSelector(Errors.CoinIssuer__InssuficientMintAvailable.selector, maxMint, amount) + abi.encodeWithSelector(Errors.CoinIssuer__InsufficientMintAvailable.selector, maxMint, amount) ); nom.mint(address(0xdead), amount); } diff --git a/l1-contracts/test/governance/governance-proposer/Base.t.sol b/l1-contracts/test/governance/governance-proposer/Base.t.sol index 1f7209114380..bbfd7b548287 100644 --- a/l1-contracts/test/governance/governance-proposer/Base.t.sol +++ b/l1-contracts/test/governance/governance-proposer/Base.t.sol @@ -4,7 +4,7 @@ pragma solidity >=0.8.27; import {Test} from "forge-std/Test.sol"; import {Registry} from "@aztec/governance/Registry.sol"; -import {GovernanceProposer} from "@aztec/governance/GovernanceProposer.sol"; +import {GovernanceProposer} from "@aztec/governance/proposer/GovernanceProposer.sol"; import {IPayload} from "@aztec/governance/interfaces/IPayload.sol"; diff --git a/l1-contracts/test/governance/governance-proposer/constructor.t.sol b/l1-contracts/test/governance/governance-proposer/constructor.t.sol index f32b8aefa59e..327ef727701d 100644 --- a/l1-contracts/test/governance/governance-proposer/constructor.t.sol +++ b/l1-contracts/test/governance/governance-proposer/constructor.t.sol @@ -2,12 +2,22 @@ pragma solidity >=0.8.27; import {Test} from "forge-std/Test.sol"; -import {GovernanceProposer} from "@aztec/governance/GovernanceProposer.sol"; +import {GovernanceProposer} from "@aztec/governance/proposer/GovernanceProposer.sol"; import {Errors} from "@aztec/governance/libraries/Errors.sol"; import {IRegistry} from "@aztec/governance/interfaces/IRegistry.sol"; +contract FakeRegistry { + function getGovernance() external pure returns (address) { + return address(0x01); + } + + function getRollup() external pure returns (address) { + return address(0x02); + } +} + contract ConstructorTest is Test { - IRegistry internal constant REGISTRY = IRegistry(address(0x02)); + IRegistry internal REGISTRY = IRegistry(address(new FakeRegistry())); function test_WhenNIsLessThanOrEqualHalfOfM(uint256 _n, uint256 _m) external { // it revert @@ -42,5 +52,7 @@ contract ConstructorTest is Test { assertEq(address(g.REGISTRY()), address(REGISTRY)); assertEq(g.N(), n); assertEq(g.M(), m); + assertEq(g.getExecutor(), address(REGISTRY.getGovernance()), "executor"); + assertEq(g.getInstance(), address(REGISTRY.getRollup()), "instance"); } } diff --git a/l1-contracts/test/governance/governance-proposer/pushProposal.t.sol b/l1-contracts/test/governance/governance-proposer/pushProposal.t.sol index 26de9a2f3432..f64ac8bc75dc 100644 --- a/l1-contracts/test/governance/governance-proposer/pushProposal.t.sol +++ b/l1-contracts/test/governance/governance-proposer/pushProposal.t.sol @@ -30,7 +30,7 @@ contract PushProposalTest is GovernanceProposerBase { } modifier givenCanonicalInstanceHoldCode() { - leonidas = new Leonidas(address(this)); + leonidas = new Leonidas(); vm.prank(registry.getGovernance()); registry.upgrade(address(leonidas)); @@ -164,12 +164,16 @@ contract PushProposalTest is GovernanceProposerBase { vm.prank(proposer); governanceProposer.vote(proposal); + uint256 votesNeeded = governanceProposer.N(); + vm.warp( Timestamp.unwrap( leonidas.getTimestampForSlot(leonidas.getCurrentSlot() + Slot.wrap(governanceProposer.M())) ) ); - vm.expectRevert(abi.encodeWithSelector(Errors.GovernanceProposer__InsufficientVotes.selector)); + vm.expectRevert( + abi.encodeWithSelector(Errors.GovernanceProposer__InsufficientVotes.selector, 1, votesNeeded) + ); governanceProposer.pushProposal(1); } @@ -204,7 +208,7 @@ contract PushProposalTest is GovernanceProposerBase { // it revert // When using a new registry we change the governanceProposer's interpetation of time :O - Leonidas freshInstance = new Leonidas(address(this)); + Leonidas freshInstance = new Leonidas(); vm.prank(registry.getGovernance()); registry.upgrade(address(freshInstance)); diff --git a/l1-contracts/test/governance/governance-proposer/vote.t.sol b/l1-contracts/test/governance/governance-proposer/vote.t.sol index f78f9f009e04..91c283639122 100644 --- a/l1-contracts/test/governance/governance-proposer/vote.t.sol +++ b/l1-contracts/test/governance/governance-proposer/vote.t.sol @@ -15,7 +15,8 @@ contract VoteTest is GovernanceProposerBase { address internal proposer = address(0); Leonidas internal leonidas; - function test_WhenProposalHoldNoCode() external { + // Skipping this test since the it matches the for now skipped check in `EmpireBase::vote` + function skip__test_WhenProposalHoldNoCode() external { // it revert vm.expectRevert( abi.encodeWithSelector(Errors.GovernanceProposer__ProposalHaveNoCode.selector, proposal) @@ -39,7 +40,7 @@ contract VoteTest is GovernanceProposerBase { } modifier givenCanonicalRollupHoldCode() { - leonidas = new Leonidas(address(this)); + leonidas = new Leonidas(); vm.prank(registry.getGovernance()); registry.upgrade(address(leonidas)); @@ -138,7 +139,7 @@ contract VoteTest is GovernanceProposerBase { uint256 leonidasRound = governanceProposer.computeRound(leonidasSlot); uint256 yeaBefore = governanceProposer.yeaCount(address(leonidas), leonidasRound, proposal); - Leonidas freshInstance = new Leonidas(address(this)); + Leonidas freshInstance = new Leonidas(); vm.prank(registry.getGovernance()); registry.upgrade(address(freshInstance)); diff --git a/l1-contracts/test/governance/governance/base.t.sol b/l1-contracts/test/governance/governance/base.t.sol index cc5a9878a068..05a125f10ff2 100644 --- a/l1-contracts/test/governance/governance/base.t.sol +++ b/l1-contracts/test/governance/governance/base.t.sol @@ -3,7 +3,7 @@ pragma solidity >=0.8.27; import {TestBase} from "@test/base/Base.sol"; import {Governance} from "@aztec/governance/Governance.sol"; -import {GovernanceProposer} from "@aztec/governance/GovernanceProposer.sol"; +import {GovernanceProposer} from "@aztec/governance/proposer/GovernanceProposer.sol"; import {Registry} from "@aztec/governance/Registry.sol"; import {DataStructures} from "@aztec/governance/libraries/DataStructures.sol"; import {IMintableERC20} from "@aztec/governance/interfaces/IMintableERC20.sol"; diff --git a/l1-contracts/test/governance/scenario/NewGovernanceProposerPayload.sol b/l1-contracts/test/governance/scenario/NewGovernanceProposerPayload.sol index 613dc7006b47..a4cb726dc2e9 100644 --- a/l1-contracts/test/governance/scenario/NewGovernanceProposerPayload.sol +++ b/l1-contracts/test/governance/scenario/NewGovernanceProposerPayload.sol @@ -4,7 +4,7 @@ pragma solidity >=0.8.27; import {IPayload} from "@aztec/governance/interfaces/IPayload.sol"; import {IRegistry} from "@aztec/governance/interfaces/IRegistry.sol"; import {Governance} from "@aztec/governance/Governance.sol"; -import {GovernanceProposer} from "@aztec/governance/GovernanceProposer.sol"; +import {GovernanceProposer} from "@aztec/governance/proposer/GovernanceProposer.sol"; /** * @title NewGovernanceProposerPayload diff --git a/l1-contracts/test/governance/scenario/UpgradeGovernanceProposerTest.t.sol b/l1-contracts/test/governance/scenario/UpgradeGovernanceProposerTest.t.sol index 8504653da175..0a403de63f17 100644 --- a/l1-contracts/test/governance/scenario/UpgradeGovernanceProposerTest.t.sol +++ b/l1-contracts/test/governance/scenario/UpgradeGovernanceProposerTest.t.sol @@ -6,7 +6,7 @@ import {TestBase} from "@test/base/Base.sol"; import {IMintableERC20} from "@aztec/governance/interfaces/IMintableERC20.sol"; import {Rollup} from "../../harnesses/Rollup.sol"; import {Governance} from "@aztec/governance/Governance.sol"; -import {GovernanceProposer} from "@aztec/governance/GovernanceProposer.sol"; +import {GovernanceProposer} from "@aztec/governance/proposer/GovernanceProposer.sol"; import {Registry} from "@aztec/governance/Registry.sol"; import {DataStructures} from "@aztec/governance/libraries/DataStructures.sol"; import {IMintableERC20} from "@aztec/governance/interfaces/IMintableERC20.sol"; diff --git a/l1-contracts/test/governance/scenario/slashing/Slashing.t.sol b/l1-contracts/test/governance/scenario/slashing/Slashing.t.sol new file mode 100644 index 000000000000..95c3010ab2a2 --- /dev/null +++ b/l1-contracts/test/governance/scenario/slashing/Slashing.t.sol @@ -0,0 +1,121 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity >=0.8.27; + +import {TestBase} from "@test/base/Base.sol"; + +import {Errors} from "@aztec/core/libraries/Errors.sol"; +import {Registry} from "@aztec/governance/Registry.sol"; +import {Rollup, Config} from "@aztec/core/Rollup.sol"; +import {TestERC20} from "@aztec/mock/TestERC20.sol"; +import {MockFeeJuicePortal} from "@aztec/mock/MockFeeJuicePortal.sol"; +import {TestConstants} from "../../../harnesses/TestConstants.sol"; +import {CheatDepositArgs} from "@aztec/core/interfaces/IRollup.sol"; + +import {RewardDistributor} from "@aztec/governance/RewardDistributor.sol"; + +import {SlashFactory} from "@aztec/periphery/SlashFactory.sol"; +import {Slasher, IPayload} from "@aztec/core/staking/Slasher.sol"; +import {ILeonidas} from "@aztec/core/interfaces/ILeonidas.sol"; +import {Status, ValidatorInfo} from "@aztec/core/interfaces/IStaking.sol"; + +import {Errors} from "@aztec/core/libraries/Errors.sol"; +import {Timestamp} from "@aztec/core/libraries/TimeMath.sol"; + +import {CheatDepositArgs} from "@aztec/core/interfaces/IRollup.sol"; +import {SlashingProposer} from "@aztec/core/staking/SlashingProposer.sol"; + +import {Slot, SlotLib, Epoch} from "@aztec/core/libraries/TimeMath.sol"; + +contract SlashingScenario is TestBase { + using SlotLib for Slot; + + TestERC20 internal testERC20; + RewardDistributor internal rewardDistributor; + Rollup internal rollup; + Slasher internal slasher; + SlashFactory internal slashFactory; + SlashingProposer internal slashingProposer; + + function test_Slashing() public { + uint256 validatorCount = 4; + + CheatDepositArgs[] memory initialValidators = new CheatDepositArgs[](validatorCount); + + for (uint256 i = 1; i < validatorCount + 1; i++) { + uint256 attesterPrivateKey = uint256(keccak256(abi.encode("attester", i))); + address attester = vm.addr(attesterPrivateKey); + uint256 proposerPrivateKey = uint256(keccak256(abi.encode("proposer", i))); + address proposer = vm.addr(proposerPrivateKey); + + initialValidators[i - 1] = CheatDepositArgs({ + attester: attester, + proposer: proposer, + withdrawer: address(this), + amount: TestConstants.AZTEC_MINIMUM_STAKE + }); + } + + testERC20 = new TestERC20("test", "TEST", address(this)); + Registry registry = new Registry(address(this)); + rewardDistributor = new RewardDistributor(testERC20, registry, address(this)); + rollup = new Rollup({ + _fpcJuicePortal: new MockFeeJuicePortal(), + _rewardDistributor: rewardDistributor, + _stakingAsset: testERC20, + _vkTreeRoot: bytes32(0), + _protocolContractTreeRoot: bytes32(0), + _ares: address(this), + _config: Config({ + aztecSlotDuration: TestConstants.AZTEC_SLOT_DURATION, + aztecEpochDuration: TestConstants.AZTEC_EPOCH_DURATION, + targetCommitteeSize: TestConstants.AZTEC_TARGET_COMMITTEE_SIZE, + aztecEpochProofClaimWindowInL2Slots: TestConstants.AZTEC_EPOCH_PROOF_CLAIM_WINDOW_IN_L2_SLOTS, + minimumStake: TestConstants.AZTEC_MINIMUM_STAKE, + slashingQuorum: TestConstants.AZTEC_SLASHING_QUORUM, + slashingRoundSize: TestConstants.AZTEC_SLASHING_ROUND_SIZE + }) + }); + slasher = rollup.SLASHER(); + slashingProposer = slasher.PROPOSER(); + slashFactory = new SlashFactory(ILeonidas(address(rollup))); + + testERC20.mint(address(this), TestConstants.AZTEC_MINIMUM_STAKE * validatorCount); + testERC20.approve(address(rollup), TestConstants.AZTEC_MINIMUM_STAKE * validatorCount); + rollup.cheat__InitialiseValidatorSet(initialValidators); + + // Lets make a proposal to slash! + + uint256 slashAmount = 10e18; + IPayload payload = slashFactory.createSlashPayload(Epoch.wrap(0), slashAmount); + + // Cast a bunch of votes + vm.warp(Timestamp.unwrap(rollup.getTimestampForSlot(Slot.wrap(1)))); + + for (uint256 i = 0; i < 10; i++) { + address proposer = rollup.getCurrentProposer(); + vm.prank(proposer); + slashingProposer.vote(payload); + vm.warp(Timestamp.unwrap(rollup.getTimestampForSlot(rollup.getCurrentSlot() + Slot.wrap(1)))); + } + + address[] memory attesters = rollup.getAttesters(); + uint256[] memory stakes = new uint256[](attesters.length); + + for (uint256 i = 0; i < attesters.length; i++) { + ValidatorInfo memory info = rollup.getInfo(attesters[i]); + stakes[i] = info.stake; + assertTrue(info.status == Status.VALIDATING, "Invalid status"); + } + + slashingProposer.pushProposal(0); + + // Make sure that the slash was successful, + // Meaning that validators are now LIVING and have lost the slash amount + for (uint256 i = 0; i < attesters.length; i++) { + ValidatorInfo memory info = rollup.getInfo(attesters[i]); + uint256 stake = info.stake; + assertEq(stake, stakes[i] - slashAmount, "Invalid stake"); + assertTrue(info.status == Status.LIVING, "Invalid status"); + } + } +} diff --git a/l1-contracts/test/harnesses/Leonidas.sol b/l1-contracts/test/harnesses/Leonidas.sol index a7c78f304b10..c52eb3015890 100644 --- a/l1-contracts/test/harnesses/Leonidas.sol +++ b/l1-contracts/test/harnesses/Leonidas.sol @@ -7,11 +7,12 @@ import {TestConstants} from "./TestConstants.sol"; import {TestERC20} from "@aztec/mock/TestERC20.sol"; contract Leonidas is RealLeonidas { - constructor(address _ares) + constructor() RealLeonidas( - _ares, new TestERC20("test", "TEST", address(this)), 100e18, + TestConstants.AZTEC_SLASHING_QUORUM, + TestConstants.AZTEC_SLASHING_ROUND_SIZE, TestConstants.AZTEC_SLOT_DURATION, TestConstants.AZTEC_EPOCH_DURATION, TestConstants.AZTEC_TARGET_COMMITTEE_SIZE diff --git a/l1-contracts/test/harnesses/Rollup.sol b/l1-contracts/test/harnesses/Rollup.sol index 41d72b20de9f..27d55a9913e3 100644 --- a/l1-contracts/test/harnesses/Rollup.sol +++ b/l1-contracts/test/harnesses/Rollup.sol @@ -29,7 +29,9 @@ contract Rollup is RealRollup { aztecEpochDuration: TestConstants.AZTEC_EPOCH_DURATION, targetCommitteeSize: TestConstants.AZTEC_TARGET_COMMITTEE_SIZE, aztecEpochProofClaimWindowInL2Slots: TestConstants.AZTEC_EPOCH_PROOF_CLAIM_WINDOW_IN_L2_SLOTS, - minimumStake: TestConstants.AZTEC_MINIMUM_STAKE + minimumStake: TestConstants.AZTEC_MINIMUM_STAKE, + slashingQuorum: TestConstants.AZTEC_SLASHING_QUORUM, + slashingRoundSize: TestConstants.AZTEC_SLASHING_ROUND_SIZE }) ) {} diff --git a/l1-contracts/test/harnesses/TestConstants.sol b/l1-contracts/test/harnesses/TestConstants.sol index 371a2d8f594e..aad8edd6db0d 100644 --- a/l1-contracts/test/harnesses/TestConstants.sol +++ b/l1-contracts/test/harnesses/TestConstants.sol @@ -10,4 +10,6 @@ library TestConstants { uint256 internal constant AZTEC_TARGET_COMMITTEE_SIZE = 48; uint256 internal constant AZTEC_EPOCH_PROOF_CLAIM_WINDOW_IN_L2_SLOTS = 13; uint256 internal constant AZTEC_MINIMUM_STAKE = 100e18; + uint256 internal constant AZTEC_SLASHING_QUORUM = 6; + uint256 internal constant AZTEC_SLASHING_ROUND_SIZE = 10; } diff --git a/l1-contracts/test/sparta/Sparta.t.sol b/l1-contracts/test/sparta/Sparta.t.sol index dc5340a39aea..9f806139582e 100644 --- a/l1-contracts/test/sparta/Sparta.t.sol +++ b/l1-contracts/test/sparta/Sparta.t.sol @@ -12,7 +12,7 @@ import {Inbox} from "@aztec/core/messagebridge/Inbox.sol"; import {Outbox} from "@aztec/core/messagebridge/Outbox.sol"; import {Errors} from "@aztec/core/libraries/Errors.sol"; import {Registry} from "@aztec/governance/Registry.sol"; -import {Rollup} from "../harnesses/Rollup.sol"; +import {Rollup, Config} from "@aztec/core/Rollup.sol"; import {Leonidas} from "@aztec/core/Leonidas.sol"; import {NaiveMerkle} from "../merkle/Naive.sol"; import {MerkleTestUtil} from "../merkle/TestUtil.sol"; @@ -28,6 +28,11 @@ import {CheatDepositArgs} from "@aztec/core/interfaces/IRollup.sol"; import {Slot, Epoch, SlotLib, EpochLib} from "@aztec/core/libraries/TimeMath.sol"; import {RewardDistributor} from "@aztec/governance/RewardDistributor.sol"; + +import {SlashFactory} from "@aztec/periphery/SlashFactory.sol"; +import {Slasher, IPayload} from "@aztec/core/staking/Slasher.sol"; +import {ILeonidas} from "@aztec/core/interfaces/ILeonidas.sol"; +import {Status, ValidatorInfo} from "@aztec/core/interfaces/IStaking.sol"; // solhint-disable comprehensive-interface /** @@ -45,6 +50,8 @@ contract SpartaTest is DecoderBase { bool shouldRevert; } + SlashFactory internal slashFactory; + Slasher internal slasher; Inbox internal inbox; Outbox internal outbox; Rollup internal rollup; @@ -66,9 +73,10 @@ contract SpartaTest is DecoderBase { string memory _name = "mixed_block_1"; { Leonidas leonidas = new Leonidas( - address(1), testERC20, TestConstants.AZTEC_MINIMUM_STAKE, + TestConstants.AZTEC_SLASHING_QUORUM, + TestConstants.AZTEC_SLASHING_ROUND_SIZE, TestConstants.AZTEC_SLOT_DURATION, TestConstants.AZTEC_EPOCH_DURATION, TestConstants.AZTEC_TARGET_COMMITTEE_SIZE @@ -104,9 +112,25 @@ contract SpartaTest is DecoderBase { testERC20 = new TestERC20("test", "TEST", address(this)); Registry registry = new Registry(address(this)); rewardDistributor = new RewardDistributor(testERC20, registry, address(this)); - rollup = new Rollup( - new MockFeeJuicePortal(), rewardDistributor, testERC20, bytes32(0), bytes32(0), address(this) - ); + rollup = new Rollup({ + _fpcJuicePortal: new MockFeeJuicePortal(), + _rewardDistributor: rewardDistributor, + _stakingAsset: testERC20, + _vkTreeRoot: bytes32(0), + _protocolContractTreeRoot: bytes32(0), + _ares: address(this), + _config: Config({ + aztecSlotDuration: TestConstants.AZTEC_SLOT_DURATION, + aztecEpochDuration: TestConstants.AZTEC_EPOCH_DURATION, + targetCommitteeSize: TestConstants.AZTEC_TARGET_COMMITTEE_SIZE, + aztecEpochProofClaimWindowInL2Slots: TestConstants.AZTEC_EPOCH_PROOF_CLAIM_WINDOW_IN_L2_SLOTS, + minimumStake: TestConstants.AZTEC_MINIMUM_STAKE, + slashingQuorum: TestConstants.AZTEC_SLASHING_QUORUM, + slashingRoundSize: TestConstants.AZTEC_SLASHING_ROUND_SIZE + }) + }); + slasher = rollup.SLASHER(); + slashFactory = new SlashFactory(ILeonidas(address(rollup))); testERC20.mint(address(this), TestConstants.AZTEC_MINIMUM_STAKE * _validatorCount); testERC20.approve(address(rollup), TestConstants.AZTEC_MINIMUM_STAKE * _validatorCount); @@ -183,6 +207,40 @@ contract SpartaTest is DecoderBase { _testBlock("mixed_block_2", false, 3, false); } + function testNukeFromOrbit() public setup(4) { + // We propose some blocks, and have a bunch of validators attest to them. + // Then we slash EVERYONE that was in the committees because the epoch never + // got finalised. + // This is LIKELY, not the action you really want to take, you want to slash + // the people actually attesting, etc, but for simplicity we can do this as showcase. + _testBlock("mixed_block_1", false, 3, false); + _testBlock("mixed_block_2", false, 3, false); + + address[] memory attesters = rollup.getAttesters(); + uint256[] memory stakes = new uint256[](attesters.length); + + for (uint256 i = 0; i < attesters.length; i++) { + ValidatorInfo memory info = rollup.getInfo(attesters[i]); + stakes[i] = info.stake; + assertTrue(info.status == Status.VALIDATING, "Invalid status"); + } + + // We say, these things are bad, call the baba yaga to take care of them! + uint256 slashAmount = 10e18; + IPayload slashPayload = slashFactory.createSlashPayload(rollup.getCurrentEpoch(), slashAmount); + vm.prank(address(slasher.PROPOSER())); + slasher.slash(slashPayload); + + // Make sure that the slash was successful, + // Meaning that validators are now LIVING and have lost the slash amount + for (uint256 i = 0; i < attesters.length; i++) { + ValidatorInfo memory info = rollup.getInfo(attesters[i]); + uint256 stake = info.stake; + assertEq(stake, stakes[i] - slashAmount, "Invalid stake"); + assertTrue(info.status == Status.LIVING, "Invalid status"); + } + } + function testInvalidProposer() public setup(4) { _testBlock("mixed_block_1", true, 3, true); } diff --git a/l1-contracts/test/staking/StakingCheater.sol b/l1-contracts/test/staking/StakingCheater.sol index ba89e1e07ab5..a886a3d2f72c 100644 --- a/l1-contracts/test/staking/StakingCheater.sol +++ b/l1-contracts/test/staking/StakingCheater.sol @@ -9,9 +9,12 @@ import {EnumerableSet} from "@oz/utils/structs/EnumerableSet.sol"; contract StakingCheater is Staking { using EnumerableSet for EnumerableSet.AddressSet; - constructor(address _slasher, IERC20 _stakingAsset, uint256 _minimumStake) - Staking(_slasher, _stakingAsset, _minimumStake) - {} + constructor( + IERC20 _stakingAsset, + uint256 _minimumStake, + uint256 _slashingQuorum, + uint256 _roundSize + ) Staking(_stakingAsset, _minimumStake, _slashingQuorum, _roundSize) {} function cheat__SetStatus(address _attester, Status _status) external { stakingStore.info[_attester].status = _status; diff --git a/l1-contracts/test/staking/base.t.sol b/l1-contracts/test/staking/base.t.sol index 6aa8eaa8ca4b..441d418d244f 100644 --- a/l1-contracts/test/staking/base.t.sol +++ b/l1-contracts/test/staking/base.t.sol @@ -16,10 +16,13 @@ contract StakingBase is TestBase { address internal constant ATTESTER = address(bytes20("ATTESTER")); address internal constant WITHDRAWER = address(bytes20("WITHDRAWER")); address internal constant RECIPIENT = address(bytes20("RECIPIENT")); - address internal constant SLASHER = address(bytes20("SLASHER")); + + address internal SLASHER; function setUp() public virtual { stakingAsset = new TestERC20("test", "TEST", address(this)); - staking = new StakingCheater(SLASHER, stakingAsset, MINIMUM_STAKE); + staking = new StakingCheater(stakingAsset, MINIMUM_STAKE, 1, 1); + + SLASHER = address(staking.SLASHER()); } } diff --git a/spartan/aztec-network/files/config/config-prover-env.sh b/spartan/aztec-network/files/config/config-prover-env.sh index 073547821d48..2d56ed1c897e 100644 --- a/spartan/aztec-network/files/config/config-prover-env.sh +++ b/spartan/aztec-network/files/config/config-prover-env.sh @@ -19,6 +19,7 @@ coin_issuer_address=$(echo "$output" | grep -oP 'CoinIssuer Address: \K0x[a-fA-F reward_distributor_address=$(echo "$output" | grep -oP 'RewardDistributor Address: \K0x[a-fA-F0-9]{40}') governance_proposer_address=$(echo "$output" | grep -oP 'GovernanceProposer Address: \K0x[a-fA-F0-9]{40}') governance_address=$(echo "$output" | grep -oP 'Governance Address: \K0x[a-fA-F0-9]{40}') +slash_factory_address=$(echo "$output" | grep -oP 'SlashFactory Address: \K0x[a-fA-F0-9]{40}') # Write the addresses to a file in the shared volume cat </shared/contracts/contracts.env @@ -34,6 +35,7 @@ export COIN_ISSUER_CONTRACT_ADDRESS=$coin_issuer_address export REWARD_DISTRIBUTOR_CONTRACT_ADDRESS=$reward_distributor_address export GOVERNANCE_PROPOSER_CONTRACT_ADDRESS=$governance_proposer_address export GOVERNANCE_CONTRACT_ADDRESS=$governance_address +export SLASH_FACTORY_CONTRACT_ADDRESS=$slash_factory_address EOF cat /shared/contracts/contracts.env diff --git a/spartan/aztec-network/files/config/config-validator-env.sh b/spartan/aztec-network/files/config/config-validator-env.sh index b2848f8e069c..05d55e437f39 100644 --- a/spartan/aztec-network/files/config/config-validator-env.sh +++ b/spartan/aztec-network/files/config/config-validator-env.sh @@ -19,6 +19,7 @@ coin_issuer_address=$(echo "$output" | grep -oP 'CoinIssuer Address: \K0x[a-fA-F reward_distributor_address=$(echo "$output" | grep -oP 'RewardDistributor Address: \K0x[a-fA-F0-9]{40}') governance_proposer_address=$(echo "$output" | grep -oP 'GovernanceProposer Address: \K0x[a-fA-F0-9]{40}') governance_address=$(echo "$output" | grep -oP 'Governance Address: \K0x[a-fA-F0-9]{40}') +slash_factory_address=$(echo "$output" | grep -oP 'SlashFactory Address: \K0x[a-fA-F0-9]{40}') # We assume that there is an env var set for validator keys from the config map # We get the index in the config map from the pod name, which will have the validator index within it @@ -39,6 +40,7 @@ export COIN_ISSUER_CONTRACT_ADDRESS=$coin_issuer_address export REWARD_DISTRIBUTOR_CONTRACT_ADDRESS=$reward_distributor_address export GOVERNANCE_PROPOSER_CONTRACT_ADDRESS=$governance_proposer_address export GOVERNANCE_CONTRACT_ADDRESS=$governance_address +export SLASH_FACTORY_CONTRACT_ADDRESS=$slash_factory_address export VALIDATOR_PRIVATE_KEY=$private_key export L1_PRIVATE_KEY=$private_key export SEQ_PUBLISHER_PRIVATE_KEY=$private_key diff --git a/spartan/aztec-network/files/config/deploy-l1-contracts.sh b/spartan/aztec-network/files/config/deploy-l1-contracts.sh index 1f4c56599f74..366a00bd41fe 100644 --- a/spartan/aztec-network/files/config/deploy-l1-contracts.sh +++ b/spartan/aztec-network/files/config/deploy-l1-contracts.sh @@ -36,6 +36,7 @@ coin_issuer_address=$(echo "$output" | grep -oP 'CoinIssuer Address: \K0x[a-fA-F reward_distributor_address=$(echo "$output" | grep -oP 'RewardDistributor Address: \K0x[a-fA-F0-9]{40}') governance_proposer_address=$(echo "$output" | grep -oP 'GovernanceProposer Address: \K0x[a-fA-F0-9]{40}') governance_address=$(echo "$output" | grep -oP 'Governance Address: \K0x[a-fA-F0-9]{40}') +slash_factory_address=$(echo "$output" | grep -oP 'SlashFactory Address: \K0x[a-fA-F0-9]{40}') # Write the addresses to a file in the shared volume cat < /shared/contracts/contracts.env @@ -50,6 +51,7 @@ export COIN_ISSUER_CONTRACT_ADDRESS=$coin_issuer_address export REWARD_DISTRIBUTOR_CONTRACT_ADDRESS=$reward_distributor_address export GOVERNANCE_PROPOSER_CONTRACT_ADDRESS=$governance_proposer_address export GOVERNANCE_CONTRACT_ADDRESS=$governance_address +export SLASH_FACTORY_CONTRACT_ADDRESS=$slash_factory_address EOF cat /shared/contracts/contracts.env diff --git a/yarn-project/aztec-node/src/aztec-node/server.ts b/yarn-project/aztec-node/src/aztec-node/server.ts index dd29b991f0cf..7885a56de985 100644 --- a/yarn-project/aztec-node/src/aztec-node/server.ts +++ b/yarn-project/aztec-node/src/aztec-node/server.ts @@ -73,7 +73,7 @@ import { createP2PClient, } from '@aztec/p2p'; import { ProtocolContractAddress } from '@aztec/protocol-contracts'; -import { GlobalVariableBuilder, type L1Publisher, SequencerClient } from '@aztec/sequencer-client'; +import { GlobalVariableBuilder, type L1Publisher, SequencerClient, createSlasherClient } from '@aztec/sequencer-client'; import { PublicProcessorFactory } from '@aztec/simulator'; import { Attributes, type TelemetryClient, type Traceable, type Tracer, trackSpan } from '@aztec/telemetry-client'; import { NoopTelemetryClient } from '@aztec/telemetry-client/noop'; @@ -177,8 +177,10 @@ export class AztecNodeService implements AztecNode, Traceable { telemetry, ); + const slasherClient = await createSlasherClient(config, archiver, telemetry); + // start both and wait for them to sync from the block source - await Promise.all([p2pClient.start(), worldStateSynchronizer.start()]); + await Promise.all([p2pClient.start(), worldStateSynchronizer.start(), slasherClient.start()]); const validatorClient = await createValidatorClient(config, rollupAddress, { p2pClient, telemetry, dateProvider }); @@ -189,6 +191,7 @@ export class AztecNodeService implements AztecNode, Traceable { validatorClient, p2pClient, worldStateSynchronizer, + slasherClient, contractDataSource: archiver, l2BlockSource: archiver, l1ToL2MessageSource: archiver, diff --git a/yarn-project/aztec.js/src/contract/contract.test.ts b/yarn-project/aztec.js/src/contract/contract.test.ts index 66a54e8cfb5b..f45eb0203d18 100644 --- a/yarn-project/aztec.js/src/contract/contract.test.ts +++ b/yarn-project/aztec.js/src/contract/contract.test.ts @@ -47,6 +47,7 @@ describe('Contract Class', () => { coinIssuerAddress: EthAddress.random(), rewardDistributorAddress: EthAddress.random(), governanceProposerAddress: EthAddress.random(), + slashFactoryAddress: EthAddress.random(), }; const mockNodeInfo: NodeInfo = { nodeVersion: 'vx.x.x', diff --git a/yarn-project/cli/src/cmds/infrastructure/sequencers.ts b/yarn-project/cli/src/cmds/infrastructure/sequencers.ts index a3e6c77d39d5..cf5dbe7bdc10 100644 --- a/yarn-project/cli/src/cmds/infrastructure/sequencers.ts +++ b/yarn-project/cli/src/cmds/infrastructure/sequencers.ts @@ -1,5 +1,5 @@ import { createCompatibleClient } from '@aztec/aztec.js'; -import { MINIMUM_STAKE, createEthereumChain } from '@aztec/ethereum'; +import { createEthereumChain, getL1ContractsConfigEnvVars } from '@aztec/ethereum'; import { type LogFn, type Logger } from '@aztec/foundation/log'; import { RollupAbi, TestERC20Abi } from '@aztec/l1-artifacts'; @@ -71,14 +71,16 @@ export async function sequencers(opts: { client: walletClient, }); + const config = getL1ContractsConfigEnvVars(); + await Promise.all( [ - await stakingAsset.write.mint([walletClient.account.address, MINIMUM_STAKE], {} as any), - await stakingAsset.write.approve([rollup.address, MINIMUM_STAKE], {} as any), + await stakingAsset.write.mint([walletClient.account.address, config.minimumStake], {} as any), + await stakingAsset.write.approve([rollup.address, config.minimumStake], {} as any), ].map(txHash => publicClient.waitForTransactionReceipt({ hash: txHash })), ); - const hash = await writeableRollup.write.deposit([who, who, who, MINIMUM_STAKE]); + const hash = await writeableRollup.write.deposit([who, who, who, config.minimumStake]); await publicClient.waitForTransactionReceipt({ hash }); log(`Added in tx ${hash}`); } else if (command === 'remove') { diff --git a/yarn-project/cli/src/cmds/l1/deploy_l1_contracts.ts b/yarn-project/cli/src/cmds/l1/deploy_l1_contracts.ts index 21ac9d71ec68..39b4bfd46351 100644 --- a/yarn-project/cli/src/cmds/l1/deploy_l1_contracts.ts +++ b/yarn-project/cli/src/cmds/l1/deploy_l1_contracts.ts @@ -48,5 +48,6 @@ export async function deployL1Contracts( log(`RewardDistributor Address: ${l1ContractAddresses.rewardDistributorAddress.toString()}`); log(`GovernanceProposer Address: ${l1ContractAddresses.governanceProposerAddress.toString()}`); log(`Governance Address: ${l1ContractAddresses.governanceAddress.toString()}`); + log(`SlashFactory Address: ${l1ContractAddresses.slashFactoryAddress.toString()}`); } } diff --git a/yarn-project/cli/src/cmds/l1/update_l1_validators.ts b/yarn-project/cli/src/cmds/l1/update_l1_validators.ts index 7d5edca07bac..40d06e2fd6d0 100644 --- a/yarn-project/cli/src/cmds/l1/update_l1_validators.ts +++ b/yarn-project/cli/src/cmds/l1/update_l1_validators.ts @@ -1,6 +1,6 @@ import { EthCheatCodes } from '@aztec/aztec.js'; import { type EthAddress } from '@aztec/circuits.js'; -import { MINIMUM_STAKE, createEthereumChain, getL1ContractsConfigEnvVars, isAnvilTestChain } from '@aztec/ethereum'; +import { createEthereumChain, getL1ContractsConfigEnvVars, isAnvilTestChain } from '@aztec/ethereum'; import { type LogFn, type Logger } from '@aztec/foundation/log'; import { RollupAbi, TestERC20Abi } from '@aztec/l1-artifacts'; @@ -40,6 +40,7 @@ export async function addL1Validator({ log, debugLogger, }: RollupCommandArgs & LoggerArgs & { validatorAddress: EthAddress }) { + const config = getL1ContractsConfigEnvVars(); const dualLog = makeDualLog(log, debugLogger); const publicClient = getPublicClient(rpcUrl, chainId); const walletClient = getWalletClient(rpcUrl, chainId, privateKey, mnemonic); @@ -57,8 +58,8 @@ export async function addL1Validator({ await Promise.all( [ - await stakingAsset.write.mint([walletClient.account.address, MINIMUM_STAKE], {} as any), - await stakingAsset.write.approve([rollupAddress.toString(), MINIMUM_STAKE], {} as any), + await stakingAsset.write.mint([walletClient.account.address, config.minimumStake], {} as any), + await stakingAsset.write.approve([rollupAddress.toString(), config.minimumStake], {} as any), ].map(txHash => publicClient.waitForTransactionReceipt({ hash: txHash })), ); @@ -67,7 +68,7 @@ export async function addL1Validator({ validatorAddress.toString(), validatorAddress.toString(), validatorAddress.toString(), - MINIMUM_STAKE, + config.minimumStake, ]); dualLog(`Transaction hash: ${txHash}`); await publicClient.waitForTransactionReceipt({ hash: txHash }); diff --git a/yarn-project/end-to-end/scripts/e2e_test_config.yml b/yarn-project/end-to-end/scripts/e2e_test_config.yml index 2fb7902c93f8..2ea76eb0e911 100644 --- a/yarn-project/end-to-end/scripts/e2e_test_config.yml +++ b/yarn-project/end-to-end/scripts/e2e_test_config.yml @@ -90,6 +90,8 @@ tests: e2e_p2p_gossip: test_path: 'e2e_p2p/gossip_network.test.ts' with_alerts: true + e2e_p2p_slashing: + test_path: 'e2e_p2p/slashing.test.ts' e2e_p2p_upgrade_governance_proposer: test_path: 'e2e_p2p/upgrade_governance_proposer.test.ts' e2e_p2p_rediscovery: diff --git a/yarn-project/end-to-end/scripts/native-network/deploy-l1-contracts.sh b/yarn-project/end-to-end/scripts/native-network/deploy-l1-contracts.sh index 2f1d670620ce..9c87ef3332c3 100755 --- a/yarn-project/end-to-end/scripts/native-network/deploy-l1-contracts.sh +++ b/yarn-project/end-to-end/scripts/native-network/deploy-l1-contracts.sh @@ -63,6 +63,7 @@ COIN_ISSUER_CONTRACT_ADDRESS=$(echo "$output" | grep -oP 'CoinIssuer Address: \K REWARD_DISTRIBUTOR_CONTRACT_ADDRESS=$(echo "$output" | grep -oP 'RewardDistributor Address: \K0x[a-fA-F0-9]{40}') GOVERNANCE_PROPOSER_CONTRACT_ADDRESS=$(echo "$output" | grep -oP 'GovernanceProposer Address: \K0x[a-fA-F0-9]{40}') GOVERNANCE_CONTRACT_ADDRESS=$(echo "$output" | grep -oP 'Governance Address: \K0x[a-fA-F0-9]{40}') +SLASH_FACTORY_CONTRACT_ADDRESS=$(echo "$output" | grep -oP 'SlashFactory Address: \K0x[a-fA-F0-9]{40}') # Save contract addresses to state/l1-contracts.env cat <$(git rev-parse --show-toplevel)/yarn-project/end-to-end/scripts/native-network/state/l1-contracts.env @@ -77,6 +78,7 @@ export COIN_ISSUER_CONTRACT_ADDRESS=$COIN_ISSUER_CONTRACT_ADDRESS export REWARD_DISTRIBUTOR_CONTRACT_ADDRESS=$REWARD_DISTRIBUTOR_CONTRACT_ADDRESS export GOVERNANCE_PROPOSER_CONTRACT_ADDRESS=$GOVERNANCE_PROPOSER_CONTRACT_ADDRESS export GOVERNANCE_CONTRACT_ADDRESS=$GOVERNANCE_CONTRACT_ADDRESS +export SLASH_FACTORY_CONTRACT_ADDRESS=$SLASH_FACTORY_CONTRACT_ADDRESS EOCONFIG echo "Contract addresses saved to state/l1-contracts.env" diff --git a/yarn-project/end-to-end/src/e2e_p2p/p2p_network.ts b/yarn-project/end-to-end/src/e2e_p2p/p2p_network.ts index a218e4e7fe3b..303389a8a937 100644 --- a/yarn-project/end-to-end/src/e2e_p2p/p2p_network.ts +++ b/yarn-project/end-to-end/src/e2e_p2p/p2p_network.ts @@ -1,7 +1,7 @@ import { getSchnorrAccount } from '@aztec/accounts/schnorr'; import { type AztecNodeConfig, type AztecNodeService } from '@aztec/aztec-node'; import { type AccountWalletWithSecretKey } from '@aztec/aztec.js'; -import { EthCheatCodes, MINIMUM_STAKE, getL1ContractsConfigEnvVars } from '@aztec/ethereum'; +import { EthCheatCodes, getL1ContractsConfigEnvVars } from '@aztec/ethereum'; import { type Logger, createLogger } from '@aztec/foundation/log'; import { RollupAbi, TestERC20Abi } from '@aztec/l1-artifacts'; import { SpamContract } from '@aztec/noir-contracts.js'; @@ -60,6 +60,7 @@ export class P2PNetworkTest { initialValidatorConfig: AztecNodeConfig, // If set enable metrics collection metricsPort?: number, + assumeProvenThrough?: number, ) { this.logger = createLogger(`e2e:e2e_p2p:${testName}`); @@ -71,12 +72,24 @@ export class P2PNetworkTest { this.bootstrapNodeEnr = bootstrapNode.getENR().encodeTxt(); - this.snapshotManager = createSnapshotManager(`e2e_p2p_network/${testName}`, process.env.E2E_DATA_PATH, { - ...initialValidatorConfig, - ethereumSlotDuration: l1ContractsConfig.ethereumSlotDuration, - salt: 420, - metricsPort: metricsPort, - }); + this.snapshotManager = createSnapshotManager( + `e2e_p2p_network/${testName}`, + process.env.E2E_DATA_PATH, + { + ...initialValidatorConfig, + ethereumSlotDuration: l1ContractsConfig.ethereumSlotDuration, + salt: 420, + metricsPort: metricsPort, + }, + { + aztecEpochDuration: initialValidatorConfig.aztecEpochDuration ?? l1ContractsConfig.aztecEpochDuration, + aztecEpochProofClaimWindowInL2Slots: + initialValidatorConfig.aztecEpochProofClaimWindowInL2Slots ?? + l1ContractsConfig.aztecEpochProofClaimWindowInL2Slots, + assumeProvenThrough: assumeProvenThrough ?? Number.MAX_SAFE_INTEGER, + initialValidators: [], + }, + ); } /** @@ -112,11 +125,15 @@ export class P2PNetworkTest { numberOfNodes, basePort, metricsPort, + initialConfig, + assumeProvenThrough, }: { testName: string; numberOfNodes: number; basePort?: number; metricsPort?: number; + initialConfig?: Partial; + assumeProvenThrough?: number; }) { const port = basePort || (await getPort()); @@ -124,9 +141,20 @@ export class P2PNetworkTest { const bootstrapNode = await createBootstrapNodeFromPrivateKey(BOOTSTRAP_NODE_PRIVATE_KEY, port, telemetry); const bootstrapNodeEnr = bootstrapNode.getENR().encodeTxt(); - const initialValidatorConfig = await createValidatorConfig({} as AztecNodeConfig, bootstrapNodeEnr); + const initialValidatorConfig = await createValidatorConfig( + (initialConfig ?? {}) as AztecNodeConfig, + bootstrapNodeEnr, + ); - return new P2PNetworkTest(testName, bootstrapNode, port, numberOfNodes, initialValidatorConfig); + return new P2PNetworkTest( + testName, + bootstrapNode, + port, + numberOfNodes, + initialValidatorConfig, + metricsPort, + assumeProvenThrough, + ); } async applyBaseSnapshots() { @@ -147,7 +175,7 @@ export class P2PNetworkTest { client: deployL1ContractsValues.walletClient, }); - const stakeNeeded = MINIMUM_STAKE * BigInt(this.numberOfNodes); + const stakeNeeded = l1ContractsConfig.minimumStake * BigInt(this.numberOfNodes); await Promise.all( [ await stakingAsset.write.mint( @@ -170,7 +198,7 @@ export class P2PNetworkTest { attester: attester.address, proposer: proposer.address, withdrawer: attester.address, - amount: MINIMUM_STAKE, + amount: l1ContractsConfig.minimumStake, } as const); this.logger.verbose( diff --git a/yarn-project/end-to-end/src/e2e_p2p/slashing.test.ts b/yarn-project/end-to-end/src/e2e_p2p/slashing.test.ts new file mode 100644 index 000000000000..62fecb6aee4a --- /dev/null +++ b/yarn-project/end-to-end/src/e2e_p2p/slashing.test.ts @@ -0,0 +1,278 @@ +import { type AztecNodeService } from '@aztec/aztec-node'; +import { sleep } from '@aztec/aztec.js'; +import { RollupAbi, SlashFactoryAbi, SlasherAbi, SlashingProposerAbi } from '@aztec/l1-artifacts'; + +import fs from 'fs'; +import { getAddress, getContract, parseEventLogs } from 'viem'; + +import { shouldCollectMetrics } from '../fixtures/fixtures.js'; +import { createNodes } from '../fixtures/setup_p2p_test.js'; +import { AlertChecker, type AlertConfig } from '../quality_of_service/alert_checker.js'; +import { P2PNetworkTest } from './p2p_network.js'; +import { createPXEServiceAndSubmitTransactions } from './shared.js'; + +const CHECK_ALERTS = process.env.CHECK_ALERTS === 'true'; + +// Don't set this to a higher value than 9 because each node will use a different L1 publisher account and anvil seeds +const NUM_NODES = 4; +const BOOT_NODE_UDP_PORT = 40600; + +const DATA_DIR = './data/gossip'; + +const qosAlerts: AlertConfig[] = [ + { + alert: 'SequencerTimeToCollectAttestations', + expr: 'aztec_sequencer_time_to_collect_attestations > 3500', + labels: { severity: 'error' }, + for: '10m', + annotations: {}, + }, +]; + +// This test is showcasing that slashing can happen, abusing that our nodes are honest but stupid +// making them slash themselves. +describe('e2e_p2p_network', () => { + let t: P2PNetworkTest; + let nodes: AztecNodeService[]; + + beforeEach(async () => { + t = await P2PNetworkTest.create({ + testName: 'e2e_p2p_network', + numberOfNodes: NUM_NODES, + basePort: BOOT_NODE_UDP_PORT, + metricsPort: shouldCollectMetrics(), + initialConfig: { + aztecEpochDuration: 1, + aztecEpochProofClaimWindowInL2Slots: 1, + }, + assumeProvenThrough: 1, + }); + + await t.applyBaseSnapshots(); + await t.setup(); + await t.removeInitialNode(); + }); + + afterEach(async () => { + await t.stopNodes(nodes); + await t.teardown(); + for (let i = 0; i < NUM_NODES; i++) { + fs.rmSync(`${DATA_DIR}-${i}`, { recursive: true, force: true }); + } + }); + + afterAll(async () => { + if (CHECK_ALERTS) { + const checker = new AlertChecker(t.logger); + await checker.runAlertCheck(qosAlerts); + } + }); + + it('should slash the attesters', async () => { + // create the bootstrap node for the network + if (!t.bootstrapNodeEnr) { + throw new Error('Bootstrap node ENR is not available'); + } + + const rollup = getContract({ + address: t.ctx.deployL1ContractsValues!.l1ContractAddresses.rollupAddress.toString(), + abi: RollupAbi, + client: t.ctx.deployL1ContractsValues!.walletClient, + }); + + const slasherContract = getContract({ + address: getAddress(await rollup.read.SLASHER()), + abi: SlasherAbi, + client: t.ctx.deployL1ContractsValues.publicClient, + }); + + const slashingProposer = getContract({ + address: getAddress(await slasherContract.read.PROPOSER()), + abi: SlashingProposerAbi, + client: t.ctx.deployL1ContractsValues.publicClient, + }); + + const slashFactory = getContract({ + address: getAddress(t.ctx.deployL1ContractsValues.l1ContractAddresses.slashFactoryAddress.toString()), + abi: SlashFactoryAbi, + client: t.ctx.deployL1ContractsValues.publicClient, + }); + + const slashingInfo = async () => { + const bn = await t.ctx.cheatCodes.eth.blockNumber(); + const slotNumber = await rollup.read.getCurrentSlot(); + const roundNumber = await slashingProposer.read.computeRound([slotNumber]); + const instanceAddress = t.ctx.deployL1ContractsValues.l1ContractAddresses.rollupAddress.toString(); + const info = await slashingProposer.read.rounds([instanceAddress, roundNumber]); + const leaderVotes = await slashingProposer.read.yeaCount([instanceAddress, roundNumber, info[1]]); + return { bn, slotNumber, roundNumber, info, leaderVotes }; + }; + + const waitForL1Block = async () => { + // Send and wait an l1 block + await t.ctx.deployL1ContractsValues.publicClient.waitForTransactionReceipt({ + hash: await t.ctx.deployL1ContractsValues.walletClient.sendTransaction({ + to: t.ctx.deployL1ContractsValues.walletClient.account.address, + value: 1n, + account: t.ctx.deployL1ContractsValues.walletClient.account, + }), + }); + }; + + t.ctx.aztecNodeConfig.validatorReexecute = false; + + // create our network of nodes and submit txs into each of them + // the number of txs per node and the number of txs per rollup + // should be set so that the only way for rollups to be built + // is if the txs are successfully gossiped around the nodes. + t.logger.info('Creating nodes'); + nodes = await createNodes( + t.ctx.aztecNodeConfig, + t.ctx.dateProvider, + t.bootstrapNodeEnr, + NUM_NODES, + BOOT_NODE_UDP_PORT, + DATA_DIR, + // To collect metrics - run in aztec-packages `docker compose --profile metrics up` and set COLLECT_METRICS=true + shouldCollectMetrics(), + ); + + // We are overriding the slashing amount to 1, such that the slashing will "really" happen. + for (const node of nodes) { + const seqClient = node.getSequencer(); + if (!seqClient) { + throw new Error('Sequencer not found'); + } + const sequencer = (seqClient as any).sequencer; + const slasher = (sequencer as any).slasherClient; + slasher.slashingAmount = 1n; + } + + // wait a bit for peers to discover each other + await sleep(4000); + + let sInfo = await slashingInfo(); + + const votesNeeded = await slashingProposer.read.N(); + const roundSize = await slashingProposer.read.M(); + + // We should push us to land exactly at the next round + const nextRoundTimestamp1 = await rollup.read.getTimestampForSlot([ + ((await rollup.read.getCurrentSlot()) / roundSize) * roundSize + roundSize, + ]); + await t.ctx.cheatCodes.eth.warp(Number(nextRoundTimestamp1)); + + // Produce blocks until we hit an issue with pruning. + // Then we should jump in time to the next round so we are sure that we have the votes + // Then we just sit on our hands and wait. + + const seqClient = nodes[0].getSequencer(); + if (!seqClient) { + throw new Error('Sequencer not found'); + } + const sequencer = (seqClient as any).sequencer; + const slasher = (sequencer as any).slasherClient; + + t.logger.info(`Producing blocks until we hit a pruning event`); + + for (let i = 0; i < 15; i++) { + t.logger.info('Submitting transactions'); + const bn = await nodes[0].getBlockNumber(); + await createPXEServiceAndSubmitTransactions(t.logger, nodes[0], 1); + + t.logger.info(`Waiting for block number to change`); + while (bn === (await nodes[0].getBlockNumber())) { + await sleep(1000); + } + + if (slasher.slashEvents.length > 0) { + t.logger.info(`We have a slash event`); + break; + } + } + + for (let i = 0; i < 15; i++) { + t.logger.info('Waiting for slot number to change and votes to be cast'); + const slotNumber = await rollup.read.getCurrentSlot(); + t.logger.info(`Waiting for block number to change`); + while (slotNumber === (await rollup.read.getCurrentSlot())) { + await sleep(1000); + } + sInfo = await slashingInfo(); + t.logger.info(`We have ${sInfo.leaderVotes} votes in round ${sInfo.roundNumber} on ${sInfo.info[1]}`); + if (sInfo.leaderVotes > votesNeeded) { + t.logger.info(`We have sufficient votes`); + break; + } + } + + t.logger.info('Deploy the actual payload for slashing!'); + const slashEvent = slasher.slashEvents[0]; + await t.ctx.deployL1ContractsValues.publicClient.waitForTransactionReceipt({ + hash: await slashFactory.write.createSlashPayload([slashEvent.epoch, slashEvent.amount], { + account: t.ctx.deployL1ContractsValues.walletClient.account, + }), + }); + + t.logger.info(`We jump in time to the next round to execute`); + const nextRoundTimestamp2 = await rollup.read.getTimestampForSlot([ + ((await rollup.read.getCurrentSlot()) / roundSize) * roundSize + roundSize, + ]); + await t.ctx.cheatCodes.eth.warp(Number(nextRoundTimestamp2)); + await waitForL1Block(); + + const attestersPre = await rollup.read.getAttesters(); + + for (const attester of attestersPre) { + const attesterInfo = await rollup.read.getInfo([attester]); + // Check that status isValidating + expect(attesterInfo.status).toEqual(1); + } + + t.logger.info(`Push the proposal, SLASHING!`); + const tx = await slashingProposer.write.pushProposal([sInfo.roundNumber], { + account: t.ctx.deployL1ContractsValues.walletClient.account, + }); + await t.ctx.deployL1ContractsValues.publicClient.waitForTransactionReceipt({ + hash: tx, + }); + + const receipt = await t.ctx.deployL1ContractsValues.publicClient.getTransactionReceipt({ + hash: tx, + }); + + const slashingEvents = parseEventLogs({ + abi: RollupAbi, + logs: receipt.logs, + }).filter(log => log.eventName === 'Slashed'); + + const attestersSlashed = slashingEvents.map(event => { + // Because TS is a little nagging bitch + return (event.args as any).attester; + }); + + // Convert attestersPre elements to lowercase for consistent comparison + const normalizedAttestersPre = attestersPre.map(addr => addr.toLowerCase()); + const normalizedAttestersSlashed = attestersSlashed.map(addr => addr.toLowerCase()); + expect(new Set(normalizedAttestersPre)).toEqual(new Set(normalizedAttestersSlashed)); + + const instanceAddress = t.ctx.deployL1ContractsValues.l1ContractAddresses.rollupAddress.toString(); + const infoPost = await slashingProposer.read.rounds([instanceAddress, sInfo.roundNumber]); + + expect(sInfo.info[0]).toEqual(infoPost[0]); + expect(sInfo.info[1]).toEqual(infoPost[1]); + expect(sInfo.info[2]).toEqual(false); + expect(infoPost[2]).toEqual(true); + + const attestersPost = await rollup.read.getAttesters(); + + for (const attester of attestersPre) { + const attesterInfo = await rollup.read.getInfo([attester]); + // Check that status is Living + expect(attesterInfo.status).toEqual(2); + } + const committee = await rollup.read.getEpochCommittee([slashEvent.epoch]); + expect(attestersPre.length).toBe(committee.length); + expect(attestersPost.length).toBe(0); + }, 1_000_000); +}); diff --git a/yarn-project/end-to-end/src/fixtures/snapshot_manager.ts b/yarn-project/end-to-end/src/fixtures/snapshot_manager.ts index 5581397e60cd..c6e3f9341e2d 100644 --- a/yarn-project/end-to-end/src/fixtures/snapshot_manager.ts +++ b/yarn-project/end-to-end/src/fixtures/snapshot_manager.ts @@ -301,9 +301,9 @@ async function setupFromFresh( } const deployL1ContractsValues = await setupL1Contracts(aztecNodeConfig.l1RpcUrl, hdAccount, logger, { + ...getL1ContractsConfigEnvVars(), salt: opts.salt, ...deployL1ContractsArgs, - ...getL1ContractsConfigEnvVars(), initialValidators: opts.initialValidators, }); aztecNodeConfig.l1Contracts = deployL1ContractsValues.l1ContractAddresses; diff --git a/yarn-project/ethereum/src/config.ts b/yarn-project/ethereum/src/config.ts index eda0024870b3..4bb362d886e4 100644 --- a/yarn-project/ethereum/src/config.ts +++ b/yarn-project/ethereum/src/config.ts @@ -1,4 +1,9 @@ -import { type ConfigMappingsType, getConfigFromMappings, numberConfigHelper } from '@aztec/foundation/config'; +import { + type ConfigMappingsType, + bigintConfigHelper, + getConfigFromMappings, + numberConfigHelper, +} from '@aztec/foundation/config'; export type L1ContractsConfig = { /** How many seconds an L1 slot lasts. */ @@ -11,6 +16,16 @@ export type L1ContractsConfig = { aztecTargetCommitteeSize: number; /** The number of L2 slots that we can wait for a proof of an epoch to be produced. */ aztecEpochProofClaimWindowInL2Slots: number; + /** The minimum stake for a validator. */ + minimumStake: bigint; + /** The slashing quorum */ + slashingQuorum: number; + /** The slashing round size */ + slashingRoundSize: number; + /** Governance proposing quorum */ + governanceProposerQuorum: number; + /** Governance proposing round size */ + governanceProposerRoundSize: number; }; export const DefaultL1ContractsConfig: L1ContractsConfig = { @@ -19,6 +34,11 @@ export const DefaultL1ContractsConfig: L1ContractsConfig = { aztecEpochDuration: 16, aztecTargetCommitteeSize: 48, aztecEpochProofClaimWindowInL2Slots: 13, + minimumStake: BigInt(100e18), + slashingQuorum: 6, + slashingRoundSize: 10, + governanceProposerQuorum: 6, + governanceProposerRoundSize: 10, }; export const l1ContractsConfigMappings: ConfigMappingsType = { @@ -47,6 +67,31 @@ export const l1ContractsConfigMappings: ConfigMappingsType = description: 'The number of L2 slots that we can wait for a proof of an epoch to be produced.', ...numberConfigHelper(DefaultL1ContractsConfig.aztecEpochProofClaimWindowInL2Slots), }, + minimumStake: { + env: 'AZTEC_MINIMUM_STAKE', + description: 'The minimum stake for a validator.', + ...bigintConfigHelper(DefaultL1ContractsConfig.minimumStake), + }, + slashingQuorum: { + env: 'AZTEC_SLASHING_QUORUM', + description: 'The slashing quorum', + ...numberConfigHelper(DefaultL1ContractsConfig.slashingQuorum), + }, + slashingRoundSize: { + env: 'AZTEC_SLASHING_ROUND_SIZE', + description: 'The slashing round size', + ...numberConfigHelper(DefaultL1ContractsConfig.slashingRoundSize), + }, + governanceProposerQuorum: { + env: 'AZTEC_GOVERNANCE_PROPOSER_QUORUM', + description: 'The governance proposing quorum', + ...numberConfigHelper(DefaultL1ContractsConfig.governanceProposerQuorum), + }, + governanceProposerRoundSize: { + env: 'AZTEC_GOVERNANCE_PROPOSER_ROUND_SIZE', + description: 'The governance proposing round size', + ...numberConfigHelper(DefaultL1ContractsConfig.governanceProposerRoundSize), + }, }; export function getL1ContractsConfigEnvVars(): L1ContractsConfig { diff --git a/yarn-project/ethereum/src/constants.ts b/yarn-project/ethereum/src/constants.ts index 2fea0175acac..c1f4b34d7321 100644 --- a/yarn-project/ethereum/src/constants.ts +++ b/yarn-project/ethereum/src/constants.ts @@ -2,4 +2,3 @@ import { type Hex } from 'viem'; export const NULL_KEY: Hex = `0x${'0000000000000000000000000000000000000000000000000000000000000000'}`; export const AZTEC_TEST_CHAIN_ID = 677692; -export const MINIMUM_STAKE = BigInt(100e18); diff --git a/yarn-project/ethereum/src/deploy_l1_contracts.ts b/yarn-project/ethereum/src/deploy_l1_contracts.ts index 31b2c1eeb50e..657c9ff71457 100644 --- a/yarn-project/ethereum/src/deploy_l1_contracts.ts +++ b/yarn-project/ethereum/src/deploy_l1_contracts.ts @@ -26,6 +26,8 @@ import { RollupAbi, RollupBytecode, RollupLinkReferences, + SlashFactoryAbi, + SlashFactoryBytecode, TestERC20Abi, TestERC20Bytecode, } from '@aztec/l1-artifacts'; @@ -53,7 +55,6 @@ import { type HDAccount, type PrivateKeyAccount, mnemonicToAccount, privateKeyTo import { foundry } from 'viem/chains'; import { type L1ContractsConfig } from './config.js'; -import { MINIMUM_STAKE } from './constants.js'; import { isAnvilTestChain } from './ethereum_chain.js'; import { type L1ContractAddresses } from './l1_contract_addresses.js'; import { L1TxUtils } from './l1_tx_utils.js'; @@ -156,6 +157,10 @@ export interface L1ContractArtifactsForDeployment { * Governance contract artifacts. */ governance: ContractArtifacts; + /** + * SlashFactory contract artifacts. + */ + slashFactory: ContractArtifacts; } export const l1Artifacts: L1ContractArtifactsForDeployment = { @@ -216,6 +221,10 @@ export const l1Artifacts: L1ContractArtifactsForDeployment = { contractAbi: GovernanceAbi, contractBytecode: GovernanceBytecode, }, + slashFactory: { + contractAbi: SlashFactoryAbi, + contractBytecode: SlashFactoryBytecode, + }, }; export interface DeployL1ContractsArgs extends L1ContractsConfig { @@ -331,14 +340,10 @@ export const deployL1Contracts = async ( ]); logger.verbose(`Deployed Staking Asset at ${stakingAssetAddress}`); - // @todo #8084 - // @note These numbers are just chosen to make testing simple. - const quorumSize = 6n; - const roundSize = 10n; const governanceProposerAddress = await govDeployer.deploy(l1Artifacts.governanceProposer, [ registryAddress.toString(), - quorumSize, - roundSize, + args.governanceProposerQuorum, + args.governanceProposerRoundSize, ]); logger.verbose(`Deployed GovernanceProposer at ${governanceProposerAddress}`); @@ -382,7 +387,9 @@ export const deployL1Contracts = async ( aztecEpochDuration: args.aztecEpochDuration, targetCommitteeSize: args.aztecTargetCommitteeSize, aztecEpochProofClaimWindowInL2Slots: args.aztecEpochProofClaimWindowInL2Slots, - minimumStake: MINIMUM_STAKE, + minimumStake: args.minimumStake, + slashingQuorum: args.slashingQuorum, + slashingRoundSize: args.slashingRoundSize, }; const rollupArgs = [ feeJuicePortalAddress.toString(), @@ -396,6 +403,9 @@ export const deployL1Contracts = async ( const rollupAddress = await deployer.deploy(l1Artifacts.rollup, rollupArgs); logger.verbose(`Deployed Rollup at ${rollupAddress}`, rollupConfigArgs); + const slashFactoryAddress = await deployer.deploy(l1Artifacts.slashFactory, [rollupAddress.toString()]); + logger.info(`Deployed SlashFactory at ${slashFactoryAddress}`); + await deployer.waitForDeployments(); logger.verbose(`All core contracts have been deployed`); @@ -434,7 +444,7 @@ export const deployL1Contracts = async ( if (args.initialValidators && args.initialValidators.length > 0) { // Mint tokens, approve them, use cheat code to initialise validator set without setting up the epoch. - const stakeNeeded = MINIMUM_STAKE * BigInt(args.initialValidators.length); + const stakeNeeded = args.minimumStake * BigInt(args.initialValidators.length); await Promise.all( [ await stakingAsset.write.mint([walletClient.account.address, stakeNeeded], {} as any), @@ -447,7 +457,7 @@ export const deployL1Contracts = async ( attester: v.toString(), proposer: v.toString(), withdrawer: v.toString(), - amount: MINIMUM_STAKE, + amount: args.minimumStake, })), ]); txHashes.push(initiateValidatorSetTxHash); @@ -560,6 +570,7 @@ export const deployL1Contracts = async ( rewardDistributorAddress, governanceProposerAddress, governanceAddress, + slashFactoryAddress, }; logger.info(`Aztec L1 contracts initialized`, l1Contracts); diff --git a/yarn-project/ethereum/src/l1_contract_addresses.ts b/yarn-project/ethereum/src/l1_contract_addresses.ts index eca35f4edead..aca32ba2dd21 100644 --- a/yarn-project/ethereum/src/l1_contract_addresses.ts +++ b/yarn-project/ethereum/src/l1_contract_addresses.ts @@ -21,6 +21,7 @@ export const L1ContractsNames = [ 'governanceProposerAddress', 'governanceAddress', 'stakingAssetAddress', + 'slashFactoryAddress', ] as const; /** Provides the directory of current L1 contract addresses */ @@ -40,6 +41,7 @@ export const L1ContractAddressesSchema = z.object({ rewardDistributorAddress: schemas.EthAddress, governanceProposerAddress: schemas.EthAddress, governanceAddress: schemas.EthAddress, + slashFactoryAddress: schemas.EthAddress, }) satisfies ZodFor; const parseEnv = (val: string) => EthAddress.fromString(val); @@ -100,4 +102,9 @@ export const l1ContractAddressesMapping: ConfigMappingsType description: 'The deployed L1 governance contract address', parseEnv, }, + slashFactoryAddress: { + env: 'SLASH_FACTORY_CONTRACT_ADDRESS', + description: 'The deployed L1 slashFactory contract address', + parseEnv, + }, }; diff --git a/yarn-project/foundation/src/config/env_var.ts b/yarn-project/foundation/src/config/env_var.ts index ea13f2f97118..894dac8d9047 100644 --- a/yarn-project/foundation/src/config/env_var.ts +++ b/yarn-project/foundation/src/config/env_var.ts @@ -147,6 +147,7 @@ export type EnvVar = | 'SEQ_REQUIRED_CONFIRMATIONS' | 'SEQ_TX_POLLING_INTERVAL_MS' | 'SEQ_ENFORCE_TIME_TABLE' + | 'SLASH_FACTORY_CONTRACT_ADDRESS' | 'STAKING_ASSET_CONTRACT_ADDRESS' | 'REWARD_DISTRIBUTOR_CONTRACT_ADDRESS' | 'TELEMETRY' @@ -174,6 +175,11 @@ export type EnvVar = | 'AZTEC_EPOCH_DURATION' | 'AZTEC_TARGET_COMMITTEE_SIZE' | 'AZTEC_EPOCH_PROOF_CLAIM_WINDOW_IN_L2_SLOTS' + | 'AZTEC_MINIMUM_STAKE' + | 'AZTEC_SLASHING_QUORUM' + | 'AZTEC_SLASHING_ROUND_SIZE' + | 'AZTEC_GOVERNANCE_PROPOSER_QUORUM' + | 'AZTEC_GOVERNANCE_PROPOSER_ROUND_SIZE' | 'L1_GAS_LIMIT_BUFFER_PERCENTAGE' | 'L1_GAS_LIMIT_BUFFER_FIXED' | 'L1_GAS_PRICE_MIN' diff --git a/yarn-project/l1-artifacts/scripts/generate-artifacts.sh b/yarn-project/l1-artifacts/scripts/generate-artifacts.sh index 896467dfd053..7bf69aafef35 100755 --- a/yarn-project/l1-artifacts/scripts/generate-artifacts.sh +++ b/yarn-project/l1-artifacts/scripts/generate-artifacts.sh @@ -30,6 +30,10 @@ CONTRACTS=( "l1-contracts:NewGovernanceProposerPayload" "l1-contracts:LeonidasLib" "l1-contracts:ExtRollupLib" + "l1-contracts:SlashingProposer" + "l1-contracts:Slasher" + "l1-contracts:EmpireBase" + "l1-contracts:SlashFactory" ) # Read the error ABI's once and store it in COMBINED_ERRORS variable diff --git a/yarn-project/pxe/src/pxe_service/test/pxe_service.test.ts b/yarn-project/pxe/src/pxe_service/test/pxe_service.test.ts index d0dc0103bbfe..51bd6ce16cc4 100644 --- a/yarn-project/pxe/src/pxe_service/test/pxe_service.test.ts +++ b/yarn-project/pxe/src/pxe_service/test/pxe_service.test.ts @@ -44,6 +44,7 @@ async function createPXEService(): Promise { coinIssuerAddress: EthAddress.random(), rewardDistributorAddress: EthAddress.random(), governanceProposerAddress: EthAddress.random(), + slashFactoryAddress: EthAddress.random(), }; node.getL1ContractAddresses.mockResolvedValue(mockedContracts); diff --git a/yarn-project/sequencer-client/package.json b/yarn-project/sequencer-client/package.json index 334377750069..1aef7c86e896 100644 --- a/yarn-project/sequencer-client/package.json +++ b/yarn-project/sequencer-client/package.json @@ -50,6 +50,7 @@ "viem": "^2.7.15" }, "devDependencies": { + "@aztec/archiver": "workspace:^", "@aztec/kv-store": "workspace:^", "@jest/globals": "^29.5.0", "@types/jest": "^29.5.0", diff --git a/yarn-project/sequencer-client/src/client/sequencer-client.ts b/yarn-project/sequencer-client/src/client/sequencer-client.ts index ba9987262c27..533e6068f8d5 100644 --- a/yarn-project/sequencer-client/src/client/sequencer-client.ts +++ b/yarn-project/sequencer-client/src/client/sequencer-client.ts @@ -11,6 +11,7 @@ import { type SequencerClientConfig } from '../config.js'; import { GlobalVariableBuilder } from '../global_variable_builder/index.js'; import { L1Publisher } from '../publisher/index.js'; import { Sequencer, type SequencerConfig } from '../sequencer/index.js'; +import { type SlasherClient } from '../slasher/index.js'; import { TxValidatorFactory } from '../tx_validator/tx_validator_factory.js'; /** @@ -38,6 +39,7 @@ export class SequencerClient { validatorClient: ValidatorClient | undefined; // allowed to be undefined while we migrate p2pClient: P2P; worldStateSynchronizer: WorldStateSynchronizer; + slasherClient: SlasherClient; contractDataSource: ContractDataSource; l2BlockSource: L2BlockSource; l1ToL2MessageSource: L1ToL2MessageSource; @@ -49,6 +51,7 @@ export class SequencerClient { validatorClient, p2pClient, worldStateSynchronizer, + slasherClient, contractDataSource, l2BlockSource, l1ToL2MessageSource, @@ -71,6 +74,7 @@ export class SequencerClient { globalsBuilder, p2pClient, worldStateSynchronizer, + slasherClient, new LightweightBlockBuilderFactory(telemetryClient), l2BlockSource, l1ToL2MessageSource, diff --git a/yarn-project/sequencer-client/src/index.ts b/yarn-project/sequencer-client/src/index.ts index 1718ed0a3a63..e02cef93366a 100644 --- a/yarn-project/sequencer-client/src/index.ts +++ b/yarn-project/sequencer-client/src/index.ts @@ -2,6 +2,7 @@ export * from './client/index.js'; export * from './config.js'; export * from './publisher/index.js'; export * from './sequencer/index.js'; +export * from './slasher/index.js'; // Used by the node to simulate public parts of transactions. Should these be moved to a shared library? // ISSUE(#9832) diff --git a/yarn-project/sequencer-client/src/publisher/l1-publisher.ts b/yarn-project/sequencer-client/src/publisher/l1-publisher.ts index a189fb28c9f0..15e7caf11894 100644 --- a/yarn-project/sequencer-client/src/publisher/l1-publisher.ts +++ b/yarn-project/sequencer-client/src/publisher/l1-publisher.ts @@ -17,22 +17,16 @@ import { type Proof, type RootRollupPublicInputs, } from '@aztec/circuits.js'; -import { - type EthereumChain, - type L1ContractsConfig, - L1TxUtils, - type L1TxUtilsConfig, - createEthereumChain, -} from '@aztec/ethereum'; +import { type EthereumChain, type L1ContractsConfig, L1TxUtils, createEthereumChain } from '@aztec/ethereum'; import { makeTuple } from '@aztec/foundation/array'; import { areArraysEqual, compactArray, times } from '@aztec/foundation/collection'; import { type Signature } from '@aztec/foundation/eth-signature'; import { Fr } from '@aztec/foundation/fields'; -import { createLogger } from '@aztec/foundation/log'; +import { type Logger, createLogger } from '@aztec/foundation/log'; import { type Tuple, serializeToBuffer } from '@aztec/foundation/serialize'; import { InterruptibleSleep } from '@aztec/foundation/sleep'; import { Timer } from '@aztec/foundation/timer'; -import { GovernanceProposerAbi, RollupAbi } from '@aztec/l1-artifacts'; +import { EmpireBaseAbi, RollupAbi, SlasherAbi } from '@aztec/l1-artifacts'; import { type TelemetryClient } from '@aztec/telemetry-client'; import pick from 'lodash.pick'; @@ -132,6 +126,13 @@ export type L1SubmitEpochProofArgs = { proof: Proof; }; +export enum VoteType { + GOVERNANCE, + SLASHING, +} + +type GetSlashPayloadCallBack = (slotNumber: bigint) => Promise; + /** * Publishes L2 blocks to L1. This implementation does *not* retry a transaction in * the event of network congestion, but should work for local development. @@ -146,20 +147,25 @@ export class L1Publisher { private interrupted = false; private metrics: L1PublisherMetrics; - private payload: EthAddress = EthAddress.ZERO; - private myLastVote: bigint = 0n; + protected governanceLog = createLogger('sequencer:publisher:governance'); + protected governanceProposerAddress?: EthAddress; + private governancePayload: EthAddress = EthAddress.ZERO; + + protected slashingLog = createLogger('sequencer:publisher:slashing'); + protected slashingProposerAddress?: EthAddress; + private getSlashPayload?: GetSlashPayloadCallBack = undefined; + + private myLastVotes: Record = { + [VoteType.GOVERNANCE]: 0n, + [VoteType.SLASHING]: 0n, + }; protected log = createLogger('sequencer:publisher'); - protected governanceLog = createLogger('sequencer:publisher:governance'); protected rollupContract: GetContractReturnType< typeof RollupAbi, WalletClient >; - protected governanceProposerContract?: GetContractReturnType< - typeof GovernanceProposerAbi, - WalletClient - > = undefined; protected publicClient: PublicClient; protected walletClient: WalletClient; @@ -172,7 +178,7 @@ export class L1Publisher { private readonly l1TxUtils: L1TxUtils; constructor( - config: TxSenderConfig & PublisherConfig & Pick & L1TxUtilsConfig, + config: TxSenderConfig & PublisherConfig & Pick, client: TelemetryClient, ) { this.sleepTimeMs = config?.l1PublishRetryIntervalMS ?? 60_000; @@ -199,16 +205,31 @@ export class L1Publisher { }); if (l1Contracts.governanceProposerAddress) { - this.governanceProposerContract = getContract({ - address: getAddress(l1Contracts.governanceProposerAddress.toString()), - abi: GovernanceProposerAbi, - client: this.walletClient, - }); + this.governanceProposerAddress = EthAddress.fromString(l1Contracts.governanceProposerAddress.toString()); } this.l1TxUtils = new L1TxUtils(this.publicClient, this.walletClient, this.log, config); } + public registerSlashPayloadGetter(callback: GetSlashPayloadCallBack) { + this.getSlashPayload = callback; + } + + private async getSlashingProposerAddress() { + if (this.slashingProposerAddress) { + return this.slashingProposerAddress; + } + + const slasherAddress = await this.rollupContract.read.SLASHER(); + const slasher = getContract({ + address: getAddress(slasherAddress.toString()), + abi: SlasherAbi, + client: this.walletClient, + }); + this.slashingProposerAddress = EthAddress.fromString(await slasher.read.PROPOSER()); + return this.slashingProposerAddress; + } + get publisherAddress() { return this.account.address; } @@ -224,12 +245,12 @@ export class L1Publisher { }); } - public getPayLoad() { - return this.payload; + public getGovernancePayload() { + return this.governancePayload; } - public setPayload(payload: EthAddress) { - this.payload = payload; + public setGovernancePayload(payload: EthAddress) { + this.governancePayload = payload; } public getSenderAddress(): EthAddress { @@ -411,68 +432,106 @@ export class L1Publisher { calldataGas: getCalldataGasUsage(calldata), }; } - - public async castVote(slotNumber: bigint, timestamp: bigint): Promise { - if (this.payload.equals(EthAddress.ZERO)) { + public async castVote(slotNumber: bigint, timestamp: bigint, voteType: VoteType) { + // @todo This function can be optimized by doing some of the computations locally instead of calling the L1 contracts + if (this.myLastVotes[voteType] >= slotNumber) { return false; } - if (!this.governanceProposerContract) { - return false; - } + const voteConfig = async (): Promise< + { payload: EthAddress; voteContractAddress: EthAddress; logger: Logger } | undefined + > => { + if (voteType === VoteType.GOVERNANCE) { + if (this.governancePayload.equals(EthAddress.ZERO)) { + return undefined; + } + if (!this.governanceProposerAddress) { + return undefined; + } + return { + payload: this.governancePayload, + voteContractAddress: this.governanceProposerAddress, + logger: this.governanceLog, + }; + } else if (voteType === VoteType.SLASHING) { + if (!this.getSlashPayload) { + return undefined; + } + const slashingProposerAddress = await this.getSlashingProposerAddress(); + if (!slashingProposerAddress) { + return undefined; + } + + const slashPayload = await this.getSlashPayload(slotNumber); + + if (!slashPayload) { + return undefined; + } + + return { + payload: slashPayload, + voteContractAddress: slashingProposerAddress, + logger: this.slashingLog, + }; + } else { + throw new Error('Invalid vote type'); + } + }; - if (this.myLastVote >= slotNumber) { + const vConfig = await voteConfig(); + + if (!vConfig) { return false; } - // @todo This can be optimized A LOT by doing the computation instead of making calls to L1, but it is very convenient - // for when we keep changing the values and don't want to have multiple versions of the same logic implemented. + const { payload, voteContractAddress, logger } = vConfig; + + const voteContract = getContract({ + address: getAddress(voteContractAddress.toString()), + abi: EmpireBaseAbi, + client: this.walletClient, + }); const [proposer, roundNumber] = await Promise.all([ this.rollupContract.read.getProposerAt([timestamp]), - this.governanceProposerContract.read.computeRound([slotNumber]), + voteContract.read.computeRound([slotNumber]), ]); if (proposer.toLowerCase() !== this.account.address.toLowerCase()) { return false; } - const [slotForLastVote] = await this.governanceProposerContract.read.rounds([ - this.rollupContract.address, - roundNumber, - ]); + const [slotForLastVote] = await voteContract.read.rounds([this.rollupContract.address, roundNumber]); if (slotForLastVote >= slotNumber) { return false; } - // Storing these early such that a quick entry again would not send another tx, - // revert the state if there is a failure. - const cachedMyLastVote = this.myLastVote; - this.myLastVote = slotNumber; - - this.governanceLog.verbose(`Casting vote for ${this.payload}`); + const cachedMyLastVote = this.myLastVotes[voteType]; + this.myLastVotes[voteType] = slotNumber; let txHash; try { - txHash = await this.governanceProposerContract.write.vote([this.payload.toString()], { account: this.account }); + txHash = await voteContract.write.vote([payload.toString()], { + account: this.account, + }); } catch (err) { const msg = prettyLogViemErrorMsg(err); - this.governanceLog.error(`Failed to vote`, msg); - this.myLastVote = cachedMyLastVote; + logger.error(`Failed to vote`, msg); + this.myLastVotes[voteType] = cachedMyLastVote; return false; } if (txHash) { const receipt = await this.getTransactionReceipt(txHash); if (!receipt) { - this.governanceLog.warn(`Failed to get receipt for tx ${txHash}`); - this.myLastVote = cachedMyLastVote; + logger.warn(`Failed to get receipt for tx ${txHash}`); + this.myLastVotes[voteType] = cachedMyLastVote; return false; } } - this.governanceLog.info(`Cast vote for ${this.payload}`); + logger.info(`Cast vote for ${payload}`); return true; } diff --git a/yarn-project/sequencer-client/src/sequencer/sequencer.test.ts b/yarn-project/sequencer-client/src/sequencer/sequencer.test.ts index 1807a42a5e1a..4b1ddf4d720d 100644 --- a/yarn-project/sequencer-client/src/sequencer/sequencer.test.ts +++ b/yarn-project/sequencer-client/src/sequencer/sequencer.test.ts @@ -46,6 +46,7 @@ import { type MockProxy, mock, mockFn } from 'jest-mock-extended'; import { type GlobalVariableBuilder } from '../global_variable_builder/global_builder.js'; import { type L1Publisher } from '../publisher/l1-publisher.js'; +import { type SlasherClient } from '../slasher/index.js'; import { TxValidatorFactory } from '../tx_validator/tx_validator_factory.js'; import { Sequencer } from './sequencer.js'; import { SequencerState } from './utils.js'; @@ -187,6 +188,8 @@ describe('sequencer', () => { createBlockProposal: mockFn().mockResolvedValue(createBlockProposal()), }); + const slasherClient = mock(); + const l1GenesisTime = Math.floor(Date.now() / 1000); sequencer = new TestSubject( publisher, @@ -195,6 +198,7 @@ describe('sequencer', () => { globalVariableBuilder, p2p, worldState, + slasherClient, blockBuilderFactory, l2BlockSource, l1ToL2MessageSource, diff --git a/yarn-project/sequencer-client/src/sequencer/sequencer.ts b/yarn-project/sequencer-client/src/sequencer/sequencer.ts index 4db869b72d37..49d51c0b3c4a 100644 --- a/yarn-project/sequencer-client/src/sequencer/sequencer.ts +++ b/yarn-project/sequencer-client/src/sequencer/sequencer.ts @@ -35,8 +35,9 @@ import { Attributes, type TelemetryClient, type Tracer, trackSpan } from '@aztec import { type ValidatorClient } from '@aztec/validator-client'; import { type GlobalVariableBuilder } from '../global_variable_builder/global_builder.js'; -import { type L1Publisher } from '../publisher/l1-publisher.js'; +import { type L1Publisher, VoteType } from '../publisher/l1-publisher.js'; import { prettyLogViemErrorMsg } from '../publisher/utils.js'; +import { type SlasherClient } from '../slasher/slasher_client.js'; import { type TxValidatorFactory } from '../tx_validator/tx_validator_factory.js'; import { type SequencerConfig } from './config.js'; import { SequencerMetrics } from './metrics.js'; @@ -100,6 +101,7 @@ export class Sequencer { private globalsBuilder: GlobalVariableBuilder, private p2pClient: P2P, private worldState: WorldStateSynchronizer, + private slasherClient: SlasherClient, private blockBuilderFactory: BlockBuilderFactory, private l2BlockSource: L2BlockSource, private l1ToL2MessageSource: L1ToL2MessageSource, @@ -116,6 +118,9 @@ export class Sequencer { // Register the block builder with the validator client for re-execution this.validatorClient?.registerBlockBuilder(this.buildBlock.bind(this)); + + // Register the slasher on the publisher to fetch slashing payloads + this.publisher.registerSlashPayloadGetter(this.slasherClient.getSlashPayload.bind(this.slasherClient)); } get tracer(): Tracer { @@ -160,7 +165,7 @@ export class Sequencer { this.maxBlockSizeInBytes = config.maxBlockSizeInBytes; } if (config.governanceProposerPayload) { - this.publisher.setPayload(config.governanceProposerPayload); + this.publisher.setGovernancePayload(config.governanceProposerPayload); } this.enforceTimeTable = config.enforceTimeTable === true; @@ -206,6 +211,7 @@ export class Sequencer { this.log.debug(`Stopping sequencer`); await this.validatorClient?.stop(); await this.runningPromise?.stop(); + await this.slasherClient?.stop(); this.publisher.interrupt(); this.setState(SequencerState.STOPPED, 0n, true /** force */); this.log.info('Stopped sequencer'); @@ -275,7 +281,8 @@ export class Sequencer { slot, ); - void this.publisher.castVote(slot, newGlobalVariables.timestamp.toBigInt()); + void this.publisher.castVote(slot, newGlobalVariables.timestamp.toBigInt(), VoteType.GOVERNANCE); + void this.publisher.castVote(slot, newGlobalVariables.timestamp.toBigInt(), VoteType.SLASHING); if (!this.shouldProposeBlock(historicalHeader, {})) { return; diff --git a/yarn-project/sequencer-client/src/slasher/index.ts b/yarn-project/sequencer-client/src/slasher/index.ts new file mode 100644 index 000000000000..7a1631a7727d --- /dev/null +++ b/yarn-project/sequencer-client/src/slasher/index.ts @@ -0,0 +1,24 @@ +import type { L2BlockSource } from '@aztec/circuit-types'; +import { type L1ContractsConfig, type L1ReaderConfig } from '@aztec/ethereum'; +import { createLogger } from '@aztec/foundation/log'; +import { type AztecKVStore } from '@aztec/kv-store'; +import { type DataStoreConfig } from '@aztec/kv-store/config'; +import { createStore } from '@aztec/kv-store/lmdb'; +import { type TelemetryClient } from '@aztec/telemetry-client'; +import { NoopTelemetryClient } from '@aztec/telemetry-client/noop'; + +import { SlasherClient } from './slasher_client.js'; +import { type SlasherConfig } from './slasher_client.js'; + +export * from './slasher_client.js'; + +export const createSlasherClient = async ( + _config: SlasherConfig & DataStoreConfig & L1ContractsConfig & L1ReaderConfig, + l2BlockSource: L2BlockSource, + telemetry: TelemetryClient = new NoopTelemetryClient(), + deps: { store?: AztecKVStore } = {}, +) => { + const config = { ..._config }; + const store = deps.store ?? (await createStore('slasher', config, createLogger('slasher:lmdb'))); + return new SlasherClient(config, store, l2BlockSource, telemetry); +}; diff --git a/yarn-project/sequencer-client/src/slasher/slasher_client.test.ts b/yarn-project/sequencer-client/src/slasher/slasher_client.test.ts new file mode 100644 index 000000000000..bb097b9da729 --- /dev/null +++ b/yarn-project/sequencer-client/src/slasher/slasher_client.test.ts @@ -0,0 +1,120 @@ +import { MockL2BlockSource } from '@aztec/archiver/test'; +import { L2Block } from '@aztec/circuit-types'; +import { + type L1ContractAddresses, + type L1ContractsConfig, + type L1ReaderConfig, + getL1ContractsConfigEnvVars, +} from '@aztec/ethereum'; +import { EthAddress } from '@aztec/foundation/eth-address'; +import { retryUntil } from '@aztec/foundation/retry'; +import { sleep } from '@aztec/foundation/sleep'; +import { type AztecKVStore } from '@aztec/kv-store'; +import { openTmpStore } from '@aztec/kv-store/lmdb'; + +import { expect } from '@jest/globals'; + +import { SlasherClient, type SlasherConfig } from './slasher_client.js'; + +// Most of this test are directly copied from the P2P client test. +describe('In-Memory Slasher Client', () => { + let blockSource: MockL2BlockSource; + let kvStore: AztecKVStore; + let client: SlasherClient; + let config: SlasherConfig & L1ContractsConfig & L1ReaderConfig; + + beforeEach(() => { + blockSource = new MockL2BlockSource(); + blockSource.createBlocks(100); + + const l1Config = getL1ContractsConfigEnvVars(); + + // Need some configuration here. Can be a basic bitch config really. + config = { + ...l1Config, + blockCheckIntervalMS: 100, + blockRequestBatchSize: 20, + l1Contracts: { + slashFactoryAddress: EthAddress.ZERO, + } as unknown as L1ContractAddresses, + l1RpcUrl: 'http://127.0.0.1:8545', + l1ChainId: 1, + viemPollingIntervalMS: 1000, + }; + + kvStore = openTmpStore(); + client = new SlasherClient(config, kvStore, blockSource); + }); + + const advanceToProvenBlock = async (getProvenBlockNumber: number, provenEpochNumber = getProvenBlockNumber) => { + blockSource.setProvenBlockNumber(getProvenBlockNumber); + blockSource.setProvenEpochNumber(provenEpochNumber); + await retryUntil( + () => Promise.resolve(client.getSyncedProvenBlockNum() >= getProvenBlockNumber), + 'synced', + 10, + 0.1, + ); + }; + + afterEach(async () => { + if (client.isReady()) { + await client.stop(); + } + }); + + it('can start & stop', async () => { + expect(client.isReady()).toEqual(false); + + await client.start(); + expect(client.isReady()).toEqual(true); + + await client.stop(); + expect(client.isReady()).toEqual(false); + }); + + it('restores the previous block number it was at', async () => { + await client.start(); + await client.stop(); + + const client2 = new SlasherClient(config, kvStore, blockSource); + expect(client2.getSyncedLatestBlockNum()).toEqual(client.getSyncedLatestBlockNum()); + }); + + describe('Chain prunes', () => { + it('moves the tips on a chain reorg', async () => { + blockSource.setProvenBlockNumber(0); + await client.start(); + + await advanceToProvenBlock(90); + + await expect(client.getL2Tips()).resolves.toEqual({ + latest: { number: 100, hash: expect.any(String) }, + proven: { number: 90, hash: expect.any(String) }, + finalized: { number: 90, hash: expect.any(String) }, + }); + + blockSource.removeBlocks(10); + + // give the client a chance to react to the reorg + await sleep(100); + + await expect(client.getL2Tips()).resolves.toEqual({ + latest: { number: 90, hash: expect.any(String) }, + proven: { number: 90, hash: expect.any(String) }, + finalized: { number: 90, hash: expect.any(String) }, + }); + + blockSource.addBlocks([L2Block.random(91), L2Block.random(92)]); + + // give the client a chance to react to the new blocks + await sleep(100); + + await expect(client.getL2Tips()).resolves.toEqual({ + latest: { number: 92, hash: expect.any(String) }, + proven: { number: 90, hash: expect.any(String) }, + finalized: { number: 90, hash: expect.any(String) }, + }); + }); + }); +}); diff --git a/yarn-project/sequencer-client/src/slasher/slasher_client.ts b/yarn-project/sequencer-client/src/slasher/slasher_client.ts new file mode 100644 index 000000000000..9e2f30d012e3 --- /dev/null +++ b/yarn-project/sequencer-client/src/slasher/slasher_client.ts @@ -0,0 +1,408 @@ +import { + type L2Block, + type L2BlockId, + type L2BlockSource, + L2BlockStream, + type L2BlockStreamEvent, + type L2Tips, +} from '@aztec/circuit-types'; +import { INITIAL_L2_BLOCK_NUM } from '@aztec/circuits.js/constants'; +import { type L1ContractsConfig, type L1ReaderConfig, createEthereumChain } from '@aztec/ethereum'; +import { EthAddress } from '@aztec/foundation/eth-address'; +import { createLogger } from '@aztec/foundation/log'; +import { type AztecKVStore, type AztecMap, type AztecSingleton } from '@aztec/kv-store'; +import { SlashFactoryAbi } from '@aztec/l1-artifacts'; +import { type TelemetryClient, WithTracer } from '@aztec/telemetry-client'; +import { NoopTelemetryClient } from '@aztec/telemetry-client/noop'; + +import { + type Chain, + type GetContractReturnType, + type HttpTransport, + type PublicClient, + createPublicClient, + getAddress, + getContract, + http, +} from 'viem'; + +/** + * Enum defining the possible states of the Slasher client. + */ +export enum SlasherClientState { + IDLE, + SYNCHING, + RUNNING, + STOPPED, +} + +/** + * The synchronization status of the Slasher client. + */ +export interface SlasherSyncState { + /** + * The current state of the slasher client. + */ + state: SlasherClientState; + /** + * The block number that the slasher client is synced to. + */ + syncedToL2Block: L2BlockId; +} + +export interface SlasherConfig { + blockCheckIntervalMS: number; + blockRequestBatchSize: number; +} + +type SlashEvent = { + epoch: bigint; + amount: bigint; + lifetime: bigint; +}; + +/** + * @notice A Hypomeiones slasher client implementation + * + * Hypomeiones: a class of individuals in ancient Sparta who were considered inferior or lesser citizens compared + * to the full Spartan citizens. + * + * The implementation here is less than ideal. It exists, not to be the end all be all, but to show that + * slashing can be done with this mechanism. + * + * The implementation is VERY brute in the sense that it only looks for pruned blocks and then tries to slash + * the full committee of that. + * If it sees a prune, it will mark the full epoch as "to be slashed" and the + * + * Also, it is not particularly smart around what it should if there were to be multiple slashing events. + * + * A few improvements: + * - Only vote on the proposal if it is possible to reach, e.g., if 6 votes are needed and only 4 slots are left don't vote. + * - Stop voting on a payload once it is processed. + * - Only vote on the proposal if it have not already been executed + * - Caveat, we need to fully decide if it is acceptable to have the same payload address multiple times. In the current + * slash factory that could mean slashing the same committee for the same error multiple times. + * - Decide how to deal with multiple slashing events in the same round. + * - This could be that multiple epochs are pruned in the same round, but with the current naive implementation we could end up + * slashing only the first, because the "lifetime" of the second would have passed after that vote + */ +export class SlasherClient extends WithTracer { + private currentState = SlasherClientState.IDLE; + private syncPromise = Promise.resolve(); + private syncResolve?: () => void = undefined; + private latestBlockNumberAtStart = -1; + private provenBlockNumberAtStart = -1; + + private synchedBlockSlots: AztecMap; + private synchedBlockHashes: AztecMap; + private synchedLatestBlockNumber: AztecSingleton; + private synchedProvenBlockNumber: AztecSingleton; + + private blockStream; + + private slashEvents: SlashEvent[] = []; + + protected slashFactoryContract?: GetContractReturnType> = + undefined; + + // The amount to slash for a prune. + // Note that we set it to 0, such that no actual slashing will happen, but the event will be fired, + // showing that the slashing mechanism is working. + private slashingAmount: bigint = 0n; + + constructor( + private config: SlasherConfig & L1ContractsConfig & L1ReaderConfig, + private store: AztecKVStore, + private l2BlockSource: L2BlockSource, + telemetry: TelemetryClient = new NoopTelemetryClient(), + private log = createLogger('slasher'), + ) { + super(telemetry, 'slasher'); + + this.blockStream = new L2BlockStream(l2BlockSource, this, this, createLogger('slasher:block_stream'), { + batchSize: config.blockRequestBatchSize, + pollIntervalMS: config.blockCheckIntervalMS, + }); + + this.synchedBlockHashes = store.openMap('slasher_block_hashes'); + this.synchedBlockSlots = store.openMap('slasher_block_slots'); + this.synchedLatestBlockNumber = store.openSingleton('slasher_last_l2_block'); + this.synchedProvenBlockNumber = store.openSingleton('slasher_last_proven_l2_block'); + + if (config.l1Contracts.slashFactoryAddress && config.l1Contracts.slashFactoryAddress !== EthAddress.ZERO) { + const chain = createEthereumChain(config.l1RpcUrl, config.l1ChainId); + const publicClient = createPublicClient({ + chain: chain.chainInfo, + transport: http(chain.rpcUrl), + pollingInterval: config.viemPollingIntervalMS, + }); + + this.slashFactoryContract = getContract({ + address: getAddress(config.l1Contracts.slashFactoryAddress.toString()), + abi: SlashFactoryAbi, + client: publicClient, + }); + } + + this.log.info(`Slasher client initialized`); + } + + // This is where we should put a bunch of the improvements mentioned earlier. + public async getSlashPayload(slotNumber: bigint): Promise { + if (!this.slashFactoryContract) { + return undefined; + } + + // As long as the slot is greater than the lifetime, we want to keep deleting the first element + // since it will not make sense to include anymore. + while (this.slashEvents.length > 0 && this.slashEvents[0].lifetime < slotNumber) { + this.slashEvents.shift(); + } + + if (this.slashEvents.length == 0) { + return undefined; + } + + const slashEvent = this.slashEvents[0]; + + const [payloadAddress, isDeployed] = await this.slashFactoryContract.read.getAddressAndIsDeployed([ + slashEvent.epoch, + slashEvent.amount, + ]); + + if (!isDeployed) { + // The proposal cannot be executed until it is deployed + this.log.verbose(`Voting on not yet deployed payload: ${payloadAddress}`); + } + + return EthAddress.fromString(payloadAddress); + } + + public getL2BlockHash(number: number): Promise { + return Promise.resolve(this.synchedBlockHashes.get(number)); + } + + public getL2Tips(): Promise { + const latestBlockNumber = this.getSyncedLatestBlockNum(); + let latestBlockHash: string | undefined; + const provenBlockNumber = this.getSyncedProvenBlockNum(); + let provenBlockHash: string | undefined; + + if (latestBlockNumber > 0) { + latestBlockHash = this.synchedBlockHashes.get(latestBlockNumber); + if (typeof latestBlockHash === 'undefined') { + this.log.warn(`Block hash for latest block ${latestBlockNumber} not found`); + throw new Error(); + } + } + + if (provenBlockNumber > 0) { + provenBlockHash = this.synchedBlockHashes.get(provenBlockNumber); + if (typeof provenBlockHash === 'undefined') { + this.log.warn(`Block hash for proven block ${provenBlockNumber} not found`); + throw new Error(); + } + } + + return Promise.resolve({ + latest: { hash: latestBlockHash!, number: latestBlockNumber }, + proven: { hash: provenBlockHash!, number: provenBlockNumber }, + finalized: { hash: provenBlockHash!, number: provenBlockNumber }, + }); + } + + public async handleBlockStreamEvent(event: L2BlockStreamEvent): Promise { + this.log.debug(`Handling block stream event ${event.type}`); + switch (event.type) { + case 'blocks-added': + await this.handleLatestL2Blocks(event.blocks); + break; + case 'chain-finalized': + // TODO (alexg): I think we can prune the block hashes map here + break; + case 'chain-proven': { + const from = this.getSyncedProvenBlockNum() + 1; + const limit = event.blockNumber - from + 1; + await this.handleProvenL2Blocks(await this.l2BlockSource.getBlocks(from, limit)); + break; + } + case 'chain-pruned': + await this.handlePruneL2Blocks(event.blockNumber); + break; + default: { + const _: never = event; + break; + } + } + } + + public async start() { + if (this.currentState === SlasherClientState.STOPPED) { + throw new Error('Slasher already stopped'); + } + if (this.currentState !== SlasherClientState.IDLE) { + return this.syncPromise; + } + + // get the current latest block numbers + this.latestBlockNumberAtStart = await this.l2BlockSource.getBlockNumber(); + this.provenBlockNumberAtStart = await this.l2BlockSource.getProvenBlockNumber(); + + const syncedLatestBlock = this.getSyncedLatestBlockNum() + 1; + const syncedProvenBlock = this.getSyncedProvenBlockNum() + 1; + + // if there are blocks to be retrieved, go to a synching state + if (syncedLatestBlock <= this.latestBlockNumberAtStart || syncedProvenBlock <= this.provenBlockNumberAtStart) { + this.setCurrentState(SlasherClientState.SYNCHING); + this.syncPromise = new Promise(resolve => { + this.syncResolve = resolve; + }); + this.log.verbose(`Starting sync from ${syncedLatestBlock} (last proven ${syncedProvenBlock})`); + } else { + // if no blocks to be retrieved, go straight to running + this.setCurrentState(SlasherClientState.RUNNING); + this.syncPromise = Promise.resolve(); + this.log.verbose(`Block ${syncedLatestBlock} (proven ${syncedProvenBlock}) already beyond current block`); + } + + this.blockStream.start(); + this.log.verbose(`Started block downloader from block ${syncedLatestBlock}`); + + return this.syncPromise; + } + + /** + * Allows consumers to stop the instance of the slasher client. + * 'ready' will now return 'false' and the running promise that keeps the client synced is interrupted. + */ + public async stop() { + this.log.debug('Stopping Slasher client...'); + await this.blockStream.stop(); + this.log.debug('Stopped block downloader'); + this.setCurrentState(SlasherClientState.STOPPED); + this.log.info('Slasher client stopped.'); + } + + /** + * Public function to check if the slasher client is fully synced and ready to receive txs. + * @returns True if the slasher client is ready to receive txs. + */ + public isReady() { + return this.currentState === SlasherClientState.RUNNING; + } + + /** + * Public function to check the latest block number that the slasher client is synced to. + * @returns Block number of latest L2 Block we've synced with. + */ + public getSyncedLatestBlockNum() { + return this.synchedLatestBlockNumber.get() ?? INITIAL_L2_BLOCK_NUM - 1; + } + + /** + * Public function to check the latest proven block number that the slasher client is synced to. + * @returns Block number of latest proven L2 Block we've synced with. + */ + public getSyncedProvenBlockNum() { + return this.synchedProvenBlockNumber.get() ?? INITIAL_L2_BLOCK_NUM - 1; + } + + /** + * Method to check the status of the slasher client. + * @returns Information about slasher client status: state & syncedToBlockNum. + */ + public async getStatus(): Promise { + const blockNumber = this.getSyncedLatestBlockNum(); + const blockHash = + blockNumber == 0 + ? '' + : await this.l2BlockSource.getBlockHeader(blockNumber).then(header => header?.hash().toString()); + return Promise.resolve({ + state: this.currentState, + syncedToL2Block: { number: blockNumber, hash: blockHash }, + } as SlasherSyncState); + } + + /** + * Handles new mined blocks by marking the txs in them as mined. + * @param blocks - A list of existing blocks with txs that the slasher client needs to ensure the tx pool is reconciled with. + * @returns Empty promise. + */ + private async handleLatestL2Blocks(blocks: L2Block[]): Promise { + if (!blocks.length) { + return Promise.resolve(); + } + + const lastBlockNum = blocks[blocks.length - 1].number; + await Promise.all(blocks.map(block => this.synchedBlockHashes.set(block.number, block.hash().toString()))); + await Promise.all( + blocks.map(block => this.synchedBlockSlots.set(block.number, block.header.globalVariables.slotNumber.toBigInt())), + ); + await this.synchedLatestBlockNumber.set(lastBlockNum); + this.log.debug(`Synched to latest block ${lastBlockNum}`); + this.startServiceIfSynched(); + } + + /** + * Handles new proven blocks by deleting the txs in them, or by deleting the txs in blocks `keepProvenTxsFor` ago. + * @param blocks - A list of proven L2 blocks. + * @returns Empty promise. + */ + private async handleProvenL2Blocks(blocks: L2Block[]): Promise { + if (!blocks.length) { + return Promise.resolve(); + } + const lastBlockNum = blocks[blocks.length - 1].number; + await this.synchedProvenBlockNumber.set(lastBlockNum); + this.log.debug(`Synched to proven block ${lastBlockNum}`); + + this.startServiceIfSynched(); + } + + /** + * Updates the tx pool after a chain prune. + * @param latestBlock - The block number the chain was pruned to. + */ + private async handlePruneL2Blocks(latestBlock: number): Promise { + const slotNumber = this.synchedBlockSlots.get(latestBlock) ?? BigInt(0); + const epochNumber = slotNumber / BigInt(this.config.aztecEpochDuration); + this.log.info(`Detected chain prune. Punishing the validators at epoch ${epochNumber}`); + + // Set the lifetime such that we have a full round that we could vote throughout. + const slotsIntoRound = slotNumber % BigInt(this.config.slashingRoundSize); + const toNext = slotsIntoRound == 0n ? 0n : BigInt(this.config.slashingRoundSize) - slotsIntoRound; + + const lifetime = slotNumber + toNext + BigInt(this.config.slashingRoundSize); + + this.slashEvents.push({ + epoch: epochNumber, + amount: this.slashingAmount, + lifetime, + }); + + await this.synchedLatestBlockNumber.set(latestBlock); + } + + private startServiceIfSynched() { + if ( + this.currentState === SlasherClientState.SYNCHING && + this.getSyncedLatestBlockNum() >= this.latestBlockNumberAtStart && + this.getSyncedProvenBlockNum() >= this.provenBlockNumberAtStart + ) { + this.log.debug(`Synched to blocks at start`); + this.setCurrentState(SlasherClientState.RUNNING); + if (this.syncResolve !== undefined) { + this.syncResolve(); + } + } + } + + /** + * Method to set the value of the current state. + * @param newState - New state value. + */ + private setCurrentState(newState: SlasherClientState) { + this.currentState = newState; + this.log.debug(`Moved to state ${SlasherClientState[this.currentState]}`); + } +} diff --git a/yarn-project/sequencer-client/tsconfig.json b/yarn-project/sequencer-client/tsconfig.json index 9a8615c0299b..4161156625e7 100644 --- a/yarn-project/sequencer-client/tsconfig.json +++ b/yarn-project/sequencer-client/tsconfig.json @@ -60,6 +60,9 @@ { "path": "../world-state" }, + { + "path": "../archiver" + }, { "path": "../kv-store" } diff --git a/yarn-project/yarn.lock b/yarn-project/yarn.lock index d478feb7ad39..b8ccfc17e809 100644 --- a/yarn-project/yarn.lock +++ b/yarn-project/yarn.lock @@ -1151,6 +1151,7 @@ __metadata: version: 0.0.0-use.local resolution: "@aztec/sequencer-client@workspace:sequencer-client" dependencies: + "@aztec/archiver": "workspace:^" "@aztec/aztec.js": "workspace:^" "@aztec/bb-prover": "workspace:^" "@aztec/circuit-types": "workspace:^"