Handle coinbase & OP_RETURN in tx detail view

This commit is contained in:
Mononaut 2022-03-11 14:58:00 -06:00
parent 987c2c801b
commit 4ee6f277da
4 changed files with 123 additions and 64 deletions

View File

@ -54,12 +54,19 @@ $: {
function expandAddresses(items) {
return items.map(item => {
let address = 'unknown'
let title = null
if (item.script_pub_key) {
address = SPKToAddress(item.script_pub_key) || ""
if (address === 'OP_RETURN') {
title = item.script_pub_key.substring(2).match(/../g).reduce((parsed, hexChar) => {
return parsed + String.fromCharCode(parseInt(hexChar, 16))
}, "")
}
}
return {
...item,
address
address,
title
}
})
}
@ -68,24 +75,34 @@ let inputs = []
let outputs = []
$: {
if ($detailTx && $detailTx.inputs) {
inputs = expandAddresses($detailTx.inputs)
if ($detailTx.isCoinbase) {
inputs = [{
address: 'coinbase',
value: $detailTx.value
}]
} else {
inputs = expandAddresses($detailTx.inputs)
}
} else inputs = []
if ($detailTx && $detailTx.outputs) {
outputs = expandAddresses($detailTx.outputs)
if ($detailTx.isCoinbase) {
outputs = expandAddresses($detailTx.outputs)
} else {
outputs = [{address: 'fee', value: $detailTx.fee}, ...expandAddresses($detailTx.outputs)]
}
} else outputs = []
}
let sankeyLines
let sankeyHeight
$: {
if ($detailTx && $detailTx.inputs && $detailTx.outputs) {
sankeyHeight = Math.max($detailTx.inputs.length, $detailTx.outputs.length + 1) * rowHeight
sankeyLines = calcSankeyLines($detailTx.inputs, $detailTx.outputs, $detailTx.fee || null, $detailTx.value, sankeyHeight, svgWidth, flowWeight)
if ($detailTx && inputs && outputs) {
sankeyHeight = Math.max(inputs.length, outputs.length) * rowHeight
sankeyLines = calcSankeyLines(inputs, outputs, $detailTx.fee || null, $detailTx.value, sankeyHeight, svgWidth, flowWeight)
}
}
function calcSankeyLines(inputs, outputs, fee, value, totalHeight, svgWidth, flowWeight) {
const feeAndOutputs = [{ value: fee }, ...outputs]
const total = fee + value
const mergeOffset = (totalHeight - flowWeight) / 2
let cumThick = 0
@ -126,7 +143,7 @@ function calcSankeyLines(inputs, outputs, fee, value, totalHeight, svgWidth, flo
cumThick = 0
xOffset = 0
const outLines = feeAndOutputs.map((output, index) => {
const outLines = outputs.map((output, index) => {
const weight = (output.value / total) * flowWeight
const height = ((index + 0.5) * rowHeight)
const step = (weight / 2)
@ -151,7 +168,7 @@ function calcSankeyLines(inputs, outputs, fee, value, totalHeight, svgWidth, flo
cumThick += weight
return { line, weight, index, total: feeAndOutputs.length }
return { line, weight, index, total: outputs.length }
})
outLines.forEach(line => {
line.line[1].x -= xOffset
@ -222,7 +239,7 @@ function getMiterOffset (weight, dy, dx) {
.pane {
background: var(--palette-b);
padding: .5em 1em;
padding: 16px;
border-radius: .5em;
margin: 0 0 1em;
@ -236,6 +253,10 @@ function getMiterOffset (weight, dy, dx) {
font-size: 0.8em;
color: var(--grey);
}
.value.coinbase-sig {
word-break: break-all;
}
}
}
@ -245,6 +266,7 @@ function getMiterOffset (weight, dy, dx) {
flex-direction: row;
justify-content: center;
width: auto;
color: var(--palette-x);
.operator {
font-size: 2em;
@ -333,7 +355,13 @@ function getMiterOffset (weight, dy, dx) {
}
}
@media (max-width: 460px) {
@media (min-width: 411px) and (max-width: 479px) {
.flow-diagram {
font-size: 0.7em;
}
}
@media (max-width: 410px) {
.flow-diagram {
display: block;
@ -352,30 +380,57 @@ function getMiterOffset (weight, dy, dx) {
<div class="icon-button" class:disabled={$highlightingFull} on:click={() => highlight($detailTx.id)} title="Add transaction to watchlist">
<Icon icon={BookmarkIcon}/>
</div>
<h2>Transaction <span class="tx-id">{ $detailTx.id }</span></h2>
<div class="pane fee-calc">
<div class="field">
<span class="label">fee</span>
<span class="value" style="color: {feeColor};">{ numberFormat.format($detailTx.fee) } sats</span>
{#if $detailTx.isCoinbase }
<h2>Coinbase <span class="tx-id">{ $detailTx.id }</span></h2>
<div class="pane fee-calc">
<div class="field">
<span class="label">block subsidy</span>
<span class="value">{ formatBTC($detailTx.coinbase.subsidy) }</span>
</div>
<span class="operator">+</span>
<div class="field">
<span class="label">fees</span>
<span class="value">{ formatBTC($detailTx.coinbase.fees) }</span>
</div>
<span class="operator">=</span>
<div class="field">
<span class="label">total reward</span>
<span class="value">{ formatBTC($detailTx.value) }</span>
</div>
</div>
<span class="operator">/</span>
<div class="field">
<span class="label">size</span>
<span class="value" style="color: {feeColor};">{ numberFormat.format($detailTx.vbytes) } vbytes</span>
</div>
<span class="operator">=</span>
<div class="field">
<span class="label">fee rate</span>
<span class="value" style="color: {feeColor};">{ numberFormat.format($detailTx.feerate.toFixed(2)) } sats/vbyte</span>
</div>
</div>
<div class="pane total-value">
<div class="field">
<span class="label">Total value</span>
<span class="value" style="color: {feeColor};">{ formatBTC($detailTx.value) }</span>
<div class="pane fee-calc">
<div class="field">
<span class="label">coinbase</span>
<span class="value coinbase-sig">{ $detailTx.coinbase.sigAscii }</span>
</div>
</div>
</div>
{:else}
<h2>Transaction <span class="tx-id">{ $detailTx.id }</span></h2>
<div class="pane fee-calc">
<div class="field">
<span class="label">fee</span>
<span class="value" style="color: {feeColor};">{ numberFormat.format($detailTx.fee) } sats</span>
</div>
<span class="operator">/</span>
<div class="field">
<span class="label">size</span>
<span class="value" style="color: {feeColor};">{ numberFormat.format($detailTx.vbytes) } vbytes</span>
</div>
<span class="operator">=</span>
<div class="field">
<span class="label">fee rate</span>
<span class="value" style="color: {feeColor};">{ numberFormat.format($detailTx.feerate.toFixed(2)) } sats/vbyte</span>
</div>
</div>
<div class="pane total-value">
<div class="field">
<span class="label">total value</span>
<span class="value" style="color: {feeColor};">{ formatBTC($detailTx.value) }</span>
</div>
</div>
{/if}
<h2>Inputs &amp; Outputs</h2>
<div class="pane flow-diagram" style="grid-template-columns: minmax(0px, 1fr) {svgWidth}px minmax(0px, 1fr);">
@ -389,7 +444,7 @@ function getMiterOffset (weight, dy, dx) {
{/each}
</div>
<div class="column diagram">
{#if sankeyLines && $pageWidth > 460}
{#if sankeyLines && $pageWidth > 410}
<svg class="sankey" height="{sankeyHeight}px" width="{svgWidth}px">
<defs>
{#each sankeyLines as line, index}
@ -416,14 +471,10 @@ function getMiterOffset (weight, dy, dx) {
{/if}
</div>
<div class="column outputs">
<p class="header">{$detailTx.outputs.length} output{$detailTx.outputs.length > 1 ? 's' : ''}</p>
<div class="entry fee">
<p class="address">fee</p>
<p class="amount">{ formatBTC($detailTx.fee) }</p>
</div>
<p class="header">{$detailTx.outputs.length} output{$detailTx.outputs.length > 1 ? 's' : ''} {#if !$detailTx.isCoinbase}+ fee{/if}</p>
{#each outputs as output}
<div class="entry">
<p class="address" title={output.address}><span class="truncatable">{output.address.slice(0,-6)}</span><span class="suffix">{output.address.slice(-6)}</span></p>
<p class="address" title={output.title || output.address}><span class="truncatable">{output.address.slice(0,-6)}</span><span class="suffix">{output.address.slice(-6)}</span></p>
<p class="amount">{ formatBTC(output.value) }</p>
</div>
{/each}

View File

@ -13,12 +13,13 @@ export default class BitcoinBlock {
this.bytes = bytes // OTW size of this block in bytes
this.txnCount = txn_count
this.txns = txns
this.coinbase = new BitcoinTx(this.txns[0])
this.coinbase = new BitcoinTx(this.txns[0], true)
if (fees) {
this.fees = fees
} else {
this.fees = null
}
this.coinbase.setBlock(this)
this.height = this.coinbase.coinbase.height
this.miner_sig = this.coinbase.coinbase.sigAscii
@ -29,7 +30,7 @@ export default class BitcoinBlock {
this.minFeerate = this.txnCount > 1 ? Infinity : 0
this.avgFeerate = 0
this.txns.forEach(txn => {
if (!BitcoinTx.prototype.isCoinbase(txn)) {
if (txn.id !== this.coinbase.id) {
const txFeerate = txn.fee / txn.vbytes
this.maxFeerate = Math.max(this.maxFeerate, txFeerate)
this.minFeerate = Math.min(this.minFeerate, txFeerate)

View File

@ -3,34 +3,34 @@ import config from '../config.js'
import { mixColor, pink, bluegreen, orange, teal, green, purple } from '../utils/color.js'
export default class BitcoinTx {
constructor (data, vertexArray) {
constructor (data, vertexArray, isCoinbase = false) {
this.vertexArray = vertexArray
this.setData(data)
this.setData(data, isCoinbase)
this.view = new TxView(this)
}
isCoinbase (txn) {
if (txn.inputs && txn.inputs.length === 1 && txn.inputs[0].prev_txid === "0000000000000000000000000000000000000000000000000000000000000000") {
const cbInfo = txn.inputs[0].script_sig
// number of bytes encoding the block height
const height_bytes = parseInt(cbInfo.substring(0,2), 16)
// extract the specified number of bytes, reverse the endianness (reverse pairs of hex characters), parse as a hex string
const height = parseInt(cbInfo.substring(2,2 + (height_bytes * 2)).match(/../g).reverse().join(''),16)
// save remaining bytes as free data
const sig = cbInfo.substring(2 + (height_bytes * 2))
const sigAscii = sig.match(/../g).reduce((parsed, hexChar) => {
return parsed + String.fromCharCode(parseInt(hexChar, 16))
}, "")
setCoinbaseData (block) {
const cbInfo = this.inputs[0].script_sig
// number of bytes encoding the block height
const height_bytes = parseInt(cbInfo.substring(0,2), 16)
// extract the specified number of bytes, reverse the endianness (reverse pairs of hex characters), parse as a hex string
const height = parseInt(cbInfo.substring(2,2 + (height_bytes * 2)).match(/../g).reverse().join(''),16)
// save remaining bytes as free data
const sig = cbInfo.substring(2 + (height_bytes * 2))
const sigAscii = sig.match(/../g).reduce((parsed, hexChar) => {
return parsed + String.fromCharCode(parseInt(hexChar, 16))
}, "")
return {
height,
sig,
sigAscii
}
} else return false
this.coinbase = {
height,
sig,
sigAscii,
fees: block.fees,
subsidy: this.value - (block.fees || 0)
}
}
setData ({ version, inflated, id, value, fee, vbytes, inputs, outputs, time, block }) {
setData ({ version, inflated, id, value, fee, vbytes, inputs, outputs, time, block }, isCoinbase=false) {
this.version = version
this.is_inflated = !!inflated
this.id = id
@ -53,8 +53,8 @@ export default class BitcoinTx {
this.highlight = false
// is a coinbase transaction?
this.coinbase = this.isCoinbase(this)
if (this.coinbase || !this.is_inflated || (this.fee < 0)) {
this.isCoinbase = isCoinbase
if (this.isCoinbase || !this.is_inflated || (this.fee < 0)) {
this.fee = null
this.feerate = null
}
@ -92,6 +92,10 @@ export default class BitcoinTx {
setBlock (block) {
this.block = block
this.state = this.block ? 'block' : 'pool'
if (this.block && this.block.coinbase && this.id == this.block.coinbase.id) {
this.isCoinbase = true
this.setCoinbaseData(this.block)
}
}
onEnterScene () {

View File

@ -45,6 +45,9 @@ export function SPKToAddress (spk) {
const payload = "05" + spk.slice(4, -2)
const checksum = hash(hash(payload)).slice(0, 8)
return binary_to_base58(hexToUintArray(payload + checksum))
} else if (spk.startsWith('6a')) {
// OP_RETURN
return 'OP_RETURN'
}
}