Reference / PoX-5 staking

PoX-5 Bitcoin Staking

Stake STX, register for BTC-paired bonds, build L1 lockup scripts, and manage signer-key grants against pox-5 (SIP-045) — one client extension, activation-aware from day one.

SIP-045 / Epoch 4.0

Ship before the fork, live at Bitcoin block 960,230

Epoch 4.0 activates at Bitcoin block 960,230 (~July 29, 2026). The module is pinned against the final pox-5 contract in stacks-core 4.0.0. Everything off-chain — lockup scripts, grant signing, cycle math — works today; contract calls go live the moment the chain crosses the activation height, and your code finds out by asking the chain, not a constant.

bun add @secondlayer/stacks

PoX-5 is a subpath module — it tree-shakes out if you don't use it.

import { pox5 } from "@secondlayer/stacks/pox5";

Extend a wallet client, gate on activation, stake.

import { createWalletClient, http, mainnet } from "@secondlayer/stacks";
import { privateKeyToAccount } from "@secondlayer/stacks/accounts";
import { pox5 } from "@secondlayer/stacks/pox5";

const client = createWalletClient({
  account: privateKeyToAccount(process.env.KEY!),
  chain: mainnet,
  transport: http(),
}).extend(pox5());

if (await client.pox5.isActive()) {
  const txid = await client.pox5.stake({
    signerManager: "SP2J6ZY48GV1EZ5V2V5RB9MP66SW86PYKKNRV9EJ7.signer-mgr",
    amountUstx: 100_000_000_000n, // 100,000 STX
    numCycles: 12,
    startBurnHeight: 960_231,
    fee: "low",
  });
  await client.waitForTransactionReceipt({ txid, confirmations: 1 });
}

Every action routes through the same transaction pipeline as callContract: fee tiers (min | low | mid | high), managed nonces, typed BroadcastError reasons. An action returns a txid — pair it with waitForTransactionReceipt to await inclusion.

No hardcoded heights. isActive() and getActivation() read the chain's /v2/pox contract_versions — the node reports where pox-5 activates on its network, so the same code is correct on mainnet, testnet, and devnet.

const activation = await client.pox5.getActivation();
// { contractId, activationBurnchainBlockHeight, firstRewardCycleId }
// undefined until the node runs stacks-core >= 4.0.0

const live = await client.pox5.isActive();
// true once the burnchain reaches the activation height

Ship your integration before the fork: actions against an inactive chain fail with a descriptive error naming the activation height, never a raw contract abort.

getStakerState returns a staker's whole position in one batched request — staker info, bond membership, custodied sBTC, and the current cycle. The state a dashboard or agent polls, without four round-trips.

const state = await client.pox5.getStakerState("SP2J6ZY48GV1EZ5V2V5RB9MP66SW86PYKKNRV9EJ7");
// { stakerInfo, bondMembership, custodiedSbtc, currentCycle }

Individual reads are exposed too: getStakerInfo, getBondMembership, getProtocolBond, getBondAllowance, getTotalSbtcStakedForBond, getStakerCustodiedSbtc, hasAnnouncedL1EarlyExit, getBondL1UnlockHeight, getSignerInfo, verifySignerKeyGrant, getCurrentRewardCycle, getFirstRewardCycle.

Individual reads return plain JS values decoded from the contract's own ABI — uint becomes bigint, tuples become camelCase objects, and an optional read returns null when the record doesn't exist:

const info = await client.pox5.getStakerInfo("SP2J6ZY48GV1EZ5V2V5RB9MP66SW86PYKKNRV9EJ7");
// { amountUstx, firstRewardCycle, numCycles, signer } | null

Every pox-5 public function, typed. All accept fee, nonce, postConditions, postConditionMode; all return a txid.

ActionContract callDoes
setupBondsetup-bondConfigure a bond's rate, ratios, early-unlock script, allowlist (bond-admin only)
registerForBondregister-for-bondJoin a bond with custodied sBTC or proven L1 BTC lockups
updateBondRegistrationupdate-bond-registrationSwitch signer-managers mid-bond
stakestakeSTX-only staking
stakeUpdatestake-updateExtend and/or increase an STX-only position
unstakeunstakeWind down an STX-only position at the next cycle
unstakeSbtcunstake-sbtcWithdraw custodied sBTC (rejected in prepare phase)
announceL1EarlyExitannounce-l1-early-exitSignal an L1 early exit (rejected in prepare phase)
calculateRewardscalculate-rewardsSettle reward accounting for up to 6 bond periods
claimRewardsclaim-rewardsSigner-manager claims a cycle's accrued rewards
claimStakerRewardsForSignerclaim-staker-rewards-for-signerClaim a staker's rewards via their signer
grantSignerKeygrant-signer-keyRegister a signed signer-key grant
revokeSignerGrantrevoke-signer-grantRevoke a grant

registerForBond takes the BTC side as a union — custodied sBTC, or SPV-proven L1 lockup outputs:

// sBTC path
await client.pox5.registerForBond({
  bondIndex: 0,
  signerManager: "SP2J6ZY48GV1EZ5V2V5RB9MP66SW86PYKKNRV9EJ7.signer-mgr",
  amountUstx: 500_000_000_000n,
  btcLockup: { sbtcSats: 100_000_000n },
});

// Proven L1 lockup path — proof fields come from buildTxProof
btcLockup: {
  l1Outputs: [{
    height: proof.height,
    tx: proof.rawTx,
    outputIndex: proof.vout,
    header: proof.header,
    leafHashes: proof.merkle.siblings,
    txCount: proof.merkle.txCount,
    txIndex: proof.merkle.txIndex,
    amount: 100_000_000n,
    unlockBurnHeight: 987_530n,
  }],
  stakerUnlockBytes,
}

buildTxProof from @secondlayer/stacks/bitcoin produces the SPV proof fields — the contract verifies the lockup tx's Bitcoin inclusion on-chain.

Works today, no chain needed. Byte-for-byte TypeScript ports of the contract's script constructors — the contract validates your L1 output against exactly these bytes, so the module builds them for you.

import {
  buildDefaultStakerUnlockBytes,
  buildLockupAddress,
  buildLockupScript,
  stakerPreimage,
} from "@secondlayer/stacks/pox5";

const stakerUnlockBytes = buildDefaultStakerUnlockBytes(compressedPubkey); // <pubkey> OP_CHECKSIG

const address = buildLockupAddress(
  {
    stxAddress: "SP2J6ZY48GV1EZ5V2V5RB9MP66SW86PYKKNRV9EJ7",
    unlockBurnHeight: 987_530,
    stakerUnlockBytes,
    earlyUnlockBytes, // from the bond's protocol-bonds.early-unlock-bytes
  },
  "mainnet",
);
// bc1q… — send the BTC lockup here
  • buildLockupScript — the witness script: a CLTV branch (spendable at unlockBurnHeight) and an early-exit branch that must reveal stakerPreimage(stxAddress).
  • buildLockupOutputScript — the P2WSH scriptPubKey (0x0020 || sha256(script)).
  • buildLockupAddress — the bech32 P2WSH address; network-aware, including regtest.
  • buildDefaultStakerUnlockBytes — the common single-key staker subscript; any script tail is valid.
  • stakerPreimage — the 32-byte witness item the early-exit branch reveals.

SIP-018 structured-data signatures authorizing a signer-manager contract to use a signer key. All three work pre-activation.

import {
  computeSignerGrantHash,
  signSignerGrant,
  verifySignerGrant,
} from "@secondlayer/stacks/pox5";

const opts = {
  signerManager: "SP2J6ZY48GV1EZ5V2V5RB9MP66SW86PYKKNRV9EJ7.signer-mgr",
  authId: 1n,
  chainId: mainnet.id,
};

const signerSig = await signSignerGrant(signerAccount, opts); // 65-byte RSV hex

verifySignerGrant({ ...opts, publicKey: signerPubkey, signature: signerSig }); // true

signSignerGrant returns the signature in RSV order — exactly the (buff 65) layout grant-signer-key expects. computeSignerGrantHash is byte-identical to the contract's get-signer-grant-message-hash, so you can cross-check any hash on-chain.

Pure functions mirroring the contract's cycle read-onlys. Nothing hardcoded — pass chain-reported parameters from /v2/pox and getActivation(), and the math is correct on any network.

import { burnHeightToRewardCycle, bondPhaseAtHeight, isInPreparePhase } from "@secondlayer/stacks/pox5";

const params = { firstBurnchainBlockHeight: 666_050, rewardCycleLength: 2_100 }; // from /v2/pox
const firstBondPeriodCycle = activation!.firstRewardCycleId; // from getActivation()

burnHeightToRewardCycle(960_231, params);                 // → cycle number
bondPhaseAtHeight(0, 960_231, { ...params, firstBondPeriodCycle }); // "too-early" | "open" | "locked" | "unlocked"
isInPreparePhase(960_231, { ...params, prepareCycleLength: 100 });  // boolean

Also exported: rewardCycleToBurnHeight, bondPeriodToRewardCycle, bondPeriodToBurnHeight, bondUnlockCycle, burnHeightToDistributionIndex.

Prepare phase rejects exits

unstake-sbtc and announce-l1-early-exit are rejected during a cycle's prepare phase (the final prepare_cycle_length blocks). Check isInPreparePhase before broadcasting — or your exit transaction burns a fee to abort.

pox-5 has no allow-contract-caller indirection like pox-4 — it acts on tx-sender / contract-caller directly. Safety is expressed with the Epoch 4.0 staking-postcondition / pox-postcondition types, passed via postConditions on any action:

await client.pox5.stake({
  signerManager: "SP2J6ZY48GV1EZ5V2V5RB9MP66SW86PYKKNRV9EJ7.signer-mgr",
  amountUstx: 100_000_000_000n,
  numCycles: 12,
  startBurnHeight: 960_231,
  postConditions: [
    { type: "staking-postcondition", address: "origin", condition: "lte", amount: 100_000_000_000n },
    { type: "pox-postcondition", address: "origin", condition: "will-not-perform" },
  ],
});

See SIP-045 staking post-conditions for the full semantics.

Pinned, not approximated

Every action is pinned against the pox-5 boot contract interface via Clarinet simnet at Epoch 4.0, and the script and grant-hash ports are byte-compared against the contract's own read-onlys (construct-lockup-script, get-signer-grant-message-hash) in CI. If stacks-core changed a byte, the build would fail before you did.

See also the Stacks SDK for clients, fees, and post-conditions, and Bitcoin SPV for building the L1 lockup proofs.