diff --git a/src/api/routes/v3/staking-bonds.ts b/src/api/routes/v3/staking-bonds.ts index e40625bc36..ead54b72e8 100644 --- a/src/api/routes/v3/staking-bonds.ts +++ b/src/api/routes/v3/staking-bonds.ts @@ -51,6 +51,7 @@ export const StakingBondsRoutes: FastifyPluginAsync< limit: req.query.limit ?? getPagingQueryLimit(ResourceType.Tx), cursor: req.query.cursor, }); + const poxConstants = await fastify.db.getPoxConstants(); await reply.send({ limit: results.limit, total: results.total, @@ -59,7 +60,9 @@ export const StakingBondsRoutes: FastifyPluginAsync< previous: results.prev_cursor, current: results.current_cursor, }, - results: results.results.map(r => serializeDbBondSummary(r, results.burn_block_height)), + results: results.results.map(r => + serializeDbBondSummary(r, results.burn_block_height, poxConstants) + ), }); } ); @@ -86,7 +89,9 @@ export const StakingBondsRoutes: FastifyPluginAsync< if (!bond) { throw new NotFoundError('Bond not found'); } - await reply.send(serializeDbBond(bond, bond.burn_block_height)); + await reply.send( + serializeDbBond(bond, bond.burn_block_height, await fastify.db.getPoxConstants()) + ); } ); @@ -118,6 +123,7 @@ export const StakingBondsRoutes: FastifyPluginAsync< limit: req.query.limit ?? getPagingQueryLimit(ResourceType.Tx), cursor: req.query.cursor, }); + const poxConstants = await fastify.db.getPoxConstants(); await reply.send({ limit: results.limit, total: results.total, @@ -126,7 +132,7 @@ export const StakingBondsRoutes: FastifyPluginAsync< previous: results.prev_cursor, current: results.current_cursor, }, - results: results.results.map(r => serializeDbBondEvent(r)), + results: results.results.map(r => serializeDbBondEvent(r, poxConstants)), }); } ); diff --git a/src/api/schemas/v3/entities/bonds.ts b/src/api/schemas/v3/entities/bonds.ts index 5f91ab8671..f31b642e41 100644 --- a/src/api/schemas/v3/entities/bonds.ts +++ b/src/api/schemas/v3/entities/bonds.ts @@ -82,10 +82,20 @@ export const BondSchedulePointSchema = Type.Object({ }); export type BondSchedulePoint = Static; -export const BondScheduleSchema = Type.Object({ - activation: BondSchedulePointSchema, - unlock: BondSchedulePointSchema, -}); +export const BondScheduleSchema = Type.Object( + { + enrollment_cutoff: BondSchedulePointSchema, + activation: BondSchedulePointSchema, + unlock: BondSchedulePointSchema, + }, + { + description: + "The bond's lifecycle timeline. Each point is the first Bitcoin height of a new phase: " + + '`enrollment_cutoff` is the first height at which `register-for-bond` is rejected (the ' + + 'prepare phase before activation, so enrollment is open strictly before it), `activation` ' + + 'is the first height the bond is active, and `unlock` the first height it is unlocked.', + } +); export type BondSchedule = Static; export const BondSummarySchema = Type.Object({ diff --git a/src/api/serializers/v3/bonds.ts b/src/api/serializers/v3/bonds.ts index c1e55bbe81..4011719298 100644 --- a/src/api/serializers/v3/bonds.ts +++ b/src/api/serializers/v3/bonds.ts @@ -13,6 +13,7 @@ import { DbBondLockupType, DbPrincipalBondPositionStatus, } from '../../../datastore/common.js'; +import { getBondEnrollmentCutoff, PoxConstants } from '../../../datastore/pox-constants.js'; import { Pox5EventAddToAllowlist, Pox5EventAnnounceL1EarlyExit, @@ -59,12 +60,20 @@ function serializeBondStatus(summary: DbBondSummary, currentBurnBlockHeight: num /** * Serializes a database bond summary to a API bond summary. * @param summary - The database bond summary to serialize. + * @param currentBurnBlockHeight - The Bitcoin height of the chain tip, for the bond's status. + * @param poxConstants - The network's PoX constants, for the bond's enrollment cutoff. * @returns The API bond summary. */ export function serializeDbBondSummary( summary: DbBondSummary, - currentBurnBlockHeight: number + currentBurnBlockHeight: number, + poxConstants: PoxConstants ): BondSummary { + const enrollmentCutoff = getBondEnrollmentCutoff( + poxConstants, + summary.bond_start_height, + summary.first_reward_cycle + ); return { index: summary.bond_index, pox_version: 'pox5', @@ -80,6 +89,10 @@ export function serializeDbBondSummary( registered_count: summary.registered_count, }, schedule: { + enrollment_cutoff: { + bitcoin_height: enrollmentCutoff.bitcoinHeight, + pox_cycle: enrollmentCutoff.poxCycle, + }, activation: { bitcoin_height: summary.bond_start_height, pox_cycle: summary.first_reward_cycle, @@ -111,11 +124,17 @@ export function serializeDbBondSummary( /** * Serializes a database bond to a API bond. * @param bond - The database bond to serialize. + * @param currentBurnBlockHeight - The Bitcoin height of the chain tip, for the bond's status. + * @param poxConstants - The network's PoX constants, for the bond's enrollment cutoff. * @returns The API bond. */ -export function serializeDbBond(bond: DbBond, currentBurnBlockHeight: number): Bond { +export function serializeDbBond( + bond: DbBond, + currentBurnBlockHeight: number, + poxConstants: PoxConstants +): Bond { return { - ...serializeDbBondSummary(bond, currentBurnBlockHeight), + ...serializeDbBondSummary(bond, currentBurnBlockHeight, poxConstants), transaction: { tx_id: bond.tx_id, block: { @@ -138,9 +157,10 @@ export function serializeDbBond(bond: DbBond, currentBurnBlockHeight: number): B * same vocabulary as the bond, allowlist, and registration entities (grouped `{ btc, stx }` string * amounts, integer heights and cycles) instead of passing the raw synthetic event fields through, * and carry only what the event uniquely records — details available on a resource endpoint (e.g. - * a registration's proven L1 lockup outputs) are not repeated here. + * a registration's proven L1 lockup outputs) are not repeated here. The PoX constants derive the + * `setup-bond` schedule's enrollment cutoff, which the event itself does not record. */ -export function serializeDbBondEvent(event: DbBondEvent): BondEvent { +export function serializeDbBondEvent(event: DbBondEvent, poxConstants: PoxConstants): BondEvent { const base = { bond_index: parseInt((event.data as { bond_index: string }).bond_index), transaction: { @@ -162,6 +182,11 @@ export function serializeDbBondEvent(event: DbBondEvent): BondEvent { switch (event.name) { case Pox5EventName.SetupBond: { const data = event.data as unknown as Pox5EventSetupBond['data']; + const enrollmentCutoff = getBondEnrollmentCutoff( + poxConstants, + parseInt(data.bond_start_height), + parseInt(data.first_reward_cycle) + ); return { ...base, name: Pox5EventName.SetupBond, @@ -173,6 +198,10 @@ export function serializeDbBondEvent(event: DbBondEvent): BondEvent { }, early_unlock_bytes: data.early_unlock_bytes, schedule: { + enrollment_cutoff: { + bitcoin_height: enrollmentCutoff.bitcoinHeight, + pox_cycle: enrollmentCutoff.poxCycle, + }, activation: { bitcoin_height: parseInt(data.bond_start_height), pox_cycle: parseInt(data.first_reward_cycle), diff --git a/src/datastore/pox-constants.ts b/src/datastore/pox-constants.ts index 90a91c1691..507833c38f 100644 --- a/src/datastore/pox-constants.ts +++ b/src/datastore/pox-constants.ts @@ -205,6 +205,24 @@ export function getPoxCycleSchedule(c: PoxConstants, cycle: number): PoxCycleSch }; } +/** + * The first Bitcoin height at which pox-5 rejects `register-for-bond` for a bond: the start of the + * prepare phase that ends the cycle before the bond activates. The contract rejects registration + * during any prepare phase (`verify-not-prepare-phase`) and from the bond's start height on, and a + * bond always starts on a reward cycle boundary, so the prepare phase is what closes enrollment. + * Exclusive, like the bond's other schedule points: enrollment is open strictly before it. + */ +export function getBondEnrollmentCutoff( + c: PoxConstants, + bondStartHeight: number, + firstRewardCycle: number +): { bitcoinHeight: number; poxCycle: number } { + return { + bitcoinHeight: bondStartHeight - c.preparePhaseBlockLength, + poxCycle: firstRewardCycle - 1, + }; +} + /** * Where a reward cycle stands relative to the burn tip. `prepare_phase` is the cycle's final * `preparePhaseBlockLength` blocks, during which the next cycle's reward set is selected and the diff --git a/tests/api/pox5/bond-events.test.ts b/tests/api/pox5/bond-events.test.ts index fa1904ee0f..9e86cc5e79 100644 --- a/tests/api/pox5/bond-events.test.ts +++ b/tests/api/pox5/bond-events.test.ts @@ -271,6 +271,12 @@ describe('pox-5 bond events', () => { skipMigrations: true, }); api = await startApiServer({ datastore: db, chainId: STACKS_TESTNET.chainId }); + // A testnet-like geometry where the bond's start height (160) opens reward cycle 8. + await db.setPoxConstants({ + firstBurnchainBlockHeight: 0, + rewardCycleLength: 20, + preparePhaseBlockLength: 5, + }); await ingestScenario(); }); @@ -375,6 +381,8 @@ describe('pox-5 bond events', () => { }, early_unlock_bytes: '', schedule: { + // Derived from the PoX constants: cycle 7's prepare phase starts 5 blocks before 160. + enrollment_cutoff: { bitcoin_height: 155, pox_cycle: 7 }, activation: { bitcoin_height: 160, pox_cycle: 8 }, unlock: { bitcoin_height: 410, pox_cycle: 20 }, }, diff --git a/tests/api/pox5/bonds.test.ts b/tests/api/pox5/bonds.test.ts index b1994ff333..4751424604 100644 --- a/tests/api/pox5/bonds.test.ts +++ b/tests/api/pox5/bonds.test.ts @@ -69,12 +69,17 @@ interface BondSummaryItem { btc_capacity: string; }; registrations: { allowed_count: number; registered_count: number }; + schedule: Record<'enrollment_cutoff' | 'activation' | 'unlock', BondSchedulePoint>; balances: { locked: { btc: string; stx: string }; rewards: { btc: { distributed: string; accrued: string; claimed: string } }; paid_out: { btc: string }; }; } +interface BondSchedulePoint { + bitcoin_height: number; + pox_cycle: number; +} interface BondDetail extends BondSummaryItem { transaction: { tx_id: string }; } @@ -223,6 +228,27 @@ describe('pox-5 bonds (simulated ingestion)', () => { assert.equal(normalizeTxId(bond.transaction.tx_id), normalizeTxId(SETUP_TX_ID)); }); + test('the bond schedule reports its enrollment cutoff (GET .../bonds and .../bonds/:index)', async () => { + // A testnet-like geometry where the bond's start height (160) opens reward cycle 8. + await db.setPoxConstants({ + firstBurnchainBlockHeight: 0, + rewardCycleLength: 20, + preparePhaseBlockLength: 5, + }); + const expected = { + // Registration closes when cycle 7's prepare phase begins, 5 blocks before activation. + enrollment_cutoff: { bitcoin_height: 155, pox_cycle: 7 }, + activation: { bitcoin_height: BOND_START_HEIGHT, pox_cycle: FIRST_REWARD_CYCLE }, + unlock: { bitcoin_height: UNLOCK_BURN_HEIGHT, pox_cycle: UNLOCK_CYCLE }, + }; + const list = await getJson>( + '/extended/v3/staking/bonds?limit=50' + ); + assert.deepEqual(list.results.find(b => b.index === BOND_INDEX)?.schedule, expected); + const bond = await getJson(`/extended/v3/staking/bonds/${BOND_INDEX}`); + assert.deepEqual(bond.schedule, expected); + }); + test('the allowlist lists alice and bob (GET .../allowlist)', async () => { const list = await getJson>( `/extended/v3/staking/bonds/${BOND_INDEX}/allowlist?limit=50` diff --git a/tests/api/pox5/pox-constants.test.ts b/tests/api/pox5/pox-constants.test.ts index 49f6f1e50f..75b6396861 100644 --- a/tests/api/pox5/pox-constants.test.ts +++ b/tests/api/pox5/pox-constants.test.ts @@ -10,6 +10,7 @@ import { PoxInfoClient, burnHeightToRewardCycle, ensurePoxConstants, + getBondEnrollmentCutoff, getPoxCyclePhase, getPoxCycleSchedule, poxConstantsFromNodeInfo, @@ -301,4 +302,14 @@ describe('pox cycle arithmetic', () => { }); assert.equal(getPoxCyclePhase(small, 10, 1095), 'prepare_phase'); }); + + test('closes bond enrollment at the prepare phase before the bond activates', () => { + // A bond activating at the start of cycle 144 stops accepting registrations when cycle 143's + // prepare phase begins, the first height `verify-not-prepare-phase` rejects. + const cutoff = getBondEnrollmentCutoff(c, rewardCycleToBurnHeight(c, 144), 144); + assert.deepEqual(cutoff, { bitcoinHeight: 968350, poxCycle: 143 }); + assert.equal(cutoff.bitcoinHeight, getPoxCycleSchedule(c, 143).preparePhaseStartBitcoinHeight); + assert.equal(getPoxCyclePhase(c, 143, cutoff.bitcoinHeight - 1), 'reward_phase'); + assert.equal(getPoxCyclePhase(c, 143, cutoff.bitcoinHeight), 'prepare_phase'); + }); });