From 2de16322ae2df5e975a6bebd566bc391bb6864a5 Mon Sep 17 00:00:00 2001 From: natsoni Date: Wed, 27 Nov 2024 17:54:07 +0100 Subject: [PATCH 01/54] 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/54] 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/54] 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/54] 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/54] 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/54] 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/54] 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/54] 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/54] 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/54] 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/54] 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/54] 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 6340dc571c6b5ae99eaadd275beddbe49502c3f2 Mon Sep 17 00:00:00 2001 From: natsoni Date: Sun, 9 Feb 2025 18:13:06 +0100 Subject: [PATCH 13/54] 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 831b923dda4244df789738471d64d05a1b377142 Mon Sep 17 00:00:00 2001 From: natsoni Date: Mon, 10 Feb 2025 15:32:16 +0100 Subject: [PATCH 14/54] 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 15/54] 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 d82a9f6c6a58366d2f022499d6b82644278356cf Mon Sep 17 00:00:00 2001 From: Felipe Knorr Kuhn Date: Tue, 25 Feb 2025 18:56:29 -0800 Subject: [PATCH 16/54] 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 17/54] 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 18/54] 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 19/54] 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 20/54] 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 21/54] 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 22/54] 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 23/54] 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 24/54] 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 25/54] 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™
From 0b1895664b43af0c2d0b78a8e4e2a868d5bfea8a Mon Sep 17 00:00:00 2001 From: Mononaut Date: Thu, 6 Mar 2025 03:52:20 +0000 Subject: [PATCH 27/54] 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 28/54] 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", From 9d711c336a51a05b55b7a7b56ec78d2ad2af2b6d Mon Sep 17 00:00:00 2001 From: nymkappa <1612910616@pm.me> Date: Sun, 9 Mar 2025 11:42:53 +0100 Subject: [PATCH 29/54] retreive -> retrieve --- backend/src/api/blocks.ts | 8 ++++---- backend/src/repositories/HashratesRepository.ts | 2 +- .../accelerate-checkout/accelerate-checkout.component.ts | 6 +++--- frontend/src/app/docs/api-docs/api-docs-data.ts | 2 +- frontend/src/app/services/services-api.service.ts | 2 +- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/backend/src/api/blocks.ts b/backend/src/api/blocks.ts index 102601594..beefc825b 100644 --- a/backend/src/api/blocks.ts +++ b/backend/src/api/blocks.ts @@ -1469,11 +1469,11 @@ class Blocks { if (rows && Array.isArray(rows)) { return rows.map(r => r.definition_hash); } else { - logger.debug(`Unable to retreive list of blocks.definition_hash from db (no result)`); + logger.debug(`Unable to retrieve list of blocks.definition_hash from db (no result)`); return null; } } catch (e) { - logger.debug(`Unable to retreive list of blocks.definition_hash from db (exception: ${e})`); + logger.debug(`Unable to retrieve list of blocks.definition_hash from db (exception: ${e})`); return null; } } @@ -1484,11 +1484,11 @@ class Blocks { if (rows && Array.isArray(rows)) { return rows.map(r => r.hash); } else { - logger.debug(`Unable to retreive list of blocks for definition hash ${definitionHash} from db (no result)`); + logger.debug(`Unable to retrieve list of blocks for definition hash ${definitionHash} from db (no result)`); return null; } } catch (e) { - logger.debug(`Unable to retreive list of blocks for definition hash ${definitionHash} from db (exception: ${e})`); + logger.debug(`Unable to retrieve list of blocks for definition hash ${definitionHash} from db (exception: ${e})`); return null; } } diff --git a/backend/src/repositories/HashratesRepository.ts b/backend/src/repositories/HashratesRepository.ts index ec44afebe..93aa2d53f 100644 --- a/backend/src/repositories/HashratesRepository.ts +++ b/backend/src/repositories/HashratesRepository.ts @@ -93,7 +93,7 @@ class HashratesRepository { const [rows]: any[] = await DB.query(query); return rows.map(row => row.timestamp); } catch (e) { - logger.err('Cannot retreive indexed weekly hashrate timestamps. Reason: ' + (e instanceof Error ? e.message : e), logger.tags.mining); + logger.err('Cannot retrieve indexed weekly hashrate timestamps. Reason: ' + (e instanceof Error ? e.message : e), logger.tags.mining); throw e; } } diff --git a/frontend/src/app/components/accelerate-checkout/accelerate-checkout.component.ts b/frontend/src/app/components/accelerate-checkout/accelerate-checkout.component.ts index ac6c7f147..bf70aebd3 100644 --- a/frontend/src/app/components/accelerate-checkout/accelerate-checkout.component.ts +++ b/frontend/src/app/components/accelerate-checkout/accelerate-checkout.component.ts @@ -525,7 +525,7 @@ export class AccelerateCheckout implements OnInit, OnDestroy { if (tokenResult?.status === 'OK') { const card = tokenResult.details?.card; if (!card || !card.brand || !card.expMonth || !card.expYear || !card.last4) { - console.error(`Cannot retreive payment card details`); + console.error(`Cannot retrieve payment card details`); this.accelerateError = 'apple_pay_no_card_details'; this.processing = false; return; @@ -643,7 +643,7 @@ export class AccelerateCheckout implements OnInit, OnDestroy { if (tokenResult?.status === 'OK') { const card = tokenResult.details?.card; if (!card || !card.brand || !card.expMonth || !card.expYear || !card.last4) { - console.error(`Cannot retreive payment card details`); + console.error(`Cannot retrieve payment card details`); this.accelerateError = 'apple_pay_no_card_details'; this.processing = false; return; @@ -936,7 +936,7 @@ export class AccelerateCheckout implements OnInit, OnDestroy { this.loadingBtcpayInvoice = true; this.servicesApiService.generateBTCPayAcceleratorInvoice$(this.tx.txid, this.userBid).pipe( switchMap(response => { - return this.servicesApiService.retreiveInvoice$(response.btcpayInvoiceId); + return this.servicesApiService.retrieveInvoice$(response.btcpayInvoiceId); }), catchError(error => { console.log(error); diff --git a/frontend/src/app/docs/api-docs/api-docs-data.ts b/frontend/src/app/docs/api-docs/api-docs-data.ts index c32baa3f7..5e9608bdf 100644 --- a/frontend/src/app/docs/api-docs/api-docs-data.ts +++ b/frontend/src/app/docs/api-docs/api-docs-data.ts @@ -11803,7 +11803,7 @@ export const restApiDocsData = [ fragment: "accelerator-cancel", title: "POST Cancel Acceleration (Pro)", description: { - default: "

Sends a request to cancel an acceleration in the accelerating status.
You can retreive eligible acceleration id using the history endpoint GET /api/v1/services/accelerator/history?status=accelerating." + default: "

Sends a request to cancel an acceleration in the accelerating status.
You can retrieve eligible acceleration id using the history endpoint GET /api/v1/services/accelerator/history?status=accelerating." }, urlString: "/v1/services/accelerator/cancel", showConditions: [""], diff --git a/frontend/src/app/services/services-api.service.ts b/frontend/src/app/services/services-api.service.ts index 59dc92358..a9550e731 100644 --- a/frontend/src/app/services/services-api.service.ts +++ b/frontend/src/app/services/services-api.service.ts @@ -213,7 +213,7 @@ export class ServicesApiServices { return this.httpClient.post(`${this.stateService.env.SERVICES_API}/payments/bitcoin`, params); } - retreiveInvoice$(invoiceId: string): Observable { + retrieveInvoice$(invoiceId: string): Observable { return this.httpClient.get(`${this.stateService.env.SERVICES_API}/payments/bitcoin/invoice?id=${invoiceId}`); } From 658151e0e8cee063d5f60852aaf0f1d9db1f38cf Mon Sep 17 00:00:00 2001 From: wiz Date: Tue, 11 Mar 2025 12:46:00 +0900 Subject: [PATCH 30/54] ops: Update electrs patch path for FreeBSD prod build --- production/install | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/production/install b/production/install index 05b12c08e..68eac6e5a 100755 --- a/production/install +++ b/production/install @@ -1314,7 +1314,7 @@ if [ "${BITCOIN_ELECTRS_INSTALL}" = ON ];then case $OS in FreeBSD) echo "[*] Patching Bitcoin Electrs code for FreeBSD" - osSudo "${BITCOIN_USER}" sh -c "cd \"${BITCOIN_HOME}/.cargo/registry/src/index.crates.io-6f17d22bba15001f/sysconf-0.3.4\" && patch -p1 < \"${MEMPOOL_HOME}/${MEMPOOL_REPO_NAME}/production/freebsd/sysconf.patch\"" + osSudo "${BITCOIN_USER}" sh -c "cd \"${BITCOIN_HOME}/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/sysconf-0.3.4\" && patch -p1 < \"${MEMPOOL_HOME}/${MEMPOOL_REPO_NAME}/production/freebsd/sysconf.patch\"" #osSudo "${BITCOIN_USER}" sh -c "cd \"${BITCOIN_ELECTRS_HOME}/src/new_index/\" && sed -i.bak -e s/Snappy/None/ db.rs && rm db.rs.bak" #osSudo "${BITCOIN_USER}" sh -c "cd \"${BITCOIN_ELECTRS_HOME}/src/bin/\" && sed -i.bak -e 's/from_secs(5)/from_secs(1)/' electrs.rs && rm electrs.rs.bak" ;; @@ -1364,7 +1364,7 @@ if [ "${ELEMENTS_ELECTRS_INSTALL}" = ON ];then case $OS in FreeBSD) echo "[*] Patching Liquid Electrs code for FreeBSD" - osSudo "${ELEMENTS_USER}" sh -c "cd \"${ELEMENTS_HOME}/.cargo/registry/src/index.crates.io-6f17d22bba15001f/sysconf-0.3.4\" && patch -p1 < \"${MEMPOOL_HOME}/${MEMPOOL_REPO_NAME}/production/freebsd/sysconf.patch\"" + osSudo "${ELEMENTS_USER}" sh -c "cd \"${ELEMENTS_HOME}/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/sysconf-0.3.4\" && patch -p1 < \"${MEMPOOL_HOME}/${MEMPOOL_REPO_NAME}/production/freebsd/sysconf.patch\"" ;; Debian) ;; From 7adc0083af973c65c321e9bcb96bfa371c87e401 Mon Sep 17 00:00:00 2001 From: wiz Date: Tue, 11 Mar 2025 14:08:51 +0900 Subject: [PATCH 31/54] ops: Modify prod install to run even if mysql exists --- production/install | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/production/install b/production/install index 68eac6e5a..487850b37 100755 --- a/production/install +++ b/production/install @@ -562,7 +562,7 @@ zfsCreateFilesystems() zfs create -o "mountpoint=${MINFEE_HOME}" "${ZPOOL}/minfee" zfs create -o "mountpoint=${ELECTRS_HOME}" "${ZPOOL}/electrs" zfs create -o "mountpoint=${MEMPOOL_HOME}" "${ZPOOL}/mempool" - zfs create -o "mountpoint=${MYSQL_HOME}" "${ZPOOL}/mysql" + zfs create -o "mountpoint=${MYSQL_HOME}" "${ZPOOL}/mysql" || true zfs create -o "mountpoint=${BITCOIN_ELECTRS_HOME}" "${ZPOOL}/bitcoin/electrs" @@ -1907,34 +1907,34 @@ esac sleep 10 mysql << _EOF_ -create database mempool; +create database if not exists mempool; grant all on mempool.* to '${MEMPOOL_MAINNET_USER}'@'localhost' identified by '${MEMPOOL_MAINNET_PASS}'; -create database mempool_testnet; +create database if not exists mempool_testnet; grant all on mempool_testnet.* to '${MEMPOOL_TESTNET_USER}'@'localhost' identified by '${MEMPOOL_TESTNET_PASS}'; -create database mempool_testnet4; +create database if not exists mempool_testnet4; grant all on mempool_testnet4.* to '${MEMPOOL_TESTNET4_USER}'@'localhost' identified by '${MEMPOOL_TESTNET4_PASS}'; -create database mempool_signet; +create database if not exists mempool_signet; grant all on mempool_signet.* to '${MEMPOOL_SIGNET_USER}'@'localhost' identified by '${MEMPOOL_SIGNET_PASS}'; -create database mempool_mainnet_lightning; +create database if not exists mempool_mainnet_lightning; grant all on mempool_mainnet_lightning.* to '${MEMPOOL_MAINNET_LIGHTNING_USER}'@'localhost' identified by '${MEMPOOL_MAINNET_LIGHTNING_PASS}'; -create database mempool_testnet_lightning; +create database if not exists mempool_testnet_lightning; grant all on mempool_testnet_lightning.* to '${MEMPOOL_TESTNET_LIGHTNING_USER}'@'localhost' identified by '${MEMPOOL_TESTNET_LIGHTNING_PASS}'; -create database mempool_signet_lightning; +create database if not exists mempool_signet_lightning; grant all on mempool_signet_lightning.* to '${MEMPOOL_SIGNET_LIGHTNING_USER}'@'localhost' identified by '${MEMPOOL_SIGNET_LIGHTNING_PASS}'; -create database mempool_liquid; +create database if not exists mempool_liquid; grant all on mempool_liquid.* to '${MEMPOOL_LIQUID_USER}'@'localhost' identified by '${MEMPOOL_LIQUID_PASS}'; -create database mempool_liquidtestnet; +create database if not exists mempool_liquidtestnet; grant all on mempool_liquidtestnet.* to '${MEMPOOL_LIQUIDTESTNET_USER}'@'localhost' identified by '${MEMPOOL_LIQUIDTESTNET_PASS}'; -create database mempool_bisq; +create database if not exists mempool_bisq; grant all on mempool_bisq.* to '${MEMPOOL_BISQ_USER}'@'localhost' identified by '${MEMPOOL_BISQ_PASS}'; _EOF_ From 636b4c0da72d31ddde3e27bdfa7d1e078d62327c Mon Sep 17 00:00:00 2001 From: wiz Date: Tue, 11 Mar 2025 15:46:03 +0900 Subject: [PATCH 32/54] ops: Add missing if check for CLN in prod install --- production/install | 3 +++ 1 file changed, 3 insertions(+) diff --git a/production/install b/production/install index 487850b37..7c84e5956 100755 --- a/production/install +++ b/production/install @@ -1378,6 +1378,8 @@ fi # Core Lightning for Bitcoin # ############################## +if [ "${CLN_INSTALL}" = ON ];then + echo "[*] Installing Core Lightning" case $OS in FreeBSD) @@ -1418,6 +1420,7 @@ case $OS in ;; esac +fi ##################### # Bisq installation # From 9bef19449fd24dc0e6a47a24a58f7a7dcc7bc051 Mon Sep 17 00:00:00 2001 From: wiz Date: Tue, 11 Mar 2025 16:37:14 +0900 Subject: [PATCH 33/54] ops: Comment out old keybase commands in build script --- production/mempool-build-all | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/production/mempool-build-all b/production/mempool-build-all index 377deb316..10ad179ea 100755 --- a/production/mempool-build-all +++ b/production/mempool-build-all @@ -173,7 +173,7 @@ for repo in $frontend_repos;do done # notify everyone -echo "${HOSTNAME} updated to \`${REF}\` @ \`${HASH}\`" | /usr/local/bin/keybase chat send --nonblock --channel general mempool.dev -echo "${HOSTNAME} updated to \`${REF}\` @ \`${HASH}\`" | /usr/local/bin/keybase chat send --nonblock --channel general "mempool.ops.${LOCATION}" +#echo "${HOSTNAME} updated to \`${REF}\` @ \`${HASH}\`" | /usr/local/bin/keybase chat send --nonblock --channel general mempool.dev +#echo "${HOSTNAME} updated to \`${REF}\` @ \`${HASH}\`" | /usr/local/bin/keybase chat send --nonblock --channel general "mempool.ops.${LOCATION}" exit 0 From c79ef93413a46115522e801cb40590df8a2afe37 Mon Sep 17 00:00:00 2001 From: wiz Date: Tue, 11 Mar 2025 16:38:26 +0900 Subject: [PATCH 34/54] ops: Bump prod to bitcoin v28.1 + elements 23.2.6 --- production/install | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/production/install b/production/install index 7c84e5956..f3bebb4e7 100755 --- a/production/install +++ b/production/install @@ -357,7 +357,7 @@ BITCOIN_REPO_URL=https://github.com/bitcoin/bitcoin BITCOIN_REPO_NAME=bitcoin BITCOIN_REPO_BRANCH=master #BITCOIN_LATEST_RELEASE=$(curl -s https://api.github.com/repos/bitcoin/bitcoin/releases/latest|grep tag_name|head -1|cut -d '"' -f4) -BITCOIN_LATEST_RELEASE=v28.0 +BITCOIN_LATEST_RELEASE=v28.1 echo -n '.' BISQ_REPO_URL=https://github.com/bisq-network/bisq @@ -378,7 +378,7 @@ ELEMENTS_REPO_URL=https://github.com/ElementsProject/elements ELEMENTS_REPO_NAME=elements ELEMENTS_REPO_BRANCH=master #ELEMENTS_LATEST_RELEASE=$(curl -s https://api.github.com/repos/ElementsProject/elements/releases/latest|grep tag_name|head -1|cut -d '"' -f4) -ELEMENTS_LATEST_RELEASE=elements-22.1.1 +ELEMENTS_LATEST_RELEASE=elements-23.2.6 echo -n '.' BITCOIN_ELECTRS_REPO_URL=https://github.com/mempool/electrs From 65b276678fb8c64ff0b2ac28d0384962a021b3dc Mon Sep 17 00:00:00 2001 From: wiz Date: Wed, 12 Mar 2025 09:35:55 +0900 Subject: [PATCH 35/54] ops: Add negative balance check to check script --- production/check | 33 +++++++++++++++++++++------------ 1 file changed, 21 insertions(+), 12 deletions(-) diff --git a/production/check b/production/check index 3f31e67ee..3da5e920d 100755 --- a/production/check +++ b/production/check @@ -18,28 +18,37 @@ check_mempoolfoss_frontend_md5_hash() { check_mempool_electrs_git_hash() { echo -n $(curl -s -i https://node${1}.${2}.mempool.space/api/mempool|grep -i x-powered-by|cut -d ' ' -f3|cut -d '-' -f3|tr -d '\r'|tr -d '\n') } +check_mempool_electrs_negative_balance() { + echo -n $(curl -s https://node${1}.${2}.mempool.space/api/address/35Ty15fzBPGQvKnXZMLYvr41Fq2FTdU54a|jq .chain_stats.spent_txo_sum|tr -d '\r'|tr -d '\n') +} check_liquid_electrs_git_hash() { echo -n $(curl -s -i --connect-to "::node${1}.${2}.mempool.space:443" https://liquid.network/api/mempool|grep -i x-powered-by|cut -d ' ' -f3|cut -d '-' -f3|tr -d '\r'|tr -d '\n') } -for site in fmt va1 fra tk7;do +check_contributors_md5_hash() { + echo -n $(curl -s --connect-to "::node${1}.${2}.mempool.space:443" https://mempool.space/api/v1/contributors|md5|cut -c1-8) +} +for site in va1 fra tk7 fmt;do echo "${site}" for node in 201 202 203 204 205 206 207 208 209 210 211 212 213 214;do [ "${site}" = "fmt" ] && [ "${node}" -gt 206 ] && continue [ "${site}" = "tk7" ] && [ "${node}" -gt 206 ] && continue echo -n "node${node}.${site}: " - check_mempoolspace_frontend_git_hash $node $site - echo -n " " - check_mempoolfoss_frontend_git_hash $node $site - echo -n " " - check_mempoolfoss_backend_git_hash $node $site - echo -n " " - check_mempoolspace_frontend_md5_hash $node $site - echo -n " " - check_mempoolfoss_frontend_md5_hash $node $site - echo -n " " +# check_mempoolspace_frontend_git_hash $node $site +# echo -n " " +# check_mempoolfoss_frontend_git_hash $node $site +# echo -n " " +# check_mempoolfoss_backend_git_hash $node $site +# echo -n " " +# check_mempoolspace_frontend_md5_hash $node $site +# echo -n " " +# check_mempoolfoss_frontend_md5_hash $node $site +# echo -n " " check_mempool_electrs_git_hash $node $site echo -n " " - check_liquid_electrs_git_hash $node $site +# check_liquid_electrs_git_hash $node $site +# echo -n " " +# check_contributors_md5_hash $node $site + check_mempool_electrs_negative_balance $node $site echo done done From 305d931d5c3fc5b87b1a2c08efbb4f634a3dee7f Mon Sep 17 00:00:00 2001 From: wiz Date: Wed, 12 Mar 2025 09:43:00 +0900 Subject: [PATCH 36/54] ops: Add more sites to check script --- production/check | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/production/check b/production/check index 3da5e920d..d1bba7826 100755 --- a/production/check +++ b/production/check @@ -27,9 +27,12 @@ check_liquid_electrs_git_hash() { check_contributors_md5_hash() { echo -n $(curl -s --connect-to "::node${1}.${2}.mempool.space:443" https://mempool.space/api/v1/contributors|md5|cut -c1-8) } -for site in va1 fra tk7 fmt;do +for site in va1 fra tk7 sg1 hnl;do echo "${site}" for node in 201 202 203 204 205 206 207 208 209 210 211 212 213 214;do + [ "${site}" = "fmt" ] && [ "${node}" = 203 ] && continue + [ "${site}" = "sg1" ] && [ "${node}" -gt 204 ] && continue + [ "${site}" = "hnl" ] && [ "${node}" -gt 204 ] && continue [ "${site}" = "fmt" ] && [ "${node}" -gt 206 ] && continue [ "${site}" = "tk7" ] && [ "${node}" -gt 206 ] && continue echo -n "node${node}.${site}: " From a152afb4af6aedc111b08c1e25ed9813e22656f9 Mon Sep 17 00:00:00 2001 From: wiz Date: Wed, 12 Mar 2025 11:51:03 +0900 Subject: [PATCH 37/54] ops: Fix nginx conf for elements unix socket paths --- production/nginx/nginx.conf | 4 ++-- production/nginx/server-esplora.conf | 4 ++-- production/nginx/upstream-esplora.conf | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/production/nginx/nginx.conf b/production/nginx/nginx.conf index 81c0c01d5..790bd6d73 100644 --- a/production/nginx/nginx.conf +++ b/production/nginx/nginx.conf @@ -99,8 +99,8 @@ http { set $mempoolTestnet "http://mempool-liquid-testnet"; # for blockstream/esplora daemon, see upstream-esplora.conf - set $esploraMainnet "http://esplora-liquid-mainnet"; - set $esploraTestnet "http://esplora-liquid-testnet"; + set $esploraMainnet "http://esplora-elements-liquid"; + set $esploraTestnet "http://esplora-elements-liquidtestnet"; # filesystem paths root /mempool/public_html/liquid/; diff --git a/production/nginx/server-esplora.conf b/production/nginx/server-esplora.conf index 38cdb0bc2..fdbea6bd5 100644 --- a/production/nginx/server-esplora.conf +++ b/production/nginx/server-esplora.conf @@ -9,7 +9,7 @@ server { listen 127.0.0.1:4001; access_log /dev/null; location / { - proxy_pass http://esplora-liquid-mainnet; + proxy_pass http://esplora-elements-liquid; } } server { @@ -30,6 +30,6 @@ server { listen 127.0.0.1:4004; access_log /dev/null; location / { - proxy_pass http://esplora-liquid-testnet; + proxy_pass http://esplora-elements-liquidtestnet; } } diff --git a/production/nginx/upstream-esplora.conf b/production/nginx/upstream-esplora.conf index 88ffa11bd..80b76df2d 100644 --- a/production/nginx/upstream-esplora.conf +++ b/production/nginx/upstream-esplora.conf @@ -1,7 +1,7 @@ upstream esplora-bitcoin-mainnet { server unix:/bitcoin/socket/esplora-bitcoin-mainnet fail_timeout=10s max_fails=10 weight=99999; } -upstream esplora-liquid-mainnet { +upstream esplora-elements-liquid { server unix:/elements/socket/esplora-elements-liquid fail_timeout=10s max_fails=10 weight=99999; } upstream esplora-bitcoin-testnet { @@ -13,6 +13,6 @@ upstream esplora-bitcoin-testnet4 { upstream esplora-bitcoin-signet { server unix:/bitcoin/socket/esplora-bitcoin-signet fail_timeout=10s max_fails=10 weight=99999; } -upstream esplora-liquid-testnet { +upstream esplora-elements-liquidtestnet { server unix:/elements/socket/esplora-elements-liquidtestnet fail_timeout=10s max_fails=10 weight=99999; } From 7d9e275803e90b621b4419561962086e5f41a3f2 Mon Sep 17 00:00:00 2001 From: nymkappa <1612910616@pm.me> Date: Wed, 12 Mar 2025 14:56:15 +0100 Subject: [PATCH 38/54] [accelerator] show square receipt if available --- .../accelerate-checkout.component.html | 19 +++++++++++++++++-- .../accelerate-checkout.component.ts | 16 ++++++++-------- 2 files changed, 25 insertions(+), 10 deletions(-) diff --git a/frontend/src/app/components/accelerate-checkout/accelerate-checkout.component.html b/frontend/src/app/components/accelerate-checkout/accelerate-checkout.component.html index 4594fd9fc..2038d4b6c 100644 --- a/frontend/src/app/components/accelerate-checkout/accelerate-checkout.component.html +++ b/frontend/src/app/components/accelerate-checkout/accelerate-checkout.component.html @@ -567,14 +567,29 @@ } @else if (step === 'success') {

-

Your transaction is being accelerated!

+

+ @if (accelerationResponse) { + Your transaction is being accelerated! + } @else { + Transaction is already being accelerated! + } +

- Your transaction has been accepted for acceleration by our mining pool partners. + @if (accelerationResponse) { + Your transaction has been accepted for acceleration by our mining pool partners. + } @else { + Transaction has already been accepted for acceleration by our mining pool partners. + }
+ @if (accelerationResponse?.receiptUrl) { + + }

diff --git a/frontend/src/app/components/accelerate-checkout/accelerate-checkout.component.ts b/frontend/src/app/components/accelerate-checkout/accelerate-checkout.component.ts index ac6c7f147..06d2fa4cd 100644 --- a/frontend/src/app/components/accelerate-checkout/accelerate-checkout.component.ts +++ b/frontend/src/app/components/accelerate-checkout/accelerate-checkout.component.ts @@ -87,6 +87,7 @@ export class AccelerateCheckout implements OnInit, OnDestroy { math = Math; isMobile: boolean = window.innerWidth <= 767.98; isProdDomain = false; + accelerationResponse: { receiptUrl: string | null } | undefined; private _step: CheckoutStep = 'summary'; simpleMode: boolean = true; @@ -194,11 +195,7 @@ export class AccelerateCheckout implements OnInit, OnDestroy { this.scrollToElement('acceleratePreviewAnchor', 'start'); } if (changes.accelerating && this.accelerating) { - if (this.step === 'processing' || this.step === 'paid') { - this.moveToStep('success', true); - } else { // Edge case where the transaction gets accelerated by someone else or on another session - this.closeModal(); - } + this.moveToStep('success', true); } } @@ -541,7 +538,8 @@ export class AccelerateCheckout implements OnInit, OnDestroy { `accelerator-${this.tx.txid.substring(0, 15)}-${Math.round(new Date().getTime() / 1000)}`, costUSD ).subscribe({ - next: () => { + next: (response) => { + this.accelerationResponse = response; this.processing = false; this.apiService.logAccelerationRequest$(this.tx.txid).subscribe(); this.audioService.playSound('ascend-chime-cartoon'); @@ -668,7 +666,8 @@ export class AccelerateCheckout implements OnInit, OnDestroy { costUSD, verificationToken.userChallenged ).subscribe({ - next: () => { + next: (response) => { + this.accelerationResponse = response; this.processing = false; this.apiService.logAccelerationRequest$(this.tx.txid).subscribe(); this.audioService.playSound('ascend-chime-cartoon'); @@ -777,7 +776,8 @@ export class AccelerateCheckout implements OnInit, OnDestroy { costUSD, verificationToken.userChallenged ).subscribe({ - next: () => { + next: (response) => { + this.accelerationResponse = response; this.processing = false; this.apiService.logAccelerationRequest$(this.tx.txid).subscribe(); this.audioService.playSound('ascend-chime-cartoon'); From cac404ae9bbce0c6192fddf3a99bb8c79483f134 Mon Sep 17 00:00:00 2001 From: nymkappa <1612910616@pm.me> Date: Wed, 12 Mar 2025 15:26:44 +0100 Subject: [PATCH 39/54] [accelerator] make sure we cannot go back from 'success' step --- .../accelerate-checkout/accelerate-checkout.component.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/app/components/accelerate-checkout/accelerate-checkout.component.ts b/frontend/src/app/components/accelerate-checkout/accelerate-checkout.component.ts index 06d2fa4cd..0db40af82 100644 --- a/frontend/src/app/components/accelerate-checkout/accelerate-checkout.component.ts +++ b/frontend/src/app/components/accelerate-checkout/accelerate-checkout.component.ts @@ -200,7 +200,7 @@ export class AccelerateCheckout implements OnInit, OnDestroy { } moveToStep(step: CheckoutStep, force: boolean = false): void { - if (this.isCheckoutLocked > 0 && !force) { + if (this.isCheckoutLocked > 0 && !force || this.step === 'success') { return; } this.processing = false; From 1121377c7a888d8a1d12859cf9488d3db4b56249 Mon Sep 17 00:00:00 2001 From: nymkappa <1612910616@pm.me> Date: Wed, 12 Mar 2025 15:27:00 +0100 Subject: [PATCH 40/54] [accelerator] add missing response from cashapp payment --- .../accelerate-checkout/accelerate-checkout.component.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frontend/src/app/components/accelerate-checkout/accelerate-checkout.component.ts b/frontend/src/app/components/accelerate-checkout/accelerate-checkout.component.ts index 0db40af82..7fc1e88ef 100644 --- a/frontend/src/app/components/accelerate-checkout/accelerate-checkout.component.ts +++ b/frontend/src/app/components/accelerate-checkout/accelerate-checkout.component.ts @@ -870,7 +870,8 @@ export class AccelerateCheckout implements OnInit, OnDestroy { tokenResult.details.cashAppPay.referenceId, costUSD ).subscribe({ - next: () => { + next: (response) => { + this.accelerationResponse = response; this.processing = false; this.apiService.logAccelerationRequest$(this.tx.txid).subscribe(); this.audioService.playSound('ascend-chime-cartoon'); From 062c5ca03a0d1bf4e8678dc717b9df5b228a4955 Mon Sep 17 00:00:00 2001 From: natsoni Date: Wed, 12 Mar 2025 16:47:31 +0100 Subject: [PATCH 41/54] Trim input data in tx preview --- .../src/app/components/transaction/transaction-raw.component.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/app/components/transaction/transaction-raw.component.ts b/frontend/src/app/components/transaction/transaction-raw.component.ts index 5ce170e12..f7ae4a751 100644 --- a/frontend/src/app/components/transaction/transaction-raw.component.ts +++ b/frontend/src/app/components/transaction/transaction-raw.component.ts @@ -82,7 +82,7 @@ export class TransactionRawComponent implements OnInit, OnDestroy { this.resetState(); this.isLoading = true; try { - const { tx, hex } = decodeRawTransaction(this.pushTxForm.get('txRaw').value, this.stateService.network); + const { tx, hex } = decodeRawTransaction(this.pushTxForm.get('txRaw').value.trim(), this.stateService.network); await this.fetchPrevouts(tx); await this.fetchCpfpInfo(tx); this.processTransaction(tx, hex); From 4dff3adf1169d056f35acd5b3c28bb5fadbd850d Mon Sep 17 00:00:00 2001 From: natsoni Date: Wed, 12 Mar 2025 16:49:38 +0100 Subject: [PATCH 42/54] Redirect to tx page 2 seconds after broadcast --- .../transaction-raw.component.html | 11 ++++--- .../transaction-raw.component.scss | 8 +++++ .../transaction/transaction-raw.component.ts | 31 ++++++++++++------- 3 files changed, 34 insertions(+), 16 deletions(-) diff --git a/frontend/src/app/components/transaction/transaction-raw.component.html b/frontend/src/app/components/transaction/transaction-raw.component.html index 3bd8ee6d2..35701889b 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 @@
-
@@ -40,14 +39,18 @@
-
+
- + This transaction is stored locally in your browser. Broadcast it to add it to the mempool. + + Redirecting to transaction page... + - + +
@if (!hasPrevouts) { diff --git a/frontend/src/app/components/transaction/transaction-raw.component.scss b/frontend/src/app/components/transaction/transaction-raw.component.scss index 5bbe5601e..a4b386cee 100644 --- a/frontend/src/app/components/transaction/transaction-raw.component.scss +++ b/frontend/src/app/components/transaction/transaction-raw.component.scss @@ -191,4 +191,12 @@ .no-cursor { cursor: default !important; pointer-events: none; +} + +.btn-broadcast { + margin-left: 5px; + @media (max-width: 567px) { + margin-left: 0; + margin-top: 5px; + } } \ 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 f7ae4a751..2e4dd4868 100644 --- a/frontend/src/app/components/transaction/transaction-raw.component.ts +++ b/frontend/src/app/components/transaction/transaction-raw.component.ts @@ -3,7 +3,7 @@ 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'; -import { firstValueFrom, Subscription } from 'rxjs'; +import { catchError, firstValueFrom, Subscription, switchMap, tap, throwError, timer } from 'rxjs'; import { WebsocketService } from '../../services/websocket.service'; import { ActivatedRoute, Router } from '@angular/router'; import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; @@ -36,6 +36,7 @@ export class TransactionRawComponent implements OnInit, OnDestroy { isLoadingBroadcast: boolean; errorBroadcast: string; successBroadcast: boolean; + broadcastSubscription: Subscription; isMobile: boolean; @ViewChild('graphContainer') @@ -207,18 +208,22 @@ export class TransactionRawComponent implements OnInit, OnDestroy { }); } - async postTx(): Promise { + postTx(): void { this.isLoadingBroadcast = true; this.errorBroadcast = null; - return new Promise((resolve, reject) => { - this.apiService.postTransaction$(this.rawHexTransaction) - .subscribe((result) => { + + this.broadcastSubscription = this.apiService.postTransaction$(this.rawHexTransaction).pipe( + tap((txid: string) => { this.isLoadingBroadcast = false; this.successBroadcast = true; - this.transaction.txid = result; - resolve(result); - }, - (error) => { + this.transaction.txid = txid; + }), + switchMap((txid: string) => + timer(2000).pipe( + tap(() => this.router.navigate([this.relativeUrlPipe.transform('/tx/' + txid)])), + ) + ), + catchError((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); @@ -226,9 +231,9 @@ export class TransactionRawComponent implements OnInit, OnDestroy { this.errorBroadcast = 'Failed to broadcast transaction, reason: ' + error.message; } this.isLoadingBroadcast = false; - reject(this.error); - }); - }); + return throwError(() => error); + }) + ).subscribe(); } resetState() { @@ -253,6 +258,7 @@ export class TransactionRawComponent implements OnInit, OnDestroy { this.missingPrevouts = []; this.stateService.markBlock$.next({}); this.mempoolBlocksSubscription?.unsubscribe(); + this.broadcastSubscription?.unsubscribe(); } resetForm() { @@ -308,6 +314,7 @@ export class TransactionRawComponent implements OnInit, OnDestroy { this.mempoolBlocksSubscription?.unsubscribe(); this.flowPrefSubscription?.unsubscribe(); this.stateService.markBlock$.next({}); + this.broadcastSubscription?.unsubscribe(); } } From 8efea6160135a6284142f911aaf03aab4045aaae Mon Sep 17 00:00:00 2001 From: wiz Date: Thu, 13 Mar 2025 09:49:40 +0900 Subject: [PATCH 43/54] ops: Add electrs popular-scripts cron jobs --- production/bitcoin.crontab | 9 +++++++++ production/elements.crontab | 4 ++++ 2 files changed, 13 insertions(+) diff --git a/production/bitcoin.crontab b/production/bitcoin.crontab index a5bc64241..a17147f7b 100644 --- a/production/bitcoin.crontab +++ b/production/bitcoin.crontab @@ -1,7 +1,16 @@ +# start test network daemons on boot @reboot sleep 5 ; /usr/local/bin/bitcoind -testnet >/dev/null 2>&1 @reboot sleep 5 ; /usr/local/bin/bitcoind -testnet4 >/dev/null 2>&1 @reboot sleep 5 ; /usr/local/bin/bitcoind -signet >/dev/null 2>&1 + +# start electrs on boot @reboot sleep 10 ; screen -dmS mainnet /bitcoin/electrs/start mainnet @reboot sleep 10 ; screen -dmS testnet /bitcoin/electrs/start testnet @reboot sleep 10 ; screen -dmS testnet4 /bitcoin/electrs/start testnet4 @reboot sleep 10 ; screen -dmS signet /bitcoin/electrs/start signet + +# daily update of popular-scripts +30 03 * * * $HOME/electrs/start testnet4 popular-scripts >/dev/null 2>&1 +31 03 * * * $HOME/electrs/start testnet popular-scripts >/dev/null 2>&1 +32 03 * * * $HOME/electrs/start signet popular-scripts >/dev/null 2>&1 +33 03 * * * $HOME/electrs/start mainnet popular-scripts >/dev/null 2>&1 diff --git a/production/elements.crontab b/production/elements.crontab index 4f837706e..6590dfbd7 100644 --- a/production/elements.crontab +++ b/production/elements.crontab @@ -8,3 +8,7 @@ # hourly asset update and electrs restart 6 * * * * cd $HOME/asset_registry_db && git pull --quiet origin master && cd $HOME/asset_registry_testnet_db && git pull --quiet origin master && killall electrs + +# daily update of popular-scripts +32 03 * * * $HOME/electrs/start liquid popular-scripts >/dev/null 2>&1 +33 03 * * * $HOME/electrs/start liquidtestnet popular-scripts >/dev/null 2>&1 From e94dc67b3187ebfbaa630e43805e2f4be306803a Mon Sep 17 00:00:00 2001 From: natsoni Date: Thu, 13 Mar 2025 15:46:11 +0100 Subject: [PATCH 44/54] Update tapscript multisig minimum size --- frontend/src/app/shared/script.utils.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/frontend/src/app/shared/script.utils.ts b/frontend/src/app/shared/script.utils.ts index 62a7a5845..df50a4070 100644 --- a/frontend/src/app/shared/script.utils.ts +++ b/frontend/src/app/shared/script.utils.ts @@ -310,8 +310,10 @@ export function parseTapscriptMultisig(script: string): undefined | { m: number, } const ops = script.split(' '); - // At minimum, one pubkey group (3 tokens) + m push + final opcode = 5 tokens - if (ops.length < 5) return; + // At minimum, 2 pubkey group (3 tokens) + m push + final opcode = 8 tokens + if (ops.length < 8) { + return; + } const finalOp = ops.pop(); if (finalOp !== 'OP_NUMEQUAL' && finalOp !== 'OP_GREATERTHANOREQUAL') { From 188096e651f745ef8a09d4091d1eb727282a50c4 Mon Sep 17 00:00:00 2001 From: natsoni Date: Thu, 13 Mar 2025 16:38:53 +0100 Subject: [PATCH 45/54] Add opcodes that can be used for tapscript multisig --- frontend/src/app/shared/script.utils.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/frontend/src/app/shared/script.utils.ts b/frontend/src/app/shared/script.utils.ts index df50a4070..f0c4701db 100644 --- a/frontend/src/app/shared/script.utils.ts +++ b/frontend/src/app/shared/script.utils.ts @@ -316,7 +316,7 @@ export function parseTapscriptMultisig(script: string): undefined | { m: number, } const finalOp = ops.pop(); - if (finalOp !== 'OP_NUMEQUAL' && finalOp !== 'OP_GREATERTHANOREQUAL') { + if (!['OP_NUMEQUAL', 'OP_NUMEQUALVERIFY', 'OP_GREATERTHANOREQUAL', 'OP_GREATERTHAN', 'OP_EQUAL', 'OP_EQUALVERIFY'].includes(finalOp)) { return; } @@ -331,6 +331,10 @@ export function parseTapscriptMultisig(script: string): undefined | { m: number, return; } + if (finalOp === 'OP_GREATERTHAN') { + m += 1; + } + if (ops.length % 3 !== 0) { return; } From 76f31623feffd946c7072a208c990f1f68f6c9cc Mon Sep 17 00:00:00 2001 From: Felipe Knorr Kuhn Date: Thu, 13 Mar 2025 15:49:45 -0700 Subject: [PATCH 46/54] Don't tag as latest by default --- .github/workflows/on-tag.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/on-tag.yml b/.github/workflows/on-tag.yml index ba9e1eb7b..1447ec4ab 100644 --- a/.github/workflows/on-tag.yml +++ b/.github/workflows/on-tag.yml @@ -105,7 +105,6 @@ 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 \ --build-context rustgbt=./rust \ --build-context backend=./backend \ --output "type=registry,push=true" \ From 4d89cb01bd06a3176f9a19a793dd85fef8eaf93a Mon Sep 17 00:00:00 2001 From: wiz Date: Fri, 14 Mar 2025 13:34:09 +0900 Subject: [PATCH 47/54] ops: Tweak delay times for bitcoin.crontab --- production/bitcoin.crontab | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/production/bitcoin.crontab b/production/bitcoin.crontab index a17147f7b..63df3c52a 100644 --- a/production/bitcoin.crontab +++ b/production/bitcoin.crontab @@ -1,13 +1,13 @@ # start test network daemons on boot -@reboot sleep 5 ; /usr/local/bin/bitcoind -testnet >/dev/null 2>&1 -@reboot sleep 5 ; /usr/local/bin/bitcoind -testnet4 >/dev/null 2>&1 -@reboot sleep 5 ; /usr/local/bin/bitcoind -signet >/dev/null 2>&1 +@reboot sleep 10 ; /usr/local/bin/bitcoind -testnet >/dev/null 2>&1 +@reboot sleep 20 ; /usr/local/bin/bitcoind -testnet4 >/dev/null 2>&1 +@reboot sleep 30 ; /usr/local/bin/bitcoind -signet >/dev/null 2>&1 # start electrs on boot -@reboot sleep 10 ; screen -dmS mainnet /bitcoin/electrs/start mainnet -@reboot sleep 10 ; screen -dmS testnet /bitcoin/electrs/start testnet -@reboot sleep 10 ; screen -dmS testnet4 /bitcoin/electrs/start testnet4 -@reboot sleep 10 ; screen -dmS signet /bitcoin/electrs/start signet +@reboot sleep 40 ; screen -dmS mainnet /bitcoin/electrs/start mainnet +@reboot sleep 50 ; screen -dmS testnet /bitcoin/electrs/start testnet +@reboot sleep 60 ; screen -dmS testnet4 /bitcoin/electrs/start testnet4 +@reboot sleep 70 ; screen -dmS signet /bitcoin/electrs/start signet # daily update of popular-scripts 30 03 * * * $HOME/electrs/start testnet4 popular-scripts >/dev/null 2>&1 From 322e81d3edcbea536274ea72110188e4f333bc40 Mon Sep 17 00:00:00 2001 From: natsoni Date: Fri, 14 Mar 2025 14:59:15 +0100 Subject: [PATCH 48/54] Parse tapscript unanimous n-of-n multisig --- frontend/src/app/shared/script.utils.ts | 52 +++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/frontend/src/app/shared/script.utils.ts b/frontend/src/app/shared/script.utils.ts index f0c4701db..8453bdd63 100644 --- a/frontend/src/app/shared/script.utils.ts +++ b/frontend/src/app/shared/script.utils.ts @@ -256,6 +256,11 @@ export function detectScriptTemplate(type: ScriptType, script_asm: string, witne return ScriptTemplates.multisig(tapscriptMultisig.m, tapscriptMultisig.n); } + const tapscriptUnanimousMultisig = parseTapscriptUnanimousMultisig(script_asm); + if (tapscriptUnanimousMultisig) { + return ScriptTemplates.multisig(tapscriptUnanimousMultisig, tapscriptUnanimousMultisig); + } + return; } @@ -366,6 +371,53 @@ export function parseTapscriptMultisig(script: string): undefined | { m: number, return { m, n }; } +export function parseTapscriptUnanimousMultisig(script: string): undefined | number { + if (!script) { + return; + } + + const ops = script.split(' '); + // At minimum, 2 pubkey group (3 tokens) = 6 tokens + if (ops.length < 6) { + return; + } + + if (ops.length % 3 !== 0) { + return; + } + + const n = ops.length / 3; + + for (let i = 0; i < n; i++) { + const pushOp = ops.shift(); + const pubkey = ops.shift(); + const sigOp = ops.shift(); + + if (pushOp !== 'OP_PUSHBYTES_32') { + return; + } + if (!/^[0-9a-fA-F]{64}$/.test(pubkey)) { + return; + } + if (i < n - 1) { + if (sigOp !== 'OP_CHECKSIGVERIFY') { + return; + } + } else { + // Last opcode can be either CHECKSIG or CHECKSIGVERIFY + if (!(sigOp === 'OP_CHECKSIGVERIFY' || sigOp === 'OP_CHECKSIG')) { + return; + } + } + } + + if (ops.length) { + return; + } + + return n; +} + export function getVarIntLength(n: number): number { if (n < 0xfd) { return 1; From 30003348ce182a53ea5e910bc0f331ea138f7202 Mon Sep 17 00:00:00 2001 From: wiz Date: Sun, 16 Mar 2025 12:49:26 +0900 Subject: [PATCH 49/54] ops: Fix premature socket close bug in nginx cache warmer scripts --- production/nginx-cache-heater | 2 +- production/nginx-cache-warmer | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/production/nginx-cache-heater b/production/nginx-cache-heater index 24ec8a061..e6dea270a 100755 --- a/production/nginx-cache-heater +++ b/production/nginx-cache-heater @@ -4,7 +4,7 @@ hostname=$(hostname) heat() { echo "$1" - curl -i -s "$1" | head -1 + curl -o /dev/null -s "$1" } heatURLs=( diff --git a/production/nginx-cache-warmer b/production/nginx-cache-warmer index f02091747..171f95430 100755 --- a/production/nginx-cache-warmer +++ b/production/nginx-cache-warmer @@ -6,19 +6,19 @@ slugs=(`curl -sSL https://${hostname}/api/v1/mining/pools/3y|jq -r -S '(.pools[] warmSlurp() { echo "$1" - curl -i -s -H 'User-Agent: Googlebot' "$1" | head -1 + curl -o /dev/null -s -H 'User-Agent: Googlebot' "$1" } warmUnfurl() { echo "$1" - curl -i -s -H 'User-Agent: Twitterbot' "$1" | head -1 + curl -o /dev/null -s -H 'User-Agent: Twitterbot' "$1" } warm() { echo "$1" - curl -i -s "$1" | head -1 + curl -o /dev/null -s "$1" } warmSlurpURLs=( From 2945e47eba9583cf2826d2ca9f159f9707eec563 Mon Sep 17 00:00:00 2001 From: nymkappa <1612910616@pm.me> Date: Wed, 26 Mar 2025 23:05:43 +0100 Subject: [PATCH 50/54] [blocks] respect 404 error code instead of misleading 500 --- backend/src/api/bitcoin/bitcoin.routes.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/src/api/bitcoin/bitcoin.routes.ts b/backend/src/api/bitcoin/bitcoin.routes.ts index 73a14ba4e..3cf2923f1 100644 --- a/backend/src/api/bitcoin/bitcoin.routes.ts +++ b/backend/src/api/bitcoin/bitcoin.routes.ts @@ -406,8 +406,8 @@ class BitcoinRoutes { res.setHeader('Expires', new Date(Date.now() + 1000 * cacheDuration).toUTCString()); res.json(block); - } catch (e) { - handleError(req, res, 500, 'Failed to get block'); + } catch (e: any) { + handleError(req, res, e?.response?.status === 404 ? 404 : 500, 'Failed to get block'); } } From b153d21162aea4e51ee773fe96bf24137a14bf0b Mon Sep 17 00:00:00 2001 From: Mononaut Date: Mon, 10 Mar 2025 04:35:52 +0000 Subject: [PATCH 51/54] automatically fetch enabled wallets from services backend --- backend/src/api/services/wallets.ts | 33 ++++++++++++++++++++++++++ backend/src/config.ts | 2 ++ production/mempool-config.mainnet.json | 1 + 3 files changed, 36 insertions(+) diff --git a/backend/src/api/services/wallets.ts b/backend/src/api/services/wallets.ts index dd4d7ebc9..f498a80ad 100644 --- a/backend/src/api/services/wallets.ts +++ b/backend/src/api/services/wallets.ts @@ -30,6 +30,7 @@ const POLL_FREQUENCY = 5 * 60 * 1000; // 5 minutes class WalletApi { private wallets: Record = {}; private syncing = false; + private lastSync = 0; constructor() { this.wallets = config.WALLETS.ENABLED ? (config.WALLETS.WALLETS as string[]).reduce((acc, wallet) => { @@ -47,7 +48,38 @@ class WalletApi { if (!config.WALLETS.ENABLED || this.syncing) { return; } + this.syncing = true; + + if (config.WALLETS.AUTO && (Date.now() - this.lastSync) > POLL_FREQUENCY) { + try { + // update list of active wallets + this.lastSync = Date.now(); + const response = await axios.get(config.MEMPOOL_SERVICES.API + `/wallets`); + const walletList: string[] = response.data; + if (walletList) { + // create a quick lookup dictionary of active wallets + const newWallets: Record = Object.fromEntries( + walletList.map(wallet => [wallet, true]) + ); + for (const wallet of walletList) { + // don't overwrite existing wallets + if (!this.wallets[wallet]) { + this.wallets[wallet] = { name: wallet, addresses: {}, lastPoll: 0 }; + } + } + // remove wallets that are no longer active + for (const wallet of Object.keys(this.wallets)) { + if (!newWallets[wallet]) { + delete this.wallets[wallet]; + } + } + } + } catch (e) { + logger.err(`Error updating active wallets: ${(e instanceof Error ? e.message : e)}`); + } + } + for (const walletKey of Object.keys(this.wallets)) { const wallet = this.wallets[walletKey]; if (wallet.lastPoll < (Date.now() - POLL_FREQUENCY)) { @@ -72,6 +104,7 @@ class WalletApi { } } } + this.syncing = false; } diff --git a/backend/src/config.ts b/backend/src/config.ts index a1050a7d5..3fe3db2ee 100644 --- a/backend/src/config.ts +++ b/backend/src/config.ts @@ -164,6 +164,7 @@ interface IConfig { }, WALLETS: { ENABLED: boolean; + AUTO: boolean; WALLETS: string[]; }, STRATUM: { @@ -334,6 +335,7 @@ const defaults: IConfig = { }, 'WALLETS': { 'ENABLED': false, + 'AUTO': false, 'WALLETS': [], }, 'STRATUM': { diff --git a/production/mempool-config.mainnet.json b/production/mempool-config.mainnet.json index 9505601d2..87f58f916 100644 --- a/production/mempool-config.mainnet.json +++ b/production/mempool-config.mainnet.json @@ -159,6 +159,7 @@ }, "WALLETS": { "ENABLED": true, + "AUTO": true, "WALLETS": ["BITB", "3350"] }, "STRATUM": { From 072b83243e5202de0b2722855d8f66ca22031f18 Mon Sep 17 00:00:00 2001 From: Mononaut Date: Mon, 10 Mar 2025 04:38:51 +0000 Subject: [PATCH 52/54] update custom dashboard config --- frontend/custom-sv-config.json | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/frontend/custom-sv-config.json b/frontend/custom-sv-config.json index dee3dab18..9a61704a2 100644 --- a/frontend/custom-sv-config.json +++ b/frontend/custom-sv-config.json @@ -16,10 +16,10 @@ "mobileOrder": 4 }, { - "component": "balance", + "component": "walletBalance", "mobileOrder": 1, "props": { - "address": "32ixEdVJWo3kmvJGMTZq5jAQVZZeuwnqzo" + "wallet": "ONBTC" } }, { @@ -30,21 +30,22 @@ } }, { - "component": "address", + "component": "wallet", "mobileOrder": 2, "props": { - "address": "32ixEdVJWo3kmvJGMTZq5jAQVZZeuwnqzo", - "period": "1m" + "wallet": "ONBTC", + "period": "1m", + "label": "bitcoin.gob.sv" } }, { "component": "blocks" }, { - "component": "addressTransactions", + "component": "walletTransactions", "mobileOrder": 3, "props": { - "address": "32ixEdVJWo3kmvJGMTZq5jAQVZZeuwnqzo" + "wallet": "ONBTC" } } ] From 3056454389ea80380920ef8e702089e9bb629f55 Mon Sep 17 00:00:00 2001 From: Mononaut Date: Wed, 26 Feb 2025 04:48:22 +0000 Subject: [PATCH 53/54] Add configurable label to balance widget --- .../address-graph/address-graph.component.ts | 22 +++++++++++++++++++ .../custom-dashboard.component.html | 4 ++-- frontend/src/app/graphs/echarts.ts | 4 ++-- 3 files changed, 26 insertions(+), 4 deletions(-) diff --git a/frontend/src/app/components/address-graph/address-graph.component.ts b/frontend/src/app/components/address-graph/address-graph.component.ts index 2bbfd5e34..005c64e9f 100644 --- a/frontend/src/app/components/address-graph/address-graph.component.ts +++ b/frontend/src/app/components/address-graph/address-graph.component.ts @@ -44,6 +44,7 @@ export class AddressGraphComponent implements OnChanges, OnDestroy { @Input() right: number | string = 10; @Input() left: number | string = 70; @Input() widget: boolean = false; + @Input() label: string = ''; @Input() defaultFiat: boolean = false; @Input() showLegend: boolean = true; @Input() showYAxis: boolean = true; @@ -55,6 +56,7 @@ export class AddressGraphComponent implements OnChanges, OnDestroy { hoverData: any[] = []; conversions: any; allowZoom: boolean = false; + labelGraphic: any; selected = { [$localize`:@@7e69426bd97a606d8ae6026762858e6e7c86a1fd:Balance`]: true, 'Fiat': false }; @@ -85,6 +87,18 @@ export class AddressGraphComponent implements OnChanges, OnDestroy { ngOnChanges(changes: SimpleChanges): void { this.isLoading = true; + this.labelGraphic = this.label ? { + type: 'text', + right: '36px', + bottom: '36px', + z: 100, + silent: true, + style: { + fill: '#fff', + text: this.label, + font: '24px sans-serif' + } + } : undefined; if (!this.addressSummary$ && (!this.address || !this.stats)) { return; } @@ -205,6 +219,10 @@ export class AddressGraphComponent implements OnChanges, OnDestroy { right: this.adjustedRight, left: this.adjustedLeft, }, + graphic: this.labelGraphic ? [{ + ...this.labelGraphic, + right: this.adjustedRight + 22 + 'px', + }] : undefined, legend: (this.showLegend && !this.stateService.isAnyTestnet()) ? { data: [ { @@ -443,6 +461,10 @@ export class AddressGraphComponent implements OnChanges, OnDestroy { right: this.adjustedRight, left: this.adjustedLeft, }, + graphic: this.labelGraphic ? [{ + ...this.labelGraphic, + right: this.adjustedRight + 22 + 'px', + }] : undefined, legend: { selected: this.selected, }, diff --git a/frontend/src/app/components/custom-dashboard/custom-dashboard.component.html b/frontend/src/app/components/custom-dashboard/custom-dashboard.component.html index 8ca1a5ac4..defb7c068 100644 --- a/frontend/src/app/components/custom-dashboard/custom-dashboard.component.html +++ b/frontend/src/app/components/custom-dashboard/custom-dashboard.component.html @@ -238,7 +238,7 @@   - +
@@ -272,7 +272,7 @@   - + diff --git a/frontend/src/app/graphs/echarts.ts b/frontend/src/app/graphs/echarts.ts index 67ed7e3b8..36a0517e4 100644 --- a/frontend/src/app/graphs/echarts.ts +++ b/frontend/src/app/graphs/echarts.ts @@ -1,7 +1,7 @@ // Import tree-shakeable echarts import * as echarts from 'echarts/core'; import { LineChart, LinesChart, BarChart, TreemapChart, PieChart, ScatterChart, GaugeChart, CustomChart } from 'echarts/charts'; -import { TitleComponent, TooltipComponent, GridComponent, LegendComponent, GeoComponent, DataZoomComponent, VisualMapComponent, MarkLineComponent } from 'echarts/components'; +import { TitleComponent, TooltipComponent, GridComponent, LegendComponent, GeoComponent, DataZoomComponent, VisualMapComponent, MarkLineComponent, GraphicComponent } from 'echarts/components'; import { SVGRenderer, CanvasRenderer } from 'echarts/renderers'; // Typescript interfaces import { EChartsOption, TreemapSeriesOption, LineSeriesOption, PieSeriesOption } from 'echarts'; @@ -13,6 +13,6 @@ echarts.use([ LegendComponent, GeoComponent, DataZoomComponent, VisualMapComponent, MarkLineComponent, LineChart, LinesChart, BarChart, TreemapChart, PieChart, ScatterChart, GaugeChart, - CustomChart, + CustomChart, GraphicComponent ]); export { echarts, EChartsOption, TreemapSeriesOption, LineSeriesOption, PieSeriesOption }; \ No newline at end of file From fb50ea7a6d06e3a4a28db3061d8b2ba74bedafee Mon Sep 17 00:00:00 2001 From: Mononaut Date: Tue, 4 Feb 2025 12:02:12 +0000 Subject: [PATCH 54/54] detect and warn about address poisoning attacks --- .../transactions-list.component.html | 21 ++- .../transactions-list.component.ts | 56 +++++++ frontend/src/app/shared/address-utils.ts | 145 ++++++++++++++++++ .../address-text/address-text.component.html | 17 ++ .../address-text/address-text.component.scss | 32 ++++ .../address-text/address-text.component.ts | 20 +++ frontend/src/app/shared/shared.module.ts | 6 +- 7 files changed, 290 insertions(+), 7 deletions(-) create mode 100644 frontend/src/app/shared/components/address-text/address-text.component.html create mode 100644 frontend/src/app/shared/components/address-text/address-text.component.scss create mode 100644 frontend/src/app/shared/components/address-text/address-text.component.ts diff --git a/frontend/src/app/components/transactions-list/transactions-list.component.html b/frontend/src/app/components/transactions-list/transactions-list.component.html index 6f1d76538..7721298fb 100644 --- a/frontend/src/app/components/transactions-list/transactions-list.component.html +++ b/frontend/src/app/components/transactions-list/transactions-list.component.html @@ -16,6 +16,11 @@
{{ errorUnblinded }}
+ @if (similarityMatches.get(tx.txid)?.size) { + + }
@@ -68,9 +73,11 @@ - - - + {{ vin.prevout.scriptpubkey_type?.toUpperCase() }} @@ -217,9 +224,11 @@ 'highlight': this.addresses.length && ((vout.scriptpubkey_type !== 'p2pk' && addresses.includes(vout.scriptpubkey_address)) || this.addresses.includes(vout.scriptpubkey.slice(2, -2))) }">
- - - + P2PK 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 20f11abd8..6f01a6e59 100644 --- a/frontend/src/app/components/transactions-list/transactions-list.component.ts +++ b/frontend/src/app/components/transactions-list/transactions-list.component.ts @@ -14,6 +14,7 @@ import { StorageService } from '@app/services/storage.service'; import { OrdApiService } from '@app/services/ord-api.service'; import { Inscription } from '@app/shared/ord/inscription.utils'; import { Etching, Runestone } from '@app/shared/ord/rune.utils'; +import { ADDRESS_SIMILARITY_THRESHOLD, AddressMatch, AddressSimilarity, AddressType, AddressTypeInfo, checkedCompareAddressStrings, detectAddressType } from '@app/shared/address-utils'; @Component({ selector: 'app-transactions-list', @@ -55,6 +56,7 @@ export class TransactionsListComponent implements OnInit, OnChanges { showFullScript: { [vinIndex: number]: boolean } = {}; showFullWitness: { [vinIndex: number]: { [witnessIndex: number]: boolean } } = {}; showOrdData: { [key: string]: { show: boolean; inscriptions?: Inscription[]; runestone?: Runestone, runeInfo?: { [id: string]: { etching: Etching; txid: string; } }; } } = {}; + similarityMatches: Map> = new Map(); constructor( public stateService: StateService, @@ -144,6 +146,8 @@ export class TransactionsListComponent implements OnInit, OnChanges { this.currency = currency; this.refreshPrice(); }); + + this.updateAddressSimilarities(); } refreshPrice(): void { @@ -183,6 +187,8 @@ export class TransactionsListComponent implements OnInit, OnChanges { } } if (changes.transactions || changes.addresses) { + this.similarityMatches.clear(); + this.updateAddressSimilarities(); if (!this.transactions || !this.transactions.length) { return; } @@ -296,6 +302,56 @@ export class TransactionsListComponent implements OnInit, OnChanges { } } + updateAddressSimilarities(): void { + if (!this.transactions || !this.transactions.length) { + return; + } + for (const tx of this.transactions) { + if (this.similarityMatches.get(tx.txid)) { + continue; + } + + const similarityGroups: Map = new Map(); + let lastGroup = 0; + + // Check for address poisoning similarity matches + this.similarityMatches.set(tx.txid, new Map()); + const comparableVouts = [ + ...tx.vout.slice(0, 20), + ...this.addresses.map(addr => ({ scriptpubkey_address: addr, scriptpubkey_type: detectAddressType(addr, this.stateService.network) })) + ].filter(v => ['p2pkh', 'p2sh', 'v0_p2wpkh', 'v0_p2wsh', 'v1_p2tr'].includes(v.scriptpubkey_type)); + const comparableVins = tx.vin.slice(0, 20).map(v => v.prevout).filter(v => ['p2pkh', 'p2sh', 'v0_p2wpkh', 'v0_p2wsh', 'v1_p2tr'].includes(v?.scriptpubkey_type)); + for (const vout of comparableVouts) { + const address = vout.scriptpubkey_address; + const addressType = vout.scriptpubkey_type; + if (this.similarityMatches.get(tx.txid)?.has(address)) { + continue; + } + for (const compareAddr of [ + ...comparableVouts.filter(v => v.scriptpubkey_type === addressType && v.scriptpubkey_address !== address), + ...comparableVins.filter(v => v.scriptpubkey_type === addressType && v.scriptpubkey_address !== address) + ]) { + const similarity = checkedCompareAddressStrings(address, compareAddr.scriptpubkey_address, addressType as AddressType, this.stateService.network); + if (similarity?.status === 'comparable' && similarity.score > ADDRESS_SIMILARITY_THRESHOLD) { + let group = similarityGroups.get(address) || lastGroup++; + similarityGroups.set(address, group); + const bestVout = this.similarityMatches.get(tx.txid)?.get(address); + if (!bestVout || bestVout.score < similarity.score) { + this.similarityMatches.get(tx.txid)?.set(address, { score: similarity.score, match: similarity.left, group }); + } + // opportunistically update the entry for the compared address + const bestCompare = this.similarityMatches.get(tx.txid)?.get(compareAddr.scriptpubkey_address); + if (!bestCompare || bestCompare.score < similarity.score) { + group = similarityGroups.get(compareAddr.scriptpubkey_address) || lastGroup++; + similarityGroups.set(compareAddr.scriptpubkey_address, group); + this.similarityMatches.get(tx.txid)?.set(compareAddr.scriptpubkey_address, { score: similarity.score, match: similarity.right, group }); + } + } + } + } + } + } + onScroll(): void { this.loadMore.emit(); } diff --git a/frontend/src/app/shared/address-utils.ts b/frontend/src/app/shared/address-utils.ts index 0a7f2df02..0ca4ec2e0 100644 --- a/frontend/src/app/shared/address-utils.ts +++ b/frontend/src/app/shared/address-utils.ts @@ -78,6 +78,7 @@ const p2trRegex = RegExp('^p' + BECH32_CHARS_LW + '{58}$'); const pubkeyRegex = RegExp('^' + `(04${HEX_CHARS}{128})|(0[23]${HEX_CHARS}{64})$`); export function detectAddressType(address: string, network: string): AddressType { + network = network || 'mainnet'; // normal address types const firstChar = address.substring(0, 1); if (ADDRESS_PREFIXES[network].base58.pubkey.includes(firstChar) && base58Regex.test(address.slice(1))) { @@ -211,6 +212,18 @@ export class AddressTypeInfo { } } + public compareTo(other: AddressTypeInfo): AddressSimilarityResult { + return compareAddresses(this.address, other.address, this.network); + } + + public compareToString(other: string): AddressSimilarityResult { + if (other === this.address) { + return { status: 'identical' }; + } + const otherInfo = new AddressTypeInfo(this.network, other); + return this.compareTo(otherInfo); + } + private processScript(script: ScriptInfo): void { this.scripts.set(script.key, script); if (script.template?.type === 'multisig') { @@ -218,3 +231,135 @@ export class AddressTypeInfo { } } } + +export interface AddressMatch { + prefix: string; + postfix: string; +} + +export interface AddressSimilarity { + status: 'comparable'; + score: number; + left: AddressMatch; + right: AddressMatch; +} +export type AddressSimilarityResult = + | { status: 'identical' } + | { status: 'incomparable' } + | AddressSimilarity; + +export const ADDRESS_SIMILARITY_THRESHOLD = 10_000_000; // 1 false positive per ~10 million comparisons + +function fuzzyPrefixMatch(a: string, b: string, rtl: boolean = false): { score: number, matchA: string, matchB: string } { + let score = 0; + let gap = false; + let done = false; + + let ai = 0; + let bi = 0; + let prefixA = ''; + let prefixB = ''; + if (rtl) { + a = a.split('').reverse().join(''); + b = b.split('').reverse().join(''); + } + + while (ai < a.length && bi < b.length && !done) { + if (a[ai] === b[bi]) { + // matching characters + prefixA += a[ai]; + prefixB += b[bi]; + score++; + ai++; + bi++; + } else if (!gap) { + // try looking ahead in both strings to find the best match + const nextMatchA = (ai + 1 < a.length && a[ai + 1] === b[bi]); + const nextMatchB = (bi + 1 < b.length && a[ai] === b[bi + 1]); + const nextMatchBoth = (ai + 1 < a.length && bi + 1 < b.length && a[ai + 1] === b[bi + 1]); + if (nextMatchBoth) { + // single differing character + prefixA += a[ai]; + prefixB += b[bi]; + ai++; + bi++; + } else if (nextMatchA) { + // character missing in b + prefixA += a[ai]; + ai++; + } else if (nextMatchB) { + // character missing in a + prefixB += b[bi]; + bi++; + } else { + ai++; + bi++; + } + gap = true; + } else { + done = true; + } + } + + if (rtl) { + prefixA = prefixA.split('').reverse().join(''); + prefixB = prefixB.split('').reverse().join(''); + } + + return { score, matchA: prefixA, matchB: prefixB }; +} + +export function compareAddressInfo(a: AddressTypeInfo, b: AddressTypeInfo): AddressSimilarityResult { + if (a.address === b.address) { + return { status: 'identical' }; + } + if (a.type !== b.type) { + return { status: 'incomparable' }; + } + if (!['p2pkh', 'p2sh', 'p2sh-p2wpkh', 'p2sh-p2wsh', 'v0_p2wpkh', 'v0_p2wsh', 'v1_p2tr'].includes(a.type)) { + return { status: 'incomparable' }; + } + const isBase58 = a.type === 'p2pkh' || a.type === 'p2sh'; + + const left = fuzzyPrefixMatch(a.address, b.address); + const right = fuzzyPrefixMatch(a.address, b.address, true); + // depending on address type, some number of matching prefix characters are guaranteed + const prefixScore = isBase58 ? 1 : ADDRESS_PREFIXES[a.network || 'mainnet'].bech32.length; + + // add the two scores together + const totalScore = left.score + right.score - prefixScore; + + // adjust for the size of the alphabet (58 vs 32) + const normalizedScore = Math.pow(isBase58 ? 58 : 32, totalScore); + + return { + status: 'comparable', + score: normalizedScore, + left: { + prefix: left.matchA, + postfix: right.matchA, + }, + right: { + prefix: left.matchB, + postfix: right.matchB, + }, + }; +} + +export function compareAddresses(a: string, b: string, network: string): AddressSimilarityResult { + if (a === b) { + return { status: 'identical' }; + } + const aInfo = new AddressTypeInfo(network, a); + return aInfo.compareToString(b); +} + +// avoids the overhead of creating AddressTypeInfo objects for each address, +// but a and b *MUST* be valid normalized addresses, of the same valid type +export function checkedCompareAddressStrings(a: string, b: string, type: AddressType, network: string): AddressSimilarityResult { + return compareAddressInfo( + { address: a, type: type, network: network } as AddressTypeInfo, + { address: b, type: type, network: network } as AddressTypeInfo, + ); +} + diff --git a/frontend/src/app/shared/components/address-text/address-text.component.html b/frontend/src/app/shared/components/address-text/address-text.component.html new file mode 100644 index 000000000..ddcd8d751 --- /dev/null +++ b/frontend/src/app/shared/components/address-text/address-text.component.html @@ -0,0 +1,17 @@ + +@if (similarity) { + +} @else { + + + +} \ No newline at end of file diff --git a/frontend/src/app/shared/components/address-text/address-text.component.scss b/frontend/src/app/shared/components/address-text/address-text.component.scss new file mode 100644 index 000000000..3b5bf50b7 --- /dev/null +++ b/frontend/src/app/shared/components/address-text/address-text.component.scss @@ -0,0 +1,32 @@ +.address-text { + text-overflow: unset; + display: flex; + flex-direction: row; + align-items: start; + position: relative; + + font-family: monospace; + + .infix { + flex-grow: 0; + flex-shrink: 1; + overflow: hidden; + text-overflow: ellipsis; + user-select: none; + + text-decoration: underline 2px; + } + + .prefix, .postfix { + flex-shrink: 0; + flex-grow: 0; + user-select: none; + + text-decoration: underline var(--red) 2px; + } +} + +.poison-alert { + margin-left: .5em; + color: var(--yellow); +} \ No newline at end of file diff --git a/frontend/src/app/shared/components/address-text/address-text.component.ts b/frontend/src/app/shared/components/address-text/address-text.component.ts new file mode 100644 index 000000000..f618428aa --- /dev/null +++ b/frontend/src/app/shared/components/address-text/address-text.component.ts @@ -0,0 +1,20 @@ +import { Component, Input } from '@angular/core'; +import { AddressMatch, AddressTypeInfo } from '@app/shared/address-utils'; + +@Component({ + selector: 'app-address-text', + templateUrl: './address-text.component.html', + styleUrls: ['./address-text.component.scss'] +}) +export class AddressTextComponent { + @Input() address: string; + @Input() info: AddressTypeInfo | null; + @Input() similarity: { score: number, match: AddressMatch, group: number } | null; + + groupColors: string[] = [ + 'var(--primary)', + 'var(--success)', + 'var(--info)', + 'white', + ]; +} diff --git a/frontend/src/app/shared/shared.module.ts b/frontend/src/app/shared/shared.module.ts index d937e6bbb..335b428ed 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, faMicroscope } from '@fortawesome/free-solid-svg-icons'; + faCircleXmark, faCalendarCheck, faMoneyBillTrendUp, faRobot, faShareNodes, faCreditCard, faMicroscope, faExclamationTriangle } 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'; @@ -94,6 +94,7 @@ import { SatsComponent } from '@app/shared/components/sats/sats.component'; import { BtcComponent } from '@app/shared/components/btc/btc.component'; import { FeeRateComponent } from '@app/shared/components/fee-rate/fee-rate.component'; import { AddressTypeComponent } from '@app/shared/components/address-type/address-type.component'; +import { AddressTextComponent } from '@app/shared/components/address-text/address-text.component'; import { TruncateComponent } from '@app/shared/components/truncate/truncate.component'; import { SearchResultsComponent } from '@components/search-form/search-results/search-results.component'; import { TimestampComponent } from '@app/shared/components/timestamp/timestamp.component'; @@ -214,6 +215,7 @@ import { GithubLogin } from '../components/github-login.component/github-login.c BtcComponent, FeeRateComponent, AddressTypeComponent, + AddressTextComponent, TruncateComponent, SearchResultsComponent, TimestampComponent, @@ -360,6 +362,7 @@ import { GithubLogin } from '../components/github-login.component/github-login.c BtcComponent, FeeRateComponent, AddressTypeComponent, + AddressTextComponent, TruncateComponent, SearchResultsComponent, TimestampComponent, @@ -465,5 +468,6 @@ export class SharedModule { library.addIcons(faShareNodes); library.addIcons(faCreditCard); library.addIcons(faMicroscope); + library.addIcons(faExclamationTriangle); } }