diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 21b7938e8..71c5db6fb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -72,17 +72,18 @@ jobs: api:transactions, api:v2-proxy, api:v3, - krypton:bns-e2e, - krypton:faucet-btc, - krypton:faucet-stx, - krypton:pox-4-btc-address-formats, - krypton:pox-4-burnchain-delegate-stx, - krypton:pox-4-burnchain-stack-stx, - krypton:pox-4-delegate-aggregation, - krypton:pox-4-delegate-revoked-stacking, - krypton:pox-4-delegate-stacking, - krypton:pox-4-stack-extend-increase, - krypton:rpc, + api:pox5, + # krypton:bns-e2e, + # krypton:faucet-btc, + # krypton:faucet-stx, + # krypton:pox-4-btc-address-formats, + # krypton:pox-4-burnchain-delegate-stx, + # krypton:pox-4-burnchain-stack-stx, + # krypton:pox-4-delegate-aggregation, + # krypton:pox-4-delegate-revoked-stacking, + # krypton:pox-4-delegate-stacking, + # krypton:pox-4-stack-extend-increase, + # krypton:rpc, snp, ] runs-on: ubuntu-latest diff --git a/migrations/1779392293803_pox5-events.ts b/migrations/1779392293803_pox5-events.ts index d7634f1c5..ffe85c40b 100644 --- a/migrations/1779392293803_pox5-events.ts +++ b/migrations/1779392293803_pox5-events.ts @@ -22,14 +22,29 @@ export const up = (pgm: MigrationBuilder) => { type: 'integer', notNull: true, }, + block_hash: { + type: 'bytea', + }, + block_time: { + type: 'bigint', + }, index_block_hash: { type: 'bytea', notNull: true, }, + parent_block_hash: { + type: 'bytea', + }, parent_index_block_hash: { type: 'bytea', notNull: true, }, + burn_block_height: { + type: 'integer', + }, + burn_block_time: { + type: 'bigint', + }, microblock_hash: { type: 'bytea', notNull: true, diff --git a/migrations/1779487960678_bonds.ts b/migrations/1779487960678_bonds.ts index de829384c..a65b07c02 100644 --- a/migrations/1779487960678_bonds.ts +++ b/migrations/1779487960678_bonds.ts @@ -20,14 +20,34 @@ export const up = (pgm: MigrationBuilder) => { type: 'integer', notNull: true, }, + block_hash: { + type: 'bytea', + notNull: true, + }, + block_time: { + type: 'bigint', + notNull: true, + }, index_block_hash: { type: 'bytea', notNull: true, }, + parent_block_hash: { + type: 'bytea', + notNull: true, + }, parent_index_block_hash: { type: 'bytea', notNull: true, }, + burn_block_height: { + type: 'integer', + notNull: true, + }, + burn_block_time: { + type: 'bigint', + notNull: true, + }, microblock_hash: { type: 'bytea', notNull: true, @@ -87,6 +107,7 @@ export const up = (pgm: MigrationBuilder) => { btc_capacity: { type: 'numeric', notNull: true, + default: 0, }, btc_locked: { type: 'numeric', diff --git a/migrations/1779487971367_bond-allowlist-entries.ts b/migrations/1779487971367_bond-allowlist-entries.ts index 2d0df0ea7..b08899fd3 100644 --- a/migrations/1779487971367_bond-allowlist-entries.ts +++ b/migrations/1779487971367_bond-allowlist-entries.ts @@ -20,14 +20,29 @@ export const up = (pgm: MigrationBuilder) => { type: 'integer', notNull: true, }, + block_hash: { + type: 'bytea', + }, + block_time: { + type: 'bigint', + }, index_block_hash: { type: 'bytea', notNull: true, }, + parent_block_hash: { + type: 'bytea', + }, parent_index_block_hash: { type: 'bytea', notNull: true, }, + burn_block_height: { + type: 'integer', + }, + burn_block_time: { + type: 'bigint', + }, microblock_hash: { type: 'bytea', notNull: true, diff --git a/migrations/1779487975550_bond-registrations.ts b/migrations/1779487975550_bond-registrations.ts index d8ad35bed..90b67ce58 100644 --- a/migrations/1779487975550_bond-registrations.ts +++ b/migrations/1779487975550_bond-registrations.ts @@ -20,14 +20,29 @@ export const up = (pgm: MigrationBuilder) => { type: 'integer', notNull: true, }, + block_hash: { + type: 'bytea', + }, + block_time: { + type: 'bigint', + }, index_block_hash: { type: 'bytea', notNull: true, }, + parent_block_hash: { + type: 'bytea', + }, parent_index_block_hash: { type: 'bytea', notNull: true, }, + burn_block_height: { + type: 'integer', + }, + burn_block_time: { + type: 'bigint', + }, microblock_hash: { type: 'bytea', notNull: true, diff --git a/migrations/1779742831642_principal-bond-positions.ts b/migrations/1779742831642_principal-bond-positions.ts index ada052486..2d92f4ddd 100644 --- a/migrations/1779742831642_principal-bond-positions.ts +++ b/migrations/1779742831642_principal-bond-positions.ts @@ -28,14 +28,29 @@ export function up(pgm: MigrationBuilder): void { type: 'integer', notNull: true, }, + block_hash: { + type: 'bytea', + }, + block_time: { + type: 'bigint', + }, index_block_hash: { type: 'bytea', notNull: true, }, + parent_block_hash: { + type: 'bytea', + }, parent_index_block_hash: { type: 'bytea', notNull: true, }, + burn_block_height: { + type: 'integer', + }, + burn_block_time: { + type: 'bigint', + }, microblock_hash: { type: 'bytea', notNull: true, diff --git a/migrations/1779745340752_bond-reward-distributions.ts b/migrations/1779745340752_bond-reward-distributions.ts index 4f427b124..3a74e398b 100644 --- a/migrations/1779745340752_bond-reward-distributions.ts +++ b/migrations/1779745340752_bond-reward-distributions.ts @@ -20,14 +20,29 @@ export function up(pgm: MigrationBuilder): void { type: 'integer', notNull: true, }, + block_hash: { + type: 'bytea', + }, + block_time: { + type: 'bigint', + }, index_block_hash: { type: 'bytea', notNull: true, }, + parent_block_hash: { + type: 'bytea', + }, parent_index_block_hash: { type: 'bytea', notNull: true, }, + burn_block_height: { + type: 'integer', + }, + burn_block_time: { + type: 'bigint', + }, microblock_hash: { type: 'bytea', notNull: true, diff --git a/package-lock.json b/package-lock.json index f86a00b69..c9585df1b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,7 +19,7 @@ "@stacks/common": "7.3.1", "@stacks/encryption": "7.4.0", "@stacks/network": "7.3.1", - "@stacks/node-publisher-client": "2.2.0", + "@stacks/node-publisher-client": "2.2.1", "@stacks/stacking": "7.4.0", "@stacks/transactions": "7.4.0", "bignumber.js": "10.0.2", @@ -1761,9 +1761,9 @@ } }, "node_modules/@stacks/node-publisher-client": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@stacks/node-publisher-client/-/node-publisher-client-2.2.0.tgz", - "integrity": "sha512-/O/udLosZGQRyfjrYQ9mIymYDZSQncKlheCpbNsFG8oAoZrNOR8TjiFzLhaRGci4d1FmWPvQU6H9xRrkXBbwsQ==", + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/@stacks/node-publisher-client/-/node-publisher-client-2.2.1.tgz", + "integrity": "sha512-W1FwbGlSBd0XawoVd6kc5dHFdi4iDC/38wqORH5yIMqbgzBTSZ67YUrDYJPSv6iNFAp8j7Gg4iKCxaxIMpFy3A==", "license": "GPL-3.0-only", "dependencies": { "@stacks/api-toolkit": "^1.13.0", diff --git a/package.json b/package.json index ebb5a15df..54b9aa331 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "test:api:transactions": "NODE_ENV=test node --import tsx --test --test-global-setup=./tests/api/setup.ts --test-concurrency=1 ./tests/api/transactions/**/*.test.ts", "test:api:v2-proxy": "NODE_ENV=test node --import tsx --test --test-global-setup=./tests/api/setup.ts --test-concurrency=1 ./tests/api/v2-proxy/**/*.test.ts", "test:api:v3": "NODE_ENV=test node --import tsx --test --test-global-setup=./tests/api/setup.ts --test-concurrency=1 ./tests/api/v3/**/*.test.ts", + "test:api:pox5": "NODE_ENV=test node --import tsx --test --test-global-setup=./tests/api/setup.ts --test-concurrency=1 ./tests/api/pox5/**/*.test.ts", "test:snp": "NODE_ENV=test node --import tsx --test --test-global-setup=./tests/snp/setup.ts --test-concurrency=1 ./tests/snp/**/*.test.ts", "test:krypton:bns-e2e": "NODE_ENV=test node --import tsx --test --test-global-setup=./tests/krypton/setup.ts --test-concurrency=1 ./tests/krypton/bns-e2e/**/*.test.ts", "test:krypton:faucet-btc": "NODE_ENV=test node --import tsx --test --test-global-setup=./tests/krypton/setup.ts --test-concurrency=1 ./tests/krypton/faucet-btc/**/*.test.ts", @@ -70,7 +71,7 @@ "@stacks/common": "7.3.1", "@stacks/encryption": "7.4.0", "@stacks/network": "7.3.1", - "@stacks/node-publisher-client": "2.2.0", + "@stacks/node-publisher-client": "2.2.1", "@stacks/stacking": "7.4.0", "@stacks/transactions": "7.4.0", "bignumber.js": "10.0.2", diff --git a/src/api/init.ts b/src/api/init.ts index 59e643977..818794784 100644 --- a/src/api/init.ts +++ b/src/api/init.ts @@ -53,6 +53,8 @@ import { PrincipalsRoutes } from './routes/v3/principals.js'; import { TransactionsRoutes } from './routes/v3/transactions.js'; import { MempoolRoutes } from './routes/v3/mempool.js'; import { BlocksRoutes } from './routes/v3/blocks.js'; +import { StakingBondsRoutes } from './routes/v3/staking-bonds.js'; +import { StakingPrincipalsRoutes } from './routes/v3/staking-principals.js'; export interface ApiServer { fastifyApp: FastifyInstance; @@ -105,10 +107,12 @@ export const StacksApiRoutes: FastifyPluginAsync< await fastify.register( async fastify => { + await fastify.register(BlocksRoutes); + await fastify.register(MempoolRoutes); await fastify.register(PrincipalsRoutes); + await fastify.register(StakingBondsRoutes); + await fastify.register(StakingPrincipalsRoutes); await fastify.register(TransactionsRoutes); - await fastify.register(MempoolRoutes); - await fastify.register(BlocksRoutes); }, { prefix: '/extended/v3' } ); diff --git a/src/api/routes/v3/staking-principals.ts b/src/api/routes/v3/staking-principals.ts index 791707400..de1f6e42e 100644 --- a/src/api/routes/v3/staking-principals.ts +++ b/src/api/routes/v3/staking-principals.ts @@ -1,7 +1,10 @@ import { FastifyPluginAsync } from 'fastify'; import { Server } from 'node:http'; import { Type, TypeBoxTypeProvider } from '@fastify/type-provider-typebox'; +import { handleChainTipCache } from '../../controllers/cache-controller.js'; import { PrincipalSchema } from '../../schemas/v3/entities/common.js'; +import { PrincipalBondPositionSchema } from '../../schemas/v3/entities/principal-bond-positions.js'; +import { serializeDbPrincipalBondPosition } from '../../serializers/v3/bonds.js'; export const StakingPrincipalsRoutes: FastifyPluginAsync< Record, @@ -11,18 +14,25 @@ export const StakingPrincipalsRoutes: FastifyPluginAsync< fastify.get( '/staking/principals/:principal/positions', { + preHandler: handleChainTipCache, schema: { operationId: 'get_principal_staking_positions', summary: 'Get principal staking positions', - description: 'Get principal staking positions', + description: "Get a principal's bond positions across all bonds it is enrolled in", tags: ['Staking'], params: Type.Object({ principal: PrincipalSchema, }), + response: { + 200: Type.Array(PrincipalBondPositionSchema), + }, }, }, - async (_req, reply) => { - await reply.send(); + async (req, reply) => { + const positions = await fastify.db.v3.getPrincipalStakingPositions({ + principal: req.params.principal, + }); + await reply.send(positions.map(serializeDbPrincipalBondPosition)); } ); diff --git a/src/api/schemas/v3/entities/principal-bond-positions.ts b/src/api/schemas/v3/entities/principal-bond-positions.ts index efa37ab81..c27e77514 100644 --- a/src/api/schemas/v3/entities/principal-bond-positions.ts +++ b/src/api/schemas/v3/entities/principal-bond-positions.ts @@ -19,9 +19,6 @@ export const PrincipalBondPositionSchema = Type.Object({ balances: BondBalancesSchema, enrollment: Type.Object({ tx_id: TransactionIdSchema, - pox_address: Type.String({ - description: 'The POX address of the principal', - }), btc_lockup: Type.Object({ amount: Type.String({ description: 'The amount of BTC that is locked up for this principal', diff --git a/src/api/serializers/v3/bonds.ts b/src/api/serializers/v3/bonds.ts index 548a89b71..033773939 100644 --- a/src/api/serializers/v3/bonds.ts +++ b/src/api/serializers/v3/bonds.ts @@ -3,11 +3,17 @@ import { DbBondAllowlistEntry, DbBondRegistration, DbBondSummary, + DbPrincipalBondPosition, } from '../../../datastore/v3/types.js'; +import { DbPrincipalBondPositionStatus } from '../../../datastore/common.js'; import { Bond, BondSummary } from '../../schemas/v3/entities/bonds.js'; import { BondStatus } from '../../schemas/v3/entities/bonds.js'; import { BondAllowlist } from '../../schemas/v3/entities/bond-allowlist-entries.js'; import { BondRegistration } from '../../schemas/v3/entities/bond-registrations.js'; +import { + PrincipalBondPosition, + PrincipalBondPositionStatus, +} from '../../schemas/v3/entities/principal-bond-positions.js'; function getBondStatus(summary: DbBondSummary, currentBurnBlockHeight: number): BondStatus { if (currentBurnBlockHeight < summary.bond_start_height) { @@ -96,6 +102,47 @@ export function serializeDbBondAllowlistEntry(entry: DbBondAllowlistEntry): Bond }; } +function getPrincipalBondPositionStatus( + status: DbPrincipalBondPositionStatus +): PrincipalBondPositionStatus { + switch (status) { + case DbPrincipalBondPositionStatus.Enrolled: + return 'enrolled'; + case DbPrincipalBondPositionStatus.Running: + return 'running'; + case DbPrincipalBondPositionStatus.Unlocked: + return 'unlocked'; + case DbPrincipalBondPositionStatus.EarlyExit: + return 'early_exit'; + } +} + +export function serializeDbPrincipalBondPosition( + position: DbPrincipalBondPosition +): PrincipalBondPosition { + return { + bond_index: position.bond_index, + status: getPrincipalBondPositionStatus(position.status), + active: position.active, + balances: { + locked: { + btc: position.btc_locked, + stx: position.stx_locked, + }, + paid_out: { + btc: position.btc_paid_out, + }, + }, + enrollment: { + tx_id: position.tx_id, + btc_lockup: { + amount: position.btc_locked, + }, + }, + amount: position.stx_locked, + }; +} + export function serializeDbBondRegistration(entry: DbBondRegistration): BondRegistration { return { bond_index: entry.bond_index, diff --git a/src/api/serializers/v3/mempool-transactions.ts b/src/api/serializers/v3/mempool-transactions.ts index f4410dd37..b69d7c437 100644 --- a/src/api/serializers/v3/mempool-transactions.ts +++ b/src/api/serializers/v3/mempool-transactions.ts @@ -93,7 +93,8 @@ export function serializeDbMempoolTransactionSummary( }; return tokenTransfer; } - case DbTxTypeId.SmartContract: { + case DbTxTypeId.SmartContract: + case DbTxTypeId.VersionedSmartContract: { const smartContract: SmartContractMempoolTransactionSummary = { ...result, type: 'smart_contract', @@ -122,7 +123,9 @@ export function serializeDbMempoolTransactionSummary( }; return poisonMicroblock; } - case DbTxTypeId.Coinbase: { + case DbTxTypeId.Coinbase: + case DbTxTypeId.NakamotoCoinbase: + case DbTxTypeId.CoinbaseToAltRecipient: { const coinbase: CoinbaseMempoolTransactionSummary = { ...result, type: 'coinbase', @@ -181,7 +184,8 @@ export function serializeDbMempoolTransaction( }; return tokenTransfer; } - case DbTxTypeId.SmartContract: { + case DbTxTypeId.SmartContract: + case DbTxTypeId.VersionedSmartContract: { const smartContract: SmartContractMempoolTransaction = { ...result, type: 'smart_contract', @@ -220,7 +224,9 @@ export function serializeDbMempoolTransaction( }; return poisonMicroblock; } - case DbTxTypeId.Coinbase: { + case DbTxTypeId.Coinbase: + case DbTxTypeId.NakamotoCoinbase: + case DbTxTypeId.CoinbaseToAltRecipient: { const coinbase: CoinbaseMempoolTransaction = { ...result, type: 'coinbase', diff --git a/src/api/serializers/v3/transactions.ts b/src/api/serializers/v3/transactions.ts index 0f473a1fe..fe1137d1c 100644 --- a/src/api/serializers/v3/transactions.ts +++ b/src/api/serializers/v3/transactions.ts @@ -134,7 +134,8 @@ export function serializeDbTransactionSummary(summary: DbTransactionSummary): Tr }; return tokenTransfer; } - case DbTxTypeId.SmartContract: { + case DbTxTypeId.SmartContract: + case DbTxTypeId.VersionedSmartContract: { const smartContract: SmartContractTransactionSummary = { ...result, type: 'smart_contract', @@ -163,7 +164,9 @@ export function serializeDbTransactionSummary(summary: DbTransactionSummary): Tr }; return poisonMicroblock; } - case DbTxTypeId.Coinbase: { + case DbTxTypeId.Coinbase: + case DbTxTypeId.NakamotoCoinbase: + case DbTxTypeId.CoinbaseToAltRecipient: { const coinbase: CoinbaseTransactionSummary = { ...result, type: 'coinbase', @@ -274,7 +277,8 @@ export function serializeDbTransaction( }; return tokenTransfer; } - case DbTxTypeId.SmartContract: { + case DbTxTypeId.SmartContract: + case DbTxTypeId.VersionedSmartContract: { const smartContract: SmartContractTransaction = { ...result, type: 'smart_contract', @@ -313,7 +317,9 @@ export function serializeDbTransaction( }; return poisonMicroblock; } - case DbTxTypeId.Coinbase: { + case DbTxTypeId.Coinbase: + case DbTxTypeId.NakamotoCoinbase: + case DbTxTypeId.CoinbaseToAltRecipient: { const coinbase: CoinbaseTransaction = { ...result, type: 'coinbase', diff --git a/src/datastore/common.ts b/src/datastore/common.ts index 59e155fe2..17ee3c406 100644 --- a/src/datastore/common.ts +++ b/src/datastore/common.ts @@ -1632,6 +1632,11 @@ export interface DbTxLocation { tx_id: string; tx_index: number; block_height: number; + block_hash: string; + block_time: number; + burn_block_height: number; + burn_block_time: number; + parent_block_hash: string; index_block_hash: string; parent_index_block_hash: string; microblock_hash: string; diff --git a/src/datastore/pg-write-store.ts b/src/datastore/pg-write-store.ts index 47c730495..a5f7ce0f7 100644 --- a/src/datastore/pg-write-store.ts +++ b/src/datastore/pg-write-store.ts @@ -535,6 +535,11 @@ export class PgWriteStore extends PgStore { tx_id: tx.tx.tx_id, tx_index: tx.tx.tx_index, block_height: tx.tx.block_height, + block_hash: tx.tx.block_hash, + block_time: tx.tx.block_time, + burn_block_height: tx.tx.burn_block_height, + burn_block_time: tx.tx.burn_block_time, + parent_block_hash: tx.tx.parent_block_hash, index_block_hash: tx.tx.index_block_hash, parent_index_block_hash: tx.tx.parent_index_block_hash, microblock_hash: tx.tx.microblock_hash, @@ -639,6 +644,14 @@ export class PgWriteStore extends PgStore { await sql` INSERT INTO bond_registrations ${sql(bondRegistration)} `; + // Maintain the bond's registered_count on write (a new registration). + await sql` + UPDATE bonds + SET registered_count = registered_count + 1 + WHERE bond_index = ${bondIndex} + AND canonical = true + AND microblock_canonical = true + `; } else { const updateResult = await sql` UPDATE bond_registrations SET @@ -858,7 +871,9 @@ export class PgWriteStore extends PgStore { RETURNING bond_index, max_sats ) UPDATE bonds AS b - SET btc_capacity = b.btc_capacity + i.max_sats::numeric + SET + btc_capacity = b.btc_capacity + i.max_sats::numeric, + allowed_count = b.allowed_count + 1 FROM inserted AS i WHERE b.bond_index = i.bond_index AND b.canonical = true @@ -4042,6 +4057,89 @@ export class PgWriteStore extends PgStore { updatedEntities.markedNonCanonical.pox4Events += pox4Result.count; } }); + // pox-5 tables that only need their canonical flag flipped (no derived + // bond counters depend on them). + for (const pox5Table of ['pox5_events', 'bonds', 'bond_reward_distributions']) { + q.enqueue(async () => { + await sql` + UPDATE ${sql(pox5Table)} + SET canonical = ${canonical} + WHERE index_block_hash = ${indexBlockHash} AND canonical != ${canonical} + `; + }); + } + // The bond aggregate counters live on the `bonds` row and are maintained + // incrementally. On reorg we flip the child rows' canonical flag and apply + // a signed delta (+ when restoring, − when orphaning) to the parent bond's + // counters — the same flip-and-delta approach used for ft_balances above. + // No `bonds.canonical` guard on the delta: the delta must apply symmetrically + // in both directions (orphan then restore) to avoid double-counting, exactly + // like the ft_balances upsert. + q.enqueue(async () => { + await sql` + WITH updated AS ( + UPDATE bond_allowlist_entries + SET canonical = ${canonical} + WHERE index_block_hash = ${indexBlockHash} AND canonical != ${canonical} + RETURNING bond_index, max_sats, canonical + ), + changes AS ( + SELECT bond_index, + SUM(CASE WHEN canonical THEN max_sats::numeric ELSE -max_sats::numeric END) AS capacity_change, + SUM(CASE WHEN canonical THEN 1 ELSE -1 END) AS count_change + FROM updated + GROUP BY bond_index + ) + UPDATE bonds AS b + SET btc_capacity = b.btc_capacity + c.capacity_change, + allowed_count = b.allowed_count + c.count_change + FROM changes c + WHERE b.bond_index = c.bond_index + `; + }); + q.enqueue(async () => { + await sql` + WITH updated AS ( + UPDATE bond_registrations + SET canonical = ${canonical} + WHERE index_block_hash = ${indexBlockHash} AND canonical != ${canonical} + RETURNING bond_index, canonical + ), + changes AS ( + SELECT bond_index, SUM(CASE WHEN canonical THEN 1 ELSE -1 END) AS count_change + FROM updated + GROUP BY bond_index + ) + UPDATE bonds AS b + SET registered_count = b.registered_count + c.count_change + FROM changes c + WHERE b.bond_index = c.bond_index + `; + }); + q.enqueue(async () => { + await sql` + WITH updated AS ( + UPDATE principal_bond_positions + SET canonical = ${canonical} + WHERE index_block_hash = ${indexBlockHash} AND canonical != ${canonical} + RETURNING bond_index, btc_locked, stx_locked, btc_paid_out, canonical + ), + changes AS ( + SELECT bond_index, + SUM(CASE WHEN canonical THEN btc_locked::numeric ELSE -btc_locked::numeric END) AS btc_change, + SUM(CASE WHEN canonical THEN stx_locked::numeric ELSE -stx_locked::numeric END) AS stx_change, + SUM(CASE WHEN canonical THEN btc_paid_out::numeric ELSE -btc_paid_out::numeric END) AS paid_change + FROM updated + GROUP BY bond_index + ) + UPDATE bonds AS b + SET btc_locked = b.btc_locked + c.btc_change, + stx_locked = b.stx_locked + c.stx_change, + btc_paid_out = b.btc_paid_out + c.paid_change + FROM changes c + WHERE b.bond_index = c.bond_index + `; + }); q.enqueue(async () => { const contractLogResult = await sql<{ contract_identifier: string; delta: number }[]>` WITH updated AS ( diff --git a/src/datastore/v3/constants.ts b/src/datastore/v3/constants.ts index b0b49a715..cf90f001a 100644 --- a/src/datastore/v3/constants.ts +++ b/src/datastore/v3/constants.ts @@ -1,16 +1,29 @@ -export const TX_SUMMARY_COLUMNS = [ +export const TX_LOCATION_COLUMNS = [ 'tx_id', + 'tx_index', + 'block_height', + 'block_hash', + 'block_time', + 'burn_block_height', + 'burn_block_time', + 'parent_block_hash', + 'index_block_hash', + 'parent_index_block_hash', + 'microblock_hash', + 'microblock_sequence', + 'microblock_canonical', + 'canonical', +]; + +export const TX_SUMMARY_COLUMNS = [ + ...TX_LOCATION_COLUMNS, 'sender_address', 'sponsor_address', 'sponsor_nonce', 'nonce', 'fee_rate', - 'block_height', 'block_hash', - 'index_block_hash', 'block_time', - 'tx_index', - 'microblock_sequence', 'burn_block_height', 'burn_block_time', 'status', @@ -107,14 +120,7 @@ export const BOND_SUMMARY_COLUMNS = [ export const BOND_COLUMNS = [ ...BOND_SUMMARY_COLUMNS, - 'tx_id', - 'block_height', - 'block_hash', - 'index_block_hash', - 'block_time', - 'tx_index', - 'burn_block_height', - 'burn_block_time', + ...TX_LOCATION_COLUMNS, 'early_unlock_bytes', 'early_unlock_admin', ]; @@ -141,3 +147,13 @@ export const BOND_REGISTRATION_COLUMNS = [ 'microblock_sequence', 'tx_index', ]; + +export const PRINCIPAL_BOND_POSITION_COLUMNS = [ + 'bond_index', + 'status', + 'active', + 'btc_locked', + 'stx_locked', + 'btc_paid_out', + 'tx_id', +]; diff --git a/src/datastore/v3/pg-store-v3.ts b/src/datastore/v3/pg-store-v3.ts index 3029a46a0..2d800ce64 100644 --- a/src/datastore/v3/pg-store-v3.ts +++ b/src/datastore/v3/pg-store-v3.ts @@ -7,6 +7,7 @@ import { DbCursorPaginatedResult, DbMempoolTransaction, DbMempoolTransactionSummary, + DbPrincipalBondPosition, DbPrincipalTransactionSummary, DbTransaction, DbTransactionCursor, @@ -15,11 +16,12 @@ import { } from './types.js'; import { BOND_ALLOWLIST_ENTRY_COLUMNS, - BOND_REGISTRATION_COLUMNS, BOND_COLUMNS, + BOND_REGISTRATION_COLUMNS, BOND_SUMMARY_COLUMNS, MEMPOOL_TX_COLUMNS, MEMPOOL_TX_SUMMARY_COLUMNS, + PRINCIPAL_BOND_POSITION_COLUMNS, TX_COLUMNS, TX_SUMMARY_COLUMNS, } from './constants.js'; @@ -735,7 +737,7 @@ export class PgStoreV3 extends BasePgStoreModule { SELECT burn_block_height FROM chain_tip LIMIT 1 `; const result = await sql` - SELECT ${this.sql(BOND_COLUMNS)} + SELECT ${sql(BOND_COLUMNS)} FROM bonds WHERE canonical = true AND microblock_canonical = true @@ -979,4 +981,24 @@ export class PgStoreV3 extends BasePgStoreModule { return result[0] ?? null; }); } + + /** + * Gets all bond positions for a principal (across the bonds it is enrolled in). + * @param args - The arguments for the query. + * @returns The principal's bond positions. + */ + async getPrincipalStakingPositions(args: { + principal: Principal; + }): Promise { + return await this.sqlTransaction(async sql => { + return await sql` + SELECT ${sql(PRINCIPAL_BOND_POSITION_COLUMNS)} + FROM principal_bond_positions + WHERE canonical = true + AND microblock_canonical = true + AND principal = ${args.principal} + ORDER BY bond_index ASC + `; + }); + } } diff --git a/src/datastore/v3/types.ts b/src/datastore/v3/types.ts index 434358aad..b581137ef 100644 --- a/src/datastore/v3/types.ts +++ b/src/datastore/v3/types.ts @@ -9,20 +9,29 @@ export type DbCursorPaginatedResult = { results: T[]; }; -export interface DbTransactionSummary { +export interface DbTxLocation { tx_id: string; - sender_address: string; - nonce: number; - sponsor_address: string | null; - sponsor_nonce: number | null; - fee_rate: string; + tx_index: number; block_height: number; block_hash: string; - index_block_hash: string; block_time: number; - tx_index: number; burn_block_height: number; burn_block_time: number; + index_block_hash: string; + parent_block_hash: string; + parent_index_block_hash: string; + microblock_hash: string; + microblock_sequence: number; + microblock_canonical: boolean; + canonical: boolean; +} + +export interface DbTransactionSummary extends DbTxLocation { + sender_address: string; + nonce: number; + sponsor_address: string | null; + sponsor_nonce: number | null; + fee_rate: string; status: DbTxStatus; type_id: DbTxTypeId; token_transfer_recipient_address: string | null; @@ -37,8 +46,6 @@ export interface DbTransactionSummary { } export interface DbTransaction extends DbTransactionSummary { - parent_block_hash: string; - parent_index_block_hash: string; post_conditions: string; event_count: number; execution_cost_read_count: number; @@ -141,15 +148,7 @@ export interface DbBondSummary { registered_count: number; } -export interface DbBond extends DbBondSummary { - tx_id: string; - block_height: number; - block_hash: string; - index_block_hash: string; - block_time: number; - tx_index: number; - burn_block_height: number; - burn_block_time: number; +export interface DbBond extends DbBondSummary, DbTxLocation { early_unlock_bytes: string; early_unlock_admin: string; } @@ -171,6 +170,17 @@ export interface DbBondRegistration { is_l1_lock: boolean; } +export interface DbPrincipalBondPosition { + bond_index: number; + /** `DbPrincipalBondPositionStatus` stored as a smallint. */ + status: number; + active: boolean; + btc_locked: string; + stx_locked: string; + btc_paid_out: string; + tx_id: string; +} + export interface DbTransactionCursor { block_height: number; microblock_sequence: number; diff --git a/src/event-stream/event-server.ts b/src/event-stream/event-server.ts index f6d5766d0..5f72de17b 100644 --- a/src/event-stream/event-server.ts +++ b/src/event-stream/event-server.ts @@ -583,6 +583,7 @@ function parseDataStoreTxEventData( } poxEvent.event_index = associatedLogEvent.event_index; } + tx.pox5Events.sort((a, b) => a.event_index - b.event_index); } return dbData; diff --git a/tests/api/pox5/bonds.test.ts b/tests/api/pox5/bonds.test.ts new file mode 100644 index 000000000..75a8c0e1f --- /dev/null +++ b/tests/api/pox5/bonds.test.ts @@ -0,0 +1,817 @@ +import supertest from 'supertest'; +import { afterEach, beforeEach, describe, test } from 'node:test'; +import assert from 'node:assert/strict'; +import { STACKS_TESTNET } from '@stacks/network'; +import { Pox5EventName } from '@stacks/codec'; +import { ApiServer, startApiServer } from '../../../src/api/init.ts'; +import { PgWriteStore } from '../../../src/datastore/pg-write-store.ts'; +import { migrate } from '../../test-helpers.ts'; +import { TestBlockBuilder } from '../test-builders.ts'; + +/** + * pox-5 bonds — simulated ingestion tests. + * + * Unlike `tests/pox5/bonds.test.ts` (a full end-to-end suite against a live + * dockerized chain), this suite builds blocks with `TestBlockBuilder`, attaches + * synthetic pox-5 events (setup-bond / add-to-allowlist / register-for-bond), + * ingests them via `db.update()`, and asserts the resulting API state — no + * blockchain, miners, or sidecars required. It mirrors the same checks: the + * pox5_events table, the bond summary/detail, allowlist, and registration + * endpoints. + */ + +// Principals (reused from the e2e suite for parity). +const ADMIN = 'ST3NBRSFKX28FQ2ZJ1MAKX58HKHSDGNV5N7R21XCP'; +const ALICE = 'STB44HYPYAT2BB2QE513NSP81HTMYWBJP02HPGK6'; +const BOB = 'ST11NJTTKGVT6D1HY4NJRVQWMQM7TVAR091EJ8P2Y'; +const SIGNER = `${ADMIN}.signer-manager`; + +const SETUP_TX_ID = '0x' + '11'.repeat(32); +const REGISTER_TX_ID = '0x' + '22'.repeat(32); + +// Bond parameters (mirror the e2e / stacks-core scenario). +const BOND_INDEX = 0; +const TARGET_RATE_BPS = 300; +const STX_VALUE_RATIO = 10_000_000; +const MIN_USTX_RATIO = 1_000; +const ALICE_MAX_SATS = 100_000_000n; +const BOB_MAX_SATS = 5_000_000n; +const EXPECTED_BTC_CAPACITY = ALICE_MAX_SATS + BOB_MAX_SATS; // summed from the allowlist + +// Registration parameters. +const AMOUNT_USTX = 10_000_000n; +const SBTC_SATS = 1_000n; +const FIRST_REWARD_CYCLE = 8; +const UNLOCK_BURN_HEIGHT = 410; +const UNLOCK_CYCLE = 20; +const BOND_START_HEIGHT = 160; + +const SETUP_BOND_DATA = { + bond_index: String(BOND_INDEX), + target_rate: String(TARGET_RATE_BPS), + stx_value_ratio: String(STX_VALUE_RATIO), + min_ustx_ratio: String(MIN_USTX_RATIO), + early_unlock_bytes: '', + early_unlock_admin: ADMIN, + first_reward_cycle: String(FIRST_REWARD_CYCLE), + bond_start_height: String(BOND_START_HEIGHT), + unlock_cycle: String(UNLOCK_CYCLE), + unlock_burn_height: String(UNLOCK_BURN_HEIGHT), +}; + +interface BondSummaryItem { + index: number; + pox_version: string; + parameters: { + target_rate_bps: number; + stx_value_ratio: number; + minimum_stx_ratio: number; + btc_capacity: string; + }; + registrations: { allowed_count: number; registered_count: number }; + balances: { locked: { btc: string; stx: string }; paid_out: { btc: string } }; +} +interface BondDetail extends BondSummaryItem { + transaction: { tx_id: string }; +} +interface BondAllowlistEntry { + staker: string; + max_sats: string; +} +interface BondRegistration { + bond_index: number; + signer: string; + staker: string; + amount_ustx: string; + sats_total: string; + is_l1_lock: boolean; +} +interface CursorPaginated { + total: number; + results: T[]; +} +interface PrincipalBondPositionItem { + bond_index: number; + status: string; + active: boolean; + balances: { locked: { btc: string; stx: string }; paid_out: { btc: string } }; + enrollment: { tx_id: string; btc_lockup: { amount: string } }; + amount: string; +} + +const normalizeTxId = (txid: string) => txid.replace(/^0x/, '').toLowerCase(); + +describe('pox-5 bonds (simulated ingestion)', () => { + let db: PgWriteStore; + let api: ApiServer; + + /** GET a JSON endpoint, asserting a 200 response. */ + async function getJson(path: string): Promise { + const res = await supertest(api.server).get(path); + assert.equal(res.status, 200, `GET ${path} -> ${res.status}: ${res.text}`); + assert.equal(res.type, 'application/json'); + return JSON.parse(res.text) as T; + } + + beforeEach(async () => { + await migrate('up'); + db = await PgWriteStore.connect({ + usageName: 'tests', + withNotifier: false, + skipMigrations: true, + }); + api = await startApiServer({ datastore: db, chainId: STACKS_TESTNET.chainId }); + + // Build one block: a setup-bond tx (SetupBond + two AddToAllowlist events) + // and a register-for-bond tx (RegisterForBond event), then ingest it. + const block = new TestBlockBuilder({ block_height: 1, index_block_hash: '0xb1' }) + .addTx({ tx_id: SETUP_TX_ID }) + .addTxPox5Event({ name: Pox5EventName.SetupBond, data: SETUP_BOND_DATA }) + .addTxPox5Event({ + name: Pox5EventName.AddToAllowlist, + data: { bond_index: String(BOND_INDEX), staker: ALICE, max_sats: ALICE_MAX_SATS.toString() }, + }) + .addTxPox5Event({ + name: Pox5EventName.AddToAllowlist, + data: { bond_index: String(BOND_INDEX), staker: BOB, max_sats: BOB_MAX_SATS.toString() }, + }) + .addTx({ tx_id: REGISTER_TX_ID }) + .addTxPox5Event({ + name: Pox5EventName.RegisterForBond, + data: { + bond_index: String(BOND_INDEX), + signer: SIGNER, + staker: ALICE, + amount_ustx: AMOUNT_USTX.toString(), + sats_total: SBTC_SATS.toString(), + first_reward_cycle: String(FIRST_REWARD_CYCLE), + unlock_burn_height: String(UNLOCK_BURN_HEIGHT), + unlock_cycle: String(UNLOCK_CYCLE), + is_l1_lock: false, + }, + }) + .build(); + await db.update(block); + }); + + afterEach(async () => { + await api.terminate(); + await db?.close(); + await migrate('down'); + }); + + test('the pox-5 events are ingested into pox5_events', async () => { + const rows = await db.sql<{ name: string }[]>` + SELECT name FROM pox5_events WHERE canonical = TRUE ORDER BY id ASC + `; + const names = rows.map(r => r.name); + assert.ok(names.includes(Pox5EventName.SetupBond), 'setup-bond event recorded'); + assert.equal( + names.filter(n => n === Pox5EventName.AddToAllowlist).length, + 2, + 'two add-to-allowlist events recorded' + ); + assert.ok(names.includes(Pox5EventName.RegisterForBond), 'register-for-bond event recorded'); + }); + + test('the bond appears in GET /extended/v3/staking/bonds', async () => { + const list = await getJson>( + '/extended/v3/staking/bonds?limit=50' + ); + const bond = list.results.find(b => b.index === BOND_INDEX); + assert.ok(bond, `bond #${BOND_INDEX} present in list`); + assert.equal(bond.pox_version, 'pox5'); + assert.equal(bond.parameters.target_rate_bps, TARGET_RATE_BPS); + assert.equal(bond.parameters.stx_value_ratio, STX_VALUE_RATIO); + assert.equal(bond.parameters.minimum_stx_ratio, MIN_USTX_RATIO); + // btc_capacity is summed from the allowlist entries' max-sats. + assert.equal(BigInt(bond.parameters.btc_capacity), EXPECTED_BTC_CAPACITY); + assert.equal(bond.registrations.allowed_count, 2); + }); + + test('the bond appears in GET /extended/v3/staking/bonds/:index', async () => { + const bond = await getJson(`/extended/v3/staking/bonds/${BOND_INDEX}`); + assert.equal(bond.index, BOND_INDEX); + assert.equal(bond.pox_version, 'pox5'); + assert.equal(bond.parameters.target_rate_bps, TARGET_RATE_BPS); + assert.equal(BigInt(bond.parameters.btc_capacity), EXPECTED_BTC_CAPACITY); + // Links back to the setup-bond transaction. + assert.equal(normalizeTxId(bond.transaction.tx_id), normalizeTxId(SETUP_TX_ID)); + }); + + test('the allowlist lists alice and bob (GET .../allowlist)', async () => { + const list = await getJson>( + `/extended/v3/staking/bonds/${BOND_INDEX}/allowlist?limit=50` + ); + const aliceEntry = list.results.find(e => e.staker === ALICE); + const bobEntry = list.results.find(e => e.staker === BOB); + assert.ok(aliceEntry, 'alice present in allowlist'); + assert.ok(bobEntry, 'bob present in allowlist'); + assert.equal(BigInt(aliceEntry.max_sats), ALICE_MAX_SATS); + assert.equal(BigInt(bobEntry.max_sats), BOB_MAX_SATS); + }); + + test('alice appears in GET .../allowlist/:principal', async () => { + const entry = await getJson( + `/extended/v3/staking/bonds/${BOND_INDEX}/allowlist/${ALICE}` + ); + assert.equal(entry.staker, ALICE); + assert.equal(BigInt(entry.max_sats), ALICE_MAX_SATS); + }); + + test("alice's registration appears in GET .../registrations", async () => { + const list = await getJson>( + `/extended/v3/staking/bonds/${BOND_INDEX}/registrations?limit=50` + ); + const reg = list.results.find(r => r.staker === ALICE); + assert.ok(reg, 'alice present in registrations'); + assert.equal(reg.bond_index, BOND_INDEX); + assert.equal(reg.signer, SIGNER); + assert.equal(BigInt(reg.amount_ustx), AMOUNT_USTX); + assert.equal(BigInt(reg.sats_total), SBTC_SATS); + assert.equal(reg.is_l1_lock, false); + }); + + test('alice appears in GET .../registrations/:principal', async () => { + const reg = await getJson( + `/extended/v3/staking/bonds/${BOND_INDEX}/registrations/${ALICE}` + ); + assert.equal(reg.staker, ALICE); + assert.equal(reg.bond_index, BOND_INDEX); + assert.equal(BigInt(reg.amount_ustx), AMOUNT_USTX); + assert.equal(reg.is_l1_lock, false); + }); + + test("alice's position appears in GET .../principals/:principal/positions", async () => { + const positions = await getJson( + `/extended/v3/staking/principals/${ALICE}/positions` + ); + const pos = positions.find(p => p.bond_index === BOND_INDEX); + assert.ok(pos, `alice has a position for bond #${BOND_INDEX}`); + assert.equal(pos.status, 'enrolled'); + assert.equal(pos.active, true); + // locked STX = registered amount_ustx; locked BTC = registered sats_total. + assert.equal(BigInt(pos.balances.locked.stx), AMOUNT_USTX); + assert.equal(BigInt(pos.balances.locked.btc), SBTC_SATS); + assert.equal(BigInt(pos.balances.paid_out.btc), 0n); + assert.equal(BigInt(pos.amount), AMOUNT_USTX); + assert.equal(BigInt(pos.enrollment.btc_lockup.amount), SBTC_SATS); + // The position links back to the register-for-bond transaction. + assert.equal(normalizeTxId(pos.enrollment.tx_id), normalizeTxId(REGISTER_TX_ID)); + }); +}); + +/** + * The same bond surfaces, but driven across MULTIPLE blocks to exercise + * cross-block state accumulation and the update-bond-registration path: + * block 1: setup-bond + allowlist + * block 2: alice register-for-bond + * block 3: alice update-bond-registration (new signer + amounts) + */ +describe('pox-5 bonds lifecycle (multi-block)', () => { + let db: PgWriteStore; + let api: ApiServer; + + // Updated registration values applied in block 3. + const NEW_SIGNER = `${ADMIN}.signer-manager-2`; + const UPDATED_AMOUNT_USTX = 20_000_000n; + const UPDATED_SATS = 2_000n; + + async function getJson(path: string): Promise { + const res = await supertest(api.server).get(path); + assert.equal(res.status, 200, `GET ${path} -> ${res.status}: ${res.text}`); + return JSON.parse(res.text) as T; + } + + async function getRegistration(): Promise { + return getJson( + `/extended/v3/staking/bonds/${BOND_INDEX}/registrations/${ALICE}` + ); + } + + beforeEach(async () => { + await migrate('up'); + db = await PgWriteStore.connect({ + usageName: 'tests', + withNotifier: false, + skipMigrations: true, + }); + api = await startApiServer({ datastore: db, chainId: STACKS_TESTNET.chainId }); + }); + + afterEach(async () => { + await api.terminate(); + await db?.close(); + await migrate('down'); + }); + + test('a bond progresses setup -> register -> update across blocks', async () => { + // --- Block 1: setup-bond + allowlist --- + await db.update( + new TestBlockBuilder({ block_height: 1, block_hash: '0xc101', index_block_hash: '0xc1' }) + .addTx({ tx_id: SETUP_TX_ID }) + .addTxPox5Event({ name: Pox5EventName.SetupBond, data: SETUP_BOND_DATA }) + .addTxPox5Event({ + name: Pox5EventName.AddToAllowlist, + data: { + bond_index: String(BOND_INDEX), + staker: ALICE, + max_sats: ALICE_MAX_SATS.toString(), + }, + }) + .addTxPox5Event({ + name: Pox5EventName.AddToAllowlist, + data: { bond_index: String(BOND_INDEX), staker: BOB, max_sats: BOB_MAX_SATS.toString() }, + }) + .build() + ); + + const bond = await getJson(`/extended/v3/staking/bonds/${BOND_INDEX}`); + assert.equal(bond.index, BOND_INDEX); + assert.equal(BigInt(bond.parameters.btc_capacity), EXPECTED_BTC_CAPACITY); + const noRegs = await getJson>( + `/extended/v3/staking/bonds/${BOND_INDEX}/registrations?limit=50` + ); + assert.equal(noRegs.results.length, 0, 'no registrations before block 2'); + + // --- Block 2: alice register-for-bond --- + await db.update( + new TestBlockBuilder({ + block_height: 2, + block_hash: '0xc102', + index_block_hash: '0xc2', + parent_block_hash: '0xc101', + parent_index_block_hash: '0xc1', + }) + .addTx({ tx_id: REGISTER_TX_ID }) + .addTxPox5Event({ + name: Pox5EventName.RegisterForBond, + data: { + bond_index: String(BOND_INDEX), + signer: SIGNER, + staker: ALICE, + amount_ustx: AMOUNT_USTX.toString(), + sats_total: SBTC_SATS.toString(), + first_reward_cycle: String(FIRST_REWARD_CYCLE), + unlock_burn_height: String(UNLOCK_BURN_HEIGHT), + unlock_cycle: String(UNLOCK_CYCLE), + is_l1_lock: false, + }, + }) + .build() + ); + + const reg = await getRegistration(); + assert.equal(reg.signer, SIGNER); + assert.equal(BigInt(reg.amount_ustx), AMOUNT_USTX); + assert.equal(BigInt(reg.sats_total), SBTC_SATS); + + // --- Block 3: alice update-bond-registration --- + await db.update( + new TestBlockBuilder({ + block_height: 3, + block_hash: '0xc103', + index_block_hash: '0xc3', + parent_block_hash: '0xc102', + parent_index_block_hash: '0xc2', + }) + .addTx({ tx_id: '0x' + '33'.repeat(32) }) + .addTxPox5Event({ + name: Pox5EventName.UpdateBondRegistration, + // The update branch reads `amount_sats` (not `sats_total`). + data: { + bond_index: String(BOND_INDEX), + signer: NEW_SIGNER, + staker: ALICE, + amount_ustx: UPDATED_AMOUNT_USTX.toString(), + amount_sats: UPDATED_SATS.toString(), + }, + }) + .build() + ); + + const updated = await getRegistration(); + assert.equal(updated.signer, NEW_SIGNER, 'signer updated'); + assert.equal(BigInt(updated.amount_ustx), UPDATED_AMOUNT_USTX, 'amount_ustx updated'); + assert.equal(BigInt(updated.sats_total), UPDATED_SATS, 'sats_total updated'); + // Still a single registration for alice (update, not a new row). + const regs = await getJson>( + `/extended/v3/staking/bonds/${BOND_INDEX}/registrations?limit=50` + ); + assert.equal(regs.results.filter(r => r.staker === ALICE).length, 1); + }); +}); + +/** + * Reorg handling: a bond ingested on one fork must disappear from the + * canonical-filtered endpoints when that fork is orphaned, and reappear if the + * fork is later restored as canonical. + */ +describe('pox-5 bonds reorg handling', () => { + let db: PgWriteStore; + let api: ApiServer; + + async function getJson(path: string): Promise { + const res = await supertest(api.server).get(path); + assert.equal(res.status, 200, `GET ${path} -> ${res.status}: ${res.text}`); + return JSON.parse(res.text) as T; + } + async function getStatus(path: string): Promise { + return (await supertest(api.server).get(path)).status; + } + async function bondExists(): Promise { + return (await getStatus(`/extended/v3/staking/bonds/${BOND_INDEX}`)) === 200; + } + async function canonicalBondEventCount(): Promise { + const rows = await db.sql<{ count: number }[]>` + SELECT COUNT(*)::int AS count FROM pox5_events WHERE canonical = TRUE + `; + return rows[0].count; + } + + beforeEach(async () => { + await migrate('up'); + db = await PgWriteStore.connect({ + usageName: 'tests', + withNotifier: false, + skipMigrations: true, + }); + api = await startApiServer({ datastore: db, chainId: STACKS_TESTNET.chainId }); + }); + + afterEach(async () => { + await api.terminate(); + await db?.close(); + await migrate('down'); + }); + + test('an orphaned bond disappears, and is restored when its fork wins again', async () => { + // Genesis. + await db.update( + new TestBlockBuilder({ block_height: 1, block_hash: '0x01', index_block_hash: '0x01' }).build() + ); + + // Fork A, block 2: the full bond (setup + allowlist + register). + const blockA = new TestBlockBuilder({ + block_height: 2, + block_hash: '0xa2', + index_block_hash: '0xa2', + parent_block_hash: '0x01', + parent_index_block_hash: '0x01', + }) + .addTx({ tx_id: SETUP_TX_ID }) + .addTxPox5Event({ name: Pox5EventName.SetupBond, data: SETUP_BOND_DATA }) + .addTxPox5Event({ + name: Pox5EventName.AddToAllowlist, + data: { bond_index: String(BOND_INDEX), staker: ALICE, max_sats: ALICE_MAX_SATS.toString() }, + }) + .addTxPox5Event({ + name: Pox5EventName.AddToAllowlist, + data: { bond_index: String(BOND_INDEX), staker: BOB, max_sats: BOB_MAX_SATS.toString() }, + }) + .addTx({ tx_id: REGISTER_TX_ID }) + .addTxPox5Event({ + name: Pox5EventName.RegisterForBond, + data: { + bond_index: String(BOND_INDEX), + signer: SIGNER, + staker: ALICE, + amount_ustx: AMOUNT_USTX.toString(), + sats_total: SBTC_SATS.toString(), + first_reward_cycle: String(FIRST_REWARD_CYCLE), + unlock_burn_height: String(UNLOCK_BURN_HEIGHT), + unlock_cycle: String(UNLOCK_CYCLE), + is_l1_lock: false, + }, + }) + .build(); + await db.update(blockA); + + // Bond + registration + position are all visible on the canonical chain. + assert.equal(await bondExists(), true, 'bond visible on fork A'); + assert.ok((await canonicalBondEventCount()) > 0, 'pox5_events canonical on fork A'); + const regs = await getJson>(`/extended/v3/staking/bonds/${BOND_INDEX}/registrations?limit=50`); + assert.equal(regs.results.length, 1, 'registration visible on fork A'); + const positions = await getJson(`/extended/v3/staking/principals/${ALICE}/positions`); + assert.equal(positions.length, 1, 'position visible on fork A'); + + // Fork B overtakes fork A (height 2 then 3) — no bond on this fork. + await db.update( + new TestBlockBuilder({ + block_height: 2, + block_hash: '0xb2', + index_block_hash: '0xb2', + parent_block_hash: '0x01', + parent_index_block_hash: '0x01', + }).build() + ); + await db.update( + new TestBlockBuilder({ + block_height: 3, + block_hash: '0xb3', + index_block_hash: '0xb3', + parent_block_hash: '0xb2', + parent_index_block_hash: '0xb2', + }).build() + ); + + // Fork A (with the bond) is now orphaned — everything must be gone. + assert.equal(await bondExists(), false, 'bond gone after reorg'); + assert.equal(await getStatus(`/extended/v3/staking/bonds/${BOND_INDEX}`), 404); + assert.equal(await canonicalBondEventCount(), 0, 'pox5_events flipped non-canonical'); + const regsAfter = await getJson>( + `/extended/v3/staking/bonds/${BOND_INDEX}/registrations?limit=50` + ); + assert.equal(regsAfter.results.length, 0, 'registration gone after reorg'); + const positionsAfter = await getJson(`/extended/v3/staking/principals/${ALICE}/positions`); + assert.equal(positionsAfter.length, 0, 'position gone after reorg'); + + // Fork A wins again (extends to height 4) — the bond is restored. + await db.update( + new TestBlockBuilder({ + block_height: 3, + block_hash: '0xa3', + index_block_hash: '0xa3', + parent_block_hash: '0xa2', + parent_index_block_hash: '0xa2', + }).build() + ); + await db.update( + new TestBlockBuilder({ + block_height: 4, + block_hash: '0xa4', + index_block_hash: '0xa4', + parent_block_hash: '0xa3', + parent_index_block_hash: '0xa3', + }).build() + ); + + assert.equal(await bondExists(), true, 'bond restored after fork A wins again'); + assert.ok((await canonicalBondEventCount()) > 0, 'pox5_events canonical again'); + const regsRestored = await getJson>( + `/extended/v3/staking/bonds/${BOND_INDEX}/registrations?limit=50` + ); + assert.equal(regsRestored.results.length, 1, 'registration restored'); + const positionsRestored = await getJson( + `/extended/v3/staking/principals/${ALICE}/positions` + ); + assert.equal(positionsRestored.length, 1, 'position restored'); + }); + + test('a partial reorg of only the registration block reverts the registration counters but keeps the bond', async () => { + // Genesis. + await db.update( + new TestBlockBuilder({ block_height: 1, block_hash: '0x01', index_block_hash: '0x01' }).build() + ); + // Block 2 (fork A): the bond itself — setup + allowlist (the SHARED ancestor). + await db.update( + new TestBlockBuilder({ + block_height: 2, + block_hash: '0xa2', + index_block_hash: '0xa2', + parent_block_hash: '0x01', + parent_index_block_hash: '0x01', + }) + .addTx({ tx_id: SETUP_TX_ID }) + .addTxPox5Event({ name: Pox5EventName.SetupBond, data: SETUP_BOND_DATA }) + .addTxPox5Event({ + name: Pox5EventName.AddToAllowlist, + data: { + bond_index: String(BOND_INDEX), + staker: ALICE, + max_sats: ALICE_MAX_SATS.toString(), + }, + }) + .addTxPox5Event({ + name: Pox5EventName.AddToAllowlist, + data: { bond_index: String(BOND_INDEX), staker: BOB, max_sats: BOB_MAX_SATS.toString() }, + }) + .build() + ); + // Block 3 (fork A): alice registers — this is the block we'll orphan. + await db.update( + new TestBlockBuilder({ + block_height: 3, + block_hash: '0xa3', + index_block_hash: '0xa3', + parent_block_hash: '0xa2', + parent_index_block_hash: '0xa2', + }) + .addTx({ tx_id: REGISTER_TX_ID }) + .addTxPox5Event({ + name: Pox5EventName.RegisterForBond, + data: { + bond_index: String(BOND_INDEX), + signer: SIGNER, + staker: ALICE, + amount_ustx: AMOUNT_USTX.toString(), + sats_total: SBTC_SATS.toString(), + first_reward_cycle: String(FIRST_REWARD_CYCLE), + unlock_burn_height: String(UNLOCK_BURN_HEIGHT), + unlock_cycle: String(UNLOCK_CYCLE), + is_l1_lock: false, + }, + }) + .build() + ); + + // With the registration applied, the registration-affected counters reflect it. + const withReg = await getJson(`/extended/v3/staking/bonds/${BOND_INDEX}`); + assert.equal(withReg.registrations.registered_count, 1, 'registered_count = 1'); + assert.equal(BigInt(withReg.balances.locked.stx), AMOUNT_USTX, 'stx_locked reflects registration'); + assert.equal(BigInt(withReg.balances.locked.btc), SBTC_SATS, 'btc_locked reflects registration'); + // Allowlist counters (from the surviving block) are present. + assert.equal(withReg.registrations.allowed_count, 2); + assert.equal(BigInt(withReg.parameters.btc_capacity), EXPECTED_BTC_CAPACITY); + + // Partial reorg: fork B branches from block 2 and overtakes, orphaning ONLY + // block 3 (the registration). The setup/allowlist block 2 stays canonical. + await db.update( + new TestBlockBuilder({ + block_height: 3, + block_hash: '0xb3', + index_block_hash: '0xb3', + parent_block_hash: '0xa2', + parent_index_block_hash: '0xa2', + }).build() + ); + await db.update( + new TestBlockBuilder({ + block_height: 4, + block_hash: '0xb4', + index_block_hash: '0xb4', + parent_block_hash: '0xb3', + parent_index_block_hash: '0xb3', + }).build() + ); + + // The bond survives, but the registration counters revert to zero... + assert.equal(await bondExists(), true, 'bond still exists (its setup block survived)'); + const afterReorg = await getJson(`/extended/v3/staking/bonds/${BOND_INDEX}`); + assert.equal(afterReorg.registrations.registered_count, 0, 'registered_count reverted'); + assert.equal(BigInt(afterReorg.balances.locked.stx), 0n, 'stx_locked reverted'); + assert.equal(BigInt(afterReorg.balances.locked.btc), 0n, 'btc_locked reverted'); + // ...while the allowlist counters (from the surviving block) are unchanged. + assert.equal(afterReorg.registrations.allowed_count, 2, 'allowed_count intact'); + assert.equal(BigInt(afterReorg.parameters.btc_capacity), EXPECTED_BTC_CAPACITY, 'btc_capacity intact'); + + // The registration and position are gone. + const regs = await getJson>( + `/extended/v3/staking/bonds/${BOND_INDEX}/registrations?limit=50` + ); + assert.equal(regs.results.length, 0, 'registration orphaned'); + assert.equal(regs.total, 0, 'registration total reverted'); + const positions = await getJson( + `/extended/v3/staking/principals/${ALICE}/positions` + ); + assert.equal(positions.length, 0, 'position orphaned'); + }); +}); + +/** + * unstake-sbtc / announce-l1-early-exit: a registered staker exits their bond. + * - unstake-sbtc reduces (and at 0, clears) the staker's locked sBTC and the + * bond's btc_locked total; a full unstake also marks the position early_exit. + * - announce-l1-early-exit marks the position early_exit + inactive without + * changing locked balances. + */ +describe('pox-5 bonds unstake / early-exit', () => { + let db: PgWriteStore; + let api: ApiServer; + + const UNSTAKE_PARTIAL_SATS = 400n; + + async function getJson(path: string): Promise { + const res = await supertest(api.server).get(path); + assert.equal(res.status, 200, `GET ${path} -> ${res.status}: ${res.text}`); + return JSON.parse(res.text) as T; + } + function alicePosition(positions: PrincipalBondPositionItem[]): PrincipalBondPositionItem { + const pos = positions.find(p => p.bond_index === BOND_INDEX); + assert.ok(pos, `alice has a position for bond #${BOND_INDEX}`); + return pos; + } + const getPositions = () => + getJson(`/extended/v3/staking/principals/${ALICE}/positions`); + const getBond = () => getJson(`/extended/v3/staking/bonds/${BOND_INDEX}`); + + beforeEach(async () => { + await migrate('up'); + db = await PgWriteStore.connect({ usageName: 'tests', withNotifier: false, skipMigrations: true }); + api = await startApiServer({ datastore: db, chainId: STACKS_TESTNET.chainId }); + + // Seed: a bond with alice registered (active, enrolled position). + await db.update( + new TestBlockBuilder({ block_height: 1, block_hash: '0x01', index_block_hash: '0x01' }) + .addTx({ tx_id: SETUP_TX_ID }) + .addTxPox5Event({ name: Pox5EventName.SetupBond, data: SETUP_BOND_DATA }) + .addTxPox5Event({ + name: Pox5EventName.AddToAllowlist, + data: { bond_index: String(BOND_INDEX), staker: ALICE, max_sats: ALICE_MAX_SATS.toString() }, + }) + .addTx({ tx_id: REGISTER_TX_ID }) + .addTxPox5Event({ + name: Pox5EventName.RegisterForBond, + data: { + bond_index: String(BOND_INDEX), + signer: SIGNER, + staker: ALICE, + amount_ustx: AMOUNT_USTX.toString(), + sats_total: SBTC_SATS.toString(), + first_reward_cycle: String(FIRST_REWARD_CYCLE), + unlock_burn_height: String(UNLOCK_BURN_HEIGHT), + unlock_cycle: String(UNLOCK_CYCLE), + is_l1_lock: false, + }, + }) + .build() + ); + }); + + afterEach(async () => { + await api.terminate(); + await db?.close(); + await migrate('down'); + }); + + test('the seeded position starts active and enrolled with the locked sBTC', async () => { + const pos = alicePosition(await getPositions()); + assert.equal(pos.status, 'enrolled'); + assert.equal(pos.active, true); + assert.equal(BigInt(pos.balances.locked.btc), SBTC_SATS); + const bond = await getBond(); + assert.equal(BigInt(bond.balances.locked.btc), SBTC_SATS); + }); + + test('unstake-sbtc reduces, then (at 0) clears the position + bond locked sBTC', async () => { + // Partial unstake: position + bond btc_locked drop to the new amount. + await db.update( + new TestBlockBuilder({ + block_height: 2, + block_hash: '0x02', + index_block_hash: '0x02', + parent_block_hash: '0x01', + parent_index_block_hash: '0x01', + }) + .addTx({ tx_id: '0x' + 'a2'.repeat(32) }) + .addTxPox5Event({ + name: Pox5EventName.UnstakeSbtc, + data: { + bond_index: String(BOND_INDEX), + staker: ALICE, + new_amount_sats: UNSTAKE_PARTIAL_SATS.toString(), + }, + }) + .build() + ); + let pos = alicePosition(await getPositions()); + assert.equal(BigInt(pos.balances.locked.btc), UNSTAKE_PARTIAL_SATS, 'position sBTC reduced'); + assert.equal(pos.status, 'enrolled', 'still enrolled on a partial unstake'); + assert.equal(BigInt((await getBond()).balances.locked.btc), UNSTAKE_PARTIAL_SATS, 'bond btc_locked reduced'); + + // Full unstake (to 0): position sBTC cleared and marked early_exit. + await db.update( + new TestBlockBuilder({ + block_height: 3, + block_hash: '0x03', + index_block_hash: '0x03', + parent_block_hash: '0x02', + parent_index_block_hash: '0x02', + }) + .addTx({ tx_id: '0x' + 'a3'.repeat(32) }) + .addTxPox5Event({ + name: Pox5EventName.UnstakeSbtc, + data: { bond_index: String(BOND_INDEX), staker: ALICE, new_amount_sats: '0' }, + }) + .build() + ); + pos = alicePosition(await getPositions()); + assert.equal(BigInt(pos.balances.locked.btc), 0n, 'position sBTC cleared'); + assert.equal(pos.status, 'early_exit', 'marked early_exit on full unstake'); + assert.equal(BigInt((await getBond()).balances.locked.btc), 0n, 'bond btc_locked cleared'); + }); + + test('announce-l1-early-exit marks the position early_exit + inactive, keeping locked balances', async () => { + await db.update( + new TestBlockBuilder({ + block_height: 2, + block_hash: '0x02', + index_block_hash: '0x02', + parent_block_hash: '0x01', + parent_index_block_hash: '0x01', + }) + .addTx({ tx_id: '0x' + 'b2'.repeat(32) }) + .addTxPox5Event({ + name: Pox5EventName.AnnounceL1EarlyExit, + data: { bond_index: String(BOND_INDEX), staker: ALICE }, + }) + .build() + ); + const pos = alicePosition(await getPositions()); + assert.equal(pos.status, 'early_exit', 'status -> early_exit'); + assert.equal(pos.active, false, 'position deactivated'); + // Announcing an exit does not move funds; locked balances are unchanged. + assert.equal(BigInt(pos.balances.locked.btc), SBTC_SATS, 'locked sBTC unchanged'); + assert.equal(BigInt((await getBond()).balances.locked.btc), SBTC_SATS, 'bond btc_locked unchanged'); + }); +}); diff --git a/tests/api/test-builders.ts b/tests/api/test-builders.ts index ec0cd2fa6..70b268875 100644 --- a/tests/api/test-builders.ts +++ b/tests/api/test-builders.ts @@ -20,6 +20,7 @@ import { DbMicroblockPartial, DbMinerReward, DbNftEvent, + DbPox5SyntheticEvent, DbSmartContract, DbSmartContractEvent, DbStxEvent, @@ -31,6 +32,7 @@ import { import { bufferCV, bufferCVFromString, serializeCV, uintCV } from '@stacks/transactions'; import { createClarityValueArray } from '../test-helpers.ts'; import { bufferToHex } from '@stacks/api-toolkit'; +import { Pox5EventName } from '@stacks/codec'; // Default values when none given. Useful when they are irrelevant for a particular test. const BLOCK_HEIGHT = 1; @@ -489,6 +491,34 @@ function testSmartContractLogEvent(args?: TestSmartContractLogEventArgs): DbSmar }; } +interface TestPox5EventArgs { + name?: Pox5EventName; + data?: any; + canonical?: boolean; + event_index?: number; + tx_id?: string; + tx_index?: number; + block_height?: number; +} + +/** + * Generate a test pox5 event. + * @param args - Optional event data + * @returns `DbPox5SyntheticEvent` + */ +function testPox5Event(args?: TestPox5EventArgs): DbPox5SyntheticEvent { + return { + event_index: args?.event_index ?? 0, + tx_id: args?.tx_id ?? TX_ID, + tx_index: args?.tx_index ?? 0, + block_height: args?.block_height ?? BLOCK_HEIGHT, + canonical: args?.canonical ?? true, + name: args?.name ?? Pox5EventName.SetupBond, + data: args?.data ?? {}, + pox_version: 'pox5', + }; +} + interface TestStxEventLockArgs { tx_id?: string; block_height?: number; @@ -799,6 +829,16 @@ export class TestBlockBuilder { return this; } + addTxPox5Event(args?: TestPox5EventArgs): TestBlockBuilder { + const defaultArgs: TestPox5EventArgs = { + tx_id: this.txData.tx.tx_id, + tx_index: this.txIndex, + event_index: ++this.eventIndex, + }; + this.txData.pox5Events.push(testPox5Event({ ...defaultArgs, ...args })); + return this; + } + build(): DataStoreBlockUpdateData { const data = this.data; data.block.tx_count = this.txIndex + 1;