diff --git a/client/nginx/bitfeed.conf.template b/client/nginx/bitfeed.conf.template index 1668a2f..bc6bf75 100644 --- a/client/nginx/bitfeed.conf.template +++ b/client/nginx/bitfeed.conf.template @@ -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; diff --git a/client/src/components/BlockInfo.svelte b/client/src/components/BlockInfo.svelte index 0006080..9fbd2df 100644 --- a/client/src/components/BlockInfo.svelte +++ b/client/src/components/BlockInfo.svelte @@ -5,9 +5,10 @@ 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, loading } from '../stores.js' import { formatCurrency } from '../utils/fx.js' + import { searchBlockHeight } from '../utils/search.js' const dispatch = createEventDispatcher() @@ -47,8 +48,37 @@ } } - function formatTime (time) { - return timeFormat.format(time) + let hasPrevBlock + let hasNextBlock + $: { + if (block) { + if (block.height > 0) hasPrevBlock = true + else hasPrevBlock = false + if (block.height < $latestBlockHeight) hasNextBlock = true + else hasNextBlock = false + } else { + hasPrevBlock = false + hasNextBlock = false + } + } + + 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 +104,34 @@ } function hideBlock () { - analytics.trackEvent('viz', 'block', 'hide') - dispatch('hideBlock') + if (block && block.height != $latestBlockHeight) { + dispatch('quitExploring') + } else { + analytics.trackEvent('viz', 'block', 'hide') + dispatch('hideBlock') + } + } + + async function explorePrevBlock (e) { + e.preventDefault() + if (!$loading && block) { + $loading = true + await searchBlockHeight(block.height - 1) + $loading = false + } + } + + async function exploreNextBlock (e) { + e.preventDefault() + if (!$loading && block) { + if (block.height + 1 < $latestBlockHeight) { + $loading = true + await searchBlockHeight(block.height + 1) + $loading = false + } else { + dispatch('quitExploring') + } + } } @@ -172,6 +228,42 @@ } } + .explore-button { + position: absolute; + bottom: 10%; + padding: .75em; + pointer-events: all; + + &.prev { + right: 100% + } + &.next { + left: 100%; + } + + .chevron { + .outline { + stroke: white; + stroke-width: 32; + stroke-linecap: butt; + stroke-linejoin: miter; + fill: white; + fill-opacity: 0; + transition: fill-opacity 300ms; + } + + &.right { + transform: scaleX(-1); + } + } + + &:hover { + .chevron .outline { + fill-opacity: 1; + } + } + } + @media (min-aspect-ratio: 1/1) { .block-info { bottom: unset; @@ -227,17 +319,17 @@ } -{#each [block] as block (block)} +{#key block} {#if block != null && visible && $blocksEnabled } -
+
- Latest Block: { numberFormat.format(block.height) } + {#if block.height == $latestBlockHeight}Latest {/if}Block: { numberFormat.format(block.height) }
- Mined { formatTime(block.time) } + { formatDateTime(block.time) } { formattedBlockValue }
@@ -260,7 +352,7 @@
- Mined { formatTime(block.time) } + { formatDateTime(block.time) } { formattedBlockValue }
@@ -273,8 +365,22 @@
- {/if} -{/each} +{/key} diff --git a/client/src/components/MempoolLegend.svelte b/client/src/components/MempoolLegend.svelte index 1c05865..eb16150 100644 --- a/client/src/components/MempoolLegend.svelte +++ b/client/src/components/MempoolLegend.svelte @@ -5,10 +5,11 @@ import { numberFormat } from '../utils/format.js' import { logTxSize, byteTxSize } from '../utils/misc.js' import { interpolateHcl } from 'd3-interpolate' import { color, hcl } from 'd3-color' -import { hlToHex, orange, teal, green, purple } from '../utils/color.js' +import { hlToHex, orange, teal, blue, green, purple } from '../utils/color.js' const orangeHex = hlToHex(orange) const tealHex = hlToHex(teal) +const blueHex = hlToHex(blue) const greenHex = hlToHex(green) const purpleHex = hlToHex(purple) @@ -42,7 +43,7 @@ resize() onMount(() => { resize() - colorScale = generateColorScale(orangeHex, tealHex) + colorScale = generateColorScale(orangeHex, blueHex) feeColorScale = generateColorScale(tealHex, purpleHex) }) @@ -207,7 +208,7 @@ function generateColorScale (colorA, colorB) { {:else} 1 - 64+ + 128+ {/if}
diff --git a/client/src/components/SearchBar.svelte b/client/src/components/SearchBar.svelte index 471ca46..71f8061 100644 --- a/client/src/components/SearchBar.svelte +++ b/client/src/components/SearchBar.svelte @@ -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' diff --git a/client/src/components/TransactionOverlay.svelte b/client/src/components/TransactionOverlay.svelte index d168661..379e57b 100644 --- a/client/src/components/TransactionOverlay.svelte +++ b/client/src/components/TransactionOverlay.svelte @@ -7,7 +7,9 @@ import { exchangeRates, settings, sidebarToggle, newHighlightQuery, highlighting import { formatCurrency } from '../utils/fx.js' import { hlToHex, mixColor, teal, purple } from '../utils/color.js' import { SPKToAddress } from '../utils/encodings.js' +import api from '../utils/api.js' import { searchTx } from '../utils/search.js' +import { fade } from 'svelte/transition' function onClose () { $detailTx = null @@ -47,7 +49,7 @@ $: { let confirmations = 0 $: { - if ($detailTx && $detailTx.block && $detailTx.block.height && $latestBlockHeight != null) { + if ($detailTx && $detailTx.block && $detailTx.block.height != null && $latestBlockHeight != null) { confirmations = (1 + $latestBlockHeight - $detailTx.block.height) } } @@ -129,8 +131,8 @@ let highlight = {} $: { if ($highlightInOut && $detailTx && $highlightInOut.txid === $detailTx.id) { highlight = {} - if ($highlightInOut.input != null) highlight.in = $highlightInOut.input - if ($highlightInOut.output != null) highlight.out = $highlightInOut.output + if ($highlightInOut.input != null) highlight.in = parseInt($highlightInOut.input) + if ($highlightInOut.output != null) highlight.out = parseInt($highlightInOut.output) } else { highlight = {} } @@ -257,15 +259,29 @@ async function clickItem (item) { if (item.rest) { truncate = false } else if (item.prev_txid && item.prev_vout != null) { - $loading++ - await searchTx(item.prev_txid, null, item.prev_vout) - $loading-- - } else if (item.spend && item.spend.txid && item.spend.vin) { - $loading++ - await searchTx(item.spend.txid, item.spend.vin) - $loading-- + // $loading++ + // await searchTx(item.prev_txid, null, item.prev_vout) + // $loading-- + } else if (item.spend) { + // $loading++ + // await searchTx(item.spend.txid, item.spend.vin) + // $loading-- } } + +async function goToInput(e, input) { + e.preventDefault() + $loading++ + await searchTx(input.prev_txid, null, input.prev_vout) + $loading-- +} + +async function goToOutput(e, output) { + e.preventDefault() + $loading++ + await searchTx(output.spend.txid, output.spend.vin) + $loading-- +} @@ -492,7 +503,7 @@
- +
{#if config.dev && config.debug && $devSettings.guides }
diff --git a/client/src/components/alert/Alerts.svelte b/client/src/components/alert/Alerts.svelte index 25280f1..b39420f 100644 --- a/client/src/components/alert/Alerts.svelte +++ b/client/src/components/alert/Alerts.svelte @@ -164,10 +164,9 @@ function rotateAlerts () { @media screen and (max-width: 480px) { height: 3em; - font-size: 0.8em; - width: 16em; + width: 18em; .alert-wrapper { - width: 16em; + width: 18em; } } } diff --git a/client/src/controllers/TxController.js b/client/src/controllers/TxController.js index a95dbf8..e495caf 100644 --- a/client/src/controllers/TxController.js +++ b/client/src/controllers/TxController.js @@ -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, fetchSpends, addSpends } 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,13 @@ export default class TxController { colorMode.subscribe(mode => { this.setColorMode(mode) }) + explorerBlockData.subscribe(blockData => { + if (blockData) { + this.exploreBlock(blockData) + } else { + this.resumeLatest() + } + }) } getVertexData () { @@ -54,7 +65,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 +75,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 +92,9 @@ export default class TxController { if (this.blockScene) { this.blockScene.setColorMode(mode) } + if (this.explorerBlockScene) { + this.explorerBlockScene.setColorMode(mode) + } } applyHighlighting () { @@ -84,6 +102,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 +146,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 +213,14 @@ 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) { + currentBlock.set(block) + } } else { this.poolScene.scrollLock = true for (let i = 0; i < block.txns.length; i++) { @@ -205,31 +259,97 @@ export default class TxController { this.poolScene.scrollLock = false this.poolScene.layoutAll() }, 5500) + + currentBlock.set(block) } - currentBlock.set(block) + this.block = block return block } + exploreBlock (blockData) { + const block = blockData.isBlock ? blockData : 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() } } clearBlock () { if (this.blockScene) { + this.blockScene.exitLeft() this.blockScene.expire() } + this.block = null currentBlock.set(null) - if (this.blockVisibleUnsub) this.blockVisibleUnsub() } destroyTx (id) { @@ -243,7 +363,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 +375,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) { @@ -266,12 +388,21 @@ export default class TxController { if (selected) selected.hoverOn() } this.selectedTx = selected - selectedTx.set(this.selectedTx) - if (sameTx && this.selectedTx) { - detailTx.set(this.selectedTx) - overlay.set('tx') + selectedTx.set(selected) + if (sameTx && selected) { + if (!selected.is_inflated) { + loading.increment() + await searchTx(selected.id) + loading.decrement() + } else { + const spendResult = await fetchSpends(selected.id) + if (spendResult) selected = addSpends(selected, spendResult) + detailTx.set(selected) + overlay.set('tx') + } + console.log(selected) } - this.selectionLocked = !!this.selectedTx && !(this.selectionLocked && sameTx) + this.selectionLocked = !!selected && !(this.selectionLocked && sameTx) } } diff --git a/client/src/models/BitcoinBlock.js b/client/src/models/BitcoinBlock.js index b1a4c82..58f5d88 100644 --- a/client/src/models/BitcoinBlock.js +++ b/client/src/models/BitcoinBlock.js @@ -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] + } + } } diff --git a/client/src/models/BitcoinTx.js b/client/src/models/BitcoinTx.js index bc3dee3..136d9cb 100644 --- a/client/src/models/BitcoinTx.js +++ b/client/src/models/BitcoinTx.js @@ -1,48 +1,64 @@ import TxView from './TxView.js' import config from '../config.js' import { subsidyAt } from '../utils/bitcoin.js' -import { mixColor, pink, bluegreen, orange, teal, green, purple } from '../utils/color.js' +import { mixColor, pink, bluegreen, orange, teal, blue, green, purple } from '../utils/color.js' 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 && sig.length) ? 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,21 +75,21 @@ 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: { block: { color: orange }, - pool: { color: orange, endColor: teal, duration: 60000 }, + pool: { color: orange, endColor: blue, duration: 60000 }, }, fee: { block: { color: feeColor }, @@ -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] }}), + } + } } diff --git a/client/src/models/TxBlockScene.js b/client/src/models/TxBlockScene.js index 9e7fd27..cea3593 100644 --- a/client/src/models/TxBlockScene.js +++ b/client/src/models/TxBlockScene.js @@ -1,4 +1,12 @@ import TxMondrianPoolScene from './TxMondrianPoolScene.js' +import { settings } from '../stores.js' +import { logTxSize, byteTxSize } from '../utils/misc.js' +import config from '../config.js' + +let settingsValue +settings.subscribe(v => { + settingsValue = v +}) export default class TxBlockScene extends TxMondrianPoolScene { constructor ({ width, height, unit = 4, padding = 1, blockId, controller, heightStore, colorMode }) { @@ -47,6 +55,12 @@ export default class TxBlockScene extends TxMondrianPoolScene { this.resetLayout() } + // calculates and returns the size of the tx in multiples of the grid size + txSize (tx={ value: 1, vbytes: 1 }) { + if (settingsValue.vbytes) return byteTxSize(tx.vbytes, Math.Infinity) + else return logTxSize(tx.value, Math.Infinity) + } + setTxOnScreen (tx, pixelPosition) { if (!tx.view.initialised) { tx.view.update({ @@ -119,6 +133,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 +267,11 @@ export default class TxBlockScene extends TxMondrianPoolScene { // } } - initialLayout () { + initialLayout (exited) { this.prepareAll() setTimeout(() => { this.layoutAll() + if (exited) this.exitRight() }, 3000) } @@ -199,15 +293,21 @@ export default class TxBlockScene extends TxMondrianPoolScene { } } - expire () { + expire (delay=3000) { this.expired = true - this.hide() setTimeout(() => { const txIds = this.getTxList() for (let i = 0; i < txIds.length; i++) { 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 } } diff --git a/client/src/models/TxMondrianPoolScene.js b/client/src/models/TxMondrianPoolScene.js index c26044a..e0e2125 100644 --- a/client/src/models/TxMondrianPoolScene.js +++ b/client/src/models/TxMondrianPoolScene.js @@ -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) { diff --git a/client/src/models/TxPoolScene.js b/client/src/models/TxPoolScene.js index 0f46509..98c2d17 100644 --- a/client/src/models/TxPoolScene.js +++ b/client/src/models/TxPoolScene.js @@ -29,8 +29,6 @@ export default class TxPoolScene { this.scrollRateLimitTimer = null this.initialised = true - - if (config.dev) console.log('pool', this) } resize ({ width = this.width, height = this.height }) { diff --git a/client/src/stores.js b/client/src/stores.js index f4bde68..e91b985 100644 --- a/client/src/stores.js +++ b/client/src/stores.js @@ -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) diff --git a/client/src/utils/color.js b/client/src/utils/color.js index 1e8d0b3..efe1f08 100644 --- a/client/src/utils/color.js +++ b/client/src/utils/color.js @@ -16,6 +16,7 @@ export const pink = { h: 0.03, l: 0.35 } export const bluegreen = { h: 0.45, l: 0.4 } export const orange = { h: 0.181, l: 0.472 } export const teal = { h: 0.475, l: 0.55 } +export const blue = { h: 0.5, l: 0.55 } export const green = { h: 0.37, l: 0.35 } export const purple = { h: 0.95, l: 0.35 } diff --git a/client/src/utils/search.js b/client/src/utils/search.js index 7b35778..fd2fc96 100644 --- a/client/src/utils/search.js +++ b/client/src/utils/search.js @@ -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,10 +137,72 @@ 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() + if (blockData) { + if (blockData.id) { + return new BitcoinBlock(blockData) + } else 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) + } +} + +async function fetchSpends (txid) { + if (txid == null) return + const response = await fetch(`${api.uri}/api/spends/${txid}`, { + method: 'GET' + }) + if (!response) throw new Error('null response') + if (response.status == 200) { + return response.json() + } else { + return null + } +} +export {fetchSpends as fetchSpends} + +function addSpends(tx, spends) { + tx.outputs.forEach((output, index) => { + if (spends[index]) { + output.spend = { + txid: spends[index][0], + vin: spends[index][1], + } + } else { + output.spend = null + } + }) + return tx +} +export {addSpends as addSpends} + +export async function searchTx (txid, input, output) { try { - const searchResult = await fetchTx(txid) + let searchResult = await fetchTx(txid) + const spendResult = await fetchSpends(txid) if (searchResult) { + if (spendResult) searchResult = addSpends(searchResult, spendResult) selectedTx.set(searchResult) detailTx.set(searchResult) overlay.set('tx') @@ -149,6 +217,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 + } } diff --git a/server/.gitignore b/server/.gitignore index 0e13369..9f8f55f 100644 --- a/server/.gitignore +++ b/server/.gitignore @@ -31,4 +31,9 @@ bitcoin_stream-*.tar !log/.gitkeep *.log +# Blocks +/data/index/** +!data/index/.gitkeep +!data/index/spend/.gitkeep + .envrc diff --git a/server/data/.gitkeep b/server/data/.gitkeep old mode 100644 new mode 100755 diff --git a/server/data/index/.gitkeep b/server/data/index/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/server/lib/block_data.ex b/server/lib/block_data.ex index 67cbd9d..b906696 100644 --- a/server/lib/block_data.ex +++ b/server/lib/block_data.ex @@ -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 diff --git a/server/lib/bridge_block.ex b/server/lib/bridge_block.ex index 7425214..937e2df 100644 --- a/server/lib/bridge_block.ex +++ b/server/lib/bridge_block.ex @@ -13,6 +13,7 @@ defmodule BitcoinStream.Bridge.Block do alias BitcoinStream.Mempool, as: Mempool alias BitcoinStream.RPC, as: RPC alias BitcoinStream.BlockData, as: BlockData + alias BitcoinStream.Index.Spend, as: SpendIndex def child_spec(host: host, port: port) do %{ diff --git a/server/lib/bridge_sequence.ex b/server/lib/bridge_sequence.ex index b194c42..062d33c 100644 --- a/server/lib/bridge_sequence.ex +++ b/server/lib/bridge_sequence.ex @@ -9,6 +9,7 @@ defmodule BitcoinStream.Bridge.Sequence do """ use GenServer + alias BitcoinStream.Index.Spend, as: SpendIndex alias BitcoinStream.Mempool, as: Mempool alias BitcoinStream.RPC, as: RPC @@ -92,13 +93,14 @@ defmodule BitcoinStream.Bridge.Sequence do defp loop(socket) do with {:ok, message} <- :chumak.recv_multipart(socket), - [_topic, <>, <<_sequence::little-size(32)>>] <- message, - txid <- Base.encode16(hash, case: :lower), + [_topic, <>, <<_sequence::little-size(32)>>] <- message, + hash <- Base.encode16(id, case: :lower), event <- to_charlist(type) do case event do # Transaction added to mempool 'A' -> - case Mempool.register(:mempool, txid, seq, true) do + <> = rest; + case Mempool.register(:mempool, hash, seq, true) do false -> false { txn, count } -> @@ -107,16 +109,26 @@ defmodule BitcoinStream.Bridge.Sequence do # Transaction removed from mempool for non block inclusion reason 'R' -> - case Mempool.drop(:mempool, txid) do + <> = rest; + case Mempool.drop(:mempool, hash) do count when is_integer(count) -> - send_drop_tx(txid, count); + send_drop_tx(hash, count); _ -> true end + 'D' -> + SpendIndex.notify_block_disconnect(:spends, hash); + true + + 'C' -> + SpendIndex.notify_block(:spends, hash); + true + # Don't care about other events - _ -> true + other -> + true end else _ -> false diff --git a/server/lib/mempool.ex b/server/lib/mempool.ex index 6582f4b..8c9ecdc 100644 --- a/server/lib/mempool.ex +++ b/server/lib/mempool.ex @@ -191,6 +191,7 @@ defmodule BitcoinStream.Mempool do :registered -> with [] <- :ets.lookup(:block_cache, txid) do # double check tx isn't included in the last block :ets.insert(:mempool_cache, {txid, { txn.inputs, txn.value + txn.fee, txn.inflated }, nil}); + cache_spends(txid, txn.inputs); get(pid) else _ -> @@ -248,6 +249,7 @@ defmodule BitcoinStream.Mempool do [{_txid, _, txn}] when txn != nil -> :ets.insert(:mempool_cache, {txid, { txn.inputs, txn.value + txn.fee, txn.inflated }, nil}); :ets.delete(:sync_cache, txid); + cache_spends(txid, txn.inputs); if do_count do increment(pid); end @@ -295,8 +297,10 @@ defmodule BitcoinStream.Mempool do get(pid) # tx fully processed and not already dropped - [{_txid, data, _status}] when data != nil -> + [{txid, data, _status}] when data != nil -> :ets.delete(:mempool_cache, txid); + {inputs, _value, _inflated} = data; + uncache_spends(inputs); decrement(pid); get(pid) @@ -429,6 +433,7 @@ defmodule BitcoinStream.Mempool do inflated_txn <- BitcoinTx.inflate(txn, false) do if inflated_txn.inflated do :ets.insert(:mempool_cache, {txid, { inflated_txn.inputs, inflated_txn.value + inflated_txn.fee, inflated_txn.inflated }, status}); + cache_spends(txid, inflated_txn.inputs); Logger.debug("repaired #{repaired} mempool txns #{txid}"); repaired + 1 else @@ -493,4 +498,31 @@ defmodule BitcoinStream.Mempool do end end + defp cache_spend(txid, index, input) do + :ets.insert(:spend_cache, {[input.prev_txid, input.prev_vout], [txid, index]}) + end + defp cache_spends(_txid, _index, []) do + :ok + end + defp cache_spends(txid, index, [input | rest]) do + cache_spend(txid, index, input); + cache_spends(txid, index + 1, rest) + end + defp cache_spends(txid, inputs) do + cache_spends(txid, 0, inputs) + end + + defp uncache_spend(input) do + :ets.delete(:spend_cache, [input.prev_txid, input.prev_vout]) + end + defp uncache_spends([]) do + :ok + end + defp uncache_spends([input | rest]) do + uncache_spend(input); + uncache_spends(rest) + end + defp uncache_spends(inputs) do + uncache_spends(inputs) + end end diff --git a/server/lib/protocol/block.ex b/server/lib/protocol/block.ex index 45205a9..84a24aa 100644 --- a/server/lib/protocol/block.ex +++ b/server/lib/protocol/block.ex @@ -59,6 +59,36 @@ def decode(block_binary) do end end +def parse(hex) do + with block_binary <- Base.decode16!(hex, [case: :lower]), + bytes <- byte_size(block_binary), + {:ok, raw_block} <- Bitcoinex.Block.decode(hex), + id <- Bitcoinex.Block.block_id(block_binary), + {summarised_txns, total_value, total_fees} <- summarise_txns(raw_block.txns) + do + {:ok, %__MODULE__{ + version: raw_block.version, + prev_block: raw_block.prev_block, + merkle_root: raw_block.merkle_root, + timestamp: raw_block.timestamp, + bits: raw_block.bits, + bytes: bytes, + txn_count: raw_block.txn_count, + txns: summarised_txns, + fees: total_fees, + value: total_value, + id: id + }} + else + {:error, reason} -> + Logger.error("Error decoding data for BitcoinBlock: #{reason}") + :error + _ -> + Logger.error("Error decoding data for BitcoinBlock: (unknown reason)") + :error + end +end + defp summarise_txns([coinbase | txns]) do # Mempool.is_done returns false while the mempool is still syncing with extended_coinbase <- BitcoinTx.extend(coinbase), @@ -87,7 +117,7 @@ defp summarise_txns([next | rest], summarised, total, fees, do_inflate) do if do_inflate do inflated_txn = BitcoinTx.inflate(extended_txn, false) if (inflated_txn.inflated) do - Logger.debug("Processing block tx #{length(summarised)}/#{length(summarised) + length(rest) + 1} | #{extended_txn.id}"); + # Logger.debug("Processing block tx #{length(summarised)}/#{length(summarised) + length(rest) + 1} | #{extended_txn.id}"); summarise_txns(rest, [inflated_txn | summarised], total + inflated_txn.value, fees + inflated_txn.fee, true) else summarise_txns(rest, [inflated_txn | summarised], total + inflated_txn.value, nil, false) diff --git a/server/lib/router.ex b/server/lib/router.ex index ab93286..62cab64 100644 --- a/server/lib/router.ex +++ b/server/lib/router.ex @@ -5,11 +5,13 @@ 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 + alias BitcoinStream.Index.Spend, as: SpendIndex plug Corsica, origins: "*", allow_headers: :all plug Plug.Static, - at: "/", + at: "data", from: :bitcoin_stream plug :match plug Plug.Parsers, @@ -18,21 +20,37 @@ 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") + {:ok, block, true} -> + put_resp_header(conn, "cache-control", "public, max-age=120, immutable") |> send_resp(200, block) + + {:ok, block, false} -> + 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"); @@ -40,17 +58,48 @@ defmodule BitcoinStream.Router do end end + match "/api/spends/:txid" do + case get_tx_spends(txid) do + {:ok, spends} -> + put_resp_header(conn, "cache-control", "public, max-age=10, immutable") + |> send_resp(200, spends) + _ -> + Logger.debug("Error getting tx spends"); + send_resp(conn, 200, "[]") + end + end + match _ 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); - {:ok, payload} - true -> :err + if hash == last_id do + payload = BlockData.get_json_block(:block_data); + {:ok, payload, true} + 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, false} + else + err -> + IO.inspect(err); + :err + end end end @@ -64,9 +113,32 @@ defmodule BitcoinStream.Router do {:ok, payload} <- Jason.encode(%{tx: inflated_txn, blockheight: height, blockhash: blockhash, time: time}) do {:ok, payload} else + {:ok, 500, nil} -> + # specially handle the genesis coinbase transaction + with true <- (txid == "4a5e1e4baab89f3a32518a88c31bc87f618f76673e2cc77ab2127b7afdeda33b"), + rawtx <- Base.decode16!("01000000010000000000000000000000000000000000000000000000000000000000000000ffffffff4d04ffff001d0104455468652054696d65732030332f4a616e2f32303039204368616e63656c6c6f72206f6e206272696e6b206f66207365636f6e64206261696c6f757420666f722062616e6b73ffffffff0100f2052a01000000434104678afdb0fe5548271967f1a67130b7105cd6a828e03909a67962e0ea1f61deb649f6bc3f4cef38c4f35504e51ec112de5c384df7ba0b8d578a4c702b6bf11d5fac00000000", [case: :lower]), + {:ok, txn } <- BitcoinTx.decode(rawtx), + inflated_txn <- BitcoinTx.inflate(txn, false), + {:ok, payload} <- Jason.encode(%{tx: inflated_txn, blockheight: 0, blockhash: "000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f", time: 1231006505}) do + {:ok, payload} + else + _ -> :err + end + err -> IO.inspect(err); :err end end + + defp get_tx_spends(txid) do + with {:ok, spends} <- SpendIndex.get_tx_spends(:spends, txid), + {:ok, payload} <- Jason.encode(spends) do + {:ok, payload} + else + err -> + IO.inspect(err) + :err + end + end end diff --git a/server/lib/server.ex b/server/lib/server.ex index bef2703..41f8b8a 100644 --- a/server/lib/server.ex +++ b/server/lib/server.ex @@ -13,6 +13,7 @@ defmodule BitcoinStream.Server do { rpc_pool_size, "" } = Integer.parse(System.get_env("RPC_POOL_SIZE")); log_level = System.get_env("LOG_LEVEL"); btc_host = System.get_env("BITCOIN_HOST"); + indexed = System.get_env("INDEXED") case log_level do "debug" -> @@ -38,6 +39,7 @@ defmodule BitcoinStream.Server do }}, { BitcoinStream.RPC, [host: btc_host, port: rpc_port, name: :rpc] }, { BitcoinStream.BlockData, [name: :block_data] }, + { BitcoinStream.Index.Spend, [name: :spends, indexed: indexed]}, Plug.Cowboy.child_spec( scheme: :http, plug: BitcoinStream.Router, diff --git a/server/lib/spend_index.ex b/server/lib/spend_index.ex new file mode 100644 index 0000000..87db187 --- /dev/null +++ b/server/lib/spend_index.ex @@ -0,0 +1,424 @@ +require Logger + +defmodule BitcoinStream.Index.Spend do + + use GenServer + + alias BitcoinStream.Protocol.Block, as: BitcoinBlock + alias BitcoinStream.Protocol.Transaction, as: BitcoinTx + alias BitcoinStream.RPC, as: RPC + + def start_link(opts) do + Logger.info("Starting Spend Index"); + {indexed, opts} = Keyword.pop(opts, :indexed); + GenServer.start_link(__MODULE__, [indexed], opts) + end + + @impl true + def init([indexed]) do + :ets.new(:spend_cache, [:set, :public, :named_table]); + if (indexed != nil) do + {:ok, dbref} = :rocksdb.open(String.to_charlist("data/index/spend"), [create_if_missing: true]); + Process.send_after(self(), :sync, 2000); + {:ok, [dbref, indexed, false]} + else + {:ok, [nil, indexed, false]} + end + end + + @impl true + def terminate(_reason, [dbref, indexed, _done]) do + if (indexed != nil) do + :rocksdb.close(dbref) + end + end + + @impl true + def handle_info(:sync, [dbref, indexed, done]) do + if (indexed != nil) do + case sync(dbref) do + true -> + {:noreply, [dbref, indexed, true]} + + _ -> + {:noreply, [dbref, indexed, false]} + end + else + {:noreply, [dbref, indexed, done]} + end + end + + @impl true + def handle_info(_event, state) do + # if RPC responds after the calling process already timed out, garbled messages get dumped to handle_info + # quietly discard + {:noreply, state} + end + + @impl true + def handle_call({:get_tx_spends, txid}, _from, [dbref, indexed, done]) do + case get_transaction_spends(dbref, txid, (indexed != nil)) do + {:ok, spends} -> + {:reply, {:ok, spends}, [dbref, indexed, done]} + + err -> + Logger.error("failed to fetch tx spends"); + {:reply, err, [dbref, indexed, done]} + end + end + + @impl true + def handle_cast(:new_block, [dbref, indexed, done]) do + if (indexed != nil and done) do + case sync(dbref) do + true -> + {:noreply, [dbref, indexed, true]} + + _ -> + {:noreply, [dbref, indexed, false]} + end + else + Logger.info("Already building spend index"); + {:noreply, [dbref, indexed, false]} + end + end + + @impl true + def handle_cast({:block_disconnected, hash}, [dbref, indexed, done]) do + if (indexed != nil and done) do + block_disconnected(dbref, hash) + end + {:noreply, [dbref, indexed, done]} + end + + def get_tx_spends(pid, txid) do + GenServer.call(pid, {:get_tx_spends, txid}, 60000) + catch + :exit, reason -> + case reason do + {:timeout, _} -> {:error, :timeout} + + _ -> {:error, reason} + end + + error -> {:error, error} + end + + def notify_block(pid, _hash) do + GenServer.cast(pid, :new_block) + end + + def notify_block_disconnect(pid, hash) do + GenServer.cast(pid, {:block_disconnected, hash}) + end + + defp wait_for_ibd() do + case RPC.get_node_status(:rpc) do + :ok -> true + + _ -> + Logger.info("Waiting for node to come online and fully sync before synchronizing spend index"); + RPC.notify_on_ready(:rpc) + end + end + + defp get_index_height(dbref) do + case :rocksdb.get(dbref, "height", []) do + {:ok, <>} -> + height + + :not_found -> + -1 + + _ -> + Logger.error("unexpected leveldb response") + end + end + + defp get_chain_height() do + case RPC.request(:rpc, "getblockcount", []) do + {:ok, 200, height} -> + height + + _ -> + Logger.error("unexpected RPC response"); + :err + end + end + + defp get_block(height) do + with {:ok, 200, blockhash} <- RPC.request(:rpc, "getblockhash", [height]), + {:ok, 200, blockdata} <- RPC.request(:rpc, "getblock", [blockhash, 0]), + {:ok, block} <- BitcoinBlock.parse(blockdata) do + block + end + end + + defp get_block_by_hash(hash) do + with {:ok, 200, blockdata} <- RPC.request(:rpc, "getblock", [hash, 0]), + {:ok, block} <- BitcoinBlock.parse(blockdata) do + block + end + end + + defp index_input(spendkey, input, spends) do + case input do + # coinbase (skip) + %{prev_txid: "0000000000000000000000000000000000000000000000000000000000000000"} -> + spends + + %{prev_txid: txid, prev_vout: vout} -> + binid = Base.decode16!(txid, [case: :lower]) + case spends[binid] do + nil -> + Map.put(spends, binid, [[vout, spendkey]]) + + a -> + Map.put(spends, binid, [[vout, spendkey] | a]) + end + + # unexpected input format (should never happen) + _ -> + spends + end + end + + defp index_inputs(_binid, [], _vout, spends) do + spends + end + defp index_inputs(binid, [vin | rest], vout, spends) do + spends = index_input(binid <> <>, vin, spends); + index_inputs(binid, rest, vout+1, spends) + end + + defp index_tx(%{id: txid, inputs: inputs}, spends) do + binid = Base.decode16!(txid, [case: :lower]); + index_inputs(binid, inputs, 0, spends) + end + + defp index_txs([], spends) do + spends + end + defp index_txs([tx | rest], spends) do + spends = index_tx(tx, spends); + index_txs(rest, spends) + end + + defp index_block_inputs(dbref, batch, txns) do + spends = index_txs(txns, %{}); + Enum.each(spends, fn {binid, outputs} -> + case get_chain_spends(dbref, binid, true) do + false -> + Logger.error("uninitialised tx in input index: #{Base.encode16(binid, [case: :lower])}") + :ok + + prev -> + :rocksdb.batch_put(batch, binid, fillBinarySpends(prev, outputs)) + end + end) + end + + defp init_block_txs(batch, txns) do + Enum.each(txns, fn tx -> + size = length(tx.outputs) * 35 * 8; + binary_txid = Base.decode16!(tx.id, [case: :lower]); + :rocksdb.batch_put(batch, binary_txid, <<0::integer-size(size)>>) + end) + end + + defp index_block(dbref, height) do + with block <- get_block(height), + {:ok, batch} <- :rocksdb.batch(), + :ok <- init_block_txs(batch, block.txns), + :ok <- :rocksdb.write_batch(dbref, batch, []), + {:ok, batch} <- :rocksdb.batch(), + :ok <- index_block_inputs(dbref, batch, block.txns), + :ok <- :rocksdb.write_batch(dbref, batch, []) do + :ok + else + err -> + Logger.error("error indexing block"); + IO.inspect(err); + :err + end + end + + # insert a 35-byte spend key into a binary spend index + # (not sure how efficient this method is?) + defp fillBinarySpend(bin, index, spendkey) do + a_size = 35 * index; + <> = bin; + <> + end + defp fillBinarySpends(bin, []) do + bin + end + defp fillBinarySpends(bin, [[index, spendkey] | rest]) do + bin = fillBinarySpend(bin, index, spendkey); + fillBinarySpends(bin, rest) + end + + # "erase" a spend by zeroing out the spend key + defp clearBinarySpend(bin, index, _spendkey) do + a_size = 35 * index; + b_size = 35 * 8; + <> = bin; + <>, c::binary>> + end + defp clearBinarySpends(bin, []) do + bin + end + defp clearBinarySpends(bin, [[index, spendkey] | rest]) do + bin = clearBinarySpend(bin, index, spendkey); + clearBinarySpends(bin, rest) + end + + # On start up, check index height (load from leveldb) vs latest block height (load via rpc) + # Until index height = block height, process next block + defp sync(dbref) do + wait_for_ibd(); + with index_height <- get_index_height(dbref), + chain_height <- get_chain_height() do + if index_height < chain_height do + with :ok <- index_block(dbref, index_height + 1), + :ok <- :rocksdb.put(dbref, "height", <<(index_height + 1)::integer-size(32)>>, []) do + Logger.info("Built spend index for block #{index_height + 1}"); + Process.send_after(self(), :sync, 0); + else + _ -> + Logger.error("Failed to build spend index"); + false + end + else + Logger.info("Spend index fully synced to height #{index_height}"); + true + end + end + end + + defp get_chain_spends(dbref, binary_txid, use_index) do + case (if use_index do :rocksdb.get(dbref, binary_txid, []) else :not_found end) do + {:ok, spends} -> + spends + + :not_found -> + # uninitialized, try to construct on-the-fly from RPC data + txid = Base.encode16(binary_txid); + with {:ok, 200, hextx} <- RPC.request(:rpc, "getrawtransaction", [txid]), + rawtx <- Base.decode16!(hextx, case: :lower), + {:ok, tx } <- BitcoinTx.decode(rawtx) do + size = length(tx.outputs) * 35 * 8; + <<0::integer-size(size)>> + else + _ -> false + end + + _ -> + Logger.error("unexpected leveldb response"); + false + end + end + + defp unpack_spends(<<>>, spend_list) do + Enum.reverse(spend_list) + end + # unspent outputs are zeroed out + defp unpack_spends(<<0::integer-size(280), rest::binary>>, spend_list) do + unpack_spends(rest, [false | spend_list]) + end + defp unpack_spends(<>, spend_list) do + txid = Base.encode16(binary_txid, [case: :lower]); + unpack_spends(rest, [[txid, index] | spend_list]) + end + defp unpack_spends(false) do + [] + end + defp unpack_spends(bin) do + unpack_spends(bin, []) + end + + defp get_transaction_spends(dbref, txid, use_index) do + binary_txid = Base.decode16!(txid, [case: :lower]); + chain_spends = get_chain_spends(dbref, binary_txid, use_index); + spend_list = unpack_spends(chain_spends); + spend_list = add_mempool_spends(txid, spend_list); + {:ok, spend_list} + end + + defp add_mempool_spends(_txid, _index, [], added) do + Enum.reverse(added) + end + defp add_mempool_spends(txid, index, [false | rest], added) do + case :ets.lookup(:spend_cache, [txid, index]) do + [] -> + add_mempool_spends(txid, index + 1, rest, [false | added]) + + [{[_index, _txid], spend}] -> + add_mempool_spends(txid, index + 1, rest, [spend | added]) + end + end + defp add_mempool_spends(txid, index, [spend | rest], added) do + add_mempool_spends(txid, index + 1, rest, [spend | added]) + end + defp add_mempool_spends(txid, spend_list) do + add_mempool_spends(txid, 0, spend_list, []) + end + + defp stack_dropped_blocks(dbref, hash, undo_stack, min_height) do + # while we're below the latest processed height + # keep adding blocks to the undo stack until we find an ancestor in the main chain + with {:ok, 200, blockdata} <- RPC.request(:rpc, "getblock", [hash, 1]), + index_height <- get_index_height(dbref), + true <- (blockdata["height"] <= index_height), + true <- (blockdata["confirmations"] < 0) do + stack_dropped_blocks(dbref, blockdata["previousblockhash"], [hash | undo_stack], blockdata["height"]) + else + _ -> [undo_stack, min_height] + end + end + + defp drop_block_inputs(dbref, batch, txns) do + spends = index_txs(txns, %{}); + Enum.each(spends, fn {binid, outputs} -> + case get_chain_spends(dbref, binid, true) do + false -> + Logger.error("uninitialised tx in input index: #{Base.encode16(binid, [case: :lower])}") + :ok + + prev -> + :rocksdb.batch_put(batch, binid, clearBinarySpends(prev, outputs)) + end + end) + end + + defp drop_block(dbref, hash) do + with block <- get_block_by_hash(hash), + {:ok, batch} <- :rocksdb.batch(), + :ok <- drop_block_inputs(dbref, batch, block.txns), + :ok <- :rocksdb.write_batch(dbref, batch, []) do + :ok + else + err -> + Logger.error("error indexing block"); + IO.inspect(err); + :err + end + end + + defp drop_blocks(_dbref, []) do + :ok + end + defp drop_blocks(dbref, [hash | rest]) do + drop_block(dbref, hash); + drop_blocks(dbref, rest) + end + defp block_disconnected(dbref, hash) do + [undo_stack, min_height] = stack_dropped_blocks(dbref, hash, [], nil); + drop_blocks(dbref, undo_stack); + if (min_height != nil) do + :rocksdb.put(dbref, "height", <<(min_height - 1)::integer-size(32)>>, []) + else + :ok + end + end +end diff --git a/server/mix.exs b/server/mix.exs index 5e1ba12..9776dd5 100644 --- a/server/mix.exs +++ b/server/mix.exs @@ -41,7 +41,8 @@ defmodule BitcoinStream.MixProject do {:plug, "~> 1.13"}, {:corsica, "~> 1.0"}, {:plug_cowboy, "~> 2.0"}, - {:jason, "~> 1.1"} + {:jason, "~> 1.1"}, + {:rocksdb, "~> 1.6"} ] end end diff --git a/server/mix.lock b/server/mix.lock index 364bbae..a06a2e4 100644 --- a/server/mix.lock +++ b/server/mix.lock @@ -9,7 +9,9 @@ "cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"}, "cowlib": {:hex, :cowlib, "2.11.0", "0b9ff9c346629256c42ebe1eeb769a83c6cb771a6ee5960bd110ab0b9b872063", [:make, :rebar3], [], "hexpm", "2b3e9da0b21c4565751a6d4901c20d1b4cc25cbb7fd50d91d2ab6dd287bc86a9"}, "decimal": {:hex, :decimal, "1.9.0", "83e8daf59631d632b171faabafb4a9f4242c514b0a06ba3df493951c08f64d07", [:mix], [], "hexpm", "b1f2343568eed6928f3e751cf2dffde95bfaa19dd95d09e8a9ea92ccfd6f7d85"}, + "eleveldb": {:hex, :eleveldb, "2.2.20", "1fff63a5055bbf4bf821f797ef76065882b193f5e8095f95fcd9287187773b58", [:rebar3], [], "hexpm", "0e67df12ef836a7bcdde9373c59f1ae18b335defd1d66b820d3d4dd7ca1844e2"}, "elixometer": {:hex, :elixometer, "1.4.0", "c16f5ac3f369bb1747de059a95e46f86e097d9adb3de5666e997922089c396d1", [:mix], [{:exometer_core, "~> 1.5", [hex: :exometer_core, repo: "hexpm", optional: false]}, {:lager, ">= 3.2.1", [hex: :lager, repo: "hexpm", optional: false]}, {:pobox, "~> 1.2", [hex: :pobox, repo: "hexpm", optional: false]}], "hexpm", "9cd6c8fca17600e3958bbea65c1274ac5baffa03d919b652bdf1f33b5aec64f2"}, + "exleveldb": {:hex, :exleveldb, "0.14.0", "8e9353bbce38482d6971d254c6b98ceb50f3f179c94732b5d17db1be426fca18", [:mix], [{:eleveldb, "~> 2.2.20", [hex: :eleveldb, repo: "hexpm", optional: false]}], "hexpm", "803cd3b4c826a1e17e7e28f6afe224837a743b475e1a48336f186af3dd8636ad"}, "exometer_core": {:hex, :exometer_core, "1.5.7", "ab97e34a5d69ab14e6ae161db4cca5b5e655e635b842f830ee6ab2cbfcfdc30a", [:rebar3], [{:folsom, "0.8.7", [hex: :folsom, repo: "hexpm", optional: false]}, {:hut, "1.2.1", [hex: :hut, repo: "hexpm", optional: false]}, {:parse_trans, "3.3.0", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:setup, "2.0.2", [hex: :setup, repo: "hexpm", optional: false]}], "hexpm", "6afbd8f6b1aaf7443d6a5a05bbbcd15285622353550f3077c87176e25be99c1e"}, "finch": {:hex, :finch, "0.10.2", "9ad27d68270d879f73f26604bb2e573d40f29bf0e907064a9a337f90a16a0312", [:mix], [{:castore, "~> 0.1", [hex: :castore, repo: "hexpm", optional: false]}, {:mint, "~> 1.3", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 0.2", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "dd8b11b282072cec2ef30852283949c248bd5d2820c88d8acc89402b81db7550"}, "folsom": {:hex, :folsom, "0.8.7", "a885f0aeee4c84270954c88a55a5a473d6b2c7493e32ffdc5765412dd555a951", [:rebar3], [{:bear, "0.8.7", [hex: :bear, repo: "hexpm", optional: false]}], "hexpm", "f7b644fc002a75af00b8bfbd3cc5c2bd955e09a118d2982d9a6c04e5646ff367"}, @@ -33,6 +35,7 @@ "plug_crypto": {:hex, :plug_crypto, "1.2.2", "05654514ac717ff3a1843204b424477d9e60c143406aa94daf2274fdd280794d", [:mix], [], "hexpm", "87631c7ad914a5a445f0a3809f99b079113ae4ed4b867348dd9eec288cecb6db"}, "pobox": {:hex, :pobox, "1.2.0", "3127cb48f13d18efec7a9ea2622077f4f9c5f067cc1182af1977dacd7a74fdb8", [:rebar3], [], "hexpm", "25d6fcdbe4fedbbf4bcaa459fadee006e75bb3281d4e6c9b2dc0ee93c51920c4"}, "ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"}, + "rocksdb": {:hex, :rocksdb, "1.7.0", "5d23319998a7fce5ffd5d7824116c905caba7f91baf8eddabd0180f1bb272cef", [:rebar3], [], "hexpm", "a4bdc5dd80ed137161549713062131e8240523787ebe7b51df61cfb48b1786ce"}, "setup": {:hex, :setup, "2.0.2", "1203f4cda11306c2e34434244576ded0a7bbfb0908d9a572356c809bd0cdf085", [:rebar3], [], "hexpm", "7d6aaf5281d0b0c40980e128f9dc410dacd03799a8577201d4c8b43e7f97509a"}, "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.6", "cf344f5692c82d2cd7554f5ec8fd961548d4fd09e7d22f5b62482e5aeaebd4b0", [:make, :mix, :rebar3], [], "hexpm", "bdb0d2471f453c88ff3908e7686f86f9be327d065cc1ec16fa4540197ea04680"}, "telemetry": {:hex, :telemetry, "1.0.0", "0f453a102cdf13d506b7c0ab158324c337c41f1cc7548f0bc0e130bbf0ae9452", [:rebar3], [], "hexpm", "73bc09fa59b4a0284efb4624335583c528e07ec9ae76aca96ea0673850aec57a"},