Read-Only Queries
Informational reads — positions, bond browsing, ratio quotes, and schedule helpers. No transactions.
Read-only queries cover the surface a wallet, explorer, or dashboard hits to render PoX-5 state without sending a transaction. The flows below group them by what they answer:
- Positions — what a given address has locked, across STX-only and paired-bond legs.
- Bond browsing — capacity, target APY, ratio, and the caller's allowlist entry for an upcoming bond.
- Ratio quotes — required STX for a given BTC commitment under a bond's static ratio.
- Schedule helpers — cycle, burn-height, and bond-period conversions for "X days until next bond opens" and re-lock window timing.
Read a staker's current position
A wallet or dashboard fetches everything needed to render "what does this address have locked?" in one shot.
import {
fetchAccountStatus,
fetchBondMembership,
fetchPoxInfo,
fetchStakerInfo,
} from '@stacks/bitcoin-staking';
const network = 'mainnet';
const address = user.stxAddress;
const [pox, account, stxOnly, bond] = await Promise.all([
fetchPoxInfo({ network }),
fetchAccountStatus({ address, network }),
fetchStakerInfo({ address, network }), // STX-only position (if any)
fetchBondMembership({ address, network }), // paired-bond position (if any)
]);
const position = {
burnHeight: pox.currentBurnchainBlockHeight,
cycle: pox.rewardCycleId,
liquidUstx: account.balance - account.locked,
lockedUstx: account.locked,
unlockHeight: account.unlockHeight,
stxOnly: stxOnly.staked ? stxOnly.details : null,
bond: bond ?? null,
};fetchStakerInfo returns { staked: false } if the address has no STX-only lock, or { staked: true, details: { amountUstx, firstRewardCycle, numCycles, signer } } — details is the decoded staker-info tuple. The details.signer field (pox-5.clar:224) is the principal a client passes as oldSignerManager to stake-update / unstake.
fetchBondMembership returns the decoded protocol-bond-memberships tuple — { bondIndex, amountUstx, signer, isL1Lock, amountSats }. amountSats is the BTC shares currently attributed to the membership — an L1 lockup total when isL1Lock is true, an sBTC contribution when false — and is the authoritative source for per-cycle bond share accounting. fetchProtocolBondMemberships reads the raw protocol-bond-memberships map entry and does not filter out expired memberships, unlike fetchBondMembership (which goes through get-bond-membership) — the latter returns undefined once the bond's unlock cycle is reached.
Browse an upcoming bond
A partner or pool operator inspects the next bond's parameters before enrolling: capacity, target APY, ratio, open burn height, and their own allowlist entry.
fetchBond returns the decoded protocol-bonds tuple — { bondIndex, targetRateBps, stxValueRatio, minUstxRatioBps, earlyUnlockBytes }. The earlyUnlockBytes field is the early-exit subscript embedded in the L1 lockup script (a pre-pushed Bitcoin script fragment that validates the early-exit key(s) signature and must leave a valid boolean result on the stack — consumed by the shared OP_VERIFY after OP_ENDIF — e.g. <pubkey> OP_CHECKSIG or an M-of-N OP_CHECKMULTISIG template). The openBurnHeight and firstRewardCycle are not stored on the bond — derive them with the per-bond conversions (bondPeriodToBurnHeight, bondPeriodToRewardCycle), which take { bondIndex, poxInfo } and read the first bond-period cycle off poxInfo.contractVersions internally (firstPox5RewardCycle(pox) exposes the same lookup for an explicit is-pox-5-active check).
import {
bondPeriodToBurnHeight,
bondPeriodToRewardCycle,
fetchBond,
fetchBondAllowance,
fetchPoxInfo,
fetchProtocolBond,
fetchTotalSbtcStakedForBond,
firstPox5RewardCycle,
} from '@stacks/bitcoin-staking';
const bondIndex = 7;
const [pox, bond, filledSbtc, allowance] = await Promise.all([
fetchPoxInfo({ network }),
fetchBond({ bondIndex, network }), // or fetchProtocolBond — same shape via the read-only
fetchTotalSbtcStakedForBond({ bondIndex, network }),
fetchBondAllowance({ bondIndex, address: user.stxAddress, network }),
]);
const firstBondPeriodCycle = firstPox5RewardCycle(pox); // derived from pox.contractVersions; mirrors pox-5's first-bond-period-cycle data var
if (firstBondPeriodCycle === undefined) throw new Error('pox-5 not yet configured');
const openBurnHeight = bondPeriodToBurnHeight({
bondIndex,
poxInfo: pox,
});
const summary = {
bondIndex,
status: openBurnHeight > pox.currentBurnchainBlockHeight ? 'announced' : 'open',
openBurnHeight,
blocksUntilOpen: Math.max(0, openBurnHeight - pox.currentBurnchainBlockHeight),
filledSbtc,
targetRateBps: bond.targetRateBps,
stxValueRatio: bond.stxValueRatio,
minUstxRatioBps: bond.minUstxRatioBps,
earlyUnlockBytes: bond.earlyUnlockBytes,
myAllowanceSats: allowance,
};fetchProtocolBond is the read-only wrapper around the same data; fetchBond reads the map entry directly. Both return the same shape.
Quote required STX for a BTC commitment
Before enrolling, a partner computes how much STX must be paired with a given sats amount under the bond's static ratio.
import { fetchBond, minUstxForSatsAmount } from '@stacks/bitcoin-staking';
const bondIndex = 7;
const intendedSats = 2_500_000_000n; // 25 BTC
const bond = await fetchBond({ bondIndex, network });
if (!bond) throw new Error('bond not configured');
const requiredUstx = minUstxForSatsAmount({
sats: intendedSats,
stxValueRatio: bond.stxValueRatio,
minUstxRatioBps: bond.minUstxRatioBps,
});Schedule helper
Convert between bond index, reward cycle, and burn height. Useful for "X days until next bond opens", re-lock window timing, and indexer pagination.
import {
bondPhaseRanges,
bondStatus,
fetchBondL1UnlockHeight,
fetchPoxInfo,
fetchProtocolBond,
firstPox5RewardCycle,
isBondActiveAtHeight,
isInPreparePhase,
} from '@stacks/bitcoin-staking';
const bondIndex = 7;
const [pox, bond, l1Unlock] = await Promise.all([
fetchPoxInfo({ network }),
fetchProtocolBond({ bondIndex, network }), // some/none — has setup-bond been called?
fetchBondL1UnlockHeight({ bondIndex, network }), // BTC CLTV unlock for the bond's L1 lockup
]);
const firstBondPeriodCycle = firstPox5RewardCycle(pox);
if (firstBondPeriodCycle === undefined) throw new Error('pox-5 not yet configured');
const now = pox.currentBurnchainBlockHeight;
const [open, locked, unlocked] = bondPhaseRanges({ bondIndex, poxInfo: pox });
const schedule = {
bondIndex,
status: bondStatus({ bondIndex, poxInfo: pox, isBondSetup: bond !== undefined }),
registrationCutoff: open.endBurnHeight, // exclusive; final prepare phase folds into `locked`
lockedRange: locked, // { name, startBurnHeight, length, endBurnHeight }
reLockWindow: unlocked, // ≈ 7.3d on mainnet
blocksUntilClose: Math.max(0, unlocked.endBurnHeight - now),
l1UnlockHeight: l1Unlock,
inPreparePhase: isInPreparePhase({ burnHeight: now, poxInfo: pox }),
isActiveNow: isBondActiveAtHeight({ bondIndex, burnHeight: now, poxInfo: pox }),
};bondPhaseRanges returns the bond's lifecycle as four named, burn-height-anchored ranges — open, locked, unlocked, finished — each { name, startBurnHeight, length, endBurnHeight } with an exclusive endBurnHeight (the next phase begins there). It is PoxInfo-pure: no fetches. open ends prepareCycleLength blocks before the bond's start height, because registration is blocked in the final prepare phase (ERR_STAKE_IN_PREPARE_PHASE) — so open.endBurnHeight is the practical registration cutoff. unlocked is the last rewardCycleLength / 2 blocks of the term (the re-lock window), and finished is capped at one bond term's worth of blocks as a UI convention.
bondStatus classifies the current burn height (poxInfo.currentBurnchainBlockHeight) into one of those phase names without assuming the bond exists on-chain. For a bond where setup-bond hasn't been called (isBondSetup: false) it resolves to too-early (before the setup window), eligible (inside the BOND_GAP_CYCLES setup window — the admin can setup-bond now), or missed (the start height passed without setup; this bond period can never run). fetchBondStatus is the fetching variant — it fetches poxInfo and the setup check (fetchProtocolBond) for whatever isn't injected:
import { fetchBondStatus } from '@stacks/bitcoin-staking';
const status = await fetchBondStatus({ bondIndex, network });
// 'open' | 'locked' | 'unlocked' | 'finished' | 'too-early' | 'eligible' | 'missed'The same boundaries can be derived by hand with the pure conversions (all take { ..., poxInfo }, no network):
bondPeriodToRewardCycle/bondPeriodToBurnHeight— a bond's first reward cycle and start burn height.rewardCycleToBurnHeight— mirrorspox-5.reward-cycle-to-burn-height.burnHeightToRewardCycle— mirrorspox-5.burn-height-to-reward-cycle; throws whenburnHeightis belowfirst-burnchain-block-height, mirroring the contract's runtime abort.burnHeightToDistributionIndex— mirrorspox-5.burn-height-to-distribution-index; distribution cycles tick twice per reward cycle (everyrewardCycleLength / 2burn blocks).
import { bondPeriodToRewardCycle, rewardCycleToBurnHeight } from '@stacks/bitcoin-staking';
const firstRewardCycle = bondPeriodToRewardCycle({ bondIndex, poxInfo: pox });
const closeBurnHt = rewardCycleToBurnHeight({
cycle: firstRewardCycle + 12, // BOND_LENGTH_CYCLES
poxInfo: pox,
});
// Re-lock window = pox-reward-cycle-length / 2.
// Mainnet: 2100 / 2 = 1050 blocks (~7.3 days). Testnet: 1050 / 2 = 525.
const reLockWindowBlocks = Math.floor(pox.rewardCycleLength / 2);The re-lock window at the end of every bond is when BTC unlocks before STX. On mainnet it is 1,050 blocks ≈ 7.3 days (pox-reward-cycle-length / 2, with pox-reward-cycle-length = 2100); on testnet it is 525. The contract's get-bond-l1-unlock-height(bondIndex) returns bondPeriodToBurnHeight(bondIndex + 6) - (pox-reward-cycle-length / 2); fetchBondL1UnlockHeight wraps it. This is the read to use for L1 timelock construction; the unlock-burn-height returned from register-for-bond / stake / stake-update is the start of the unlock cycle (a different value derived from reward-cycle-to-burn-height) and is only for indexing the membership's unlock schedule.
isInPreparePhase is the gate that register-for-bond, stake, stake-update, update-bond-registration, and unstake all hit (ERR_STAKE_IN_PREPARE_PHASE u47 and ERR_UNSTAKE_IN_PREPARE_PHASE u28).
Earned rewards and protocol totals
The reward model splits "what is this signer owed?" into two raw on-chain reads, plus the get-earned aggregator that combines them (pox-5.clar:2341, pox-5.clar:3217, pox-5.clar:3203):
get-earned(signer, reward-cycle, bond-index) -> uint— pending + accrued, in sBTC sats. The single number a UI should display.get-signer-unclaimed-rewards-for-cycle— running pending sBTC for{ reward-cycle, bond-index, signer }since the last settlement (bond-index: (some N)for a bond cycle,nonefor STX-only).get-signer-rewards-per-token-settled-for-cycle— last-settled rewards-per-token snapshot for the same key.
import {
fetchEarned,
fetchSignerRewardsPerTokenSettled,
fetchSignerUnclaimedRewards,
} from '@stacks/bitcoin-staking';
const signerManager = 'SP000…USER.signer-manager';
const bondIndex = 7;
// bond legs are keyed per reward cycle
const rewardCycle = pox.rewardCycleId;
const [earned, unclaimed, rptSettled] = await Promise.all([
fetchEarned({ signerManager, rewardCycle, bondIndex, network }),
fetchSignerUnclaimedRewards({ signerManager, rewardCycle, bondIndex, network }),
fetchSignerRewardsPerTokenSettled({ signerManager, rewardCycle, bondIndex, network }),
]);Staker-level rewards
For a signer manager paying individual stakers out of its slice, the staker-level layer mirrors the signer-level reads one level down (pox-5.clar:2358, pox-5.clar:3247, pox-5.clar:3231, pox-5.clar:3263):
get-earned-staker-rewards(signer, reward-cycle, bond-index, staker) -> uint— pending + accrued for a single staker under one signer.get-staker-unclaimed-rewards-for-cycle— running pending sBTC for{ reward-cycle, bond-index, signer, staker }.get-staker-rewards-per-token-settled-for-cycle— staker's last-settled RPT snapshot.get-signer-rewards-per-token-for-cycle— the signer's own RPT watermark for the cycle, used as the staker-side RPT reference. Returns0nfor signers that never crossedSIGNER_SET_MIN_USTXin the cycle.
import {
fetchBondMembership,
fetchBondOverlapsNewPosition,
fetchEarnedStakerRewards,
fetchSignerRewardsPerTokenForCycle,
fetchStakerCustodiedSbtc,
fetchStakerRewardsPerTokenSettled,
fetchStakerUnclaimedRewards,
} from '@stacks/bitcoin-staking';
const staker = 'SP000…STAKER';
// bond legs are keyed per reward cycle
const rewardCycle = pox.rewardCycleId;
const [stakerEarned, stakerUnclaimed, stakerRptSettled, signerRptForCycle, custodiedSbtc, membership] =
await Promise.all([
fetchEarnedStakerRewards({ signerManager, staker, rewardCycle, bondIndex, network }),
fetchStakerUnclaimedRewards({ signerManager, staker, rewardCycle, bondIndex, network }),
fetchStakerRewardsPerTokenSettled({ signerManager, staker, rewardCycle, bondIndex, network }),
fetchSignerRewardsPerTokenForCycle({ signerManager, rewardCycle, bondIndex, network }),
fetchStakerCustodiedSbtc({ staker, network }),
fetchBondMembership({ address: staker, network }),
]);
// Pre-flight check for `register-for-bond` / `stake` rollovers — true if the
// caller's existing membership term overlaps the proposed new first reward cycle.
// Pass `membership: undefined` if the caller has no existing bond membership.
const overlaps = await fetchBondOverlapsNewPosition({
membership,
newFirstRewardCycle: 200,
network,
});fetchStakerCustodiedSbtc returns the sBTC the contract currently holds for a given staker (the source side of roll-sbtc). fetchBondOverlapsNewPosition wraps the on-chain overlap predicate and is the right call to gate a rollover UI without broadcasting.
For protocol-wide totals:
import {
fetchPoxInfo,
fetchTotalSbtcStaked,
fetchTotalSbtcStakedForBond,
fetchTotalUstxStacked,
firstPox5RewardCycle,
} from '@stacks/bitcoin-staking';
const [pox, sbtcTotal, sbtcForBond7, ustxForCycle142] = await Promise.all([
fetchPoxInfo({ network }),
fetchTotalSbtcStaked({ network }),
fetchTotalSbtcStakedForBond({ bondIndex: 7, network }),
fetchTotalUstxStacked({ rewardCycle: 142, network }),
]);
const firstCycle = firstPox5RewardCycle(pox);