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] 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');
}
}