From eb9c6f22319b49d837b1ab4e6c408f9dee2f71be Mon Sep 17 00:00:00 2001 From: Antoni Spaanderman <56turtle56@gmail.com> Date: Sun, 24 Jul 2022 00:08:53 +0200 Subject: [PATCH 1/4] Add Taproot transaction feature tooltip with fee saving information --- frontend/src/app/bitcoin.utils.ts | 85 +++++++++++++++---- .../address-labels.component.ts | 74 +++++++++------- .../tx-features/tx-features.component.html | 17 ++-- .../tx-features/tx-features.component.ts | 6 +- 4 files changed, 122 insertions(+), 60 deletions(-) diff --git a/frontend/src/app/bitcoin.utils.ts b/frontend/src/app/bitcoin.utils.ts index 82b929f93..e5cdde87f 100644 --- a/frontend/src/app/bitcoin.utils.ts +++ b/frontend/src/app/bitcoin.utils.ts @@ -1,26 +1,32 @@ import { Transaction, Vin } from './interfaces/electrs.interface'; +import { parseMultisigScript } from './components/address-labels/address-labels.component'; const P2SH_P2WPKH_COST = 21 * 4; // the WU cost for the non-witness part of P2SH-P2WPKH const P2SH_P2WSH_COST = 35 * 4; // the WU cost for the non-witness part of P2SH-P2WSH export function calcSegwitFeeGains(tx: Transaction) { // calculated in weight units - let realizedGains = 0; + let realizedBech32Gains = 0; let potentialBech32Gains = 0; let potentialP2shGains = 0; + let potentialTaprootGains = 0; + let realizedTaprootGains = 0; for (const vin of tx.vin) { if (!vin.prevout) { continue; } - const isP2pkh = vin.prevout.scriptpubkey_type === 'p2pkh'; - const isP2sh = vin.prevout.scriptpubkey_type === 'p2sh'; - const isP2wsh = vin.prevout.scriptpubkey_type === 'v0_p2wsh'; - const isP2wpkh = vin.prevout.scriptpubkey_type === 'v0_p2wpkh'; - const isP2tr = vin.prevout.scriptpubkey_type === 'v1_p2tr'; + const isP2pk = vin.prevout.scriptpubkey_type === 'p2pk'; + // const isBareMultisig = vin.prevout.scriptpubkey_type === 'multisig'; // type will be unknown, so use the multisig helper from the address labels + const isBareMultisig = !!parseMultisigScript(vin.prevout.scriptpubkey_asm); + const isP2pkh = vin.prevout.scriptpubkey_type === 'p2pkh'; + const isP2sh = vin.prevout.scriptpubkey_type === 'p2sh'; + const isP2wsh = vin.prevout.scriptpubkey_type === 'v0_p2wsh'; + const isP2wpkh = vin.prevout.scriptpubkey_type === 'v0_p2wpkh'; + const isP2tr = vin.prevout.scriptpubkey_type === 'v1_p2tr'; const op = vin.scriptsig ? vin.scriptsig_asm.split(' ')[0] : null; const isP2sh2Wpkh = isP2sh && !!vin.witness && op === 'OP_PUSHBYTES_22'; - const isP2sh2Wsh = isP2sh && !!vin.witness && op === 'OP_PUSHBYTES_34'; + const isP2sh2Wsh = isP2sh && !!vin.witness && op === 'OP_PUSHBYTES_34'; switch (true) { // Native Segwit - P2WPKH/P2WSH (Bech32) @@ -28,41 +34,84 @@ export function calcSegwitFeeGains(tx: Transaction) { case isP2wsh: case isP2tr: // maximal gains: the scriptSig is moved entirely to the witness part - realizedGains += witnessSize(vin) * 3; + realizedBech32Gains += witnessSize(vin) * 3; // XXX P2WSH output creation is more expensive, should we take this into consideration? break; // Backward compatible Segwit - P2SH-P2WPKH case isP2sh2Wpkh: // the scriptSig is moved to the witness, but we have extra 21 extra non-witness bytes (48 WU) - realizedGains += witnessSize(vin) * 3 - P2SH_P2WPKH_COST; + realizedBech32Gains += witnessSize(vin) * 3 - P2SH_P2WPKH_COST; potentialBech32Gains += P2SH_P2WPKH_COST; break; // Backward compatible Segwit - P2SH-P2WSH case isP2sh2Wsh: // the scriptSig is moved to the witness, but we have extra 35 extra non-witness bytes - realizedGains += witnessSize(vin) * 3 - P2SH_P2WSH_COST; + realizedBech32Gains += witnessSize(vin) * 3 - P2SH_P2WSH_COST; potentialBech32Gains += P2SH_P2WSH_COST; break; - // Non-segwit P2PKH/P2SH + // Non-segwit P2PKH/P2SH/P2PK/bare multisig case isP2pkh: case isP2sh: + case isP2pk: + case isBareMultisig: { const fullGains = scriptSigSize(vin) * 3; potentialBech32Gains += fullGains; potentialP2shGains += fullGains - (isP2pkh ? P2SH_P2WPKH_COST : P2SH_P2WSH_COST); break; + } + } - // TODO: should we also consider P2PK and pay-to-bare-script (non-p2sh-wrapped) as upgradable to P2WPKH and P2WSH? + if (isP2tr) { + if (vin.witness.length == 1) { + // key path spend + // we don't know if this was a multisig or single sig (the goal of taproot :)), + // so calculate fee savings by comparing to the cheapest single sig input type: P2WPKH and say "saved at least ...%" + // the witness size of P2WPKH is 1 (stack size) + 1 (size) + 72 (low s signature) + 1 (size) + 33 (pubkey) = 108 WU + // the witness size of key path P2TR is 1 (stack size) + 1 (size) + 64 (signature) = 66 WU + realizedTaprootGains += 42; + } else { + // script path spend + // complex scripts with multiple spending paths can often be made around 2x to 3x smaller with the Taproot script tree + // because only the hash of the alternative spending path has the be in the witness data, not the entire script, + // but only assumptions can be made because the scripts themselves are unknown (again, the goal of taproot :)) + // TODO maybe add some complex scripts that are specified somewhere, so that size is known, such as lightning scripts + } + } else { + const script = isP2sh2Wsh || isP2wsh ? vin.inner_witnessscript_asm : vin.inner_redeemscript_asm; + let replacementSize: number; + if ( + // single sig + isP2pk || isP2pkh || isP2wpkh || isP2sh2Wpkh || + // multisig + isBareMultisig || parseMultisigScript(script) + ) { + // the scriptSig and scriptWitness can all be replaced by a 66 witness WU with taproot + replacementSize = 66; + } else if (script) { + // not single sig, not multisig: the complex scripts + // rough calculations on spending paths + // every OP_IF and OP_NOTIF indicates an _extra_ spending path, so add 1 + const spendingPaths = script.split(' ').filter(op => /^(OP_IF|OP_NOTIF)$/g.test(op)).length + 1; + // now assume the script could have been split in ${spendingPaths} equal tapleaves + replacementSize = script.length / 2 / spendingPaths + + // but account for the leaf and branch hashes and internal key in the control block + 32 * Math.log2((spendingPaths - 1) || 1) + 33; + } + potentialTaprootGains += witnessSize(vin) + scriptSigSize(vin) * 4 - replacementSize; } } // returned as percentage of the total tx weight - return { realizedGains: realizedGains / (tx.weight + realizedGains) // percent of the pre-segwit tx size - , potentialBech32Gains: potentialBech32Gains / tx.weight - , potentialP2shGains: potentialP2shGains / tx.weight - }; + return { + realizedBech32Gains: realizedBech32Gains / (tx.weight + realizedBech32Gains), // percent of the pre-segwit tx size + potentialBech32Gains: potentialBech32Gains / tx.weight, + potentialP2shGains: potentialP2shGains / tx.weight, + potentialTaprootGains: potentialTaprootGains / tx.weight, + realizedTaprootGains: realizedTaprootGains / tx.weight + }; } // https://github.com/shesek/move-decimal-point @@ -128,7 +177,7 @@ export const formatNumber = (s, precision = null) => { }; // Utilities for segwitFeeGains -const witnessSize = (vin: Vin) => vin.witness.reduce((S, w) => S + (w.length / 2), 0); +const witnessSize = (vin: Vin) => vin.witness ? vin.witness.reduce((S, w) => S + (w.length / 2), 0) : 0; const scriptSigSize = (vin: Vin) => vin.scriptsig ? vin.scriptsig.length / 2 : 0; // Power of ten wrapper @@ -160,4 +209,4 @@ export function selectPowerOfTen(val: number) { } return selectedPowerOfTen; -} \ No newline at end of file +} diff --git a/frontend/src/app/components/address-labels/address-labels.component.ts b/frontend/src/app/components/address-labels/address-labels.component.ts index ba03aada8..6dc10f6e9 100644 --- a/frontend/src/app/components/address-labels/address-labels.component.ts +++ b/frontend/src/app/components/address-labels/address-labels.component.ts @@ -98,44 +98,52 @@ export class AddressLabelsComponent implements OnChanges { } detectMultisig(script: string) { - if (!script) { - return; - } - const ops = script.split(' '); - if (ops.length < 3 || ops.pop() !== 'OP_CHECKMULTISIG') { - return; - } - const opN = ops.pop(); - if (!opN.startsWith('OP_PUSHNUM_')) { - return; - } - const n = parseInt(opN.match(/[0-9]+/)[0], 10); - if (ops.length < n * 2 + 1) { - return; - } - // pop n public keys - for (let i = 0; i < n; i++) { - if (!/^0((2|3)\w{64}|4\w{128})$/.test(ops.pop())) { - return; - } - if (!/^OP_PUSHBYTES_(33|65)$/.test(ops.pop())) { - return; - } - } - const opM = ops.pop(); - if (!opM.startsWith('OP_PUSHNUM_')) { - return; - } - const m = parseInt(opM.match(/[0-9]+/)[0], 10); + const ms = parseMultisigScript(script); - if (ops.length) { - return; + if (ms) { + this.label = $localize`:@@address-label.multisig:Multisig ${ms.m}:multisigM: of ${ms.n}:multisigN:`; } - - this.label = $localize`:@@address-label.multisig:Multisig ${m}:multisigM: of ${n}:multisigN:`; } handleVout() { this.detectMultisig(this.vout.scriptpubkey_asm); } } + +export function parseMultisigScript(script: string): void | { m: number, n: number } { + if (!script) { + return; + } + const ops = script.split(' '); + if (ops.length < 3 || ops.pop() !== 'OP_CHECKMULTISIG') { + return; + } + const opN = ops.pop(); + if (!opN.startsWith('OP_PUSHNUM_')) { + return; + } + const n = parseInt(opN.match(/[0-9]+/)[0], 10); + if (ops.length < n * 2 + 1) { + return; + } + // pop n public keys + for (let i = 0; i < n; i++) { + if (!/^0((2|3)\w{64}|4\w{128})$/.test(ops.pop())) { + return; + } + if (!/^OP_PUSHBYTES_(33|65)$/.test(ops.pop())) { + return; + } + } + const opM = ops.pop(); + if (!opM.startsWith('OP_PUSHNUM_')) { + return; + } + const m = parseInt(opM.match(/[0-9]+/)[0], 10); + + if (ops.length) { + return; + } + + return { m, n }; +} diff --git a/frontend/src/app/components/tx-features/tx-features.component.html b/frontend/src/app/components/tx-features/tx-features.component.html index b5463185e..49a1fa90f 100644 --- a/frontend/src/app/components/tx-features/tx-features.component.html +++ b/frontend/src/app/components/tx-features/tx-features.component.html @@ -1,13 +1,18 @@ -SegWit +SegWit - SegWit + SegWit SegWit -Taproot - - Taproot + +Taproot + + Taproot + + Taproot + -RBF + +RBF RBF diff --git a/frontend/src/app/components/tx-features/tx-features.component.ts b/frontend/src/app/components/tx-features/tx-features.component.ts index 08aba51e1..ce25bb097 100644 --- a/frontend/src/app/components/tx-features/tx-features.component.ts +++ b/frontend/src/app/components/tx-features/tx-features.component.ts @@ -12,12 +12,13 @@ export class TxFeaturesComponent implements OnChanges { @Input() tx: Transaction; segwitGains = { - realizedGains: 0, + realizedBech32Gains: 0, potentialBech32Gains: 0, potentialP2shGains: 0, + potentialTaprootGains: 0, + realizedTaprootGains: 0 }; isRbfTransaction: boolean; - isTaproot: boolean; constructor() { } @@ -27,6 +28,5 @@ export class TxFeaturesComponent implements OnChanges { } this.segwitGains = calcSegwitFeeGains(this.tx); this.isRbfTransaction = this.tx.vin.some((v) => v.sequence < 0xfffffffe); - this.isTaproot = this.tx.vin.some((v) => v.prevout && v.prevout.scriptpubkey_type === 'v1_p2tr'); } } From 37fd1fb76dd9a01151a3877afcc7b5c2409e70d4 Mon Sep 17 00:00:00 2001 From: Antoni Spaanderman <56turtle56@gmail.com> Date: Sun, 24 Jul 2022 18:44:27 +0200 Subject: [PATCH 2/4] move parseMultisigScript to bitcoin.util.ts --- frontend/src/app/bitcoin.utils.ts | 40 ++++++++++++++++++- .../address-labels.component.ts | 39 +----------------- 2 files changed, 40 insertions(+), 39 deletions(-) diff --git a/frontend/src/app/bitcoin.utils.ts b/frontend/src/app/bitcoin.utils.ts index e5cdde87f..d1f0d6553 100644 --- a/frontend/src/app/bitcoin.utils.ts +++ b/frontend/src/app/bitcoin.utils.ts @@ -1,5 +1,4 @@ import { Transaction, Vin } from './interfaces/electrs.interface'; -import { parseMultisigScript } from './components/address-labels/address-labels.component'; const P2SH_P2WPKH_COST = 21 * 4; // the WU cost for the non-witness part of P2SH-P2WPKH const P2SH_P2WSH_COST = 35 * 4; // the WU cost for the non-witness part of P2SH-P2WSH @@ -114,6 +113,45 @@ export function calcSegwitFeeGains(tx: Transaction) { }; } +/** extracts m and n from a multisig script (asm), returns nothing if it is not a multisig script */ +export function parseMultisigScript(script: string): void | { m: number, n: number } { + if (!script) { + return; + } + const ops = script.split(' '); + if (ops.length < 3 || ops.pop() !== 'OP_CHECKMULTISIG') { + return; + } + const opN = ops.pop(); + if (!opN.startsWith('OP_PUSHNUM_')) { + return; + } + const n = parseInt(opN.match(/[0-9]+/)[0], 10); + if (ops.length < n * 2 + 1) { + return; + } + // pop n public keys + for (let i = 0; i < n; i++) { + if (!/^0((2|3)\w{64}|4\w{128})$/.test(ops.pop())) { + return; + } + if (!/^OP_PUSHBYTES_(33|65)$/.test(ops.pop())) { + return; + } + } + const opM = ops.pop(); + if (!opM.startsWith('OP_PUSHNUM_')) { + return; + } + const m = parseInt(opM.match(/[0-9]+/)[0], 10); + + if (ops.length) { + return; + } + + return { m, n }; +} + // https://github.com/shesek/move-decimal-point export function moveDec(num: number, n: number) { let frac, int, neg, ref; diff --git a/frontend/src/app/components/address-labels/address-labels.component.ts b/frontend/src/app/components/address-labels/address-labels.component.ts index 6dc10f6e9..331114ff4 100644 --- a/frontend/src/app/components/address-labels/address-labels.component.ts +++ b/frontend/src/app/components/address-labels/address-labels.component.ts @@ -1,6 +1,7 @@ import { Component, ChangeDetectionStrategy, Input, OnChanges } from '@angular/core'; import { Vin, Vout } from '../../interfaces/electrs.interface'; import { StateService } from 'src/app/services/state.service'; +import { parseMultisigScript } from 'src/app/bitcoin.utils'; @Component({ selector: 'app-address-labels', @@ -109,41 +110,3 @@ export class AddressLabelsComponent implements OnChanges { this.detectMultisig(this.vout.scriptpubkey_asm); } } - -export function parseMultisigScript(script: string): void | { m: number, n: number } { - if (!script) { - return; - } - const ops = script.split(' '); - if (ops.length < 3 || ops.pop() !== 'OP_CHECKMULTISIG') { - return; - } - const opN = ops.pop(); - if (!opN.startsWith('OP_PUSHNUM_')) { - return; - } - const n = parseInt(opN.match(/[0-9]+/)[0], 10); - if (ops.length < n * 2 + 1) { - return; - } - // pop n public keys - for (let i = 0; i < n; i++) { - if (!/^0((2|3)\w{64}|4\w{128})$/.test(ops.pop())) { - return; - } - if (!/^OP_PUSHBYTES_(33|65)$/.test(ops.pop())) { - return; - } - } - const opM = ops.pop(); - if (!opM.startsWith('OP_PUSHNUM_')) { - return; - } - const m = parseInt(opM.match(/[0-9]+/)[0], 10); - - if (ops.length) { - return; - } - - return { m, n }; -} From b875bc2552a10bf1e1f82d6c48a8589189e5a2b1 Mon Sep 17 00:00:00 2001 From: Antoni Spaanderman <49868160+antonilol@users.noreply.github.com> Date: Sun, 24 Jul 2022 18:44:53 +0200 Subject: [PATCH 3/4] Update frontend/src/app/bitcoin.utils.ts triple equals Co-authored-by: softsimon --- frontend/src/app/bitcoin.utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/app/bitcoin.utils.ts b/frontend/src/app/bitcoin.utils.ts index d1f0d6553..b2ea1d7a9 100644 --- a/frontend/src/app/bitcoin.utils.ts +++ b/frontend/src/app/bitcoin.utils.ts @@ -64,7 +64,7 @@ export function calcSegwitFeeGains(tx: Transaction) { } if (isP2tr) { - if (vin.witness.length == 1) { + if (vin.witness.length === 1) { // key path spend // we don't know if this was a multisig or single sig (the goal of taproot :)), // so calculate fee savings by comparing to the cheapest single sig input type: P2WPKH and say "saved at least ...%" From 46bce30a64080cb558d50b4c9c29f71f5ed50ffd Mon Sep 17 00:00:00 2001 From: Antoni Spaanderman <56turtle56@gmail.com> Date: Sun, 24 Jul 2022 19:39:13 +0200 Subject: [PATCH 4/4] add taproot badge with only privacy tooltip if no fees can be saved --- .../components/tx-features/tx-features.component.html | 9 ++++++--- .../app/components/tx-features/tx-features.component.ts | 2 ++ 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/frontend/src/app/components/tx-features/tx-features.component.html b/frontend/src/app/components/tx-features/tx-features.component.html index 49a1fa90f..16dbb66f4 100644 --- a/frontend/src/app/components/tx-features/tx-features.component.html +++ b/frontend/src/app/components/tx-features/tx-features.component.html @@ -6,11 +6,14 @@ -Taproot +Taproot - Taproot + Taproot - Taproot + Taproot + + Taproot + diff --git a/frontend/src/app/components/tx-features/tx-features.component.ts b/frontend/src/app/components/tx-features/tx-features.component.ts index ce25bb097..f73d8ae8a 100644 --- a/frontend/src/app/components/tx-features/tx-features.component.ts +++ b/frontend/src/app/components/tx-features/tx-features.component.ts @@ -19,6 +19,7 @@ export class TxFeaturesComponent implements OnChanges { realizedTaprootGains: 0 }; isRbfTransaction: boolean; + isTaproot: boolean; constructor() { } @@ -28,5 +29,6 @@ export class TxFeaturesComponent implements OnChanges { } this.segwitGains = calcSegwitFeeGains(this.tx); this.isRbfTransaction = this.tx.vin.some((v) => v.sequence < 0xfffffffe); + this.isTaproot = this.tx.vin.some((v) => v.prevout && v.prevout.scriptpubkey_type === 'v1_p2tr'); } }