diff --git a/backend/src/api/blocks.ts b/backend/src/api/blocks.ts index a5b8af0e2..306179ca5 100644 --- a/backend/src/api/blocks.ts +++ b/backend/src/api/blocks.ts @@ -219,10 +219,10 @@ class Blocks { }; } - public summarizeBlockTransactions(hash: string, transactions: TransactionExtended[]): BlockSummary { + public summarizeBlockTransactions(hash: string, height: number, transactions: TransactionExtended[]): BlockSummary { return { id: hash, - transactions: Common.classifyTransactions(transactions), + transactions: Common.classifyTransactions(transactions, height), }; } @@ -616,7 +616,7 @@ class Blocks { // add CPFP const cpfpSummary = calculateGoodBlockCpfp(height, txs, []); // classify - const { transactions: classifiedTxs } = this.summarizeBlockTransactions(blockHash, cpfpSummary.transactions); + const { transactions: classifiedTxs } = this.summarizeBlockTransactions(blockHash, height, cpfpSummary.transactions); await BlocksSummariesRepository.$saveTransactions(height, blockHash, classifiedTxs, 2); if (unclassifiedBlocks[height].version < 2 && targetSummaryVersion === 2) { const cpfpClusters = await CpfpRepository.$getClustersAt(height); @@ -653,7 +653,7 @@ class Blocks { } const cpfpSummary = calculateGoodBlockCpfp(height, templateTxs?.filter(tx => tx['effectiveFeePerVsize'] != null) as MempoolTransactionExtended[], []); // classify - const { transactions: classifiedTxs } = this.summarizeBlockTransactions(blockHash, cpfpSummary.transactions); + const { transactions: classifiedTxs } = this.summarizeBlockTransactions(blockHash, height, cpfpSummary.transactions); const classifiedTxMap: { [txid: string]: TransactionClassified } = {}; for (const tx of classifiedTxs) { classifiedTxMap[tx.txid] = tx; @@ -912,7 +912,7 @@ class Blocks { } const cpfpSummary: CpfpSummary = calculateGoodBlockCpfp(block.height, transactions, accelerations.map(a => ({ txid: a.txid, max_bid: a.feeDelta }))); const blockExtended: BlockExtended = await this.$getBlockExtended(block, cpfpSummary.transactions); - const blockSummary: BlockSummary = this.summarizeBlockTransactions(block.id, cpfpSummary.transactions); + const blockSummary: BlockSummary = this.summarizeBlockTransactions(block.id, block.height, cpfpSummary.transactions); this.updateTimerProgress(timer, `got block data for ${this.currentBlockHeight}`); if (Common.indexingEnabled()) { @@ -1169,7 +1169,7 @@ class Blocks { transactions: cpfpSummary.transactions.map(tx => { let flags: number = 0; try { - flags = Common.getTransactionFlags(tx); + flags = Common.getTransactionFlags(tx, height); } catch (e) { logger.warn('Failed to classify transaction: ' + (e instanceof Error ? e.message : e)); } @@ -1188,7 +1188,7 @@ class Blocks { } else { if (config.MEMPOOL.BACKEND === 'esplora') { const txs = (await bitcoinApi.$getTxsForBlock(hash)).map(tx => transactionUtils.extendTransaction(tx)); - summary = this.summarizeBlockTransactions(hash, txs); + summary = this.summarizeBlockTransactions(hash, height || 0, txs); summaryVersion = 1; } else { // Call Core RPC @@ -1324,7 +1324,7 @@ class Blocks { let summaryVersion = 0; if (config.MEMPOOL.BACKEND === 'esplora') { const txs = (await bitcoinApi.$getTxsForBlock(cleanBlock.hash)).map(tx => transactionUtils.extendTransaction(tx)); - summary = this.summarizeBlockTransactions(cleanBlock.hash, txs); + summary = this.summarizeBlockTransactions(cleanBlock.hash, cleanBlock.height, txs); summaryVersion = 1; } else { // Call Core RPC diff --git a/backend/src/api/common.ts b/backend/src/api/common.ts index 13fc86147..d17068a09 100644 --- a/backend/src/api/common.ts +++ b/backend/src/api/common.ts @@ -10,7 +10,6 @@ import logger from '../logger'; import { getVarIntLength, opcodes, parseMultisigScript } from '../utils/bitcoin-script'; // Bitcoin Core default policy settings -const TX_MAX_STANDARD_VERSION = 2; const MAX_STANDARD_TX_WEIGHT = 400_000; const MAX_BLOCK_SIGOPS_COST = 80_000; const MAX_STANDARD_TX_SIGOPS_COST = (MAX_BLOCK_SIGOPS_COST / 5); @@ -200,10 +199,13 @@ export class Common { * * returns true early if any standardness rule is violated, otherwise false * (except for non-mandatory-script-verify-flag and p2sh script evaluation rules which are *not* enforced) + * + * As standardness rules change, we'll need to apply the rules in force *at the time* to older blocks. + * For now, just pull out individual rules into versioned functions where necessary. */ - static isNonStandard(tx: TransactionExtended): boolean { + static isNonStandard(tx: TransactionExtended, height?: number): boolean { // version - if (tx.version > TX_MAX_STANDARD_VERSION) { + if (this.isNonStandardVersion(tx, height)) { return true; } @@ -250,6 +252,8 @@ export class Common { } } else if (['unknown', 'provably_unspendable', 'empty'].includes(vin.prevout?.scriptpubkey_type || '')) { return true; + } else if (this.isNonStandardAnchor(tx, height)) { + return true; } // TODO: bad-witness-nonstandard } @@ -335,6 +339,49 @@ export class Common { return false; } + // Individual versioned standardness rules + + static V3_STANDARDNESS_ACTIVATION_HEIGHT = { + 'testnet4': 42_000, + 'testnet': 2_900_000, + 'signet': 211_000, + '': 863_500, + }; + static isNonStandardVersion(tx: TransactionExtended, height?: number): boolean { + let TX_MAX_STANDARD_VERSION = 3; + if ( + height != null + && this.V3_STANDARDNESS_ACTIVATION_HEIGHT[config.MEMPOOL.NETWORK] + && height <= this.V3_STANDARDNESS_ACTIVATION_HEIGHT[config.MEMPOOL.NETWORK] + ) { + // V3 transactions were non-standard to spend before v28.x (scheduled for 2024/09/30 https://github.com/bitcoin/bitcoin/issues/29891) + TX_MAX_STANDARD_VERSION = 2; + } + + if (tx.version > TX_MAX_STANDARD_VERSION) { + return true; + } + return false; + } + + static ANCHOR_STANDARDNESS_ACTIVATION_HEIGHT = { + 'testnet4': 42_000, + 'testnet': 2_900_000, + 'signet': 211_000, + '': 863_500, + }; + static isNonStandardAnchor(tx: TransactionExtended, height?: number): boolean { + if ( + height != null + && this.ANCHOR_STANDARDNESS_ACTIVATION_HEIGHT[config.MEMPOOL.NETWORK] + && height <= this.ANCHOR_STANDARDNESS_ACTIVATION_HEIGHT[config.MEMPOOL.NETWORK] + ) { + // anchor outputs were non-standard to spend before v28.x (scheduled for 2024/09/30 https://github.com/bitcoin/bitcoin/issues/29891) + return true; + } + return false; + } + static getNonWitnessSize(tx: TransactionExtended): number { let weight = tx.weight; let hasWitness = false; @@ -415,7 +462,7 @@ export class Common { return flags; } - static getTransactionFlags(tx: TransactionExtended): number { + static getTransactionFlags(tx: TransactionExtended, height?: number): number { let flags = tx.flags ? BigInt(tx.flags) : 0n; // Update variable flags (CPFP, RBF) @@ -548,7 +595,7 @@ export class Common { if (hasFakePubkey) { flags |= TransactionFlags.fake_pubkey; } - + // fast but bad heuristic to detect possible coinjoins // (at least 5 inputs and 5 outputs, less than half of which are unique amounts, with no address reuse) const addressReuse = Object.keys(reusedOutputAddresses).reduce((acc, key) => Math.max(acc, (reusedInputAddresses[key] || 0) + (reusedOutputAddresses[key] || 0)), 0) > 1; @@ -564,17 +611,17 @@ export class Common { flags |= TransactionFlags.batch_payout; } - if (this.isNonStandard(tx)) { + if (this.isNonStandard(tx, height)) { flags |= TransactionFlags.nonstandard; } return Number(flags); } - static classifyTransaction(tx: TransactionExtended): TransactionClassified { + static classifyTransaction(tx: TransactionExtended, height?: number): TransactionClassified { let flags = 0; try { - flags = Common.getTransactionFlags(tx); + flags = Common.getTransactionFlags(tx, height); } catch (e) { logger.warn('Failed to add classification flags to transaction: ' + (e instanceof Error ? e.message : e)); } @@ -585,8 +632,8 @@ export class Common { }; } - static classifyTransactions(txs: TransactionExtended[]): TransactionClassified[] { - return txs.map(Common.classifyTransaction); + static classifyTransactions(txs: TransactionExtended[], height?: number): TransactionClassified[] { + return txs.map(tx => Common.classifyTransaction(tx, height)); } static stripTransaction(tx: TransactionExtended): TransactionStripped { diff --git a/backend/src/repositories/BlocksRepository.ts b/backend/src/repositories/BlocksRepository.ts index 90100a767..de6c1deb8 100644 --- a/backend/src/repositories/BlocksRepository.ts +++ b/backend/src/repositories/BlocksRepository.ts @@ -1106,7 +1106,7 @@ class BlocksRepository { let summaryVersion = 0; if (config.MEMPOOL.BACKEND === 'esplora') { const txs = (await bitcoinApi.$getTxsForBlock(dbBlk.id)).map(tx => transactionUtils.extendTransaction(tx)); - summary = blocks.summarizeBlockTransactions(dbBlk.id, txs); + summary = blocks.summarizeBlockTransactions(dbBlk.id, dbBlk.height, txs); summaryVersion = 1; } else { // Call Core RPC diff --git a/frontend/src/app/components/tracker/tracker.component.ts b/frontend/src/app/components/tracker/tracker.component.ts index 24b5fc1dc..3b0f53e9c 100644 --- a/frontend/src/app/components/tracker/tracker.component.ts +++ b/frontend/src/app/components/tracker/tracker.component.ts @@ -747,7 +747,7 @@ export class TrackerComponent implements OnInit, OnDestroy { checkAccelerationEligibility() { if (this.tx) { - this.tx.flags = getTransactionFlags(this.tx); + this.tx.flags = getTransactionFlags(this.tx, null, null, this.tx.status?.block_time, this.stateService.network); const replaceableInputs = (this.tx.flags & (TransactionFlags.sighash_none | TransactionFlags.sighash_acp)) > 0n; const highSigop = (this.tx.sigops * 20) > this.tx.weight; this.eligibleForAcceleration = !replaceableInputs && !highSigop; diff --git a/frontend/src/app/components/transaction/transaction.component.ts b/frontend/src/app/components/transaction/transaction.component.ts index 8c0d3b4a9..c80006552 100644 --- a/frontend/src/app/components/transaction/transaction.component.ts +++ b/frontend/src/app/components/transaction/transaction.component.ts @@ -901,7 +901,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { this.segwitEnabled = !this.tx.status.confirmed || isFeatureActive(this.stateService.network, this.tx.status.block_height, 'segwit'); this.taprootEnabled = !this.tx.status.confirmed || isFeatureActive(this.stateService.network, this.tx.status.block_height, 'taproot'); this.rbfEnabled = !this.tx.status.confirmed || isFeatureActive(this.stateService.network, this.tx.status.block_height, 'rbf'); - this.tx.flags = getTransactionFlags(this.tx); + this.tx.flags = getTransactionFlags(this.tx, null, null, this.tx.status?.block_time, this.stateService.network); this.filters = this.tx.flags ? toFilters(this.tx.flags).filter(f => f.txPage) : []; this.checkAccelerationEligibility(); } else { diff --git a/frontend/src/app/shared/transaction.utils.ts b/frontend/src/app/shared/transaction.utils.ts index c13616c60..bbf28a250 100644 --- a/frontend/src/app/shared/transaction.utils.ts +++ b/frontend/src/app/shared/transaction.utils.ts @@ -2,9 +2,9 @@ import { TransactionFlags } from './filters.utils'; import { getVarIntLength, opcodes, parseMultisigScript, isPoint } from './script.utils'; import { Transaction } from '../interfaces/electrs.interface'; import { CpfpInfo, RbfInfo, TransactionStripped } from '../interfaces/node-api.interface'; +import { StateService } from '../services/state.service'; // Bitcoin Core default policy settings -const TX_MAX_STANDARD_VERSION = 2; const MAX_STANDARD_TX_WEIGHT = 400_000; const MAX_BLOCK_SIGOPS_COST = 80_000; const MAX_STANDARD_TX_SIGOPS_COST = (MAX_BLOCK_SIGOPS_COST / 5); @@ -89,10 +89,13 @@ export function isDERSig(w: string): boolean { * * returns true early if any standardness rule is violated, otherwise false * (except for non-mandatory-script-verify-flag and p2sh script evaluation rules which are *not* enforced) + * + * As standardness rules change, we'll need to apply the rules in force *at the time* to older blocks. + * For now, just pull out individual rules into versioned functions where necessary. */ -export function isNonStandard(tx: Transaction): boolean { +export function isNonStandard(tx: Transaction, height?: number, network?: string): boolean { // version - if (tx.version > TX_MAX_STANDARD_VERSION) { + if (isNonStandardVersion(tx, height, network)) { return true; } @@ -139,6 +142,8 @@ export function isNonStandard(tx: Transaction): boolean { } } else if (['unknown', 'provably_unspendable', 'empty'].includes(vin.prevout?.scriptpubkey_type || '')) { return true; + } else if (isNonStandardAnchor(tx, height, network)) { + return true; } // TODO: bad-witness-nonstandard } @@ -203,6 +208,51 @@ export function isNonStandard(tx: Transaction): boolean { return false; } +// Individual versioned standardness rules + +const V3_STANDARDNESS_ACTIVATION_HEIGHT = { + 'testnet4': 42_000, + 'testnet': 2_900_000, + 'signet': 211_000, + '': 863_500, +}; +function isNonStandardVersion(tx: Transaction, height?: number, network?: string): boolean { + let TX_MAX_STANDARD_VERSION = 3; + if ( + height != null + && network != null + && V3_STANDARDNESS_ACTIVATION_HEIGHT[network] + && height <= V3_STANDARDNESS_ACTIVATION_HEIGHT[network] + ) { + // V3 transactions were non-standard to spend before v28.x (scheduled for 2024/09/30 https://github.com/bitcoin/bitcoin/issues/29891) + TX_MAX_STANDARD_VERSION = 2; + } + + if (tx.version > TX_MAX_STANDARD_VERSION) { + return true; + } + return false; +} + +const ANCHOR_STANDARDNESS_ACTIVATION_HEIGHT = { + 'testnet4': 42_000, + 'testnet': 2_900_000, + 'signet': 211_000, + '': 863_500, +}; +function isNonStandardAnchor(tx: Transaction, height?: number, network?: string): boolean { + if ( + height != null + && network != null + && ANCHOR_STANDARDNESS_ACTIVATION_HEIGHT[network] + && height <= ANCHOR_STANDARDNESS_ACTIVATION_HEIGHT[network] + ) { + // anchor outputs were non-standard to spend before v28.x (scheduled for 2024/09/30 https://github.com/bitcoin/bitcoin/issues/29891) + return true; + } + return false; +} + // A witness program is any valid scriptpubkey that consists of a 1-byte push opcode // followed by a data push between 2 and 40 bytes. // https://github.com/bitcoin/bitcoin/blob/2c79abc7ad4850e9e3ba32a04c530155cda7f980/src/script/script.cpp#L224-L240 @@ -289,7 +339,7 @@ export function isBurnKey(pubkey: string): boolean { ].includes(pubkey); } -export function getTransactionFlags(tx: Transaction, cpfpInfo?: CpfpInfo, replacement?: boolean): bigint { +export function getTransactionFlags(tx: Transaction, cpfpInfo?: CpfpInfo, replacement?: boolean, height?: number, network?: string): bigint { let flags = tx.flags ? BigInt(tx.flags) : 0n; // Update variable flags (CPFP, RBF) @@ -439,7 +489,7 @@ export function getTransactionFlags(tx: Transaction, cpfpInfo?: CpfpInfo, replac flags |= TransactionFlags.batch_payout; } - if (isNonStandard(tx)) { + if (isNonStandard(tx, height, network)) { flags |= TransactionFlags.nonstandard; }