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

View File

@ -13,12 +13,13 @@ export default class BitcoinBlock {
this.bytes = bytes // OTW size of this block in bytes this.bytes = bytes // OTW size of this block in bytes
this.txnCount = txn_count this.txnCount = txn_count
this.txns = txns this.txns = txns
this.coinbase = new BitcoinTx(this.txns[0]) this.coinbase = new BitcoinTx(this.txns[0], true)
if (fees) { if (fees) {
this.fees = fees this.fees = fees
} else { } else {
this.fees = null this.fees = null
} }
this.coinbase.setBlock(this)
this.height = this.coinbase.coinbase.height this.height = this.coinbase.coinbase.height
this.miner_sig = this.coinbase.coinbase.sigAscii this.miner_sig = this.coinbase.coinbase.sigAscii
@ -29,7 +30,7 @@ export default class BitcoinBlock {
this.minFeerate = this.txnCount > 1 ? Infinity : 0 this.minFeerate = this.txnCount > 1 ? Infinity : 0
this.avgFeerate = 0 this.avgFeerate = 0
this.txns.forEach(txn => { this.txns.forEach(txn => {
if (!BitcoinTx.prototype.isCoinbase(txn)) { if (txn.id !== this.coinbase.id) {
const txFeerate = txn.fee / txn.vbytes const txFeerate = txn.fee / txn.vbytes
this.maxFeerate = Math.max(this.maxFeerate, txFeerate) this.maxFeerate = Math.max(this.maxFeerate, txFeerate)
this.minFeerate = Math.min(this.minFeerate, 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' import { mixColor, pink, bluegreen, orange, teal, green, purple } from '../utils/color.js'
export default class BitcoinTx { export default class BitcoinTx {
constructor (data, vertexArray) { constructor (data, vertexArray, isCoinbase = false) {
this.vertexArray = vertexArray this.vertexArray = vertexArray
this.setData(data) this.setData(data, isCoinbase)
this.view = new TxView(this) this.view = new TxView(this)
} }
isCoinbase (txn) { setCoinbaseData (block) {
if (txn.inputs && txn.inputs.length === 1 && txn.inputs[0].prev_txid === "0000000000000000000000000000000000000000000000000000000000000000") { const cbInfo = this.inputs[0].script_sig
const cbInfo = txn.inputs[0].script_sig // number of bytes encoding the block height
// number of bytes encoding the block height const height_bytes = parseInt(cbInfo.substring(0,2), 16)
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
// 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)
const height = parseInt(cbInfo.substring(2,2 + (height_bytes * 2)).match(/../g).reverse().join(''),16) // save remaining bytes as free data
// save remaining bytes as free data const sig = cbInfo.substring(2 + (height_bytes * 2))
const sig = cbInfo.substring(2 + (height_bytes * 2)) const sigAscii = sig.match(/../g).reduce((parsed, hexChar) => {
const sigAscii = sig.match(/../g).reduce((parsed, hexChar) => { return parsed + String.fromCharCode(parseInt(hexChar, 16))
return parsed + String.fromCharCode(parseInt(hexChar, 16)) }, "")
}, "")
return { this.coinbase = {
height, height,
sig, sig,
sigAscii sigAscii,
} fees: block.fees,
} else return false 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.version = version
this.is_inflated = !!inflated this.is_inflated = !!inflated
this.id = id this.id = id
@ -53,8 +53,8 @@ export default class BitcoinTx {
this.highlight = false this.highlight = false
// is a coinbase transaction? // is a coinbase transaction?
this.coinbase = this.isCoinbase(this) this.isCoinbase = isCoinbase
if (this.coinbase || !this.is_inflated || (this.fee < 0)) { if (this.isCoinbase || !this.is_inflated || (this.fee < 0)) {
this.fee = null this.fee = null
this.feerate = null this.feerate = null
} }
@ -92,6 +92,10 @@ export default class BitcoinTx {
setBlock (block) { setBlock (block) {
this.block = block this.block = block
this.state = this.block ? 'block' : 'pool' 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 () { onEnterScene () {

View File

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