From 2de16322ae2df5e975a6bebd566bc391bb6864a5 Mon Sep 17 00:00:00 2001 From: natsoni Date: Wed, 27 Nov 2024 17:54:07 +0100 Subject: [PATCH 01/35] Utils functions for decoding tx client side --- frontend/src/app/shared/transaction.utils.ts | 746 ++++++++++++++++++- 1 file changed, 744 insertions(+), 2 deletions(-) diff --git a/frontend/src/app/shared/transaction.utils.ts b/frontend/src/app/shared/transaction.utils.ts index b3678986b..8d7281a20 100644 --- a/frontend/src/app/shared/transaction.utils.ts +++ b/frontend/src/app/shared/transaction.utils.ts @@ -1,8 +1,9 @@ import { TransactionFlags } from '@app/shared/filters.utils'; -import { getVarIntLength, opcodes, parseMultisigScript, isPoint } from '@app/shared/script.utils'; -import { Transaction } from '@interfaces/electrs.interface'; +import { getVarIntLength, parseMultisigScript, isPoint } from '@app/shared/script.utils'; +import { Transaction, Vin } from '@interfaces/electrs.interface'; import { CpfpInfo, RbfInfo, TransactionStripped } from '@interfaces/node-api.interface'; import { StateService } from '@app/services/state.service'; +import { Hash } from './sha256'; // Bitcoin Core default policy settings const MAX_STANDARD_TX_WEIGHT = 400_000; @@ -588,3 +589,744 @@ export function identifyPrioritizedTransactions(transactions: TransactionStrippe return { prioritized, deprioritized }; } + +function convertScriptSigAsm(hex: string): string { + + const buf = new Uint8Array(hex.length / 2); + for (let i = 0; i < buf.length; i++) { + buf[i] = parseInt(hex.substr(i * 2, 2), 16); + } + + const b = []; + let i = 0; + + while (i < buf.length) { + const op = buf[i]; + if (op >= 0x01 && op <= 0x4e) { + i++; + let push; + if (op === 0x4c) { + push = buf[i]; + b.push('OP_PUSHDATA1'); + i += 1; + } else if (op === 0x4d) { + push = buf[i] | (buf[i + 1] << 8); + b.push('OP_PUSHDATA2'); + i += 2; + } else if (op === 0x4e) { + push = buf[i] | (buf[i + 1] << 8) | (buf[i + 2] << 16) | (buf[i + 3] << 24); + b.push('OP_PUSHDATA4'); + i += 4; + } else { + push = op; + b.push('OP_PUSHBYTES_' + push); + } + + const data = buf.slice(i, i + push); + if (data.length !== push) { + break; + } + + b.push(uint8ArrayToHexString(data)); + i += data.length; + } else { + if (op === 0x00) { + b.push('OP_0'); + } else if (op === 0x4f) { + b.push('OP_PUSHNUM_NEG1'); + } else if (op === 0xb1) { + b.push('OP_CLTV'); + } else if (op === 0xb2) { + b.push('OP_CSV'); + } else if (op === 0xba) { + b.push('OP_CHECKSIGADD'); + } else { + const opcode = opcodes[op]; + if (opcode) { + b.push(opcode); + } else { + b.push('OP_RETURN_' + op); + } + } + i += 1; + } + } + + return b.join(' '); +} + +/** + * This function must only be called when we know the witness we are parsing + * is a taproot witness. + * @param witness An array of hex strings that represents the witness stack of + * the input. + * @returns null if the witness is not a script spend, and the hex string of + * the script item if it is a script spend. + */ +function witnessToP2TRScript(witness: string[]): string | null { + if (witness.length < 2) return null; + // Note: see BIP341 for parsing details of witness stack + + // If there are at least two witness elements, and the first byte of the + // last element is 0x50, this last element is called annex a and + // is removed from the witness stack. + const hasAnnex = witness[witness.length - 1].substring(0, 2) === '50'; + // If there are at least two witness elements left, script path spending is used. + // Call the second-to-last stack element s, the script. + // (Note: this phrasing from BIP341 assumes we've *removed* the annex from the stack) + if (hasAnnex && witness.length < 3) return null; + const positionOfScript = hasAnnex ? witness.length - 3 : witness.length - 2; + return witness[positionOfScript]; +} + +export function addInnerScriptsToVin(vin: Vin): void { + if (!vin.prevout) { + return; + } + + if (vin.prevout.scriptpubkey_type === 'p2sh') { + const redeemScript = vin.scriptsig_asm.split(' ').reverse()[0]; + vin.inner_redeemscript_asm = convertScriptSigAsm(redeemScript); + if (vin.witness && vin.witness.length > 2) { + const witnessScript = vin.witness[vin.witness.length - 1]; + vin.inner_witnessscript_asm = convertScriptSigAsm(witnessScript); + } + } + + if (vin.prevout.scriptpubkey_type === 'v0_p2wsh' && vin.witness) { + const witnessScript = vin.witness[vin.witness.length - 1]; + vin.inner_witnessscript_asm = convertScriptSigAsm(witnessScript); + } + + if (vin.prevout.scriptpubkey_type === 'v1_p2tr' && vin.witness) { + const witnessScript = witnessToP2TRScript(vin.witness); + if (witnessScript !== null) { + vin.inner_witnessscript_asm = convertScriptSigAsm(witnessScript); + } + } +} + +function fromBuffer(buffer: Uint8Array, network: string): Transaction { + let offset = 0; + + function readInt8(): number { + if (offset + 1 > buffer.length) { + throw new Error('Buffer out of bounds'); + } + return buffer[offset++]; + } + + function readInt16() { + if (offset + 2 > buffer.length) { + throw new Error('Buffer out of bounds'); + } + const value = buffer[offset] | (buffer[offset + 1] << 8); + offset += 2; + return value; + } + + function readInt32(unsigned = false): number { + if (offset + 4 > buffer.length) { + throw new Error('Buffer out of bounds'); + } + const value = buffer[offset] | (buffer[offset + 1] << 8) | (buffer[offset + 2] << 16) | (buffer[offset + 3] << 24); + offset += 4; + if (unsigned) { + return value >>> 0; + } + return value; + } + + function readInt64(): bigint { + if (offset + 8 > buffer.length) { + throw new Error('Buffer out of bounds'); + } + const low = BigInt(buffer[offset] | (buffer[offset + 1] << 8) | (buffer[offset + 2] << 16) | (buffer[offset + 3] << 24)); + const high = BigInt(buffer[offset + 4] | (buffer[offset + 5] << 8) | (buffer[offset + 6] << 16) | (buffer[offset + 7] << 24)); + offset += 8; + return (high << 32n) | (low & 0xffffffffn); + } + + function readVarInt(): bigint { + const first = readInt8(); + if (first < 0xfd) { + return BigInt(first); + } else if (first === 0xfd) { + return BigInt(readInt16()); + } else if (first === 0xfe) { + return BigInt(readInt32(true)); + } else if (first === 0xff) { + return readInt64(); + } else { + throw new Error("Invalid VarInt prefix"); + } + } + + function readSlice(n: number | bigint): Uint8Array { + const length = Number(n); + if (offset + length > buffer.length) { + throw new Error('Cannot read slice out of bounds'); + } + const slice = buffer.slice(offset, offset + length); + offset += length; + return slice; + } + + function readVarSlice(): Uint8Array { + return readSlice(readVarInt()); + } + + function readVector(): Uint8Array[] { + const count = readVarInt(); + const vector = []; + for (let i = 0; i < count; i++) { + vector.push(readVarSlice()); + } + return vector; + } + + // Parse raw transaction + const tx = { + status: { + confirmed: null, + block_height: null, + block_hash: null, + block_time: null, + } + } as Transaction; + + tx.version = readInt32(); + + const marker = readInt8(); + const flag = readInt8(); + + let hasWitnesses = false; + if ( + marker === 0x00 && + flag === 0x01 + ) { + hasWitnesses = true; + } else { + offset -= 2; + } + + const vinLen = readVarInt(); + tx.vin = []; + for (let i = 0; i < vinLen; ++i) { + const txid = uint8ArrayToHexString(readSlice(32).reverse()); + const vout = readInt32(true); + const scriptsig = uint8ArrayToHexString(readVarSlice()); + const sequence = readInt32(true); + const is_coinbase = txid === '0'.repeat(64); + const scriptsig_asm = convertScriptSigAsm(scriptsig); + tx.vin.push({ txid, vout, scriptsig, sequence, is_coinbase, scriptsig_asm, prevout: null }); + } + + const voutLen = readVarInt(); + tx.vout = []; + for (let i = 0; i < voutLen; ++i) { + const value = Number(readInt64()); + const scriptpubkeyArray = readVarSlice(); + const scriptpubkey = uint8ArrayToHexString(scriptpubkeyArray) + const scriptpubkey_asm = convertScriptSigAsm(scriptpubkey); + const toAddress = scriptPubKeyToAddress(scriptpubkey, network); + const scriptpubkey_type = toAddress.type; + const scriptpubkey_address = toAddress?.address; + tx.vout.push({ value, scriptpubkey, scriptpubkey_asm, scriptpubkey_type, scriptpubkey_address }); + } + + let witnessSize = 0; + if (hasWitnesses) { + const startOffset = offset; + for (let i = 0; i < vinLen; ++i) { + tx.vin[i].witness = readVector().map(uint8ArrayToHexString); + } + witnessSize = offset - startOffset + 2; + } + + tx.locktime = readInt32(true); + + if (offset !== buffer.length) { + throw new Error('Transaction has unexpected data'); + } + + tx.size = buffer.length; + tx.weight = (tx.size - witnessSize) * 3 + tx.size; + + tx.txid = txid(tx); + + return tx; +} + +export function decodeRawTransaction(rawtx: string, network: string): Transaction { + if (!rawtx.length || rawtx.length % 2 !== 0 || !/^[0-9a-fA-F]*$/.test(rawtx)) { + throw new Error('Invalid hex string'); + } + + const buffer = new Uint8Array(rawtx.length / 2); + for (let i = 0; i < rawtx.length; i += 2) { + buffer[i / 2] = parseInt(rawtx.substring(i, i + 2), 16); + } + + return fromBuffer(buffer, network); +} + +function serializeTransaction(tx: Transaction): Uint8Array { + const result: number[] = []; + + // Add version + result.push(...intToBytes(tx.version, 4)); + + // Add input count and inputs + result.push(...varIntToBytes(tx.vin.length)); + for (const input of tx.vin) { + result.push(...hexStringToUint8Array(input.txid).reverse()); + result.push(...intToBytes(input.vout, 4)); + const scriptSig = hexStringToUint8Array(input.scriptsig); + result.push(...varIntToBytes(scriptSig.length)); + result.push(...scriptSig); + result.push(...intToBytes(input.sequence, 4)); + } + + // Add output count and outputs + result.push(...varIntToBytes(tx.vout.length)); + for (const output of tx.vout) { + result.push(...bigIntToBytes(BigInt(output.value), 8)); + const scriptPubKey = hexStringToUint8Array(output.scriptpubkey); + result.push(...varIntToBytes(scriptPubKey.length)); + result.push(...scriptPubKey); + } + + // Add locktime + result.push(...intToBytes(tx.locktime, 4)); + + return new Uint8Array(result); +} + +function txid(tx: Transaction): string { + const serializedTx = serializeTransaction(tx); + const hash1 = new Hash().update(serializedTx).digest(); + const hash2 = new Hash().update(hash1).digest(); + return uint8ArrayToHexString(hash2.reverse()); +} + +export function countSigops(transaction: Transaction): number { + let sigops = 0; + + for (const input of transaction.vin) { + if (input.scriptsig_asm) { + sigops += countScriptSigops(input.scriptsig_asm, true); + } + if (input.prevout) { + switch (true) { + case input.prevout.scriptpubkey_type === 'p2sh' && input.witness?.length === 2 && input.scriptsig && input.scriptsig.startsWith('160014'): + case input.prevout.scriptpubkey_type === 'v0_p2wpkh': + sigops += 1; + break; + + case input.prevout?.scriptpubkey_type === 'p2sh' && input.witness?.length && input.scriptsig && input.scriptsig.startsWith('220020'): + case input.prevout.scriptpubkey_type === 'v0_p2wsh': + if (input.witness?.length) { + sigops += countScriptSigops(convertScriptSigAsm(input.witness[input.witness.length - 1]), false, true); + } + break; + + case input.prevout.scriptpubkey_type === 'p2sh': + if (input.inner_redeemscript_asm) { + sigops += countScriptSigops(input.inner_redeemscript_asm); + } + break; + } + } + } + + for (const output of transaction.vout) { + if (output.scriptpubkey_asm) { + sigops += countScriptSigops(output.scriptpubkey_asm, true); + } + } + + return sigops; +} + +function scriptPubKeyToAddress(scriptPubKey: string, network: string): { address: string, type: string } { + // P2PKH + if (/^76a914[0-9a-f]{40}88ac$/.test(scriptPubKey)) { + return { address: p2pkh(scriptPubKey.substring(6, 6 + 40), network), type: 'p2pkh' }; + } + // P2PK + if (/^21[0-9a-f]{66}ac$/.test(scriptPubKey) || /^41[0-9a-f]{130}ac$/.test(scriptPubKey)) { + return { address: null, type: 'p2pk' }; + } + // P2SH + if (/^a914[0-9a-f]{40}87$/.test(scriptPubKey)) { + return { address: p2sh(scriptPubKey.substring(4, 4 + 40), network), type: 'p2sh' }; + } + // P2WPKH + if (/^0014[0-9a-f]{40}$/.test(scriptPubKey)) { + return { address: p2wpkh(scriptPubKey.substring(4, 4 + 40), network), type: 'v0_p2wpkh' }; + } + // P2WSH + if (/^0020[0-9a-f]{64}$/.test(scriptPubKey)) { + return { address: p2wsh(scriptPubKey.substring(4, 4 + 64), network), type: 'v0_p2wsh' }; + } + // P2TR + if (/^5120[0-9a-f]{64}$/.test(scriptPubKey)) { + return { address: p2tr(scriptPubKey.substring(4, 4 + 64), network), type: 'v1_p2tr' }; + } + // multisig + if (/^[0-9a-f]+ae$/.test(scriptPubKey)) { + return { address: null, type: 'multisig' }; + } + // anchor + if (scriptPubKey === '51024e73') { + return { address: 'bc1pfeessrawgf', type: 'anchor' }; + } + // op_return + if (/^6a/.test(scriptPubKey)) { + return { address: null, type: 'op_return' }; + } + return { address: null, type: 'unknown' }; +} + +function p2pkh(pubKeyHash: string, network: string): string { + const pubkeyHashArray = hexStringToUint8Array(pubKeyHash); + const version = ['testnet', 'testnet4', 'signet'].includes(network) ? 0x6f : 0x00; + const versionedPayload = Uint8Array.from([version, ...pubkeyHashArray]); + const hash1 = new Hash().update(versionedPayload).digest(); + const hash2 = new Hash().update(hash1).digest(); + const checksum = hash2.slice(0, 4); + const finalPayload = Uint8Array.from([...versionedPayload, ...checksum]); + const bitcoinAddress = base58Encode(finalPayload); + return bitcoinAddress; +} + +function p2sh(scriptHash: string, network: string): string { + const scriptHashArray = hexStringToUint8Array(scriptHash); + const version = ['testnet', 'testnet4', 'signet'].includes(network) ? 0xc4 : 0x05; + const versionedPayload = Uint8Array.from([version, ...scriptHashArray]); + const hash1 = new Hash().update(versionedPayload).digest(); + const hash2 = new Hash().update(hash1).digest(); + const checksum = hash2.slice(0, 4); + const finalPayload = Uint8Array.from([...versionedPayload, ...checksum]); + const bitcoinAddress = base58Encode(finalPayload); + return bitcoinAddress; +} + +function p2wpkh(pubKeyHash: string, network: string): string { + const pubkeyHashArray = hexStringToUint8Array(pubKeyHash); + const hrp = ['testnet', 'testnet4', 'signet'].includes(network) ? 'tb' : 'bc'; + const version = 0; + const words = [version].concat(toWords(pubkeyHashArray)); + const bech32Address = bech32Encode(hrp, words); + return bech32Address; +} + +function p2wsh(scriptHash: string, network: string): string { + const scriptHashArray = hexStringToUint8Array(scriptHash); + const hrp = ['testnet', 'testnet4', 'signet'].includes(network) ? 'tb' : 'bc'; + const version = 0; + const words = [version].concat(toWords(scriptHashArray)); + const bech32Address = bech32Encode(hrp, words); + return bech32Address; +} + +function p2tr(pubKeyHash: string, network: string): string { + const pubkeyHashArray = hexStringToUint8Array(pubKeyHash); + const hrp = ['testnet', 'testnet4', 'signet'].includes(network) ? 'tb' : 'bc'; + const version = 1; + const words = [version].concat(toWords(pubkeyHashArray)); + const bech32Address = bech32Encode(hrp, words, 0x2bc830a3); + return bech32Address; +} + +// base58 encoding +function base58Encode(data: Uint8Array): string { + const BASE58_ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"; + + let hexString = Array.from(data) + .map(byte => byte.toString(16).padStart(2, '0')) + .join(''); + + let num = BigInt("0x" + hexString); + + let encoded = ""; + while (num > 0) { + const remainder = Number(num % 58n); + num = num / 58n; + encoded = BASE58_ALPHABET[remainder] + encoded; + } + + for (let byte of data) { + if (byte === 0) { + encoded = "1" + encoded; + } else { + break; + } + } + + return encoded; +} + +// bech32 encoding +// Adapted from https://github.com/bitcoinjs/bech32/blob/5ceb0e3d4625561a459c85643ca6947739b2d83c/src/index.ts +function bech32Encode(prefix: string, words: number[], constant: number = 1) { + const BECH32_ALPHABET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"; + + const checksum = createChecksum(prefix, words, constant); + const combined = words.concat(checksum); + let result = prefix + '1'; + for (let i = 0; i < combined.length; ++i) { + result += BECH32_ALPHABET.charAt(combined[i]); + } + return result; +} + +function polymodStep(pre) { + const GENERATORS = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3]; + const b = pre >> 25; + return ( + ((pre & 0x1ffffff) << 5) ^ + ((b & 1 ? GENERATORS[0] : 0) ^ + (b & 2 ? GENERATORS[1] : 0) ^ + (b & 4 ? GENERATORS[2] : 0) ^ + (b & 8 ? GENERATORS[3] : 0) ^ + (b & 16 ? GENERATORS[4] : 0)) + ); +} + +function prefixChk(prefix) { + let chk = 1; + for (let i = 0; i < prefix.length; ++i) { + const c = prefix.charCodeAt(i); + chk = polymodStep(chk) ^ (c >> 5); + } + chk = polymodStep(chk); + for (let i = 0; i < prefix.length; ++i) { + const c = prefix.charCodeAt(i); + chk = polymodStep(chk) ^ (c & 0x1f); + } + return chk; +} + +function createChecksum(prefix: string, words: number[], constant: number) { + const POLYMOD_CONST = constant; + let chk = prefixChk(prefix); + for (let i = 0; i < words.length; ++i) { + const x = words[i]; + chk = polymodStep(chk) ^ x; + } + for (let i = 0; i < 6; ++i) { + chk = polymodStep(chk); + } + chk ^= POLYMOD_CONST; + + const checksum = []; + for (let i = 0; i < 6; ++i) { + checksum.push((chk >> (5 * (5 - i))) & 31); + } + return checksum; +} + +function convertBits(data, fromBits, toBits, pad) { + let acc = 0; + let bits = 0; + const ret = []; + const maxV = (1 << toBits) - 1; + + for (let i = 0; i < data.length; ++i) { + const value = data[i]; + if (value < 0 || value >> fromBits) throw new Error('Invalid value'); + acc = (acc << fromBits) | value; + bits += fromBits; + while (bits >= toBits) { + bits -= toBits; + ret.push((acc >> bits) & maxV); + } + } + if (pad) { + if (bits > 0) { + ret.push((acc << (toBits - bits)) & maxV); + } + } else if (bits >= fromBits || ((acc << (toBits - bits)) & maxV)) { + throw new Error('Invalid data'); + } + return ret; +} + +function toWords(bytes) { + return convertBits(bytes, 8, 5, true); +} + +// Helper functions +function uint8ArrayToHexString(uint8Array: Uint8Array): string { + return Array.from(uint8Array).map(byte => byte.toString(16).padStart(2, '0')).join(''); +} + +function hexStringToUint8Array(hex: string): Uint8Array { + const buf = new Uint8Array(hex.length / 2); + for (let i = 0; i < buf.length; i++) { + buf[i] = parseInt(hex.substr(i * 2, 2), 16); + } + return buf; +} + +function intToBytes(value: number, byteLength: number): number[] { + const bytes = []; + for (let i = 0; i < byteLength; i++) { + bytes.push((value >> (8 * i)) & 0xff); + } + return bytes; +} + +function bigIntToBytes(value: bigint, byteLength: number): number[] { + const bytes = []; + for (let i = 0; i < byteLength; i++) { + bytes.push(Number((value >> BigInt(8 * i)) & 0xffn)); + } + return bytes; +} + +function varIntToBytes(value: number | bigint): number[] { + const bytes = []; + + if (typeof value === 'number') { + if (value < 0xfd) { + bytes.push(value); + } else if (value <= 0xffff) { + bytes.push(0xfd, value & 0xff, (value >> 8) & 0xff); + } else if (value <= 0xffffffff) { + bytes.push(0xfe, ...intToBytes(value, 4)); + } + } else { + if (value < 0xfdn) { + bytes.push(Number(value)); + } else if (value <= 0xffffn) { + bytes.push(0xfd, Number(value & 0xffn), Number((value >> 8n) & 0xffn)); + } else if (value <= 0xffffffffn) { + bytes.push(0xfe, ...intToBytes(Number(value), 4)); + } else { + bytes.push(0xff, ...bigIntToBytes(value, 8)); + } + } + + return bytes; +} + +const opcodes = { + 0: 'OP_0', + 76: 'OP_PUSHDATA1', + 77: 'OP_PUSHDATA2', + 78: 'OP_PUSHDATA4', + 79: 'OP_PUSHNUM_NEG1', + 80: 'OP_RESERVED', + 81: 'OP_PUSHNUM_1', + 82: 'OP_PUSHNUM_2', + 83: 'OP_PUSHNUM_3', + 84: 'OP_PUSHNUM_4', + 85: 'OP_PUSHNUM_5', + 86: 'OP_PUSHNUM_6', + 87: 'OP_PUSHNUM_7', + 88: 'OP_PUSHNUM_8', + 89: 'OP_PUSHNUM_9', + 90: 'OP_PUSHNUM_10', + 91: 'OP_PUSHNUM_11', + 92: 'OP_PUSHNUM_12', + 93: 'OP_PUSHNUM_13', + 94: 'OP_PUSHNUM_14', + 95: 'OP_PUSHNUM_15', + 96: 'OP_PUSHNUM_16', + 97: 'OP_NOP', + 98: 'OP_VER', + 99: 'OP_IF', + 100: 'OP_NOTIF', + 101: 'OP_VERIF', + 102: 'OP_VERNOTIF', + 103: 'OP_ELSE', + 104: 'OP_ENDIF', + 105: 'OP_VERIFY', + 106: 'OP_RETURN', + 107: 'OP_TOALTSTACK', + 108: 'OP_FROMALTSTACK', + 109: 'OP_2DROP', + 110: 'OP_2DUP', + 111: 'OP_3DUP', + 112: 'OP_2OVER', + 113: 'OP_2ROT', + 114: 'OP_2SWAP', + 115: 'OP_IFDUP', + 116: 'OP_DEPTH', + 117: 'OP_DROP', + 118: 'OP_DUP', + 119: 'OP_NIP', + 120: 'OP_OVER', + 121: 'OP_PICK', + 122: 'OP_ROLL', + 123: 'OP_ROT', + 124: 'OP_SWAP', + 125: 'OP_TUCK', + 126: 'OP_CAT', + 127: 'OP_SUBSTR', + 128: 'OP_LEFT', + 129: 'OP_RIGHT', + 130: 'OP_SIZE', + 131: 'OP_INVERT', + 132: 'OP_AND', + 133: 'OP_OR', + 134: 'OP_XOR', + 135: 'OP_EQUAL', + 136: 'OP_EQUALVERIFY', + 137: 'OP_RESERVED1', + 138: 'OP_RESERVED2', + 139: 'OP_1ADD', + 140: 'OP_1SUB', + 141: 'OP_2MUL', + 142: 'OP_2DIV', + 143: 'OP_NEGATE', + 144: 'OP_ABS', + 145: 'OP_NOT', + 146: 'OP_0NOTEQUAL', + 147: 'OP_ADD', + 148: 'OP_SUB', + 149: 'OP_MUL', + 150: 'OP_DIV', + 151: 'OP_MOD', + 152: 'OP_LSHIFT', + 153: 'OP_RSHIFT', + 154: 'OP_BOOLAND', + 155: 'OP_BOOLOR', + 156: 'OP_NUMEQUAL', + 157: 'OP_NUMEQUALVERIFY', + 158: 'OP_NUMNOTEQUAL', + 159: 'OP_LESSTHAN', + 160: 'OP_GREATERTHAN', + 161: 'OP_LESSTHANOREQUAL', + 162: 'OP_GREATERTHANOREQUAL', + 163: 'OP_MIN', + 164: 'OP_MAX', + 165: 'OP_WITHIN', + 166: 'OP_RIPEMD160', + 167: 'OP_SHA1', + 168: 'OP_SHA256', + 169: 'OP_HASH160', + 170: 'OP_HASH256', + 171: 'OP_CODESEPARATOR', + 172: 'OP_CHECKSIG', + 173: 'OP_CHECKSIGVERIFY', + 174: 'OP_CHECKMULTISIG', + 175: 'OP_CHECKMULTISIGVERIFY', + 176: 'OP_NOP1', + 177: 'OP_CHECKLOCKTIMEVERIFY', + 178: 'OP_CHECKSEQUENCEVERIFY', + 179: 'OP_NOP4', + 180: 'OP_NOP5', + 181: 'OP_NOP6', + 182: 'OP_NOP7', + 183: 'OP_NOP8', + 184: 'OP_NOP9', + 185: 'OP_NOP10', + 186: 'OP_CHECKSIGADD', + 253: 'OP_PUBKEYHASH', + 254: 'OP_PUBKEY', + 255: 'OP_INVALIDOPCODE', +}; From 025b0585b483168f2ccfffd33e204f7ecfad8c71 Mon Sep 17 00:00:00 2001 From: natsoni Date: Wed, 27 Nov 2024 18:11:56 +0100 Subject: [PATCH 02/35] Preview transaction from raw data --- .../transaction-raw.component.html | 190 +++++++++++ .../transaction-raw.component.scss | 194 ++++++++++++ .../transaction/transaction-raw.component.ts | 296 ++++++++++++++++++ .../transaction/transaction.module.ts | 6 + .../transactions-list.component.ts | 12 +- frontend/src/app/route-guards.ts | 2 +- .../global-footer.component.html | 1 + .../truncate/truncate.component.html | 2 +- .../truncate/truncate.component.scss | 6 + .../components/truncate/truncate.component.ts | 1 + 10 files changed, 704 insertions(+), 6 deletions(-) create mode 100644 frontend/src/app/components/transaction/transaction-raw.component.html create mode 100644 frontend/src/app/components/transaction/transaction-raw.component.scss create mode 100644 frontend/src/app/components/transaction/transaction-raw.component.ts diff --git a/frontend/src/app/components/transaction/transaction-raw.component.html b/frontend/src/app/components/transaction/transaction-raw.component.html new file mode 100644 index 000000000..15293e2dd --- /dev/null +++ b/frontend/src/app/components/transaction/transaction-raw.component.html @@ -0,0 +1,190 @@ +
+ + @if (!transaction) { + +

Preview Transaction

+ +
+
+ +
+ + + +

Error decoding transaction, reason: {{ error }}

+
+ } + + @if (transaction && !error && !isLoading) { +
+

Preview Transaction

+ + + + + + + + + +
+ + + +
+ +
+ +

{{ errorBroadcast }}

+ +
+ + @if (!hasPrevouts) { +
+ This transaction is missing prevouts data. {{ errorPrevouts ? 'Reason: ' + errorPrevouts : '' }} +
+ } + + + +
+ + +
+

Flow

+
+ + + +
+ +
+
+ + +
+
+ + + + +
+
+ +
+
+ + + + +
+
+

Inputs & Outputs

+
+ +
+ + +
+
+ + + + +
+

Details

+
+
+
+
+ + + + + + + + + + + + + + + + + + + +
Size
Virtual size
Adjusted vsize + + + +
Weight
+
+
+ + + + + + + + + + + + + + + + + + + +
Version
Locktime
Sigops + + + +
Transaction hex
+
+
+
+ } + + @if (isLoading) { +
+
+

Loading transaction prevouts ({{ prevoutsLoadedCount }} / {{ prevoutsCount }})

+
+ } +
\ No newline at end of file diff --git a/frontend/src/app/components/transaction/transaction-raw.component.scss b/frontend/src/app/components/transaction/transaction-raw.component.scss new file mode 100644 index 000000000..5bbe5601e --- /dev/null +++ b/frontend/src/app/components/transaction/transaction-raw.component.scss @@ -0,0 +1,194 @@ +.label { + margin: 0 5px; +} + +.container-buttons { + align-self: center; +} + +.title-block { + flex-wrap: wrap; + align-items: baseline; + @media (min-width: 650px) { + flex-direction: row; + } + h1 { + margin: 0rem; + margin-right: 15px; + line-height: 1; + } +} + +.tx-link { + display: flex; + flex-direction: row; + justify-content: flex-start; + align-items: baseline; + width: 0; + max-width: 100%; + margin-right: 0px; + margin-bottom: 0px; + margin-top: 8px; + @media (min-width: 651px) { + flex-grow: 1; + margin-bottom: 0px; + margin-right: 1em; + top: 1px; + position: relative; + } + @media (max-width: 650px) { + width: 100%; + order: 3; + } + + .txid { + width: 200px; + min-width: 200px; + flex-grow: 1; + } +} + +.container-xl { + margin-bottom: 40px; +} + +.row { + flex-direction: column; + @media (min-width: 850px) { + flex-direction: row; + } +} + +.box.hidden { + visibility: hidden; + height: 0px; + padding-top: 0px; + padding-bottom: 0px; + margin-top: 0px; + margin-bottom: 0px; +} + +.graph-container { + position: relative; + width: 100%; + background: var(--stat-box-bg); + padding: 10px 0; + padding-bottom: 0; +} + +.toggle-wrapper { + width: 100%; + text-align: center; + margin: 1.25em 0 0; +} + +.graph-toggle { + margin: auto; +} + +.table { + tr td { + padding: 0.75rem 0.5rem; + @media (min-width: 576px) { + padding: 0.75rem 0.75rem; + } + &:last-child { + text-align: right; + @media (min-width: 850px) { + text-align: left; + } + } + .btn { + display: block; + } + + &.wrap-cell { + white-space: normal; + } + } +} + +.effective-fee-container { + display: block; + @media (min-width: 768px){ + display: inline-block; + } + @media (max-width: 425px){ + display: flex; + flex-direction: column; + } +} + +.effective-fee-rating { + @media (max-width: 767px){ + margin-right: 0px !important; + } +} + +.title { + h2 { + line-height: 1; + margin: 0; + padding-bottom: 5px; + } +} + +.btn-outline-info { + margin-top: 5px; + @media (min-width: 768px){ + margin-top: 0px; + } +} + +.flow-toggle { + margin-top: -5px; + margin-left: 10px; + @media (min-width: 768px){ + display: inline-block; + margin-top: 0px; + margin-bottom: 0px; + } +} + +.subtitle-block { + display: flex; + flex-direction: row; + align-items: baseline; + justify-content: space-between; + + .title { + flex-shrink: 0; + } + + .title-buttons { + flex-shrink: 1; + text-align: right; + .btn { + margin-top: 0; + margin-bottom: 8px; + margin-left: 8px; + } + } +} + +.cpfp-details { + .txids { + width: 60%; + } + + @media (max-width: 500px) { + .txids { + width: 40%; + } + } +} + +.disabled { + opacity: 0.5; + pointer-events: none; +} + +.no-cursor { + cursor: default !important; + pointer-events: none; +} \ No newline at end of file diff --git a/frontend/src/app/components/transaction/transaction-raw.component.ts b/frontend/src/app/components/transaction/transaction-raw.component.ts new file mode 100644 index 000000000..cac7b595f --- /dev/null +++ b/frontend/src/app/components/transaction/transaction-raw.component.ts @@ -0,0 +1,296 @@ +import { Component, OnInit, HostListener, ViewChild, ElementRef, OnDestroy, ChangeDetectorRef } from '@angular/core'; +import { Transaction } from '@interfaces/electrs.interface'; +import { StateService } from '../../services/state.service'; +import { Filter, toFilters } from '../../shared/filters.utils'; +import { decodeRawTransaction, getTransactionFlags, addInnerScriptsToVin, countSigops } from '../../shared/transaction.utils'; +import { ETA, EtaService } from '../../services/eta.service'; +import { combineLatest, firstValueFrom, map, Observable, startWith, Subscription } from 'rxjs'; +import { WebsocketService } from '../../services/websocket.service'; +import { ActivatedRoute, Router } from '@angular/router'; +import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; +import { ElectrsApiService } from '../../services/electrs-api.service'; +import { SeoService } from '../../services/seo.service'; +import { seoDescriptionNetwork } from '@app/shared/common.utils'; +import { ApiService } from '../../services/api.service'; +import { RelativeUrlPipe } from '@app/shared/pipes/relative-url/relative-url.pipe'; + +@Component({ + selector: 'app-transaction-raw', + templateUrl: './transaction-raw.component.html', + styleUrls: ['./transaction-raw.component.scss'], +}) +export class TransactionRawComponent implements OnInit, OnDestroy { + + pushTxForm: UntypedFormGroup; + isLoading: boolean; + offlineMode: boolean = false; + transaction: Transaction; + error: string; + errorPrevouts: string; + hasPrevouts: boolean; + prevoutsLoadedCount: number = 0; + prevoutsCount: number; + isLoadingBroadcast: boolean; + errorBroadcast: string; + successBroadcast: boolean; + + isMobile: boolean; + @ViewChild('graphContainer') + graphContainer: ElementRef; + graphExpanded: boolean = false; + graphWidth: number = 1068; + graphHeight: number = 360; + inOutLimit: number = 150; + maxInOut: number = 0; + flowPrefSubscription: Subscription; + hideFlow: boolean = this.stateService.hideFlow.value; + flowEnabled: boolean; + adjustedVsize: number; + filters: Filter[] = []; + showCpfpDetails = false; + ETA$: Observable; + mempoolBlocksSubscription: Subscription; + + constructor( + public route: ActivatedRoute, + public router: Router, + public stateService: StateService, + public etaService: EtaService, + public electrsApi: ElectrsApiService, + public websocketService: WebsocketService, + public formBuilder: UntypedFormBuilder, + public cd: ChangeDetectorRef, + public seoService: SeoService, + public apiService: ApiService, + public relativeUrlPipe: RelativeUrlPipe, + ) {} + + ngOnInit(): void { + this.seoService.setTitle($localize`:@@meta.title.preview-tx:Preview Transaction`); + this.seoService.setDescription($localize`:@@meta.description.preview-tx:Preview a transaction to the Bitcoin${seoDescriptionNetwork(this.stateService.network)} network using the transaction's raw hex data.`); + this.websocketService.want(['blocks', 'mempool-blocks']); + this.pushTxForm = this.formBuilder.group({ + txRaw: ['', Validators.required], + }); + } + + async decodeTransaction(): Promise { + this.resetState(); + this.isLoading = true; + try { + const tx = decodeRawTransaction(this.pushTxForm.get('txRaw').value, this.stateService.network); + await this.fetchPrevouts(tx); + this.processTransaction(tx); + } catch (error) { + this.error = error.message; + } finally { + this.isLoading = false; + } + } + + async fetchPrevouts(transaction: Transaction): Promise { + if (this.offlineMode) { + return; + } + + this.prevoutsCount = transaction.vin.filter(input => !input.is_coinbase).length; + if (this.prevoutsCount === 0) { + this.hasPrevouts = true; + return; + } + + const txsToFetch: { [txid: string]: number } = transaction.vin.reduce((acc, input) => { + if (!input.is_coinbase) { + acc[input.txid] = (acc[input.txid] || 0) + 1; + } + return acc; + }, {} as { [txid: string]: number }); + + try { + + if (Object.keys(txsToFetch).length > 20) { + throw new Error($localize`:@@transaction.too-many-prevouts:Too many transactions to fetch (${Object.keys(txsToFetch).length})`); + } + + const fetchedTransactions = await Promise.all( + Object.keys(txsToFetch).map(txid => + firstValueFrom(this.electrsApi.getTransaction$(txid)) + .then(response => { + this.prevoutsLoadedCount += txsToFetch[txid]; + this.cd.markForCheck(); + return response; + }) + ) + ); + + const transactionsMap = fetchedTransactions.reduce((acc, transaction) => { + acc[transaction.txid] = transaction; + return acc; + }, {} as { [txid: string]: any }); + + const prevouts = transaction.vin.map((input, index) => ({ index, prevout: transactionsMap[input.txid]?.vout[input.vout] || null})); + + transaction.vin = transaction.vin.map((input, index) => { + if (!input.is_coinbase) { + input.prevout = prevouts.find(p => p.index === index)?.prevout; + addInnerScriptsToVin(input); + } + return input; + }); + this.hasPrevouts = true; + } catch (error) { + this.errorPrevouts = error.message; + } + } + + processTransaction(tx: Transaction): void { + this.transaction = tx; + + if (this.hasPrevouts) { + this.transaction.fee = this.transaction.vin.some(input => input.is_coinbase) + ? 0 + : this.transaction.vin.reduce((fee, input) => { + return fee + (input.prevout?.value || 0); + }, 0) - this.transaction.vout.reduce((sum, output) => sum + output.value, 0); + this.transaction.feePerVsize = this.transaction.fee / (this.transaction.weight / 4); + } + + this.transaction.flags = getTransactionFlags(this.transaction, null, null, null, this.stateService.network); + this.filters = this.transaction.flags ? toFilters(this.transaction.flags).filter(f => f.txPage) : []; + this.transaction.sigops = countSigops(this.transaction); + if (this.transaction.sigops >= 0) { + this.adjustedVsize = Math.max(this.transaction.weight / 4, this.transaction.sigops * 5); + } + + this.setupGraph(); + this.setFlowEnabled(); + this.flowPrefSubscription = this.stateService.hideFlow.subscribe((hide) => { + this.hideFlow = !!hide; + this.setFlowEnabled(); + }); + this.setGraphSize(); + + this.ETA$ = combineLatest([ + this.stateService.mempoolTxPosition$.pipe(startWith(null)), + this.stateService.mempoolBlocks$.pipe(startWith(null)), + this.stateService.difficultyAdjustment$.pipe(startWith(null)), + ]).pipe( + map(([position, mempoolBlocks, da]) => { + return this.etaService.calculateETA( + this.stateService.network, + this.transaction, + mempoolBlocks, + position, + da, + null, + null, + null + ); + }) + ); + + this.mempoolBlocksSubscription = this.stateService.mempoolBlocks$.subscribe(() => { + if (this.transaction) { + this.stateService.markBlock$.next({ + txid: this.transaction.txid, + txFeePerVSize: this.transaction.feePerVsize, + }); + } + }); + } + + async postTx(): Promise { + this.isLoadingBroadcast = true; + this.errorBroadcast = null; + return new Promise((resolve, reject) => { + this.apiService.postTransaction$(this.pushTxForm.get('txRaw').value) + .subscribe((result) => { + this.isLoadingBroadcast = false; + this.successBroadcast = true; + resolve(result); + }, + (error) => { + if (typeof error.error === 'string') { + const matchText = error.error.replace(/\\/g, '').match('"message":"(.*?)"'); + this.errorBroadcast = 'Failed to broadcast transaction, reason: ' + (matchText && matchText[1] || error.error); + } else if (error.message) { + this.errorBroadcast = 'Failed to broadcast transaction, reason: ' + error.message; + } + this.isLoadingBroadcast = false; + reject(this.error); + }); + }); + } + + resetState() { + this.transaction = null; + this.error = null; + this.errorPrevouts = null; + this.errorBroadcast = null; + this.successBroadcast = false; + this.isLoading = false; + this.adjustedVsize = null; + this.filters = []; + this.hasPrevouts = false; + this.prevoutsLoadedCount = 0; + this.prevoutsCount = 0; + this.stateService.markBlock$.next({}); + this.mempoolBlocksSubscription?.unsubscribe(); + } + + resetForm() { + this.resetState(); + this.pushTxForm.reset(); + } + + @HostListener('window:resize', ['$event']) + setGraphSize(): void { + this.isMobile = window.innerWidth < 850; + if (this.graphContainer?.nativeElement && this.stateService.isBrowser) { + setTimeout(() => { + if (this.graphContainer?.nativeElement?.clientWidth) { + this.graphWidth = this.graphContainer.nativeElement.clientWidth; + } else { + setTimeout(() => { this.setGraphSize(); }, 1); + } + }, 1); + } else { + setTimeout(() => { this.setGraphSize(); }, 1); + } + } + + setupGraph() { + this.maxInOut = Math.min(this.inOutLimit, Math.max(this.transaction?.vin?.length || 1, this.transaction?.vout?.length + 1 || 1)); + this.graphHeight = this.graphExpanded ? this.maxInOut * 15 : Math.min(360, this.maxInOut * 80); + } + + toggleGraph() { + const showFlow = !this.flowEnabled; + this.stateService.hideFlow.next(!showFlow); + } + + setFlowEnabled() { + this.flowEnabled = !this.hideFlow; + } + + expandGraph() { + this.graphExpanded = true; + this.graphHeight = this.maxInOut * 15; + } + + collapseGraph() { + this.graphExpanded = false; + this.graphHeight = Math.min(360, this.maxInOut * 80); + } + + onOfflineModeChange(e): void { + this.offlineMode = !e.target.checked; + } + + ngOnDestroy(): void { + this.mempoolBlocksSubscription?.unsubscribe(); + this.flowPrefSubscription?.unsubscribe(); + this.stateService.markBlock$.next({}); + } + +} diff --git a/frontend/src/app/components/transaction/transaction.module.ts b/frontend/src/app/components/transaction/transaction.module.ts index 80de0cf40..58b6493d8 100644 --- a/frontend/src/app/components/transaction/transaction.module.ts +++ b/frontend/src/app/components/transaction/transaction.module.ts @@ -9,6 +9,7 @@ import { TransactionExtrasModule } from '@components/transaction/transaction-ext import { GraphsModule } from '@app/graphs/graphs.module'; import { AccelerateCheckout } from '@components/accelerate-checkout/accelerate-checkout.component'; import { AccelerateFeeGraphComponent } from '@components/accelerate-checkout/accelerate-fee-graph.component'; +import { TransactionRawComponent } from '@components/transaction/transaction-raw.component'; const routes: Routes = [ { @@ -16,6 +17,10 @@ const routes: Routes = [ redirectTo: '/', pathMatch: 'full', }, + { + path: 'preview', + component: TransactionRawComponent, + }, { path: ':id', component: TransactionComponent, @@ -49,6 +54,7 @@ export class TransactionRoutingModule { } TransactionDetailsComponent, AccelerateCheckout, AccelerateFeeGraphComponent, + TransactionRawComponent, ], exports: [ TransactionComponent, diff --git a/frontend/src/app/components/transactions-list/transactions-list.component.ts b/frontend/src/app/components/transactions-list/transactions-list.component.ts index b07546e5e..d1764442e 100644 --- a/frontend/src/app/components/transactions-list/transactions-list.component.ts +++ b/frontend/src/app/components/transactions-list/transactions-list.component.ts @@ -37,6 +37,7 @@ export class TransactionsListComponent implements OnInit, OnChanges { @Input() addresses: string[] = []; @Input() rowLimit = 12; @Input() blockTime: number = 0; // Used for price calculation if all the transactions are in the same block + @Input() txPreview = false; @Output() loadMore = new EventEmitter(); @@ -81,7 +82,7 @@ export class TransactionsListComponent implements OnInit, OnChanges { this.refreshOutspends$ .pipe( switchMap((txIds) => { - if (!this.cached) { + if (!this.cached && !this.txPreview) { // break list into batches of 50 (maximum supported by esplora) const batches = []; for (let i = 0; i < txIds.length; i += 50) { @@ -119,7 +120,7 @@ export class TransactionsListComponent implements OnInit, OnChanges { ), this.refreshChannels$ .pipe( - filter(() => this.stateService.networkSupportsLightning()), + filter(() => this.stateService.networkSupportsLightning() && !this.txPreview), switchMap((txIds) => this.apiService.getChannelByTxIds$(txIds)), catchError((error) => { // handle 404 @@ -187,7 +188,10 @@ export class TransactionsListComponent implements OnInit, OnChanges { } this.transactionsLength = this.transactions.length; - this.cacheService.setTxCache(this.transactions); + + if (!this.txPreview) { + this.cacheService.setTxCache(this.transactions); + } const confirmedTxs = this.transactions.filter((tx) => tx.status.confirmed).length; this.transactions.forEach((tx) => { @@ -347,7 +351,7 @@ export class TransactionsListComponent implements OnInit, OnChanges { } loadMoreInputs(tx: Transaction): void { - if (!tx['@vinLoaded']) { + if (!tx['@vinLoaded'] && !this.txPreview) { this.electrsApiService.getTransaction$(tx.txid) .subscribe((newTx) => { tx['@vinLoaded'] = true; diff --git a/frontend/src/app/route-guards.ts b/frontend/src/app/route-guards.ts index 780e997db..81cbf03ae 100644 --- a/frontend/src/app/route-guards.ts +++ b/frontend/src/app/route-guards.ts @@ -14,7 +14,7 @@ class GuardService { trackerGuard(route: Route, segments: UrlSegment[]): boolean { const preferredRoute = this.router.getCurrentNavigation()?.extractedUrl.queryParams?.mode; const path = this.router.getCurrentNavigation()?.extractedUrl.root.children.primary.segments; - return (preferredRoute === 'status' || (preferredRoute !== 'details' && this.navigationService.isInitialLoad())) && window.innerWidth <= 767.98 && !(path.length === 2 && ['push', 'test'].includes(path[1].path)); + return (preferredRoute === 'status' || (preferredRoute !== 'details' && this.navigationService.isInitialLoad())) && window.innerWidth <= 767.98 && !(path.length === 2 && ['push', 'test', 'preview'].includes(path[1].path)); } } diff --git a/frontend/src/app/shared/components/global-footer/global-footer.component.html b/frontend/src/app/shared/components/global-footer/global-footer.component.html index d82bb8062..edce7bb88 100644 --- a/frontend/src/app/shared/components/global-footer/global-footer.component.html +++ b/frontend/src/app/shared/components/global-footer/global-footer.component.html @@ -76,6 +76,7 @@

Recent Blocks

Broadcast Transaction

Test Transaction

+

Preview Transaction

Connect to our Nodes

API Documentation

diff --git a/frontend/src/app/shared/components/truncate/truncate.component.html b/frontend/src/app/shared/components/truncate/truncate.component.html index 066f83244..b7e31483e 100644 --- a/frontend/src/app/shared/components/truncate/truncate.component.html +++ b/frontend/src/app/shared/components/truncate/truncate.component.html @@ -1,6 +1,6 @@ - + diff --git a/frontend/src/app/shared/components/truncate/truncate.component.scss b/frontend/src/app/shared/components/truncate/truncate.component.scss index 8c22dd836..739376ed2 100644 --- a/frontend/src/app/shared/components/truncate/truncate.component.scss +++ b/frontend/src/app/shared/components/truncate/truncate.component.scss @@ -37,6 +37,12 @@ max-width: 300px; overflow: hidden; } + + .disabled { + pointer-events: none; + opacity: 0.8; + color: #fff; + } } @media (max-width: 567px) { diff --git a/frontend/src/app/shared/components/truncate/truncate.component.ts b/frontend/src/app/shared/components/truncate/truncate.component.ts index 589f7aa36..f9ab34ee9 100644 --- a/frontend/src/app/shared/components/truncate/truncate.component.ts +++ b/frontend/src/app/shared/components/truncate/truncate.component.ts @@ -15,6 +15,7 @@ export class TruncateComponent { @Input() maxWidth: number = null; @Input() inline: boolean = false; @Input() textAlign: 'start' | 'end' = 'start'; + @Input() disabled: boolean = false; rtl: boolean; constructor( From 722eaa3e9638aaa712f35a662dc12715b0c9db77 Mon Sep 17 00:00:00 2001 From: natsoni Date: Thu, 28 Nov 2024 12:07:05 +0100 Subject: [PATCH 03/35] Add note on borrowed code used for transaction decoding --- frontend/src/app/shared/transaction.utils.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/frontend/src/app/shared/transaction.utils.ts b/frontend/src/app/shared/transaction.utils.ts index 8d7281a20..af1412838 100644 --- a/frontend/src/app/shared/transaction.utils.ts +++ b/frontend/src/app/shared/transaction.utils.ts @@ -590,6 +590,8 @@ export function identifyPrioritizedTransactions(transactions: TransactionStrippe return { prioritized, deprioritized }; } +// Adapted from mempool backend https://github.com/mempool/mempool/blob/14e49126c3ca8416a8d7ad134a95c5e090324d69/backend/src/api/transaction-utils.ts#L254 +// Converts hex bitcoin script to ASM function convertScriptSigAsm(hex: string): string { const buf = new Uint8Array(hex.length / 2); @@ -655,6 +657,7 @@ function convertScriptSigAsm(hex: string): string { return b.join(' '); } +// Copied from mempool backend https://github.com/mempool/mempool/blob/14e49126c3ca8416a8d7ad134a95c5e090324d69/backend/src/api/transaction-utils.ts#L327 /** * This function must only be called when we know the witness we are parsing * is a taproot witness. @@ -679,6 +682,8 @@ function witnessToP2TRScript(witness: string[]): string | null { return witness[positionOfScript]; } +// Copied from mempool backend https://github.com/mempool/mempool/blob/14e49126c3ca8416a8d7ad134a95c5e090324d69/backend/src/api/transaction-utils.ts#L227 +// Fills inner_redeemscript_asm and inner_witnessscript_asm fields of fetched prevouts for decoded transactions export function addInnerScriptsToVin(vin: Vin): void { if (!vin.prevout) { return; @@ -706,6 +711,8 @@ export function addInnerScriptsToVin(vin: Vin): void { } } +// Adapted from bitcoinjs-lib at https://github.com/bitcoinjs/bitcoinjs-lib/blob/32e08aa57f6a023e995d8c4f0c9fbdc5f11d1fa0/ts_src/transaction.ts#L78 +// Reads buffer of raw transaction data function fromBuffer(buffer: Uint8Array, network: string): Transaction { let offset = 0; @@ -910,6 +917,7 @@ function txid(tx: Transaction): string { return uint8ArrayToHexString(hash2.reverse()); } +// Copied from mempool backend https://github.com/mempool/mempool/blob/14e49126c3ca8416a8d7ad134a95c5e090324d69/backend/src/api/transaction-utils.ts#L177 export function countSigops(transaction: Transaction): number { let sigops = 0; @@ -1213,6 +1221,7 @@ function varIntToBytes(value: number | bigint): number[] { return bytes; } +// Inversed the opcodes object from https://github.com/mempool/mempool/blob/14e49126c3ca8416a8d7ad134a95c5e090324d69/backend/src/utils/bitcoin-script.ts#L1 const opcodes = { 0: 'OP_0', 76: 'OP_PUSHDATA1', From 74ecd1aaac90789855ab1436b5d3eeba9ab830ff Mon Sep 17 00:00:00 2001 From: natsoni Date: Thu, 28 Nov 2024 14:32:20 +0100 Subject: [PATCH 04/35] Fix missing prevouts message --- .../components/transaction/transaction-raw.component.html | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/frontend/src/app/components/transaction/transaction-raw.component.html b/frontend/src/app/components/transaction/transaction-raw.component.html index 15293e2dd..461d77bc4 100644 --- a/frontend/src/app/components/transaction/transaction-raw.component.html +++ b/frontend/src/app/components/transaction/transaction-raw.component.html @@ -43,7 +43,11 @@ @if (!hasPrevouts) {
- This transaction is missing prevouts data. {{ errorPrevouts ? 'Reason: ' + errorPrevouts : '' }} + @if (offlineMode) { + Prevouts are not loaded, some fields like fee rate cannot be displayed. + } @else { + Could not load prevouts. {{ errorPrevouts ? 'Reason: ' + errorPrevouts : '' }} + }
} From 727f22bc9d919e4b660f1134c6ed61f1fc92d149 Mon Sep 17 00:00:00 2001 From: natsoni Date: Mon, 9 Dec 2024 23:24:11 +0100 Subject: [PATCH 05/35] Add backend endpoint to fetch prevouts --- backend/src/api/bitcoin/bitcoin.routes.ts | 50 ++++++++++++++- backend/src/api/transaction-utils.ts | 23 +++++++ .../transaction-raw.component.html | 4 +- .../transaction/transaction-raw.component.ts | 62 +++++++------------ frontend/src/app/services/api.service.ts | 4 ++ 5 files changed, 101 insertions(+), 42 deletions(-) diff --git a/backend/src/api/bitcoin/bitcoin.routes.ts b/backend/src/api/bitcoin/bitcoin.routes.ts index d2d298e09..0dbd4fa27 100644 --- a/backend/src/api/bitcoin/bitcoin.routes.ts +++ b/backend/src/api/bitcoin/bitcoin.routes.ts @@ -12,7 +12,7 @@ import backendInfo from '../backend-info'; import transactionUtils from '../transaction-utils'; import { IEsploraApi } from './esplora-api.interface'; import loadingIndicators from '../loading-indicators'; -import { TransactionExtended } from '../../mempool.interfaces'; +import { MempoolTransactionExtended, TransactionExtended } from '../../mempool.interfaces'; import logger from '../../logger'; import blocks from '../blocks'; import bitcoinClient from './bitcoin-client'; @@ -49,6 +49,7 @@ class BitcoinRoutes { .post(config.MEMPOOL.API_URL_PREFIX + 'psbt/addparents', this.postPsbtCompletion) .get(config.MEMPOOL.API_URL_PREFIX + 'blocks-bulk/:from', this.getBlocksByBulk.bind(this)) .get(config.MEMPOOL.API_URL_PREFIX + 'blocks-bulk/:from/:to', this.getBlocksByBulk.bind(this)) + .post(config.MEMPOOL.API_URL_PREFIX + 'prevouts', this.$getPrevouts) // Temporarily add txs/package endpoint for all backends until esplora supports it .post(config.MEMPOOL.API_URL_PREFIX + 'txs/package', this.$submitPackage) ; @@ -824,6 +825,53 @@ class BitcoinRoutes { } } + private async $getPrevouts(req: Request, res: Response) { + try { + const outpoints = req.body; + if (!Array.isArray(outpoints) || outpoints.some((item) => !/^[a-fA-F0-9]{64}$/.test(item.txid) || typeof item.vout !== 'number')) { + return res.status(400).json({ error: 'Invalid input format' }); + } + + if (outpoints.length > 100) { + return res.status(400).json({ error: 'Too many prevouts requested' }); + } + + const result = Array(outpoints.length).fill(null); + const memPool = mempool.getMempool(); + + for (let i = 0; i < outpoints.length; i++) { + const outpoint = outpoints[i]; + let prevout: IEsploraApi.Vout | null = null; + let tx: MempoolTransactionExtended | null = null; + + const mempoolTx = memPool[outpoint.txid]; + if (mempoolTx) { + prevout = mempoolTx.vout[outpoint.vout]; + tx = mempoolTx; + } else { + const rawPrevout = await bitcoinClient.getTxOut(outpoint.txid, outpoint.vout, false); + if (rawPrevout) { + prevout = { + value: Math.round(rawPrevout.value * 100000000), + scriptpubkey: rawPrevout.scriptPubKey.hex, + scriptpubkey_asm: rawPrevout.scriptPubKey.asm ? transactionUtils.convertScriptSigAsm(rawPrevout.scriptPubKey.hex) : '', + scriptpubkey_type: transactionUtils.translateScriptPubKeyType(rawPrevout.scriptPubKey.type), + scriptpubkey_address: rawPrevout.scriptPubKey && rawPrevout.scriptPubKey.address ? rawPrevout.scriptPubKey.address : '', + }; + } + } + + if (prevout) { + result[i] = { prevout, tx }; + } + } + + res.json(result); + + } catch (e) { + handleError(req, res, 500, e instanceof Error ? e.message : e); + } + } } export default new BitcoinRoutes(); diff --git a/backend/src/api/transaction-utils.ts b/backend/src/api/transaction-utils.ts index 28fa72bba..519527d5c 100644 --- a/backend/src/api/transaction-utils.ts +++ b/backend/src/api/transaction-utils.ts @@ -420,6 +420,29 @@ class TransactionUtils { return { prioritized, deprioritized }; } + + // Copied from https://github.com/mempool/mempool/blob/14e49126c3ca8416a8d7ad134a95c5e090324d69/backend/src/api/bitcoin/bitcoin-api.ts#L324 + public translateScriptPubKeyType(outputType: string): string { + const map = { + 'pubkey': 'p2pk', + 'pubkeyhash': 'p2pkh', + 'scripthash': 'p2sh', + 'witness_v0_keyhash': 'v0_p2wpkh', + 'witness_v0_scripthash': 'v0_p2wsh', + 'witness_v1_taproot': 'v1_p2tr', + 'nonstandard': 'nonstandard', + 'multisig': 'multisig', + 'anchor': 'anchor', + 'nulldata': 'op_return' + }; + + if (map[outputType]) { + return map[outputType]; + } else { + return 'unknown'; + } + } + } export default new TransactionUtils(); diff --git a/frontend/src/app/components/transaction/transaction-raw.component.html b/frontend/src/app/components/transaction/transaction-raw.component.html index 461d77bc4..ca76d0e78 100644 --- a/frontend/src/app/components/transaction/transaction-raw.component.html +++ b/frontend/src/app/components/transaction/transaction-raw.component.html @@ -46,7 +46,7 @@ @if (offlineMode) { Prevouts are not loaded, some fields like fee rate cannot be displayed. } @else { - Could not load prevouts. {{ errorPrevouts ? 'Reason: ' + errorPrevouts : '' }} + Error loading prevouts. {{ errorPrevouts ? 'Reason: ' + errorPrevouts : '' }} } } @@ -188,7 +188,7 @@ @if (isLoading) {
-

Loading transaction prevouts ({{ prevoutsLoadedCount }} / {{ prevoutsCount }})

+

Loading transaction prevouts

} \ No newline at end of file diff --git a/frontend/src/app/components/transaction/transaction-raw.component.ts b/frontend/src/app/components/transaction/transaction-raw.component.ts index cac7b595f..c9a0c2544 100644 --- a/frontend/src/app/components/transaction/transaction-raw.component.ts +++ b/frontend/src/app/components/transaction/transaction-raw.component.ts @@ -1,5 +1,5 @@ -import { Component, OnInit, HostListener, ViewChild, ElementRef, OnDestroy, ChangeDetectorRef } from '@angular/core'; -import { Transaction } from '@interfaces/electrs.interface'; +import { Component, OnInit, HostListener, ViewChild, ElementRef, OnDestroy } from '@angular/core'; +import { Transaction, Vout } from '@interfaces/electrs.interface'; import { StateService } from '../../services/state.service'; import { Filter, toFilters } from '../../shared/filters.utils'; import { decodeRawTransaction, getTransactionFlags, addInnerScriptsToVin, countSigops } from '../../shared/transaction.utils'; @@ -28,8 +28,7 @@ export class TransactionRawComponent implements OnInit, OnDestroy { error: string; errorPrevouts: string; hasPrevouts: boolean; - prevoutsLoadedCount: number = 0; - prevoutsCount: number; + missingPrevouts: string[]; isLoadingBroadcast: boolean; errorBroadcast: string; successBroadcast: boolean; @@ -59,7 +58,6 @@ export class TransactionRawComponent implements OnInit, OnDestroy { public electrsApi: ElectrsApiService, public websocketService: WebsocketService, public formBuilder: UntypedFormBuilder, - public cd: ChangeDetectorRef, public seoService: SeoService, public apiService: ApiService, public relativeUrlPipe: RelativeUrlPipe, @@ -93,52 +91,38 @@ export class TransactionRawComponent implements OnInit, OnDestroy { return; } - this.prevoutsCount = transaction.vin.filter(input => !input.is_coinbase).length; - if (this.prevoutsCount === 0) { + const prevoutsToFetch = transaction.vin.map((input) => ({ txid: input.txid, vout: input.vout })); + + if (!prevoutsToFetch.length || transaction.vin[0].is_coinbase) { this.hasPrevouts = true; return; } - const txsToFetch: { [txid: string]: number } = transaction.vin.reduce((acc, input) => { - if (!input.is_coinbase) { - acc[input.txid] = (acc[input.txid] || 0) + 1; - } - return acc; - }, {} as { [txid: string]: number }); - try { + this.missingPrevouts = []; - if (Object.keys(txsToFetch).length > 20) { - throw new Error($localize`:@@transaction.too-many-prevouts:Too many transactions to fetch (${Object.keys(txsToFetch).length})`); + const prevouts: { prevout: Vout, tx?: any }[] = await firstValueFrom(this.apiService.getPrevouts$(prevoutsToFetch)); + + if (prevouts?.length !== prevoutsToFetch.length) { + throw new Error(); } - const fetchedTransactions = await Promise.all( - Object.keys(txsToFetch).map(txid => - firstValueFrom(this.electrsApi.getTransaction$(txid)) - .then(response => { - this.prevoutsLoadedCount += txsToFetch[txid]; - this.cd.markForCheck(); - return response; - }) - ) - ); - - const transactionsMap = fetchedTransactions.reduce((acc, transaction) => { - acc[transaction.txid] = transaction; - return acc; - }, {} as { [txid: string]: any }); - - const prevouts = transaction.vin.map((input, index) => ({ index, prevout: transactionsMap[input.txid]?.vout[input.vout] || null})); - transaction.vin = transaction.vin.map((input, index) => { - if (!input.is_coinbase) { - input.prevout = prevouts.find(p => p.index === index)?.prevout; + if (prevouts[index]) { + input.prevout = prevouts[index].prevout; addInnerScriptsToVin(input); + } else { + this.missingPrevouts.push(`${input.txid}:${input.vout}`); } return input; }); + + if (this.missingPrevouts.length) { + throw new Error(`Some prevouts do not exist or are already spent (${this.missingPrevouts.length})`); + } + this.hasPrevouts = true; - } catch (error) { + } catch (error) { this.errorPrevouts = error.message; } } @@ -207,6 +191,7 @@ export class TransactionRawComponent implements OnInit, OnDestroy { .subscribe((result) => { this.isLoadingBroadcast = false; this.successBroadcast = true; + this.transaction.txid = result; resolve(result); }, (error) => { @@ -232,8 +217,7 @@ export class TransactionRawComponent implements OnInit, OnDestroy { this.adjustedVsize = null; this.filters = []; this.hasPrevouts = false; - this.prevoutsLoadedCount = 0; - this.prevoutsCount = 0; + this.missingPrevouts = []; this.stateService.markBlock$.next({}); this.mempoolBlocksSubscription?.unsubscribe(); } diff --git a/frontend/src/app/services/api.service.ts b/frontend/src/app/services/api.service.ts index 3c8cf8807..ce0e67cbf 100644 --- a/frontend/src/app/services/api.service.ts +++ b/frontend/src/app/services/api.service.ts @@ -565,6 +565,10 @@ export class ApiService { return this.httpClient.post(this.apiBaseUrl + this.apiBasePath + '/api/v1/acceleration/request/' + txid, ''); } + getPrevouts$(outpoints: {txid: string; vout: number}[]): Observable { + return this.httpClient.post(this.apiBaseUrl + this.apiBasePath + '/api/v1/prevouts', outpoints); + } + // Cache methods async setBlockAuditLoaded(hash: string) { this.blockAuditLoaded[hash] = true; From d852c48370066f3aba8b58853c7a12f0c969eeee Mon Sep 17 00:00:00 2001 From: natsoni Date: Mon, 16 Dec 2024 18:06:01 +0100 Subject: [PATCH 06/35] Move 'related transactions' to dedicated component --- .../transaction/cpfp-info.component.html | 56 ++++++++++++++++++ .../transaction/cpfp-info.component.scss | 32 ++++++++++ .../transaction/cpfp-info.component.ts | 22 +++++++ .../transaction/transaction.component.html | 59 +------------------ .../transaction/transaction.component.scss | 12 ---- .../transaction/transaction.component.ts | 4 -- .../transaction/transaction.module.ts | 3 + 7 files changed, 114 insertions(+), 74 deletions(-) create mode 100644 frontend/src/app/components/transaction/cpfp-info.component.html create mode 100644 frontend/src/app/components/transaction/cpfp-info.component.scss create mode 100644 frontend/src/app/components/transaction/cpfp-info.component.ts diff --git a/frontend/src/app/components/transaction/cpfp-info.component.html b/frontend/src/app/components/transaction/cpfp-info.component.html new file mode 100644 index 000000000..55945c388 --- /dev/null +++ b/frontend/src/app/components/transaction/cpfp-info.component.html @@ -0,0 +1,56 @@ +
+
+

Related Transactions

+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
TypeTXIDVirtual sizeWeightFee rate
Descendant + +
Descendant + +
Ancestor + +
+
\ No newline at end of file diff --git a/frontend/src/app/components/transaction/cpfp-info.component.scss b/frontend/src/app/components/transaction/cpfp-info.component.scss new file mode 100644 index 000000000..df2b622e7 --- /dev/null +++ b/frontend/src/app/components/transaction/cpfp-info.component.scss @@ -0,0 +1,32 @@ +.title { + h2 { + line-height: 1; + margin: 0; + padding-bottom: 5px; + } +} + +.cpfp-details { + .txids { + width: 60%; + } + + @media (max-width: 500px) { + .txids { + width: 40%; + } + } +} + +.arrow-green { + color: var(--success); +} + +.arrow-red { + color: var(--red); +} + +.badge { + position: relative; + top: -1px; +} \ No newline at end of file diff --git a/frontend/src/app/components/transaction/cpfp-info.component.ts b/frontend/src/app/components/transaction/cpfp-info.component.ts new file mode 100644 index 000000000..3d122183b --- /dev/null +++ b/frontend/src/app/components/transaction/cpfp-info.component.ts @@ -0,0 +1,22 @@ +import { Component, OnInit, Input, ChangeDetectionStrategy } from '@angular/core'; +import { CpfpInfo } from '@interfaces/node-api.interface'; +import { Transaction } from '@interfaces/electrs.interface'; + +@Component({ + selector: 'app-cpfp-info', + templateUrl: './cpfp-info.component.html', + styleUrls: ['./cpfp-info.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class CpfpInfoComponent implements OnInit { + @Input() cpfpInfo: CpfpInfo; + @Input() tx: Transaction; + + constructor() {} + + ngOnInit(): void {} + + roundToOneDecimal(cpfpTx: any): number { + return +(cpfpTx.fee / (cpfpTx.weight / 4)).toFixed(1); + } +} diff --git a/frontend/src/app/components/transaction/transaction.component.html b/frontend/src/app/components/transaction/transaction.component.html index 8c2d9de01..099d7beb5 100644 --- a/frontend/src/app/components/transaction/transaction.component.html +++ b/frontend/src/app/components/transaction/transaction.component.html @@ -66,64 +66,7 @@ - -
-
-

Related Transactions

-
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
TypeTXIDVirtual sizeWeightFee rate
Descendant - -
Descendant - -
Ancestor - -
-
-
+ diff --git a/frontend/src/app/components/transaction/transaction.component.scss b/frontend/src/app/components/transaction/transaction.component.scss index d35f26130..fed9f742c 100644 --- a/frontend/src/app/components/transaction/transaction.component.scss +++ b/frontend/src/app/components/transaction/transaction.component.scss @@ -227,18 +227,6 @@ } } -.cpfp-details { - .txids { - width: 60%; - } - - @media (max-width: 500px) { - .txids { - width: 40%; - } - } -} - .tx-list { .alert-link { display: block; diff --git a/frontend/src/app/components/transaction/transaction.component.ts b/frontend/src/app/components/transaction/transaction.component.ts index 71ffaa2cd..50ff32340 100644 --- a/frontend/src/app/components/transaction/transaction.component.ts +++ b/frontend/src/app/components/transaction/transaction.component.ts @@ -1054,10 +1054,6 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { this.stateService.markBlock$.next({}); } - roundToOneDecimal(cpfpTx: any): number { - return +(cpfpTx.fee / (cpfpTx.weight / 4)).toFixed(1); - } - setupGraph() { this.maxInOut = Math.min(this.inOutLimit, Math.max(this.tx?.vin?.length || 1, this.tx?.vout?.length + 1 || 1)); this.graphHeight = this.graphExpanded ? this.maxInOut * 15 : Math.min(360, this.maxInOut * 80); diff --git a/frontend/src/app/components/transaction/transaction.module.ts b/frontend/src/app/components/transaction/transaction.module.ts index 58b6493d8..a05191346 100644 --- a/frontend/src/app/components/transaction/transaction.module.ts +++ b/frontend/src/app/components/transaction/transaction.module.ts @@ -10,6 +10,7 @@ import { GraphsModule } from '@app/graphs/graphs.module'; import { AccelerateCheckout } from '@components/accelerate-checkout/accelerate-checkout.component'; import { AccelerateFeeGraphComponent } from '@components/accelerate-checkout/accelerate-fee-graph.component'; import { TransactionRawComponent } from '@components/transaction/transaction-raw.component'; +import { CpfpInfoComponent } from '@components/transaction/cpfp-info.component'; const routes: Routes = [ { @@ -55,12 +56,14 @@ export class TransactionRoutingModule { } AccelerateCheckout, AccelerateFeeGraphComponent, TransactionRawComponent, + CpfpInfoComponent, ], exports: [ TransactionComponent, TransactionDetailsComponent, AccelerateCheckout, AccelerateFeeGraphComponent, + CpfpInfoComponent, ] }) export class TransactionModule { } From 2987f86cd38214edda204b6ea428a039192619c1 Mon Sep 17 00:00:00 2001 From: natsoni Date: Tue, 17 Dec 2024 19:42:31 +0100 Subject: [PATCH 07/35] Compute decoded tx CPFP data in the backend --- backend/src/api/bitcoin/bitcoin.routes.ts | 44 +++++++++-- backend/src/api/cpfp.ts | 28 +++++++ .../transaction-raw.component.html | 15 +++- .../transaction/transaction-raw.component.ts | 79 +++++++++++++++---- frontend/src/app/services/api.service.ts | 4 + 5 files changed, 144 insertions(+), 26 deletions(-) diff --git a/backend/src/api/bitcoin/bitcoin.routes.ts b/backend/src/api/bitcoin/bitcoin.routes.ts index 0dbd4fa27..91dcf35c5 100644 --- a/backend/src/api/bitcoin/bitcoin.routes.ts +++ b/backend/src/api/bitcoin/bitcoin.routes.ts @@ -12,14 +12,14 @@ import backendInfo from '../backend-info'; import transactionUtils from '../transaction-utils'; import { IEsploraApi } from './esplora-api.interface'; import loadingIndicators from '../loading-indicators'; -import { MempoolTransactionExtended, TransactionExtended } from '../../mempool.interfaces'; +import { TransactionExtended } from '../../mempool.interfaces'; import logger from '../../logger'; import blocks from '../blocks'; import bitcoinClient from './bitcoin-client'; import difficultyAdjustment from '../difficulty-adjustment'; import transactionRepository from '../../repositories/TransactionRepository'; import rbfCache from '../rbf-cache'; -import { calculateMempoolTxCpfp } from '../cpfp'; +import { calculateMempoolTxCpfp, calculateLocalTxCpfp } from '../cpfp'; import { handleError } from '../../utils/api'; class BitcoinRoutes { @@ -50,6 +50,7 @@ class BitcoinRoutes { .get(config.MEMPOOL.API_URL_PREFIX + 'blocks-bulk/:from', this.getBlocksByBulk.bind(this)) .get(config.MEMPOOL.API_URL_PREFIX + 'blocks-bulk/:from/:to', this.getBlocksByBulk.bind(this)) .post(config.MEMPOOL.API_URL_PREFIX + 'prevouts', this.$getPrevouts) + .post(config.MEMPOOL.API_URL_PREFIX + 'cpfp', this.getCpfpLocalTx) // Temporarily add txs/package endpoint for all backends until esplora supports it .post(config.MEMPOOL.API_URL_PREFIX + 'txs/package', this.$submitPackage) ; @@ -829,11 +830,11 @@ class BitcoinRoutes { try { const outpoints = req.body; if (!Array.isArray(outpoints) || outpoints.some((item) => !/^[a-fA-F0-9]{64}$/.test(item.txid) || typeof item.vout !== 'number')) { - return res.status(400).json({ error: 'Invalid input format' }); + return res.status(400).json({ message: 'Invalid input format' }); } if (outpoints.length > 100) { - return res.status(400).json({ error: 'Too many prevouts requested' }); + return res.status(400).json({ message: 'Too many prevouts requested' }); } const result = Array(outpoints.length).fill(null); @@ -842,12 +843,14 @@ class BitcoinRoutes { for (let i = 0; i < outpoints.length; i++) { const outpoint = outpoints[i]; let prevout: IEsploraApi.Vout | null = null; - let tx: MempoolTransactionExtended | null = null; + let unconfirmed: boolean | null = null; const mempoolTx = memPool[outpoint.txid]; if (mempoolTx) { - prevout = mempoolTx.vout[outpoint.vout]; - tx = mempoolTx; + if (outpoint.vout < mempoolTx.vout.length) { + prevout = mempoolTx.vout[outpoint.vout]; + unconfirmed = true; + } } else { const rawPrevout = await bitcoinClient.getTxOut(outpoint.txid, outpoint.vout, false); if (rawPrevout) { @@ -858,11 +861,12 @@ class BitcoinRoutes { scriptpubkey_type: transactionUtils.translateScriptPubKeyType(rawPrevout.scriptPubKey.type), scriptpubkey_address: rawPrevout.scriptPubKey && rawPrevout.scriptPubKey.address ? rawPrevout.scriptPubKey.address : '', }; + unconfirmed = false; } } if (prevout) { - result[i] = { prevout, tx }; + result[i] = { prevout, unconfirmed }; } } @@ -872,6 +876,30 @@ class BitcoinRoutes { handleError(req, res, 500, e instanceof Error ? e.message : e); } } + + private getCpfpLocalTx(req: Request, res: Response) { + try { + const tx = req.body; + + if ( + !tx || typeof tx !== "object" || + !tx.txid || typeof tx.txid !== "string" || + typeof tx.weight !== "number" || + typeof tx.sigops !== "number" || + typeof tx.fee !== "number" || + !Array.isArray(tx.vin) || + !Array.isArray(tx.vout) + ) { + return res.status(400).json({ message: 'Invalid transaction format: missing or incorrect fields' }); + } + + const cpfpInfo = calculateLocalTxCpfp(tx, mempool.getMempool()); + res.json(cpfpInfo); + + } catch (e) { + handleError(req, res, 500, e instanceof Error ? e.message : e); + } + } } export default new BitcoinRoutes(); diff --git a/backend/src/api/cpfp.ts b/backend/src/api/cpfp.ts index 9da11328b..3421d9c7a 100644 --- a/backend/src/api/cpfp.ts +++ b/backend/src/api/cpfp.ts @@ -222,6 +222,34 @@ export function calculateMempoolTxCpfp(tx: MempoolTransactionExtended, mempool: }; } + +/** + * Takes an unbroadcasted transaction and a copy of the current mempool, and calculates an estimate + * of the CPFP data if the transaction were to enter the mempool. This only returns potential ancerstors + * and effective fee rate, and does not update the CPFP data of other transactions in the cluster. + */ +export function calculateLocalTxCpfp(tx: MempoolTransactionExtended, mempool: { [txid: string]: MempoolTransactionExtended }): CpfpInfo { + const ancestorMap = new Map(); + const graphTx = convertToGraphTx(tx, memPool.getSpendMap()); + ancestorMap.set(tx.txid, graphTx); + + const allRelatives = expandRelativesGraph(mempool, ancestorMap, memPool.getSpendMap()); + const relativesMap = initializeRelatives(allRelatives); + const cluster = calculateCpfpCluster(tx.txid, relativesMap); + + let totalVsize = 0; + let totalFee = 0; + for (const tx of cluster.values()) { + totalVsize += tx.vsize; + totalFee += tx.fees.base; + } + + return { + ancestors: Array.from(cluster.get(tx.txid)?.ancestors.values() || []).map(ancestor => ({ txid: ancestor.txid, weight: ancestor.weight, fee: ancestor.fees.base })), + effectiveFeePerVsize: totalFee / totalVsize + } +} + /** * Given a root transaction and a list of in-mempool ancestors, * Calculate the CPFP cluster diff --git a/frontend/src/app/components/transaction/transaction-raw.component.html b/frontend/src/app/components/transaction/transaction-raw.component.html index ca76d0e78..b6286779a 100644 --- a/frontend/src/app/components/transaction/transaction-raw.component.html +++ b/frontend/src/app/components/transaction/transaction-raw.component.html @@ -51,6 +51,12 @@ } + @if (errorCpfpInfo) { +
+ Error loading CPFP data. Reason: {{ errorCpfpInfo }} +
+ } + +
@@ -188,7 +197,9 @@ @if (isLoading) {
-

Loading transaction prevouts

+

+ Loading {{ isLoadingPrevouts ? 'transaction prevouts' : isLoadingCpfpInfo ? 'CPFP' : '' }} +

} \ No newline at end of file diff --git a/frontend/src/app/components/transaction/transaction-raw.component.ts b/frontend/src/app/components/transaction/transaction-raw.component.ts index c9a0c2544..441a72f61 100644 --- a/frontend/src/app/components/transaction/transaction-raw.component.ts +++ b/frontend/src/app/components/transaction/transaction-raw.component.ts @@ -13,6 +13,7 @@ import { SeoService } from '../../services/seo.service'; import { seoDescriptionNetwork } from '@app/shared/common.utils'; import { ApiService } from '../../services/api.service'; import { RelativeUrlPipe } from '@app/shared/pipes/relative-url/relative-url.pipe'; +import { CpfpInfo } from '../../interfaces/node-api.interface'; @Component({ selector: 'app-transaction-raw', @@ -23,10 +24,13 @@ export class TransactionRawComponent implements OnInit, OnDestroy { pushTxForm: UntypedFormGroup; isLoading: boolean; + isLoadingPrevouts: boolean; + isLoadingCpfpInfo: boolean; offlineMode: boolean = false; transaction: Transaction; error: string; errorPrevouts: string; + errorCpfpInfo: string; hasPrevouts: boolean; missingPrevouts: string[]; isLoadingBroadcast: boolean; @@ -46,6 +50,10 @@ export class TransactionRawComponent implements OnInit, OnDestroy { flowEnabled: boolean; adjustedVsize: number; filters: Filter[] = []; + hasEffectiveFeeRate: boolean; + fetchCpfp: boolean; + cpfpInfo: CpfpInfo | null; + hasCpfp: boolean = false; showCpfpDetails = false; ETA$: Observable; mempoolBlocksSubscription: Subscription; @@ -78,6 +86,7 @@ export class TransactionRawComponent implements OnInit, OnDestroy { try { const tx = decodeRawTransaction(this.pushTxForm.get('txRaw').value, this.stateService.network); await this.fetchPrevouts(tx); + await this.fetchCpfpInfo(tx); this.processTransaction(tx); } catch (error) { this.error = error.message; @@ -100,8 +109,9 @@ export class TransactionRawComponent implements OnInit, OnDestroy { try { this.missingPrevouts = []; + this.isLoadingPrevouts = true; - const prevouts: { prevout: Vout, tx?: any }[] = await firstValueFrom(this.apiService.getPrevouts$(prevoutsToFetch)); + const prevouts: { prevout: Vout, unconfirmed: boolean }[] = await firstValueFrom(this.apiService.getPrevouts$(prevoutsToFetch)); if (prevouts?.length !== prevoutsToFetch.length) { throw new Error(); @@ -121,27 +131,57 @@ export class TransactionRawComponent implements OnInit, OnDestroy { throw new Error(`Some prevouts do not exist or are already spent (${this.missingPrevouts.length})`); } + transaction.fee = transaction.vin.some(input => input.is_coinbase) + ? 0 + : transaction.vin.reduce((fee, input) => { + return fee + (input.prevout?.value || 0); + }, 0) - transaction.vout.reduce((sum, output) => sum + output.value, 0); + transaction.feePerVsize = transaction.fee / (transaction.weight / 4); + transaction.sigops = countSigops(transaction); + this.hasPrevouts = true; + this.isLoadingPrevouts = false; + this.fetchCpfp = prevouts.some(prevout => prevout?.unconfirmed); + } catch (error) { + this.errorPrevouts = error?.error?.message || error?.message; + this.isLoadingPrevouts = false; + } + } + + async fetchCpfpInfo(transaction: Transaction): Promise { + // Fetch potential cpfp data if all prevouts were parsed successfully and at least one of them is unconfirmed + if (this.hasPrevouts && this.fetchCpfp) { + try { + this.isLoadingCpfpInfo = true; + const cpfpInfo: CpfpInfo = await firstValueFrom(this.apiService.getCpfpLocalTx$({ + txid: transaction.txid, + weight: transaction.weight, + sigops: transaction.sigops, + fee: transaction.fee, + vin: transaction.vin, + vout: transaction.vout + })); + + if (cpfpInfo && cpfpInfo.ancestors.length > 0) { + const { ancestors, effectiveFeePerVsize } = cpfpInfo; + transaction.effectiveFeePerVsize = effectiveFeePerVsize; + this.cpfpInfo = { ancestors, effectiveFeePerVsize }; + this.hasCpfp = true; + this.hasEffectiveFeeRate = true; + } + this.isLoadingCpfpInfo = false; } catch (error) { - this.errorPrevouts = error.message; + this.errorCpfpInfo = error?.error?.message || error?.message; + this.isLoadingCpfpInfo = false; + } } } processTransaction(tx: Transaction): void { this.transaction = tx; - if (this.hasPrevouts) { - this.transaction.fee = this.transaction.vin.some(input => input.is_coinbase) - ? 0 - : this.transaction.vin.reduce((fee, input) => { - return fee + (input.prevout?.value || 0); - }, 0) - this.transaction.vout.reduce((sum, output) => sum + output.value, 0); - this.transaction.feePerVsize = this.transaction.fee / (this.transaction.weight / 4); - } - this.transaction.flags = getTransactionFlags(this.transaction, null, null, null, this.stateService.network); this.filters = this.transaction.flags ? toFilters(this.transaction.flags).filter(f => f.txPage) : []; - this.transaction.sigops = countSigops(this.transaction); if (this.transaction.sigops >= 0) { this.adjustedVsize = Math.max(this.transaction.weight / 4, this.transaction.sigops * 5); } @@ -155,16 +195,15 @@ export class TransactionRawComponent implements OnInit, OnDestroy { this.setGraphSize(); this.ETA$ = combineLatest([ - this.stateService.mempoolTxPosition$.pipe(startWith(null)), this.stateService.mempoolBlocks$.pipe(startWith(null)), this.stateService.difficultyAdjustment$.pipe(startWith(null)), ]).pipe( - map(([position, mempoolBlocks, da]) => { + map(([mempoolBlocks, da]) => { return this.etaService.calculateETA( this.stateService.network, this.transaction, mempoolBlocks, - position, + null, da, null, null, @@ -177,7 +216,7 @@ export class TransactionRawComponent implements OnInit, OnDestroy { if (this.transaction) { this.stateService.markBlock$.next({ txid: this.transaction.txid, - txFeePerVSize: this.transaction.feePerVsize, + txFeePerVSize: this.transaction.effectiveFeePerVsize || this.transaction.feePerVsize, }); } }); @@ -214,7 +253,15 @@ export class TransactionRawComponent implements OnInit, OnDestroy { this.errorBroadcast = null; this.successBroadcast = false; this.isLoading = false; + this.isLoadingPrevouts = false; + this.isLoadingCpfpInfo = false; + this.isLoadingBroadcast = false; this.adjustedVsize = null; + this.showCpfpDetails = false; + this.hasCpfp = false; + this.fetchCpfp = false; + this.cpfpInfo = null; + this.hasEffectiveFeeRate = false; this.filters = []; this.hasPrevouts = false; this.missingPrevouts = []; diff --git a/frontend/src/app/services/api.service.ts b/frontend/src/app/services/api.service.ts index ce0e67cbf..698eede91 100644 --- a/frontend/src/app/services/api.service.ts +++ b/frontend/src/app/services/api.service.ts @@ -569,6 +569,10 @@ export class ApiService { return this.httpClient.post(this.apiBaseUrl + this.apiBasePath + '/api/v1/prevouts', outpoints); } + getCpfpLocalTx$(tx: any): Observable { + return this.httpClient.post(this.apiBaseUrl + this.apiBasePath + '/api/v1/cpfp', tx); + } + // Cache methods async setBlockAuditLoaded(hash: string) { this.blockAuditLoaded[hash] = true; From 74fa3c7eb1654279c1454c04d1c47b6ad3827827 Mon Sep 17 00:00:00 2001 From: Mononaut Date: Wed, 1 Jan 2025 16:51:34 +0000 Subject: [PATCH 08/35] conform getPrevouts and getCpfpLocalTx to new error handling standard --- backend/src/api/bitcoin/bitcoin.routes.ts | 25 +++++++++++-------- .../transaction/transaction-raw.component.ts | 5 ++-- 2 files changed, 17 insertions(+), 13 deletions(-) diff --git a/backend/src/api/bitcoin/bitcoin.routes.ts b/backend/src/api/bitcoin/bitcoin.routes.ts index f92b7cd0c..0e9016bde 100644 --- a/backend/src/api/bitcoin/bitcoin.routes.ts +++ b/backend/src/api/bitcoin/bitcoin.routes.ts @@ -936,11 +936,13 @@ class BitcoinRoutes { try { const outpoints = req.body; if (!Array.isArray(outpoints) || outpoints.some((item) => !/^[a-fA-F0-9]{64}$/.test(item.txid) || typeof item.vout !== 'number')) { - return res.status(400).json({ message: 'Invalid input format' }); + handleError(req, res, 400, 'Invalid outpoints format'); + return; } if (outpoints.length > 100) { - return res.status(400).json({ message: 'Too many prevouts requested' }); + handleError(req, res, 400, 'Too many outpoints requested'); + return; } const result = Array(outpoints.length).fill(null); @@ -955,7 +957,7 @@ class BitcoinRoutes { if (mempoolTx) { if (outpoint.vout < mempoolTx.vout.length) { prevout = mempoolTx.vout[outpoint.vout]; - unconfirmed = true; + unconfirmed = true; } } else { const rawPrevout = await bitcoinClient.getTxOut(outpoint.txid, outpoint.vout, false); @@ -979,7 +981,7 @@ class BitcoinRoutes { res.json(result); } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to get prevouts'); } } @@ -988,22 +990,23 @@ class BitcoinRoutes { const tx = req.body; if ( - !tx || typeof tx !== "object" || - !tx.txid || typeof tx.txid !== "string" || - typeof tx.weight !== "number" || - typeof tx.sigops !== "number" || - typeof tx.fee !== "number" || + !tx || typeof tx !== 'object' || + !tx.txid || typeof tx.txid !== 'string' || + typeof tx.weight !== 'number' || + typeof tx.sigops !== 'number' || + typeof tx.fee !== 'number' || !Array.isArray(tx.vin) || !Array.isArray(tx.vout) ) { - return res.status(400).json({ message: 'Invalid transaction format: missing or incorrect fields' }); + handleError(req, res, 400, 'Invalid transaction format'); + return; } const cpfpInfo = calculateLocalTxCpfp(tx, mempool.getMempool()); res.json(cpfpInfo); } catch (e) { - handleError(req, res, 500, e instanceof Error ? e.message : e); + handleError(req, res, 500, 'Failed to calculate CPFP info'); } } } diff --git a/frontend/src/app/components/transaction/transaction-raw.component.ts b/frontend/src/app/components/transaction/transaction-raw.component.ts index 441a72f61..80f3eeb93 100644 --- a/frontend/src/app/components/transaction/transaction-raw.component.ts +++ b/frontend/src/app/components/transaction/transaction-raw.component.ts @@ -143,7 +143,8 @@ export class TransactionRawComponent implements OnInit, OnDestroy { this.isLoadingPrevouts = false; this.fetchCpfp = prevouts.some(prevout => prevout?.unconfirmed); } catch (error) { - this.errorPrevouts = error?.error?.message || error?.message; + console.log(error); + this.errorPrevouts = error?.error?.error || error?.message; this.isLoadingPrevouts = false; } } @@ -171,7 +172,7 @@ export class TransactionRawComponent implements OnInit, OnDestroy { } this.isLoadingCpfpInfo = false; } catch (error) { - this.errorCpfpInfo = error?.error?.message || error?.message; + this.errorCpfpInfo = error?.error?.error || error?.message; this.isLoadingCpfpInfo = false; } } From 5b331c144ba2498df23969a97fc257f227f1f9d8 Mon Sep 17 00:00:00 2001 From: natsoni Date: Wed, 8 Jan 2025 15:25:19 +0100 Subject: [PATCH 09/35] P2A address format decoding --- frontend/src/app/shared/transaction.utils.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/frontend/src/app/shared/transaction.utils.ts b/frontend/src/app/shared/transaction.utils.ts index af1412838..b33d88c2f 100644 --- a/frontend/src/app/shared/transaction.utils.ts +++ b/frontend/src/app/shared/transaction.utils.ts @@ -988,7 +988,7 @@ function scriptPubKeyToAddress(scriptPubKey: string, network: string): { address } // anchor if (scriptPubKey === '51024e73') { - return { address: 'bc1pfeessrawgf', type: 'anchor' }; + return { address: p2a(network), type: 'anchor' }; } // op_return if (/^6a/.test(scriptPubKey)) { @@ -1048,6 +1048,15 @@ function p2tr(pubKeyHash: string, network: string): string { return bech32Address; } +function p2a(network: string): string { + const pubkeyHashArray = hexStringToUint8Array('4e73'); + const hrp = ['testnet', 'testnet4', 'signet'].includes(network) ? 'tb' : 'bc'; + const version = 1; + const words = [version].concat(toWords(pubkeyHashArray)); + const bech32Address = bech32Encode(hrp, words, 0x2bc830a3); + return bech32Address; +} + // base58 encoding function base58Encode(data: Uint8Array): string { const BASE58_ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"; From af0c78be8129c0510dfcfe8827f17e13ec12d229 Mon Sep 17 00:00:00 2001 From: natsoni Date: Tue, 7 Jan 2025 15:01:36 +0100 Subject: [PATCH 10/35] Handle error from bitcoin client when querying prevouts --- backend/src/api/bitcoin/bitcoin.routes.ts | 24 +++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/backend/src/api/bitcoin/bitcoin.routes.ts b/backend/src/api/bitcoin/bitcoin.routes.ts index 0e9016bde..545ad510c 100644 --- a/backend/src/api/bitcoin/bitcoin.routes.ts +++ b/backend/src/api/bitcoin/bitcoin.routes.ts @@ -960,16 +960,20 @@ class BitcoinRoutes { unconfirmed = true; } } else { - const rawPrevout = await bitcoinClient.getTxOut(outpoint.txid, outpoint.vout, false); - if (rawPrevout) { - prevout = { - value: Math.round(rawPrevout.value * 100000000), - scriptpubkey: rawPrevout.scriptPubKey.hex, - scriptpubkey_asm: rawPrevout.scriptPubKey.asm ? transactionUtils.convertScriptSigAsm(rawPrevout.scriptPubKey.hex) : '', - scriptpubkey_type: transactionUtils.translateScriptPubKeyType(rawPrevout.scriptPubKey.type), - scriptpubkey_address: rawPrevout.scriptPubKey && rawPrevout.scriptPubKey.address ? rawPrevout.scriptPubKey.address : '', - }; - unconfirmed = false; + try { + const rawPrevout = await bitcoinClient.getTxOut(outpoint.txid, outpoint.vout, false); + if (rawPrevout) { + prevout = { + value: Math.round(rawPrevout.value * 100000000), + scriptpubkey: rawPrevout.scriptPubKey.hex, + scriptpubkey_asm: rawPrevout.scriptPubKey.asm ? transactionUtils.convertScriptSigAsm(rawPrevout.scriptPubKey.hex) : '', + scriptpubkey_type: transactionUtils.translateScriptPubKeyType(rawPrevout.scriptPubKey.type), + scriptpubkey_address: rawPrevout.scriptPubKey && rawPrevout.scriptPubKey.address ? rawPrevout.scriptPubKey.address : '', + }; + unconfirmed = false; + } + } catch (e) { + // Ignore bitcoin client errors, just leave prevout as null } } From 6c95cd21491cfb377a14fd82f82e773a16d20d79 Mon Sep 17 00:00:00 2001 From: natsoni Date: Wed, 8 Jan 2025 15:07:37 +0100 Subject: [PATCH 11/35] Update local cpfp API to accept array of transactions --- backend/src/api/bitcoin/bitcoin.routes.ts | 23 +++++++++++-------- backend/src/api/cpfp.ts | 1 - .../transaction/transaction-raw.component.ts | 8 +++---- frontend/src/app/services/api.service.ts | 4 ++-- 4 files changed, 20 insertions(+), 16 deletions(-) diff --git a/backend/src/api/bitcoin/bitcoin.routes.ts b/backend/src/api/bitcoin/bitcoin.routes.ts index 545ad510c..d49cd95b2 100644 --- a/backend/src/api/bitcoin/bitcoin.routes.ts +++ b/backend/src/api/bitcoin/bitcoin.routes.ts @@ -55,7 +55,7 @@ class BitcoinRoutes { .get(config.MEMPOOL.API_URL_PREFIX + 'blocks-bulk/:from', this.getBlocksByBulk.bind(this)) .get(config.MEMPOOL.API_URL_PREFIX + 'blocks-bulk/:from/:to', this.getBlocksByBulk.bind(this)) .post(config.MEMPOOL.API_URL_PREFIX + 'prevouts', this.$getPrevouts) - .post(config.MEMPOOL.API_URL_PREFIX + 'cpfp', this.getCpfpLocalTx) + .post(config.MEMPOOL.API_URL_PREFIX + 'cpfp', this.getCpfpLocalTxs) // Temporarily add txs/package endpoint for all backends until esplora supports it .post(config.MEMPOOL.API_URL_PREFIX + 'txs/package', this.$submitPackage) ; @@ -989,25 +989,30 @@ class BitcoinRoutes { } } - private getCpfpLocalTx(req: Request, res: Response) { + private getCpfpLocalTxs(req: Request, res: Response) { try { - const tx = req.body; + const transactions = req.body; - if ( + if (!Array.isArray(transactions) || transactions.some(tx => !tx || typeof tx !== 'object' || - !tx.txid || typeof tx.txid !== 'string' || + !/^[a-fA-F0-9]{64}$/.test(tx.txid) || typeof tx.weight !== 'number' || typeof tx.sigops !== 'number' || typeof tx.fee !== 'number' || !Array.isArray(tx.vin) || !Array.isArray(tx.vout) - ) { - handleError(req, res, 400, 'Invalid transaction format'); + )) { + handleError(req, res, 400, 'Invalid transactions format'); return; } - const cpfpInfo = calculateLocalTxCpfp(tx, mempool.getMempool()); - res.json(cpfpInfo); + if (transactions.length > 1) { + handleError(req, res, 400, 'More than one transaction is not supported yet'); + return; + } + + const cpfpInfo = calculateLocalTxCpfp(transactions[0], mempool.getMempool()); + res.json([cpfpInfo]); } catch (e) { handleError(req, res, 500, 'Failed to calculate CPFP info'); diff --git a/backend/src/api/cpfp.ts b/backend/src/api/cpfp.ts index 3421d9c7a..2b9f90542 100644 --- a/backend/src/api/cpfp.ts +++ b/backend/src/api/cpfp.ts @@ -222,7 +222,6 @@ export function calculateMempoolTxCpfp(tx: MempoolTransactionExtended, mempool: }; } - /** * Takes an unbroadcasted transaction and a copy of the current mempool, and calculates an estimate * of the CPFP data if the transaction were to enter the mempool. This only returns potential ancerstors diff --git a/frontend/src/app/components/transaction/transaction-raw.component.ts b/frontend/src/app/components/transaction/transaction-raw.component.ts index 80f3eeb93..8917dce87 100644 --- a/frontend/src/app/components/transaction/transaction-raw.component.ts +++ b/frontend/src/app/components/transaction/transaction-raw.component.ts @@ -154,17 +154,17 @@ export class TransactionRawComponent implements OnInit, OnDestroy { if (this.hasPrevouts && this.fetchCpfp) { try { this.isLoadingCpfpInfo = true; - const cpfpInfo: CpfpInfo = await firstValueFrom(this.apiService.getCpfpLocalTx$({ + const cpfpInfo: CpfpInfo[] = await firstValueFrom(this.apiService.getCpfpLocalTx$([{ txid: transaction.txid, weight: transaction.weight, sigops: transaction.sigops, fee: transaction.fee, vin: transaction.vin, vout: transaction.vout - })); + }])); - if (cpfpInfo && cpfpInfo.ancestors.length > 0) { - const { ancestors, effectiveFeePerVsize } = cpfpInfo; + if (cpfpInfo?.[0]?.ancestors?.length) { + const { ancestors, effectiveFeePerVsize } = cpfpInfo[0]; transaction.effectiveFeePerVsize = effectiveFeePerVsize; this.cpfpInfo = { ancestors, effectiveFeePerVsize }; this.hasCpfp = true; diff --git a/frontend/src/app/services/api.service.ts b/frontend/src/app/services/api.service.ts index 698eede91..d958bfa25 100644 --- a/frontend/src/app/services/api.service.ts +++ b/frontend/src/app/services/api.service.ts @@ -569,8 +569,8 @@ export class ApiService { return this.httpClient.post(this.apiBaseUrl + this.apiBasePath + '/api/v1/prevouts', outpoints); } - getCpfpLocalTx$(tx: any): Observable { - return this.httpClient.post(this.apiBaseUrl + this.apiBasePath + '/api/v1/cpfp', tx); + getCpfpLocalTx$(tx: any[]): Observable { + return this.httpClient.post(this.apiBaseUrl + this.apiBasePath + '/api/v1/cpfp', tx); } // Cache methods From 860bc7d14db79b9e64632d17bc479fa63cbd78e8 Mon Sep 17 00:00:00 2001 From: natsoni Date: Thu, 9 Jan 2025 14:35:34 +0100 Subject: [PATCH 12/35] Merge calculateMempoolTxCpfp and calculateLocalTxCpfp --- backend/src/api/bitcoin/bitcoin.routes.ts | 4 +- backend/src/api/cpfp.ts | 60 +++++++++-------------- 2 files changed, 24 insertions(+), 40 deletions(-) diff --git a/backend/src/api/bitcoin/bitcoin.routes.ts b/backend/src/api/bitcoin/bitcoin.routes.ts index d49cd95b2..cb1bb0696 100644 --- a/backend/src/api/bitcoin/bitcoin.routes.ts +++ b/backend/src/api/bitcoin/bitcoin.routes.ts @@ -19,7 +19,7 @@ import bitcoinClient from './bitcoin-client'; import difficultyAdjustment from '../difficulty-adjustment'; import transactionRepository from '../../repositories/TransactionRepository'; import rbfCache from '../rbf-cache'; -import { calculateMempoolTxCpfp, calculateLocalTxCpfp } from '../cpfp'; +import { calculateMempoolTxCpfp } from '../cpfp'; import { handleError } from '../../utils/api'; const TXID_REGEX = /^[a-f0-9]{64}$/i; @@ -1011,7 +1011,7 @@ class BitcoinRoutes { return; } - const cpfpInfo = calculateLocalTxCpfp(transactions[0], mempool.getMempool()); + const cpfpInfo = calculateMempoolTxCpfp(transactions[0], mempool.getMempool(), true); res.json([cpfpInfo]); } catch (e) { diff --git a/backend/src/api/cpfp.ts b/backend/src/api/cpfp.ts index 2b9f90542..953664fcc 100644 --- a/backend/src/api/cpfp.ts +++ b/backend/src/api/cpfp.ts @@ -167,8 +167,10 @@ export function calculateGoodBlockCpfp(height: number, transactions: MempoolTran /** * Takes a mempool transaction and a copy of the current mempool, and calculates the CPFP data for * that transaction (and all others in the same cluster) + * If the passed transaction is not guaranteed to be in the mempool, set localTx to true: this will + * prevent updating the CPFP data of other transactions in the cluster */ -export function calculateMempoolTxCpfp(tx: MempoolTransactionExtended, mempool: { [txid: string]: MempoolTransactionExtended }): CpfpInfo { +export function calculateMempoolTxCpfp(tx: MempoolTransactionExtended, mempool: { [txid: string]: MempoolTransactionExtended }, localTx: boolean = false): CpfpInfo { if (tx.cpfpUpdated && Date.now() < (tx.cpfpUpdated + CPFP_UPDATE_INTERVAL)) { tx.cpfpDirty = false; return { @@ -198,17 +200,26 @@ export function calculateMempoolTxCpfp(tx: MempoolTransactionExtended, mempool: totalFee += tx.fees.base; } const effectiveFeePerVsize = totalFee / totalVsize; - for (const tx of cluster.values()) { - mempool[tx.txid].effectiveFeePerVsize = effectiveFeePerVsize; - mempool[tx.txid].ancestors = Array.from(tx.ancestors.values()).map(tx => ({ txid: tx.txid, weight: tx.weight, fee: tx.fees.base })); - mempool[tx.txid].descendants = Array.from(cluster.values()).filter(entry => entry.txid !== tx.txid && !tx.ancestors.has(entry.txid)).map(tx => ({ txid: tx.txid, weight: tx.weight, fee: tx.fees.base })); - mempool[tx.txid].bestDescendant = null; - mempool[tx.txid].cpfpChecked = true; - mempool[tx.txid].cpfpDirty = true; - mempool[tx.txid].cpfpUpdated = Date.now(); - } - tx = mempool[tx.txid]; + if (localTx) { + tx.effectiveFeePerVsize = effectiveFeePerVsize; + tx.ancestors = Array.from(cluster.get(tx.txid)?.ancestors.values() || []).map(ancestor => ({ txid: ancestor.txid, weight: ancestor.weight, fee: ancestor.fees.base })); + tx.descendants = Array.from(cluster.values()).filter(entry => entry.txid !== tx.txid && !cluster.get(tx.txid)?.ancestors.has(entry.txid)).map(tx => ({ txid: tx.txid, weight: tx.weight, fee: tx.fees.base })); + tx.bestDescendant = null; + } else { + for (const tx of cluster.values()) { + mempool[tx.txid].effectiveFeePerVsize = effectiveFeePerVsize; + mempool[tx.txid].ancestors = Array.from(tx.ancestors.values()).map(tx => ({ txid: tx.txid, weight: tx.weight, fee: tx.fees.base })); + mempool[tx.txid].descendants = Array.from(cluster.values()).filter(entry => entry.txid !== tx.txid && !tx.ancestors.has(entry.txid)).map(tx => ({ txid: tx.txid, weight: tx.weight, fee: tx.fees.base })); + mempool[tx.txid].bestDescendant = null; + mempool[tx.txid].cpfpChecked = true; + mempool[tx.txid].cpfpDirty = true; + mempool[tx.txid].cpfpUpdated = Date.now(); + } + + tx = mempool[tx.txid]; + + } return { ancestors: tx.ancestors || [], @@ -222,33 +233,6 @@ export function calculateMempoolTxCpfp(tx: MempoolTransactionExtended, mempool: }; } -/** - * Takes an unbroadcasted transaction and a copy of the current mempool, and calculates an estimate - * of the CPFP data if the transaction were to enter the mempool. This only returns potential ancerstors - * and effective fee rate, and does not update the CPFP data of other transactions in the cluster. - */ -export function calculateLocalTxCpfp(tx: MempoolTransactionExtended, mempool: { [txid: string]: MempoolTransactionExtended }): CpfpInfo { - const ancestorMap = new Map(); - const graphTx = convertToGraphTx(tx, memPool.getSpendMap()); - ancestorMap.set(tx.txid, graphTx); - - const allRelatives = expandRelativesGraph(mempool, ancestorMap, memPool.getSpendMap()); - const relativesMap = initializeRelatives(allRelatives); - const cluster = calculateCpfpCluster(tx.txid, relativesMap); - - let totalVsize = 0; - let totalFee = 0; - for (const tx of cluster.values()) { - totalVsize += tx.vsize; - totalFee += tx.fees.base; - } - - return { - ancestors: Array.from(cluster.get(tx.txid)?.ancestors.values() || []).map(ancestor => ({ txid: ancestor.txid, weight: ancestor.weight, fee: ancestor.fees.base })), - effectiveFeePerVsize: totalFee / totalVsize - } -} - /** * Given a root transaction and a list of in-mempool ancestors, * Calculate the CPFP cluster From 5e0cbb084a391659b5872e090a54b55873d620c0 Mon Sep 17 00:00:00 2001 From: nymkappa <1612910616@pm.me> Date: Fri, 7 Feb 2025 11:34:18 +0100 Subject: [PATCH 13/35] add new fa icon --- frontend/src/app/shared/shared.module.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frontend/src/app/shared/shared.module.ts b/frontend/src/app/shared/shared.module.ts index 76184113f..d937e6bbb 100644 --- a/frontend/src/app/shared/shared.module.ts +++ b/frontend/src/app/shared/shared.module.ts @@ -7,7 +7,7 @@ import { faFilter, faAngleDown, faAngleUp, faAngleRight, faAngleLeft, faBolt, fa faFileAlt, faRedoAlt, faArrowAltCircleRight, faExternalLinkAlt, faBook, faListUl, faDownload, faQrcode, faArrowRightArrowLeft, faArrowsRotate, faCircleLeft, faFastForward, faWallet, faUserClock, faWrench, faUserFriends, faQuestionCircle, faHistory, faSignOutAlt, faKey, faSuitcase, faIdCardAlt, faNetworkWired, faUserCheck, faCircleCheck, faUserCircle, faCheck, faRocket, faScaleBalanced, faHourglassStart, faHourglassHalf, faHourglassEnd, faWandMagicSparkles, faFaucetDrip, faTimeline, - faCircleXmark, faCalendarCheck, faMoneyBillTrendUp, faRobot, faShareNodes, faCreditCard } from '@fortawesome/free-solid-svg-icons'; + faCircleXmark, faCalendarCheck, faMoneyBillTrendUp, faRobot, faShareNodes, faCreditCard, faMicroscope } from '@fortawesome/free-solid-svg-icons'; import { InfiniteScrollModule } from 'ngx-infinite-scroll'; import { MenuComponent } from '@components/menu/menu.component'; import { PreviewTitleComponent } from '@components/master-page-preview/preview-title.component'; @@ -464,5 +464,6 @@ export class SharedModule { library.addIcons(faRobot); library.addIcons(faShareNodes); library.addIcons(faCreditCard); + library.addIcons(faMicroscope); } } From 1779c672e359b0d678f49a139d64becdaccfcdf4 Mon Sep 17 00:00:00 2001 From: natsoni Date: Fri, 7 Feb 2025 16:52:46 +0100 Subject: [PATCH 14/35] Don't tweak scrollLeft if time is left to right --- .../app/components/start/start.component.ts | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/frontend/src/app/components/start/start.component.ts b/frontend/src/app/components/start/start.component.ts index 31317cab5..7db1a75e1 100644 --- a/frontend/src/app/components/start/start.component.ts +++ b/frontend/src/app/components/start/start.component.ts @@ -194,14 +194,16 @@ export class StartComponent implements OnInit, AfterViewChecked, OnDestroy { applyScrollLeft(): void { if (this.blockchainContainer?.nativeElement?.scrollWidth) { let lastScrollLeft = null; - while (this.scrollLeft < 0 && this.shiftPagesForward() && lastScrollLeft !== this.scrollLeft) { - lastScrollLeft = this.scrollLeft; - this.scrollLeft += this.pageWidth; - } - lastScrollLeft = null; - while (this.scrollLeft > this.blockchainContainer.nativeElement.scrollWidth && this.shiftPagesBack() && lastScrollLeft !== this.scrollLeft) { - lastScrollLeft = this.scrollLeft; - this.scrollLeft -= this.pageWidth; + if (!this.timeLtr) { + while (this.scrollLeft < 0 && this.shiftPagesForward() && lastScrollLeft !== this.scrollLeft) { + lastScrollLeft = this.scrollLeft; + this.scrollLeft += this.pageWidth; + } + lastScrollLeft = null; + while (this.scrollLeft > this.blockchainContainer.nativeElement.scrollWidth && this.shiftPagesBack() && lastScrollLeft !== this.scrollLeft) { + lastScrollLeft = this.scrollLeft; + this.scrollLeft -= this.pageWidth; + } } this.blockchainContainer.nativeElement.scrollLeft = this.scrollLeft; } From 6340dc571c6b5ae99eaadd275beddbe49502c3f2 Mon Sep 17 00:00:00 2001 From: natsoni Date: Sun, 9 Feb 2025 18:13:06 +0100 Subject: [PATCH 15/35] Don't show ETA on unbroadcasted txs, and placeholder for missing fee --- .../transaction-details.component.html | 8 +++---- .../transaction-details.component.ts | 1 + .../transaction-raw.component.html | 4 +--- .../transaction/transaction-raw.component.ts | 23 +------------------ 4 files changed, 7 insertions(+), 29 deletions(-) diff --git a/frontend/src/app/components/transaction/transaction-details/transaction-details.component.html b/frontend/src/app/components/transaction/transaction-details/transaction-details.component.html index c5609882c..78bba955c 100644 --- a/frontend/src/app/components/transaction/transaction-details/transaction-details.component.html +++ b/frontend/src/app/components/transaction/transaction-details/transaction-details.component.html @@ -153,7 +153,7 @@ @if (!isLoadingTx) { - @if (!replaced && !isCached) { + @if (!replaced && !isCached && !unbroadcasted) { ETA @@ -184,7 +184,7 @@ } - } @else { + } @else if (!unbroadcasted){ } @@ -213,11 +213,11 @@ @if (!isLoadingTx) { Fee - {{ tx.fee | number }} sats + {{ (tx.fee | number) ?? '-' }} sats @if (isAcceleration && accelerationInfo?.bidBoost ?? tx.feeDelta > 0) { +{{ accelerationInfo?.bidBoost ?? tx.feeDelta | number }} sats } - + } @else { diff --git a/frontend/src/app/components/transaction/transaction-details/transaction-details.component.ts b/frontend/src/app/components/transaction/transaction-details/transaction-details.component.ts index 2b539c154..c6260da48 100644 --- a/frontend/src/app/components/transaction/transaction-details/transaction-details.component.ts +++ b/frontend/src/app/components/transaction/transaction-details/transaction-details.component.ts @@ -38,6 +38,7 @@ export class TransactionDetailsComponent implements OnInit { @Input() replaced: boolean; @Input() isCached: boolean; @Input() ETA$: Observable; + @Input() unbroadcasted: boolean; @Output() accelerateClicked = new EventEmitter(); @Output() toggleCpfp$ = new EventEmitter(); diff --git a/frontend/src/app/components/transaction/transaction-raw.component.html b/frontend/src/app/components/transaction/transaction-raw.component.html index b6286779a..450e18ecd 100644 --- a/frontend/src/app/components/transaction/transaction-raw.component.html +++ b/frontend/src/app/components/transaction/transaction-raw.component.html @@ -58,6 +58,7 @@ } ; mempoolBlocksSubscription: Subscription; constructor( public route: ActivatedRoute, public router: Router, public stateService: StateService, - public etaService: EtaService, public electrsApi: ElectrsApiService, public websocketService: WebsocketService, public formBuilder: UntypedFormBuilder, @@ -195,24 +192,6 @@ export class TransactionRawComponent implements OnInit, OnDestroy { }); this.setGraphSize(); - this.ETA$ = combineLatest([ - this.stateService.mempoolBlocks$.pipe(startWith(null)), - this.stateService.difficultyAdjustment$.pipe(startWith(null)), - ]).pipe( - map(([mempoolBlocks, da]) => { - return this.etaService.calculateETA( - this.stateService.network, - this.transaction, - mempoolBlocks, - null, - da, - null, - null, - null - ); - }) - ); - this.mempoolBlocksSubscription = this.stateService.mempoolBlocks$.subscribe(() => { if (this.transaction) { this.stateService.markBlock$.next({ From 27c28f939c31e50c3aad0dc4e014dab3ea4ecfec Mon Sep 17 00:00:00 2001 From: Mononaut Date: Mon, 10 Feb 2025 03:47:05 +0000 Subject: [PATCH 16/35] misc unfurl preview fixes --- .../address/address-preview.component.ts | 10 +++--- .../block/block-preview.component.html | 2 +- .../block/block-preview.component.ts | 23 ++++++++------ .../components/pool/pool-preview.component.ts | 28 +++++++++-------- .../transaction-preview.component.ts | 20 ++++++------ .../wallet/wallet-preview.component.ts | 20 ++++++------ .../channel/channel-preview.component.ts | 14 +++++---- .../group/group-preview.component.ts | 18 ++++++----- .../lightning/node/node-preview.component.ts | 14 +++++---- .../nodes-per-isp-preview.component.ts | 14 +++++---- .../src/app/services/opengraph.service.ts | 31 ++++++++++++------- 11 files changed, 111 insertions(+), 83 deletions(-) diff --git a/frontend/src/app/components/address/address-preview.component.ts b/frontend/src/app/components/address/address-preview.component.ts index bcc328787..1106d6096 100644 --- a/frontend/src/app/components/address/address-preview.component.ts +++ b/frontend/src/app/components/address/address-preview.component.ts @@ -36,6 +36,8 @@ export class AddressPreviewComponent implements OnInit, OnDestroy { sent = 0; totalUnspent = 0; + ogSession: number; + constructor( private route: ActivatedRoute, private electrsApiService: ElectrsApiService, @@ -58,7 +60,7 @@ export class AddressPreviewComponent implements OnInit, OnDestroy { .pipe( switchMap((params: ParamMap) => { this.rawAddress = params.get('id') || ''; - this.openGraphService.waitFor('address-data-' + this.rawAddress); + this.ogSession = this.openGraphService.waitFor('address-data-' + this.rawAddress); this.error = undefined; this.isLoadingAddress = true; this.loadedConfirmedTxCount = 0; @@ -79,7 +81,7 @@ export class AddressPreviewComponent implements OnInit, OnDestroy { this.isLoadingAddress = false; this.error = err; console.log(err); - this.openGraphService.fail('address-data-' + this.rawAddress); + this.openGraphService.fail({ event: 'address-data-' + this.rawAddress, sessionId: this.ogSession }); return of(null); }) ); @@ -97,7 +99,7 @@ export class AddressPreviewComponent implements OnInit, OnDestroy { this.address = address; this.updateChainStats(); this.isLoadingAddress = false; - this.openGraphService.waitOver('address-data-' + this.rawAddress); + this.openGraphService.waitOver({ event: 'address-data-' + this.rawAddress, sessionId: this.ogSession }); }) ) .subscribe(() => {}, @@ -105,7 +107,7 @@ export class AddressPreviewComponent implements OnInit, OnDestroy { console.log(error); this.error = error; this.isLoadingAddress = false; - this.openGraphService.fail('address-data-' + this.rawAddress); + this.openGraphService.fail({ event: 'address-data-' + this.rawAddress, sessionId: this.ogSession }); } ); } diff --git a/frontend/src/app/components/block/block-preview.component.html b/frontend/src/app/components/block/block-preview.component.html index 036ab8399..6ea8e3387 100644 --- a/frontend/src/app/components/block/block-preview.component.html +++ b/frontend/src/app/components/block/block-preview.component.html @@ -49,7 +49,7 @@
- + Miner diff --git a/frontend/src/app/components/block/block-preview.component.ts b/frontend/src/app/components/block/block-preview.component.ts index 42a47f3c4..f5b31e846 100644 --- a/frontend/src/app/components/block/block-preview.component.ts +++ b/frontend/src/app/components/block/block-preview.component.ts @@ -35,6 +35,8 @@ export class BlockPreviewComponent implements OnInit, OnDestroy { overviewSubscription: Subscription; networkChangedSubscription: Subscription; + ogSession: number; + @ViewChild('blockGraph') blockGraph: BlockOverviewGraphComponent; constructor( @@ -53,8 +55,8 @@ export class BlockPreviewComponent implements OnInit, OnDestroy { const block$ = this.route.paramMap.pipe( switchMap((params: ParamMap) => { this.rawId = params.get('id') || ''; - this.openGraphService.waitFor('block-viz-' + this.rawId); - this.openGraphService.waitFor('block-data-' + this.rawId); + this.ogSession = this.openGraphService.waitFor('block-viz-' + this.rawId); + this.ogSession = this.openGraphService.waitFor('block-data-' + this.rawId); const blockHash: string = params.get('id') || ''; this.block = undefined; @@ -86,8 +88,8 @@ export class BlockPreviewComponent implements OnInit, OnDestroy { catchError((err) => { this.error = err; this.seoService.logSoft404(); - this.openGraphService.fail('block-data-' + this.rawId); - this.openGraphService.fail('block-viz-' + this.rawId); + this.openGraphService.fail({ event: 'block-data-' + this.rawId, sessionId: this.ogSession }); + this.openGraphService.fail({ event: 'block-viz-' + this.rawId, sessionId: this.ogSession }); return of(null); }), ); @@ -114,7 +116,7 @@ export class BlockPreviewComponent implements OnInit, OnDestroy { this.isLoadingOverview = true; this.overviewError = null; - this.openGraphService.waitOver('block-data-' + this.rawId); + this.openGraphService.waitOver({ event: 'block-data-' + this.rawId, sessionId: this.ogSession }); }), throttleTime(50, asyncScheduler, { leading: true, trailing: true }), shareReplay({ bufferSize: 1, refCount: true }) @@ -129,7 +131,7 @@ export class BlockPreviewComponent implements OnInit, OnDestroy { .pipe( catchError((err) => { this.overviewError = err; - this.openGraphService.fail('block-viz-' + this.rawId); + this.openGraphService.fail({ event: 'block-viz-' + this.rawId, sessionId: this.ogSession }); return of([]); }), switchMap((transactions) => { @@ -138,7 +140,8 @@ export class BlockPreviewComponent implements OnInit, OnDestroy { ), this.stateService.env.ACCELERATOR === true && block.height > 819500 ? this.servicesApiService.getAllAccelerationHistory$({ blockHeight: block.height }) - .pipe(catchError(() => { + .pipe( + catchError(() => { return of([]); })) : of([]) @@ -169,8 +172,8 @@ export class BlockPreviewComponent implements OnInit, OnDestroy { this.error = error; this.isLoadingOverview = false; this.seoService.logSoft404(); - this.openGraphService.fail('block-viz-' + this.rawId); - this.openGraphService.fail('block-data-' + this.rawId); + this.openGraphService.fail({ event: 'block-viz-' + this.rawId, sessionId: this.ogSession }); + this.openGraphService.fail({ event: 'block-data-' + this.rawId, sessionId: this.ogSession }); if (this.blockGraph) { this.blockGraph.destroy(); } @@ -196,6 +199,6 @@ export class BlockPreviewComponent implements OnInit, OnDestroy { } onGraphReady(): void { - this.openGraphService.waitOver('block-viz-' + this.rawId); + this.openGraphService.waitOver({ event: 'block-viz-' + this.rawId, sessionId: this.ogSession }); } } diff --git a/frontend/src/app/components/pool/pool-preview.component.ts b/frontend/src/app/components/pool/pool-preview.component.ts index 93077120d..7478e5f6f 100644 --- a/frontend/src/app/components/pool/pool-preview.component.ts +++ b/frontend/src/app/components/pool/pool-preview.component.ts @@ -30,6 +30,8 @@ export class PoolPreviewComponent implements OnInit { slug: string = undefined; + ogSession: number; + constructor( @Inject(LOCALE_ID) public locale: string, private apiService: ApiService, @@ -47,22 +49,22 @@ export class PoolPreviewComponent implements OnInit { this.isLoading = true; this.imageLoaded = false; this.slug = slug; - this.openGraphService.waitFor('pool-hash-' + this.slug); - this.openGraphService.waitFor('pool-stats-' + this.slug); - this.openGraphService.waitFor('pool-chart-' + this.slug); - this.openGraphService.waitFor('pool-img-' + this.slug); + this.ogSession = this.openGraphService.waitFor('pool-hash-' + this.slug); + this.ogSession = this.openGraphService.waitFor('pool-stats-' + this.slug); + this.ogSession = this.openGraphService.waitFor('pool-chart-' + this.slug); + this.ogSession = this.openGraphService.waitFor('pool-img-' + this.slug); return this.apiService.getPoolHashrate$(this.slug) .pipe( switchMap((data) => { this.isLoading = false; this.prepareChartOptions(data.map(val => [val.timestamp * 1000, val.avgHashrate])); - this.openGraphService.waitOver('pool-hash-' + this.slug); + this.openGraphService.waitOver({ event: 'pool-hash-' + this.slug, sessionId: this.ogSession }); return [slug]; }), catchError(() => { this.isLoading = false; this.seoService.logSoft404(); - this.openGraphService.fail('pool-hash-' + this.slug); + this.openGraphService.fail({ event: 'pool-hash-' + this.slug, sessionId: this.ogSession }); return of([slug]); }) ); @@ -72,7 +74,7 @@ export class PoolPreviewComponent implements OnInit { catchError(() => { this.isLoading = false; this.seoService.logSoft404(); - this.openGraphService.fail('pool-stats-' + this.slug); + this.openGraphService.fail({ event: 'pool-stats-' + this.slug, sessionId: this.ogSession }); return of(null); }) ); @@ -90,11 +92,11 @@ export class PoolPreviewComponent implements OnInit { } poolStats.pool.regexes = regexes.slice(0, -3); - this.openGraphService.waitOver('pool-stats-' + this.slug); + this.openGraphService.waitOver({ event: 'pool-stats-' + this.slug, sessionId: this.ogSession }); const logoSrc = `/resources/mining-pools/` + poolStats.pool.slug + '.svg'; if (logoSrc === this.lastImgSrc) { - this.openGraphService.waitOver('pool-img-' + this.slug); + this.openGraphService.waitOver({ event: 'pool-img-' + this.slug, sessionId: this.ogSession }); } this.lastImgSrc = logoSrc; return Object.assign({ @@ -103,7 +105,7 @@ export class PoolPreviewComponent implements OnInit { }), catchError(() => { this.isLoading = false; - this.openGraphService.fail('pool-stats-' + this.slug); + this.openGraphService.fail({ event: 'pool-stats-' + this.slug, sessionId: this.ogSession }); return of(null); }) ); @@ -170,16 +172,16 @@ export class PoolPreviewComponent implements OnInit { } onChartReady(): void { - this.openGraphService.waitOver('pool-chart-' + this.slug); + this.openGraphService.waitOver({ event: 'pool-chart-' + this.slug, sessionId: this.ogSession }); } onImageLoad(): void { this.imageLoaded = true; - this.openGraphService.waitOver('pool-img-' + this.slug); + this.openGraphService.waitOver({ event: 'pool-img-' + this.slug, sessionId: this.ogSession }); } onImageFail(): void { this.imageLoaded = false; - this.openGraphService.waitOver('pool-img-' + this.slug); + this.openGraphService.waitOver({ event: 'pool-img-' + this.slug, sessionId: this.ogSession }); } } diff --git a/frontend/src/app/components/transaction/transaction-preview.component.ts b/frontend/src/app/components/transaction/transaction-preview.component.ts index 0c51e0064..4746f9de7 100644 --- a/frontend/src/app/components/transaction/transaction-preview.component.ts +++ b/frontend/src/app/components/transaction/transaction-preview.component.ts @@ -43,6 +43,8 @@ export class TransactionPreviewComponent implements OnInit, OnDestroy { opReturns: Vout[]; extraData: 'none' | 'coinbase' | 'opreturn'; + ogSession: number; + constructor( private route: ActivatedRoute, private electrsApiService: ElectrsApiService, @@ -75,7 +77,7 @@ export class TransactionPreviewComponent implements OnInit, OnDestroy { ) .subscribe((cpfpInfo) => { this.cpfpInfo = cpfpInfo; - this.openGraphService.waitOver('cpfp-data-' + this.txId); + this.openGraphService.waitOver({ event: 'cpfp-data-' + this.txId, sessionId: this.ogSession }); }); this.subscription = this.route.paramMap @@ -83,8 +85,8 @@ export class TransactionPreviewComponent implements OnInit, OnDestroy { switchMap((params: ParamMap) => { const urlMatch = (params.get('id') || '').split(':'); this.txId = urlMatch[0]; - this.openGraphService.waitFor('tx-data-' + this.txId); - this.openGraphService.waitFor('tx-time-' + this.txId); + this.ogSession = this.openGraphService.waitFor('tx-data-' + this.txId); + this.ogSession = this.openGraphService.waitFor('tx-time-' + this.txId); this.seoService.setTitle( $localize`:@@bisq.transaction.browser-title:Transaction: ${this.txId}:INTERPOLATION:` ); @@ -138,7 +140,7 @@ export class TransactionPreviewComponent implements OnInit, OnDestroy { .subscribe((tx: Transaction) => { if (!tx) { this.seoService.logSoft404(); - this.openGraphService.fail('tx-data-' + this.txId); + this.openGraphService.fail({ event: 'tx-data-' + this.txId, sessionId: this.ogSession }); return; } @@ -155,10 +157,10 @@ export class TransactionPreviewComponent implements OnInit, OnDestroy { if (tx.status.confirmed) { this.transactionTime = tx.status.block_time; - this.openGraphService.waitOver('tx-time-' + this.txId); + this.openGraphService.waitOver({ event: 'tx-time-' + this.txId, sessionId: this.ogSession }); } else if (!tx.status.confirmed && tx.firstSeen) { this.transactionTime = tx.firstSeen; - this.openGraphService.waitOver('tx-time-' + this.txId); + this.openGraphService.waitOver({ event: 'tx-time-' + this.txId, sessionId: this.ogSession }); } else { this.getTransactionTime(); } @@ -184,11 +186,11 @@ export class TransactionPreviewComponent implements OnInit, OnDestroy { } } - this.openGraphService.waitOver('tx-data-' + this.txId); + this.openGraphService.waitOver({ event: 'tx-data-' + this.txId, sessionId: this.ogSession }); }, (error) => { this.seoService.logSoft404(); - this.openGraphService.fail('tx-data-' + this.txId); + this.openGraphService.fail({ event: 'tx-data-' + this.txId, sessionId: this.ogSession }); this.error = error; this.isLoadingTx = false; } @@ -205,7 +207,7 @@ export class TransactionPreviewComponent implements OnInit, OnDestroy { ) .subscribe((transactionTimes) => { this.transactionTime = transactionTimes[0]; - this.openGraphService.waitOver('tx-time-' + this.txId); + this.openGraphService.waitOver({ event: 'tx-time-' + this.txId, sessionId: this.ogSession }); }); } diff --git a/frontend/src/app/components/wallet/wallet-preview.component.ts b/frontend/src/app/components/wallet/wallet-preview.component.ts index 0387822aa..bbf8ecf7a 100644 --- a/frontend/src/app/components/wallet/wallet-preview.component.ts +++ b/frontend/src/app/components/wallet/wallet-preview.component.ts @@ -125,6 +125,8 @@ export class WalletPreviewComponent implements OnInit, OnDestroy { sent = 0; chainBalance = 0; + ogSession: number; + constructor( private route: ActivatedRoute, private stateService: StateService, @@ -141,9 +143,9 @@ export class WalletPreviewComponent implements OnInit, OnDestroy { map((params: ParamMap) => params.get('wallet') as string), tap((walletName: string) => { this.walletName = walletName; - this.openGraphService.waitFor('wallet-addresses-' + this.walletName); - this.openGraphService.waitFor('wallet-data-' + this.walletName); - this.openGraphService.waitFor('wallet-txs-' + this.walletName); + this.ogSession = this.openGraphService.waitFor('wallet-addresses-' + this.walletName); + this.ogSession = this.openGraphService.waitFor('wallet-data-' + this.walletName); + this.ogSession = this.openGraphService.waitFor('wallet-txs-' + this.walletName); this.seoService.setTitle($localize`:@@wallet.component.browser-title:Wallet: ${walletName}:INTERPOLATION:`); this.seoService.setDescription($localize`:@@meta.description.bitcoin.wallet:See mempool transactions, confirmed transactions, balance, and more for ${this.stateService.network==='liquid'||this.stateService.network==='liquidtestnet'?'Liquid':'Bitcoin'}${seoDescriptionNetwork(this.stateService.network)} wallet ${walletName}:INTERPOLATION:.`); }), @@ -152,9 +154,9 @@ export class WalletPreviewComponent implements OnInit, OnDestroy { this.error = err; this.seoService.logSoft404(); console.log(err); - this.openGraphService.fail('wallet-addresses-' + this.walletName); - this.openGraphService.fail('wallet-data-' + this.walletName); - this.openGraphService.fail('wallet-txs-' + this.walletName); + this.openGraphService.fail({ event: 'wallet-addresses-' + this.walletName, sessionId: this.ogSession }); + this.openGraphService.fail({ event: 'wallet-data-' + this.walletName, sessionId: this.ogSession }); + this.openGraphService.fail({ event: 'wallet-txs-' + this.walletName, sessionId: this.ogSession }); return of({}); }) )), @@ -185,13 +187,13 @@ export class WalletPreviewComponent implements OnInit, OnDestroy { this.walletSubscription = this.walletAddresses$.subscribe(wallet => { this.addressStrings = Object.keys(wallet); this.addresses = Object.values(wallet); - this.openGraphService.waitOver('wallet-addresses-' + this.walletName); + this.openGraphService.waitOver({ event: 'wallet-addresses-' + this.walletName, sessionId: this.ogSession }); }); this.walletSummary$ = this.wallet$.pipe( map(wallet => this.deduplicateWalletTransactions(Object.values(wallet).flatMap(address => address.transactions))), tap(() => { - this.openGraphService.waitOver('wallet-txs-' + this.walletName); + this.openGraphService.waitOver({ event: 'wallet-txs-' + this.walletName, sessionId: this.ogSession }); }) ); @@ -209,7 +211,7 @@ export class WalletPreviewComponent implements OnInit, OnDestroy { ); }), tap(() => { - this.openGraphService.waitOver('wallet-data-' + this.walletName); + this.openGraphService.waitOver({ event: 'wallet-data-' + this.walletName, sessionId: this.ogSession }); }) ); } diff --git a/frontend/src/app/lightning/channel/channel-preview.component.ts b/frontend/src/app/lightning/channel/channel-preview.component.ts index 84a85f9c6..2af0dcd57 100644 --- a/frontend/src/app/lightning/channel/channel-preview.component.ts +++ b/frontend/src/app/lightning/channel/channel-preview.component.ts @@ -18,6 +18,8 @@ export class ChannelPreviewComponent implements OnInit { channelGeo: number[] = []; shortId: string; + ogSession: number; + constructor( private lightningApiService: LightningApiService, private activatedRoute: ActivatedRoute, @@ -30,8 +32,8 @@ export class ChannelPreviewComponent implements OnInit { .pipe( switchMap((params: ParamMap) => { this.shortId = params.get('short_id') || ''; - this.openGraphService.waitFor('channel-map-' + this.shortId); - this.openGraphService.waitFor('channel-data-' + this.shortId); + this.ogSession = this.openGraphService.waitFor('channel-map-' + this.shortId); + this.ogSession = this.openGraphService.waitFor('channel-data-' + this.shortId); this.error = null; this.seoService.setTitle(`Channel: ${params.get('short_id')}`); this.seoService.setDescription($localize`:@@meta.description.lightning.channel:Overview for Lightning channel ${params.get('short_id')}. See channel capacity, the Lightning nodes involved, related on-chain transactions, and more.`); @@ -51,13 +53,13 @@ export class ChannelPreviewComponent implements OnInit { data.node_right.longitude, data.node_right.latitude, ]; } - this.openGraphService.waitOver('channel-data-' + this.shortId); + this.openGraphService.waitOver({ event: 'channel-data-' + this.shortId, sessionId: this.ogSession }); }), catchError((err) => { this.error = err; this.seoService.logSoft404(); - this.openGraphService.fail('channel-map-' + this.shortId); - this.openGraphService.fail('channel-data-' + this.shortId); + this.openGraphService.fail({ event: 'channel-map-' + this.shortId, sessionId: this.ogSession }); + this.openGraphService.fail({ event: 'channel-data-' + this.shortId, sessionId: this.ogSession }); return of(null); }) ); @@ -66,6 +68,6 @@ export class ChannelPreviewComponent implements OnInit { } onMapReady() { - this.openGraphService.waitOver('channel-map-' + this.shortId); + this.openGraphService.waitOver({ event: 'channel-map-' + this.shortId, sessionId: this.ogSession }); } } diff --git a/frontend/src/app/lightning/group/group-preview.component.ts b/frontend/src/app/lightning/group/group-preview.component.ts index 4b8f5ed77..4e7d56bbe 100644 --- a/frontend/src/app/lightning/group/group-preview.component.ts +++ b/frontend/src/app/lightning/group/group-preview.component.ts @@ -22,6 +22,8 @@ export class GroupPreviewComponent implements OnInit { slug: string; groupId: string; + ogSession: number; + constructor( private lightningApiService: LightningApiService, private activatedRoute: ActivatedRoute, @@ -37,8 +39,8 @@ export class GroupPreviewComponent implements OnInit { .pipe( switchMap((params: ParamMap) => { this.slug = params.get('slug'); - this.openGraphService.waitFor('ln-group-map-' + this.slug); - this.openGraphService.waitFor('ln-group-data-' + this.slug); + this.ogSession = this.openGraphService.waitFor('ln-group-map-' + this.slug); + this.ogSession = this.openGraphService.waitFor('ln-group-data-' + this.slug); if (this.slug === 'the-mempool-open-source-project') { this.groupId = 'mempool.space'; @@ -52,8 +54,8 @@ export class GroupPreviewComponent implements OnInit { description: '', }; this.seoService.logSoft404(); - this.openGraphService.fail('ln-group-map-' + this.slug); - this.openGraphService.fail('ln-group-data-' + this.slug); + this.openGraphService.fail({ event: 'ln-group-map-' + this.slug, sessionId: this.ogSession }); + this.openGraphService.fail({ event: 'ln-group-data-' + this.slug, sessionId: this.ogSession }); return of(null); } @@ -99,7 +101,7 @@ export class GroupPreviewComponent implements OnInit { const sumLiquidity = nodes.reduce((partialSum, a) => partialSum + parseInt(a.capacity, 10), 0); const sumChannels = nodes.reduce((partialSum, a) => partialSum + a.opened_channel_count, 0); - this.openGraphService.waitOver('ln-group-data-' + this.slug); + this.openGraphService.waitOver({ event: 'ln-group-data-' + this.slug, sessionId: this.ogSession }); return { nodes: nodes, @@ -109,8 +111,8 @@ export class GroupPreviewComponent implements OnInit { }), catchError(() => { this.seoService.logSoft404(); - this.openGraphService.fail('ln-group-map-' + this.slug); - this.openGraphService.fail('ln-group-data-' + this.slug); + this.openGraphService.fail({ event: 'ln-group-map-' + this.slug, sessionId: this.ogSession }); + this.openGraphService.fail({ event: 'ln-group-data-' + this.slug, sessionId: this.ogSession }); return of({ nodes: [], sumLiquidity: 0, @@ -121,7 +123,7 @@ export class GroupPreviewComponent implements OnInit { } onMapReady(): void { - this.openGraphService.waitOver('ln-group-map-' + this.slug); + this.openGraphService.waitOver({ event: 'ln-group-map-' + this.slug, sessionId: this.ogSession }); } } diff --git a/frontend/src/app/lightning/node/node-preview.component.ts b/frontend/src/app/lightning/node/node-preview.component.ts index 259313de6..7a45ea905 100644 --- a/frontend/src/app/lightning/node/node-preview.component.ts +++ b/frontend/src/app/lightning/node/node-preview.component.ts @@ -27,6 +27,8 @@ export class NodePreviewComponent implements OnInit { publicKeySize = 99; + ogSession: number; + constructor( private lightningApiService: LightningApiService, private activatedRoute: ActivatedRoute, @@ -43,8 +45,8 @@ export class NodePreviewComponent implements OnInit { .pipe( switchMap((params: ParamMap) => { this.publicKey = params.get('public_key'); - this.openGraphService.waitFor('node-map-' + this.publicKey); - this.openGraphService.waitFor('node-data-' + this.publicKey); + this.ogSession = this.openGraphService.waitFor('node-map-' + this.publicKey); + this.ogSession = this.openGraphService.waitFor('node-data-' + this.publicKey); return this.lightningApiService.getNode$(params.get('public_key')); }), map((node) => { @@ -76,15 +78,15 @@ export class NodePreviewComponent implements OnInit { this.socketTypes = Object.keys(socketTypesMap); node.avgCapacity = node.capacity / Math.max(1, node.active_channel_count); - this.openGraphService.waitOver('node-data-' + this.publicKey); + this.openGraphService.waitOver({ event: 'node-data-' + this.publicKey, sessionId: this.ogSession }); return node; }), catchError(err => { this.error = err; this.seoService.logSoft404(); - this.openGraphService.fail('node-map-' + this.publicKey); - this.openGraphService.fail('node-data-' + this.publicKey); + this.openGraphService.fail({ event: 'node-map-' + this.publicKey, sessionId: this.ogSession }); + this.openGraphService.fail({ event: 'node-data-' + this.publicKey, sessionId: this.ogSession }); return [{ alias: this.publicKey, public_key: this.publicKey, @@ -102,6 +104,6 @@ export class NodePreviewComponent implements OnInit { } onMapReady() { - this.openGraphService.waitOver('node-map-' + this.publicKey); + this.openGraphService.waitOver({ event: 'node-map-' + this.publicKey, sessionId: this.ogSession }); } } diff --git a/frontend/src/app/lightning/nodes-per-isp/nodes-per-isp-preview.component.ts b/frontend/src/app/lightning/nodes-per-isp/nodes-per-isp-preview.component.ts index 9fc071eb5..bab34ae8f 100644 --- a/frontend/src/app/lightning/nodes-per-isp/nodes-per-isp-preview.component.ts +++ b/frontend/src/app/lightning/nodes-per-isp/nodes-per-isp-preview.component.ts @@ -19,6 +19,8 @@ export class NodesPerISPPreview implements OnInit { id: string; error: Error; + ogSession: number; + constructor( private apiService: ApiService, private seoService: SeoService, @@ -32,8 +34,8 @@ export class NodesPerISPPreview implements OnInit { switchMap((params: ParamMap) => { this.id = params.get('isp'); this.isp = null; - this.openGraphService.waitFor('isp-map-' + this.id); - this.openGraphService.waitFor('isp-data-' + this.id); + this.ogSession = this.openGraphService.waitFor('isp-map-' + this.id); + this.ogSession = this.openGraphService.waitFor('isp-data-' + this.id); return this.apiService.getNodeForISP$(params.get('isp')); }), map(response => { @@ -75,7 +77,7 @@ export class NodesPerISPPreview implements OnInit { } topCountry.flag = getFlagEmoji(topCountry.iso); - this.openGraphService.waitOver('isp-data-' + this.id); + this.openGraphService.waitOver({ event: 'isp-data-' + this.id, sessionId: this.ogSession }); return { nodes: response.nodes, @@ -87,8 +89,8 @@ export class NodesPerISPPreview implements OnInit { catchError(err => { this.error = err; this.seoService.logSoft404(); - this.openGraphService.fail('isp-map-' + this.id); - this.openGraphService.fail('isp-data-' + this.id); + this.openGraphService.fail({ event: 'isp-map-' + this.id, sessionId: this.ogSession }); + this.openGraphService.fail({ event: 'isp-data-' + this.id, sessionId: this.ogSession }); return of({ nodes: [], sumLiquidity: 0, @@ -100,6 +102,6 @@ export class NodesPerISPPreview implements OnInit { } onMapReady() { - this.openGraphService.waitOver('isp-map-' + this.id); + this.openGraphService.waitOver({ event: 'isp-map-' + this.id, sessionId: this.ogSession }); } } diff --git a/frontend/src/app/services/opengraph.service.ts b/frontend/src/app/services/opengraph.service.ts index e969dd07a..47b9d87d4 100644 --- a/frontend/src/app/services/opengraph.service.ts +++ b/frontend/src/app/services/opengraph.service.ts @@ -12,8 +12,9 @@ import { LanguageService } from '@app/services/language.service'; export class OpenGraphService { network = ''; defaultImageUrl = ''; - previewLoadingEvents = {}; - previewLoadingCount = 0; + previewLoadingEvents = {}; // pending count per event type + previewLoadingCount = 0; // number of unique events pending + sessionId = 1; constructor( private ngZone: NgZone, @@ -45,7 +46,7 @@ export class OpenGraphService { // expose routing method to global scope, so we can access it from the unfurler window['ogService'] = { - loadPage: (path) => { return this.loadPage(path) } + loadPage: (path) => { return this.loadPage(path); } }; } @@ -77,7 +78,7 @@ export class OpenGraphService { } /// register an event that needs to resolve before we can take a screenshot - waitFor(event) { + waitFor(event: string): number { if (!this.previewLoadingEvents[event]) { this.previewLoadingEvents[event] = 1; this.previewLoadingCount++; @@ -85,24 +86,31 @@ export class OpenGraphService { this.previewLoadingEvents[event]++; } this.metaService.updateTag({ property: 'og:preview:loading', content: 'loading'}); + return this.sessionId; } // mark an event as resolved // if all registered events have resolved, signal we are ready for a screenshot - waitOver(event) { + waitOver({ event, sessionId }: { event: string, sessionId: number }) { + if (sessionId !== this.sessionId) { + return; + } if (this.previewLoadingEvents[event]) { this.previewLoadingEvents[event]--; if (this.previewLoadingEvents[event] === 0 && this.previewLoadingCount > 0) { - delete this.previewLoadingEvents[event] + delete this.previewLoadingEvents[event]; this.previewLoadingCount--; } - if (this.previewLoadingCount === 0) { - this.metaService.updateTag({ property: 'og:preview:ready', content: 'ready'}); - } + } + if (this.previewLoadingCount === 0) { + this.metaService.updateTag({ property: 'og:preview:ready', content: 'ready'}); } } - fail(event) { + fail({ event, sessionId }: { event: string, sessionId: number }) { + if (sessionId !== this.sessionId) { + return; + } if (this.previewLoadingEvents[event]) { this.metaService.updateTag({ property: 'og:preview:fail', content: 'fail'}); } @@ -111,6 +119,7 @@ export class OpenGraphService { resetLoading() { this.previewLoadingEvents = {}; this.previewLoadingCount = 0; + this.sessionId++; this.metaService.removeTag("property='og:preview:loading'"); this.metaService.removeTag("property='og:preview:ready'"); this.metaService.removeTag("property='og:preview:fail'"); @@ -122,7 +131,7 @@ export class OpenGraphService { this.resetLoading(); this.ngZone.run(() => { this.router.navigateByUrl(path); - }) + }); } } } From 831b923dda4244df789738471d64d05a1b377142 Mon Sep 17 00:00:00 2001 From: natsoni Date: Mon, 10 Feb 2025 15:32:16 +0100 Subject: [PATCH 17/35] Update transaction preview messages --- .../components/transaction/transaction-raw.component.html | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/frontend/src/app/components/transaction/transaction-raw.component.html b/frontend/src/app/components/transaction/transaction-raw.component.html index 450e18ecd..7f8723353 100644 --- a/frontend/src/app/components/transaction/transaction-raw.component.html +++ b/frontend/src/app/components/transaction/transaction-raw.component.html @@ -8,10 +8,10 @@
- +

Error decoding transaction, reason: {{ error }}

@@ -44,9 +44,9 @@ @if (!hasPrevouts) {
@if (offlineMode) { - Prevouts are not loaded, some fields like fee rate cannot be displayed. + Missing prevouts are not loaded. Some fields like fee rate cannot be calculated. } @else { - Error loading prevouts. {{ errorPrevouts ? 'Reason: ' + errorPrevouts : '' }} + Error loading missing prevouts. {{ errorPrevouts ? 'Reason: ' + errorPrevouts : '' }} }
} From 4c20d2b180152c69abf15a790d77fd20142e570e Mon Sep 17 00:00:00 2001 From: natsoni Date: Mon, 10 Feb 2025 15:33:21 +0100 Subject: [PATCH 18/35] Move broadcast button to alert banner --- .../transaction/transaction-raw.component.html | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/frontend/src/app/components/transaction/transaction-raw.component.html b/frontend/src/app/components/transaction/transaction-raw.component.html index 7f8723353..b761bc8a9 100644 --- a/frontend/src/app/components/transaction/transaction-raw.component.html +++ b/frontend/src/app/components/transaction/transaction-raw.component.html @@ -30,7 +30,6 @@
-
@@ -41,6 +40,16 @@
+
+ + + + This transaction is stored locally in your browser. Broadcast it to add it to the mempool. + + + +
+ @if (!hasPrevouts) {
@if (offlineMode) { From 80201c082127226226e955d56a236c615f3bef72 Mon Sep 17 00:00:00 2001 From: wiz Date: Mon, 10 Feb 2025 09:33:07 -1000 Subject: [PATCH 19/35] ops: Add new Bitcoin nodes in SG1 and HNL to bitcoin.conf --- production/bitcoin.conf | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/production/bitcoin.conf b/production/bitcoin.conf index 8fe17d921..adff1ef6b 100644 --- a/production/bitcoin.conf +++ b/production/bitcoin.conf @@ -12,6 +12,7 @@ rpcallowip=127.0.0.1 rpcuser=__BITCOIN_RPC_USER__ rpcpassword=__BITCOIN_RPC_PASS__ whitelist=127.0.0.1 +whitelist=209.146.50.0/23 whitelist=103.99.168.0/22 whitelist=2401:b140::/32 blocksxor=0 @@ -27,6 +28,10 @@ bind=0.0.0.0:8333 bind=[::]:8333 zmqpubrawblock=tcp://127.0.0.1:8334 zmqpubrawtx=tcp://127.0.0.1:8335 +#addnode=[2401:b140::92:201]:8333 +#addnode=[2401:b140::92:202]:8333 +#addnode=[2401:b140::92:203]:8333 +#addnode=[2401:b140::92:204]:8333 #addnode=[2401:b140:1::92:201]:8333 #addnode=[2401:b140:1::92:202]:8333 #addnode=[2401:b140:1::92:203]:8333 @@ -65,6 +70,10 @@ zmqpubrawtx=tcp://127.0.0.1:8335 #addnode=[2401:b140:4::92:210]:8333 #addnode=[2401:b140:4::92:211]:8333 #addnode=[2401:b140:4::92:212]:8333 +#addnode=[2401:b140:5::92:201]:8333 +#addnode=[2401:b140:5::92:202]:8333 +#addnode=[2401:b140:5::92:203]:8333 +#addnode=[2401:b140:5::92:204]:8333 [test] daemon=1 @@ -74,6 +83,10 @@ bind=0.0.0.0:18333 bind=[::]:18333 zmqpubrawblock=tcp://127.0.0.1:18334 zmqpubrawtx=tcp://127.0.0.1:18335 +#addnode=[2401:b140::92:201]:18333 +#addnode=[2401:b140::92:202]:18333 +#addnode=[2401:b140::92:203]:18333 +#addnode=[2401:b140::92:204]:18333 #addnode=[2401:b140:1::92:201]:18333 #addnode=[2401:b140:1::92:202]:18333 #addnode=[2401:b140:1::92:203]:18333 @@ -112,6 +125,10 @@ zmqpubrawtx=tcp://127.0.0.1:18335 #addnode=[2401:b140:4::92:210]:18333 #addnode=[2401:b140:4::92:211]:18333 #addnode=[2401:b140:4::92:212]:18333 +#addnode=[2401:b140:5::92:201]:18333 +#addnode=[2401:b140:5::92:202]:18333 +#addnode=[2401:b140:5::92:203]:18333 +#addnode=[2401:b140:5::92:204]:18333 [signet] daemon=1 @@ -121,6 +138,10 @@ bind=0.0.0.0:38333 bind=[::]:38333 zmqpubrawblock=tcp://127.0.0.1:38334 zmqpubrawtx=tcp://127.0.0.1:38335 +#addnode=[2401:b140::92:201]:38333 +#addnode=[2401:b140::92:202]:38333 +#addnode=[2401:b140::92:203]:38333 +#addnode=[2401:b140::92:204]:38333 #addnode=[2401:b140:1::92:201]:38333 #addnode=[2401:b140:1::92:202]:38333 #addnode=[2401:b140:1::92:203]:38333 @@ -161,6 +182,10 @@ zmqpubrawtx=tcp://127.0.0.1:38335 #addnode=[2401:b140:4::92:212]:38333 #addnode=[2401:b140:4::92:213]:38333 #addnode=[2401:b140:4::92:214]:38333 +#addnode=[2401:b140:5::92:201]:38333 +#addnode=[2401:b140:5::92:202]:38333 +#addnode=[2401:b140:5::92:203]:38333 +#addnode=[2401:b140:5::92:204]:38333 [testnet4] daemon=1 @@ -170,6 +195,10 @@ bind=0.0.0.0:48333 bind=[::]:48333 zmqpubrawblock=tcp://127.0.0.1:48334 zmqpubrawtx=tcp://127.0.0.1:48335 +#addnode=[2401:b140::92:201]:48333 +#addnode=[2401:b140::92:202]:48333 +#addnode=[2401:b140::92:203]:48333 +#addnode=[2401:b140::92:204]:48333 #addnode=[2401:b140:1::92:201]:48333 #addnode=[2401:b140:1::92:202]:48333 #addnode=[2401:b140:1::92:203]:48333 @@ -210,3 +239,7 @@ zmqpubrawtx=tcp://127.0.0.1:48335 #addnode=[2401:b140:4::92:212]:48333 #addnode=[2401:b140:4::92:213]:48333 #addnode=[2401:b140:4::92:214]:48333 +#addnode=[2401:b140:5::92:201]:48333 +#addnode=[2401:b140:5::92:202]:48333 +#addnode=[2401:b140:5::92:203]:48333 +#addnode=[2401:b140:5::92:204]:48333 From d3b5c15f33fa4c54ec22bb5b9b27c6a2480ad78b Mon Sep 17 00:00:00 2001 From: Mononaut Date: Wed, 12 Feb 2025 15:03:52 +0000 Subject: [PATCH 20/35] [ops] check for pool updates every hour --- production/mempool-config.mainnet.json | 1 + production/mempool-config.signet.json | 1 + production/mempool-config.testnet.json | 1 + production/mempool-config.testnet4.json | 1 + 4 files changed, 4 insertions(+) diff --git a/production/mempool-config.mainnet.json b/production/mempool-config.mainnet.json index b14e3cd07..9505601d2 100644 --- a/production/mempool-config.mainnet.json +++ b/production/mempool-config.mainnet.json @@ -14,6 +14,7 @@ "BLOCKS_SUMMARIES_INDEXING": true, "GOGGLES_INDEXING": true, "AUTOMATIC_POOLS_UPDATE": true, + "POOLS_UPDATE_DELAY": 3600, "AUDIT": true, "CPFP_INDEXING": true, "RUST_GBT": true, diff --git a/production/mempool-config.signet.json b/production/mempool-config.signet.json index a0a2353cb..952845ae9 100644 --- a/production/mempool-config.signet.json +++ b/production/mempool-config.signet.json @@ -9,6 +9,7 @@ "API_URL_PREFIX": "/api/v1/", "INDEXING_BLOCKS_AMOUNT": -1, "AUTOMATIC_POOLS_UPDATE": true, + "POOLS_UPDATE_DELAY": 3600, "AUDIT": true, "RUST_GBT": true, "POLL_RATE_MS": 1000, diff --git a/production/mempool-config.testnet.json b/production/mempool-config.testnet.json index 81cd61dc4..5f9f3abb9 100644 --- a/production/mempool-config.testnet.json +++ b/production/mempool-config.testnet.json @@ -9,6 +9,7 @@ "API_URL_PREFIX": "/api/v1/", "INDEXING_BLOCKS_AMOUNT": -1, "AUTOMATIC_POOLS_UPDATE": true, + "POOLS_UPDATE_DELAY": 3600, "AUDIT": true, "RUST_GBT": true, "POLL_RATE_MS": 1000, diff --git a/production/mempool-config.testnet4.json b/production/mempool-config.testnet4.json index 91373d223..2e79309ed 100644 --- a/production/mempool-config.testnet4.json +++ b/production/mempool-config.testnet4.json @@ -9,6 +9,7 @@ "API_URL_PREFIX": "/api/v1/", "INDEXING_BLOCKS_AMOUNT": -1, "AUTOMATIC_POOLS_UPDATE": true, + "POOLS_UPDATE_DELAY": 3600, "AUDIT": true, "RUST_GBT": true, "POLL_RATE_MS": 1000, From c626bd1ea2b51d8eed9787e9f701613a5887279a Mon Sep 17 00:00:00 2001 From: wiz Date: Wed, 19 Feb 2025 10:56:13 -0600 Subject: [PATCH 21/35] ops: Remove old X-Frame-Options HTTP header --- production/nginx/server-common.conf | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/production/nginx/server-common.conf b/production/nginx/server-common.conf index 9a2a582c0..5a0b17b4e 100644 --- a/production/nginx/server-common.conf +++ b/production/nginx/server-common.conf @@ -8,33 +8,28 @@ add_header Onion-Location http://$onion.onion$request_uri; add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload"; # generate frame configuration from origin header -if ($frameOptions = '') +if ($contentSecurityPolicy = '') { - set $frameOptions "DENY"; - set $contentSecurityPolicy "frame-ancestors 'none'"; + set $contentSecurityPolicy "frame-ancestors 'self'"; } # used for iframes on https://mempool.space/network if ($http_referer ~ ^https://mempool.space/) { - set $frameOptions "ALLOW-FROM https://mempool.space"; set $contentSecurityPolicy "frame-ancestors https://mempool.space"; } # used for iframes on https://mempool.ninja/network if ($http_referer ~ ^https://mempool.ninja/) { - set $frameOptions "ALLOW-FROM https://mempool.ninja"; set $contentSecurityPolicy "frame-ancestors https://mempool.ninja"; } # used for iframes on https://wiz.biz/bitcoin/nodes if ($http_referer ~ ^https://wiz.biz/) { - set $frameOptions "ALLOW-FROM https://wiz.biz"; set $contentSecurityPolicy "frame-ancestors https://wiz.biz"; } # restrict usage of frames -add_header X-Frame-Options $frameOptions; add_header Content-Security-Policy $contentSecurityPolicy; # enable browser and proxy caching From 7671600455b94bcbc96907bb1bdf07bc39be094f Mon Sep 17 00:00:00 2001 From: Mononaut Date: Wed, 19 Feb 2025 15:03:03 +0000 Subject: [PATCH 22/35] temporary twidget mirror --- .../twitter-widget.component.ts | 54 +++++++++++-------- 1 file changed, 32 insertions(+), 22 deletions(-) diff --git a/frontend/src/app/components/twitter-widget/twitter-widget.component.ts b/frontend/src/app/components/twitter-widget/twitter-widget.component.ts index 06b50b1dc..8f5894ad0 100644 --- a/frontend/src/app/components/twitter-widget/twitter-widget.component.ts +++ b/frontend/src/app/components/twitter-widget/twitter-widget.component.ts @@ -34,29 +34,39 @@ export class TwitterWidgetComponent implements OnChanges { } setIframeSrc(): void { - if (this.handle) { - this.iframeSrc = this.sanitizer.bypassSecurityTrustResourceUrl(this.sanitizer.sanitize(SecurityContext.URL, - `https://syndication.x.com/srv/timeline-profile/screen-name/${this.handle}?creatorScreenName=mempool` - + '&dnt=true' - + '&embedId=twitter-widget-0' - + '&features=eyJ0ZndfdGltZWxpbmVfgbGlzdCI6eyJidWNrZXQiOltdLCJ2ZXJzaW9uIjpudWxsfSwidGZ3X2ZvbGxvd2VyX2NvdW50X3N1bnNldCI6eyJidWNrZXQiOnRydWUsInZlcnNpb24iOm51bGx9LCJ0ZndfdHdlZXRfZWRpdF9iYWNrZW5kIjp7ImJ1Y2tldCI6Im9uIiwidmVyc2lvbiI6bnVsbH0sInRmd19yZWZzcmNfc2Vzc2lvbiI6eyJidWNrZXQiOiJvbiIsInZlcnNpb24iOm51bGx9LCJ0ZndfZm9zbnJfc29mdF9pbnRlcnZlbnRpb25zX2VuYWJsZWQiOnsiYnVja2V0Ijoib24iLCJ2ZXJzaW9uIjpudWxsfSwidGZ3X21peGVkX21lZGlhXzE1ODk3Ijp7ImJ1Y2tldCI6InRyZWF0bWVudCIsInZlcnNpb24iOm51bGx9LCJ0ZndfZXhwZXJpbWVudHNfY29va2llX2V4cGlyYXRpb24iOnsiYnVja2V0IjoxMjA5NjAwLCJ2ZXJzaW9uIjpudWxsfSwidGZ3X3Nob3dfYmlyZHdhdGNoX3Bpdm90c19lbmFibGVkIjp7ImJ1Y2tldCI6Im9uIiwidmVyc2lvbiI6bnVsbH0sInRmd19kdXBsaWNhdGVfc2NyaWJlc190b19zZXR0aW5ncyI6eyJidWNrZXQiOiJvbiIsInZlcnNpb24iOm51bGx9LCJ0ZndfdXNlX3Byb2ZpbGVfaW1hZ2Vfc2hhcGVfZW5hYmxlZCI6eyJidWNrZXQiOiJvbiIsInZlcnNpb24iOm51bGx9LCJ0ZndfdmlkZW9faGxzX2R5bmFtaWNfbWFuaWZlc3RzXzE1MDgyIjp7ImJ1Y2tldCI6InRydWVfYml0cmF0ZSIsInZlcnNpb24iOm51bGx9LCJ0ZndfbGVnYWN5X3RpbWVsaW5lX3N1bnNldCI6eyJidWNrZXQiOnRydWUsInZlcnNpb24iOm51bGx9LCJ0ZndfdHdlZXRfZWRpdF9mcm9udGVuZCI6eyJidWNrZXQiOiJvbiIsInZlcnNpb24iOm51bGx9fQ%3D%3D' - + '&frame=false' - + '&hideBorder=true' - + '&hideFooter=false' - + '&hideHeader=true' - + '&hideScrollBar=false' - + `&lang=${this.lang}` - + '&maxHeight=500px' - + '&origin=https%3A%2F%2Fmempool.space%2F' - // + '&sessionId=88f6d661d0dcca99c43c0a590f6a3e61c89226a9' - + '&showHeader=false' - + '&showReplies=false' - + '&siteScreenName=mempool' - + '&theme=dark' - + '&transparent=true' - + '&widgetsVersion=2615f7e52b7e0%3A1702314776716' - )); + if (!this.handle) { + return; } + let url = `https://syndication.x.com/srv/timeline-profile/screen-name/${this.handle}?creatorScreenName=mempool` + + '&dnt=true' + + '&embedId=twitter-widget-0' + + '&features=eyJ0ZndfdGltZWxpbmVfgbGlzdCI6eyJidWNrZXQiOltdLCJ2ZXJzaW9uIjpudWxsfSwidGZ3X2ZvbGxvd2VyX2NvdW50X3N1bnNldCI6eyJidWNrZXQiOnRydWUsInZlcnNpb24iOm51bGx9LCJ0ZndfdHdlZXRfZWRpdF9iYWNrZW5kIjp7ImJ1Y2tldCI6Im9uIiwidmVyc2lvbiI6bnVsbH0sInRmd19yZWZzcmNfc2Vzc2lvbiI6eyJidWNrZXQiOiJvbiIsInZlcnNpb24iOm51bGx9LCJ0ZndfZm9zbnJfc29mdF9pbnRlcnZlbnRpb25zX2VuYWJsZWQiOnsiYnVja2V0Ijoib24iLCJ2ZXJzaW9uIjpudWxsfSwidGZ3X21peGVkX21lZGlhXzE1ODk3Ijp7ImJ1Y2tldCI6InRyZWF0bWVudCIsInZlcnNpb24iOm51bGx9LCJ0ZndfZXhwZXJpbWVudHNfY29va2llX2V4cGlyYXRpb24iOnsiYnVja2V0IjoxMjA5NjAwLCJ2ZXJzaW9uIjpudWxsfSwidGZ3X3Nob3dfYmlyZHdhdGNoX3Bpdm90c19lbmFibGVkIjp7ImJ1Y2tldCI6Im9uIiwidmVyc2lvbiI6bnVsbH0sInRmd19kdXBsaWNhdGVfc2NyaWJlc190b19zZXR0aW5ncyI6eyJidWNrZXQiOiJvbiIsInZlcnNpb24iOm51bGx9LCJ0ZndfdXNlX3Byb2ZpbGVfaW1hZ2Vfc2hhcGVfZW5hYmxlZCI6eyJidWNrZXQiOiJvbiIsInZlcnNpb24iOm51bGx9LCJ0ZndfdmlkZW9faGxzX2R5bmFtaWNfbWFuaWZlc3RzXzE1MDgyIjp7ImJ1Y2tldCI6InRydWVfYml0cmF0ZSIsInZlcnNpb24iOm51bGx9LCJ0ZndfbGVnYWN5X3RpbWVsaW5lX3N1bnNldCI6eyJidWNrZXQiOnRydWUsInZlcnNpb24iOm51bGx9LCJ0ZndfdHdlZXRfZWRpdF9mcm9udGVuZCI6eyJidWNrZXQiOiJvbiIsInZlcnNpb24iOm51bGx9fQ%3D%3D' + + '&frame=false' + + '&hideBorder=true' + + '&hideFooter=false' + + '&hideHeader=true' + + '&hideScrollBar=false' + + `&lang=${this.lang}` + + '&maxHeight=500px' + + '&origin=https%3A%2F%2Fmempool.space%2F' + // + '&sessionId=88f6d661d0dcca99c43c0a590f6a3e61c89226a9' + + '&showHeader=false' + + '&showReplies=false' + + '&siteScreenName=mempool' + + '&theme=dark' + + '&transparent=true' + + '&widgetsVersion=2615f7e52b7e0%3A1702314776716'; + switch (this.handle.toLowerCase()) { + case 'nayibbukele': + url = 'https://bitcoin.gob.sv/twidget'; + break; + case 'metaplanet_jp': + url = 'https://metaplanet.mempool.space/twidget'; + break; + default: + break; + } + this.iframeSrc = this.sanitizer.bypassSecurityTrustResourceUrl(this.sanitizer.sanitize(SecurityContext.URL, url)); } onReady(): void { From d82a9f6c6a58366d2f022499d6b82644278356cf Mon Sep 17 00:00:00 2001 From: Felipe Knorr Kuhn Date: Tue, 25 Feb 2025 18:56:29 -0800 Subject: [PATCH 23/35] Tweak Docker workflow --- .github/workflows/on-tag.yml | 28 +++++++++++++++++----------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/.github/workflows/on-tag.yml b/.github/workflows/on-tag.yml index 634a27ab9..ba9e1eb7b 100644 --- a/.github/workflows/on-tag.yml +++ b/.github/workflows/on-tag.yml @@ -2,7 +2,7 @@ name: Docker build on tag env: DOCKER_CLI_EXPERIMENTAL: enabled TAG_FMT: "^refs/tags/(((.?[0-9]+){3,4}))$" - DOCKER_BUILDKIT: 0 + DOCKER_BUILDKIT: 1 # Enable BuildKit for better performance COMPOSE_DOCKER_CLI_BUILD: 0 on: @@ -25,13 +25,12 @@ jobs: timeout-minutes: 120 name: Build and push to DockerHub steps: - # Workaround based on JonasAlfredsson/docker-on-tmpfs@v1.0.1 - name: Replace the current swap file shell: bash run: | - sudo swapoff /mnt/swapfile - sudo rm -v /mnt/swapfile - sudo fallocate -l 13G /mnt/swapfile + sudo swapoff /mnt/swapfile || true + sudo rm -f /mnt/swapfile + sudo fallocate -l 16G /mnt/swapfile sudo chmod 600 /mnt/swapfile sudo mkswap /mnt/swapfile sudo swapon /mnt/swapfile @@ -50,7 +49,7 @@ jobs: echo "Directory '/var/lib/docker' not found" exit 1 fi - sudo mount -t tmpfs -o size=10G tmpfs /var/lib/docker + sudo mount -t tmpfs -o size=12G tmpfs /var/lib/docker sudo systemctl restart docker sudo df -h | grep docker @@ -75,10 +74,16 @@ jobs: - name: Set up QEMU uses: docker/setup-qemu-action@v3 + with: + platforms: linux/amd64,linux/arm64 id: qemu - name: Setup Docker buildx action uses: docker/setup-buildx-action@v3 + with: + platforms: linux/amd64,linux/arm64 + driver-opts: | + network=host id: buildx - name: Available platforms @@ -89,19 +94,20 @@ jobs: id: cache with: path: /tmp/.buildx-cache - key: ${{ runner.os }}-buildx-${{ github.sha }} + key: ${{ runner.os }}-buildx-${{ matrix.service }}-${{ github.sha }} restore-keys: | - ${{ runner.os }}-buildx- + ${{ runner.os }}-buildx-${{ matrix.service }}- - name: Run Docker buildx for ${{ matrix.service }} against tag run: | docker buildx build \ --cache-from "type=local,src=/tmp/.buildx-cache" \ - --cache-to "type=local,dest=/tmp/.buildx-cache" \ + --cache-to "type=local,dest=/tmp/.buildx-cache,mode=max" \ --platform linux/amd64,linux/arm64 \ --tag ${{ secrets.DOCKER_HUB_USER }}/${{ matrix.service }}:$TAG \ --tag ${{ secrets.DOCKER_HUB_USER }}/${{ matrix.service }}:latest \ --build-context rustgbt=./rust \ --build-context backend=./backend \ - --output "type=registry" ./${{ matrix.service }}/ \ - --build-arg commitHash=$SHORT_SHA + --output "type=registry,push=true" \ + --build-arg commitHash=$SHORT_SHA \ + ./${{ matrix.service }}/ \ No newline at end of file From e6f13766d3c05a910d3d146f457762a30f6c86ee Mon Sep 17 00:00:00 2001 From: Felipe Knorr Kuhn Date: Tue, 25 Feb 2025 19:05:28 -0800 Subject: [PATCH 24/35] Update Docker images --- docker/backend/Dockerfile | 29 ++++++++++++++++++----------- docker/frontend/Dockerfile | 2 +- 2 files changed, 19 insertions(+), 12 deletions(-) diff --git a/docker/backend/Dockerfile b/docker/backend/Dockerfile index 60d663f20..e56b07da3 100644 --- a/docker/backend/Dockerfile +++ b/docker/backend/Dockerfile @@ -1,20 +1,20 @@ -FROM node:20.15.0-buster-slim AS builder +FROM rust:1.84-bookworm AS builder ARG commitHash ENV MEMPOOL_COMMIT_HASH=${commitHash} WORKDIR /build + +RUN apt-get update && \ + apt-get install -y curl ca-certificates && \ + curl -fsSL https://deb.nodesource.com/setup_22.x | bash - && \ + apt-get install -y nodejs build-essential python3 pkg-config && \ + apt-get clean && \ + rm -rf /var/lib/apt/lists/* + COPY . . -RUN apt-get update -RUN apt-get install -y build-essential python3 pkg-config curl ca-certificates - -# Install Rust via rustup -RUN CPU_ARCH=$(uname -m); if [ "$CPU_ARCH" = "armv7l" ]; then c_rehash; fi -#RUN curl https://sh.rustup.rs -sSf | sh -s -- -y --default-toolchain stable -#Workaround to run on github actions from https://github.com/rust-lang/rustup/issues/2700#issuecomment-1367488985 -RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sed 's#/proc/self/exe#\/bin\/sh#g' | sh -s -- -y --default-toolchain stable -ENV PATH="/root/.cargo/bin:$PATH" +ENV PATH="/usr/local/cargo/bin:$PATH" COPY --from=backend . . COPY --from=rustgbt . ../rust/ @@ -24,7 +24,14 @@ RUN npm install --omit=dev --omit=optional WORKDIR /build RUN npm run package -FROM node:20.15.0-buster-slim +FROM rust:1.84-bookworm AS runtime + +RUN apt-get update && \ + apt-get install -y curl ca-certificates && \ + curl -fsSL https://deb.nodesource.com/setup_22.x | bash - && \ + apt-get install -y nodejs && \ + apt-get clean && \ + rm -rf /var/lib/apt/lists/* WORKDIR /backend diff --git a/docker/frontend/Dockerfile b/docker/frontend/Dockerfile index 8374ebe49..8d97c9dc6 100644 --- a/docker/frontend/Dockerfile +++ b/docker/frontend/Dockerfile @@ -1,4 +1,4 @@ -FROM node:20.15.0-buster-slim AS builder +FROM node:22-bookworm-slim AS builder ARG commitHash ENV DOCKER_COMMIT_HASH=${commitHash} From cfe7c93755c38a0837cc4744add1c6a821fc2160 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 27 Feb 2025 02:13:31 +0000 Subject: [PATCH 25/35] Bump axios from 1.7.2 to 1.8.1 in /backend Bumps [axios](https://github.com/axios/axios) from 1.7.2 to 1.8.1. - [Release notes](https://github.com/axios/axios/releases) - [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md) - [Commits](https://github.com/axios/axios/compare/v1.7.2...v1.8.1) --- updated-dependencies: - dependency-name: axios dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- backend/package-lock.json | 14 +++++++------- backend/package.json | 2 +- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/backend/package-lock.json b/backend/package-lock.json index 3f66fa25b..1aaa77f85 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -12,7 +12,7 @@ "dependencies": { "@mempool/electrum-client": "1.1.9", "@types/node": "^18.15.3", - "axios": "1.7.2", + "axios": "1.8.1", "bitcoinjs-lib": "~6.1.3", "crypto-js": "~4.2.0", "express": "~4.21.1", @@ -2275,9 +2275,9 @@ } }, "node_modules/axios": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.2.tgz", - "integrity": "sha512-2A8QhOMrbomlDuiLeK9XibIBzuHeRcqqNOHp0Cyp5EoJ1IFDh+XZH3A6BkXtv0K4gFGCI0Y4BM7B1wOEi0Rmgw==", + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.8.1.tgz", + "integrity": "sha512-NN+fvwH/kV01dYUQ3PTOZns4LWtWhOFCAhQ/pHb88WQ1hNe5V/dvFwc4VJcDL11LT9xSX0QtsR8sWUuyOuOq7g==", "license": "MIT", "dependencies": { "follow-redirects": "^1.15.6", @@ -9459,9 +9459,9 @@ "integrity": "sha512-+H+kuK34PfMaI9PNU/NSjBKL5hh/KDM9J72kwYeYEm0A8B1AC4fuCy3qsjnA7lxklgyXsB68yn8Z2xoZEjgwCQ==" }, "axios": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.2.tgz", - "integrity": "sha512-2A8QhOMrbomlDuiLeK9XibIBzuHeRcqqNOHp0Cyp5EoJ1IFDh+XZH3A6BkXtv0K4gFGCI0Y4BM7B1wOEi0Rmgw==", + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.8.1.tgz", + "integrity": "sha512-NN+fvwH/kV01dYUQ3PTOZns4LWtWhOFCAhQ/pHb88WQ1hNe5V/dvFwc4VJcDL11LT9xSX0QtsR8sWUuyOuOq7g==", "requires": { "follow-redirects": "^1.15.6", "form-data": "^4.0.0", diff --git a/backend/package.json b/backend/package.json index ee5944f93..efc5a4501 100644 --- a/backend/package.json +++ b/backend/package.json @@ -41,7 +41,7 @@ "dependencies": { "@mempool/electrum-client": "1.1.9", "@types/node": "^18.15.3", - "axios": "1.7.2", + "axios": "1.8.1", "bitcoinjs-lib": "~6.1.3", "crypto-js": "~4.2.0", "express": "~4.21.1", From 5116da2626432b4c6a0818671cee19897370d5f6 Mon Sep 17 00:00:00 2001 From: Felipe Knorr Kuhn Date: Fri, 28 Feb 2025 23:20:40 -0800 Subject: [PATCH 26/35] Do not update the latest tag when building --- .github/workflows/on-tag.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/on-tag.yml b/.github/workflows/on-tag.yml index ba9e1eb7b..8a846631c 100644 --- a/.github/workflows/on-tag.yml +++ b/.github/workflows/on-tag.yml @@ -105,7 +105,7 @@ jobs: --cache-to "type=local,dest=/tmp/.buildx-cache,mode=max" \ --platform linux/amd64,linux/arm64 \ --tag ${{ secrets.DOCKER_HUB_USER }}/${{ matrix.service }}:$TAG \ - --tag ${{ secrets.DOCKER_HUB_USER }}/${{ matrix.service }}:latest \ + # --tag ${{ secrets.DOCKER_HUB_USER }}/${{ matrix.service }}:latest \ --build-context rustgbt=./rust \ --build-context backend=./backend \ --output "type=registry,push=true" \ From 9c358060aa0f881414a95ac9d52cf38d364845aa Mon Sep 17 00:00:00 2001 From: Felipe Knorr Kuhn Date: Fri, 28 Feb 2025 23:22:00 -0800 Subject: [PATCH 27/35] Add dispatch workflow to update the latest tag --- .../workflows/docker_update_latest_tag.yml | 181 ++++++++++++++++++ 1 file changed, 181 insertions(+) create mode 100644 .github/workflows/docker_update_latest_tag.yml diff --git a/.github/workflows/docker_update_latest_tag.yml b/.github/workflows/docker_update_latest_tag.yml new file mode 100644 index 000000000..5d21697d5 --- /dev/null +++ b/.github/workflows/docker_update_latest_tag.yml @@ -0,0 +1,181 @@ +name: Docker - Update latest tag + +on: + workflow_dispatch: + inputs: + tag: + description: 'The Docker image tag to pull' + required: true + type: string + +jobs: + retag-and-push: + strategy: + matrix: + service: + - frontend + - backend + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Docker Buildx + uses: docker/setup-buildx-action@v3 + id: buildx + with: + install: true + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + with: + platforms: linux/amd64,linux/arm64 + + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_HUB_USER }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: Get source image manifest and SHAs + id: source-manifest + run: | + set -e + echo "Fetching source manifest..." + MANIFEST=$(docker manifest inspect ${{ secrets.DOCKER_USERNAME }}/${{ matrix.service }}:${{ github.event.inputs.tag }}) + if [ -z "$MANIFEST" ]; then + echo "No manifest found. Assuming single-arch image." + exit 1 + fi + + echo "Original source manifest:" + echo "$MANIFEST" | jq . + + AMD64_SHA=$(echo "$MANIFEST" | jq -r '.manifests[] | select(.platform.architecture=="amd64" and .platform.os=="linux") | .digest') + ARM64_SHA=$(echo "$MANIFEST" | jq -r '.manifests[] | select(.platform.architecture=="arm64" and .platform.os=="linux") | .digest') + + if [ -z "$AMD64_SHA" ] || [ -z "$ARM64_SHA" ]; then + echo "Source image is not multi-arch (missing amd64 or arm64)" + exit 1 + fi + + echo "Source amd64 manifest digest: $AMD64_SHA" + echo "Source arm64 manifest digest: $ARM64_SHA" + + echo "amd64_sha=$AMD64_SHA" >> $GITHUB_OUTPUT + echo "arm64_sha=$ARM64_SHA" >> $GITHUB_OUTPUT + + - name: Pull and retag architecture-specific images + run: | + set -e + + docker buildx inspect --bootstrap + + # Remove any existing local images to avoid cache interference + echo "Removing existing local images if they exist..." + docker image rm ${{ secrets.DOCKER_USERNAME }}/${{ matrix.service }}:${{ github.event.inputs.tag }} || true + + # Pull amd64 image by digest + echo "Pulling amd64 image by digest..." + docker pull --platform linux/amd64 ${{ secrets.DOCKER_USERNAME }}/${{ matrix.service }}@${{ steps.source-manifest.outputs.amd64_sha }} + PULLED_AMD64_MANIFEST_DIGEST=$(docker inspect ${{ secrets.DOCKER_USERNAME }}/${{ matrix.service }}@${{ steps.source-manifest.outputs.amd64_sha }} --format '{{index .RepoDigests 0}}' | cut -d@ -f2) + PULLED_AMD64_IMAGE_ID=$(docker inspect ${{ secrets.DOCKER_USERNAME }}/${{ matrix.service }}@${{ steps.source-manifest.outputs.amd64_sha }} --format '{{.Id}}') + echo "Pulled amd64 manifest digest: $PULLED_AMD64_MANIFEST_DIGEST" + echo "Pulled amd64 image ID (sha256): $PULLED_AMD64_IMAGE_ID" + + # Pull arm64 image by digest + echo "Pulling arm64 image by digest..." + docker pull --platform linux/arm64 ${{ secrets.DOCKER_USERNAME }}/${{ matrix.service }}@${{ steps.source-manifest.outputs.arm64_sha }} + PULLED_ARM64_MANIFEST_DIGEST=$(docker inspect ${{ secrets.DOCKER_USERNAME }}/${{ matrix.service }}@${{ steps.source-manifest.outputs.arm64_sha }} --format '{{index .RepoDigests 0}}' | cut -d@ -f2) + PULLED_ARM64_IMAGE_ID=$(docker inspect ${{ secrets.DOCKER_USERNAME }}/${{ matrix.service }}@${{ steps.source-manifest.outputs.arm64_sha }} --format '{{.Id}}') + echo "Pulled arm64 manifest digest: $PULLED_ARM64_MANIFEST_DIGEST" + echo "Pulled arm64 image ID (sha256): $PULLED_ARM64_IMAGE_ID" + + # Tag the images + docker tag ${{ secrets.DOCKER_USERNAME }}/${{ matrix.service }}@${{ steps.source-manifest.outputs.amd64_sha }} ${{ secrets.DOCKER_USERNAME }}/${{ matrix.service }}:latest-amd64 + docker tag ${{ secrets.DOCKER_USERNAME }}/${{ matrix.service }}@${{ steps.source-manifest.outputs.arm64_sha }} ${{ secrets.DOCKER_USERNAME }}/${{ matrix.service }}:latest-arm64 + + # Verify tagged images + TAGGED_AMD64_IMAGE_ID=$(docker inspect ${{ secrets.DOCKER_USERNAME }}/${{ matrix.service }}:latest-amd64 --format '{{.Id}}') + TAGGED_ARM64_IMAGE_ID=$(docker inspect ${{ secrets.DOCKER_USERNAME }}/${{ matrix.service }}:latest-arm64 --format '{{.Id}}') + echo "Tagged amd64 image ID (sha256): $TAGGED_AMD64_IMAGE_ID" + echo "Tagged arm64 image ID (sha256): $TAGGED_ARM64_IMAGE_ID" + + - name: Push architecture-specific images + run: | + set -e + + echo "Pushing amd64 image..." + docker push ${{ secrets.DOCKER_USERNAME }}/${{ matrix.service }}:latest-amd64 + PUSHED_AMD64_DIGEST=$(docker inspect ${{ secrets.DOCKER_USERNAME }}/${{ matrix.service }}:latest-amd64 --format '{{index .RepoDigests 0}}' | cut -d@ -f2) + echo "Pushed amd64 manifest digest (local): $PUSHED_AMD64_DIGEST" + + # Fetch manifest from registry after push + echo "Fetching pushed amd64 manifest from registry..." + PUSHED_AMD64_REGISTRY_MANIFEST=$(docker manifest inspect ${{ secrets.DOCKER_USERNAME }}/${{ matrix.service }}:latest-amd64) + PUSHED_AMD64_REGISTRY_DIGEST=$(echo "$PUSHED_AMD64_REGISTRY_MANIFEST" | jq -r '.config.digest') + echo "Pushed amd64 manifest digest (registry): $PUSHED_AMD64_REGISTRY_DIGEST" + + echo "Pushing arm64 image..." + docker push ${{ secrets.DOCKER_USERNAME }}/${{ matrix.service }}:latest-arm64 + PUSHED_ARM64_DIGEST=$(docker inspect ${{ secrets.DOCKER_USERNAME }}/${{ matrix.service }}:latest-arm64 --format '{{index .RepoDigests 0}}' | cut -d@ -f2) + echo "Pushed arm64 manifest digest (local): $PUSHED_ARM64_DIGEST" + + # Fetch manifest from registry after push + echo "Fetching pushed arm64 manifest from registry..." + PUSHED_ARM64_REGISTRY_MANIFEST=$(docker manifest inspect ${{ secrets.DOCKER_USERNAME }}/${{ matrix.service }}:latest-arm64) + PUSHED_ARM64_REGISTRY_DIGEST=$(echo "$PUSHED_ARM64_REGISTRY_MANIFEST" | jq -r '.config.digest') + echo "Pushed arm64 manifest digest (registry): $PUSHED_ARM64_REGISTRY_DIGEST" + + - name: Create and push multi-arch manifest with original digests + run: | + set -e + + echo "Creating multi-arch manifest with original digests..." + docker manifest create ${{ secrets.DOCKER_USERNAME }}/${{ matrix.service }}:latest \ + ${{ secrets.DOCKER_USERNAME }}/${{ matrix.service }}@${{ steps.source-manifest.outputs.amd64_sha }} \ + ${{ secrets.DOCKER_USERNAME }}/${{ matrix.service }}@${{ steps.source-manifest.outputs.arm64_sha }} + + echo "Pushing multi-arch manifest..." + docker manifest push ${{ secrets.DOCKER_USERNAME }}/${{ matrix.service }}:latest + + - name: Clean up intermediate tags + if: success() + run: | + docker rmi ${{ secrets.DOCKER_USERNAME }}/${{ matrix.service }}:latest-amd64 || true + docker rmi ${{ secrets.DOCKER_USERNAME }}/${{ matrix.service }}:latest-arm64 || true + docker rmi ${{ secrets.DOCKER_USERNAME }}/${{ matrix.service }}:${{ github.event.inputs.tag }} || true + + - name: Verify final manifest + run: | + set -e + echo "Fetching final generated manifest..." + FINAL_MANIFEST=$(docker manifest inspect ${{ secrets.DOCKER_USERNAME }}/${{ matrix.service }}:latest) + echo "Generated final manifest:" + echo "$FINAL_MANIFEST" | jq . + + FINAL_AMD64_SHA=$(echo "$FINAL_MANIFEST" | jq -r '.manifests[] | select(.platform.architecture=="amd64" and .platform.os=="linux") | .digest') + FINAL_ARM64_SHA=$(echo "$FINAL_MANIFEST" | jq -r '.manifests[] | select(.platform.architecture=="arm64" and .platform.os=="linux") | .digest') + + echo "Final amd64 manifest digest: $FINAL_AMD64_SHA" + echo "Final arm64 manifest digest: $FINAL_ARM64_SHA" + + # Compare all digests + echo "Comparing digests..." + echo "Source amd64 digest: ${{ steps.source-manifest.outputs.amd64_sha }}" + echo "Pulled amd64 manifest digest: $PULLED_AMD64_MANIFEST_DIGEST" + echo "Pushed amd64 manifest digest (local): $PUSHED_AMD64_DIGEST" + echo "Pushed amd64 manifest digest (registry): $PUSHED_AMD64_REGISTRY_DIGEST" + echo "Final amd64 digest: $FINAL_AMD64_SHA" + echo "Source arm64 digest: ${{ steps.source-manifest.outputs.arm64_sha }}" + echo "Pulled arm64 manifest digest: $PULLED_ARM64_MANIFEST_DIGEST" + echo "Pushed arm64 manifest digest (local): $PUSHED_ARM64_DIGEST" + echo "Pushed arm64 manifest digest (registry): $PUSHED_ARM64_REGISTRY_DIGEST" + echo "Final arm64 digest: $FINAL_ARM64_SHA" + + if [ "$FINAL_AMD64_SHA" != "${{ steps.source-manifest.outputs.amd64_sha }}" ] || [ "$FINAL_ARM64_SHA" != "${{ steps.source-manifest.outputs.arm64_sha }}" ]; then + echo "Error: Final manifest SHAs do not match source SHAs" + exit 1 + fi + + echo "Successfully created multi-arch ${{ secrets.DOCKER_USERNAME }}/${{ matrix.service }}:latest from ${{ github.event.inputs.tag }}" From c01e11899c78c14d3aa2a7c6371ed5015407f398 Mon Sep 17 00:00:00 2001 From: natsoni Date: Tue, 4 Mar 2025 16:25:54 +0100 Subject: [PATCH 28/35] PSBT support in transaction preview --- .../transaction-raw.component.html | 4 +- .../transaction/transaction-raw.component.ts | 92 +-- frontend/src/app/shared/transaction.utils.ts | 523 ++++++++++++++---- 3 files changed, 458 insertions(+), 161 deletions(-) diff --git a/frontend/src/app/components/transaction/transaction-raw.component.html b/frontend/src/app/components/transaction/transaction-raw.component.html index b761bc8a9..3bd8ee6d2 100644 --- a/frontend/src/app/components/transaction/transaction-raw.component.html +++ b/frontend/src/app/components/transaction/transaction-raw.component.html @@ -6,7 +6,7 @@
- +
@@ -192,7 +192,7 @@ Transaction hex - + diff --git a/frontend/src/app/components/transaction/transaction-raw.component.ts b/frontend/src/app/components/transaction/transaction-raw.component.ts index 321b0ffe5..5ce170e12 100644 --- a/frontend/src/app/components/transaction/transaction-raw.component.ts +++ b/frontend/src/app/components/transaction/transaction-raw.component.ts @@ -22,6 +22,7 @@ import { CpfpInfo } from '../../interfaces/node-api.interface'; export class TransactionRawComponent implements OnInit, OnDestroy { pushTxForm: UntypedFormGroup; + rawHexTransaction: string; isLoading: boolean; isLoadingPrevouts: boolean; isLoadingCpfpInfo: boolean; @@ -81,10 +82,10 @@ export class TransactionRawComponent implements OnInit, OnDestroy { this.resetState(); this.isLoading = true; try { - const tx = decodeRawTransaction(this.pushTxForm.get('txRaw').value, this.stateService.network); + const { tx, hex } = decodeRawTransaction(this.pushTxForm.get('txRaw').value, this.stateService.network); await this.fetchPrevouts(tx); await this.fetchCpfpInfo(tx); - this.processTransaction(tx); + this.processTransaction(tx, hex); } catch (error) { this.error = error.message; } finally { @@ -93,57 +94,60 @@ export class TransactionRawComponent implements OnInit, OnDestroy { } async fetchPrevouts(transaction: Transaction): Promise { - if (this.offlineMode) { - return; - } + const prevoutsToFetch = transaction.vin.filter(input => !input.prevout).map((input) => ({ txid: input.txid, vout: input.vout })); - const prevoutsToFetch = transaction.vin.map((input) => ({ txid: input.txid, vout: input.vout })); + if (!prevoutsToFetch.length || transaction.vin[0].is_coinbase || this.offlineMode) { + this.hasPrevouts = !prevoutsToFetch.length || transaction.vin[0].is_coinbase; + this.fetchCpfp = this.hasPrevouts && !this.offlineMode; + } else { + try { + this.missingPrevouts = []; + this.isLoadingPrevouts = true; - if (!prevoutsToFetch.length || transaction.vin[0].is_coinbase) { - this.hasPrevouts = true; - return; - } + const prevouts: { prevout: Vout, unconfirmed: boolean }[] = await firstValueFrom(this.apiService.getPrevouts$(prevoutsToFetch)); - try { - this.missingPrevouts = []; - this.isLoadingPrevouts = true; - - const prevouts: { prevout: Vout, unconfirmed: boolean }[] = await firstValueFrom(this.apiService.getPrevouts$(prevoutsToFetch)); - - if (prevouts?.length !== prevoutsToFetch.length) { - throw new Error(); - } - - transaction.vin = transaction.vin.map((input, index) => { - if (prevouts[index]) { - input.prevout = prevouts[index].prevout; - addInnerScriptsToVin(input); - } else { - this.missingPrevouts.push(`${input.txid}:${input.vout}`); + if (prevouts?.length !== prevoutsToFetch.length) { + throw new Error(); } - return input; - }); - if (this.missingPrevouts.length) { - throw new Error(`Some prevouts do not exist or are already spent (${this.missingPrevouts.length})`); + let fetchIndex = 0; + transaction.vin.forEach(input => { + if (!input.prevout) { + const fetched = prevouts[fetchIndex]; + if (fetched) { + input.prevout = fetched.prevout; + } else { + this.missingPrevouts.push(`${input.txid}:${input.vout}`); + } + fetchIndex++; + } + }); + + if (this.missingPrevouts.length) { + throw new Error(`Some prevouts do not exist or are already spent (${this.missingPrevouts.length})`); + } + + this.hasPrevouts = true; + this.isLoadingPrevouts = false; + this.fetchCpfp = prevouts.some(prevout => prevout?.unconfirmed); + } catch (error) { + console.log(error); + this.errorPrevouts = error?.error?.error || error?.message; + this.isLoadingPrevouts = false; } + } + if (this.hasPrevouts) { transaction.fee = transaction.vin.some(input => input.is_coinbase) ? 0 : transaction.vin.reduce((fee, input) => { return fee + (input.prevout?.value || 0); }, 0) - transaction.vout.reduce((sum, output) => sum + output.value, 0); transaction.feePerVsize = transaction.fee / (transaction.weight / 4); - transaction.sigops = countSigops(transaction); - - this.hasPrevouts = true; - this.isLoadingPrevouts = false; - this.fetchCpfp = prevouts.some(prevout => prevout?.unconfirmed); - } catch (error) { - console.log(error); - this.errorPrevouts = error?.error?.error || error?.message; - this.isLoadingPrevouts = false; } + + transaction.vin.forEach(addInnerScriptsToVin); + transaction.sigops = countSigops(transaction); } async fetchCpfpInfo(transaction: Transaction): Promise { @@ -175,10 +179,11 @@ export class TransactionRawComponent implements OnInit, OnDestroy { } } - processTransaction(tx: Transaction): void { + processTransaction(tx: Transaction, hex: string): void { this.transaction = tx; + this.rawHexTransaction = hex; - this.transaction.flags = getTransactionFlags(this.transaction, null, null, null, this.stateService.network); + this.transaction.flags = getTransactionFlags(this.transaction, this.cpfpInfo, null, null, this.stateService.network); this.filters = this.transaction.flags ? toFilters(this.transaction.flags).filter(f => f.txPage) : []; if (this.transaction.sigops >= 0) { this.adjustedVsize = Math.max(this.transaction.weight / 4, this.transaction.sigops * 5); @@ -206,7 +211,7 @@ export class TransactionRawComponent implements OnInit, OnDestroy { this.isLoadingBroadcast = true; this.errorBroadcast = null; return new Promise((resolve, reject) => { - this.apiService.postTransaction$(this.pushTxForm.get('txRaw').value) + this.apiService.postTransaction$(this.rawHexTransaction) .subscribe((result) => { this.isLoadingBroadcast = false; this.successBroadcast = true; @@ -228,6 +233,7 @@ export class TransactionRawComponent implements OnInit, OnDestroy { resetState() { this.transaction = null; + this.rawHexTransaction = null; this.error = null; this.errorPrevouts = null; this.errorBroadcast = null; @@ -251,7 +257,7 @@ export class TransactionRawComponent implements OnInit, OnDestroy { resetForm() { this.resetState(); - this.pushTxForm.reset(); + this.pushTxForm.get('txRaw').setValue(''); } @HostListener('window:resize', ['$event']) diff --git a/frontend/src/app/shared/transaction.utils.ts b/frontend/src/app/shared/transaction.utils.ts index b33d88c2f..eafe8ae99 100644 --- a/frontend/src/app/shared/transaction.utils.ts +++ b/frontend/src/app/shared/transaction.utils.ts @@ -692,7 +692,7 @@ export function addInnerScriptsToVin(vin: Vin): void { if (vin.prevout.scriptpubkey_type === 'p2sh') { const redeemScript = vin.scriptsig_asm.split(' ').reverse()[0]; vin.inner_redeemscript_asm = convertScriptSigAsm(redeemScript); - if (vin.witness && vin.witness.length > 2) { + if (vin.witness && vin.witness.length) { const witnessScript = vin.witness[vin.witness.length - 1]; vin.inner_witnessscript_asm = convertScriptSigAsm(witnessScript); } @@ -712,86 +712,15 @@ export function addInnerScriptsToVin(vin: Vin): void { } // Adapted from bitcoinjs-lib at https://github.com/bitcoinjs/bitcoinjs-lib/blob/32e08aa57f6a023e995d8c4f0c9fbdc5f11d1fa0/ts_src/transaction.ts#L78 -// Reads buffer of raw transaction data -function fromBuffer(buffer: Uint8Array, network: string): Transaction { +/** + * @param buffer The raw transaction data + * @param network + * @param inputs Additional information from a PSBT, if available + * @returns The decoded transaction object and the raw hex + */ +function fromBuffer(buffer: Uint8Array, network: string, inputs?: { key: Uint8Array; value: Uint8Array }[][]): { tx: Transaction, hex: string } { let offset = 0; - function readInt8(): number { - if (offset + 1 > buffer.length) { - throw new Error('Buffer out of bounds'); - } - return buffer[offset++]; - } - - function readInt16() { - if (offset + 2 > buffer.length) { - throw new Error('Buffer out of bounds'); - } - const value = buffer[offset] | (buffer[offset + 1] << 8); - offset += 2; - return value; - } - - function readInt32(unsigned = false): number { - if (offset + 4 > buffer.length) { - throw new Error('Buffer out of bounds'); - } - const value = buffer[offset] | (buffer[offset + 1] << 8) | (buffer[offset + 2] << 16) | (buffer[offset + 3] << 24); - offset += 4; - if (unsigned) { - return value >>> 0; - } - return value; - } - - function readInt64(): bigint { - if (offset + 8 > buffer.length) { - throw new Error('Buffer out of bounds'); - } - const low = BigInt(buffer[offset] | (buffer[offset + 1] << 8) | (buffer[offset + 2] << 16) | (buffer[offset + 3] << 24)); - const high = BigInt(buffer[offset + 4] | (buffer[offset + 5] << 8) | (buffer[offset + 6] << 16) | (buffer[offset + 7] << 24)); - offset += 8; - return (high << 32n) | (low & 0xffffffffn); - } - - function readVarInt(): bigint { - const first = readInt8(); - if (first < 0xfd) { - return BigInt(first); - } else if (first === 0xfd) { - return BigInt(readInt16()); - } else if (first === 0xfe) { - return BigInt(readInt32(true)); - } else if (first === 0xff) { - return readInt64(); - } else { - throw new Error("Invalid VarInt prefix"); - } - } - - function readSlice(n: number | bigint): Uint8Array { - const length = Number(n); - if (offset + length > buffer.length) { - throw new Error('Cannot read slice out of bounds'); - } - const slice = buffer.slice(offset, offset + length); - offset += length; - return slice; - } - - function readVarSlice(): Uint8Array { - return readSlice(readVarInt()); - } - - function readVector(): Uint8Array[] { - const count = readVarInt(); - const vector = []; - for (let i = 0; i < count; i++) { - vector.push(readVarSlice()); - } - return vector; - } - // Parse raw transaction const tx = { status: { @@ -802,39 +731,47 @@ function fromBuffer(buffer: Uint8Array, network: string): Transaction { } } as Transaction; - tx.version = readInt32(); + [tx.version, offset] = readInt32(buffer, offset); - const marker = readInt8(); - const flag = readInt8(); + let marker, flag; + [marker, offset] = readInt8(buffer, offset); + [flag, offset] = readInt8(buffer, offset); - let hasWitnesses = false; - if ( - marker === 0x00 && - flag === 0x01 - ) { - hasWitnesses = true; + let isLegacyTransaction = true; + if (marker === 0x00 && flag === 0x01) { + isLegacyTransaction = false; } else { offset -= 2; } - const vinLen = readVarInt(); + let vinLen; + [vinLen, offset] = readVarInt(buffer, offset); + if (vinLen === 0) { + throw new Error('Transaction has no inputs'); + } tx.vin = []; for (let i = 0; i < vinLen; ++i) { - const txid = uint8ArrayToHexString(readSlice(32).reverse()); - const vout = readInt32(true); - const scriptsig = uint8ArrayToHexString(readVarSlice()); - const sequence = readInt32(true); + let txid, vout, scriptsig, sequence; + [txid, offset] = readSlice(buffer, offset, 32); + txid = uint8ArrayToHexString(txid.reverse()); + [vout, offset] = readInt32(buffer, offset, true); + [scriptsig, offset] = readVarSlice(buffer, offset); + scriptsig = uint8ArrayToHexString(scriptsig); + [sequence, offset] = readInt32(buffer, offset, true); const is_coinbase = txid === '0'.repeat(64); const scriptsig_asm = convertScriptSigAsm(scriptsig); tx.vin.push({ txid, vout, scriptsig, sequence, is_coinbase, scriptsig_asm, prevout: null }); } - const voutLen = readVarInt(); + let voutLen; + [voutLen, offset] = readVarInt(buffer, offset); tx.vout = []; for (let i = 0; i < voutLen; ++i) { - const value = Number(readInt64()); - const scriptpubkeyArray = readVarSlice(); - const scriptpubkey = uint8ArrayToHexString(scriptpubkeyArray) + let value, scriptpubkeyArray, scriptpubkey; + [value, offset] = readInt64(buffer, offset); + value = Number(value); + [scriptpubkeyArray, offset] = readVarSlice(buffer, offset); + scriptpubkey = uint8ArrayToHexString(scriptpubkeyArray); const scriptpubkey_asm = convertScriptSigAsm(scriptpubkey); const toAddress = scriptPubKeyToAddress(scriptpubkey, network); const scriptpubkey_type = toAddress.type; @@ -842,48 +779,303 @@ function fromBuffer(buffer: Uint8Array, network: string): Transaction { tx.vout.push({ value, scriptpubkey, scriptpubkey_asm, scriptpubkey_type, scriptpubkey_address }); } - let witnessSize = 0; - if (hasWitnesses) { - const startOffset = offset; + if (!isLegacyTransaction) { for (let i = 0; i < vinLen; ++i) { - tx.vin[i].witness = readVector().map(uint8ArrayToHexString); + let witness; + [witness, offset] = readVector(buffer, offset); + tx.vin[i].witness = witness.map(uint8ArrayToHexString); } - witnessSize = offset - startOffset + 2; } - tx.locktime = readInt32(true); + [tx.locktime, offset] = readInt32(buffer, offset, true); if (offset !== buffer.length) { throw new Error('Transaction has unexpected data'); } - tx.size = buffer.length; - tx.weight = (tx.size - witnessSize) * 3 + tx.size; + // Optionally add data from PSBT: prevouts, redeem/witness scripts and signatures + if (inputs) { + for (let i = 0; i < tx.vin.length; i++) { + const vin = tx.vin[i]; + const inputRecords = inputs[i]; - tx.txid = txid(tx); + const groups = { + nonWitnessUtxo: null, + witnessUtxo: null, + finalScriptSig: null, + finalScriptWitness: null, + redeemScript: null, + witnessScript: null, + partialSigs: [] + }; - return tx; -} + for (const record of inputRecords) { + switch (record.key[0]) { + case 0x00: + groups.nonWitnessUtxo = record; + break; + case 0x01: + groups.witnessUtxo = record; + break; + case 0x07: + groups.finalScriptSig = record; + break; + case 0x08: + groups.finalScriptWitness = record; + break; + case 0x04: + groups.redeemScript = record; + break; + case 0x05: + groups.witnessScript = record; + break; + case 0x02: + groups.partialSigs.push(record); + break; + } + } -export function decodeRawTransaction(rawtx: string, network: string): Transaction { - if (!rawtx.length || rawtx.length % 2 !== 0 || !/^[0-9a-fA-F]*$/.test(rawtx)) { - throw new Error('Invalid hex string'); + // Fill prevout + if (groups.witnessUtxo && !vin.prevout) { + let value, scriptpubkeyArray, scriptpubkey, outputOffset = 0; + [value, outputOffset] = readInt64(groups.witnessUtxo.value, outputOffset); + value = Number(value); + [scriptpubkeyArray, outputOffset] = readVarSlice(groups.witnessUtxo.value, outputOffset); + scriptpubkey = uint8ArrayToHexString(scriptpubkeyArray); + const scriptpubkey_asm = convertScriptSigAsm(scriptpubkey); + const toAddress = scriptPubKeyToAddress(scriptpubkey, network); + const scriptpubkey_type = toAddress.type; + const scriptpubkey_address = toAddress?.address; + vin.prevout = { value, scriptpubkey, scriptpubkey_asm, scriptpubkey_type, scriptpubkey_address }; + } + if (groups.nonWitnessUtxo && !vin.prevout) { + const utxoTx = fromBuffer(groups.nonWitnessUtxo.value, network).tx; + vin.prevout = utxoTx.vout[vin.vout]; + } + + // Fill final scriptSig or witness + let finalizedScriptSig = false; + if (groups.finalScriptSig) { + vin.scriptsig = uint8ArrayToHexString(groups.finalScriptSig.value); + vin.scriptsig_asm = convertScriptSigAsm(vin.scriptsig); + finalizedScriptSig = true; + } + let finalizedWitness = false; + if (groups.finalScriptWitness) { + let witness = []; + let witnessOffset = 0; + [witness, witnessOffset] = readVector(groups.finalScriptWitness.value, witnessOffset); + vin.witness = witness.map(uint8ArrayToHexString); + finalizedWitness = true; + } + if (finalizedScriptSig && finalizedWitness) { + continue; + } + + // Fill redeem script and/or witness script + if (groups.redeemScript && !finalizedScriptSig) { + const redeemScript = groups.redeemScript.value; + if (redeemScript.length > 520) { + throw new Error("Redeem script must be <= 520 bytes"); + } + let pushOpcode; + if (redeemScript.length < 0x4c) { + pushOpcode = new Uint8Array([redeemScript.length]); + } else if (redeemScript.length <= 0xff) { + pushOpcode = new Uint8Array([0x4c, redeemScript.length]); // OP_PUSHDATA1 + } else { + pushOpcode = new Uint8Array([0x4d, redeemScript.length & 0xff, redeemScript.length >> 8]); // OP_PUSHDATA2 + } + vin.scriptsig = (vin.scriptsig || '') + uint8ArrayToHexString(pushOpcode) + uint8ArrayToHexString(redeemScript); + vin.scriptsig_asm = convertScriptSigAsm(vin.scriptsig); + } + if (groups.witnessScript && !finalizedWitness) { + vin.witness = (vin.witness || []).concat(uint8ArrayToHexString(groups.witnessScript.value)); + } + + + // Fill partial signatures + for (const record of groups.partialSigs) { + const scriptpubkey_type = vin.prevout?.scriptpubkey_type; + if (scriptpubkey_type === 'v0_p2wsh' && !finalizedWitness) { + vin.witness = vin.witness || []; + vin.witness.unshift(uint8ArrayToHexString(record.value)); + } + if (scriptpubkey_type === 'p2sh') { + const redeemScriptStr = vin.scriptsig_asm ? vin.scriptsig_asm.split(' ').reverse()[0] : ''; + if (redeemScriptStr.startsWith('00') && redeemScriptStr.length === 68 && vin.witness?.length) { + if (!finalizedWitness) { + vin.witness.unshift(uint8ArrayToHexString(record.value)); + } + } else { + if (!finalizedScriptSig) { + const signature = record.value; + if (signature.length > 73) { + throw new Error("Signature must be <= 73 bytes"); + } + const pushOpcode = new Uint8Array([signature.length]); + vin.scriptsig = uint8ArrayToHexString(pushOpcode) + uint8ArrayToHexString(signature) + (vin.scriptsig || ''); + vin.scriptsig_asm = convertScriptSigAsm(vin.scriptsig); + } + } + } + } + } } - const buffer = new Uint8Array(rawtx.length / 2); - for (let i = 0; i < rawtx.length; i += 2) { - buffer[i / 2] = parseInt(rawtx.substring(i, i + 2), 16); + // Calculate final size, weight, and txid + const hasWitness = tx.vin.some(vin => vin.witness?.length); + let witnessSize = 0; + if (hasWitness) { + for (let i = 0; i < tx.vin.length; ++i) { + const witnessItems = tx.vin[i].witness || []; + witnessSize += getVarIntLength(witnessItems.length); + for (const item of witnessItems) { + const witnessItem = hexStringToUint8Array(item); + witnessSize += getVarIntLength(witnessItem.length); + witnessSize += witnessItem.length; + } + } + witnessSize += 2; + } + + const rawHex = serializeTransaction(tx, hasWitness); + tx.size = rawHex.length; + tx.weight = (tx.size - witnessSize) * 3 + tx.size; + tx.txid = txid(tx); + + return { tx, hex: uint8ArrayToHexString(rawHex) }; +} + +/** + * Decodes a PSBT buffer into the unsigned raw transaction and input map + * @param psbtBuffer + * @returns + * - the unsigned transaction from a PSBT (txHex) + * - the full input map for each input in to fill signatures and prevouts later (inputs) + */ +function decodePsbt(psbtBuffer: Uint8Array): { rawTx: Uint8Array; inputs: { key: Uint8Array; value: Uint8Array }[][] } { + let offset = 0; + + // magic: "psbt" in ASCII + const expectedMagic = [0x70, 0x73, 0x62, 0x74]; + for (let i = 0; i < expectedMagic.length; i++) { + if (psbtBuffer[offset + i] !== expectedMagic[i]) { + throw new Error("Invalid PSBT magic bytes"); + } + } + offset += expectedMagic.length; + + const separator = psbtBuffer[offset]; + offset += 1; + if (separator !== 0xff) { + throw new Error("Invalid PSBT separator"); + } + + // GLOBAL MAP + let rawTx: Uint8Array | null = null; + while (offset < psbtBuffer.length) { + const [keyLen, newOffset] = readVarInt(psbtBuffer, offset); + offset = newOffset; + // key length of 0 means the end of the global map + if (keyLen === 0) { + break; + } + const key = psbtBuffer.slice(offset, offset + keyLen); + offset += keyLen; + const [valLen, newOffset2] = readVarInt(psbtBuffer, offset); + offset = newOffset2; + const value = psbtBuffer.slice(offset, offset + valLen); + offset += valLen; + + // Global key type 0x00 holds the unsigned transaction. + if (key[0] === 0x00) { + rawTx = value; + } + } + + if (!rawTx) { + throw new Error("Unsigned transaction not found in PSBT"); + } + + let numInputs: number; + let txOffset = 0; + // Skip version (4 bytes) + txOffset += 4; + if (rawTx[txOffset] === 0x00 && rawTx[txOffset + 1] === 0x01) { + txOffset += 2; + } + const [inputCount, newTxOffset] = readVarInt(rawTx, txOffset); + txOffset = newTxOffset; + numInputs = inputCount; + + // INPUT MAPS + const inputs: { key: Uint8Array; value: Uint8Array }[][] = []; + for (let i = 0; i < numInputs; i++) { + const inputRecords: { key: Uint8Array; value: Uint8Array }[] = []; + const seenKeys = new Set(); + while (offset < psbtBuffer.length) { + const [keyLen, newOffset] = readVarInt(psbtBuffer, offset); + offset = newOffset; + // key length of 0 means the end of the input map + if (keyLen === 0) { + break; + } + const key = psbtBuffer.slice(offset, offset + keyLen); + offset += keyLen; + + const keyHex = uint8ArrayToHexString(key); + if (seenKeys.has(keyHex)) { + throw new Error(`Duplicate key in input map`); + } + seenKeys.add(keyHex); + + const [valLen, newOffset2] = readVarInt(psbtBuffer, offset); + offset = newOffset2; + const value = psbtBuffer.slice(offset, offset + valLen); + offset += valLen; + + inputRecords.push({ key, value }); + } + inputs.push(inputRecords); + } + + return { rawTx, inputs }; +} + +export function decodeRawTransaction(input: string, network: string): { tx: Transaction, hex: string } { + if (!input.length) { + throw new Error('Empty input'); + } + + let buffer: Uint8Array; + if (input.length % 2 === 0 && /^[0-9a-fA-F]+$/.test(input)) { + buffer = hexStringToUint8Array(input); + } else if (/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}(?:==)|[A-Za-z0-9+/]{3}=)?$/.test(input)) { + buffer = base64ToUint8Array(input); + } else { + throw new Error('Invalid input: not a valid transaction or PSBT'); + } + + if (buffer[0] === 0x70 && buffer[1] === 0x73 && buffer[2] === 0x62 && buffer[3] === 0x74) { // PSBT magic bytes + const { rawTx, inputs } = decodePsbt(buffer); + return fromBuffer(rawTx, network, inputs); } return fromBuffer(buffer, network); } -function serializeTransaction(tx: Transaction): Uint8Array { +function serializeTransaction(tx: Transaction, includeWitness: boolean = true): Uint8Array { const result: number[] = []; // Add version result.push(...intToBytes(tx.version, 4)); + if (includeWitness) { + // Add SegWit marker and flag bytes (0x00, 0x01) + result.push(0x00, 0x01); + } + // Add input count and inputs result.push(...varIntToBytes(tx.vin.length)); for (const input of tx.vin) { @@ -904,6 +1096,18 @@ function serializeTransaction(tx: Transaction): Uint8Array { result.push(...scriptPubKey); } + if (includeWitness) { + for (const input of tx.vin) { + const witnessItems = input.witness || []; + result.push(...varIntToBytes(witnessItems.length)); + for (const item of witnessItems) { + const witnessBytes = hexStringToUint8Array(item); + result.push(...varIntToBytes(witnessBytes.length)); + result.push(...witnessBytes); + } + } + } + // Add locktime result.push(...intToBytes(tx.locktime, 4)); @@ -911,7 +1115,7 @@ function serializeTransaction(tx: Transaction): Uint8Array { } function txid(tx: Transaction): string { - const serializedTx = serializeTransaction(tx); + const serializedTx = serializeTransaction(tx, false); const hash1 = new Hash().update(serializedTx).digest(); const hash2 = new Hash().update(hash1).digest(); return uint8ArrayToHexString(hash2.reverse()); @@ -1188,6 +1392,11 @@ function hexStringToUint8Array(hex: string): Uint8Array { return buf; } +function base64ToUint8Array(base64: string): Uint8Array { + const binaryString = atob(base64); + return new Uint8Array([...binaryString].map(char => char.charCodeAt(0))); +} + function intToBytes(value: number, byteLength: number): number[] { const bytes = []; for (let i = 0; i < byteLength; i++) { @@ -1230,6 +1439,88 @@ function varIntToBytes(value: number | bigint): number[] { return bytes; } +function readInt8(buffer: Uint8Array, offset: number): [number, number] { + if (offset + 1 > buffer.length) { + throw new Error('Buffer out of bounds'); + } + return [buffer[offset], offset + 1]; +} + +function readInt16(buffer: Uint8Array, offset: number): [number, number] { + if (offset + 2 > buffer.length) { + throw new Error('Buffer out of bounds'); + } + return [buffer[offset] | (buffer[offset + 1] << 8), offset + 2]; +} + +function readInt32(buffer: Uint8Array, offset: number, unsigned: boolean = false): [number, number] { + if (offset + 4 > buffer.length) { + throw new Error('Buffer out of bounds'); + } + const value = buffer[offset] | (buffer[offset + 1] << 8) | (buffer[offset + 2] << 16) | (buffer[offset + 3] << 24); + return [unsigned ? value >>> 0 : value, offset + 4]; +} + +function readInt64(buffer: Uint8Array, offset: number): [bigint, number] { + if (offset + 8 > buffer.length) { + throw new Error('Buffer out of bounds'); + } + const low = BigInt(buffer[offset] | (buffer[offset + 1] << 8) | (buffer[offset + 2] << 16) | (buffer[offset + 3] << 24)); + const high = BigInt(buffer[offset + 4] | (buffer[offset + 5] << 8) | (buffer[offset + 6] << 16) | (buffer[offset + 7] << 24)); + return [(high << 32n) | (low & 0xffffffffn), offset + 8]; +} + +function readVarInt(buffer: Uint8Array, offset: number): [number, number] { + const [first, newOffset] = readInt8(buffer, offset); + + if (first < 0xfd) { + return [first, newOffset]; + } else if (first === 0xfd) { + return readInt16(buffer, newOffset); + } else if (first === 0xfe) { + return readInt32(buffer, newOffset, true); + } else if (first === 0xff) { + const [bigValue, nextOffset] = readInt64(buffer, newOffset); + + if (bigValue > Number.MAX_SAFE_INTEGER) { + throw new Error("VarInt exceeds safe integer range"); + } + + const numValue = Number(bigValue); + return [numValue, nextOffset]; + } else { + throw new Error("Invalid VarInt prefix"); + } +} + +function readSlice(buffer: Uint8Array, offset: number, n: number | bigint): [Uint8Array, number] { + const length = Number(n); + if (offset + length > buffer.length) { + throw new Error('Cannot read slice out of bounds'); + } + const slice = buffer.slice(offset, offset + length); + return [slice, offset + length]; +} + +function readVarSlice(buffer: Uint8Array, offset: number): [Uint8Array, number] { + const [length, newOffset] = readVarInt(buffer, offset); + return readSlice(buffer, newOffset, length); +} + +function readVector(buffer: Uint8Array, offset: number): [Uint8Array[], number] { + const [count, newOffset] = readVarInt(buffer, offset); + let updatedOffset = newOffset; + const vector: Uint8Array[] = []; + + for (let i = 0; i < count; i++) { + const [slice, nextOffset] = readVarSlice(buffer, updatedOffset); + vector.push(slice); + updatedOffset = nextOffset; + } + + return [vector, updatedOffset]; +} + // Inversed the opcodes object from https://github.com/mempool/mempool/blob/14e49126c3ca8416a8d7ad134a95c5e090324d69/backend/src/utils/bitcoin-script.ts#L1 const opcodes = { 0: 'OP_0', From 494be165ad70b3e2f8d3b82afde710e86424c202 Mon Sep 17 00:00:00 2001 From: wiz Date: Tue, 4 Mar 2025 09:25:39 -1000 Subject: [PATCH 29/35] Update latest tag on dockerhub --- .github/workflows/on-tag.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/on-tag.yml b/.github/workflows/on-tag.yml index 8a846631c..ba9e1eb7b 100644 --- a/.github/workflows/on-tag.yml +++ b/.github/workflows/on-tag.yml @@ -105,7 +105,7 @@ jobs: --cache-to "type=local,dest=/tmp/.buildx-cache,mode=max" \ --platform linux/amd64,linux/arm64 \ --tag ${{ secrets.DOCKER_HUB_USER }}/${{ matrix.service }}:$TAG \ - # --tag ${{ secrets.DOCKER_HUB_USER }}/${{ matrix.service }}:latest \ + --tag ${{ secrets.DOCKER_HUB_USER }}/${{ matrix.service }}:latest \ --build-context rustgbt=./rust \ --build-context backend=./backend \ --output "type=registry,push=true" \ From c4e22a6225c04547666e3a0d962b3f9d6cab5db9 Mon Sep 17 00:00:00 2001 From: Mononaut Date: Wed, 5 Mar 2025 04:18:31 +0000 Subject: [PATCH 30/35] disabled ON UPDATE for blocks_audits time field --- backend/src/api/database-migration.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/backend/src/api/database-migration.ts b/backend/src/api/database-migration.ts index 4f43bd9d2..299cd309b 100644 --- a/backend/src/api/database-migration.ts +++ b/backend/src/api/database-migration.ts @@ -7,7 +7,7 @@ import cpfpRepository from '../repositories/CpfpRepository'; import { RowDataPacket } from 'mysql2'; class DatabaseMigration { - private static currentVersion = 95; + private static currentVersion = 96; private queryTimeout = 3600_000; private statisticsAddedIndexed = false; private uniqueLogs: string[] = []; @@ -1130,6 +1130,11 @@ class DatabaseMigration { await this.$executeQuery('ALTER TABLE blocks ADD INDEX `definition_hash` (`definition_hash`)'); await this.updateToSchemaVersion(95); } + + if (databaseSchemaVersion < 96) { + await this.$executeQuery(`ALTER TABLE blocks_audits MODIFY time timestamp NOT NULL DEFAULT 0`); + await this.updateToSchemaVersion(96); + } } /** From ad140dc60a32a9ced5d2772d355731f60f2553c6 Mon Sep 17 00:00:00 2001 From: natsoni Date: Wed, 5 Mar 2025 15:29:54 +0100 Subject: [PATCH 31/35] Tapscript multisig parsing --- frontend/src/app/shared/script.utils.ts | 61 +++++++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/frontend/src/app/shared/script.utils.ts b/frontend/src/app/shared/script.utils.ts index 731e0051b..62a7a5845 100644 --- a/frontend/src/app/shared/script.utils.ts +++ b/frontend/src/app/shared/script.utils.ts @@ -251,6 +251,11 @@ export function detectScriptTemplate(type: ScriptType, script_asm: string, witne return ScriptTemplates.multisig(multisig.m, multisig.n); } + const tapscriptMultisig = parseTapscriptMultisig(script_asm); + if (tapscriptMultisig) { + return ScriptTemplates.multisig(tapscriptMultisig.m, tapscriptMultisig.n); + } + return; } @@ -299,6 +304,62 @@ export function parseMultisigScript(script: string): undefined | { m: number, n: return { m, n }; } +export function parseTapscriptMultisig(script: string): undefined | { m: number, n: number } { + if (!script) { + return; + } + + const ops = script.split(' '); + // At minimum, one pubkey group (3 tokens) + m push + final opcode = 5 tokens + if (ops.length < 5) return; + + const finalOp = ops.pop(); + if (finalOp !== 'OP_NUMEQUAL' && finalOp !== 'OP_GREATERTHANOREQUAL') { + return; + } + + let m: number; + if (['OP_PUSHBYTES_1', 'OP_PUSHBYTES_2'].includes(ops[ops.length - 2])) { + const data = ops.pop(); + ops.pop(); + m = parseInt(data.match(/../g).reverse().join(''), 16); + } else if (ops[ops.length - 1].startsWith('OP_PUSHNUM_') || ops[ops.length - 1] === 'OP_0') { + m = parseInt(ops.pop().match(/[0-9]+/)?.[0], 10); + } else { + return; + } + + if (ops.length % 3 !== 0) { + return; + } + const n = ops.length / 3; + if (n < 1) { + return; + } + + for (let i = 0; i < n; i++) { + const push = ops.shift(); + const pubkey = ops.shift(); + const sigOp = ops.shift(); + + if (push !== 'OP_PUSHBYTES_32') { + return; + } + if (!/^[0-9a-fA-F]{64}$/.test(pubkey)) { + return; + } + if (sigOp !== (i === 0 ? 'OP_CHECKSIG' : 'OP_CHECKSIGADD')) { + return; + } + } + + if (ops.length) { + return; + } + + return { m, n }; +} + export function getVarIntLength(n: number): number { if (n < 0xfd) { return 1; From 55c09efb580f87849bc2c8c6d4a81b3345b0b17d Mon Sep 17 00:00:00 2001 From: Mononaut Date: Thu, 6 Mar 2025 02:42:17 +0000 Subject: [PATCH 32/35] be your own explorer --- frontend/src/app/components/about/about.component.html | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/src/app/components/about/about.component.html b/frontend/src/app/components/about/about.component.html index 433fe1abb..3bd8960f5 100644 --- a/frontend/src/app/components/about/about.component.html +++ b/frontend/src/app/components/about/about.component.html @@ -12,6 +12,7 @@
The Mempool Open Source Project ®

Our mempool and blockchain explorer for the Bitcoin community, focusing on the transaction fee market and multi-layer ecosystem, completely self-hosted without any trusted third-parties.

+
Be your own explorer™

What is a block explorer?

What is a mempool explorer?

Why isn't my transaction confirming?

+

Be your own explorer™

More FAQs »

Research

From 0b1895664b43af0c2d0b78a8e4e2a868d5bfea8a Mon Sep 17 00:00:00 2001 From: Mononaut Date: Thu, 6 Mar 2025 03:52:20 +0000 Subject: [PATCH 34/35] change staging proxy from fmt to va1 --- frontend/proxy.conf.staging.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/proxy.conf.staging.js b/frontend/proxy.conf.staging.js index 260b222c0..0165bed96 100644 --- a/frontend/proxy.conf.staging.js +++ b/frontend/proxy.conf.staging.js @@ -3,10 +3,10 @@ const fs = require('fs'); let PROXY_CONFIG = require('./proxy.conf'); PROXY_CONFIG.forEach(entry => { - const hostname = process.env.CYPRESS_REROUTE_TESTNET === 'true' ? 'mempool-staging.fra.mempool.space' : 'node201.fmt.mempool.space'; + const hostname = process.env.CYPRESS_REROUTE_TESTNET === 'true' ? 'mempool-staging.fra.mempool.space' : 'node201.va1.mempool.space'; console.log(`e2e tests running against ${hostname}`); entry.target = entry.target.replace("mempool.space", hostname); - entry.target = entry.target.replace("liquid.network", "liquid-staging.fmt.mempool.space"); + entry.target = entry.target.replace("liquid.network", "liquid-staging.va1.mempool.space"); }); module.exports = PROXY_CONFIG; From 3b9d9864cf9bf4f72f9b89b57aa4c5b28fe531f2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 7 Mar 2025 02:22:43 +0000 Subject: [PATCH 35/35] Bump mysql2 from 3.12.0 to 3.13.0 in /backend Bumps [mysql2](https://github.com/sidorares/node-mysql2) from 3.12.0 to 3.13.0. - [Release notes](https://github.com/sidorares/node-mysql2/releases) - [Changelog](https://github.com/sidorares/node-mysql2/blob/master/Changelog.md) - [Commits](https://github.com/sidorares/node-mysql2/compare/v3.12.0...v3.13.0) --- updated-dependencies: - dependency-name: mysql2 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- backend/package-lock.json | 14 +++++++------- backend/package.json | 2 +- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/backend/package-lock.json b/backend/package-lock.json index 1aaa77f85..a4963d6f0 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -17,7 +17,7 @@ "crypto-js": "~4.2.0", "express": "~4.21.1", "maxmind": "~4.3.11", - "mysql2": "~3.12.0", + "mysql2": "~3.13.0", "redis": "^4.7.0", "rust-gbt": "file:./rust-gbt", "socks-proxy-agent": "~7.0.0", @@ -6173,9 +6173,9 @@ "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" }, "node_modules/mysql2": { - "version": "3.12.0", - "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.12.0.tgz", - "integrity": "sha512-C8fWhVysZoH63tJbX8d10IAoYCyXy4fdRFz2Ihrt9jtPILYynFEKUUzpp1U7qxzDc3tMbotvaBH+sl6bFnGZiw==", + "version": "3.13.0", + "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.13.0.tgz", + "integrity": "sha512-M6DIQjTqKeqXH5HLbLMxwcK5XfXHw30u5ap6EZmu7QVmcF/gnh2wS/EOiQ4MTbXz/vQeoXrmycPlVRM00WSslg==", "license": "MIT", "dependencies": { "aws-ssl-profiles": "^1.1.1", @@ -12337,9 +12337,9 @@ "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" }, "mysql2": { - "version": "3.12.0", - "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.12.0.tgz", - "integrity": "sha512-C8fWhVysZoH63tJbX8d10IAoYCyXy4fdRFz2Ihrt9jtPILYynFEKUUzpp1U7qxzDc3tMbotvaBH+sl6bFnGZiw==", + "version": "3.13.0", + "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.13.0.tgz", + "integrity": "sha512-M6DIQjTqKeqXH5HLbLMxwcK5XfXHw30u5ap6EZmu7QVmcF/gnh2wS/EOiQ4MTbXz/vQeoXrmycPlVRM00WSslg==", "requires": { "aws-ssl-profiles": "^1.1.1", "denque": "^2.1.0", diff --git a/backend/package.json b/backend/package.json index efc5a4501..bcbc0f256 100644 --- a/backend/package.json +++ b/backend/package.json @@ -46,7 +46,7 @@ "crypto-js": "~4.2.0", "express": "~4.21.1", "maxmind": "~4.3.11", - "mysql2": "~3.12.0", + "mysql2": "~3.13.0", "rust-gbt": "file:./rust-gbt", "redis": "^4.7.0", "socks-proxy-agent": "~7.0.0",