mirror of
https://github.com/Retropex/bitfeed.git
synced 2025-05-12 19:20:46 +02:00
Merge pull request #35 from bitfeed-project/transaction-details
Transaction detail overlay
This commit is contained in:
commit
eb8323a55d
33
client/package-lock.json
generated
33
client/package-lock.json
generated
@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "bitfeed-client",
|
||||
"version": "2.1.5",
|
||||
"version": "2.2.1",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "bitfeed-client",
|
||||
"version": "2.1.5",
|
||||
"version": "2.2.1",
|
||||
"dependencies": {
|
||||
"@babel/core": "^7.16.5",
|
||||
"@babel/preset-env": "^7.16.5",
|
||||
@ -21,6 +21,7 @@
|
||||
"d3-color": "^3.0.1",
|
||||
"d3-interpolate": "^3.0.1",
|
||||
"dotenv": "^10.0.0",
|
||||
"hash.js": "^1.1.7",
|
||||
"locale-currency": "0.0.2",
|
||||
"qrcode": "^1.5.0",
|
||||
"rollup": "^2.62.0",
|
||||
@ -2834,6 +2835,15 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/hash.js": {
|
||||
"version": "1.1.7",
|
||||
"resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz",
|
||||
"integrity": "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==",
|
||||
"dependencies": {
|
||||
"inherits": "^2.0.3",
|
||||
"minimalistic-assert": "^1.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/ieee754": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
||||
@ -3161,6 +3171,11 @@
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/minimalistic-assert": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
|
||||
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A=="
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz",
|
||||
@ -6383,6 +6398,15 @@
|
||||
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.2.tgz",
|
||||
"integrity": "sha512-chXa79rL/UC2KlX17jo3vRGz0azaWEx5tGqZg5pO3NUyEJVB17dMruQlzCCOfUvElghKcm5194+BCRvi2Rv/Gw=="
|
||||
},
|
||||
"hash.js": {
|
||||
"version": "1.1.7",
|
||||
"resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz",
|
||||
"integrity": "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==",
|
||||
"requires": {
|
||||
"inherits": "^2.0.3",
|
||||
"minimalistic-assert": "^1.0.1"
|
||||
}
|
||||
},
|
||||
"ieee754": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
||||
@ -6628,6 +6652,11 @@
|
||||
"resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",
|
||||
"integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg=="
|
||||
},
|
||||
"minimalistic-assert": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
|
||||
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A=="
|
||||
},
|
||||
"minimatch": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz",
|
||||
|
@ -20,6 +20,7 @@
|
||||
"d3-color": "^3.0.1",
|
||||
"d3-interpolate": "^3.0.1",
|
||||
"dotenv": "^10.0.0",
|
||||
"hash.js": "^1.1.7",
|
||||
"locale-currency": "0.0.2",
|
||||
"qrcode": "^1.5.0",
|
||||
"rollup": "^2.62.0",
|
||||
|
485
client/src/components/TransactionOverlay.svelte
Normal file
485
client/src/components/TransactionOverlay.svelte
Normal file
@ -0,0 +1,485 @@
|
||||
<script>
|
||||
import Overlay from '../components/Overlay.svelte'
|
||||
import Icon from './Icon.svelte'
|
||||
import BookmarkIcon from '../assets/icon/cil-bookmark.svg'
|
||||
import { longBtcFormat, numberFormat, feeRateFormat } from '../utils/format.js'
|
||||
import { exchangeRates, settings, sidebarToggle, newHighlightQuery, highlightingFull, detailTx, pageWidth } from '../stores.js'
|
||||
import { formatCurrency } from '../utils/fx.js'
|
||||
import { hlToHex, mixColor, teal, purple } from '../utils/color.js'
|
||||
import { SPKToAddress } from '../utils/encodings.js'
|
||||
|
||||
function onClose () {
|
||||
$detailTx = null
|
||||
}
|
||||
|
||||
function formatBTC (sats) {
|
||||
return `₿ ${(sats/100000000).toFixed(8)}`
|
||||
}
|
||||
|
||||
function highlight (query) {
|
||||
if (!$highlightingFull && query) {
|
||||
$newHighlightQuery = query
|
||||
$sidebarToggle = 'search'
|
||||
}
|
||||
}
|
||||
const rowHeight = 60
|
||||
let svgWidth = 380
|
||||
let flowWeight = 60
|
||||
let triangleWidth = 20
|
||||
$: {
|
||||
if ($pageWidth) {
|
||||
if ($pageWidth < 800) {
|
||||
svgWidth = Math.max($pageWidth - 420, 120)
|
||||
flowWeight = 0.1875 * (svgWidth - 60)
|
||||
} else {
|
||||
svgWidth = 380
|
||||
flowWeight = 60
|
||||
}
|
||||
if ($pageWidth < 600) {
|
||||
triangleWidth = 10
|
||||
} else {
|
||||
triangleWidth = 20
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const midColor = hlToHex(mixColor(teal, purple, 1, 3, 2))
|
||||
let feeColor
|
||||
$: {
|
||||
if ($detailTx && $detailTx.feerate != null) {
|
||||
feeColor = hlToHex(mixColor(teal, purple, 1, Math.log2(64), Math.log2($detailTx.feerate)))
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
title
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
let inputs = []
|
||||
let outputs = []
|
||||
$: {
|
||||
if ($detailTx && $detailTx.inputs) {
|
||||
if ($detailTx.isCoinbase) {
|
||||
inputs = [{
|
||||
address: 'coinbase',
|
||||
value: $detailTx.value
|
||||
}]
|
||||
} else {
|
||||
inputs = expandAddresses($detailTx.inputs)
|
||||
}
|
||||
} else inputs = []
|
||||
if ($detailTx && $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 && 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 total = fee + value
|
||||
const mergeOffset = (totalHeight - flowWeight) / 2
|
||||
let cumThick = 0
|
||||
let xOffset = 0
|
||||
|
||||
const inLines = inputs.map((input, index) => {
|
||||
const weight = (input.value / total) * flowWeight
|
||||
const height = ((index + 0.5) * rowHeight)
|
||||
const step = (weight / 2)
|
||||
const line = []
|
||||
const yOffset = 0.5
|
||||
|
||||
line.push({ x: triangleWidth, y: height })
|
||||
line.push({ x: triangleWidth + (0.25 * svgWidth), y: height })
|
||||
line.push({ x: 0.375 * svgWidth, y: mergeOffset + cumThick + step + yOffset })
|
||||
line.push({ x: (0.5 * svgWidth) + 1, y: mergeOffset + cumThick + step + yOffset })
|
||||
|
||||
const dy = line[1].y - line[2].y
|
||||
const dx = line[2].x - line[1].x
|
||||
const miterOffset = getMiterOffset(weight, dy, dx)
|
||||
xOffset -= miterOffset
|
||||
line[1].x -= xOffset
|
||||
line[2].x -= xOffset
|
||||
xOffset -= miterOffset
|
||||
|
||||
// inLines.push({ line, weight })
|
||||
// inLines.push({ line: [{x: line[1].x + miterOffset, y: line[1].y - (weight / 2)}, {x: line[2].x + miterOffset, y: line[2].y - (weight / 2)}], weight: 1})
|
||||
|
||||
cumThick += weight
|
||||
|
||||
return { line, weight, index, total: inputs.length, in: true }
|
||||
})
|
||||
inLines.forEach(line => {
|
||||
line.line[1].x += xOffset
|
||||
line.line[2].x += xOffset
|
||||
})
|
||||
|
||||
cumThick = 0
|
||||
xOffset = 0
|
||||
|
||||
const outLines = outputs.map((output, index) => {
|
||||
const weight = (output.value / total) * flowWeight
|
||||
const height = ((index + 0.5) * rowHeight)
|
||||
const step = (weight / 2)
|
||||
const line = []
|
||||
const yOffset = 0.5
|
||||
|
||||
line.push({ x: (0.5 * svgWidth) - 1, y: mergeOffset + cumThick + step + yOffset })
|
||||
line.push({ x: 0.625 * svgWidth, y: mergeOffset + cumThick + step + yOffset })
|
||||
line.push({ x: svgWidth - triangleWidth - (0.25 * svgWidth), y: height })
|
||||
line.push({ x: svgWidth - triangleWidth, y: height })
|
||||
|
||||
const dy = line[2].y - line[1].y
|
||||
const dx = line[2].x - line[1].x
|
||||
const miterOffset = getMiterOffset(weight, dy, dx)
|
||||
xOffset -= miterOffset
|
||||
line[1].x += xOffset
|
||||
line[2].x += xOffset
|
||||
xOffset -= miterOffset
|
||||
|
||||
// outLines.push({ line, weight })
|
||||
// outLines.push({ line: [{x: line[1].x + miterOffset, y: line[1].y - (weight / 2)}, {x: line[2].x + miterOffset, y: line[2].y - (weight / 2)}], weight: 1})
|
||||
|
||||
cumThick += weight
|
||||
|
||||
return { line, weight, index, total: outputs.length }
|
||||
})
|
||||
outLines.forEach(line => {
|
||||
line.line[1].x -= xOffset
|
||||
line.line[2].x -= xOffset
|
||||
})
|
||||
|
||||
return [...inLines, ...outLines].map(line => {
|
||||
return {
|
||||
points: line.line.map(point => { return `${point.x},${point.y}`}).join(' '),
|
||||
weight: line.weight,
|
||||
index: line.index,
|
||||
total: line.total,
|
||||
in: line.in
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// given a line weight and corner angle
|
||||
// return the horizontal distance of a miter from the corner
|
||||
function getMiterOffset (weight, dy, dx) {
|
||||
if (dy != 0) {
|
||||
const angle = Math.atan2(dy, dx)
|
||||
const u = weight / 2
|
||||
const a = 0
|
||||
const b = dy / dx
|
||||
const c = -u
|
||||
const d = -u * (Math.cos(angle) + (b * Math.sin(angle)))
|
||||
return (d - c) / (a - b)
|
||||
} else return 0
|
||||
}
|
||||
</script>
|
||||
|
||||
<style type="text/scss">
|
||||
.tx-detail {
|
||||
width: 100%;
|
||||
overflow-x: hidden;
|
||||
text-align: left;
|
||||
|
||||
h2 {
|
||||
font-size: 1.2em;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.tx-id {
|
||||
font-family: monospace;
|
||||
font-size: 0.9em;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.icon-button {
|
||||
float: right;
|
||||
font-size: 24px;
|
||||
margin: 0;
|
||||
transition: opacity 300ms, color 300ms, background 300ms;
|
||||
background: var(--palette-d);
|
||||
color: var(--bold-a);
|
||||
cursor: pointer;
|
||||
padding: 5px;
|
||||
border-radius: 5px;
|
||||
&:hover {
|
||||
background: var(--palette-e);
|
||||
}
|
||||
&.disabled {
|
||||
color: var(--palette-e);
|
||||
background: none;
|
||||
}
|
||||
}
|
||||
|
||||
.pane {
|
||||
background: var(--palette-b);
|
||||
padding: 16px;
|
||||
border-radius: .5em;
|
||||
margin: 0 0 1em;
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
|
||||
.label {
|
||||
font-size: 0.8em;
|
||||
color: var(--grey);
|
||||
}
|
||||
|
||||
.value.coinbase-sig {
|
||||
word-break: break-all;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.fee-calc {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
width: auto;
|
||||
color: var(--palette-x);
|
||||
|
||||
.operator {
|
||||
font-size: 2em;
|
||||
color: var(--grey);
|
||||
margin: 0 1em;
|
||||
}
|
||||
}
|
||||
|
||||
.flow-diagram {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0px, 1fr) 380px minmax(0px, 1fr);
|
||||
|
||||
.header {
|
||||
height: 60px;
|
||||
font-size: 1.1em;
|
||||
font-weight: 800;
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.column {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
|
||||
.entry {
|
||||
height: 60px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
border-top: solid 1px var(--grey);
|
||||
box-sizing: border-box;
|
||||
text-align: right;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
&:last-child {
|
||||
border-bottom: solid 1px var(--grey);
|
||||
}
|
||||
|
||||
.amount, .address {
|
||||
margin: 0;
|
||||
font-family: monospace;
|
||||
font-size: 1.1em;
|
||||
}
|
||||
.amount {
|
||||
white-space: nowrap;
|
||||
}
|
||||
.address {
|
||||
width: 100%;
|
||||
text-align: right;
|
||||
span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: inline-block;
|
||||
}
|
||||
.truncatable {
|
||||
max-width: calc(100% - 4em);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.inputs {
|
||||
.entry {
|
||||
align-items: flex-start;
|
||||
.address {
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sankey {
|
||||
margin-top: 60px;
|
||||
|
||||
polyline {
|
||||
fill: none;
|
||||
stroke-linecap: butt;
|
||||
stroke-linejoin: miter;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 679px) {
|
||||
.fee-calc {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 411px) and (max-width: 479px) {
|
||||
.flow-diagram {
|
||||
font-size: 0.7em;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 410px) {
|
||||
.flow-diagram {
|
||||
display: block;
|
||||
|
||||
.column {
|
||||
width: 100%;
|
||||
margin: 30px 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<Overlay name="tx" on:close={onClose}>
|
||||
{#if $detailTx}
|
||||
<section class="tx-detail">
|
||||
<div class="icon-button" class:disabled={$highlightingFull} on:click={() => highlight($detailTx.id)} title="Add transaction to watchlist">
|
||||
<Icon icon={BookmarkIcon}/>
|
||||
</div>
|
||||
{#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>
|
||||
|
||||
<div class="pane fee-calc">
|
||||
<div class="field">
|
||||
<span class="label">coinbase</span>
|
||||
<span class="value coinbase-sig">{ $detailTx.coinbase.sigAscii }</span>
|
||||
</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 & Outputs</h2>
|
||||
<div class="pane flow-diagram" style="grid-template-columns: minmax(0px, 1fr) {svgWidth}px minmax(0px, 1fr);">
|
||||
<div class="column inputs">
|
||||
<p class="header">{$detailTx.inputs.length} input{$detailTx.inputs.length > 1 ? 's' : ''}</p>
|
||||
{#each inputs as input}
|
||||
<div class="entry">
|
||||
<p class="address" title={input.address}><span class="truncatable">{input.address.slice(0,-6)}</span><span class="suffix">{input.address.slice(-6)}</span></p>
|
||||
<p class="amount">{ formatBTC(input.value) }</p>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="column diagram">
|
||||
{#if sankeyLines && $pageWidth > 410}
|
||||
<svg class="sankey" height="{sankeyHeight}px" width="{svgWidth}px">
|
||||
<defs>
|
||||
{#each sankeyLines as line, index}
|
||||
<linearGradient id="lg{index}" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
{#if line.in}
|
||||
<stop offset="0%" stop-color={hlToHex(mixColor(teal, purple, 0, Math.max(1,line.total-1), line.index))}/>
|
||||
<stop offset="100%" stop-color={midColor}/>
|
||||
{:else}
|
||||
<stop offset="0%" stop-color={midColor}/>
|
||||
<stop offset="100%" stop-color={hlToHex(mixColor(purple, teal, 0, Math.max(1,line.total-1), line.index))}/>
|
||||
{/if}
|
||||
</linearGradient>
|
||||
{/each}
|
||||
</defs>
|
||||
{#each sankeyLines as line, index }
|
||||
<polyline points="{line.points}" stroke="url(#lg{index})" style="stroke-width: {line.weight + 1}px;" />
|
||||
{#if line.in}
|
||||
<polyline points="0,{line.index * 60} {triangleWidth},{(line.index * 60 )+ 30} 0,{(line.index * 60) + 60}" stroke="var(--grey)" style="stroke-width: 1px;" />
|
||||
{:else}
|
||||
<polyline points="{svgWidth},{line.index * 60} {svgWidth - triangleWidth},{(line.index * 60 )+ 30} {svgWidth},{(line.index * 60) + 60}" stroke="var(--grey)" style="stroke-width: 1px;" />
|
||||
{/if}
|
||||
{/each}
|
||||
</svg>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="column outputs">
|
||||
<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.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}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
</Overlay>
|
@ -3,10 +3,11 @@
|
||||
import TxController from '../controllers/TxController.js'
|
||||
import TxRender from './TxRender.svelte'
|
||||
import getTxStream from '../controllers/TxStream.js'
|
||||
import { settings, overlay, serverConnected, serverDelay, txCount, mempoolCount, mempoolScreenHeight, frameRate, avgFrameRate, blockVisible, tinyScreen, currentBlock, selectedTx, blockAreaSize, devEvents, devSettings } from '../stores.js'
|
||||
import { settings, overlay, serverConnected, serverDelay, txCount, mempoolCount, mempoolScreenHeight, frameRate, avgFrameRate, blockVisible, tinyScreen, currentBlock, selectedTx, blockAreaSize, devEvents, devSettings, pageWidth } from '../stores.js'
|
||||
import BlockInfo from '../components/BlockInfo.svelte'
|
||||
import TxInfo from '../components/TxInfo.svelte'
|
||||
import Sidebar from '../components/Sidebar.svelte'
|
||||
import TransactionOverlay from '../components/TransactionOverlay.svelte'
|
||||
import AboutOverlay from '../components/AboutOverlay.svelte'
|
||||
import DonationOverlay from '../components/DonationOverlay.svelte'
|
||||
import SupportersOverlay from '../components/SupportersOverlay.svelte'
|
||||
@ -77,6 +78,7 @@
|
||||
})
|
||||
|
||||
function resize () {
|
||||
$pageWidth = window.innerWidth
|
||||
if (width !== window.innerWidth - 20 || height !== window.innerHeight - 20) {
|
||||
// don't force resize unless the viewport has actually changed
|
||||
width = window.innerWidth - 20
|
||||
@ -477,6 +479,7 @@
|
||||
|
||||
<Sidebar />
|
||||
|
||||
<TransactionOverlay />
|
||||
<AboutOverlay />
|
||||
{#if config.donationsEnabled }
|
||||
<DonationOverlay />
|
||||
|
@ -5,7 +5,7 @@ import BitcoinTx from '../models/BitcoinTx.js'
|
||||
import BitcoinBlock from '../models/BitcoinBlock.js'
|
||||
import TxSprite from '../models/TxSprite.js'
|
||||
import { FastVertexArray } from '../utils/memory.js'
|
||||
import { txCount, mempoolCount, mempoolScreenHeight, blockVisible, currentBlock, selectedTx, blockAreaSize, highlight, colorMode } from '../stores.js'
|
||||
import { overlay, txCount, mempoolCount, mempoolScreenHeight, blockVisible, currentBlock, selectedTx, detailTx, blockAreaSize, highlight, colorMode } from '../stores.js'
|
||||
import config from "../config.js"
|
||||
|
||||
export default class TxController {
|
||||
@ -29,6 +29,9 @@ export default class TxController {
|
||||
this.lastTxTime = 0
|
||||
this.txDelay = 0
|
||||
|
||||
detailTx.subscribe(tx => {
|
||||
this.onDetailTxChanged(tx)
|
||||
})
|
||||
highlight.subscribe(criteria => {
|
||||
this.highlightCriteria = criteria
|
||||
this.applyHighlighting()
|
||||
@ -202,7 +205,24 @@ export default class TxController {
|
||||
}
|
||||
this.selectedTx = selected
|
||||
selectedTx.set(this.selectedTx)
|
||||
if (sameTx && this.selectedTx) {
|
||||
detailTx.set(this.selectedTx)
|
||||
overlay.set('tx')
|
||||
}
|
||||
this.selectionLocked = !!this.selectedTx && !(this.selectionLocked && sameTx)
|
||||
}
|
||||
}
|
||||
|
||||
onDetailTxChanged (tx) {
|
||||
if (!tx) {
|
||||
if (this.selectedTx) {
|
||||
this.selectedTx.hoverOff()
|
||||
this.selectedTx = null
|
||||
}
|
||||
selectedTx.set(null)
|
||||
this.selectionLocked = false
|
||||
}
|
||||
|
||||
selectedTx.set(null)
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
|
@ -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 () {
|
||||
|
@ -92,6 +92,7 @@ export const avgFrameRate = writable(null)
|
||||
export const blockVisible = writable(false)
|
||||
export const currentBlock = writable(null)
|
||||
export const selectedTx = writable(null)
|
||||
export const detailTx = writable(null)
|
||||
export const blockAreaSize = writable(0)
|
||||
|
||||
export const settingsOpen = writable(false)
|
||||
@ -149,3 +150,5 @@ export const highlightingFull = writable(false)
|
||||
const aspectRatio = window.innerWidth / window.innerHeight
|
||||
let isTinyScreen = (window.innerWidth < 480 && window.innerHeight < 480)
|
||||
export const tinyScreen = writable(isTinyScreen)
|
||||
|
||||
export const pageWidth = writable(window.innerWidth)
|
||||
|
@ -1,7 +1,8 @@
|
||||
import { Buffer } from 'buffer/'
|
||||
window.Buffer = Buffer
|
||||
import bech32 from 'bech32-buffer'
|
||||
import { base58_to_binary } from 'base58-js'
|
||||
import { base58_to_binary, binary_to_base58 } from 'base58-js'
|
||||
import { sha256 } from 'hash.js'
|
||||
|
||||
// Extract a raw script hash from an address
|
||||
export function addressToSPK (address) {
|
||||
@ -25,3 +26,39 @@ export function addressToSPK (address) {
|
||||
return prefix + Buffer.from(result).toString('hex').slice(2, -8) + postfix
|
||||
}
|
||||
}
|
||||
|
||||
// Extract an address from a raw scriptpubkey
|
||||
export function SPKToAddress (spk) {
|
||||
if (spk.startsWith('5120')) {
|
||||
// taproot
|
||||
return (new bech32.BitcoinAddress('bc', 1, hexToUintArray(spk.slice(4)))).encode()
|
||||
} else if (spk.startsWith('0020') || spk.startsWith('0014')) {
|
||||
// p2wsh or p2wpkh
|
||||
return (new bech32.BitcoinAddress('bc', 0, hexToUintArray(spk.slice(4)))).encode()
|
||||
} else if (spk.startsWith('76a914')) {
|
||||
// p2pkh
|
||||
const payload = "00" + spk.slice(6, -4)
|
||||
const checksum = hash(hash(payload)).slice(0, 8)
|
||||
return binary_to_base58(hexToUintArray(payload + checksum))
|
||||
} else if (spk.startsWith('a914')) {
|
||||
// p2sh
|
||||
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'
|
||||
}
|
||||
}
|
||||
|
||||
function hexToUintArray(hex) {
|
||||
let a = new Uint8Array(hex.length / 2)
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
a[i] = parseInt(hex.substr(2 * i, 2), 16)
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
function hash (hex) {
|
||||
return sha256().update(hexToUintArray(hex)).digest('hex')
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user