diff --git a/Justfile b/Justfile new file mode 100644 index 000000000..723134513 --- /dev/null +++ b/Justfile @@ -0,0 +1,28 @@ +docker_tag := "latest" +docker_registry := "localhost:5000" +docker_image := docker_registry + "/subnet-node:" + docker_tag + +# Print help message +help: + @just --list --unsorted + @echo "" + @echo "Available variables and default values:" + @just --evaluate + +# Build subnets node +build *args: + #!/usr/bin/env bash + pushd testnet/stacks-node + cargo build {{args}} + popd + +# Build release version subnets node +build-release: (build "--features" "monitoring_prom,slog_json" "--release") + +# Build docker image +docker-build: + docker build -t {{docker_image}} . + +# Build and push docker image +docker-push: docker-build + docker push {{docker_image}} \ No newline at end of file diff --git a/core-contracts/contracts/multi-miner.clar b/core-contracts/contracts/multi-miner.clar index 932c292ad..787e9e926 100644 --- a/core-contracts/contracts/multi-miner.clar +++ b/core-contracts/contracts/multi-miner.clar @@ -6,6 +6,7 @@ (define-constant ERR_INVALID_SIGNATURE 103) (define-constant ERR_UNAUTHORIZED_CONTRACT_CALLER 104) (define-constant ERR_MINER_ALREADY_SET 105) +(define-constant ERR_UNSUPPORTED_SUBNET_CONTRACT_VERSION 106) ;; SIP-018 Constants (define-constant sip18-prefix 0x534950303138) @@ -19,6 +20,33 @@ ;; List of miners (define-data-var miners (optional (list 10 principal)) none) +;; Minimun version of subnet contract required +(define-constant SUBNET_CONTRACT_VERSION_MIN { + major: 2, + minor: 0, + patch: 0, +}) + +;; Return error if subnet contract version not supported +(define-read-only (check-subnet-contract-version) ( + let ( + (subnet-contract-version (contract-call? .subnet-v2-0-0 get-version)) + ) + + ;; Check subnet contract version is greater than min supported version + (asserts! (is-eq (get major subnet-contract-version) (get major SUBNET_CONTRACT_VERSION_MIN)) (err ERR_UNSUPPORTED_SUBNET_CONTRACT_VERSION)) + (asserts! (>= (get minor subnet-contract-version) (get minor SUBNET_CONTRACT_VERSION_MIN)) (err ERR_UNSUPPORTED_SUBNET_CONTRACT_VERSION)) + ;; Only check patch version if major and minor version are equal + (asserts! (or + (not (is-eq (get minor subnet-contract-version) (get minor SUBNET_CONTRACT_VERSION_MIN))) + (>= (get patch subnet-contract-version) (get patch SUBNET_CONTRACT_VERSION_MIN))) + (err ERR_UNSUPPORTED_SUBNET_CONTRACT_VERSION)) + (ok true) +)) + +;; Fail if the subnet contract is not compatible +(try! (check-subnet-contract-version)) + (define-private (get-miners) (unwrap-panic (var-get miners))) @@ -83,6 +111,6 @@ ;; check that we have enough signatures (try! (check-miners (append (get signers signer-principals) tx-sender))) ;; execute the block commit - (as-contract (contract-call? .subnet commit-block (get block block-data) (get subnet-block-height block-data) (get target-tip block-data) (get withdrawal-root block-data))) + (as-contract (contract-call? .subnet-v2-0-0 commit-block (get block block-data) (get subnet-block-height block-data) (get target-tip block-data) (get withdrawal-root block-data))) ) ) diff --git a/core-contracts/contracts/subnet.clar b/core-contracts/contracts/subnet.clar index 56be369c8..ce1d93e08 100644 --- a/core-contracts/contracts/subnet.clar +++ b/core-contracts/contracts/subnet.clar @@ -2,6 +2,18 @@ (define-constant CONTRACT_ADDRESS (as-contract tx-sender)) +;; Versioning of this contract +;; Must follow Semver rules: https://semver.org/ +;; NOTE: Versioning was added as of `2.0.0` +;; NOTE: Contract should be deployed with name matching version here +(define-constant VERSION { + major: 2, + minor: 0, + patch: 0, + prerelease: "", + metadata: "" +}) + ;; Error codes (define-constant ERR_BLOCK_ALREADY_COMMITTED 1) (define-constant ERR_INVALID_MINER 2) @@ -44,6 +56,12 @@ (use-trait ft-trait 'SP3FBR2AGK5H9QBDH3EEN6DF8EK8JY7RX8QJ5SVTE.sip-010-trait-ft-standard.sip-010-trait) (use-trait mint-from-subnet-trait .subnet-traits.mint-from-subnet-trait) +;; Get the version of this contract +;; Returns a tuple containing the 5 Semver fields: major, minor, patch, prerelease, and metadata +(define-read-only (get-version) + VERSION +) + ;; Update the miner for this contract. (define-public (update-miner (new-miner principal)) (begin diff --git a/testnet/stacks-node/src/burnchains/l1_events.rs b/testnet/stacks-node/src/burnchains/l1_events.rs index 1f911e01c..004ee5be2 100644 --- a/testnet/stacks-node/src/burnchains/l1_events.rs +++ b/testnet/stacks-node/src/burnchains/l1_events.rs @@ -13,8 +13,10 @@ use stacks::chainstate::stacks::miner::SignedProposal; use stacks::chainstate::stacks::StacksTransaction; use stacks::codec::StacksMessageCodec; use stacks::core::StacksEpoch; +use stacks::net::CallReadOnlyRequestBody; use stacks::util::hash::hex_bytes; use stacks::util::sleep_ms; +use stacks::util_lib::boot::boot_code_addr; use stacks_common::types::chainstate::{BlockHeaderHash, BurnchainHeaderHash, StacksBlockId}; use super::commitment::{Layer1Committer, MultiPartyCommitter}; @@ -46,6 +48,28 @@ pub struct L1Controller { chain_tip: Option, committer: Box, + + l1_contract_check_passed: bool, +} + +/// Semver version of a Clarity contract +#[derive(Deserialize, Serialize)] +pub struct ContractVersion { + major: u32, + minor: u32, + patch: u32, + prerelease: Option, + metadata: Option, +} + +/// Response from read-only function +#[derive(Deserialize, Serialize)] +pub struct GetVersionResponse { + okay: bool, + /// Response will contain `result` on success + result: Option, + /// Response will contain `cause` on failure + cause: Option, } impl L1Channel { @@ -109,7 +133,7 @@ impl L1Controller { other_participants.clone(), )), }; - Ok(L1Controller { + let l1_controller = L1Controller { burnchain, config, indexer, @@ -119,7 +143,9 @@ impl L1Controller { coordinator, chain_tip: None, committer, - }) + l1_contract_check_passed: false, + }; + Ok(l1_controller) } fn receive_blocks( @@ -227,6 +253,91 @@ impl L1Controller { Err(Error::RPCError(res.text()?)) } } + + /// Return the Semver version of the `subnet.clar` contract this node is configured to use + fn get_l1_contract_version(&self) -> Result { + let burn_conf = &self.config.burnchain; + let url = format!( + "{http_origin}/v2/contracts/call-read/{contract_addr}/{contract}/get-version", + http_origin = self.l1_rpc_interface(), + contract_addr = burn_conf.contract_identifier.issuer, + contract = burn_conf.contract_identifier.name, + ); + + let body = CallReadOnlyRequestBody { + sender: boot_code_addr(self.config.is_mainnet()).to_string(), + arguments: Vec::default(), + }; + + let response = reqwest::blocking::Client::new() + .post(url) + .header("Content-Type", "application/octet-stream") + .json(&body) + .send()? + .error_for_status()? + .json::() + .map_err(Error::from)?; + + if !response.okay { + let message = response + .cause + .unwrap_or_else(|| "Unknown contract error".to_string()); + return Err(Error::RPCError(message)); + } + + match response.result { + Some(r) => Ok(r), + None => Err(Error::RPCError("Empty result".to_string())), + } + } + + /// Check that the version of `subnet.clar` the node is configured to use is supported + fn check_l1_contract_version(&self) -> Result<(), Error> { + const EXACT_MAJOR_VERSION: u32 = 2; + const MINIMUM_MINOR_VERSION: u32 = 0; + const MINIMUM_PATCH_VERSION: u32 = 0; + let ContractVersion { + major, + minor, + patch, + .. + } = self.get_l1_contract_version()?; + + if major != EXACT_MAJOR_VERSION { + let msg = format!("Major version must be {EXACT_MAJOR_VERSION} (found {major})"); + return Err(Error::UnsupportedBurnchainContract(msg)); + }; + if minor < MINIMUM_MINOR_VERSION { + let msg = + format!("Minor version must be at least {MINIMUM_MINOR_VERSION} (found {minor})"); + return Err(Error::UnsupportedBurnchainContract(msg)); + }; + if minor == MINIMUM_MINOR_VERSION && patch < MINIMUM_PATCH_VERSION { + let msg = + format!("Patch version must be at least {MINIMUM_PATCH_VERSION} (found {patch})"); + return Err(Error::UnsupportedBurnchainContract(msg)); + }; + Ok(()) + } + + /// Check that the version of `subnet.clar` the node is configured to use is supported + fn l1_contract_ok(&mut self) -> Result<(), Error> { + match self.l1_contract_check_passed { + true => Ok(()), + false => match self.check_l1_contract_version() { + // This error is fatal. We can't continue with wrong contract version + Err(Error::UnsupportedBurnchainContract(e)) => { + panic!("Unsupported burnchain contract version: {e}") + } + // Error, but not fatal + Err(e) => Err(e), + Ok(_) => { + self.l1_contract_check_passed = true; + Ok(()) + } + }, + } + } } impl BurnchainController for L1Controller { @@ -270,6 +381,8 @@ impl BurnchainController for L1Controller { op_signer: &mut BurnchainOpSigner, attempt: u64, ) -> Result { + self.l1_contract_ok()?; + let tx = self.committer.make_commit_tx( committed_block_hash, committed_block_height, diff --git a/testnet/stacks-node/src/burnchains/mod.rs b/testnet/stacks-node/src/burnchains/mod.rs index 44ba747b4..799ea1a20 100644 --- a/testnet/stacks-node/src/burnchains/mod.rs +++ b/testnet/stacks-node/src/burnchains/mod.rs @@ -41,6 +41,8 @@ mod tests; #[derive(Debug)] pub enum Error { UnsupportedBurnchain(String), + /// Problem with contract deployed on burnchain + UnsupportedBurnchainContract(String), CoordinatorClosed, IndexerError(burnchains::Error), RPCError(String), @@ -51,12 +53,15 @@ impl fmt::Display for Error { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { match self { Error::UnsupportedBurnchain(ref chain_name) => { - write!(f, "Burnchain is not supported: {:?}", chain_name) + write!(f, "Burnchain is not supported: {chain_name:?}") + } + Error::UnsupportedBurnchainContract(ref msg) => { + write!(f, "Burnchain contract is not supported: {msg}") } Error::CoordinatorClosed => write!(f, "ChainsCoordinator closed"), - Error::IndexerError(ref e) => write!(f, "Indexer error: {:?}", e), - Error::RPCError(ref e) => write!(f, "ControllerError(RPCError: {})", e), - Error::BadCommitment(ref e) => write!(f, "ControllerError(BadCommitment: {}))", e), + Error::IndexerError(ref e) => write!(f, "Indexer error: {e:?}"), + Error::RPCError(ref e) => write!(f, "ControllerError(RPCError: {e})"), + Error::BadCommitment(ref e) => write!(f, "ControllerError(BadCommitment: {e}))"), } } } diff --git a/testnet/stacks-node/src/neon_node.rs b/testnet/stacks-node/src/neon_node.rs index 317f5fb5c..6a02d6849 100644 --- a/testnet/stacks-node/src/neon_node.rs +++ b/testnet/stacks-node/src/neon_node.rs @@ -817,7 +817,7 @@ fn spawn_miner_relayer( Vec<(AssembledAnchorBlock, Secp256k1PrivateKey)>, > = HashMap::new(); - let mut bitcoin_controller = config + let mut burnchain_controller = config .make_burnchain_controller(coord_comms.clone()) .expect("couldn't create burnchain controller"); let mut microblock_miner_state: Option = None; @@ -1031,7 +1031,7 @@ fn spawn_miner_relayer( burn_tenure_snapshot, &mut keychain, &mut mem_pool, - &mut *bitcoin_controller, + &mut *burnchain_controller, &last_mined_blocks_vec.iter().map(|(blk, _)| blk).collect(), &event_dispatcher, ); @@ -1562,7 +1562,7 @@ impl StacksNode { burn_block: BlockSnapshot, keychain: &mut Keychain, mem_pool: &mut MemPoolDB, - bitcoin_controller: &mut (dyn BurnchainController + Send), + burnchain_controller: &mut (dyn BurnchainController + Send), last_mined_blocks: &Vec<&AssembledAnchorBlock>, event_dispatcher: &EventDispatcher, ) -> Option<(AssembledAnchorBlock, Secp256k1PrivateKey)> { @@ -1901,7 +1901,7 @@ impl StacksNode { let withdrawal_merkle_root = anchored_block.header.withdrawal_merkle_root; let mut op_signer = keychain.generate_op_signer(); - let required_signatures = bitcoin_controller.commit_required_signatures(); + let required_signatures = burnchain_controller.commit_required_signatures(); let signatures = if required_signatures > 0 { // if we need to collect signatures, assemble the proposal and send to other participants let proposal = Proposal { @@ -1928,7 +1928,7 @@ impl StacksNode { (0..required_signatures) .filter_map(|participant_index| { - match bitcoin_controller.propose_block(participant_index, &proposal) { + match burnchain_controller.propose_block(participant_index, &proposal) { Ok(signature) => Some(signature), Err(rejection) => { warn!("Failed to obtain approval"; "error" => %rejection); @@ -2001,7 +2001,7 @@ impl StacksNode { "attempt" => attempt ); - let res = bitcoin_controller.submit_commit( + let res = burnchain_controller.submit_commit( committed_block_hash, block_height, target_burn_hash,