diff --git a/contrib/core-contract-tests/Clarinet.toml b/contrib/core-contract-tests/Clarinet.toml index 075681d4ef..7bd70e4745 100644 --- a/contrib/core-contract-tests/Clarinet.toml +++ b/contrib/core-contract-tests/Clarinet.toml @@ -15,3 +15,15 @@ path = "../../stackslib/src/chainstate/stacks/boot/pox-4.clar" depends_on = [] clarity = 2 epoch = 2.4 + +[contracts.signers] +path = "../../stackslib/src/chainstate/stacks/boot/signers.clar" +depends_on = [] +clarity = 2 +epoch = 2.4 + +[contracts.signers-voting] +path = "../../stackslib/src/chainstate/stacks/boot/signers-voting.clar" +depends_on = [] +clarity = 2 +epoch = 2.4 diff --git a/contrib/core-contract-tests/tests/pox-4/signers-voting.test.ts b/contrib/core-contract-tests/tests/pox-4/signers-voting.test.ts new file mode 100644 index 0000000000..96b45d426c --- /dev/null +++ b/contrib/core-contract-tests/tests/pox-4/signers-voting.test.ts @@ -0,0 +1,92 @@ +import { Cl } from "@stacks/transactions"; +import { beforeEach, describe, expect, it } from "vitest"; + +const accounts = simnet.getAccounts(); +const alice = accounts.get("wallet_1")!; +const bob = accounts.get("wallet_2")!; +const charlie = accounts.get("wallet_3")!; + +const ERR_SIGNER_INDEX_MISMATCH = 10000; +const ERR_INVALID_SIGNER_INDEX = 10001; +const ERR_OUT_OF_VOTING_WINDOW = 10002 +const ERR_OLD_ROUND = 10003; +const ERR_ILL_FORMED_AGGREGATE_PUBLIC_KEY = 10004; +const ERR_DUPLICATE_AGGREGATE_PUBLIC_KEY = 10005; +const ERR_DUPLICATE_VOTE = 10006; +const ERR_INVALID_BURN_BLOCK_HEIGHT = 10007 + +const KEY_1 = "123456789a123456789a123456789a123456789a123456789a123456789a010203"; +const KEY_2 = "123456789a123456789a123456789a123456789a123456789a123456789ab0b1b2"; +const SIGNERS_VOTING = "signers-voting"; + +describe("test signers-voting contract voting rounds", () => { + describe("test pox-info", () => { + it("should return correct burn-height", () => { + const { result:result1 } = simnet.callReadOnlyFn(SIGNERS_VOTING, + "reward-cycle-to-burn-height", + [Cl.uint(1)], + alice) + expect(result1).toEqual(Cl.uint(1050)) + + const { result:result2 } = simnet.callReadOnlyFn(SIGNERS_VOTING, + "reward-cycle-to-burn-height", + [Cl.uint(2)], + alice) + expect(result2).toEqual(Cl.uint(2100)) + }) + + it("should return correct reward-cycle", () => { + const { result: result1 } = simnet.callReadOnlyFn(SIGNERS_VOTING, + "burn-height-to-reward-cycle", + [Cl.uint(1)], + alice) + expect(result1).toEqual(Cl.uint(0)) + + const { result: result2000 } = simnet.callReadOnlyFn(SIGNERS_VOTING, + "burn-height-to-reward-cycle", + [Cl.uint(2000)], + alice) + expect(result2000).toEqual(Cl.uint(1)) + }) + + it("should return true if in prepare phase", () => { + const { result:result999 } = simnet.callReadOnlyFn(SIGNERS_VOTING, + "is-in-prepare-phase", + [Cl.uint(999)], + alice) + expect(result999).toEqual(Cl.bool(false)) + + const { result } = simnet.callReadOnlyFn(SIGNERS_VOTING, + "is-in-prepare-phase", + [Cl.uint(1000)], + alice) + expect(result).toEqual(Cl.bool(true)) + + const { result: result1001 } = simnet.callReadOnlyFn(SIGNERS_VOTING, + "is-in-prepare-phase", + [Cl.uint(1001)], + alice) + expect(result1001).toEqual(Cl.bool(true)) + + + const { result: result0 } = simnet.callReadOnlyFn(SIGNERS_VOTING, + "is-in-prepare-phase", + [Cl.uint(1049)], + alice) + expect(result0).toEqual(Cl.bool(true)) + + const { result: result1 } = simnet.callReadOnlyFn(SIGNERS_VOTING, + "is-in-prepare-phase", + [Cl.uint(1050)], + alice) + expect(result1).toEqual(Cl.bool(false)) + + const { result: result2 } = simnet.callReadOnlyFn(SIGNERS_VOTING, + "is-in-prepare-phase", + [Cl.uint(1051)], + alice) + expect(result2).toEqual(Cl.bool(false)) + }) + }) + +}); \ No newline at end of file diff --git a/stackslib/src/chainstate/nakamoto/coordinator/tests.rs b/stackslib/src/chainstate/nakamoto/coordinator/tests.rs index 633f480604..055fd05210 100644 --- a/stackslib/src/chainstate/nakamoto/coordinator/tests.rs +++ b/stackslib/src/chainstate/nakamoto/coordinator/tests.rs @@ -105,6 +105,7 @@ pub fn boot_nakamoto<'a>( mut initial_balances: Vec<(PrincipalData, u64)>, test_signers: &TestSigners, test_stackers: Option>, + observer: Option<&'a TestEventObserver>, ) -> TestPeer<'a> { let aggregate_public_key = test_signers.aggregate_public_key.clone(); let mut peer_config = TestPeerConfig::new(test_name, 0, 0); @@ -135,7 +136,7 @@ pub fn boot_nakamoto<'a>( (0..test_signers.num_keys) .map(|index| { let stacker_private_key = StacksPrivateKey::from_seed(&index.to_be_bytes()); - let signer_private_key = StacksPrivateKey::from_seed(&(index + 1000).to_be_bytes()); + let signer_private_key = StacksPrivateKey::from_seed(&index.to_be_bytes()); TestStacker { stacker_private_key, signer_private_key, @@ -151,7 +152,7 @@ pub fn boot_nakamoto<'a>( .map(|test_stacker| { ( PrincipalData::from(key_to_stacks_addr(&test_stacker.stacker_private_key)), - u64::try_from(test_stacker.amount).expect("Stacking amount too large"), + u64::try_from(test_stacker.amount + 10000).expect("Stacking amount too large"), ) }) .collect(); @@ -163,7 +164,7 @@ pub fn boot_nakamoto<'a>( peer_config.burnchain.pox_constants.v3_unlock_height = 27; peer_config.burnchain.pox_constants.pox_4_activation_height = 31; peer_config.test_stackers = Some(test_stackers.clone()); - let mut peer = TestPeer::new(peer_config); + let mut peer = TestPeer::new_with_observer(peer_config, observer); advance_to_nakamoto(&mut peer, &test_signers, test_stackers); @@ -296,7 +297,7 @@ fn replay_reward_cycle( #[test] fn test_simple_nakamoto_coordinator_bootup() { let mut test_signers = TestSigners::default(); - let mut peer = boot_nakamoto(function_name!(), vec![], &test_signers, None); + let mut peer = boot_nakamoto(function_name!(), vec![], &test_signers, None, None); let (burn_ops, mut tenure_change, miner_key) = peer.begin_nakamoto_tenure(TenureChangeCause::BlockFound); @@ -357,6 +358,7 @@ fn test_simple_nakamoto_coordinator_1_tenure_10_blocks() { vec![(addr.into(), 100_000_000)], &test_signers, None, + None, ); let (burn_ops, mut tenure_change, miner_key) = @@ -479,6 +481,7 @@ fn test_nakamoto_chainstate_getters() { vec![(addr.into(), 100_000_000)], &test_signers, None, + None, ); let sort_tip = { @@ -969,6 +972,7 @@ fn test_simple_nakamoto_coordinator_10_tenures_10_blocks() { vec![(addr.into(), 100_000_000)], &test_signers, None, + None, ); let mut all_blocks = vec![]; @@ -1290,6 +1294,7 @@ fn test_simple_nakamoto_coordinator_2_tenures_3_sortitions() { vec![(addr.into(), 100_000_000)], &test_signers, None, + None, ); let mut rc_burn_ops = vec![]; @@ -1619,6 +1624,7 @@ fn test_simple_nakamoto_coordinator_10_tenures_and_extensions_10_blocks() { vec![(addr.into(), 100_000_000)], &test_signers, None, + None, ); let mut all_blocks = vec![]; diff --git a/stackslib/src/chainstate/nakamoto/tests/mod.rs b/stackslib/src/chainstate/nakamoto/tests/mod.rs index c6c8217463..9df80e73f9 100644 --- a/stackslib/src/chainstate/nakamoto/tests/mod.rs +++ b/stackslib/src/chainstate/nakamoto/tests/mod.rs @@ -1502,7 +1502,7 @@ fn make_fork_run_with_arrivals( #[test] pub fn test_get_highest_nakamoto_tenure() { let test_signers = TestSigners::default(); - let mut peer = boot_nakamoto(function_name!(), vec![], &test_signers, None); + let mut peer = boot_nakamoto(function_name!(), vec![], &test_signers, None, None); // extract chainstate and sortdb -- we don't need the peer anymore let chainstate = &mut peer.stacks_node.as_mut().unwrap().chainstate; @@ -1644,7 +1644,7 @@ pub fn test_get_highest_nakamoto_tenure() { #[test] fn test_make_miners_stackerdb_config() { let test_signers = TestSigners::default(); - let mut peer = boot_nakamoto(function_name!(), vec![], &test_signers, None); + let mut peer = boot_nakamoto(function_name!(), vec![], &test_signers, None, None); let naka_miner_hash160 = peer.miner.nakamoto_miner_hash160(); let miner_keys: Vec<_> = (0..10).map(|_| StacksPrivateKey::new()).collect(); diff --git a/stackslib/src/chainstate/stacks/boot/mod.rs b/stackslib/src/chainstate/stacks/boot/mod.rs index 2f9695d428..f4b57ab470 100644 --- a/stackslib/src/chainstate/stacks/boot/mod.rs +++ b/stackslib/src/chainstate/stacks/boot/mod.rs @@ -79,6 +79,7 @@ pub const POX_2_NAME: &'static str = "pox-2"; pub const POX_3_NAME: &'static str = "pox-3"; pub const POX_4_NAME: &'static str = "pox-4"; pub const SIGNERS_NAME: &'static str = "signers"; +pub const SIGNERS_VOTING_NAME: &'static str = "signers-voting"; /// This is the name of a variable in the `.signers` contract which tracks the most recently updated /// reward cycle number. pub const SIGNERS_UPDATE_STATE: &'static str = "last-set-cycle"; @@ -89,6 +90,7 @@ const POX_2_BODY: &'static str = std::include_str!("pox-2.clar"); const POX_3_BODY: &'static str = std::include_str!("pox-3.clar"); const POX_4_BODY: &'static str = std::include_str!("pox-4.clar"); pub const SIGNERS_BODY: &'static str = std::include_str!("signers.clar"); +const SIGNERS_VOTING_BODY: &'static str = std::include_str!("signers-voting.clar"); pub const COSTS_1_NAME: &'static str = "costs"; pub const COSTS_2_NAME: &'static str = "costs-2"; @@ -117,6 +119,7 @@ lazy_static! { pub static ref POX_3_TESTNET_CODE: String = format!("{}\n{}", BOOT_CODE_POX_TESTNET_CONSTS, POX_3_BODY); pub static ref POX_4_CODE: String = format!("{}", POX_4_BODY); + pub static ref SIGNER_VOTING_CODE: String = format!("{}", SIGNERS_VOTING_BODY); pub static ref BOOT_CODE_COST_VOTING_TESTNET: String = make_testnet_cost_voting(); pub static ref STACKS_BOOT_CODE_MAINNET: [(&'static str, &'static str); 6] = [ ("pox", &BOOT_CODE_POX_MAINNET), @@ -1307,6 +1310,8 @@ pub mod pox_3_tests; pub mod pox_4_tests; #[cfg(test)] mod signers_tests; +#[cfg(test)] +pub mod signers_voting_tests; #[cfg(test)] pub mod test { @@ -1314,7 +1319,9 @@ pub mod test { use std::convert::From; use std::fs; + use clarity::boot_util::boot_code_addr; use clarity::vm::contracts::Contract; + use clarity::vm::tests::symbols_from_values; use clarity::vm::types::*; use stacks_common::util::hash::to_hex; use stacks_common::util::*; @@ -1874,6 +1881,30 @@ pub mod test { make_tx(key, nonce, 0, payload) } + pub fn make_signers_vote_for_aggregate_public_key( + key: &StacksPrivateKey, + nonce: u64, + signer_index: u128, + aggregate_public_key: &Point, + round: u128, + ) -> StacksTransaction { + let aggregate_public_key = Value::buff_from(aggregate_public_key.compress().data.to_vec()) + .expect("Failed to serialize aggregate public key"); + let payload = TransactionPayload::new_contract_call( + boot_code_test_addr(), + SIGNERS_VOTING_NAME, + "vote-for-aggregate-public-key", + vec![ + Value::UInt(signer_index), + aggregate_public_key, + Value::UInt(round), + ], + ) + .unwrap(); + // TODO set tx_fee back to 0 once these txs are free + make_tx(key, nonce, 1, payload) + } + pub fn make_pox_2_increase( key: &StacksPrivateKey, nonce: u64, diff --git a/stackslib/src/chainstate/stacks/boot/pox_4_tests.rs b/stackslib/src/chainstate/stacks/boot/pox_4_tests.rs index 69ba7f8e84..c92520a57c 100644 --- a/stackslib/src/chainstate/stacks/boot/pox_4_tests.rs +++ b/stackslib/src/chainstate/stacks/boot/pox_4_tests.rs @@ -82,7 +82,7 @@ const ERR_REUSED_SIGNER_KEY: i128 = 33; /// Return the BlockSnapshot for the latest sortition in the provided /// SortitionDB option-reference. Panics on any errors. -fn get_tip(sortdb: Option<&SortitionDB>) -> BlockSnapshot { +pub fn get_tip(sortdb: Option<&SortitionDB>) -> BlockSnapshot { SortitionDB::get_canonical_burn_chain_tip(&sortdb.unwrap().conn()).unwrap() } diff --git a/stackslib/src/chainstate/stacks/boot/signers-voting.clar b/stackslib/src/chainstate/stacks/boot/signers-voting.clar new file mode 100644 index 0000000000..d193386128 --- /dev/null +++ b/stackslib/src/chainstate/stacks/boot/signers-voting.clar @@ -0,0 +1,105 @@ +;; +;; @contract voting for the aggregate public key +;; + +;; maps dkg round and signer to proposed aggregate public key +(define-map votes {reward-cycle: uint, round: uint, signer: principal} {aggregate-public-key: (buff 33), reward-slots: uint}) +;; maps dkg round and aggregate public key to weights of signers supporting this key so far +(define-map tally {reward-cycle: uint, round: uint, aggregate-public-key: (buff 33)} uint) +;; maps aggregate public keys to rewards cycles and rounds +(define-map used-aggregate-public-keys (buff 33) {reward-cycle: uint, round: uint}) + +(define-constant err-signer-index-mismatch (err u10000)) +(define-constant err-invalid-signer-index (err u10001)) +(define-constant err-out-of-voting-window (err u10002)) +(define-constant err-old-round (err u10003)) +(define-constant err-ill-formed-aggregate-public-key (err u10004)) +(define-constant err-duplicate-aggregate-public-key (err u10005)) +(define-constant err-duplicate-vote (err u10006)) +(define-constant err-invalid-burn-block-height (err u10007)) + +(define-constant pox-info + (unwrap-panic (contract-call? .pox-4 get-pox-info))) + +;; maps reward-cycle ids to last round +(define-map rounds uint uint) + +(define-data-var state-1 {reward-cycle: uint, round: uint, aggregate-public-key: (optional (buff 33)), + total-votes: uint} {reward-cycle: u0, round: u0, aggregate-public-key: none, total-votes: u0}) +(define-data-var state-2 {reward-cycle: uint, round: uint, aggregate-public-key: (optional (buff 33)), + total-votes: uint} {reward-cycle: u0, round: u0, aggregate-public-key: none, total-votes: u0}) + +;; get voting info by burn block height +(define-read-only (get-info (height uint)) + (ok (at-block (unwrap! (get-block-info? id-header-hash height) err-invalid-burn-block-height) (get-current-info)))) + +;; get current voting info +(define-read-only (get-current-info) + (var-get state-1)) + +(define-read-only (burn-height-to-reward-cycle (height uint)) + (/ (- height (get first-burnchain-block-height pox-info)) (get reward-cycle-length pox-info))) + +(define-read-only (reward-cycle-to-burn-height (reward-cycle uint)) + (+ (* reward-cycle (get reward-cycle-length pox-info)) (get first-burnchain-block-height pox-info))) + +(define-read-only (current-reward-cycle) + (burn-height-to-reward-cycle burn-block-height)) + +(define-read-only (get-last-round (reward-cycle uint)) + (map-get? rounds reward-cycle)) + +(define-read-only (get-vote (reward-cycle uint) (round uint) (signer principal)) + (map-get? votes {reward-cycle: reward-cycle, round: round, signer: signer})) + +(define-read-only (get-tally (reward-cycle uint) (round uint) (aggregate-public-key (buff 33))) + (map-get? tally {reward-cycle: reward-cycle, round: round, aggregate-public-key: aggregate-public-key})) + +(define-read-only (get-signer-slots (signer-index uint) (reward-cycle uint)) + (let ((height (reward-cycle-to-burn-height reward-cycle))) + (ok (at-block + (unwrap! (get-block-info? id-header-hash height) err-invalid-burn-block-height) + (get-current-signer-slots signer-index))))) + +(define-read-only (get-current-signer-slots (signer-index uint)) + (let ((details (unwrap! (unwrap-panic (contract-call? .signers stackerdb-get-signer-by-index signer-index)) err-invalid-signer-index))) + (asserts! (is-eq (get signer details) tx-sender) err-signer-index-mismatch) + (ok (get num-slots details)))) + +;; aggregate public key must be unique and can be used only in a single cycle-round pair +(define-read-only (is-valid-aggregated-public-key (key (buff 33)) (dkg-id {reward-cycle: uint, round: uint})) + (is-eq (default-to dkg-id (map-get? used-aggregate-public-keys key)) dkg-id)) + +(define-read-only (is-in-prepare-phase (height uint)) + (< (mod (+ (- height (get first-burnchain-block-height pox-info)) + (get prepare-cycle-length pox-info)) + (get reward-cycle-length pox-info) + ) + (get prepare-cycle-length pox-info))) + +(define-private (is-in-voting-window (height uint) (reward-cycle uint)) + (let ((last-cycle (unwrap-panic (contract-call? .signers stackerdb-get-last-set-cycle)))) + (and (is-eq last-cycle reward-cycle) + (is-in-prepare-phase height)))) + +(define-public (vote-for-aggregate-public-key (signer-index uint) (key (buff 33)) (round uint)) + (let ((reward-cycle (+ u1 (burn-height-to-reward-cycle burn-block-height))) + (tally-key {reward-cycle: reward-cycle, round: round, aggregate-public-key: key}) + ;; one slot, one vote + (num-slots (try! (get-current-signer-slots signer-index))) + (new-total (+ num-slots (default-to u0 (map-get? tally tally-key))))) + (asserts! (is-in-voting-window burn-block-height reward-cycle) err-out-of-voting-window) + (asserts! (>= round (default-to u0 (map-get? rounds reward-cycle))) err-old-round) + (asserts! (is-eq (len key) u33) err-ill-formed-aggregate-public-key) + (asserts! (is-valid-aggregated-public-key key {reward-cycle: reward-cycle, round: round}) err-duplicate-aggregate-public-key) + (asserts! (map-insert votes {reward-cycle: reward-cycle, round: round, signer: tx-sender} {aggregate-public-key: key, reward-slots: num-slots}) err-duplicate-vote) + (map-set tally tally-key new-total) + (map-set used-aggregate-public-keys key {reward-cycle: reward-cycle, round: round}) + (update-last-round reward-cycle round) + (print "voted") + (ok true))) + +(define-private (update-last-round (reward-cycle uint) (round uint)) + (match (map-get? rounds reward-cycle) + last-round (and (> round last-round) (map-set rounds reward-cycle round)) + (map-set rounds reward-cycle round))) \ No newline at end of file diff --git a/stackslib/src/chainstate/stacks/boot/signers.clar b/stackslib/src/chainstate/stacks/boot/signers.clar index 42edf8ef0e..a901dc0f94 100644 --- a/stackslib/src/chainstate/stacks/boot/signers.clar +++ b/stackslib/src/chainstate/stacks/boot/signers.clar @@ -13,6 +13,10 @@ (define-read-only (stackerdb-get-signer-slots) (ok (var-get stackerdb-signer-slots))) +(define-read-only (stackerdb-get-signer-by-index (signer-index uint)) + (ok (element-at (var-get stackerdb-signer-slots) signer-index)) +) + (define-read-only (stackerdb-get-config) (ok { chunk-size: CHUNK_SIZE, @@ -21,3 +25,6 @@ max-neighbors: u32, hint-replicas: (list) } )) + +(define-read-only (stackerdb-get-last-set-cycle) + (ok (var-get last-set-cycle))) \ No newline at end of file diff --git a/stackslib/src/chainstate/stacks/boot/signers_tests.rs b/stackslib/src/chainstate/stacks/boot/signers_tests.rs index 42f80fb599..bcd47d4177 100644 --- a/stackslib/src/chainstate/stacks/boot/signers_tests.rs +++ b/stackslib/src/chainstate/stacks/boot/signers_tests.rs @@ -18,7 +18,9 @@ use clarity::vm::clarity::ClarityConnection; use clarity::vm::contexts::OwnedEnvironment; use clarity::vm::costs::LimitedCostTracker; use clarity::vm::tests::symbols_from_values; -use clarity::vm::types::{PrincipalData, QualifiedContractIdentifier, TupleData}; +use clarity::vm::types::{ + PrincipalData, QualifiedContractIdentifier, StacksAddressExtensions, TupleData, +}; use clarity::vm::Value::Principal; use clarity::vm::{ClarityName, ClarityVersion, ContractName, Value}; use stacks_common::address::AddressHashMode; @@ -44,7 +46,7 @@ use crate::chainstate::stacks::boot::pox_4_tests::{ use crate::chainstate::stacks::boot::test::{ instantiate_pox_peer_with_epoch, key_to_stacks_addr, make_pox_4_lockup, with_sortdb, }; -use crate::chainstate::stacks::boot::{NakamotoSignerEntry, SIGNERS_NAME}; +use crate::chainstate::stacks::boot::{NakamotoSignerEntry, SIGNERS_NAME, SIGNERS_VOTING_NAME}; use crate::chainstate::stacks::db::StacksChainState; use crate::chainstate::stacks::index::marf::MarfConnection; use crate::chainstate::stacks::{ @@ -197,8 +199,12 @@ fn signers_get_signer_keys_from_stackerdb() { let stacker_1 = TestStacker::from_seed(&[3, 4]); let stacker_2 = TestStacker::from_seed(&[5, 6]); - let (mut peer, test_signers, latest_block_id) = - prepare_signers_test(function_name!(), Some(vec![&stacker_1, &stacker_2])); + let (mut peer, test_signers, latest_block_id, _) = prepare_signers_test( + function_name!(), + vec![], + Some(vec![&stacker_1, &stacker_2]), + None, + ); let private_key = peer.config.private_key.clone(); @@ -239,13 +245,21 @@ fn signers_get_signer_keys_from_stackerdb() { assert_eq!(signers, expected_stackerdb_slots); } -fn prepare_signers_test<'a>( +pub fn prepare_signers_test<'a>( test_name: &str, + initial_balances: Vec<(PrincipalData, u64)>, stackers: Option>, -) -> (TestPeer<'a>, TestSigners, StacksBlockId) { + observer: Option<&'a TestEventObserver>, +) -> (TestPeer<'a>, TestSigners, StacksBlockId, u128) { let mut test_signers = TestSigners::default(); - let mut peer = boot_nakamoto(test_name, vec![], &test_signers, stackers); + let mut peer = boot_nakamoto( + test_name, + initial_balances, + &test_signers, + stackers, + observer, + ); let (burn_ops, mut tenure_change, miner_key) = peer.begin_nakamoto_tenure(TenureChangeCause::BlockFound); @@ -269,7 +283,30 @@ fn prepare_signers_test<'a>( ); let latest_block_id = blocks_and_sizes.last().unwrap().0.block_id(); - (peer, test_signers, latest_block_id) + let current_reward_cycle = readonly_call( + &mut peer, + &latest_block_id, + SIGNERS_VOTING_NAME.into(), + "current-reward-cycle".into(), + vec![], + ) + .expect_u128(); + + assert_eq!(current_reward_cycle, 7); + + let last_set_cycle = readonly_call( + &mut peer, + &latest_block_id, + SIGNERS_NAME.into(), + "stackerdb-get-last-set-cycle".into(), + vec![], + ) + .expect_result_ok() + .expect_u128(); + + assert_eq!(last_set_cycle, 7); + + (peer, test_signers, latest_block_id, current_reward_cycle) } fn advance_blocks( @@ -355,3 +392,33 @@ fn readonly_call( }) .unwrap() } + +pub fn get_signer_index( + peer: &mut TestPeer<'_>, + latest_block_id: StacksBlockId, + signer_address: StacksAddress, +) -> u128 { + let signers = readonly_call( + peer, + &latest_block_id, + "signers".into(), + "stackerdb-get-signer-slots".into(), + vec![], + ) + .expect_result_ok() + .expect_list(); + + signers + .iter() + .position(|value| { + value + .clone() + .expect_tuple() + .get("signer") + .unwrap() + .clone() + .expect_principal() + == signer_address.to_account_principal() + }) + .expect("signer not found") as u128 +} diff --git a/stackslib/src/chainstate/stacks/boot/signers_voting_tests.rs b/stackslib/src/chainstate/stacks/boot/signers_voting_tests.rs new file mode 100644 index 0000000000..f31ced3e06 --- /dev/null +++ b/stackslib/src/chainstate/stacks/boot/signers_voting_tests.rs @@ -0,0 +1,435 @@ +// Copyright (C) 2013-2020 Blockstack PBC, a public benefit corporation +// Copyright (C) 2020-2024 Stacks Open Internet Foundation +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +use std::collections::{HashMap, HashSet, VecDeque}; +use std::convert::{TryFrom, TryInto}; + +use clarity::boot_util::boot_code_addr; +use clarity::vm::clarity::ClarityConnection; +use clarity::vm::contexts::OwnedEnvironment; +use clarity::vm::contracts::Contract; +use clarity::vm::costs::{CostOverflowingMath, LimitedCostTracker}; +use clarity::vm::database::*; +use clarity::vm::errors::{ + CheckErrors, Error, IncomparableError, InterpreterError, InterpreterResult, RuntimeErrorType, +}; +use clarity::vm::eval; +use clarity::vm::events::StacksTransactionEvent; +use clarity::vm::representations::SymbolicExpression; +use clarity::vm::tests::{execute, is_committed, is_err_code, symbols_from_values}; +use clarity::vm::types::Value::Response; +use clarity::vm::types::{ + BuffData, OptionalData, PrincipalData, QualifiedContractIdentifier, ResponseData, SequenceData, + StacksAddressExtensions, StandardPrincipalData, TupleData, TupleTypeSignature, TypeSignature, + Value, NONE, +}; +use stacks_common::address::AddressHashMode; +use stacks_common::types::chainstate::{ + BlockHeaderHash, BurnchainHeaderHash, StacksAddress, StacksBlockId, VRFSeed, +}; +use stacks_common::types::Address; +use stacks_common::util::hash::{hex_bytes, to_hex, Sha256Sum, Sha512Trunc256Sum}; +use stacks_common::util::secp256k1::Secp256k1PrivateKey; +use wsts::curve::point::{Compressed, Point}; + +use super::test::*; +use super::RawRewardSetEntry; +use crate::burnchains::{Burnchain, PoxConstants}; +use crate::chainstate::burn::db::sortdb::{self, SortitionDB}; +use crate::chainstate::burn::operations::*; +use crate::chainstate::burn::{BlockSnapshot, ConsensusHash}; +use crate::chainstate::nakamoto::coordinator::tests::make_token_transfer; +use crate::chainstate::nakamoto::tests::get_account; +use crate::chainstate::nakamoto::tests::node::{TestSigners, TestStacker}; +use crate::chainstate::nakamoto::NakamotoBlock; +use crate::chainstate::stacks::address::{PoxAddress, PoxAddressType20, PoxAddressType32}; +use crate::chainstate::stacks::boot::pox_2_tests::{ + check_pox_print_event, generate_pox_clarity_value, get_reward_set_entries_at, + get_stacking_state_pox, get_stx_account_at, with_clarity_db_ro, PoxPrintFields, + StackingStateCheckData, +}; +use crate::chainstate::stacks::boot::pox_4_tests::{ + assert_latest_was_burn, get_last_block_sender_transactions, get_tip, make_test_epochs_pox, +}; +use crate::chainstate::stacks::boot::signers_tests::{get_signer_index, prepare_signers_test}; +use crate::chainstate::stacks::boot::{ + BOOT_CODE_COST_VOTING_TESTNET as BOOT_CODE_COST_VOTING, BOOT_CODE_POX_TESTNET, SIGNERS_NAME, + SIGNERS_VOTING_NAME, +}; +use crate::chainstate::stacks::db::{ + MinerPaymentSchedule, StacksChainState, StacksHeaderInfo, MINER_REWARD_MATURITY, +}; +use crate::chainstate::stacks::events::{StacksTransactionReceipt, TransactionOrigin}; +use crate::chainstate::stacks::index::marf::MarfConnection; +use crate::chainstate::stacks::index::MarfTrieId; +use crate::chainstate::stacks::tests::make_coinbase; +use crate::chainstate::{self, stacks::*}; +use crate::clarity_vm::clarity::{ClarityBlockConnection, Error as ClarityError}; +use crate::clarity_vm::database::marf::{MarfedKV, WritableMarfStore}; +use crate::clarity_vm::database::HeadersDBConn; +use crate::core::*; +use crate::net::test::{TestEventObserver, TestPeer}; +use crate::util_lib::boot::boot_code_id; +use crate::util_lib::db::{DBConn, FromRow}; + +pub fn prepare_pox4_test<'a>( + test_name: &str, + observer: Option<&'a TestEventObserver>, +) -> ( + Burnchain, + TestPeer<'a>, + Vec, + StacksBlockId, + u64, + usize, +) { + let (epochs, pox_constants) = make_test_epochs_pox(); + + let mut burnchain = Burnchain::default_unittest( + 0, + &BurnchainHeaderHash::from_hex(BITCOIN_REGTEST_FIRST_BLOCK_HASH).unwrap(), + ); + burnchain.pox_constants = pox_constants.clone(); + + let (mut peer, keys) = + instantiate_pox_peer_with_epoch(&burnchain, test_name, Some(epochs.clone()), observer); + + assert_eq!(burnchain.pox_constants.reward_slots(), 6); + let mut coinbase_nonce = 0; + + // Advance into pox4 + let target_height = burnchain.pox_constants.pox_4_activation_height; + let mut latest_block = peer.tenure_with_txs(&[], &mut coinbase_nonce); + while get_tip(peer.sortdb.as_ref()).block_height < u64::from(target_height) { + latest_block = peer.tenure_with_txs(&[], &mut coinbase_nonce); + // if we reach epoch 2.1, perform the check + if get_tip(peer.sortdb.as_ref()).block_height > epochs[3].start_height { + assert_latest_was_burn(&mut peer); + } + } + + let block_height = get_tip(peer.sortdb.as_ref()).block_height; + + info!("Block height: {}", block_height); + + ( + burnchain, + peer, + keys, + latest_block, + block_height, + coinbase_nonce, + ) +} + +/// In this test case, Alice votes in the first block of the first tenure of the prepare phase. +/// Alice can vote successfully. +/// A second vote on the same key and round fails with "duplicate vote" error +#[test] +fn vote_for_aggregate_public_key_in_first_block() { + let stacker_1 = TestStacker::from_seed(&[3, 4]); + let stacker_2 = TestStacker::from_seed(&[5, 6]); + let observer = TestEventObserver::new(); + + let signer = key_to_stacks_addr(&stacker_1.signer_private_key).to_account_principal(); + + let (mut peer, mut test_signers, latest_block_id, current_reward_cycle) = prepare_signers_test( + function_name!(), + vec![(signer, 1000)], + Some(vec![&stacker_1, &stacker_2]), + Some(&observer), + ); + + // create vote txs + + let signer_nonce = 0; + let signer_key = &stacker_1.signer_private_key; + let signer_address = key_to_stacks_addr(signer_key); + let signer_principal = PrincipalData::from(signer_address); + let cycle_id = current_reward_cycle; + + let signer_index = get_signer_index(&mut peer, latest_block_id, signer_address); + + let aggregate_public_key: Point = Point::new(); + let aggreagte_public_key_value = + Value::buff_from(aggregate_public_key.compress().data.to_vec()) + .expect("Failed to serialize aggregate public key"); + + let txs = vec![ + // cast a vote for the aggregate public key + make_signers_vote_for_aggregate_public_key( + signer_key, + signer_nonce, + signer_index, + &aggregate_public_key, + 0, + ), + // cast the vote twice + make_signers_vote_for_aggregate_public_key( + signer_key, + signer_nonce + 1, + signer_index, + &aggregate_public_key, + 0, + ), + ]; + + // + // vote in the first burn block of prepare phase + // + let blocks_and_sizes = nakamoto_tenure(&mut peer, &mut test_signers, vec![txs], signer_key); + + // check the last two txs in the last block + let block = observer.get_blocks().last().unwrap().clone(); + let receipts = block.receipts.as_slice(); + assert_eq!(receipts.len(), 4); + // ignore tenure change tx + // ignore tenure coinbase tx + + // first vote should succeed + let tx1 = &receipts[receipts.len() - 2]; + assert_eq!( + tx1.result, + Value::Response(ResponseData { + committed: true, + data: Box::new(Value::Bool(true)) + }) + ); + + // second vote should fail with duplicate vote error + let tx2 = &receipts[receipts.len() - 1]; + assert_eq!( + tx2.result, + Value::Response(ResponseData { + committed: false, + data: Box::new(Value::UInt(10006)) // err-duplicate-vote + }) + ); +} + +/// In this test case, Alice votes in the first block of the last tenure of the prepare phase. +/// Bob votes in the second block of that tenure. +/// Alice can vote successfully. +/// Bob is out of the voting window. +#[test] +fn vote_for_aggregate_public_key_in_last_block() { + let stacker_1 = TestStacker::from_seed(&[3, 4]); + let stacker_2 = TestStacker::from_seed(&[5, 6]); + let observer = TestEventObserver::new(); + + let signer_1 = key_to_stacks_addr(&stacker_1.signer_private_key).to_account_principal(); + let signer_2 = key_to_stacks_addr(&stacker_2.signer_private_key).to_account_principal(); + + let (mut peer, mut test_signers, latest_block_id, current_reward_cycle) = prepare_signers_test( + function_name!(), + vec![(signer_1, 1000), (signer_2, 1000)], + Some(vec![&stacker_1, &stacker_2]), + Some(&observer), + ); + + let mut stacker_1_nonce: u64 = 1; + let dummy_tx_1 = make_dummy_tx( + &mut peer, + &stacker_1.stacker_private_key, + &mut stacker_1_nonce, + ); + let dummy_tx_2 = make_dummy_tx( + &mut peer, + &stacker_1.stacker_private_key, + &mut stacker_1_nonce, + ); + let dummy_tx_3 = make_dummy_tx( + &mut peer, + &stacker_1.stacker_private_key, + &mut stacker_1_nonce, + ); + + let cycle_id: u128 = current_reward_cycle; + let aggregated_public_key: Point = Point::new(); + + // create vote txs for alice + let signer_1_nonce = 0; + let signer_1_key = &stacker_1.signer_private_key; + let signer_1_address = key_to_stacks_addr(signer_1_key); + let signer_1_principal = PrincipalData::from(signer_1_address); + let signer_1_index = get_signer_index(&mut peer, latest_block_id, signer_1_address); + + let txs_1 = vec![ + // cast a vote for the aggregate public key + make_signers_vote_for_aggregate_public_key( + signer_1_key, + signer_1_nonce, + signer_1_index, + &aggregated_public_key, + 0, + ), + // cast the vote twice + make_signers_vote_for_aggregate_public_key( + signer_1_key, + signer_1_nonce + 1, + signer_1_index, + &aggregated_public_key, + 0, + ), + ]; + + // create vote txs for bob + let signer_2_nonce = 0; + let signer_2_key = &stacker_2.signer_private_key; + let signer_2_address = key_to_stacks_addr(signer_2_key); + let signer_2_principal = PrincipalData::from(signer_2_address); + let signer_2_index = get_signer_index(&mut peer, latest_block_id, signer_2_address); + + let txs_2 = vec![ + // cast a vote for the aggregate public key + make_signers_vote_for_aggregate_public_key( + signer_2_key, + signer_2_nonce, + signer_2_index, + &aggregated_public_key, + 0, + ), + ]; + + // + // vote in the last burn block of prepare phase + // + + nakamoto_tenure( + &mut peer, + &mut test_signers, + vec![vec![dummy_tx_1]], + signer_1_key, + ); + + nakamoto_tenure( + &mut peer, + &mut test_signers, + vec![vec![dummy_tx_2]], + signer_1_key, + ); + + // alice votes in first block of tenure + // bob votes in second block of tenure + let blocks_and_sizes = nakamoto_tenure( + &mut peer, + &mut test_signers, + vec![txs_1, txs_2], + signer_1_key, + ); + + // check alice's and bob's txs + let blocks = observer.get_blocks(); + // alice's block + let block = &blocks[blocks.len() - 2].clone(); + let receipts = &block.receipts; + assert_eq!(receipts.len(), 4); + + // first vote should succeed + let tx1 = &receipts[receipts.len() - 2]; + assert_eq!( + tx1.result, + Value::Response(ResponseData { + committed: true, + data: Box::new(Value::Bool(true)) + }) + ); + + // second vote should fail with duplicate vote error + let tx2 = &receipts[receipts.len() - 1]; + assert_eq!( + tx2.result, + Value::Response(ResponseData { + committed: false, + data: Box::new(Value::UInt(10006)) // err-duplicate-vote + }) + ); + + // bob's block + let block = blocks.last().unwrap().clone(); + let receipts = block.receipts.as_slice(); + assert_eq!(receipts.len(), 1); + + // vote should succeed + let tx1 = &receipts[receipts.len() - 1]; + assert_eq!( + tx1.result, + Value::Response(ResponseData { + committed: false, + data: Box::new(Value::UInt(10002)) // err-out-of-voting-window + }) + ); +} + +fn nakamoto_tenure( + peer: &mut TestPeer, + test_signers: &mut TestSigners, + txs_of_blocks: Vec>, + stacker_private_key: &StacksPrivateKey, +) -> Vec<(NakamotoBlock, u64, ExecutionCost)> { + let current_height = peer.get_burnchain_view().unwrap().burn_block_height; + + info!("current height: {}", current_height); + + let (burn_ops, mut tenure_change, miner_key) = + peer.begin_nakamoto_tenure(TenureChangeCause::BlockFound); + + let (_, _, consensus_hash) = peer.next_burnchain_block(burn_ops); + + let vrf_proof = peer.make_nakamoto_vrf_proof(miner_key); + + tenure_change.tenure_consensus_hash = consensus_hash.clone(); + tenure_change.burn_view_consensus_hash = consensus_hash.clone(); + let tenure_change_tx = peer + .miner + .make_nakamoto_tenure_change(tenure_change.clone()); + let coinbase_tx = peer.miner.make_nakamoto_coinbase(None, vrf_proof); + let recipient_addr = boot_code_addr(false); + let mut mutable_txs_of_blocks = txs_of_blocks.clone(); + mutable_txs_of_blocks.reverse(); + let blocks_and_sizes = peer.make_nakamoto_tenure( + tenure_change_tx, + coinbase_tx.clone(), + test_signers, + |miner, chainstate, sortdb, blocks| mutable_txs_of_blocks.pop().unwrap_or(vec![]), + ); + info!("tenure length {}", blocks_and_sizes.len()); + blocks_and_sizes +} + +fn make_dummy_tx( + peer: &mut TestPeer, + private_key: &StacksPrivateKey, + nonce: &mut u64, +) -> StacksTransaction { + peer.with_db_state(|sortdb, chainstate, _, _| { + let addr = key_to_stacks_addr(&private_key); + let account = get_account(chainstate, sortdb, &addr); + let recipient_addr = boot_code_addr(false); + let stx_transfer = make_token_transfer( + chainstate, + sortdb, + &private_key, + *nonce, + 1, + 1, + &recipient_addr, + ); + *nonce += 1; + Ok(stx_transfer) + }) + .unwrap() +} diff --git a/stackslib/src/clarity_vm/clarity.rs b/stackslib/src/clarity_vm/clarity.rs index ca898da558..0c19aeb6cf 100644 --- a/stackslib/src/clarity_vm/clarity.rs +++ b/stackslib/src/clarity_vm/clarity.rs @@ -49,6 +49,7 @@ use crate::chainstate::stacks::boot::{ BOOT_TEST_POX_4_AGG_KEY_CONTRACT, BOOT_TEST_POX_4_AGG_KEY_FNAME, COSTS_2_NAME, COSTS_3_NAME, MINERS_NAME, POX_2_MAINNET_CODE, POX_2_NAME, POX_2_TESTNET_CODE, POX_3_MAINNET_CODE, POX_3_NAME, POX_3_TESTNET_CODE, POX_4_CODE, POX_4_NAME, SIGNERS_BODY, SIGNERS_NAME, + SIGNERS_VOTING_NAME, SIGNER_VOTING_CODE, }; use crate::chainstate::stacks::db::{StacksAccount, StacksChainState}; use crate::chainstate::stacks::events::{StacksTransactionEvent, StacksTransactionReceipt}; @@ -1436,7 +1437,7 @@ impl<'a, 'b> ClarityBlockConnection<'a, 'b> { &boot_code_account, ASTRules::PrecheckSize, ) - .expect("FATAL: Failed to process .miners contract initialization"); + .expect("FATAL: Failed to process .signers contract initialization"); receipt }); @@ -1449,6 +1450,43 @@ impl<'a, 'b> ClarityBlockConnection<'a, 'b> { ); } + let signers_voting_code = &*SIGNER_VOTING_CODE; + let signers_voting_contract_id = boot_code_id(SIGNERS_VOTING_NAME, mainnet); + let payload = TransactionPayload::SmartContract( + TransactionSmartContract { + name: ContractName::try_from(SIGNERS_VOTING_NAME) + .expect("FATAL: invalid boot-code contract name"), + code_body: StacksString::from_str(signers_voting_code) + .expect("FATAL: invalid boot code body"), + }, + Some(ClarityVersion::Clarity2), + ); + + let signers_contract_tx = + StacksTransaction::new(tx_version.clone(), boot_code_auth.clone(), payload); + + let signers_voting_initialization_receipt = self.as_transaction(|tx_conn| { + // initialize with a synthetic transaction + debug!("Instantiate {} contract", &signers_voting_contract_id); + let receipt = StacksChainState::process_transaction_payload( + tx_conn, + &signers_contract_tx, + &boot_code_account, + ASTRules::PrecheckSize, + ) + .expect("FATAL: Failed to process .signers-voting contract initialization"); + receipt + }); + + if signers_voting_initialization_receipt.result != Value::okay_true() + || signers_voting_initialization_receipt.post_condition_aborted + { + panic!( + "FATAL: Failure processing signers-voting contract initialization: {:#?}", + &signers_voting_initialization_receipt + ); + } + debug!("Epoch 2.5 initialized"); (old_cost_tracker, Ok(vec![pox_4_initialization_receipt])) })