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 }
-
-
+
{#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"},