Eligibility Preflights
Dry-run any PoX-5 entrypoint's assert chain client-side and get back the contract's own error codes before broadcasting.
Every state-changing PoX-5 entrypoint guards itself with a chain of asserts, and a failed assert costs the sender a transaction fee for nothing. @stacks/bitcoin-staking ships a fetchEligible* preflight for each entrypoint that rebuilds those asserts client-side from read-only fetches — no transaction, no fee, no signature. Run the preflight, and if it passes, build and broadcast; if it fails, you get the exact error codes the contract would return.
All examples use @stacks/bitcoin-staking for the PoX-5 surface.
The EligibilityResult contract
Every preflight resolves to the same shape:
type EligibilityResult =
| { ok: true }
| { ok: false; reasons: [Pox5ErrorCode, ...Pox5ErrorCode[]] };On ok: false, reasons lists every gate that would fail — not just the first — as the contract's own error codes (Pox5ErrorCode), in the order the contract evaluates them. reasons[0] is the error the transaction would actually abort with; the rest tell you what would fail next once you've fixed it. The POX5_ERROR_NAMES and POX5_ERROR_DESCRIPTIONS maps translate each code to its Clarity constant name (e.g. ERR_NOT_ALLOWLISTED) and a human-readable description.
Two caveats apply across the whole family:
Point-in-time. Timing gates (prepare phase, bond setup windows, bond start heights) are evaluated against the current burn height. A result that is true now can flip by the time your transaction is mined — treat a passing preflight near a cycle boundary with suspicion.
Some gates can't be reproduced off-chain. The signer-manager's
validate-stake!trait call, parts of the L1 SPV proof, andcontract-callerconstruction concerns are only enforced on-chain (details per function below). A passing preflight is a strong signal, not a guarantee.
Preflights that need chain metadata accept an optional poxInfo (from fetchPoxInfo), so callers that already hold it avoid the extra round-trip.
Worked example: register-for-bond
fetchEligibleRegisterForBond takes the same identifying inputs you'd pass to buildRegisterForBond and checks the allowlist, timing, STX minimum and balance, signer registration and key grant, overlapping positions, and rollover window:
import { POX5_ERROR_NAMES, fetchEligibleRegisterForBond } from '@stacks/bitcoin-staking';
const network = 'mainnet';
const eligible = await fetchEligibleRegisterForBond({
bondIndex: 4,
staker: user.stxAddress, // the future tx-sender
amountUstx: 150_000_000_000n,
satsTotal: 100_000_000n, // sBTC amount, or summed L1 lockup outputs
signerManager: 'SP000…USER.signer-manager',
network,
});
if (!eligible.ok) {
// reasons[0] is what the tx would actually abort with
const named = eligible.reasons.map(code => `${POX5_ERROR_NAMES[code]} (u${code})`);
throw new Error(`register-for-bond would fail: ${named.join(', ')}`);
}
// Safe to buildRegisterForBond(...) with the same inputs and broadcast.When registering with an L1 lockup (kind: 'btc'), also pass the lockup outputs — this enables the unlock-height (u52), duplicate-outpoint (u46), and block-header (u40) SPV checks. Pass the summed output sats as satsTotal regardless. The merkle proof (u41), output script (u42), amount (u45), and tx-parse (u39) legs of the proof are verified only on-chain.
In simpler flows a one-line guard before the builder call is enough:
The full family
Each preflight gates exactly one entrypoint:
fetchEligibleRegisterForBond
register-for-bond
Allowlist, prepare phase, bond exists and hasn't started, STX minimum and balance, signer registered with active key grant, satsTotal within allowance, no overlapping position, rollover within the L1 unlock window; with outputs: unlock-height, duplicate outpoint, header validity
fetchEligibleUpdateBondRegistration
update-bond-registration
Active bond membership, not in prepare phase, oldSignerManager matches the current signer, new signer differs and is registered with an active key grant
fetchEligibleAnnounceL1EarlyExit
announce-l1-early-exit
Active bond membership, not in prepare phase, membership is an L1 lock, oldSignerManager matches, no early exit already announced
fetchEligibleUnstakeSbtc
unstake-sbtc
Bond membership exists (expired-but-present counts), amountToWithdrawSats within the staker's shares, not in prepare phase, signerManager matches, membership is sBTC-backed
fetchEligibleStake
stake
Not in prepare phase, signer registered with active key grant, startBurnHt resolves to the next cycle, lock period in [1, MAX_NUM_CYCLES], no existing STX-only stake or overlapping bond, rollover within the L1 unlock window, balance covers amountUstx
fetchEligibleStakeUpdate
stake-update
Active STX-only stake, not in prepare phase, oldSignerManager matches, new signer registered with active key grant, resulting lock period in [1, MAX_NUM_CYCLES], unlocked STX covers amountIncrease
fetchEligibleUnstake
unstake
Active STX-only stake, oldSignerManager matches, not in prepare phase — every on-chain gate is covered
fetchEligibleCalculateRewards
calculate-rewards
Distribution cycle not already computed, bondIndices includes every active bond at the calculation height, each listed bond exists and is active, list ordered by descending stx-value-ratio (ties: ascending bond index)
fetchEligibleClaimRewards
claim-rewards
Rewards not permanently paused, total claimable > 0 across the STX-only leg and each bondIndices leg
fetchEligibleSetupBond
setup-bond
Caller is the bond admin, setup window open (not too soon, not too late), bond index unused, no duplicate stakers in the allowlist
fetchEligibleSetBondAdmin
set-bond-admin
Caller is the current bond-admin
fetchEligibleGrantSignerKey
grant-signer-key
The (signerKey, signerManager, authId) grant triple hasn't been used, the SIP-018 signature recovers to signerKey
fetchEligibleRevokeSignerGrant
revoke-signer-grant
Caller is the Stacks principal derived from signerKey
What preflights can't see
A few gates live outside what read-only fetches can reproduce; a passing preflight does not clear them:
validate-stake!—register-for-bond,update-bond-registration,stake, andstake-updateall call into the signer-manager contract, which may reject the transaction on its own logic (allowlisting, capacity, fees). See Signers for how managers own approval.Caller-construction gates —
announce-l1-early-exitrequirescontract-caller == tx-sender == staker(send it directly from the staker, never through an intermediary contract), andgrant-signer-keyrequirescontract-caller == signerManager(the signer-manager contract submits it itself). These fail at the contract regardless of what the preflight says about state.Token transfers —
unstake-sbtcandclaim-rewardsmove sBTC the contract already custodies, so the transfer itself isn't checked.L1 SPV proof legs — for
register-for-bondwithoutputs, the merkle proof, output script, amount, and tx-parse checks run only on-chain.
A few behavioral notes worth knowing:
fetchEligibleStakeUpdatereports a lock period ≤ 0 asINVALID_NUM_CYCLES, though on-chain a negative result is a uint underflow (runtime abort) rather than that error code.fetchEligibleGrantSignerKeycollapses the contract'sERR_INVALID_SIGNATURE_RECOVER (u13)(malformed signature) andERR_INVALID_SIGNATURE_PUBKEY (u14)(recovers to a different key) into the latter.fetchEligibleClaimRewardstakes a reward cycle, not a distribution-cycle index — passing the wrong cycle yields 0 earned and aNO_CLAIMABLE_REWARDSresult rather than an error.
Was this helpful?