diff --git a/.gitignore b/.gitignore index 1bd8742..198b1eb 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,2 @@ docker/chainstate .current-chainstate-dir - diff --git a/Makefile b/Makefile index 01f905d..fb2006f 100644 --- a/Makefile +++ b/Makefile @@ -106,7 +106,7 @@ up: check-not-running | build $(CHAINSTATE_DIR) genesis: check-not-running | build $(CHAINSTATE_DIR) @echo "Starting $(PROJECT) network from genesis" @echo " OS: $(OS)" - @[ -d "$(CHAINSTATE_DIR)" ] && { echo " Removing existing genesis chainstate dir: $(CHAINSTATE_DIR)"; sudo rm -rf $(CHAINSTATE_DIR); } + @[ -d "$(CHAINSTATE_DIR)" ] && { echo " Removing existing genesis chainstate dir: $(CHAINSTATE_DIR)"; rm -rf "$(CHAINSTATE_DIR)"; } @echo " Chainstate Dir: $(CHAINSTATE_DIR)" mkdir -p "$(CHAINSTATE_DIR)" echo "$(CHAINSTATE_DIR)" > .current-chainstate-dir diff --git a/README.md b/README.md index 8b8dc93..a801e4f 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,7 @@ - bind-mounts a local filesystem for data persistence - Uses a chainstate archive to boot the network quickly - Configurable signing weight across the 3 signers +- Configured for Epoch 4.0 / PoX-5 using the PoX waterfall integration branch - Designed to run on Linux (tested on Debian-based) or MacOS ## Quickstart @@ -47,6 +48,37 @@ make down ## Full list of options +### Epoch 4.0 / PoX-5 + +Hacknet defaults to the Stacks Core PoX-5 integration SHA configured in `docker/docker-compose.yml`. +The main PoX-5 controls are: + +- `STACKS_CORE_BASE_BRANCH`: branch, tag, or commit SHA used to build `stacks-node` and `stacks-signer` +- `STACKS_40_HEIGHT`: Epoch 4.0 activation height, default `262` +- `POX_5_DEPLOYER_PRIVATE_KEY`: funded key that deploys PoX-5 prerequisite sBTC contracts +- `POX_5_SBTC_CONTRACT`: contract id used by `.pox-5` for the sBTC token +- `POX_5_SBTC_REGISTRY_CONTRACT`: contract id used by signer-set computation for the sBTC aggregate key +- `POX_5_BOND_ADMIN`: principal initialized as PoX-5 bond admin +- `POX_5_BOND_ADMIN_PRIVATE_KEY`: key used by the opt-in Bitcoin Staking helper to call `setup-bond` + +Changing these values requires a fresh genesis run and a regenerated chainstate archive. + +### Bitcoin Staking + +Bitcoin Staking is available as an opt-in compose profile: + +```sh +docker compose -f docker/docker-compose.yml --profile default --profile bitcoin-staking up bitcoin-staking +``` + +The helper waits for `.pox-5`, waits for signer-manager registration, sets up an available protocol bond, mints mock sBTC to the configured participants, and registers those participants through `register-for-bond` using the sBTC path. + +- `BITCOIN_STAKING_KEYS`: funded participant keys, defaulting to accounts that are not used by `tx-broadcaster` +- `BITCOIN_STAKING_BOND_INDEX`: first bond index to try, default `0` +- `BITCOIN_STAKING_AMOUNT_USTX`: STX to lock per participant, default `99000000000000` +- `BITCOIN_STAKING_AMOUNT_SATS`: sBTC sats to bond per participant, default `1000000` +- `BITCOIN_STAKING_TARGET_RATE`, `BITCOIN_STAKING_STX_VALUE_RATIO`, `BITCOIN_STAKING_MIN_USTX_RATIO`: PoX-5 bond parameters + ### Logs `docker logs -f ` will work, along with some defined Makefile targets @@ -227,7 +259,9 @@ make down-prom - **stacks-signer-3**: event observer for stacks-miner-3 - **stacks-api**: API instance receiving events from stacks-miner-1 - **postgres**: postgres DB used by stacks-api +- **pox5-setup**: deploys `sbtc-registry` and `sbtc-token` before Epoch 4.0 so `.pox-5` can initialize - **stacker**: stack for `stacks-signer-1`, `stacks-signer-2` and `stacks-signer-3` +- **bitcoin-staking**: optional helper that configures a PoX-5 protocol bond and registers funded participants with mock sBTC - **tx-broadcaster**: submits token transfer txs to ensure stacks block production during a sortition ## Bitcoin Miner @@ -363,6 +397,34 @@ _Dedicated address for Bitcoin block production after initial setup (~200 blocks ‣ WIF: cMz2ZSsaVgWPFUkE44zHpJepB4NdwB9L938h53hQfFoot81AZFb3 ``` +## Bitcoin Staking Accounts + +Funded accounts used by the optional `bitcoin-staking` profile. These are separate from the transaction-generation accounts to avoid nonce contention with `tx-broadcaster`. + +### Bitcoin Staking 1 + +```text +‣ Private Key: d9493653ea195060a599a356bd8381f70f52f007827dd25e7486d14e5197157801 +‣ Public Key: 020d6085b50919598386c7e96a252283420eecdf7cbaca4b968e90295e386c2028 +‣ STX Address: ST2N30Q9PQPPPTBFYN4WN7KF3N2KRHZA9QFAABWP4 +``` + +### Bitcoin Staking 2 + +```text +‣ Private Key: c47a4264c2bd5b20ddb2ee3f731118555ecf41c63befd998d3bff89f204c0c9701 +‣ Public Key: 02303e0b336ce291e545558385521eaab845653ef4d9c7afa8fd445c15f39d6da2 +‣ STX Address: ST3ZPZ54BV6BARTSGGM05PCJ5HA0XRAHYK3T8RSM8 +``` + +### Bitcoin Staking 3 + +```text +‣ Private Key: 7b546d17e6d8aea6692f456c45489f218700eb3074a11f1a01f9c8c2cdf2a98901 +‣ Public Key: 0249cd888fc2e95faf7c82f83f79e20c5e8ef60829edd93ded57ef90542d794909 +‣ STX Address: ST3AY3W1J4F67C5VSD8AZYD6N10P5M8SYT8WQWV40 +``` + ## Testing Accounts _Unused but funded accounts that may be used to deploy contracts or other txs_ diff --git a/docker/chainstate.tar.zstd b/docker/chainstate.tar.zstd index 0cfb5e6..baa835d 100644 Binary files a/docker/chainstate.tar.zstd and b/docker/chainstate.tar.zstd differ diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 55378cb..311c7d2 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -62,7 +62,7 @@ x-common-vars: - &BITCOIN_MINER_IP 10.0.0.251 # External Docker Images - - &IMAGE_STACKS_API hirosystems/stacks-blockchain-api:8.13.6@sha256:f23e1a0f5f288605c39f6a6c8dab50bf6b9ab2f723ebaae5ad89b9a903642462 + - &IMAGE_STACKS_API hirosystems/stacks-blockchain-api:9.0.0-pox5.8 - &IMAGE_POSTGRES postgres:16.6-bookworm@sha256:c965017e1d29eb03e18a11abc25f5e3cd78cb5ac799d495922264b8489d5a3a1 - &IMAGE_BITCOIN bitcoin/bitcoin:25.2@sha256:14b4777166cba8de36b62ce72801038760a8f490122781b66d40592c8c69ebda @@ -88,13 +88,28 @@ x-common-vars: - &STACKS_32_HEIGHT ${STACKS_32_HEIGHT:-234} - &STACKS_33_HEIGHT ${STACKS_33_HEIGHT:-235} - &STACKS_34_HEIGHT ${STACKS_34_HEIGHT:-242} + - &STACKS_40_HEIGHT ${STACKS_40_HEIGHT:-262} + - &STACKS_CHAIN_ID ${STACKS_CHAIN_ID:-2147483648} - &STACKING_CYCLES ${STACKING_CYCLES:-1} # number of cycles to stack-stx or stack-extend for - &POX_PREPARE_LENGTH ${POX_PREPARE_LENGTH:-5} - &POX_REWARD_LENGTH ${POX_REWARD_LENGTH:-20} - &REWARD_RECIPIENT_1 ${REWARD_RECIPIENT_1:-ST1XVSVQN0KP5SDYFNT8E5TXWVW0XZVQEDBMCJ3XM} # priv: a6143d20cd73d0dce2179e2af7771372a95b9d6795924492bd4d15d17709531e01 - &REWARD_RECIPIENT_2 ${REWARD_RECIPIENT_2:-ST2FW15NGB4H76FMVXKHYYSM865YVS6V3SA1GNABC} # priv: fe3087801196d8027008146b13e6d365920c2e4b7bc9969729ec2f0f22ef74fc01 - &REWARD_RECIPIENT_3 ${REWARD_RECIPIENT_3:-ST2MES40ZEXTX9M4YXW9QSWHRVC9HYT419S198VPM} # priv: ed7eb063c61b8e892987228f1fcfb74eab5009568861613dc4b074b708a7893701 - - &STACKS_CORE_BASE_BRANCH ${STACKS_CORE_BASE_BRANCH:-3.4.0.0.2} # branch, tag, or commit SHA + - &POX_5_DEPLOYER_PRIVATE_KEY ${POX_5_DEPLOYER_PRIVATE_KEY:-27e27a9c242bcf79784bb8b19c8d875e23aaf65c132d54a47c84e1a5a67bc62601} + - &POX_5_DEPLOYER_ADDRESS ${POX_5_DEPLOYER_ADDRESS:-ST2SBXRBJJTH7GV5J93HJ62W2NRRQ46XYBK92Y039} + - &POX_5_SBTC_CONTRACT ${POX_5_SBTC_CONTRACT:-ST2SBXRBJJTH7GV5J93HJ62W2NRRQ46XYBK92Y039.sbtc-token} + - &POX_5_SBTC_REGISTRY_CONTRACT ${POX_5_SBTC_REGISTRY_CONTRACT:-ST2SBXRBJJTH7GV5J93HJ62W2NRRQ46XYBK92Y039.sbtc-registry} + - &POX_5_BOND_ADMIN ${POX_5_BOND_ADMIN:-ST2SBXRBJJTH7GV5J93HJ62W2NRRQ46XYBK92Y039} + - &POX_5_BOND_ADMIN_PRIVATE_KEY ${POX_5_BOND_ADMIN_PRIVATE_KEY:-27e27a9c242bcf79784bb8b19c8d875e23aaf65c132d54a47c84e1a5a67bc62601} + - &BITCOIN_STAKING_KEYS ${BITCOIN_STAKING_KEYS:-d9493653ea195060a599a356bd8381f70f52f007827dd25e7486d14e5197157801,c47a4264c2bd5b20ddb2ee3f731118555ecf41c63befd998d3bff89f204c0c9701,7b546d17e6d8aea6692f456c45489f218700eb3074a11f1a01f9c8c2cdf2a98901} + - &BITCOIN_STAKING_BOND_INDEX ${BITCOIN_STAKING_BOND_INDEX:-0} + - &BITCOIN_STAKING_AMOUNT_USTX ${BITCOIN_STAKING_AMOUNT_USTX:-99000000000000} + - &BITCOIN_STAKING_AMOUNT_SATS ${BITCOIN_STAKING_AMOUNT_SATS:-1000000} + - &BITCOIN_STAKING_TARGET_RATE ${BITCOIN_STAKING_TARGET_RATE:-1000} + - &BITCOIN_STAKING_STX_VALUE_RATIO ${BITCOIN_STAKING_STX_VALUE_RATIO:-100} + - &BITCOIN_STAKING_MIN_USTX_RATIO ${BITCOIN_STAKING_MIN_USTX_RATIO:-10000} + - &STACKS_CORE_BASE_BRANCH ${STACKS_CORE_BASE_BRANCH:-e04ba8e101fe2f71fae9c79a956b599e0523f3ae} # branch, tag, or commit SHA - &PAUSE_HEIGHT ${PAUSE_HEIGHT:-999999999999} - &PAUSE_TIMER 86400000 @@ -139,8 +154,12 @@ x-stacks-node: &stacks-node STACKS_32_HEIGHT: *STACKS_32_HEIGHT STACKS_33_HEIGHT: *STACKS_33_HEIGHT STACKS_34_HEIGHT: *STACKS_34_HEIGHT + STACKS_40_HEIGHT: *STACKS_40_HEIGHT POX_PREPARE_LENGTH: *POX_PREPARE_LENGTH POX_REWARD_LENGTH: *POX_REWARD_LENGTH + POX_5_SBTC_CONTRACT: *POX_5_SBTC_CONTRACT + POX_5_SBTC_REGISTRY_CONTRACT: *POX_5_SBTC_REGISTRY_CONTRACT + POX_5_BOND_ADMIN: *POX_5_BOND_ADMIN STACKS_LOG_JSON: *STACKS_LOG_JSON STACKS_LOG_DEBUG: *STACKS_LOG_DEBUG STACKS_LOG_FORMAT_TIME: *STACKS_LOG_FORMAT_TIME @@ -439,6 +458,7 @@ services: STACKS_BLOCKCHAIN_API_HOST: "0.0.0.0" STACKS_CORE_RPC_HOST: "stacks-miner-1" STACKS_CORE_RPC_PORT: 20443 + PG_SCHEMA: "public" API_DOCS_URL: http://127.0.0.1:3999/doc ports: - '127.0.0.1:3700:3700' @@ -453,6 +473,36 @@ services: # Stacker # ------------------ + pox5-setup: + build: stacker + container_name: pox5-setup + depends_on: + - stacks-miner-1 + environment: + STACKS_CORE_RPC_HOST: stacks-miner-1 + STACKS_CORE_RPC_PORT: 20443 + STACKS_CHAIN_ID: *STACKS_CHAIN_ID + STACKS_25_HEIGHT: *STACKS_25_HEIGHT + STACKS_30_HEIGHT: *STACKS_30_HEIGHT + STACKS_40_HEIGHT: *STACKS_40_HEIGHT + POX_PREPARE_LENGTH: *POX_PREPARE_LENGTH + POX_REWARD_LENGTH: *POX_REWARD_LENGTH + POX_5_DEPLOYER_PRIVATE_KEY: *POX_5_DEPLOYER_PRIVATE_KEY + POX_5_DEPLOYER_ADDRESS: *POX_5_DEPLOYER_ADDRESS + POX_5_SBTC_CONTRACT: *POX_5_SBTC_CONTRACT + POX_5_SBTC_REGISTRY_CONTRACT: *POX_5_SBTC_REGISTRY_CONTRACT + POX_5_BOND_ADMIN: *POX_5_BOND_ADMIN + STACKING_KEYS: 41634762d89dfa09133a4a8e9c1378d0161d29cd0a9433b51f1e3d32947a73dc01,9bfecf16c9c12792589dd2b843f850d5b89b81a04f8ab91c083bdf6709fbefee01,3ec0ca5770a356d6cd1a9bfcbf6cd151eb1bd85c388cc00648ec4ef5853fdb7401 + SERVICE_NAME: pox5-setup + entrypoint: + - /bin/bash + - -c + - | + set -e + exec npx tsx /root/pox5-setup.ts + profiles: + - default + stacker: build: stacker container_name: stacker @@ -462,19 +512,62 @@ services: STACKS_CORE_RPC_HOST: stacks-miner-1 STACKS_CORE_RPC_PORT: 20443 STACKING_CYCLES: *STACKING_CYCLES + STACKS_CHAIN_ID: *STACKS_CHAIN_ID STACKING_KEYS: 41634762d89dfa09133a4a8e9c1378d0161d29cd0a9433b51f1e3d32947a73dc01,9bfecf16c9c12792589dd2b843f850d5b89b81a04f8ab91c083bdf6709fbefee01,3ec0ca5770a356d6cd1a9bfcbf6cd151eb1bd85c388cc00648ec4ef5853fdb7401 # STACKING_SLOT_DISTRO: 1,4,5 STACKING_SLOT_DISTRO: 2,2,2 # Sets the stacking weight for the 3 stacking addresses. Default is evenly distributed across all 3 stackers STACKS_25_HEIGHT: *STACKS_25_HEIGHT STACKS_30_HEIGHT: *STACKS_30_HEIGHT + STACKS_40_HEIGHT: *STACKS_40_HEIGHT POX_PREPARE_LENGTH: *POX_PREPARE_LENGTH POX_REWARD_LENGTH: *POX_REWARD_LENGTH + POX_5_DEPLOYER_PRIVATE_KEY: *POX_5_DEPLOYER_PRIVATE_KEY + POX_5_DEPLOYER_ADDRESS: *POX_5_DEPLOYER_ADDRESS + POX_5_SBTC_CONTRACT: *POX_5_SBTC_CONTRACT + POX_5_SBTC_REGISTRY_CONTRACT: *POX_5_SBTC_REGISTRY_CONTRACT + POX_5_BOND_ADMIN: *POX_5_BOND_ADMIN STACKING_INTERVAL: 2 # Interval (seconds) for checking if stacking transactions are needed POST_TX_WAIT: 10 # Seconds to wait after a stacking transaction is broadcast before continuing the loop SERVICE_NAME: stacker profiles: - default + bitcoin-staking: + build: stacker + container_name: bitcoin-staking + depends_on: + - stacks-miner-1 + - pox5-setup + - stacker + environment: + STACKS_CORE_RPC_HOST: stacks-miner-1 + STACKS_CORE_RPC_PORT: 20443 + STACKS_25_HEIGHT: *STACKS_25_HEIGHT + STACKS_30_HEIGHT: *STACKS_30_HEIGHT + POX_PREPARE_LENGTH: *POX_PREPARE_LENGTH + POX_REWARD_LENGTH: *POX_REWARD_LENGTH + POX_5_DEPLOYER_PRIVATE_KEY: *POX_5_DEPLOYER_PRIVATE_KEY + POX_5_BOND_ADMIN: *POX_5_BOND_ADMIN + POX_5_BOND_ADMIN_PRIVATE_KEY: *POX_5_BOND_ADMIN_PRIVATE_KEY + POX_5_SBTC_CONTRACT: *POX_5_SBTC_CONTRACT + BITCOIN_STAKING_KEYS: *BITCOIN_STAKING_KEYS + BITCOIN_STAKING_BOND_INDEX: *BITCOIN_STAKING_BOND_INDEX + BITCOIN_STAKING_AMOUNT_USTX: *BITCOIN_STAKING_AMOUNT_USTX + BITCOIN_STAKING_AMOUNT_SATS: *BITCOIN_STAKING_AMOUNT_SATS + BITCOIN_STAKING_TARGET_RATE: *BITCOIN_STAKING_TARGET_RATE + BITCOIN_STAKING_STX_VALUE_RATIO: *BITCOIN_STAKING_STX_VALUE_RATIO + BITCOIN_STAKING_MIN_USTX_RATIO: *BITCOIN_STAKING_MIN_USTX_RATIO + STACKING_KEYS: 41634762d89dfa09133a4a8e9c1378d0161d29cd0a9433b51f1e3d32947a73dc01,9bfecf16c9c12792589dd2b843f850d5b89b81a04f8ab91c083bdf6709fbefee01,3ec0ca5770a356d6cd1a9bfcbf6cd151eb1bd85c388cc00648ec4ef5853fdb7401 + SERVICE_NAME: bitcoin-staking + entrypoint: + - /bin/bash + - -c + - | + set -e + exec npx tsx /root/bitcoin-staking.ts + profiles: + - bitcoin-staking + tx-broadcaster: build: stacker container_name: tx-broadcaster diff --git a/docker/stacker/Dockerfile b/docker/stacker/Dockerfile index 49a7edd..83c30f0 100644 --- a/docker/stacker/Dockerfile +++ b/docker/stacker/Dockerfile @@ -6,6 +6,6 @@ WORKDIR /root COPY ./stacking/package.json /root/ RUN npm i -COPY ./stacking/stacking.ts ./stacking/common.ts ./stacking/tx-broadcaster.ts /root/ +COPY ./stacking/stacking.ts ./stacking/common.ts ./stacking/tx-broadcaster.ts ./stacking/pox5-setup.ts ./stacking/bitcoin-staking.ts /root/ CMD ["npx", "tsx", "/root/stacking.ts"] diff --git a/docker/stacker/stacking/bitcoin-staking.ts b/docker/stacker/stacking/bitcoin-staking.ts new file mode 100644 index 0000000..8085dd7 --- /dev/null +++ b/docker/stacker/stacking/bitcoin-staking.ts @@ -0,0 +1,557 @@ +import type { PoxInfo } from '@stacks/stacking'; +import { + AnchorMode, + ClarityType, + type ClarityValue, + PostConditionMode, + type StacksTransaction, + TransactionVersion, + broadcastTransaction, + bufferCV, + callReadOnlyFunction, + contractPrincipalCV, + cvToString, + getAddressFromPrivateKey, + getNonce, + listCV, + makeContractCall, + noneCV, + principalCV, + responseErrorCV, + tupleCV, + uintCV, +} from '@stacks/transactions'; +import { + type Account, + POX_REWARD_LENGTH, + contractsApi, + getAccounts, + isPreparePhase, + logger, + network, + parseEnvInt, + waitForSetup, +} from './common'; + +const POX5_BOOT_ADDRESS = 'ST000000000000000000002AMW42H'; +const POX5_CONTRACT_NAME = 'pox-5'; +const BOND_GAP_CYCLES = 2n; + +const bondAdminAddress = process.env.POX_5_BOND_ADMIN!; +const bondAdminPrivateKey = + process.env.POX_5_BOND_ADMIN_PRIVATE_KEY ?? process.env.POX_5_DEPLOYER_PRIVATE_KEY; +const sbtcContractId = process.env.POX_5_SBTC_CONTRACT!; +const participantKeys = process.env.BITCOIN_STAKING_KEYS?.split(',').filter(Boolean) ?? []; +const signerKeys = process.env.STACKING_KEYS?.split(',').filter(Boolean) ?? []; + +const configuredBondIndex = parseEnvBigInt('BITCOIN_STAKING_BOND_INDEX') ?? 0n; +const amountUstx = parseEnvBigInt('BITCOIN_STAKING_AMOUNT_USTX') ?? 99_000_000_000_000n; +const amountSats = parseEnvBigInt('BITCOIN_STAKING_AMOUNT_SATS') ?? 1_000_000n; +const targetRate = parseEnvBigInt('BITCOIN_STAKING_TARGET_RATE') ?? 1_000n; +const stxValueRatio = parseEnvBigInt('BITCOIN_STAKING_STX_VALUE_RATIO') ?? 100n; +const minUstxRatio = parseEnvBigInt('BITCOIN_STAKING_MIN_USTX_RATIO') ?? 10_000n; +const setupFee = parseEnvInt('BITCOIN_STAKING_SETUP_FEE', false) ?? 3_000_000; +const callFee = parseEnvInt('BITCOIN_STAKING_CALL_FEE', false) ?? 10_000; +const pollIntervalMs = (parseEnvInt('BITCOIN_STAKING_POLL_INTERVAL', false) ?? 3) * 1000; +const mintMultiplier = BigInt(parseEnvInt('BITCOIN_STAKING_MINT_MULTIPLIER', false) ?? 2); + +const sbtcContract = splitContractId(sbtcContractId); + +type SignerManager = { + contractAddress: string; + contractName: string; +}; + +type BondSelection = { + index: bigint; + startBurnHeight: bigint; + exists: boolean; +}; + +function splitContractId(contractId: string) { + const [contractAddress, contractName, ...extra] = contractId.split('.'); + if (!contractAddress || !contractName || extra.length > 0) { + throw new Error(`Invalid contract id: ${contractId}`); + } + return { contractAddress, contractName }; +} + +function parseEnvBigInt(envKey: string) { + const value = process.env[envKey]; + if (typeof value === 'undefined') return undefined; + if (!/^[0-9]+$/.test(value)) { + throw new Error(`${envKey} must be an unsigned integer, got ${value}`); + } + return BigInt(value); +} + +function validateConfig() { + if (!bondAdminPrivateKey) { + throw new Error('POX_5_BOND_ADMIN_PRIVATE_KEY or POX_5_DEPLOYER_PRIVATE_KEY must be set'); + } + const derivedBondAdmin = getAddressFromPrivateKey( + bondAdminPrivateKey, + TransactionVersion.Testnet + ); + if (derivedBondAdmin !== bondAdminAddress) { + throw new Error( + `POX_5_BOND_ADMIN_PRIVATE_KEY derives ${derivedBondAdmin}, expected ${bondAdminAddress}` + ); + } + if (participantKeys.length === 0) { + throw new Error('No BITCOIN_STAKING_KEYS provided'); + } + if (signerKeys.length === 0) { + throw new Error('No STACKING_KEYS provided; cannot discover PoX-5 signer managers'); + } + if (amountSats <= 0n || amountUstx <= 0n) { + throw new Error('BITCOIN_STAKING_AMOUNT_SATS and BITCOIN_STAKING_AMOUNT_USTX must be > 0'); + } + if (mintMultiplier <= 0n) { + throw new Error('BITCOIN_STAKING_MINT_MULTIPLIER must be > 0'); + } +} + +function signerManagerName(account: Account) { + return `pox5-signer-${account.index}`; +} + +function sleep(ms = pollIntervalMs) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +function uintValue(value: ClarityValue, label: string) { + if (value.type !== ClarityType.UInt) { + throw new Error(`${label} expected uint, got ${cvToString(value)}`); + } + return value.value; +} + +function okUintValue(value: ClarityValue, label: string) { + if (value.type !== ClarityType.ResponseOk) { + throw new Error(`${label} expected ok uint, got ${cvToString(value)}`); + } + return uintValue(value.value, label); +} + +function optionalUintValue(value: ClarityValue, label: string) { + if (value.type === ClarityType.OptionalNone) return undefined; + if (value.type !== ClarityType.OptionalSome) { + throw new Error(`${label} expected optional uint, got ${cvToString(value)}`); + } + return uintValue(value.value, label); +} + +function isSome(value: ClarityValue) { + return value.type === ClarityType.OptionalSome; +} + +async function pox5ReadOnly( + functionName: string, + functionArgs: ClarityValue[], + senderAddress = bondAdminAddress +) { + return callReadOnlyFunction({ + contractAddress: POX5_BOOT_ADDRESS, + contractName: POX5_CONTRACT_NAME, + functionName, + functionArgs, + senderAddress, + network, + }); +} + +async function waitForPox5(client: Account['client']) { + while (true) { + const poxInfo = await client.getPoxInfo(); + const burnHeight = poxInfo.current_burnchain_block_height ?? 0; + if (poxInfo.contract_id?.endsWith('.pox-5')) { + logger.info( + { burnHeight, poxContract: poxInfo.contract_id }, + 'PoX-5 is active; starting Bitcoin Staking setup' + ); + return poxInfo; + } + logger.info( + { burnHeight, poxContract: poxInfo.contract_id }, + 'Waiting for active PoX-5 contract' + ); + await sleep(); + } +} + +async function signerRegistered(signerManager: SignerManager) { + const result = await pox5ReadOnly('get-signer-info', [ + contractPrincipalCV(signerManager.contractAddress, signerManager.contractName), + ]); + return isSome(result); +} + +async function waitForSignerManagers(signerManagers: SignerManager[]) { + while (true) { + const missing: string[] = []; + for (const signerManager of signerManagers) { + if (!(await signerRegistered(signerManager))) { + missing.push(`${signerManager.contractAddress}.${signerManager.contractName}`); + } + } + if (missing.length === 0) { + logger.info({ signers: signerManagers.length }, 'PoX-5 signer managers are registered'); + return; + } + logger.info({ missing }, 'Waiting for PoX-5 signer managers to register'); + await sleep(); + } +} + +async function bondStartBurnHeight(bondIndex: bigint) { + return uintValue( + await pox5ReadOnly('bond-period-to-burn-height', [uintCV(bondIndex)]), + 'bond-period-to-burn-height' + ); +} + +async function bondExists(bondIndex: bigint) { + return isSome(await pox5ReadOnly('get-protocol-bond', [uintCV(bondIndex)])); +} + +async function bondAllowance(bondIndex: bigint, staker: string) { + return optionalUintValue( + await pox5ReadOnly('get-bond-allowance', [uintCV(bondIndex), principalCV(staker)], staker), + 'get-bond-allowance' + ); +} + +async function missingAllowances(bondIndex: bigint, participants: Account[]) { + const missing: string[] = []; + for (const participant of participants) { + const allowance = await bondAllowance(bondIndex, participant.stxAddress); + if (allowance === undefined || allowance < amountSats) { + missing.push(participant.stxAddress); + } + } + return missing; +} + +async function selectBondIndex(client: Account['client'], participants: Account[]) { + let baseIndex = configuredBondIndex; + const setupWindow = BOND_GAP_CYCLES * BigInt(POX_REWARD_LENGTH); + + while (true) { + const poxInfo = await client.getPoxInfo(); + const currentBurnHeight = BigInt(poxInfo.current_burnchain_block_height ?? 0); + + for (let offset = 0n; offset < 16n; offset++) { + const bondIndex = baseIndex + offset; + const startBurnHeight = await bondStartBurnHeight(bondIndex); + + if (currentBurnHeight >= startBurnHeight) { + baseIndex = bondIndex + 1n; + continue; + } + + if (await bondExists(bondIndex)) { + const missing = await missingAllowances(bondIndex, participants); + if (missing.length === 0) { + return { index: bondIndex, startBurnHeight, exists: true }; + } + logger.warn( + { bondIndex: bondIndex.toString(), missing }, + 'Existing PoX-5 bond does not allowlist the configured participants; trying a later bond' + ); + continue; + } + + const setupOpenHeight = startBurnHeight > setupWindow ? startBurnHeight - setupWindow : 0n; + if (currentBurnHeight >= setupOpenHeight) { + return { index: bondIndex, startBurnHeight, exists: false }; + } + + logger.info( + { + bondIndex: bondIndex.toString(), + currentBurnHeight: currentBurnHeight.toString(), + setupOpenHeight: setupOpenHeight.toString(), + startBurnHeight: startBurnHeight.toString(), + }, + 'Waiting for PoX-5 bond setup window' + ); + break; + } + + await sleep(); + } +} + +async function broadcast(tx: StacksTransaction, label: string) { + const result = await broadcastTransaction(tx, network); + if (result.error) { + throw new Error(`Error broadcasting ${label}: ${JSON.stringify(result)}`); + } + logger.info({ txid: result.txid, label }, 'Broadcast Bitcoin Staking transaction'); + return result.txid; +} + +async function setupBond(selection: BondSelection, participants: Account[]) { + if (selection.exists) { + logger.info({ bondIndex: selection.index.toString() }, 'PoX-5 bond already configured'); + return; + } + + const nonce = await getNonce(bondAdminAddress, network); + const allowlist = listCV( + participants.map(participant => + tupleCV({ + staker: principalCV(participant.stxAddress), + 'max-sats': uintCV(amountSats), + }) + ) + ); + const tx = await makeContractCall({ + contractAddress: POX5_BOOT_ADDRESS, + contractName: POX5_CONTRACT_NAME, + functionName: 'setup-bond', + functionArgs: [ + uintCV(selection.index), + uintCV(targetRate), + uintCV(stxValueRatio), + uintCV(minUstxRatio), + bufferCV(new Uint8Array(683)), + allowlist, + ], + senderKey: bondAdminPrivateKey!, + nonce, + fee: setupFee, + anchorMode: AnchorMode.Any, + network, + postConditionMode: PostConditionMode.Allow, + }); + + await broadcast(tx, `pox-5 setup-bond ${selection.index}`); + await waitForBond(selection.index, participants); +} + +async function waitForBond(bondIndex: bigint, participants: Account[]) { + for (let attempt = 1; attempt <= 120; attempt++) { + if ( + (await bondExists(bondIndex)) && + (await missingAllowances(bondIndex, participants)).length === 0 + ) { + logger.info({ bondIndex: bondIndex.toString() }, 'PoX-5 bond configured'); + return; + } + await sleep(); + } + throw new Error(`Timed out waiting for PoX-5 bond ${bondIndex} to be configured`); +} + +async function sbtcBalance(participant: Account) { + return okUintValue( + await callReadOnlyFunction({ + contractAddress: sbtcContract.contractAddress, + contractName: sbtcContract.contractName, + functionName: 'get-balance', + functionArgs: [principalCV(participant.stxAddress)], + senderAddress: participant.stxAddress, + network, + }), + 'sbtc-token.get-balance' + ); +} + +async function mintSbtcIfNeeded(participant: Account) { + const current = await sbtcBalance(participant); + if (current >= amountSats) { + participant.logger.info( + { balance: current.toString(), required: amountSats.toString() }, + 'Participant already has enough mock sBTC' + ); + return; + } + + const targetBalance = amountSats * mintMultiplier; + const amountToMint = targetBalance - current; + const nonce = await getNonce(participant.stxAddress, network); + const tx = await makeContractCall({ + contractAddress: sbtcContract.contractAddress, + contractName: sbtcContract.contractName, + functionName: 'mint', + functionArgs: [uintCV(amountToMint), principalCV(participant.stxAddress)], + senderKey: participant.privKey, + nonce, + fee: callFee, + anchorMode: AnchorMode.Any, + network, + postConditionMode: PostConditionMode.Allow, + }); + + await broadcast(tx, `sbtc-token mint ${participant.stxAddress}`); + for (let attempt = 1; attempt <= 120; attempt++) { + const updated = await sbtcBalance(participant); + if (updated >= amountSats) { + participant.logger.info( + { balance: updated.toString(), required: amountSats.toString() }, + 'Participant mock sBTC balance is ready' + ); + return; + } + await sleep(); + } + throw new Error(`Timed out waiting for mock sBTC mint to ${participant.stxAddress}`); +} + +async function hasBondMembership(participant: Account) { + return isSome( + await pox5ReadOnly( + 'get-bond-membership', + [principalCV(participant.stxAddress)], + participant.stxAddress + ) + ); +} + +async function waitForRegistrationWindow(client: Account['client'], selection: BondSelection) { + while (true) { + const poxInfo: PoxInfo = await client.getPoxInfo(); + const currentBurnHeight = poxInfo.current_burnchain_block_height ?? 0; + if (BigInt(currentBurnHeight) >= selection.startBurnHeight) { + throw new Error( + `PoX-5 bond ${selection.index} has already started at burn height ${selection.startBurnHeight}` + ); + } + if (!isPreparePhase(currentBurnHeight)) return; + logger.info( + { + bondIndex: selection.index.toString(), + currentBurnHeight, + startBurnHeight: selection.startBurnHeight.toString(), + }, + 'Waiting for non-prepare phase before register-for-bond' + ); + await sleep(); + } +} + +async function registerForBond( + client: Account['client'], + selection: BondSelection, + participant: Account, + signerManager: SignerManager +) { + if (await hasBondMembership(participant)) { + participant.logger.info('Participant already has active PoX-5 bond membership'); + return; + } + + await waitForRegistrationWindow(client, selection); + const nonce = await getNonce(participant.stxAddress, network); + const tx = await makeContractCall({ + contractAddress: POX5_BOOT_ADDRESS, + contractName: POX5_CONTRACT_NAME, + functionName: 'register-for-bond', + functionArgs: [ + uintCV(selection.index), + contractPrincipalCV(signerManager.contractAddress, signerManager.contractName), + uintCV(amountUstx), + responseErrorCV(uintCV(amountSats)), + noneCV(), + ], + senderKey: participant.privKey, + nonce, + fee: callFee, + anchorMode: AnchorMode.Any, + network, + postConditionMode: PostConditionMode.Allow, + }); + + await broadcast(tx, `pox-5 register-for-bond ${participant.stxAddress}`); + for (let attempt = 1; attempt <= 120; attempt++) { + if (await hasBondMembership(participant)) { + participant.logger.info( + { + bondIndex: selection.index.toString(), + amountUstx: amountUstx.toString(), + amountSats: amountSats.toString(), + signerManager: `${signerManager.contractAddress}.${signerManager.contractName}`, + }, + 'Participant registered for PoX-5 Bitcoin Staking bond' + ); + return; + } + await sleep(); + } + throw new Error(`Timed out waiting for PoX-5 bond membership for ${participant.stxAddress}`); +} + +async function contractExists(contractAddress: string, contractName: string) { + try { + const result = await contractsApi.getContractSource({ contractAddress, contractName }); + return !!result.source; + } catch { + return false; + } +} + +async function waitForSbtcContract() { + while (!(await contractExists(sbtcContract.contractAddress, sbtcContract.contractName))) { + logger.info({ contract: sbtcContractId }, 'Waiting for mock sBTC contract'); + await sleep(); + } +} + +async function run() { + validateConfig(); + + const participants = getAccounts(participantKeys, new Array(participantKeys.length).fill(1)); + const signerAccounts = getAccounts(signerKeys, new Array(signerKeys.length).fill(1)); + const signerManagers = signerAccounts.map(account => ({ + contractAddress: account.stxAddress, + contractName: signerManagerName(account), + })); + + logger.info( + { + participants: participants.map(account => account.stxAddress), + signerManagers: signerManagers.map( + signerManager => `${signerManager.contractAddress}.${signerManager.contractName}` + ), + configuredBondIndex: configuredBondIndex.toString(), + amountUstx: amountUstx.toString(), + amountSats: amountSats.toString(), + sbtcContractId, + }, + 'Starting PoX-5 Bitcoin Staking helper' + ); + + await waitForSetup(participantKeys, new Array(participantKeys.length).fill(1)); + const poxInfo = await waitForPox5(participants[0].client); + await waitForSbtcContract(); + await waitForSignerManagers(signerManagers); + + const selection = await selectBondIndex(participants[0].client, participants); + logger.info( + { + bondIndex: selection.index.toString(), + bondStartBurnHeight: selection.startBurnHeight.toString(), + currentBurnHeight: poxInfo.current_burnchain_block_height, + exists: selection.exists, + }, + 'Selected PoX-5 bond' + ); + + await setupBond(selection, participants); + for (const participant of participants) { + await mintSbtcIfNeeded(participant); + } + for (const participant of participants) { + const signerManager = signerManagers[participant.index % signerManagers.length]!; + await registerForBond(participants[0].client, selection, participant, signerManager); + } + + logger.info({ bondIndex: selection.index.toString() }, 'PoX-5 Bitcoin Staking setup complete'); +} + +run().catch(error => { + logger.error({ error }, 'PoX-5 Bitcoin Staking setup failed'); + process.exit(1); +}); diff --git a/docker/stacker/stacking/common.ts b/docker/stacker/stacking/common.ts index b4147e6..3fc0bf7 100644 --- a/docker/stacker/stacking/common.ts +++ b/docker/stacker/stacking/common.ts @@ -93,11 +93,27 @@ export const getAccounts = (stackingKeys: string[], stackingSlotDistribution: nu export const MAX_U128 = 2n ** 128n - 1n; export const maxAmount = MAX_U128; +export function isNodeNotReadyError(error: unknown) { + if (!(error instanceof Error)) { + return false; + } + const cause = error.cause; + if ( + typeof cause !== 'object' || + cause === null || + !('message' in cause) || + typeof cause.message !== 'string' + ) { + return false; + } + return /(ECONNREFUSED|ENOTFOUND|SyntaxError)/.test(cause.message); +} + export async function waitForSetup(stackingKeys: string[], stackingSlotDistribution: number[]) { try { await getAccounts(stackingKeys, stackingSlotDistribution)[0].client.getPoxInfo(); } catch (error) { - if (/(ECONNREFUSED|ENOTFOUND|SyntaxError)/.test(error.cause?.message)) { + if (isNodeNotReadyError(error)) { console.log(`Stacks node not ready, waiting...`); } await new Promise(resolve => setTimeout(resolve, 3000)); @@ -127,7 +143,7 @@ export function burnBlockToRewardCycle(burnBlock: number) { export const EPOCH_30_START_CYCLE = burnBlockToRewardCycle(EPOCH_30_START); export function isPreparePhase(burnBlock: number) { - return POX_REWARD_LENGTH - (burnBlock % POX_REWARD_LENGTH) < POX_PREPARE_LENGTH; + return POX_REWARD_LENGTH - (burnBlock % POX_REWARD_LENGTH) <= POX_PREPARE_LENGTH; } export function didCrossPreparePhase(lastBurnHeight: number, newBurnHeight: number) { diff --git a/docker/stacker/stacking/flood.ts b/docker/stacker/stacking/flood.ts index dd7a5c5..c4f7193 100644 --- a/docker/stacker/stacking/flood.ts +++ b/docker/stacker/stacking/flood.ts @@ -20,7 +20,7 @@ if (process.argv.slice(2).length > 0) { config({ path: './tx-broadcaster.env' }); } import { bytesToHex } from '@stacks/common'; -import { logger, parseEnvInt, contractsApi, accountsApi } from './common'; +import { accountsApi, contractsApi, isNodeNotReadyError, logger, parseEnvInt } from './common'; const broadcastInterval = parseInt(process.env.NAKAMOTO_BLOCK_INTERVAL ?? '2'); const url = `http://${process.env.STACKS_CORE_RPC_HOST}:${process.env.STACKS_CORE_RPC_PORT}`; @@ -183,7 +183,7 @@ async function waitForNakamoto() { break; } } catch (error) { - if (/(ECONNREFUSED|ENOTFOUND|SyntaxError)/.test(error.cause?.message)) { + if (isNodeNotReadyError(error)) { logger.info(`Stacks node not ready, waiting...`); } else { logger.error('Error getting pox info:', error); diff --git a/docker/stacker/stacking/pox5-setup.ts b/docker/stacker/stacking/pox5-setup.ts new file mode 100644 index 0000000..4c61fbe --- /dev/null +++ b/docker/stacker/stacking/pox5-setup.ts @@ -0,0 +1,213 @@ +import { StacksTestnet } from '@stacks/network'; +import { StackingClient } from '@stacks/stacking'; +import { + AnchorMode, + PostConditionMode, + TransactionVersion, + broadcastTransaction, + getAddressFromPrivateKey, + getNonce, + makeContractDeploy, + StacksTransaction, +} from '@stacks/transactions'; +import { getPublicKeyFromPrivate } from '@stacks/encryption'; +import { contractsApi, logger, parseEnvInt } from './common'; + +const nodeUrl = `http://${process.env.STACKS_CORE_RPC_HOST}:${process.env.STACKS_CORE_RPC_PORT}`; +const network = new StacksTestnet({ url: nodeUrl }); +const deployerPrivateKey = process.env.POX_5_DEPLOYER_PRIVATE_KEY!; +const deployerAddress = process.env.POX_5_DEPLOYER_ADDRESS!; +const sbtcContractId = process.env.POX_5_SBTC_CONTRACT!; +const sbtcRegistryContractId = process.env.POX_5_SBTC_REGISTRY_CONTRACT!; +const epoch30Start = parseEnvInt('STACKS_30_HEIGHT', true); +const epoch40Start = parseEnvInt('STACKS_40_HEIGHT', true); +const setupSafetyBlocks = parseEnvInt('POX_5_SETUP_SAFETY_BLOCKS', false) ?? 5; +const stackerKeys = process.env.STACKING_KEYS?.split(',').filter(Boolean) ?? []; + +const deployerFromPrivateKey = getAddressFromPrivateKey( + deployerPrivateKey, + TransactionVersion.Testnet +); +const client = new StackingClient(deployerAddress, network); + +function splitContractId(contractId: string) { + const [contractAddress, contractName, ...extra] = contractId.split('.'); + if (!contractAddress || !contractName || extra.length > 0) { + throw new Error(`Invalid contract id: ${contractId}`); + } + return { contractAddress, contractName }; +} + +const sbtcContract = splitContractId(sbtcContractId); +const sbtcRegistryContract = splitContractId(sbtcRegistryContractId); + +function validateConfig() { + if (deployerFromPrivateKey !== deployerAddress) { + throw new Error( + `POX_5_DEPLOYER_PRIVATE_KEY derives ${deployerFromPrivateKey}, expected ${deployerAddress}` + ); + } + for (const contract of [sbtcContract, sbtcRegistryContract]) { + if (contract.contractAddress !== deployerAddress) { + throw new Error( + `PoX-5 setup can only deploy ${deployerAddress} contracts, got ${contract.contractAddress}.${contract.contractName}` + ); + } + } + if (stackerKeys.length === 0) { + throw new Error('No STACKING_KEYS provided; cannot derive sBTC registry aggregate key'); + } +} + +function sbtcRegistrySource(aggregatePubkey: string) { + return ` +(define-read-only (get-current-aggregate-pubkey) + 0x${aggregatePubkey} +) +`.trim(); +} + +function sbtcTokenSource() { + return ` +(define-fungible-token sbtc-token) + +(define-public (transfer + (amount uint) + (sender principal) + (recipient principal) + (memo (optional (buff 34)))) + (begin + (try! (ft-transfer? sbtc-token amount sender recipient)) + (ok true))) + +(define-read-only (get-balance (who principal)) + (ok (ft-get-balance sbtc-token who))) + +(define-public (mint (amount uint) (recipient principal)) + (ft-mint? sbtc-token amount recipient)) +`.trim(); +} + +async function contractExists(contractAddress: string, contractName: string) { + try { + const result = await contractsApi.getContractSource({ contractAddress, contractName }); + return !!result.source; + } catch { + return false; + } +} + +async function waitForNode() { + while (true) { + try { + const poxInfo = await client.getPoxInfo(); + const burnHeight = poxInfo.current_burnchain_block_height ?? 0; + if (burnHeight <= epoch30Start) { + logger.info( + { burnHeight, epoch30Start }, + 'Waiting for Nakamoto before deploying PoX-5 prerequisite contracts' + ); + } else { + return poxInfo; + } + } catch (error) { + logger.info({ error }, 'Stacks node not ready for PoX-5 setup'); + } + await new Promise(resolve => setTimeout(resolve, 3000)); + } +} + +async function assertBeforeEpoch4() { + const poxInfo = await client.getPoxInfo(); + const burnHeight = poxInfo.current_burnchain_block_height ?? 0; + if (burnHeight >= epoch40Start - setupSafetyBlocks) { + throw new Error( + `PoX-5 prerequisite setup is too close to Epoch 4.0 (burn=${burnHeight}, epoch4=${epoch40Start}, safety=${setupSafetyBlocks})` + ); + } +} + +async function broadcast(tx: StacksTransaction, label: string) { + const result = await broadcastTransaction(tx, network); + if (result.error) { + throw new Error(`Error broadcasting ${label}: ${JSON.stringify(result)}`); + } + logger.info({ txid: result.txid, label }, 'Broadcast PoX-5 prerequisite deploy'); + return result.txid; +} + +async function deployContract(contractName: string, codeBody: string, nonce: bigint) { + await assertBeforeEpoch4(); + const tx = await makeContractDeploy({ + contractName, + codeBody, + senderKey: deployerPrivateKey, + nonce, + fee: 3_000_000, + anchorMode: AnchorMode.Any, + network, + postConditionMode: PostConditionMode.Allow, + }); + await broadcast(tx, `${deployerAddress}.${contractName}`); +} + +async function waitForContract(contractName: string) { + for (let attempt = 1; attempt <= 90; attempt++) { + if (await contractExists(deployerAddress, contractName)) { + logger.info({ contract: `${deployerAddress}.${contractName}` }, 'Contract deployed'); + return; + } + await assertBeforeEpoch4(); + await new Promise(resolve => setTimeout(resolve, 2000)); + } + throw new Error(`Timed out waiting for ${deployerAddress}.${contractName} to deploy`); +} + +async function deployIfMissing(contractName: string, codeBody: string, nonce: bigint) { + if (await contractExists(deployerAddress, contractName)) { + logger.info({ contract: `${deployerAddress}.${contractName}` }, 'Contract already deployed'); + return false; + } + await deployContract(contractName, codeBody, nonce); + await waitForContract(contractName); + return true; +} + +async function run() { + validateConfig(); + await waitForNode(); + + const aggregatePubkey = getPublicKeyFromPrivate(stackerKeys[0]); + let nonce = await getNonce(deployerAddress, network); + + logger.info( + { + deployerAddress, + sbtcContractId, + sbtcRegistryContractId, + aggregatePubkey, + epoch40Start, + }, + 'Starting PoX-5 prerequisite setup' + ); + + if (await deployIfMissing(sbtcContract.contractName, sbtcTokenSource(), nonce)) { + nonce += 1n; + } + if ( + await deployIfMissing( + sbtcRegistryContract.contractName, + sbtcRegistrySource(aggregatePubkey), + nonce + ) + ) { + nonce += 1n; + } + + logger.info('PoX-5 prerequisite setup complete'); +} + +run().catch(error => { + logger.error({ error }, 'PoX-5 prerequisite setup failed'); + process.exit(1); +}); diff --git a/docker/stacker/stacking/stacking.ts b/docker/stacker/stacking/stacking.ts index 5a91606..8079593 100644 --- a/docker/stacker/stacking/stacking.ts +++ b/docker/stacker/stacking/stacking.ts @@ -1,4 +1,25 @@ import { PoxInfo, Pox4SignatureTopic } from '@stacks/stacking'; +import { hexToBytes } from '@stacks/common'; +import { + AnchorMode, + ClarityVersion, + PostConditionMode, + StacksTransaction, + broadcastTransaction, + bufferCV, + callReadOnlyFunction, + contractPrincipalCV, + cvToString, + getNonce, + makeContractCall, + makeContractDeploy, + noneCV, + principalCV, + signStructuredData, + stringAsciiCV, + tupleCV, + uintCV, +} from '@stacks/transactions'; import crypto from 'crypto'; import { Account, @@ -8,18 +29,28 @@ import { waitForSetup, logger, burnBlockToRewardCycle, + isPreparePhase, + network, + contractsApi, } from './common'; const randInt = () => crypto.randomInt(0, 0xffffffffffff); const stackingInterval = parseEnvInt('STACKING_INTERVAL', true); const postTxWait = parseEnvInt('POST_TX_WAIT', true); const stackingCycles = parseEnvInt('STACKING_CYCLES', true); +const chainId = parseEnvInt('STACKS_CHAIN_ID', false) ?? 0x80000000; +const pox5DeployFee = parseEnvInt('POX_5_DEPLOY_FEE', false) ?? 3_000_000; +const pox5CallFee = parseEnvInt('POX_5_CALL_FEE', false) ?? 10_000; const SLOT_MULTIPLIER = 1.1; const DEFAULT_NUM_SLOTS = 2; +const POX5_BOOT_ADDRESS = 'ST000000000000000000002AMW42H'; +const POX5_CONTRACT_NAME = 'pox-5'; +const POX5_CLARITY_VERSION = 6 as ClarityVersion; let startTxFee = 1000; const getNextTxFee = () => startTxFee++; +let lastPoxContractId = ''; type RewardCycleId = number; type AmountToStack = bigint; @@ -76,18 +107,47 @@ function getFixedStackingAmount( async function run(stackingKeys: string[], stackingSlotDistribution: number[]) { const accounts = getAccounts(stackingKeys, stackingSlotDistribution); const poxInfo = await accounts[0].client.getPoxInfo(); - if (!poxInfo.contract_id.endsWith('.pox-4')) { + + const poxContractId = poxInfo.contract_id; + const previousPoxContractId = lastPoxContractId; + const poxTransitioned = previousPoxContractId !== '' && previousPoxContractId !== poxContractId; + lastPoxContractId = poxContractId; + const isPox4 = poxContractId.endsWith('.pox-4'); + const isPox5 = poxContractId.endsWith('.pox-5'); + + if (!isPox4 && !isPox5) { + logger.info( + { + poxContract: poxContractId, + }, + `Pox contract is not .pox-4 or .pox-5, skipping stacking (contract=${poxContractId})` + ); + return; + } + + if (poxTransitioned) { logger.info( { - poxContract: poxInfo.contract_id, + from: previousPoxContractId, + to: poxContractId, }, - `Pox contract is not .pox-4, skipping stacking (contract=${poxInfo.contract_id})` + `Pox contract changed, forcing fresh stacking submissions` + ); + } + + if (isPox5 && isPreparePhase(poxInfo.current_burnchain_block_height ?? 0)) { + logger.info( + { + burnHeight: poxInfo.current_burnchain_block_height, + }, + 'PoX-5 staking updates are skipped during prepare phase' ); return; } const runLog = logger.child({ burnHeight: poxInfo.current_burnchain_block_height, + poxContract: poxContractId, }); const accountInfos = await Promise.all( @@ -112,6 +172,52 @@ async function run(stackingKeys: string[], stackingSlotDistribution: number[]) { await Promise.all( accountInfos.map(async account => { + if (isPox5) { + const hasActivePox5Stake = !poxTransitioned && (await hasPox5Stake(account)); + if (!hasActivePox5Stake) { + runLog.info( + { + burnHeight: poxInfo.current_burnchain_block_height, + unlockHeight: account.unlockHeight, + account: account.index, + }, + `Account ${account.index} needs fresh PoX-5 stake` + ); + await stakePox5(poxInfo, account, account.balance + account.lockedAmount); + txSubmitted = true; + return; + } + + const unlockHeightCycle = burnBlockToRewardCycle(account.unlockHeight); + const nowCycle = burnBlockToRewardCycle(poxInfo.current_burnchain_block_height ?? 0); + if (unlockHeightCycle === nowCycle + 1) { + runLog.info( + { + burnHeight: poxInfo.current_burnchain_block_height, + unlockHeight: account.unlockHeight, + account: account.index, + nowCycle, + unlockCycle: unlockHeightCycle, + }, + `Account ${account.index} needs PoX-5 stake-update` + ); + await stakeUpdatePox5(account); + txSubmitted = true; + return; + } + runLog.info( + { + burnHeight: poxInfo.current_burnchain_block_height, + unlockHeight: account.unlockHeight, + account: account.index, + nowCycle, + unlockCycle: unlockHeightCycle, + }, + `Account ${account.index} has active PoX-5 stake, skipping stacking` + ); + return; + } + if (account.lockedAmount === 0n) { runLog.info( { @@ -283,6 +389,223 @@ async function stackExtend( ); } +function pox5SignerManagerName(account: Account) { + return `pox5-signer-${account.index}`; +} + +function pox5SignerManagerSource() { + return ` +(impl-trait 'ST000000000000000000002AMW42H.pox-5.signer-manager-trait) +(use-trait signer-manager-trait 'ST000000000000000000002AMW42H.pox-5.signer-manager-trait) + +(define-public (validate-stake! + (staker principal) + (first-index uint) + (num-indexes uint) + (amount-ustx uint) + (amount-sats uint) + (is-bond bool) + (signer-calldata (optional (buff 500))) + ) + (ok true) +) + +(define-public (register-self + (signer-manager ) + (signer-key (buff 33)) + (auth-id uint) + (signer-sig (buff 65)) + ) + (as-contract? () + (try! (contract-call? 'ST000000000000000000002AMW42H.pox-5 grant-signer-key + signer-key current-contract auth-id signer-sig + )) + (try! (contract-call? 'ST000000000000000000002AMW42H.pox-5 register-signer + signer-manager signer-key + )) + ) +) +`.trim(); +} + +async function contractExists(contractAddress: string, contractName: string) { + try { + const result = await contractsApi.getContractSource({ contractAddress, contractName }); + return !!result.source; + } catch { + return false; + } +} + +async function waitForContract(contractAddress: string, contractName: string) { + for (let attempt = 1; attempt <= 90; attempt++) { + if (await contractExists(contractAddress, contractName)) return; + await new Promise(resolve => setTimeout(resolve, 2000)); + } + throw new Error(`Timed out waiting for ${contractAddress}.${contractName} to deploy`); +} + +async function broadcastPox5Tx(tx: StacksTransaction, account: Account, label: string) { + const result = await broadcastTransaction(tx, network); + if (result.error) { + account.logger.error({ ...result, label }, `Error broadcasting ${label}`); + throw new Error(`Error broadcasting ${label}: ${JSON.stringify(result)}`); + } + account.logger.info({ txid: result.txid, label }, `Broadcast ${label}`); + return result.txid; +} + +function makePox5GrantSignature(account: Account, signerManagerName: string, authId: number) { + const signerManagerPrincipal = `${account.stxAddress}.${signerManagerName}`; + const domain = tupleCV({ + name: stringAsciiCV('pox-5-signer'), + version: stringAsciiCV('1.0.0'), + 'chain-id': uintCV(chainId), + }); + const message = tupleCV({ + topic: stringAsciiCV('grant-authorization'), + 'signer-manager': principalCV(signerManagerPrincipal), + 'auth-id': uintCV(authId), + }); + return signStructuredData({ + message, + domain, + privateKey: account.signerPrivKey, + }).data; +} + +async function ensurePox5Signer(account: Account) { + const contractName = pox5SignerManagerName(account); + let nonce = await getNonce(account.stxAddress, network); + + if (!(await contractExists(account.stxAddress, contractName))) { + const deployTx = await makeContractDeploy({ + contractName, + codeBody: pox5SignerManagerSource(), + senderKey: account.privKey, + nonce, + fee: pox5DeployFee, + anchorMode: AnchorMode.Any, + network, + clarityVersion: POX5_CLARITY_VERSION, + postConditionMode: PostConditionMode.Allow, + }); + await broadcastPox5Tx(deployTx, account, `${contractName} deploy`); + await waitForContract(account.stxAddress, contractName); + nonce += 1n; + } + + const authId = randInt(); + const signerSignature = makePox5GrantSignature(account, contractName, authId); + const registerTx = await makeContractCall({ + contractAddress: account.stxAddress, + contractName, + functionName: 'register-self', + functionArgs: [ + contractPrincipalCV(account.stxAddress, contractName), + bufferCV(hexToBytes(account.signerPubKey)), + uintCV(authId), + bufferCV(hexToBytes(signerSignature)), + ], + senderKey: account.privKey, + nonce, + fee: pox5CallFee, + anchorMode: AnchorMode.Any, + network, + postConditionMode: PostConditionMode.Allow, + }); + await broadcastPox5Tx(registerTx, account, `${contractName} register-self`); + return nonce + 1n; +} + +async function hasPox5Stake(account: Account) { + try { + const result = await callReadOnlyFunction({ + contractAddress: POX5_BOOT_ADDRESS, + contractName: POX5_CONTRACT_NAME, + functionName: 'get-staker-info', + functionArgs: [principalCV(account.stxAddress)], + senderAddress: account.stxAddress, + network, + }); + return cvToString(result) !== 'none'; + } catch (error) { + account.logger.warn({ error }, 'Could not read PoX-5 staker info'); + return false; + } +} + +async function stakePox5(poxInfo: PoxInfo, account: Account, totalBalance: bigint) { + const baseStackingAmount = getFixedStackingAmount( + poxInfo.next_cycle.id, + poxInfo.next_cycle.min_threshold_ustx + ); + const amountToStack = baseStackingAmount * BigInt(account.targetSlots); + + if (totalBalance < amountToStack) { + throw new Error( + `Insufficient balance to PoX-5 stake (required=${amountToStack}, total=${totalBalance})` + ); + } + + const contractName = pox5SignerManagerName(account); + const nonce = await ensurePox5Signer(account); + const startBurnHeight = poxInfo.current_burnchain_block_height ?? 0; + const stakeTx = await makeContractCall({ + contractAddress: POX5_BOOT_ADDRESS, + contractName: POX5_CONTRACT_NAME, + functionName: 'stake', + functionArgs: [ + contractPrincipalCV(account.stxAddress, contractName), + uintCV(amountToStack), + uintCV(stackingCycles), + uintCV(startBurnHeight), + noneCV(), + ], + senderKey: account.privKey, + nonce, + fee: pox5CallFee, + anchorMode: AnchorMode.Any, + network, + postConditionMode: PostConditionMode.Allow, + }); + account.logger.debug( + { + signerManager: `${account.stxAddress}.${contractName}`, + amountToStack: amountToStack.toString(), + targetSlots: account.targetSlots, + startBurnHeight, + cycles: stackingCycles, + }, + 'PoX-5 stake with args' + ); + await broadcastPox5Tx(stakeTx, account, 'pox-5 stake'); +} + +async function stakeUpdatePox5(account: Account) { + const contractName = pox5SignerManagerName(account); + const nonce = await ensurePox5Signer(account); + const stakeUpdateTx = await makeContractCall({ + contractAddress: POX5_BOOT_ADDRESS, + contractName: POX5_CONTRACT_NAME, + functionName: 'stake-update', + functionArgs: [ + contractPrincipalCV(account.stxAddress, contractName), + contractPrincipalCV(account.stxAddress, contractName), + uintCV(stackingCycles), + uintCV(0), + noneCV(), + ], + senderKey: account.privKey, + nonce, + fee: pox5CallFee, + anchorMode: AnchorMode.Any, + network, + postConditionMode: PostConditionMode.Allow, + }); + await broadcastPox5Tx(stakeUpdateTx, account, 'pox-5 stake-update'); +} + async function loop() { const stackingKeys = process.env.STACKING_KEYS?.split(',') || []; diff --git a/docker/stacker/stacking/tx-broadcaster.ts b/docker/stacker/stacking/tx-broadcaster.ts index 3e2cd80..f61aa5f 100644 --- a/docker/stacker/stacking/tx-broadcaster.ts +++ b/docker/stacker/stacking/tx-broadcaster.ts @@ -8,7 +8,7 @@ import { broadcastTransaction, StacksTransaction, } from '@stacks/transactions'; -import { logger } from './common'; +import { isNodeNotReadyError, logger } from './common'; const broadcastInterval = parseInt(process.env.NAKAMOTO_BLOCK_INTERVAL ?? '2'); const url = `http://${process.env.STACKS_CORE_RPC_HOST}:${process.env.STACKS_CORE_RPC_PORT}`; @@ -93,7 +93,7 @@ async function waitForNakamoto() { break; } } catch (error) { - if (/(ECONNREFUSED|ENOTFOUND|SyntaxError)/.test(error.cause?.message)) { + if (isNodeNotReadyError(error)) { logger.info( `Stacks node not ready, waiting...` ); diff --git a/docker/stacks/stacks-follower.toml b/docker/stacks/stacks-follower.toml index cac84ba..93cf76e 100644 --- a/docker/stacks/stacks-follower.toml +++ b/docker/stacks/stacks-follower.toml @@ -6,6 +6,9 @@ p2p_bind = "0.0.0.0:20444" prometheus_bind = "0.0.0.0:9153" working_dir = "/data/chainstate" bootstrap_node = "$BOOTSTRAP_NODE" +pox_5_sbtc_contract = "$POX_5_SBTC_CONTRACT" +pox_5_sbtc_registry_contract = "$POX_5_SBTC_REGISTRY_CONTRACT" +pox_5_bond_admin = "$POX_5_BOND_ADMIN" wait_time_for_microblocks = 0 mine_microblocks = false use_test_genesis_chainstate = true @@ -88,6 +91,10 @@ epoch_name = "3.3" start_height = $STACKS_34_HEIGHT epoch_name = "3.4" +[[burnchain.epochs]] +start_height = $STACKS_40_HEIGHT +epoch_name = "4.0" + [[ustx_balance]] address = "ST2SBXRBJJTH7GV5J93HJ62W2NRRQ46XYBK92Y039" # Deployer Account amount = 10000000000000000 @@ -122,6 +129,18 @@ amount = 10000000000000000 address = "ST1MDWBDVDGAANEH9001HGXQA6XRNK7PX7A7X8M6R" # Stacker 3 amount = 10000000000000000 +[[ustx_balance]] +address = "ST2N30Q9PQPPPTBFYN4WN7KF3N2KRHZA9QFAABWP4" # Bitcoin Staking 1 +amount = 10000000000000000 + +[[ustx_balance]] +address = "ST3ZPZ54BV6BARTSGGM05PCJ5HA0XRAHYK3T8RSM8" # Bitcoin Staking 2 +amount = 10000000000000000 + +[[ustx_balance]] +address = "ST3AY3W1J4F67C5VSD8AZYD6N10P5M8SYT8WQWV40" # Bitcoin Staking 3 +amount = 10000000000000000 + [[ustx_balance]] address = "ST332DWHNM323264X869MKXFZABSE5WZ60EA07TJ1" # Tester 1 amount = 10000000000000000 diff --git a/docker/stacks/stacks-miner_signer.toml b/docker/stacks/stacks-miner_signer.toml index feb8473..be4ddcf 100644 --- a/docker/stacks/stacks-miner_signer.toml +++ b/docker/stacks/stacks-miner_signer.toml @@ -14,6 +14,9 @@ seed = "$MINER_SEED" local_peer_seed = "$MINER_SEED" miner = true stacker = true +pox_5_sbtc_contract = "$POX_5_SBTC_CONTRACT" +pox_5_sbtc_registry_contract = "$POX_5_SBTC_REGISTRY_CONTRACT" +pox_5_bond_admin = "$POX_5_BOND_ADMIN" wait_time_for_microblocks = 0 mine_microblocks = false use_test_genesis_chainstate = true @@ -114,6 +117,10 @@ epoch_name = "3.3" start_height = $STACKS_34_HEIGHT epoch_name = "3.4" +[[burnchain.epochs]] +start_height = $STACKS_40_HEIGHT +epoch_name = "4.0" + [[ustx_balance]] address = "ST2SBXRBJJTH7GV5J93HJ62W2NRRQ46XYBK92Y039" # Deployer Account amount = 10000000000000000 @@ -148,6 +155,18 @@ amount = 10000000000000000 address = "ST1MDWBDVDGAANEH9001HGXQA6XRNK7PX7A7X8M6R" # Stacker 3 amount = 10000000000000000 +[[ustx_balance]] +address = "ST2N30Q9PQPPPTBFYN4WN7KF3N2KRHZA9QFAABWP4" # Bitcoin Staking 1 +amount = 10000000000000000 + +[[ustx_balance]] +address = "ST3ZPZ54BV6BARTSGGM05PCJ5HA0XRAHYK3T8RSM8" # Bitcoin Staking 2 +amount = 10000000000000000 + +[[ustx_balance]] +address = "ST3AY3W1J4F67C5VSD8AZYD6N10P5M8SYT8WQWV40" # Bitcoin Staking 3 +amount = 10000000000000000 + [[ustx_balance]] address = "ST332DWHNM323264X869MKXFZABSE5WZ60EA07TJ1" # Tester 1 amount = 10000000000000000 diff --git a/docker/tests/hacknet-liveness.sh b/docker/tests/hacknet-liveness.sh index 3938d20..7053082 100755 --- a/docker/tests/hacknet-liveness.sh +++ b/docker/tests/hacknet-liveness.sh @@ -4,6 +4,7 @@ set -u DEBUG="${DEBUG:-0}" +STACKS_40_HEIGHT="${STACKS_40_HEIGHT:-262}" FAILED=0 TOTAL=0 @@ -108,6 +109,66 @@ else check "stacks-api connected to postgres" 1 "(no 'PgNotifier connected' in stacks-api logs)" fi +# 12. PoX-5 setup helper did not fail +pox5_setup_state=$(docker inspect -f '{{.State.Status}} {{.State.ExitCode}}' pox5-setup 2>/dev/null || true) +case "$pox5_setup_state" in + running*|exited\ 0) + check "pox5-setup not failed" 0 "$pox5_setup_state" + ;; + *) + check "pox5-setup not failed" 1 "${pox5_setup_state:-pox5-setup container not found}" + ;; +esac + +bitcoin_staking_state=$(docker inspect -f '{{.State.Status}} {{.State.ExitCode}}' bitcoin-staking 2>/dev/null || true) +if [ -n "$bitcoin_staking_state" ]; then + case "$bitcoin_staking_state" in + running*|exited\ 0) + check "bitcoin-staking not failed" 0 "$bitcoin_staking_state" + ;; + *) + check "bitcoin-staking not failed" 1 "$bitcoin_staking_state" + ;; + esac +fi + +# 13+. PoX-5 checks, only required after Epoch 4.0 activation +burn_height=$(echo "$info" | jq -r '.burn_block_height // 0' 2>/dev/null) +burn_height="${burn_height:-0}" +if [ "$burn_height" -ge "$STACKS_40_HEIGHT" ] 2>/dev/null; then + pox=$(curl -sf -m 5 "http://localhost:20443/v2/pox" 2>&1 || true) + pox_contract=$(echo "$pox" | jq -r '.contract_id // empty' 2>/dev/null) + if [[ "$pox_contract" == *.pox-5 ]]; then + check "active PoX contract is pox-5" 0 "$pox" + else + check "active PoX contract is pox-5" 1 "$pox" + fi + + for contract in \ + "ST2SBXRBJJTH7GV5J93HJ62W2NRRQ46XYBK92Y039/sbtc-registry" \ + "ST2SBXRBJJTH7GV5J93HJ62W2NRRQ46XYBK92Y039/sbtc-token" \ + "ST24VB7FBXCBV6P0SRDSPSW0Y2J9XHDXNHW9Q8S7H/pox5-signer-0" \ + "ST2XAK68AR2TKBQBFNYSK9KN2AY9CVA91A7CSK63Z/pox5-signer-1" \ + "ST1J9R0VMA5GQTW65QVHW1KVSKD7MCGT27X37A551/pox5-signer-2" + do + contract_addr="${contract%/*}" + contract_name="${contract#*/}" + contract_resp=$(curl -sf -m 5 "http://localhost:20443/v2/contracts/source/${contract_addr}/${contract_name}" 2>&1 || true) + if echo "$contract_resp" | jq -e '.source | length > 0' >/dev/null 2>&1; then + check "contract deployed ${contract_addr}.${contract_name}" 0 + else + check "contract deployed ${contract_addr}.${contract_name}" 1 "$contract_resp" + fi + done + + waterfall_errors=$(docker logs stacks-miner-1 2>/dev/null | grep -E "Invalid waterfall block commit|Expected reward set to be present during waterfall" || true) + if [ -z "$waterfall_errors" ]; then + check "no waterfall commit errors" 0 + else + check "no waterfall commit errors" 1 "$waterfall_errors" + fi +fi + # Summary echo if [ "$FAILED" -eq 0 ]; then