diff --git a/CHANGELOG.md b/CHANGELOG.md index 363193e347..95483cd914 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,11 +26,18 @@ * run inserts in batch and in parallel when processing new block ([#1818](https://github.com/hirosystems/stacks-blockchain-api/issues/1818)) ([86dfdb5](https://github.com/hirosystems/stacks-blockchain-api/commit/86dfdb5d536fee8d7490ca5213f7005a8800f9fa)) - ### Bug Fixes * log block event counts after processing ([#1820](https://github.com/hirosystems/stacks-blockchain-api/issues/1820)) ([9c39743](https://github.com/hirosystems/stacks-blockchain-api/commit/9c397439e6eb2830186cda90a213b3ab3d5a4301)), closes [#1819](https://github.com/hirosystems/stacks-blockchain-api/issues/1819) [#1819](https://github.com/hirosystems/stacks-blockchain-api/issues/1819) + +## [7.7.2](https://github.com/hirosystems/stacks-blockchain-api/compare/v7.7.1...v7.7.2) (2024-01-16) + + +### Bug Fixes + +* revive dropped mempool rebroadcasts ([#1823](https://github.com/hirosystems/stacks-blockchain-api/issues/1823)) ([862b36c](https://github.com/hirosystems/stacks-blockchain-api/commit/862b36c3fa896bcf9b5434ecf33c1bc0c9077aed)) + ## [7.7.1](https://github.com/hirosystems/stacks-blockchain-api/compare/v7.7.0...v7.7.1) (2024-01-11) diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 9bb97492c2..67c6917c63 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -3397,10 +3397,12 @@ paths: endpoint with an estimation of the final length (in bytes) of the transaction, including any post-conditions and signatures + If the node cannot provide an estimate for the transaction (e.g., if the node has never seen a contract-call for the given contract and function) or if estimation is not configured on this node, a 400 response is returned. + The 400 response will be a JSON error containing a `reason` field which can be one of the following: * `DatabaseError` - this Stacks node has had an internal @@ -3412,6 +3414,7 @@ paths: * `CostEstimationDisabled` - this Stacks node does not perform fee or cost estimation, and it cannot respond on this endpoint. + The 200 response contains the following data: * `estimated_cost` - the estimated multi-dimensional cost of executing the Clarity VM on the provided transaction. @@ -3440,6 +3443,7 @@ paths: If the estimated fees are less than the minimum relay fee `(1 ustx x estimated_len)`, then that minimum relay fee will be returned here instead. + Note: If the final transaction's byte size is larger than supplied to `estimated_len`, then applications should increase this fee amount by: diff --git a/src/datastore/common.ts b/src/datastore/common.ts index 2f3459b90b..7e6bb5abad 100644 --- a/src/datastore/common.ts +++ b/src/datastore/common.ts @@ -1548,6 +1548,7 @@ export interface DbChainTip { microblock_count: number; tx_count: number; tx_count_unanchored: number; + mempool_tx_count: number; } export enum IndexesState { diff --git a/src/datastore/pg-store.ts b/src/datastore/pg-store.ts index f74b98cdd8..887025bbad 100644 --- a/src/datastore/pg-store.ts +++ b/src/datastore/pg-store.ts @@ -218,6 +218,7 @@ export class PgStore extends BasePgStore { microblock_count: tip?.microblock_count ?? 0, tx_count: tip?.tx_count ?? 0, tx_count_unanchored: tip?.tx_count_unanchored ?? 0, + mempool_tx_count: tip?.mempool_tx_count ?? 0, }; } diff --git a/src/datastore/pg-write-store.ts b/src/datastore/pg-write-store.ts index e43f72626e..91002f332a 100644 --- a/src/datastore/pg-write-store.ts +++ b/src/datastore/pg-write-store.ts @@ -1688,6 +1688,27 @@ export class PgWriteStore extends PgStore { tenure_change_signature: tx.tenure_change_signature ?? null, tenure_change_signers: tx.tenure_change_signers ?? null, })); + + // Revive mempool txs that were previously dropped + const revivedTxs = await sql<{ tx_id: string }[]>` + UPDATE mempool_txs + SET pruned = false, + status = ${DbTxStatus.Pending}, + receipt_block_height = ${values[0].receipt_block_height}, + receipt_time = ${values[0].receipt_time} + WHERE tx_id IN ${sql(values.map(v => v.tx_id))} + AND pruned = true + AND NOT EXISTS ( + SELECT 1 + FROM txs + WHERE txs.tx_id = mempool_txs.tx_id + AND txs.canonical = true + AND txs.microblock_canonical = true + ) + RETURNING tx_id + `; + txIds.push(...revivedTxs.map(r => r.tx_id)); + const result = await sql<{ tx_id: string }[]>` WITH inserted AS ( INSERT INTO mempool_txs ${sql(values)} @@ -1696,7 +1717,9 @@ export class PgWriteStore extends PgStore { ), count_update AS ( UPDATE chain_tip SET - mempool_tx_count = mempool_tx_count + (SELECT COUNT(*) FROM inserted), + mempool_tx_count = mempool_tx_count + + (SELECT COUNT(*) FROM inserted) + + ${revivedTxs.count}, mempool_updated_at = NOW() ) SELECT tx_id FROM inserted @@ -2381,7 +2404,7 @@ export class PgWriteStore extends PgStore { const updatedRows = await sql<{ tx_id: string }[]>` WITH restored AS ( UPDATE mempool_txs - SET pruned = FALSE + SET pruned = FALSE, status = ${DbTxStatus.Pending} WHERE tx_id IN ${sql(txIds)} AND pruned = TRUE RETURNING tx_id ), diff --git a/src/tests/datastore-tests.ts b/src/tests/datastore-tests.ts index 9faac56268..54012798ae 100644 --- a/src/tests/datastore-tests.ts +++ b/src/tests/datastore-tests.ts @@ -4108,6 +4108,7 @@ describe('postgres datastore', () => { index_block_hash: '0xcc', burn_block_height: 123, block_count: 3, + mempool_tx_count: 0, microblock_count: 0, microblock_hash: undefined, microblock_sequence: undefined, @@ -4180,6 +4181,7 @@ describe('postgres datastore', () => { microblock_sequence: undefined, tx_count: 2, tx_count_unanchored: 2, + mempool_tx_count: 0, }); const block4b: DbBlock = { @@ -4230,6 +4232,7 @@ describe('postgres datastore', () => { microblock_sequence: undefined, tx_count: 2, // Tx from block 2b now counts, but compensates with tx from block 2 tx_count_unanchored: 2, + mempool_tx_count: 1, }); const b1 = await db.getBlock({ hash: block1.block_hash }); diff --git a/src/tests/mempool-tests.ts b/src/tests/mempool-tests.ts index 3b2ee3057d..1436ce9aa5 100644 --- a/src/tests/mempool-tests.ts +++ b/src/tests/mempool-tests.ts @@ -1726,6 +1726,226 @@ describe('mempool tests', () => { expect(txResult2.body.tx_status).toBe('success'); }); + test('Revive dropped and rebroadcasted mempool tx', async () => { + const senderAddress = 'SP25YGP221F01S9SSCGN114MKDAK9VRK8P3KXGEMB'; + const txId = '0x521234'; + const dbBlock1: DbBlock = { + block_hash: '0x0123', + index_block_hash: '0x1234', + parent_index_block_hash: '0x5678', + parent_block_hash: '0x5678', + parent_microblock_hash: '0x00', + parent_microblock_sequence: 0, + block_height: 1, + burn_block_time: 39486, + burn_block_hash: '0x1234', + burn_block_height: 123, + miner_txid: '0x4321', + canonical: true, + execution_cost_read_count: 0, + execution_cost_read_length: 0, + execution_cost_runtime: 0, + execution_cost_write_count: 0, + execution_cost_write_length: 0, + tx_count: 1, + }; + const dbBlock1b: DbBlock = { + block_hash: '0x0123bb', + index_block_hash: '0x1234bb', + parent_index_block_hash: '0x5678bb', + parent_block_hash: '0x5678bb', + parent_microblock_hash: '0x00', + parent_microblock_sequence: 0, + block_height: 1, + burn_block_time: 39486, + burn_block_hash: '0x1234bb', + burn_block_height: 123, + miner_txid: '0x4321bb', + canonical: true, + execution_cost_read_count: 0, + execution_cost_read_length: 0, + execution_cost_runtime: 0, + execution_cost_write_count: 0, + execution_cost_write_length: 0, + tx_count: 1, + }; + const dbBlock2b: DbBlock = { + block_hash: '0x2123', + index_block_hash: '0x2234', + parent_index_block_hash: dbBlock1b.index_block_hash, + parent_block_hash: dbBlock1b.block_hash, + parent_microblock_hash: '0x00', + parent_microblock_sequence: 0, + block_height: 2, + burn_block_time: 39486, + burn_block_hash: '0x1234', + burn_block_height: 123, + miner_txid: '0x4321', + canonical: true, + execution_cost_read_count: 0, + execution_cost_read_length: 0, + execution_cost_runtime: 0, + execution_cost_write_count: 0, + execution_cost_write_length: 0, + tx_count: 1, + }; + const mempoolTx: DbMempoolTxRaw = { + tx_id: txId, + anchor_mode: 3, + nonce: 0, + raw_tx: bufferToHex(Buffer.from('test-raw-mempool-tx')), + type_id: DbTxTypeId.Coinbase, + status: 1, + post_conditions: '0x01f5', + fee_rate: 1234n, + sponsored: false, + sponsor_address: undefined, + sender_address: senderAddress, + origin_hash_mode: 1, + coinbase_payload: bufferToHex(Buffer.from('hi')), + pruned: false, + receipt_time: 1616063078, + }; + const dbTx1: DbTxRaw = { + ...mempoolTx, + ...dbBlock1, + parent_burn_block_time: 1626122935, + tx_index: 4, + status: DbTxStatus.Success, + raw_result: '0x0100000000000000000000000000000001', // u1 + canonical: true, + microblock_canonical: true, + microblock_sequence: I32_MAX, + microblock_hash: '', + parent_index_block_hash: '', + event_count: 0, + execution_cost_read_count: 0, + execution_cost_read_length: 0, + execution_cost_runtime: 0, + execution_cost_write_count: 0, + execution_cost_write_length: 0, + }; + + await db.updateMempoolTxs({ mempoolTxs: [mempoolTx] }); + + let chainTip = await db.getChainTip(); + expect(chainTip.mempool_tx_count).toBe(1); + + // Verify tx shows up in mempool (non-pruned) + const mempoolResult1 = await supertest(api.server).get( + `/extended/v1/address/${mempoolTx.sender_address}/mempool` + ); + expect(mempoolResult1.body.results[0].tx_id).toBe(txId); + const mempoolCount1 = await supertest(api.server).get(`/extended/v1/tx/mempool`); + expect(mempoolCount1.body.total).toBe(1); + + // Drop mempool tx + await db.dropMempoolTxs({ + status: DbTxStatus.DroppedStaleGarbageCollect, + txIds: [mempoolTx.tx_id], + }); + + // Verify tx is pruned from mempool + const mempoolResult2 = await supertest(api.server).get( + `/extended/v1/address/${mempoolTx.sender_address}/mempool` + ); + expect(mempoolResult2.body.results).toHaveLength(0); + const mempoolCount2 = await supertest(api.server).get(`/extended/v1/tx/mempool`); + expect(mempoolCount2.body.total).toBe(0); + chainTip = await db.getChainTip(); + expect(chainTip.mempool_tx_count).toBe(0); + + // Re-broadcast mempool tx + await db.updateMempoolTxs({ mempoolTxs: [mempoolTx] }); + + // Verify tx shows up in mempool again (revived) + const mempoolResult3 = await supertest(api.server).get( + `/extended/v1/address/${mempoolTx.sender_address}/mempool` + ); + expect(mempoolResult3.body.results[0].tx_id).toBe(txId); + const mempoolCount3 = await supertest(api.server).get(`/extended/v1/tx/mempool`); + expect(mempoolCount3.body.total).toBe(1); + chainTip = await db.getChainTip(); + expect(chainTip.mempool_tx_count).toBe(1); + + // Mine tx in block to prune from mempool + await db.update({ + block: dbBlock1, + microblocks: [], + minerRewards: [], + txs: [ + { + tx: dbTx1, + stxEvents: [], + stxLockEvents: [], + ftEvents: [], + nftEvents: [], + contractLogEvents: [], + smartContracts: [], + names: [], + namespaces: [], + pox2Events: [], + pox3Events: [], + pox4Events: [], + }, + ], + }); + + // Verify tx is pruned from mempool + const mempoolResult4 = await supertest(api.server).get( + `/extended/v1/address/${mempoolTx.sender_address}/mempool` + ); + expect(mempoolResult4.body.results).toHaveLength(0); + const mempoolCount4 = await supertest(api.server).get(`/extended/v1/tx/mempool`); + expect(mempoolCount4.body.total).toBe(0); + chainTip = await db.getChainTip(); + expect(chainTip.mempool_tx_count).toBe(0); + + // Verify tx is mined + const txResult1 = await supertest(api.server).get(`/extended/v1/tx/${txId}`); + expect(txResult1.body.tx_status).toBe('success'); + expect(txResult1.body.canonical).toBe(true); + + // Orphan the block to get the tx orphaned and placed back in the pool + await db.update({ + block: dbBlock1b, + microblocks: [], + minerRewards: [], + txs: [], + }); + await db.update({ + block: dbBlock2b, + microblocks: [], + minerRewards: [], + txs: [], + }); + + // Verify tx is orphaned and back in mempool + const txResult2 = await supertest(api.server).get(`/extended/v1/tx/${txId}`); + expect(txResult2.body.canonical).toBeFalsy(); + + // Verify tx has been revived and is back in the mempool + const mempoolResult5 = await supertest(api.server).get( + `/extended/v1/address/${mempoolTx.sender_address}/mempool` + ); + expect(mempoolResult5.body.results[0].tx_id).toBe(txId); + const mempoolCount5 = await supertest(api.server).get(`/extended/v1/tx/mempool`); + expect(mempoolCount5.body.total).toBe(1); + chainTip = await db.getChainTip(); + expect(chainTip.mempool_tx_count).toBe(1); + + // Re-broadcast mempool tx + await db.updateMempoolTxs({ mempoolTxs: [mempoolTx] }); + + // Verify tx has been revived and is back in the mempool + const mempoolResult6 = await supertest(api.server).get( + `/extended/v1/address/${mempoolTx.sender_address}/mempool` + ); + expect(mempoolResult6.body.results[0].tx_id).toBe(txId); + const mempoolCount6 = await supertest(api.server).get(`/extended/v1/tx/mempool`); + expect(mempoolCount6.body.total).toBe(1); + }); + test('returns fee priorities for mempool transactions', async () => { const mempoolTxs: DbMempoolTxRaw[] = []; for (let i = 0; i < 10; i++) {