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.
bun add @secondlayer/stacksPoX-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 heightShip 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 } | nullEvery pox-5 public function, typed. All accept fee, nonce, postConditions, postConditionMode; all return a txid.
| Action | Contract call | Does |
|---|---|---|
setupBond | setup-bond | Configure a bond's rate, ratios, early-unlock script, allowlist (bond-admin only) |
registerForBond | register-for-bond | Join a bond with custodied sBTC or proven L1 BTC lockups |
updateBondRegistration | update-bond-registration | Switch signer-managers mid-bond |
stake | stake | STX-only staking |
stakeUpdate | stake-update | Extend and/or increase an STX-only position |
unstake | unstake | Wind down an STX-only position at the next cycle |
unstakeSbtc | unstake-sbtc | Withdraw custodied sBTC (rejected in prepare phase) |
announceL1EarlyExit | announce-l1-early-exit | Signal an L1 early exit (rejected in prepare phase) |
calculateRewards | calculate-rewards | Settle reward accounting for up to 6 bond periods |
claimRewards | claim-rewards | Signer-manager claims a cycle's accrued rewards |
claimStakerRewardsForSigner | claim-staker-rewards-for-signer | Claim a staker's rewards via their signer |
grantSignerKey | grant-signer-key | Register a signed signer-key grant |
revokeSignerGrant | revoke-signer-grant | Revoke 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 herebuildLockupScript— the witness script: a CLTV branch (spendable atunlockBurnHeight) and an early-exit branch that must revealstakerPreimage(stxAddress).buildLockupOutputScript— the P2WSHscriptPubKey(0x0020 || sha256(script)).buildLockupAddress— the bech32 P2WSH address; network-aware, includingregtest.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 }); // truesignSignerGrant 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 }); // booleanAlso 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.