Basic block explorer & entry/exit transitions

This commit is contained in:
Mononaut 2022-04-23 07:49:39 -06:00
parent b5bcaf2377
commit a827ac036b
17 changed files with 615 additions and 83 deletions

View File

@ -4,6 +4,8 @@ map $sent_http_content_type $expires {
application/javascript max;
}
proxy_cache_path /var/cache/nginx/bitfeed levels=1:2 keys_zone=bitfeed:10m max_size=500m inactive=1w use_temp_path=off;
server {
listen 80;
@ -22,6 +24,11 @@ server {
}
location /api {
proxy_cache bitfeed;
proxy_cache_revalidate on;
proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504;
proxy_cache_background_update on;
proxy_cache_lock on;
proxy_pass http://wsmonobackend;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;

View File

@ -5,8 +5,8 @@
import { createEventDispatcher } from 'svelte'
import Icon from '../components/Icon.svelte'
import closeIcon from '../assets/icon/cil-x-circle.svg'
import { shortBtcFormat, longBtcFormat, timeFormat, numberFormat } from '../utils/format.js'
import { exchangeRates, settings, blocksEnabled } from '../stores.js'
import { shortBtcFormat, longBtcFormat, dateFormat, numberFormat } from '../utils/format.js'
import { exchangeRates, settings, blocksEnabled, latestBlockHeight, blockTransitionDirection } from '../stores.js'
import { formatCurrency } from '../utils/fx.js'
const dispatch = createEventDispatcher()
@ -47,8 +47,23 @@
}
}
function formatTime (time) {
return timeFormat.format(time)
let flyIn
let flyOut
$: {
if ($blockTransitionDirection && $blockTransitionDirection === 'right') {
flyIn = { x: 100, easing: linear, delay: 1000, duration: 1000 }
flyOut = { x: -100, easing: linear, delay: 0, duration: 1000 }
} else if ($blockTransitionDirection && $blockTransitionDirection === 'left') {
flyIn = { x: -100, easing: linear, delay: 1000, duration: 1000 }
flyOut = { x: 100, easing: linear, delay: 0, duration: 1000 }
} else {
flyIn = { y: (restoring ? -50 : 50), duration: (restoring ? 500 : 1000), easing: linear, delay: (restoring ? 0 : newBlockDelay) }
flyOut = { y: -50, duration: 2000, easing: linear }
}
}
function formatDateTime (time) {
return dateFormat.format(time)
}
function formatBTC (sats) {
@ -74,8 +89,12 @@
}
function hideBlock () {
analytics.trackEvent('viz', 'block', 'hide')
dispatch('hideBlock')
if (block && block.height != $latestBlockHeight) {
dispatch('quitExploring')
} else {
analytics.trackEvent('viz', 'block', 'hide')
dispatch('hideBlock')
}
}
</script>
@ -229,15 +248,15 @@
{#each [block] as block (block)}
{#if block != null && visible && $blocksEnabled }
<div class="block-info" out:fly="{{ y: -50, duration: 2000, easing: linear }}" in:fly="{{ y: (restoring ? -50 : 50), duration: (restoring ? 500 : 1000), easing: linear, delay: (restoring ? 0 : newBlockDelay) }}">
<div class="block-info" out:fly={flyOut} in:fly={flyIn}>
<!-- <span class="data-field">Hash: { block.id }</span> -->
<div class="full-size">
<div class="data-row">
<span class="data-field title-field" title="{block.miner_sig}"><b>Latest Block: </b>{ numberFormat.format(block.height) }</span>
<span class="data-field title-field" title="{block.miner_sig}"><b>{#if block.height == $latestBlockHeight}Latest {/if}Block: </b>{ numberFormat.format(block.height) }</span>
<button class="data-field close-button" on:click={hideBlock}><Icon icon={closeIcon} color="var(--palette-x)" /></button>
</div>
<div class="data-row">
<span class="data-field">Mined { formatTime(block.time) }</span>
<span class="data-field" title="block timestamp">{ formatDateTime(block.time) }</span>
<span class="data-field">{ formattedBlockValue }</span>
</div>
<div class="data-row">
@ -260,7 +279,7 @@
<button class="data-field close-button" on:click={hideBlock}><Icon icon={closeIcon} color="var(--palette-x)" /></button>
</div>
<div class="data-row">
<span class="data-field">Mined { formatTime(block.time) }</span>
<span class="data-field">{ formatDateTime(block.time) }</span>
<span class="data-field">{ formattedBlockValue }</span>
</div>
<div class="data-row">
@ -273,7 +292,7 @@
</div>
</div>
</div>
<button class="close-button standalone" on:click={hideBlock} out:fly="{{ y: -50, duration: 2000, easing: linear }}" in:fly="{{ y: (restoring ? -50 : 50), duration: (restoring ? 500 : 1000), easing: linear, delay: (restoring ? 0 : newBlockDelay) }}" >
<button class="close-button standalone" on:click={hideBlock} out:fly={flyOut} in:fly={flyIn} >
<Icon icon={closeIcon} color="var(--palette-x)" />
</button>
{/if}

View File

@ -207,7 +207,7 @@ function generateColorScale (colorA, colorB) {
{:else}
<span class="value left">1</span>
<img src={feeColorScale} alt="" class="color-scale-img" width="200" height="15">
<span class="value right">64+</span>
<span class="value right">128+</span>
{/if}
</div>
</div>

View File

@ -9,7 +9,7 @@ import AddressIcon from '../assets/icon/cil-wallet.svg'
import TxIcon from '../assets/icon/cil-arrow-circle-right.svg'
import BlockIcon from '../assets/icon/grid-icon.svg'
import { fly } from 'svelte/transition'
import { matchQuery, searchTx, searchBlock } from '../utils/search.js'
import { matchQuery, searchTx, searchBlockHeight, searchBlockHash } from '../utils/search.js'
import { selectedTx, detailTx, overlay, loading } from '../stores.js'
const queryIcons = {
@ -68,8 +68,17 @@ async function searchSubmit (e) {
case 'output':
searchErr = await searchTx(matchedQuery.txid, null, matchedQuery.output)
break;
case 'blockheight':
searchErr = await searchBlockHeight(matchedQuery.height)
break;
case 'blockhash':
searchErr = await searchBlockHash(matchedQuery.hash)
break;
}
if (searchErr != null) handleSearchError(searchErr)
if (searchErr == null) errorMessage = null
else handleSearchError(searchErr)
$loading--
} else {
errorMessage = 'enter a transaction id, block hash or block height'

View File

@ -31,6 +31,17 @@ $: {
}
}
let inputCount
let outputCount
$: {
if (tx) {
if (tx.inputs) inputCount = tx.inputs.length
else inputCount = tx.numInputs || 0
if (tx.outputs) outputCount = tx.outputs.length
else outputCount = tx.numOutputs || 0
}
}
function formatBTC (sats) {
return `₿ ${longBtcFormat.format(sats/100000000)}`
}
@ -137,14 +148,18 @@ function highlight () {
<p class="field hash">
TxID: { tx.id }
</p>
{#if tx.inputs && tx.outputs && !tx.coinbase }
{#if inputCount && outputCount && !tx.coinbase }
<p class="field inputs">
<span>{ tx.inputs.length } input{#if tx.inputs.length != 1}s{/if}</span>
<span>{ inputCount } input{#if inputCount != 1}s{/if}</span>
<span class="arrow"> &#10230; </span>
<span>{ tx.outputs.length } output{#if tx.outputs.length != 1}s{/if}</span>
<span>{ outputCount } output{#if outputCount != 1}s{/if}</span>
</p>
{:else if tx.coinbase }
<p class="field coinbase">Coinbase: { tx.coinbase.sigAscii }</p>
{#if tx.coinbase.sigAscii }
<p class="field coinbase">Coinbase: { tx.coinbase.sigAscii }</p>
{:else}
<p class="field coinbase">Coinbase</p>
{/if}
<p class="field inputs">{ tx.outputs.length } output{#if tx.outputs.length != 1}s{/if}</p>
{/if}
<p class="field vbytes">Size: { numberFormat.format(tx.vbytes) } vbytes</p>

View File

@ -110,6 +110,10 @@
$blockVisible = false
}
function quitExploring () {
if (txController) txController.resumeLatest()
}
function fakeBlock () {
const block = txController.simulateBlock()
// txController.addBlock(new BitcoinBlock({
@ -492,7 +496,7 @@
<div class="spacer" style="flex: {$pageWidth <= 640 ? '1.5' : '1'}"></div>
<div class="block-area-outer" style="width: {$blockAreaSize}px; height: {$blockAreaSize}px">
<div class="block-area">
<BlockInfo block={$currentBlock} visible={$blockVisible && !$tinyScreen} on:hideBlock={hideBlock} />
<BlockInfo block={$currentBlock} visible={$blockVisible && !$tinyScreen} on:hideBlock={hideBlock} on:quitExploring={quitExploring} />
</div>
{#if config.dev && config.debug && $devSettings.guides }
<div class="guide-area" />

View File

@ -5,7 +5,8 @@ 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 { overlay, txCount, mempoolCount, mempoolScreenHeight, blockVisible, currentBlock, selectedTx, detailTx, blockAreaSize, highlight, colorMode, blocksEnabled, latestBlockHeight } from '../stores.js'
import { searchTx } from '../utils/search.js'
import { overlay, txCount, mempoolCount, mempoolScreenHeight, blockVisible, currentBlock, selectedTx, detailTx, blockAreaSize, highlight, colorMode, blocksEnabled, latestBlockHeight, explorerBlockData, blockTransitionDirection, loading } from '../stores.js'
import config from "../config.js"
export default class TxController {
@ -18,6 +19,9 @@ export default class TxController {
this.blockAreaSize = (width <= 620) ? Math.min(window.innerWidth * 0.7, window.innerHeight / 2.75) : Math.min(window.innerWidth * 0.75, window.innerHeight / 2.5)
blockAreaSize.set(this.blockAreaSize)
this.blockScene = null
this.block = null
this.explorerBlockScene = null
this.explorerBlock = null
this.clearBlockTimeout = null
this.txDelay = 0 //config.txDelay
this.maxTxDelay = config.txDelay
@ -43,6 +47,15 @@ export default class TxController {
colorMode.subscribe(mode => {
this.setColorMode(mode)
})
explorerBlockData.subscribe(blockData => {
console.log('explorerBlock changed: ', blockData)
if (blockData) {
this.exploreBlock(blockData)
} else {
this.resumeLatest()
}
})
}
getVertexData () {
@ -54,7 +67,8 @@ export default class TxController {
}
getScenes () {
if (this.blockScene) return [this.poolScene, this.blockScene]
if (this.blockScene && this.explorerBlockScene) return [this.poolScene, this.blockScene, this.explorerBlockScene]
else if (this.blockScene) return [this.poolScene, this.blockScene]
else return [this.poolScene]
}
@ -63,6 +77,9 @@ export default class TxController {
if (this.blockScene) {
this.blockScene.layoutAll({ width: this.blockAreaSize, height: this.blockAreaSize })
}
if (this.explorerBlockScene) {
this.explorerBlockScene.layoutAll({ width: this.blockAreaSize, height: this.blockAreaSize })
}
}
resize ({ width, height }) {
@ -77,6 +94,9 @@ export default class TxController {
if (this.blockScene) {
this.blockScene.setColorMode(mode)
}
if (this.explorerBlockScene) {
this.explorerBlockScene.setColorMode(mode)
}
}
applyHighlighting () {
@ -84,6 +104,9 @@ export default class TxController {
if (this.blockScene) {
this.blockScene.applyHighlighting(this.highlightCriteria)
}
if (this.explorerBlockScene) {
this.explorerBlockScene.applyHighlighting(this.highlightCriteria)
}
}
addTx (txData) {
@ -125,20 +148,49 @@ export default class TxController {
}
}
simulateBlock () {
const time = Date.now() / 1000
console.log('sim time ', time)
this.addBlock({
version: 'fake',
id: Math.random(),
value: 10000,
prev_block: 'also_fake',
merkle_root: 'merkle',
timestamp: time,
bits: 'none',
txn_count: 20,
fees: 100,
txns: [{ version: 'fake', inflated: false, id: 'coinbase', value: 625000100, fee: 100, vbytes: 500, inputs:[{ prev_txid: '00000000000000000000000000000', prev_vout: 0, script_sig: '03e0170b04efb72c622f466f756e6472792055534120506f6f6c202364726f70676f6c642f0eb5059f0000000000000000',
sequence_no: 0, value: 625000100, script_pub_key: "76a9145e9b23809261178723055968d134a947f47e799f88ac" }], outputs: [{ prev_txid: '00000000000000000000000000000', prev_vout: 0, script_sig: '03e0170b04efb72c622f466f756e6472792055534120506f6f6c202364726f70676f6c642f0eb5059f0000000000000000',
sequence_no: 0, value: 625000100, script_pub_key: "76a9145e9b23809261178723055968d134a947f47e799f88ac" }], time: Date.now()
}, ...Object.keys(this.txs).filter(() => {
return (Math.random() < 0.5)
}).map(key => {
return {
...this.txs[key],
inputs: this.txs[key].inputs.map(input => { return {...input, script_pub_key: null, value: null }}),
}
})]
})
}
addBlock (blockData, realtime=true) {
// discard duplicate blocks
if (!blockData || !blockData.id || this.knownBlocks[blockData.id]) {
return
}
const block = new BitcoinBlock(blockData)
let block
block = new BitcoinBlock(blockData)
latestBlockHeight.set(block.height)
this.knownBlocks[block.id] = true
// this.knownBlocks[block.id] = true
if (this.clearBlockTimeout) clearTimeout(this.clearBlockTimeout)
this.expiredTxs = {}
this.clearBlock()
if (!this.explorerBlockScene) this.clearBlock()
if (this.blocksEnabled) {
this.blockScene = new TxBlockScene({ width: this.blockAreaSize, height: this.blockAreaSize, blockId: block.id, controller: this, colorMode: this.colorMode })
@ -163,10 +215,15 @@ export default class TxController {
this.expiredTxs[block.txns[i].id] = true
}
console.log(`New block with ${knownCount} known transactions and ${unknownCount} unknown transactions`)
this.blockScene.initialLayout()
this.blockScene.initialLayout(!!this.explorerBlockScene)
setTimeout(() => { this.poolScene.scrollLock = false; this.poolScene.layoutAll() }, 4000)
blockVisible.set(true)
if (!this.explorerBlockScene) {
blockTransitionDirection.set(null)
currentBlock.set(block)
}
} else {
this.poolScene.scrollLock = true
for (let i = 0; i < block.txns.length; i++) {
@ -205,21 +262,87 @@ export default class TxController {
this.poolScene.scrollLock = false
this.poolScene.layoutAll()
}, 5500)
blockTransitionDirection.set(null)
currentBlock.set(block)
}
currentBlock.set(block)
this.block = block
return block
}
exploreBlock (blockData) {
const block = new BitcoinBlock(blockData)
let enterFromRight = false
// clean up previous block
if (this.explorerBlock && this.explorerBlockScene) {
const prevBlock = this.explorerBlock
const prevBlockScene = this.explorerBlockScene
if (prevBlock.height < block.height) {
prevBlockScene.exitLeft()
enterFromRight = true
}
else prevBlockScene.exitRight()
prevBlockScene.expire(2000)
} else if (this.blockScene) {
this.blockScene.exitRight()
}
this.explorerBlock = block
if (this.blocksEnabled) {
this.explorerBlockScene = new TxBlockScene({ width: this.blockAreaSize, height: this.blockAreaSize, blockId: block.id, controller: this, colorMode: this.colorMode })
for (let i = 0; i < block.txns.length; i++) {
const tx = new BitcoinTx({
...block.txns[i],
block: block
}, this.vertexArray)
this.txs[tx.id] = tx
this.txs[tx.id].applyHighlighting(this.highlightCriteria)
this.explorerBlockScene.insert(tx, 0, false)
}
this.explorerBlockScene.prepareAll()
this.explorerBlockScene.layoutAll()
if (enterFromRight) {
blockTransitionDirection.set('right')
this.explorerBlockScene.enterRight()
} else {
blockTransitionDirection.set('left')
this.explorerBlockScene.enterLeft()
}
}
blockVisible.set(true)
currentBlock.set(block)
}
resumeLatest () {
if (this.explorerBlock && this.explorerBlockScene) {
const prevBlock = this.explorerBlock
const prevBlockScene = this.explorerBlockScene
prevBlockScene.exitLeft()
prevBlockScene.expire(2000)
this.explorerBlockScene = null
this.explorerBlock = null
}
if (this.blockScene && this.block) {
blockTransitionDirection.set('right')
this.blockScene.enterRight()
currentBlock.set(this.block)
}
}
hideBlock () {
if (this.blockScene) {
if (this.blockScene && !this.explorerBlockScene) {
blockTransitionDirection.set(null)
this.blockScene.hide()
}
}
showBlock () {
if (this.blockScene) {
if (this.blockScene && !this.explorerBlockScene) {
this.blockScene.show()
}
}
@ -228,8 +351,8 @@ export default class TxController {
if (this.blockScene) {
this.blockScene.expire()
}
this.block = null
currentBlock.set(null)
if (this.blockVisibleUnsub) this.blockVisibleUnsub()
}
destroyTx (id) {
@ -243,7 +366,8 @@ export default class TxController {
mouseMove (position) {
if (this.poolScene && !this.selectionLocked) {
let selected = this.poolScene.selectAt(position)
if (!selected && this.blockScene && !this.blockScene.hidden) selected = this.blockScene.selectAt(position)
if (!selected && this.blockScene && !this.explorerBlock && !this.blockScene.hidden) selected = this.blockScene.selectAt(position)
if (!selected && this.explorerBlockScene && this.explorerBlock && !this.explorerBlockScene.hidden) selected = this.explorerBlockScene.selectAt(position)
if (selected !== this.selectedTx) {
if (this.selectedTx) this.selectedTx.hoverOff()
@ -254,10 +378,11 @@ export default class TxController {
}
}
mouseClick (position) {
async mouseClick (position) {
if (this.poolScene) {
let selected = this.poolScene.selectAt(position)
if (!selected && this.blockScene && !this.blockScene.hidden) selected = this.blockScene.selectAt(position)
if (!selected && this.blockScene && !this.explorerBlock && !this.blockScene.hidden) selected = this.blockScene.selectAt(position)
if (!selected && this.explorerBlockScene && this.explorerBlock && !this.explorerBlockScene.hidden) selected = this.explorerBlockScene.selectAt(position)
let sameTx = true
if (selected !== this.selectedTx) {
@ -268,9 +393,16 @@ export default class TxController {
this.selectedTx = selected
selectedTx.set(this.selectedTx)
if (sameTx && this.selectedTx) {
detailTx.set(this.selectedTx)
overlay.set('tx')
if (!this.selectedTx.is_inflated) {
loading.increment()
await searchTx(this.selectedTx.id)
loading.decrement()
} else {
detailTx.set(this.selectedTx)
overlay.set('tx')
}
}
console.log(this.selectedTx)
this.selectionLocked = !!this.selectedTx && !(this.selectionLocked && sameTx)
}
}

View File

@ -1,10 +1,11 @@
import BitcoinTx from '../models/BitcoinTx.js'
export default class BitcoinBlock {
constructor ({ version, id, value, prev_block, merkle_root, timestamp, bits, bytes, txn_count, txns, fees }) {
constructor ({ version, id, height, value, prev_block, merkle_root, timestamp, bits, bytes, txn_count, txns, fees }) {
this.isBlock = true
this.version = version
this.id = id
this.height = height
this.value = value
this.prev_block = prev_block
this.merkle_root = merkle_root
@ -14,13 +15,9 @@ export default class BitcoinBlock {
this.txnCount = txn_count
this.txns = txns
this.coinbase = new BitcoinTx(this.txns[0], true)
if (fees) {
this.fees = fees
} else {
this.fees = null
}
this.fees = fees
this.coinbase.setBlock(this)
this.height = this.coinbase.coinbase.height
this.height = this.height || this.coinbase.coinbase.height
this.miner_sig = this.coinbase.coinbase.sigAscii
this.total_vbytes = 0
@ -41,4 +38,49 @@ export default class BitcoinBlock {
this.avgFeerate = this.fees / this.total_vbytes
}
}
setVertexArray (vertexArray) {
if (this.txns) {
this.txns.forEach(txn => {
txn.setVertexArray(vertexArray)
})
}
}
static fromRPCData (data) {
const txns = data.tx.map((tx, index) => { return BitcoinTx.fromRPCData(tx, index == 0) })
const value = txns.reduce((acc, tx) => { return acc + tx.fee + tx.value }, 0)
const fees = txns.reduce((acc, tx) => { return acc + tx.fee }, 0)
return {
version: data.version,
id: data.hash,
height: data.height,
value: value,
prev_block: data.previousblockhash,
merkle_root: data.merkleroot,
timestamp: data.time,
bits: data.bits,
bytes: data.size,
txn_count: txns.length,
txns,
fees
}
}
static decompress (data) {
return {
version: data[0],
id: data[1],
height: data[2],
value: data[3],
prev_block: data[4],
timestamp: data[5],
bits: data[6],
bytes: data[7],
txn_count: data[8].length,
txns: data[8].map(txData => BitcoinTx.decompress(txData)),
fees: data[9]
}
}
}

View File

@ -7,42 +7,58 @@ export default class BitcoinTx {
constructor (data, vertexArray, isCoinbase = false) {
this.vertexArray = vertexArray
this.setData(data, isCoinbase)
this.view = new TxView(this)
if (vertexArray) this.view = new TxView(this)
}
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 parsed_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))
}, "")
if (this.is_preview) {
const subsidy = subsidyAt(block.height)
this.coinbase = {
height: block.height,
fees: this.value - subsidy,
subsidy
}
} else {
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 parsed_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))
}, "")
const height = block.height == null ? parsed_height : block.height
const height = block.height == null ? parsed_height : block.height
const subsidy = subsidyAt(height)
const subsidy = subsidyAt(height)
this.coinbase = {
height,
sig,
sigAscii,
fees: this.value - subsidy,
subsidy
this.coinbase = {
height,
sig,
sigAscii,
fees: this.value - subsidy,
subsidy
}
}
}
setData ({ version, inflated, id, value, fee, vbytes, inputs, outputs, time, block }, isCoinbase=false) {
setVertexArray (vertexArray) {
this.vertexArray = vertexArray
this.view = new TxView(this)
}
setData ({ version, inflated, preview, id, value, fee, vbytes, numInputs, inputs, outputs, time, block }, isCoinbase=false) {
this.version = version
this.is_inflated = !!inflated
this.is_preview = !!preview
this.id = id
this.pixelPosition = { x: 0, y: 0, r: 0}
this.screenPosition = { x: 0, y: 0, r: 0}
this.gridPosition = { x: 0, y: 0, r: 0}
this.inputs = inputs
if (numInputs != null) this.numInputs = numInputs
this.outputs = outputs
this.value = value
this.fee = fee
@ -59,16 +75,16 @@ export default class BitcoinTx {
// is a coinbase transaction?
this.isCoinbase = isCoinbase
if (this.isCoinbase || !this.is_inflated || (this.fee < 0)) {
if (this.isCoinbase || this.fee == null || this.fee < 0) {
this.fee = null
this.feerate = null
}
if (!this.block) this.setBlock(block)
const feeColor = (this.feerate == null
const feeColor = ((this.isCoinbase || this.feerate == null)
? orange
: mixColor(teal, purple, 1, Math.log2(64), Math.log2(this.feerate))
: mixColor(teal, purple, 1, Math.log2(128), Math.log2(this.feerate))
)
this.colors = {
age: {
@ -147,4 +163,33 @@ export default class BitcoinTx {
})
this.view.setHighlight(this.highlight, color || pink)
}
static fromRPCData(txData, isCoinbase) {
return {
version: txData.version,
inflated: false,
preview: true,
id: txData.txid,
value: null, // calculated in constructor
fee: txData.fee * 100000000,
vbytes: txData.vsize,
inputs: txData.vin.map(vin => { return { script_sig: vin.coinbase || vin.scriptSig.hex, prev_txid: vin.txid, prev_vout: vin.vout }}),
outputs: txData.vout.map(vout => { return { value: vout.value * 100000000, script_pub_key: vout.scriptPubKey.hex }}),
}
}
// unpack compact array format tx data
static decompress (data, blockData) {
return {
version: data[0],
inflated: false,
preview: true,
id: data[1],
fee: data[2],
value: data[3],
vbytes: data[4],
numInputs: data[5],
outputs: data[6].map(vout => { return { value: vout[0], script_pub_key: vout[1] }}),
}
}
}

View File

@ -119,6 +119,85 @@ export default class TxBlockScene extends TxMondrianPoolScene {
this.prepareTxOnScreen(tx, this.layoutTx(tx, sequence, 0, false))
}
enterTx (tx, right) {
tx.view.update({
display: {
position: {
x: tx.screenPosition.x + (right ? window.innerWidth : -window.innerWidth) + ((Math.random()-0.5) * (window.innerHeight/4)),
y: tx.screenPosition.y + ((Math.random()-0.5) * (window.innerHeight/4)),
r: tx.pixelPosition.r
},
color: {
...tx.getColor('block', this.colorMode).color,
alpha: 0
}
},
delay: 0,
state: 'ready'
})
tx.view.update({
display: {
position: tx.screenPosition,
color: {
...tx.getColor('block', this.colorMode).color,
alpha: 1
}
},
duration: 2000,
delay: 0
})
}
enter (right) {
this.hidden = false
const ids = this.getActiveTxList()
for (let i = 0; i < ids.length; i++) {
this.enterTx(this.txs[ids[i]], right)
}
}
enterRight () {
this.enter(true)
}
enterLeft () {
this.enter(false)
}
exitTx (tx, right) {
tx.view.update({
display: {
position: {
x: tx.screenPosition.x + (right ? window.innerWidth : -window.innerWidth) + ((Math.random()-0.5) * (window.innerHeight/4)),
y: tx.screenPosition.y + ((Math.random()-0.5) * (window.innerHeight/4)),
r: tx.pixelPosition.r
},
color: {
...tx.getColor('block', this.colorMode).color,
alpha: 0
}
},
delay: 0,
duration: 2000
})
}
exit (right) {
this.hidden = true
const ids = this.getActiveTxList()
for (let i = 0; i < ids.length; i++) {
this.exitTx(this.txs[ids[i]], right)
}
}
exitRight () {
this.exit(true)
}
exitLeft () {
this.exit(false)
}
hideTx (tx) {
this.savePixelsToScreenPosition(tx)
tx.view.update({
@ -174,10 +253,11 @@ export default class TxBlockScene extends TxMondrianPoolScene {
// }
}
initialLayout () {
initialLayout (exited) {
this.prepareAll()
setTimeout(() => {
this.layoutAll()
if (exited) this.exitRight()
}, 3000)
}
@ -199,7 +279,7 @@ export default class TxBlockScene extends TxMondrianPoolScene {
}
}
expire () {
expire (delay=3000) {
this.expired = true
this.hide()
setTimeout(() => {
@ -208,6 +288,13 @@ export default class TxBlockScene extends TxMondrianPoolScene {
if (this.txs[txIds[i]]) this.controller.destroyTx(txIds[i])
}
this.layout.destroy()
}, 3000)
}, delay)
}
selectAt (position) {
if (this.layout) {
const gridPosition = this.screenToGrid({ x: position.x + (this.gridSize/4), y: position.y - (this.gridSize/2) })
return this.layout.getTxInGridCell(gridPosition)
} else return null
}
}

View File

@ -339,7 +339,7 @@ class MondrianLayout {
while (this.txMap.length <= offsetY) {
this.txMap.push(new Array(this.width).fill(null))
}
this.txMap[offsetY][coord.x] = null
if (this.txMap[offsetY]) this.txMap[offsetY][coord.x] = null
}
getTxInGridCell(coord) {

View File

@ -167,4 +167,6 @@ export const blocksEnabled = derived([settings], ([$settings]) => {
export const latestBlockHeight = writable(null)
export const highlightInOut = writable(null)
export const loading = writable(0)
export const loading = createCounter()
export const explorerBlockData = writable(null)
export const blockTransitionDirection = writable(null)

View File

@ -1,6 +1,7 @@
import api from './api.js'
import BitcoinTx from '../models/BitcoinTx.js'
import { detailTx, selectedTx, overlay, highlightInOut } from '../stores.js'
import BitcoinBlock from '../models/BitcoinBlock.js'
import { detailTx, selectedTx, currentBlock, explorerBlockData, overlay, highlightInOut } from '../stores.js'
import { addressToSPK } from './encodings.js'
// Quick heuristic matching to guess what kind of search a query is for
@ -113,6 +114,11 @@ function matchQuery (query) {
}
export {matchQuery as matchQuery}
let currentBlockVal
currentBlock.subscribe(block => {
currentBlockVal = block
})
async function fetchTx (txid) {
if (!txid) return
const response = await fetch(`${api.uri}/api/tx/${txid}`, {
@ -131,7 +137,34 @@ async function fetchTx (txid) {
}
}
export async function searchTx(txid, input, output) {
async function fetchBlockByHash (hash) {
if (!hash || (currentBlockVal && hash === currentBlockVal.id)) return true
// try to fetch static block
let response = await fetch(`${api.uri}/api/block/${hash}`, {
method: 'GET'
})
if (!response) throw new Error('null response')
if (response && response.status == 200) {
const blockData = await response.json()
return BitcoinBlock.decompress(blockData)
}
}
async function fetchBlockByHeight (height) {
if (height == null) return
const response = await fetch(`${api.uri}/api/block/height/${height}`, {
method: 'GET'
})
if (!response) throw new Error('null response')
if (response.status == 200) {
const hash = await response.json()
return fetchBlockByHash(hash)
} else {
throw new Error(response.status)
}
}
export async function searchTx (txid, input, output) {
try {
const searchResult = await fetchTx(txid)
if (searchResult) {
@ -149,6 +182,36 @@ export async function searchTx(txid, input, output) {
}
}
export async function searchBlock(hash) {
console.log("search block ", hash)
export async function searchBlockHash (hash) {
try {
const searchResult = await fetchBlockByHash(hash)
if (searchResult) {
if (searchResult.id) {
explorerBlockData.set(searchResult)
}
return null
} else {
return '500'
}
} catch (err) {
console.log('error fetching block ', err)
return err.message
}
}
export async function searchBlockHeight (height) {
try {
const searchResult = await fetchBlockByHeight(height)
if (searchResult) {
if (searchResult.id) {
explorerBlockData.set(searchResult)
}
return null
} else {
return '500'
}
} catch (err) {
console.log('error fetching block ', err)
return err.message
}
}

4
server/.gitignore vendored
View File

@ -31,4 +31,8 @@ bitcoin_stream-*.tar
!log/.gitkeep
*.log
# Blocks
!data/block/.gitkeep
/data/block/**
.envrc

0
server/data/.gitkeep Normal file → Executable file
View File

View File

@ -6,9 +6,11 @@ defmodule BitcoinStream.BlockData do
@moduledoc """
Block data module.
Maintains a flat-file db of blocks (if enabled)
Serves a cached copy of the latest block
"""
use GenServer
use Task, restart: :transient
def start_link(opts) do
Logger.info("Starting block data link");
@ -53,4 +55,73 @@ defmodule BitcoinStream.BlockData do
def set_json_block(pid, block_id, json) do
GenServer.call(pid, {:json, { block_id, json }}, 10000)
end
def clean_block(block) do
{txs, value, fees} = clean_txs(block["tx"]);
{:ok, [
block["version"],
block["hash"],
block["height"],
value,
block["previousblockhash"],
block["time"],
block["bits"],
block["size"],
txs,
fees
]}
end
defp clean_txs([], clean, value, fees) do
{Enum.reverse(clean), value, fees}
end
defp clean_txs([tx | rest], clean, value, fees) do
{cleantx, txvalue, txfee} = clean_tx(tx)
clean_txs(rest, [cleantx | clean], value + txvalue, fees + txfee)
end
defp clean_txs(txs) do
clean_txs(txs, [], 0, 0)
end
defp clean_tx(tx) do
total_value = sum_output_values(tx["vout"]);
outputs = clean_outputs(tx["vout"]);
fee = if tx["fee"] != nil do round(tx["fee"] * 100000000) else 0 end
{[
tx["version"],
tx["txid"],
fee,
total_value,
tx["vsize"],
length(tx["vin"]),
outputs
], total_value, fee}
end
defp clean_outputs([], clean) do
Enum.reverse(clean)
end
defp clean_outputs([out | rest], clean) do
clean_outputs(rest, [clean_output(out) | clean])
end
defp clean_outputs(outputs) do
clean_outputs(outputs, [])
end
defp clean_output(output) do
[
round(output["value"] * 100000000),
output["scriptPubKey"]["hex"]
]
end
defp sum_output_values([], value) do
value
end
defp sum_output_values([out|rest], value) do
sum_output_values(rest, value + round(out["value"] * 100000000))
end
defp sum_output_values(outputs) do
sum_output_values(outputs, 0)
end
end

View File

@ -5,11 +5,12 @@ defmodule BitcoinStream.Router do
alias BitcoinStream.BlockData, as: BlockData
alias BitcoinStream.Protocol.Transaction, as: BitcoinTx
alias BitcoinStream.BlockData, as: BlockData
alias BitcoinStream.RPC, as: RPC
plug Corsica, origins: "*", allow_headers: :all
plug Plug.Static,
at: "/",
at: "data",
from: :bitcoin_stream
plug :match
plug Plug.Parsers,
@ -18,21 +19,32 @@ defmodule BitcoinStream.Router do
json_decoder: Jason
plug :dispatch
match "api/block/height/:height" do
case get_block_by_height(height) do
{:ok, hash} ->
put_resp_header(conn, "cache-control", "public, max-age=3600, immutable")
|> send_resp(200, hash)
_ ->
Logger.debug("Error getting blockhash at height #{height}");
send_resp(conn, 404, "Block not found")
end
end
match "/api/block/:hash" do
case get_block(hash) do
{:ok, block} ->
put_resp_header(conn, "cache-control", "public, max-age=604800, immutable")
put_resp_header(conn, "cache-control", "public, max-age=31536000, immutable")
|> send_resp(200, block)
_ ->
Logger.debug("Error getting block hash");
send_resp(conn, 404, "Block not available")
Logger.debug("Error getting block with hash #{hash}");
send_resp(conn, 404, "Block not found")
end
end
match "/api/tx/:hash" do
case get_tx(hash) do
{:ok, tx} ->
put_resp_header(conn, "cache-control", "public, max-age=604800, immutable")
put_resp_header(conn, "cache-control", "public, max-age=60, immutable")
|> send_resp(200, tx)
_ ->
Logger.debug("Error getting tx hash");
@ -44,13 +56,33 @@ defmodule BitcoinStream.Router do
send_resp(conn, 404, "Not found")
end
defp get_block(last_seen) do
defp get_block_by_height(height_str) do
with {height, _} <- Integer.parse(height_str),
{:ok, 200, blockhash} <- RPC.request(:rpc, "getblockhash", [height]),
{:ok, payload} <- Jason.encode(blockhash) do
{:ok, payload}
else
err ->
IO.inspect(err)
:err
end
end
defp get_block(hash) do
last_id = BlockData.get_block_id(:block_data);
cond do
(last_seen == last_id) ->
payload = BlockData.get_json_block(:block_data);
if hash == last_id do
payload = BlockData.get_json_block(:block_data);
{:ok, payload}
else
with {:ok, 200, block} <- RPC.request(:rpc, "getblock", [hash, 2]),
{:ok, cleaned} <- BlockData.clean_block(block),
{:ok, payload} <- Jason.encode(cleaned) do
{:ok, payload}
true -> :err
else
err ->
IO.inspect(err);
:err
end
end
end