Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions src/api/routes/v3/staking-bonds.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Comment thread
rafa-stacks marked this conversation as resolved.
await reply.send({
limit: results.limit,
total: results.total,
Expand All @@ -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)
),
});
}
);
Expand All @@ -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())
);
}
);

Expand Down Expand Up @@ -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,
Expand All @@ -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)),
});
}
);
Expand Down
18 changes: 14 additions & 4 deletions src/api/schemas/v3/entities/bonds.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,10 +82,20 @@ export const BondSchedulePointSchema = Type.Object({
});
export type BondSchedulePoint = Static<typeof BondSchedulePointSchema>;

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<typeof BondScheduleSchema>;

export const BondSummarySchema = Type.Object({
Expand Down
39 changes: 34 additions & 5 deletions src/api/serializers/v3/bonds.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
DbBondLockupType,
DbPrincipalBondPositionStatus,
} from '../../../datastore/common.js';
import { getBondEnrollmentCutoff, PoxConstants } from '../../../datastore/pox-constants.js';
import {
Pox5EventAddToAllowlist,
Pox5EventAnnounceL1EarlyExit,
Expand Down Expand Up @@ -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',
Expand All @@ -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,
Expand Down Expand Up @@ -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: {
Expand All @@ -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: {
Expand All @@ -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,
Expand All @@ -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),
Expand Down
18 changes: 18 additions & 0 deletions src/datastore/pox-constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions tests/api/pox5/bond-events.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});

Expand Down Expand Up @@ -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 },
},
Expand Down
26 changes: 26 additions & 0 deletions tests/api/pox5/bonds.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
}
Expand Down Expand Up @@ -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<CursorPaginated<BondSummaryItem>>(
'/extended/v3/staking/bonds?limit=50'
);
assert.deepEqual(list.results.find(b => b.index === BOND_INDEX)?.schedule, expected);
const bond = await getJson<BondDetail>(`/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<CursorPaginated<BondAllowlistEntry>>(
`/extended/v3/staking/bonds/${BOND_INDEX}/allowlist?limit=50`
Expand Down
11 changes: 11 additions & 0 deletions tests/api/pox5/pox-constants.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
PoxInfoClient,
burnHeightToRewardCycle,
ensurePoxConstants,
getBondEnrollmentCutoff,
getPoxCyclePhase,
getPoxCycleSchedule,
poxConstantsFromNodeInfo,
Expand Down Expand Up @@ -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');
});
});
Loading