diff --git a/CHANGELOG.md b/CHANGELOG.md index 68857aa95..f9e718332 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,18 @@ +## [8.1.2](https://github.com/hirosystems/stacks-blockchain-api/compare/v8.1.1...v8.1.2) (2024-10-21) + + +### Bug Fixes + +* **rosetta:** support tenure change transactions ([#2128](https://github.com/hirosystems/stacks-blockchain-api/issues/2128)) ([bfbf65c](https://github.com/hirosystems/stacks-blockchain-api/commit/bfbf65c6f3a7baf869e3d5124e53b7c5861c5afb)) +* **rosetta:** use Nakamoto block timestamps for epoch3/Nakamoto block responses ([#2132](https://github.com/hirosystems/stacks-blockchain-api/issues/2132)) ([bd13962](https://github.com/hirosystems/stacks-blockchain-api/commit/bd13962dacc4023a247da40e06c6861cd1e8f2bf)) + +## [8.1.1](https://github.com/hirosystems/stacks-blockchain-api/compare/v8.1.0...v8.1.1) (2024-10-18) + + +### Bug Fixes + +* identify mempool transactions separately when calculating principal etag ([#2126](https://github.com/hirosystems/stacks-blockchain-api/issues/2126)) ([b9dee2a](https://github.com/hirosystems/stacks-blockchain-api/commit/b9dee2a85cb6e733cb0ab2f4d1c7c12cd303bec4)) + ## [8.1.0](https://github.com/hirosystems/stacks-blockchain-api/compare/v8.0.4...v8.1.0) (2024-10-16) diff --git a/src/api/controllers/db-controller.ts b/src/api/controllers/db-controller.ts index 19ddc73a7..ec70ce459 100644 --- a/src/api/controllers/db-controller.ts +++ b/src/api/controllers/db-controller.ts @@ -124,6 +124,7 @@ export function getTxTypeString(typeId: DbTxTypeId): Transaction['tx_type'] { return 'poison_microblock'; case DbTxTypeId.Coinbase: case DbTxTypeId.CoinbaseToAltRecipient: + case DbTxTypeId.NakamotoCoinbase: return 'coinbase'; case DbTxTypeId.TenureChange: return 'tenure_change'; @@ -145,7 +146,7 @@ function getTxAnchorModeString(anchorMode: number): TransactionAnchorModeType { } } -function getTxTenureChangeCauseString(cause: number) { +export function getTxTenureChangeCauseString(cause: number) { switch (cause) { case 0: return 'block_found'; @@ -540,10 +541,17 @@ export async function getRosettaBlockFromDataStore( } } + // In epoch2.x, only the burn_block_time is consensus-level. Starting in epoch3, Stacks blocks include a consensus-level timestamp. + // Use `signer_bitvec` field to determine if the block is from epoch3. + let timestamp = dbBlock.burn_block_time * 1000; + if (dbBlock.signer_bitvec) { + timestamp = dbBlock.block_time * 1000; + } + const apiBlock: RosettaBlock = { block_identifier: { index: dbBlock.block_height, hash: dbBlock.block_hash }, parent_block_identifier, - timestamp: dbBlock.burn_block_time * 1000, + timestamp: timestamp, transactions: blockTxs.found ? blockTxs.result : [], metadata: { burn_block_height: dbBlock.burn_block_height, diff --git a/src/api/rosetta-constants.ts b/src/api/rosetta-constants.ts index 3a391272c..d3ff59130 100644 --- a/src/api/rosetta-constants.ts +++ b/src/api/rosetta-constants.ts @@ -52,7 +52,7 @@ export enum RosettaOperationType { StackStx = 'stack_stx', DelegateStx = 'delegate_stx', RevokeDelegateStx = 'revoke_delegate_stx', - // todo: add new pox-2 methods + TenureChange = 'tenure_change', } type RosettaOperationTypeUnion = `${RosettaOperationType}`; @@ -77,6 +77,7 @@ export const RosettaOperationTypes = arrayOfAllOpTypes([ 'stack_stx', 'delegate_stx', 'revoke_delegate_stx', + 'tenure_change', ]) as RosettaOperationType[]; export const RosettaOperationStatuses = [ diff --git a/src/datastore/pg-store.ts b/src/datastore/pg-store.ts index c1175b7cc..1641e459d 100644 --- a/src/datastore/pg-store.ts +++ b/src/datastore/pg-store.ts @@ -4444,7 +4444,7 @@ export class PgStore extends BasePgStore { const result = await this.sql<{ tx_id: string }[]>` WITH activity AS ( ( - SELECT tx_id + SELECT '0x' || encode(tx_id, 'hex') AS tx_id FROM principal_stx_txs WHERE principal = ${principal} AND canonical = true AND microblock_canonical = true ORDER BY block_height DESC, microblock_sequence DESC, tx_index DESC @@ -4452,7 +4452,7 @@ export class PgStore extends BasePgStore { ) UNION ( - SELECT tx_id + SELECT '0x' || encode(tx_id, 'hex') AS tx_id FROM ft_events WHERE (sender = ${principal} OR recipient = ${principal}) AND canonical = true @@ -4462,7 +4462,7 @@ export class PgStore extends BasePgStore { ) UNION ( - SELECT tx_id + SELECT '0x' || encode(tx_id, 'hex') AS tx_id FROM nft_events WHERE (sender = ${principal} OR recipient = ${principal}) AND canonical = true @@ -4474,7 +4474,7 @@ export class PgStore extends BasePgStore { includeMempool ? this.sql`UNION ( - SELECT tx_id + SELECT 'mempool-' || '0x' || encode(tx_id, 'hex') AS tx_id FROM mempool_txs WHERE pruned = false AND (sender_address = ${principal} @@ -4486,7 +4486,7 @@ export class PgStore extends BasePgStore { : this.sql`` } ) - SELECT DISTINCT tx_id FROM activity WHERE tx_id IS NOT NULL + SELECT tx_id FROM activity WHERE tx_id IS NOT NULL `; return result.map(r => r.tx_id); } diff --git a/src/rosetta/rosetta-helpers.ts b/src/rosetta/rosetta-helpers.ts index 2f924e82f..5e8c4e294 100644 --- a/src/rosetta/rosetta-helpers.ts +++ b/src/rosetta/rosetta-helpers.ts @@ -25,6 +25,7 @@ import * as btc from 'bitcoinjs-lib'; import { getTxFromDataStore, getTxStatus, + getTxTenureChangeCauseString, getTxTypeString, parseContractCallMetadata, } from '../api/controllers/db-controller'; @@ -170,6 +171,9 @@ async function getOperationsInternal( case RosettaOperationType.PoisonMicroblock: operations.push(makePoisonMicroblockOperation(tx, 0)); break; + case RosettaOperationType.TenureChange: + operations.push(makeTenureChangeOperation(tx, operations.length)); + break; default: throw new Error(`Unexpected tx type: ${JSON.stringify(txType)}`); } @@ -727,6 +731,23 @@ function makePoisonMicroblockOperation(tx: BaseTx, index: number): RosettaOperat return sender; } +function makeTenureChangeOperation(tx: BaseTx, index: number): RosettaOperation { + return { + operation_identifier: { index: index }, + type: RosettaOperationType.TenureChange, + status: getTxStatus(tx.status), + metadata: { + tenure_consensus_hash: tx.tenure_change_tenure_consensus_hash as string, + prev_tenure_consensus_hash: tx.tenure_change_prev_tenure_consensus_hash as string, + burn_view_consensus_hash: tx.tenure_change_burn_view_consensus_hash as string, + previous_tenure_end: tx.tenure_change_previous_tenure_end as string, + previous_tenure_blocks: tx.tenure_change_previous_tenure_blocks as number, + cause: getTxTenureChangeCauseString(tx.tenure_change_cause as number), + pubkey_hash: tx.tenure_change_pubkey_hash as string, + }, + }; +} + export function publicKeyToBitcoinAddress(publicKey: string, network: string): string | undefined { const publicKeyBuffer = Buffer.from(publicKey, 'hex'); diff --git a/tests/api/cache-control.test.ts b/tests/api/cache-control.test.ts index 8a083f606..c7c010ad1 100644 --- a/tests/api/cache-control.test.ts +++ b/tests/api/cache-control.test.ts @@ -819,6 +819,76 @@ describe('cache-control tests', () => { expect(request8.text).toBe(''); }); + test('principal mempool cache on received tx balance confirmation', async () => { + const address = 'SP3FXEKSA6D4BW3TFP2BWTSREV6FY863Y90YY7D8G'; + const url = `/extended/v1/address/${address}/balances`; + await db.update( + new TestBlockBuilder({ + block_height: 1, + index_block_hash: '0x01', + parent_index_block_hash: '0x00', + }).build() + ); + + // ETag zero. + const request1 = await supertest(api.server).get(url); + expect(request1.status).toBe(200); + expect(request1.type).toBe('application/json'); + const etag0 = request1.headers['etag']; + + // Add receiving STX tx. + await db.updateMempoolTxs({ + mempoolTxs: [ + testMempoolTx({ + tx_id: '0x0001', + token_transfer_amount: 2000n, + token_transfer_recipient_address: address, + }), + ], + }); + + // Valid ETag. + const request2 = await supertest(api.server).get(url); + expect(request2.status).toBe(200); + expect(request2.type).toBe('application/json'); + expect(request2.headers['etag']).toBeTruthy(); + const json2 = JSON.parse(request2.text); + expect(json2.stx.balance).toBe('0'); + expect(json2.stx.estimated_balance).toBe('2000'); + const etag1 = request2.headers['etag']; + expect(etag1).not.toEqual(etag0); + + // Cache works with valid ETag. + const request3 = await supertest(api.server).get(url).set('If-None-Match', etag1); + expect(request3.status).toBe(304); + expect(request3.text).toBe(''); + + // Confirm mempool tx. + await db.update( + new TestBlockBuilder({ + block_height: 2, + index_block_hash: '0x02', + parent_index_block_hash: '0x01', + }) + .addTx({ + tx_id: '0x0001', + token_transfer_amount: 2000n, + token_transfer_recipient_address: address, + }) + .addTxStxEvent({ amount: 2000n, recipient: address }) + .build() + ); + + // Cache is now a miss. + const request4 = await supertest(api.server).get(url).set('If-None-Match', etag1); + expect(request4.status).toBe(200); + expect(request4.type).toBe('application/json'); + expect(request4.headers['etag']).not.toEqual(etag1); + const json4 = JSON.parse(request4.text); + expect(json4.stx.balance).toBe('2000'); + expect(json4.stx.estimated_balance).toBe('2000'); + }); + test('block cache control', async () => { await db.update( new TestBlockBuilder({ diff --git a/tests/rosetta/api.test.ts b/tests/rosetta/api.test.ts index 2bce79f64..8ba073993 100644 --- a/tests/rosetta/api.test.ts +++ b/tests/rosetta/api.test.ts @@ -460,6 +460,94 @@ describe('Rosetta API', () => { }); }); + test('block - Nakamoto timestamps', async () => { + const parentData = new TestBlockBuilder({ + block_height: 0, + }).build(); + + // Epoch2.x block + const block1 = new TestBlockBuilder({ + block_height: 1, + block_hash: '0x1234', + index_block_hash: '0x123456', + parent_block_hash: parentData.block.block_hash, + parent_index_block_hash: parentData.block.index_block_hash, + }).build(); + block1.block.burn_block_time = 1222; + block1.block.block_time = 1333; + block1.block.signer_bitvec = null; + + // Epoch3 block + const block2 = new TestBlockBuilder({ + block_height: 2, + block_hash: '0x2234', + index_block_hash: '0x223456', + parent_block_hash: block1.block.block_hash, + parent_index_block_hash: block1.block.index_block_hash, + }).build(); + block2.block.burn_block_time = 2222; + block2.block.block_time = 2333; + block2.block.signer_bitvec = '1111'; + + await db.update(parentData); + await db.update(block1); + await db.update(block2); + + const query1 = await supertest(api.address) + .post(`/rosetta/v1/block`) + .send({ + network_identifier: { blockchain: 'stacks', network: 'testnet' }, + block_identifier: { index: block1.block.block_height }, + }); + expect(query1.status).toBe(200); + expect(query1.type).toBe('application/json'); + const expected1: RosettaBlockResponse = { + block: { + block_identifier: { + index: block1.block.block_height, + hash: block1.block.block_hash, + }, + parent_block_identifier: { + index: 1, + hash: '0x1234', + }, + timestamp: block1.block.burn_block_time * 1000, // epoch2.x, should be burn-block-time + transactions: [], + metadata: { + burn_block_height: block1.block.burn_block_height, + }, + }, + }; + expect(query1.body).toEqual(expected1); + + const query2 = await supertest(api.address) + .post(`/rosetta/v1/block`) + .send({ + network_identifier: { blockchain: 'stacks', network: 'testnet' }, + block_identifier: { index: block2.block.block_height }, + }); + expect(query2.status).toBe(200); + expect(query2.type).toBe('application/json'); + const expected2: RosettaBlockResponse = { + block: { + block_identifier: { + index: block2.block.block_height, + hash: block2.block.block_hash, + }, + parent_block_identifier: { + index: block2.block.block_height - 1, + hash: block2.block.parent_block_hash, + }, + timestamp: block2.block.block_time * 1000, // epoch3, should be Stacks-block-time + transactions: [], + metadata: { + burn_block_height: block2.block.burn_block_height, + }, + }, + }; + expect(query2.body).toEqual(expected2); + }); + test('stx-transfer-memo block/transaction', async () => { const parentData = new TestBlockBuilder().addTx().build(); const block: TestBlockArgs = { @@ -730,6 +818,133 @@ describe('Rosetta API', () => { }); }); + test('epoch3 tenure-change block/transaction', async () => { + const parentData = new TestBlockBuilder().addTx().build(); + const block1: TestBlockArgs = { + block_height: 2, + block_hash: '0xd0dd05e3d0a1bd60640c9d9d30d57012ffe47b52fe643140c39199c757d37e3f', + index_block_hash: '0x6a36c14514047074c2877065809bbb70d81d52507747f4616da997deb7228fad', + parent_index_block_hash: parentData.block.index_block_hash, + parent_block_hash: parentData.block.block_hash, + parent_microblock_hash: '0x0000000000000000000000000000000000000000000000000000000000000000', + burn_block_hash: '0xfe15c0d3ebe314fad720a08b839a004c2e6386f5aecc19ec74807d1920cb6aeb', + miner_txid: '0x0000000000000000000000000000000000000000000000000000000000000000', + }; + const txTenureChange1: TestTxArgs = { + tx_id: '0xc152de9376bab4fc27291c9cd088643698290a12bb511d768f873cb3d280eb48', + tx_index: 1, + type_id: DbTxTypeId.TenureChange, + status: DbTxStatus.Success, + raw_result: '0x0703', + canonical: true, + microblock_canonical: true, + microblock_sequence: 2147483647, + microblock_hash: '0x00', + fee_rate: 0n, + sender_address: 'ST1HB1T8WRNBYB0Y3T7WXZS38NKKPTBR3EG9EPJKR', + tenure_change_tenure_consensus_hash: '0x2fedd90a5f318ed8cec419fd1c6656b5af452497', + tenure_change_prev_tenure_consensus_hash: '0x5104aae6d442b49c8e8d2031df7f40b67528e654', + tenure_change_burn_view_consensus_hash: '0x2fedd90a5f318ed8cec419fd1c6656b5af452497', + tenure_change_previous_tenure_end: + '0xb77b061202b1e6dce889ba1633efa969d3c24679d32a7542d29015ee94e8a860', + tenure_change_previous_tenure_blocks: 9, + tenure_change_cause: 0, + tenure_change_pubkey_hash: '0x62b4273562dfa3825496094507564bf2b30c8b11', + }; + const blockData1 = new TestBlockBuilder(block1).addTx(txTenureChange1).build(); + + await db.update(parentData); + await db.update(blockData1); + + const query1 = await supertest(api.server) + .post(`/rosetta/v1/block/transaction`) + .send({ + network_identifier: { blockchain: 'stacks', network: 'testnet' }, + block_identifier: { + index: blockData1.block.block_height, + hash: blockData1.block.block_hash, + }, + transaction_identifier: { hash: txTenureChange1.tx_id }, + }); + expect(query1.status).toBe(200); + expect(query1.type).toBe('application/json'); + expect(query1.body).toEqual({ + transaction_identifier: { + hash: txTenureChange1.tx_id, + }, + operations: [ + { + operation_identifier: { + index: 0, + }, + type: 'tenure_change', + status: 'success', + metadata: { + tenure_consensus_hash: txTenureChange1.tenure_change_tenure_consensus_hash, + prev_tenure_consensus_hash: txTenureChange1.tenure_change_prev_tenure_consensus_hash, + burn_view_consensus_hash: txTenureChange1.tenure_change_burn_view_consensus_hash, + previous_tenure_end: txTenureChange1.tenure_change_previous_tenure_end, + previous_tenure_blocks: txTenureChange1.tenure_change_previous_tenure_blocks, + cause: 'block_found', + pubkey_hash: txTenureChange1.tenure_change_pubkey_hash, + }, + }, + ], + }); + + const query2 = await supertest(api.address) + .post(`/rosetta/v1/block`) + .send({ + network_identifier: { blockchain: 'stacks', network: 'testnet' }, + block_identifier: { index: blockData1.block.block_height }, + }); + expect(query1.status).toBe(200); + expect(query1.type).toBe('application/json'); + const expected: RosettaBlockResponse = { + block: { + block_identifier: { + index: blockData1.block.block_height, + hash: blockData1.block.block_hash, + }, + parent_block_identifier: { + index: blockData1.block.block_height - 1, + hash: blockData1.block.parent_block_hash, + }, + timestamp: blockData1.block.burn_block_time * 1000, + transactions: [ + { + transaction_identifier: { + hash: txTenureChange1.tx_id as string, + }, + operations: [ + { + operation_identifier: { + index: 0, + }, + type: 'tenure_change', + status: 'success', + metadata: { + tenure_consensus_hash: txTenureChange1.tenure_change_tenure_consensus_hash, + prev_tenure_consensus_hash: + txTenureChange1.tenure_change_prev_tenure_consensus_hash, + burn_view_consensus_hash: txTenureChange1.tenure_change_burn_view_consensus_hash, + previous_tenure_end: txTenureChange1.tenure_change_previous_tenure_end, + previous_tenure_blocks: txTenureChange1.tenure_change_previous_tenure_blocks, + cause: 'block_found', + pubkey_hash: txTenureChange1.tenure_change_pubkey_hash, + }, + }, + ], + }, + ], + metadata: { + burn_block_height: blockData1.block.burn_block_height, + }, + }, + }; + expect(query2.body).toEqual(expected); + }); + test('block/transaction - invalid transaction hash', async () => { const query1 = await supertest(api.server) .post(`/rosetta/v1/block/transaction`)