Merge pull request #26 from bitfeed-project/fees

API server refactor & fee features
This commit is contained in:
Mononaut 2022-02-19 22:10:07 -06:00 committed by GitHub
commit d8cc46b137
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
37 changed files with 1103 additions and 485 deletions

View File

@ -1,6 +1,6 @@
{ {
"name": "bitfeed-client", "name": "bitfeed-client",
"version": "2.1.5", "version": "2.2.0",
"scripts": { "scripts": {
"build": "rollup -c", "build": "rollup -c",
"dev": "rollup -c -w", "dev": "rollup -c -w",

View File

@ -5,7 +5,7 @@
import { createEventDispatcher } from 'svelte' import { createEventDispatcher } from 'svelte'
import Icon from '../components/Icon.svelte' import Icon from '../components/Icon.svelte'
import closeIcon from '../assets/icon/cil-x-circle.svg' import closeIcon from '../assets/icon/cil-x-circle.svg'
import { shortBtcFormat, longBtcFormat, timeFormat, integerFormat } from '../utils/format.js' import { shortBtcFormat, longBtcFormat, timeFormat, numberFormat } from '../utils/format.js'
import { exchangeRates, settings } from '../stores.js' import { exchangeRates, settings } from '../stores.js'
import { formatCurrency } from '../utils/fx.js' import { formatCurrency } from '../utils/fx.js'
@ -57,16 +57,22 @@
function formatBytes (bytes) { function formatBytes (bytes) {
if (bytes) { if (bytes) {
return `${integerFormat.format(bytes)} bytes` return `${numberFormat.format(bytes)} bytes`
} else return `unknown size` } else return `unknown size`
} }
function formatCount (n) { function formatCount (n) {
if (n) { if (n) {
return integerFormat.format(n) return numberFormat.format(n)
} else return '0' } else return '0'
} }
function formatFee (n) {
if (n) {
return numberFormat.format(n.toFixed(2))
} else return '0'
}
function hideBlock () { function hideBlock () {
analytics.trackEvent('viz', 'block', 'hide') analytics.trackEvent('viz', 'block', 'hide')
dispatch('hideBlock') dispatch('hideBlock')
@ -108,11 +114,36 @@
font-size: 4.4vw; font-size: 4.4vw;
} }
.compact {
display: none;
}
@media (max-aspect-ratio: 1/1) and (max-height: 760px) {
.compact {
display: block;
}
.full-size {
display: none;
}
}
@media (aspect-ratio: 1/1) and (max-height: 760px) {
.compact {
display: none;
}
.full-size {
display: block;
}
}
.data-row { .data-row {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
flex-wrap: nowrap; flex-wrap: nowrap;
justify-content: space-between; justify-content: space-between;
&.spacer {
display: none;
}
} }
.data-field { .data-field {
@ -157,6 +188,10 @@
flex-direction: column; flex-direction: column;
justify-content: flex-start; justify-content: flex-start;
align-items: flex-end; align-items: flex-end;
&.spacer {
display: flex;
}
} }
.data-field { .data-field {
@ -196,17 +231,46 @@
{#if block != null && visible } {#if block != null && visible }
<div class="block-info" out:fly="{{ y: -50, duration: 2000, easing: linear }}" in:fly="{{ y: (restoring ? -50 : 50), duration: (restoring ? 500 : 1000), easing: linear, delay: (restoring ? 0 : newBlockDelay) }}"> <div class="block-info" out:fly="{{ y: -50, duration: 2000, easing: linear }}" in:fly="{{ y: (restoring ? -50 : 50), duration: (restoring ? 500 : 1000), easing: linear, delay: (restoring ? 0 : newBlockDelay) }}">
<!-- <span class="data-field">Hash: { block.id }</span> --> <!-- <span class="data-field">Hash: { block.id }</span> -->
<div class="data-row"> <div class="full-size">
<span class="data-field title-field" title="{block.miner_sig}"><b>Latest Block: </b>{ integerFormat.format(block.height) }</span> <div class="data-row">
<button class="data-field close-button" on:click={hideBlock}><Icon icon={closeIcon} color="var(--palette-x)" /></button> <span class="data-field title-field" title="{block.miner_sig}"><b>Latest Block: </b>{ numberFormat.format(block.height) }</span>
<button class="data-field close-button" on:click={hideBlock}><Icon icon={closeIcon} color="var(--palette-x)" /></button>
</div>
<div class="data-row">
<span class="data-field">Mined { formatTime(block.time) }</span>
<span class="data-field">{ formattedBlockValue }</span>
</div>
<div class="data-row">
<span class="data-field">{ formatBytes(block.bytes) }</span>
<span class="data-field">{ formatCount(block.txnCount) } transactions</span>
</div>
<div class="data-row spacer">&nbsp;</div>
<div class="data-row">
<span class="data-field">Avg Fee Rate</span>
{#if block.fees != null}
<span class="data-field">{ formatFee(block.avgFeerate) } sats/vbyte</span>
{:else}
<span class="data-field">unavailable</span>
{/if}
</div>
</div> </div>
<div class="data-row"> <div class="compact">
<span class="data-field">Mined { formatTime(block.time) }</span> <div class="data-row">
<span class="data-field">{ formattedBlockValue }</span> <span class="data-field title-field" title="{block.miner_sig}"><b>Latest Block: </b>{ numberFormat.format(block.height) }</span>
</div> <button class="data-field close-button" on:click={hideBlock}><Icon icon={closeIcon} color="var(--palette-x)" /></button>
<div class="data-row"> </div>
<span class="data-field">{ formatBytes(block.bytes) }</span> <div class="data-row">
<span class="data-field">{ formatCount(block.txnCount) } transactions</span> <span class="data-field">Mined { formatTime(block.time) }</span>
<span class="data-field">{ formattedBlockValue }</span>
</div>
<div class="data-row">
<span class="data-field">{ formatCount(block.txnCount) } transactions</span>
{#if block.fees != null}
<span class="data-field">{ formatFee(block.avgFeerate) } sats/vb</span>
{:else}
<span class="data-field">{ formatBytes(block.bytes) }</span>
{/if}
</div>
</div> </div>
</div> </div>
<button class="close-button standalone" on:click={hideBlock} out:fly="{{ y: -50, duration: 2000, easing: linear }}" in:fly="{{ y: (restoring ? -50 : 50), duration: (restoring ? 500 : 1000), easing: linear, delay: (restoring ? 0 : newBlockDelay) }}" > <button class="close-button standalone" on:click={hideBlock} out:fly="{{ y: -50, duration: 2000, easing: linear }}" in:fly="{{ y: (restoring ? -50 : 50), duration: (restoring ? 500 : 1000), easing: linear, delay: (restoring ? 0 : newBlockDelay) }}" >

View File

@ -1,10 +1,16 @@
<script> <script>
import { onMount } from 'svelte' import { onMount } from 'svelte'
import { settings } from '../stores.js' import { settings, colorMode } from '../stores.js'
import { integerFormat } from '../utils/format.js' import { numberFormat } from '../utils/format.js'
import { logTxSize, byteTxSize } from '../utils/misc.js' import { logTxSize, byteTxSize } from '../utils/misc.js'
import { interpolateHcl } from 'd3-interpolate' import { interpolateHcl } from 'd3-interpolate'
import { color } from 'd3-color' import { color, hcl } from 'd3-color'
import { hlToHex, orange, teal, green, purple } from '../utils/color.js'
const orangeHex = hlToHex(orange)
const tealHex = hlToHex(teal)
const greenHex = hlToHex(green)
const purpleHex = hlToHex(purple)
const sizes = [ const sizes = [
{ value: 1000000, vbytes: 1*256, size: null }, { value: 1000000, vbytes: 1*256, size: null },
@ -18,8 +24,15 @@ let unitWidth
let unitPadding let unitPadding
let gridSize let gridSize
let colorScale let colorScale
let feeColorScale
const colorScaleWidth = 200 const colorScaleWidth = 200
let squareColor = orangeHex
$: {
if ($colorMode === 'age') squareColor = orangeHex
else squareColor = purpleHex
}
function resize () { function resize () {
unitWidth = Math.floor(Math.max(4, (window.innerWidth - 20) / 250)) unitWidth = Math.floor(Math.max(4, (window.innerWidth - 20) / 250))
unitPadding = Math.floor(Math.max(1, (window.innerWidth - 20) / 1000)) unitPadding = Math.floor(Math.max(1, (window.innerWidth - 20) / 1000))
@ -29,7 +42,8 @@ resize()
onMount(() => { onMount(() => {
resize() resize()
colorScale = generateColorScale('#f7941d', '#00ffc6') colorScale = generateColorScale(orangeHex, tealHex)
feeColorScale = generateColorScale(tealHex, purpleHex)
}) })
function calcSizes (gridSize, unitWidth, unitPadding) { function calcSizes (gridSize, unitWidth, unitPadding) {
@ -52,7 +66,7 @@ function calcSize ({ vbytes, value }) {
} }
function formatBytes (bytes) { function formatBytes (bytes) {
const str = integerFormat.format(bytes) + ' vbytes' const str = numberFormat.format(bytes) + ' vbytes'
const padded = str.padStart(13, '') const padded = str.padStart(13, '')
return padded return padded
} }
@ -98,6 +112,7 @@ function generateColorScale (colorA, colorB) {
.size-legend { .size-legend {
display: table; display: table;
margin: auto; margin: auto;
margin-bottom: 5px;
.size-row { .size-row {
display: table-row; display: table-row;
@ -166,23 +181,33 @@ function generateColorScale (colorA, colorB) {
{#if $settings.vbytes } {#if $settings.vbytes }
{#each sizes as { size, vbytes } } {#each sizes as { size, vbytes } }
<div class="size-row"> <div class="size-row">
<span class="square-container"><div class="square" style="width: {size}px; height: {size}px" /></span> <span class="square-container"><div class="square" style="width: {size}px; height: {size}px; background: {squareColor};" /></span>
<span class="value"><span class="part left">&lt;</span><span class="part center">&nbsp;</span><span class="part right">{ formatBytes(vbytes) }</span></span> <span class="value"><span class="part left">&lt;</span><span class="part center">&nbsp;</span><span class="part right">{ formatBytes(vbytes) }</span></span>
</div> </div>
{/each} {/each}
{:else} {:else}
{#each sizes as { size, value } } {#each sizes as { size, value } }
<div class="size-row"> <div class="size-row">
<span class="square-container"><div class="square" style="width: {size}px; height: {size}px" /></span> <span class="square-container"><div class="square" style="width: {size}px; height: {size}px; background: {squareColor}" /></span>
<span class="value"><span class="part left">&lt;</span> <span class="part center">&#8383;</span><span class="part right">{ formatValue(value) }</span></span> <span class="value"><span class="part left">&lt;</span> <span class="part center">&#8383;</span><span class="part right">{ formatValue(value) }</span></span>
</div> </div>
{/each} {/each}
{/if} {/if}
</div> </div>
<h3 class="subheading">Age in seconds</h3> {#if $colorMode === 'age'}
<h3 class="subheading">Age in seconds</h3>
{:else}
<h3 class="subheading">Fee rate in sats/vbyte</h3>
{/if}
<div class="color-legend"> <div class="color-legend">
<span class="value left">0</span> {#if $colorMode === 'age'}
<img src={colorScale} alt="" class="color-scale-img" width="200" height="15"> <span class="value left">0</span>
<span class="value right">60+</span> <img src={colorScale} alt="" class="color-scale-img" width="200" height="15">
<span class="value right">60+</span>
{:else}
<span class="value left">1</span>
<img src={feeColorScale} alt="" class="color-scale-img" width="200" height="15">
<span class="value right">64+</span>
{/if}
</div> </div>
</div> </div>

View File

@ -10,16 +10,10 @@ import AddressIcon from '../assets/icon/cil-wallet.svg'
import TxIcon from '../assets/icon/cil-arrow-circle-right.svg' import TxIcon from '../assets/icon/cil-arrow-circle-right.svg'
import { matchQuery } from '../utils/search.js' import { matchQuery } from '../utils/search.js'
import { highlight, newHighlightQuery, highlightingFull } from '../stores.js' import { highlight, newHighlightQuery, highlightingFull } from '../stores.js'
import { hcl } from 'd3-color' import { hlToHex, highlightA, highlightB, highlightC, highlightD, highlightE } from '../utils/color.js'
const highlightColors = [ const highlightColors = [highlightA, highlightB, highlightC, highlightD, highlightE]
{ h: 0.03, l: 0.35 }, const highlightHexColors = highlightColors.map(c => hlToHex(c))
{ h: 0.40, l: 0.35 },
{ h: 0.65, l: 0.35 },
{ h: 0.85, l: 0.35 },
{ h: 0.12, l: 0.35 },
]
const highlightHexColors = highlightColors.map(c => hclToHex(c))
const usedColors = [false, false, false, false, false] const usedColors = [false, false, false, false, false]
const queryIcons = { const queryIcons = {
@ -36,6 +30,7 @@ export let tab
let query let query
let matchedQuery let matchedQuery
let queryAddress let queryAddress
let queryColorIndex
let queryColor let queryColor
let queryColorHex let queryColorHex
let watchlist = [] let watchlist = []
@ -48,8 +43,9 @@ $: {
if ($newHighlightQuery) { if ($newHighlightQuery) {
matchedQuery = matchQuery($newHighlightQuery) matchedQuery = matchQuery($newHighlightQuery)
if (matchedQuery) { if (matchedQuery) {
matchedQuery.color = queryColor matchedQuery.colorIndex = queryColorIndex
matchedQuery.colorHex = queryColorHex matchedQuery.color = highlightColors[queryColorIndex]
matchedQuery.colorHex = highlightHexColors[queryColorIndex]
add() add()
query = null query = null
} }
@ -66,8 +62,9 @@ $: {
if (query) { if (query) {
matchedQuery = matchQuery(query.trim()) matchedQuery = matchQuery(query.trim())
if (matchedQuery) { if (matchedQuery) {
matchedQuery.color = queryColor matchedQuery.colorIndex = queryColorIndex
matchedQuery.colorHex = queryColorHex matchedQuery.color = highlightColors[queryColorIndex]
matchedQuery.colorHex = highlightHexColors[queryColorIndex]
} }
} else matchedQuery = null } else matchedQuery = null
} }
@ -81,9 +78,20 @@ function init () {
try { try {
watchlist = JSON.parse(val) watchlist = JSON.parse(val)
watchlist.forEach(q => { watchlist.forEach(q => {
const i = highlightHexColors.findIndex(c => c === q.colorHex) if (q.colorIndex) {
if (i >= 0) usedColors[i] = q.colorHex usedColors[q.colorIndex] = true
else console.log('unknown color') q.color = highlightColors[q.colorIndex]
q.colorHex = highlightHexColors[q.colorIndex]
}
})
watchlist.forEach(q => {
if (!q.colorIndex){
const nextIndex = usedColors.findIndex(used => !used)
usedColors[nextIndex] = true
q.colorIndex = nextIndex
q.color = highlightColors[nextIndex]
q.colorHex = highlightHexColors[nextIndex]
}
}) })
} catch (e) { } catch (e) {
console.log('failed to parse cached highlight queries') console.log('failed to parse cached highlight queries')
@ -94,18 +102,14 @@ function init () {
function setNextColor () { function setNextColor () {
const nextIndex = usedColors.findIndex(used => !used) const nextIndex = usedColors.findIndex(used => !used)
if (nextIndex >= 0) { if (nextIndex >= 0) {
queryColorIndex = nextIndex
queryColor = highlightColors[nextIndex] queryColor = highlightColors[nextIndex]
queryColorHex = highlightHexColors[nextIndex] queryColorHex = highlightHexColors[nextIndex]
usedColors[nextIndex] = queryColorHex usedColors[nextIndex] = true
} }
} }
function clearUsedColor (hex) { function clearUsedColor (colorIndex) {
const clearIndex = usedColors.findIndex(used => used === hex) usedColors[colorIndex] = false
usedColors[clearIndex] = false
}
function hclToHex (color) {
return hcl(color.h * 360, 78.225, color.l * 150).hex()
} }
async function add () { async function add () {
@ -127,7 +131,7 @@ async function remove (index) {
const wasFull = $highlightingFull const wasFull = $highlightingFull
const removed = watchlist.splice(index,1) const removed = watchlist.splice(index,1)
if (removed.length) { if (removed.length) {
clearUsedColor(removed[0].colorHex) clearUsedColor(removed[0].colorIndex)
watchlist = watchlist watchlist = watchlist
if (tab) { if (tab) {
await tick() await tick()
@ -249,7 +253,7 @@ function searchSubmit (e) {
</div> </div>
</div> </div>
<div class="watchlist"> <div class="watchlist">
{#each watchlist as watched, index (watched.colorHex)} {#each watchlist as watched, index (watched.colorIndex)}
<div <div
class="watched" class="watched"
transition:fade={{ duration: 200 }} transition:fade={{ duration: 200 }}

View File

@ -46,6 +46,13 @@ let settingConfig = {
trueLabel: 'vbytes', trueLabel: 'vbytes',
valueType: 'bool' valueType: 'bool'
}, },
colorByFee: {
label: 'Color by',
type: 'pill',
falseLabel: 'age',
trueLabel: 'fee rate',
valueType: 'bool'
}
} }
$: { $: {
if ($nativeAntialias) { if ($nativeAntialias) {

View File

@ -1,7 +1,7 @@
<script> <script>
import Icon from './Icon.svelte' import Icon from './Icon.svelte'
import BookmarkIcon from '../assets/icon/cil-bookmark.svg' import BookmarkIcon from '../assets/icon/cil-bookmark.svg'
import { longBtcFormat, integerFormat } from '../utils/format.js' import { longBtcFormat, numberFormat, feeRateFormat } from '../utils/format.js'
import { exchangeRates, settings, sidebarToggle, newHighlightQuery, highlightingFull } from '../stores.js' import { exchangeRates, settings, sidebarToggle, newHighlightQuery, highlightingFull } from '../stores.js'
import { formatCurrency } from '../utils/fx.js' import { formatCurrency } from '../utils/fx.js'
@ -84,6 +84,24 @@ function highlight () {
word-break: break-all; word-break: break-all;
} }
.inputs {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: baseline;
span {
width: 0;
flex-shrink: 1;
flex-grow: 1;
&.arrow {
min-width: 1.5em;
flex-shrink: 1;
flex-grow: 0;
}
}
}
&:hover { &:hover {
.hash { .hash {
white-space: pre-wrap; white-space: pre-wrap;
@ -119,12 +137,21 @@ function highlight () {
<p class="field hash"> <p class="field hash">
TxID: { tx.id } TxID: { tx.id }
</p> </p>
{#if tx.inputs && !tx.coinbase }<p class="field inputs">{ tx.inputs.length } input{#if tx.inputs.length != 1}s{/if}</p> {#if tx.inputs && tx.outputs && !tx.coinbase }
<p class="field inputs">
<span>{ tx.inputs.length } input{#if tx.inputs.length != 1}s{/if}</span>
<span class="arrow"> &rarr; </span>
<span>{ tx.outputs.length } output{#if tx.outputs.length != 1}s{/if}</span>
</p>
{:else if tx.coinbase } {:else if tx.coinbase }
<p class="field coinbase">Coinbase: { tx.coinbase.sigAscii }</p> <p class="field coinbase">Coinbase: { tx.coinbase.sigAscii }</p>
<p class="field inputs">{ tx.outputs.length } output{#if tx.outputs.length != 1}s{/if}</p>
{/if}
<p class="field vbytes">Size: { numberFormat.format(tx.vbytes) } vbytes</p>
{#if !tx.coinbase && tx.fee != null }
<p class="field feerate">Fee rate: { numberFormat.format(tx.feerate.toFixed(2)) } sats/vbyte</p>
<p class="field fee">Fee: { numberFormat.format(tx.fee) } sats</p>
{/if} {/if}
{#if tx.outputs }<p class="field outputs">{ tx.outputs.length } output{#if tx.outputs.length != 1}s{/if}</p>{/if}
<p class="field vbytes">{ integerFormat.format(tx.vbytes) } vbytes</p>
<p class="field value"> <p class="field value">
Total value: { formatBTC(tx.value) } Total value: { formatBTC(tx.value) }
{#if formattedLocalValue != null } {#if formattedLocalValue != null }

View File

@ -3,7 +3,7 @@
import TxController from '../controllers/TxController.js' import TxController from '../controllers/TxController.js'
import TxRender from './TxRender.svelte' import TxRender from './TxRender.svelte'
import getTxStream from '../controllers/TxStream.js' import getTxStream from '../controllers/TxStream.js'
import { settings, overlay, serverConnected, serverDelay, txQueueLength, txCount, mempoolCount, mempoolScreenHeight, frameRate, avgFrameRate, blockVisible, currentBlock, selectedTx, blockAreaSize, devEvents, devSettings } from '../stores.js' import { settings, overlay, serverConnected, serverDelay, txCount, mempoolCount, mempoolScreenHeight, frameRate, avgFrameRate, blockVisible, currentBlock, selectedTx, blockAreaSize, devEvents, devSettings } from '../stores.js'
import BitcoinBlock from '../models/BitcoinBlock.js' import BitcoinBlock from '../models/BitcoinBlock.js'
import BlockInfo from '../components/BlockInfo.svelte' import BlockInfo from '../components/BlockInfo.svelte'
import TxInfo from '../components/TxInfo.svelte' import TxInfo from '../components/TxInfo.svelte'
@ -12,7 +12,7 @@
import DonationOverlay from '../components/DonationOverlay.svelte' import DonationOverlay from '../components/DonationOverlay.svelte'
import SupportersOverlay from '../components/SupportersOverlay.svelte' import SupportersOverlay from '../components/SupportersOverlay.svelte'
import Alerts from '../components/alert/Alerts.svelte' import Alerts from '../components/alert/Alerts.svelte'
import { integerFormat } from '../utils/format.js' import { numberFormat } from '../utils/format.js'
import { exchangeRates, lastBlockId, haveSupporters, sidebarToggle } from '../stores.js' import { exchangeRates, lastBlockId, haveSupporters, sidebarToggle } from '../stores.js'
import { formatCurrency } from '../utils/fx.js' import { formatCurrency } from '../utils/fx.js'
import config from '../config.js' import config from '../config.js'
@ -54,14 +54,16 @@
txStream.subscribe('tx', tx => { txStream.subscribe('tx', tx => {
txController.addTx(tx) txController.addTx(tx)
}) })
txStream.subscribe('drop_tx', txid => {
txController.dropTx(txid)
})
} }
if (!config.noBlockFeed) { if (!config.noBlockFeed) {
txStream.subscribe('block', block => { txStream.subscribe('block', ({block, realtime}) => {
if (block) { if (block) {
const added = txController.addBlock(block) const added = txController.addBlock(block, realtime)
if (added && added.id) $lastBlockId = added.id if (added && added.id) $lastBlockId = added.id
} }
txStream.sendMempoolRequest()
}) })
} }
if (!config.noTxFeed || !config.noBlockFeed) { if (!config.noTxFeed || !config.noBlockFeed) {
@ -398,7 +400,7 @@
<div class="mempool-height" style="bottom: calc({$mempoolScreenHeight + 20}px)"> <div class="mempool-height" style="bottom: calc({$mempoolScreenHeight + 20}px)">
<div class="height-bar" /> <div class="height-bar" />
<span class="mempool-count">Mempool: { integerFormat.format($mempoolCount) } unconfirmed</span> <span class="mempool-count">Mempool: { numberFormat.format(Math.round($mempoolCount)) } unconfirmed</span>
</div> </div>
<div class="block-area-wrapper"> <div class="block-area-wrapper">

View File

@ -10,7 +10,7 @@ function chooseImgs () {
const randomIndex = Math.floor(Math.random() * imgs.length) const randomIndex = Math.floor(Math.random() * imgs.length)
for (let i = 0; i < Math.min(3, imgs.length); i++) { for (let i = 0; i < Math.min(3, imgs.length); i++) {
const randomImg = imgs[(randomIndex + i) % imgs.length] const randomImg = imgs[(randomIndex + i) % imgs.length]
displayImgs.push(randomImg) if (randomImg && randomImg.img) displayImgs.push(randomImg)
} }
} }

View File

@ -31,7 +31,7 @@ export default {
noTxFeed: false, noTxFeed: false,
noBlockFeed: false, noBlockFeed: false,
// Minimum delay in ms before newly recieved transactions enter the visualization // Minimum delay in ms before newly recieved transactions enter the visualization
txDelay: 10000, txDelay: 3000,
donationsEnabled: true, donationsEnabled: true,
// Enables the message bar // Enables the message bar
messagesEnabled: true, messagesEnabled: true,

View File

@ -5,7 +5,7 @@ import BitcoinTx from '../models/BitcoinTx.js'
import BitcoinBlock from '../models/BitcoinBlock.js' import BitcoinBlock from '../models/BitcoinBlock.js'
import TxSprite from '../models/TxSprite.js' import TxSprite from '../models/TxSprite.js'
import { FastVertexArray } from '../utils/memory.js' import { FastVertexArray } from '../utils/memory.js'
import { txQueueLength, txCount, mempoolCount, mempoolScreenHeight, blockVisible, currentBlock, selectedTx, blockAreaSize, highlight } from '../stores.js' import { txCount, mempoolCount, mempoolScreenHeight, blockVisible, currentBlock, selectedTx, blockAreaSize, highlight, colorMode } from '../stores.js'
import config from "../config.js" import config from "../config.js"
export default class TxController { export default class TxController {
@ -26,17 +26,16 @@ export default class TxController {
this.selectedTx = null this.selectedTx = null
this.selectionLocked = false this.selectionLocked = false
this.pendingTxs = [] this.lastTxTime = 0
this.pendingMap = {} this.txDelay = 0
this.queueTimeout = null
this.queueLength = 0
highlight.subscribe(criteria => { highlight.subscribe(criteria => {
this.highlightCriteria = criteria this.highlightCriteria = criteria
this.applyHighlighting() this.applyHighlighting()
}) })
colorMode.subscribe(mode => {
this.scheduleQueue(1000) this.setColorMode(mode)
})
} }
getVertexData () { getVertexData () {
@ -65,6 +64,14 @@ export default class TxController {
this.redoLayout({ width, height }) this.redoLayout({ width, height })
} }
setColorMode (mode) {
this.colorMode = mode
this.poolScene.setColorMode(mode)
if (this.blockScene) {
this.blockScene.setColorMode(mode)
}
}
applyHighlighting () { applyHighlighting () {
this.poolScene.applyHighlighting(this.highlightCriteria) this.poolScene.applyHighlighting(this.highlightCriteria)
if (this.blockScene) { if (this.blockScene) {
@ -76,71 +83,25 @@ export default class TxController {
const tx = new BitcoinTx(txData, this.vertexArray) const tx = new BitcoinTx(txData, this.vertexArray)
tx.applyHighlighting(this.highlightCriteria) tx.applyHighlighting(this.highlightCriteria)
if (!this.txs[tx.id] && !this.expiredTxs[tx.id]) { if (!this.txs[tx.id] && !this.expiredTxs[tx.id]) {
this.pendingTxs.push([tx, performance.now()]) // smooth near-simultaneous arrivals over up to three seconds
this.pendingTxs[tx.id] = tx const dx = performance.now() - this.lastTxTime
txQueueLength.increment() this.lastTxTime = performance.now()
if (dx <= 250) {
this.txDelay = Math.min(3000, this.txDelay + (Math.random() * 250))
} else {
this.txDelay = Math.max(0, this.txDelay - (dx-250))
}
this.txs[tx.id] = tx
tx.onEnterScene()
this.poolScene.insert(this.txs[tx.id], this.txDelay)
} }
} }
// Dual-strategy queue processing: dropTx (txid) {
// - ensure transactions are queued for at least txDelay // don't actually need to do anything, just let the tx expire
// - when queue length exceeds 500, process iteratively to avoid unbounded growth
// - while queue is small, use jittered timeouts to evenly distribute arrivals
//
// transactions tend to arrive in groups, so for smoothest
// animation the queue should stay short but never empty.
processQueue () {
let done
let delay
while (!done) {
if (this.pendingTxs && this.pendingTxs.length) {
if (this.txs[this.pendingTxs[0][0].id] || this.expiredTxs[this.pendingTxs[0][0].id]) {
// duplicate transaction, skip without delay
const tx = this.pendingTxs.shift()[0]
delete this.pendingMap[tx.id]
txQueueLength.decrement()
} else {
const timeSince = performance.now() - this.pendingTxs[0][1]
if (timeSince > this.txDelay) {
//process the next tx in the queue, if it arrived longer ago than txDelay
if (this.txDelay < this.maxTxDelay) {
// slowly ramp up from 0 to maxTxDelay on start, so there's no wait for the first txs on page load
this.txDelay += 50
}
const tx = this.pendingTxs.shift()[0]
delete this.pendingMap[tx.id]
txQueueLength.decrement()
this.txs[tx.id] = tx
this.poolScene.insert(this.txs[tx.id])
mempoolCount.increment()
} else {
// end the loop when the head of the queue arrived more recently than txDelay
done = true
// schedule to continue processing when head of queue matures
delay = this.txDelay - timeSince
}
if (this.pendingTxs.length < 500) {
// or the queue is under 500
done = true
}
// otherwise keep processing the queue
}
} else done = true
}
// randomly jitter arrival times so that txs enter more naturally
// with jittered delay inversely proportional to size of queue
let jitter = Math.random() * Math.max(1, (500 - this.pendingTxs.length))
this.scheduleQueue(delay || jitter)
} }
scheduleQueue (delay) { addBlock (blockData, realtime=true) {
if (this.queueTimeout) clearTimeout(this.queueTimeout)
this.queueTimeout = setTimeout(() => {
this.processQueue()
}, delay)
}
addBlock (blockData) {
// discard duplicate blocks // discard duplicate blocks
if (!blockData || !blockData.id || this.knownBlocks[blockData.id]) { if (!blockData || !blockData.id || this.knownBlocks[blockData.id]) {
return return
@ -148,7 +109,7 @@ export default class TxController {
this.poolScene.scrollLock = true this.poolScene.scrollLock = true
const block = (blockData && blockData.isBlock) ? blockData : new BitcoinBlock(blockData) const block = new BitcoinBlock(blockData)
this.knownBlocks[block.id] = true this.knownBlocks[block.id] = true
if (this.clearBlockTimeout) clearTimeout(this.clearBlockTimeout) if (this.clearBlockTimeout) clearTimeout(this.clearBlockTimeout)
@ -156,39 +117,27 @@ export default class TxController {
this.clearBlock() this.clearBlock()
this.blockScene = new TxBlockScene({ width: this.blockAreaSize, height: this.blockAreaSize, blockId: block.id, controller: this }) this.blockScene = new TxBlockScene({ width: this.blockAreaSize, height: this.blockAreaSize, blockId: block.id, controller: this, colorMode: this.colorMode })
let poolCount = 0
let knownCount = 0 let knownCount = 0
let unknownCount = 0 let unknownCount = 0
for (let i = 0; i < block.txns.length; i++) { for (let i = 0; i < block.txns.length; i++) {
if (this.poolScene.remove(block.txns[i].id)) { if (this.poolScene.remove(block.txns[i].id)) {
poolCount++
knownCount++ knownCount++
this.txs[block.txns[i].id].setBlock(block.id) this.txs[block.txns[i].id].setBlock(block)
this.blockScene.insert(this.txs[block.txns[i].id], false) this.blockScene.insert(this.txs[block.txns[i].id], 0, false)
} else if (this.pendingMap[block.txns[i].id]) {
knownCount++
const tx = this.pendingMap[tx.id]
const pendingIndex = this.pendingTxs.indexOf(tx)
if (pendingIndex >= 0) this.pendingTxs.splice(pendingIndex, 1)
delete this.pendingMap[tx.id]
tx.setBlock(block.id)
this.txs[tx.id] = tx
this.blockScene.insert(tx, false)
} else { } else {
unknownCount++ unknownCount++
const tx = new BitcoinTx({ const tx = new BitcoinTx({
...block.txns[i], ...block.txns[i],
block: block.id block: block
}, this.vertexArray) }, this.vertexArray)
this.txs[tx.id] = tx this.txs[tx.id] = tx
this.txs[tx.id].applyHighlighting(this.highlightCriteria) this.txs[tx.id].applyHighlighting(this.highlightCriteria)
this.blockScene.insert(tx, false) this.blockScene.insert(tx, 0, false)
} }
this.expiredTxs[block.txns[i].id] = true this.expiredTxs[block.txns[i].id] = true
} }
console.log(`New block with ${knownCount} known transactions and ${unknownCount} unknown transactions`) console.log(`New block with ${knownCount} known transactions and ${unknownCount} unknown transactions`)
mempoolCount.subtract(poolCount)
this.blockScene.initialLayout() this.blockScene.initialLayout()
setTimeout(() => { this.poolScene.scrollLock = false; this.poolScene.layoutAll() }, 4000) setTimeout(() => { this.poolScene.scrollLock = false; this.poolScene.layoutAll() }, 4000)
@ -198,64 +147,6 @@ export default class TxController {
return block return block
} }
simulateBlock () {
for (var i = 0; i < 1000; i++) {
this.addTx(new BitcoinTx({
version: 'simulated',
time: Date.now(),
id: `simulated_${i}_${Math.random()}`,
value: Math.floor(Math.pow(2,(0.1-(Math.log(1 - Math.random()))) * 8))
}))
}
const simulatedTxns = this.pendingTxs.map(pending => {
return {
version: pending[0].version,
id: pending[0].id,
time: pending[0].time,
value: pending[0].value
}
})
// const simulatedTxns = []
Object.values(this.poolScene.txs).forEach(tx => {
if (Math.random() < 0.5) {
simulatedTxns.push({
version: tx.version,
id: tx.id,
time: tx.time,
value: tx.value
})
}
})
const block = new BitcoinBlock({
version: 'fake',
id: 'fake_block' + Math.random(),
value: 12345678900,
prev_block: 'also_fake',
merkle_root: 'merkle',
timestamp: Date.now() / 1000,
bits: 'none',
bytes: 1379334,
txn_count: simulatedTxns.length,
txns: simulatedTxns
})
setTimeout(() => {
this.addBlock(block)
}, 2500)
return block
}
simulateDumpTx (n, value) {
for (var i = 0; i < n; i++) {
this.addTx(new BitcoinTx({
version: 'simulated',
time: Date.now(),
id: `simulated_${i}_${Math.random()}`,
value: value || Math.min(10000, Math.floor(Math.pow(2,(0.1-(Math.log(1 - Math.random()))) * 8))) // horrible but plausible distribution of tx values
// value: value || Math.pow(10,Math.floor(Math.random() * 5))
}, this.vertexArray))
}
}
hideBlock () { hideBlock () {
if (this.blockScene) { if (this.blockScene) {
this.blockScene.hide() this.blockScene.hide()

View File

@ -8,7 +8,9 @@ lastBlockId.subscribe(val => { lastBlockSeen = val })
class TxStream { class TxStream {
constructor () { constructor () {
this.websocketUri = `${config.secureSocket ? 'wss://' : 'ws://'}${config.backend ? config.backend : window.location.host }${config.backendPort ? ':' + config.backendPort : ''}/ws/txs` this.apiRoot = `${config.backend ? config.backend : window.location.host }${config.backendPort ? ':' + config.backendPort : ''}`
this.websocketUri = `${config.secureSocket ? 'wss://' : 'ws://'}${this.apiRoot}/ws/txs`
this.apiUri = `${config.secureSocket ? 'https://' : 'http://'}${this.apiRoot}`
console.log('connecting to ', this.websocketUri) console.log('connecting to ', this.websocketUri)
this.reconnectBackoff = 250 this.reconnectBackoff = 250
this.websocket = null this.websocket = null
@ -91,13 +93,7 @@ class TxStream {
sendBlockRequest () { sendBlockRequest () {
if (config.noBlockFeed) return if (config.noBlockFeed) return
console.log('Checking for missed blocks...') console.log('Checking for missed blocks...')
this.websocket.send(JSON.stringify({method: 'get_block', last: lastBlockSeen })) this.websocket.send("block_id")
}
sendMempoolRequest () {
this.websocket.send('count')
if (mempoolTimer) clearTimeout(mempoolTimer)
mempoolTimer = setTimeout(() => { this.sendMempoolRequest() }, 60000)
} }
disconnect () { disconnect () {
@ -122,7 +118,25 @@ class TxStream {
this.reconnectBackoff = 128 this.reconnectBackoff = 128
this.sendHeartbeat() this.sendHeartbeat()
this.sendBlockRequest() this.sendBlockRequest()
this.sendMempoolRequest() }
async fetchBlock (id, calledOnLoad) {
if (!id) return
if (id !== lastBlockSeen) {
try {
console.log('downloading block', id)
const response = await fetch(`${this.apiUri}/api/block/${id}`, {
method: 'GET'
})
let blockData = await response.json()
console.log('downloaded block', id)
window.dispatchEvent(new CustomEvent('bitcoin_block', { detail: { block: blockData, realtime: !calledOnLoad} }))
} catch (err) {
console.log("failed to download block ", id)
}
} else {
console.log('already seen block ', lastBlockSeen)
}
} }
onmessage (event) { onmessage (event) {
@ -134,17 +148,38 @@ class TxStream {
} else { } else {
try { try {
const msg = JSON.parse(event.data) const msg = JSON.parse(event.data)
if (msg && msg.type === 'count') {
window.dispatchEvent(new CustomEvent('bitcoin_mempool_count', { detail: msg.count })) if (!msg) throw new Error('null websocket message')
} else if (msg && msg.type === 'txn') {
window.dispatchEvent(new CustomEvent('bitcoin_tx', { detail: msg.txn })) switch (msg.type) {
} else if (msg && msg.type === 'block') {
if (msg.block && msg.block.id) window.dispatchEvent(new CustomEvent('bitcoin_block', { detail: msg.block })) // reply to a last block_id request message
} else { case 'block_id':
// console.log('unknown message from websocket: ', msg) this.fetchBlock(msg.block_id, true)
break;
// notification of tx added to mempool
case 'txn':
window.dispatchEvent(new CustomEvent('bitcoin_tx', { detail: msg.txn }))
break;
// notification of tx dropped from mempool
case 'drop':
window.dispatchEvent(new CustomEvent('bitcoin_drop_tx', { detail: msg.txid }))
break;
// notification of a new block
case 'block':
if (msg.block && msg.block.id) {
this.fetchBlock(msg.block.id)
}
break;
} }
// all events can include a count field, with the latest mempool size
if (msg.count) window.dispatchEvent(new CustomEvent('bitcoin_mempool_count', { detail: msg.count }))
} catch (err) { } catch (err) {
// console.log('unknown message from websocket: ', msg) console.log('error parsing msg json: ', err)
} }
} }
} }

View File

@ -1,7 +1,7 @@
import BitcoinTx from '../models/BitcoinTx.js' import BitcoinTx from '../models/BitcoinTx.js'
export default class BitcoinBlock { export default class BitcoinBlock {
constructor ({ version, id, value, prev_block, merkle_root, timestamp, bits, bytes, txn_count, txns }) { constructor ({ version, id, value, prev_block, merkle_root, timestamp, bits, bytes, txn_count, txns, fees }) {
this.isBlock = true this.isBlock = true
this.version = version this.version = version
this.id = id this.id = id
@ -14,7 +14,31 @@ export default class BitcoinBlock {
this.txnCount = txn_count this.txnCount = txn_count
this.txns = txns this.txns = txns
this.coinbase = new BitcoinTx(this.txns[0]) this.coinbase = new BitcoinTx(this.txns[0])
if (fees) {
this.fees = fees + this.coinbase.value
} else {
this.fees = null
}
this.height = this.coinbase.coinbase.height this.height = this.coinbase.coinbase.height
this.miner_sig = this.coinbase.coinbase.sigAscii this.miner_sig = this.coinbase.coinbase.sigAscii
this.total_vbytes = 0
if (this.fees != null) {
this.maxFeerate = 0
this.minFeerate = this.txnCount > 1 ? Infinity : 0
this.avgFeerate = 0
this.txns.forEach(txn => {
if (!BitcoinTx.prototype.isCoinbase(txn)) {
if (txn.fee <= 0) console.log(txn)
const txFeerate = txn.fee / txn.vbytes
this.maxFeerate = Math.max(this.maxFeerate, txFeerate)
this.minFeerate = Math.min(this.minFeerate, txFeerate)
this.avgFeerate += (txn.feerate / this.txnCount)
}
this.total_vbytes += txn.vbytes
})
this.avgFeerate = this.fees / this.total_vbytes
}
} }
} }

View File

@ -1,17 +1,9 @@
import TxView from './TxView.js' import TxView from './TxView.js'
import config from '../config.js' import config from '../config.js'
import { mixColor, pink, bluegreen, orange, teal, green, purple } from '../utils/color.js'
const highlightColor = {
h: 0.03,
l: 0.35
}
const hoverColor = {
h: 0.4,
l: 0.42
}
export default class BitcoinTx { export default class BitcoinTx {
constructor ({ version, id, value, vbytes, inputs, outputs, time, block }, vertexArray) { constructor ({ version, id, value, fee, vbytes, inputs, outputs, time, block }, vertexArray) {
this.version = version this.version = version
this.id = id this.id = id
this.vertexArray = vertexArray this.vertexArray = vertexArray
@ -21,8 +13,11 @@ export default class BitcoinTx {
this.inputs = inputs this.inputs = inputs
this.outputs = outputs this.outputs = outputs
this.value = value this.value = value
this.fee = fee
this.vbytes = vbytes this.vbytes = vbytes
if (this.fee != null) this.feerate = fee / vbytes
if (inputs && outputs && value == null) { if (inputs && outputs && value == null) {
this.value = this.calcValue() this.value = this.calcValue()
} }
@ -31,8 +26,35 @@ export default class BitcoinTx {
this.highlight = false this.highlight = false
// is a coinbase transaction? // is a coinbase transaction?
if (this.inputs && this.inputs.length === 1 && this.inputs[0].prev_txid === "0000000000000000000000000000000000000000000000000000000000000000") { this.coinbase = this.isCoinbase(this)
const cbInfo = this.inputs[0].script_sig if (this.coinbase) {
this.fee = null
this.feerate = null
}
this.setBlock(block)
const feeColor = (this.feerate == null
? orange
: mixColor(teal, purple, 1, Math.log2(64), Math.log2(this.feerate))
)
this.colors = {
age: {
block: { color: orange },
pool: { color: orange, endColor: teal, duration: 60000 },
},
fee: {
block: { color: feeColor },
pool: { color: feeColor },
}
}
this.view = new TxView(this)
}
isCoinbase (txn) {
if (txn.inputs && txn.inputs.length === 1 && txn.inputs[0].prev_txid === "0000000000000000000000000000000000000000000000000000000000000000") {
const cbInfo = txn.inputs[0].script_sig
// number of bytes encoding the block height // number of bytes encoding the block height
const height_bytes = parseInt(cbInfo.substring(0,2), 16) const height_bytes = parseInt(cbInfo.substring(0,2), 16)
// extract the specified number of bytes, reverse the endianness (reverse pairs of hex characters), parse as a hex string // extract the specified number of bytes, reverse the endianness (reverse pairs of hex characters), parse as a hex string
@ -42,15 +64,13 @@ export default class BitcoinTx {
const sigAscii = sig.match(/../g).reduce((parsed, hexChar) => { const sigAscii = sig.match(/../g).reduce((parsed, hexChar) => {
return parsed + String.fromCharCode(parseInt(hexChar, 16)) return parsed + String.fromCharCode(parseInt(hexChar, 16))
}, "") }, "")
this.coinbase = {
return {
height, height,
sig, sig,
sigAscii sigAscii
} }
} } else return false
this.setBlock(block)
this.view = new TxView(this)
} }
destroy () { destroy () {
@ -70,7 +90,15 @@ export default class BitcoinTx {
this.state = this.block ? 'block' : 'pool' this.state = this.block ? 'block' : 'pool'
} }
hoverOn (color = hoverColor) { onEnterScene () {
this.enteredTime = performance.now()
}
getColor (scene, mode) {
return this.colors[mode][scene]
}
hoverOn (color = bluegreen) {
if (this.view) this.view.setHover(true, color) if (this.view) this.view.setHover(true, color)
} }
@ -78,7 +106,7 @@ export default class BitcoinTx {
if (this.view) this.view.setHover(false) if (this.view) this.view.setHover(false)
} }
highlightOn (color = highlightColor) { highlightOn (color = pink) {
if (this.view) this.view.setHighlight(true, color) if (this.view) this.view.setHighlight(true, color)
this.highlight = true this.highlight = true
} }
@ -104,6 +132,6 @@ export default class BitcoinTx {
}) })
} }
}) })
this.view.setHighlight(this.highlight, color || highlightColor) this.view.setHighlight(this.highlight, color || pink)
} }
} }

View File

@ -1,8 +1,8 @@
import TxMondrianPoolScene from './TxMondrianPoolScene.js' import TxMondrianPoolScene from './TxMondrianPoolScene.js'
export default class TxBlockScene extends TxMondrianPoolScene { export default class TxBlockScene extends TxMondrianPoolScene {
constructor ({ width, height, unit = 4, padding = 1, blockId, controller }) { constructor ({ width, height, unit = 4, padding = 1, blockId, controller, heightStore, colorMode }) {
super({ width, height, unit, padding, controller }) super({ width, height, unit, padding, controller, heightStore, colorMode })
this.heightLimit = null this.heightLimit = null
this.expired = false this.expired = false
this.laidOut = false this.laidOut = false
@ -10,6 +10,7 @@ export default class TxBlockScene extends TxMondrianPoolScene {
this.initialised = true this.initialised = true
this.inverted = true this.inverted = true
this.hidden = false this.hidden = false
this.sceneType = 'block'
} }
resize ({ width = this.width, height = this.height }) { resize ({ width = this.width, height = this.height }) {
@ -55,7 +56,7 @@ export default class TxBlockScene extends TxMondrianPoolScene {
y: -(Math.random() * window.innerWidth) - (this.scene.offset.y * 2) - pixelPosition.r, y: -(Math.random() * window.innerWidth) - (this.scene.offset.y * 2) - pixelPosition.r,
r: pixelPosition.r r: pixelPosition.r
}, },
color: this.defaultColor color: tx.getColor('block', this.colorMode).color
}, },
delay: 0, delay: 0,
state: 'ready' state: 'ready'
@ -67,7 +68,7 @@ export default class TxBlockScene extends TxMondrianPoolScene {
tx.view.update({ tx.view.update({
display: { display: {
position: tx.screenPosition, position: tx.screenPosition,
color: this.defaultColor color: tx.getColor('block', this.colorMode).color
}, },
duration: 0, duration: 0,
delay: 0, delay: 0,
@ -77,7 +78,7 @@ export default class TxBlockScene extends TxMondrianPoolScene {
tx.view.update({ tx.view.update({
display: { display: {
position: tx.screenPosition, position: tx.screenPosition,
color: this.defaultColor color: tx.getColor('block', this.colorMode).color
}, },
duration: this.laidOut ? 1000 : 2000, duration: this.laidOut ? 1000 : 2000,
delay: 0, delay: 0,
@ -96,7 +97,10 @@ export default class TxBlockScene extends TxMondrianPoolScene {
y: -(Math.random() * window.innerWidth) - (this.scene.offset.y * 2) - tx.pixelPosition.r, y: -(Math.random() * window.innerWidth) - (this.scene.offset.y * 2) - tx.pixelPosition.r,
r: tx.pixelPosition.r r: tx.pixelPosition.r
}, },
color: this.defaultColor color: {
...tx.getColor('block', this.colorMode).color,
alpha: 1
}
}, },
delay: 0, delay: 0,
state: 'ready' state: 'ready'
@ -104,7 +108,7 @@ export default class TxBlockScene extends TxMondrianPoolScene {
} }
tx.view.update({ tx.view.update({
display: { display: {
color: this.defaultColor color: tx.getColor('block', this.colorMode).color
}, },
duration: 2000, duration: 2000,
delay: 0 delay: 0
@ -112,7 +116,7 @@ export default class TxBlockScene extends TxMondrianPoolScene {
} }
prepareTx (tx, sequence) { prepareTx (tx, sequence) {
this.prepareTxOnScreen(tx, this.layoutTx(tx, sequence, false)) this.prepareTxOnScreen(tx, this.layoutTx(tx, sequence, 0, false))
} }
hideTx (tx) { hideTx (tx) {

View File

@ -10,8 +10,8 @@ settings.subscribe(v => {
}) })
export default class TxMondrianPoolScene extends TxPoolScene { export default class TxMondrianPoolScene extends TxPoolScene {
constructor ({ width, height, unit, padding, controller, heightStore }) { constructor ({ width, height, unit, padding, controller, heightStore, colorMode }) {
super({ width, height, unit, padding, controller, heightStore }) super({ width, height, unit, padding, controller, heightStore, colorMode })
} }
resize ({ width, height, unit, padding }) { resize ({ width, height, unit, padding }) {

View File

@ -1,50 +0,0 @@
/*
Interface definition for TxPackingScene type classes
*/
export default class TxPackingScene {
constructor ({ width, height }) {
this.init({ width, height })
}
init ({ width, height }) {
this.width = width
this.height = height
this.txs = {}
this.hiddenTxs = {}
this.scene = {
width: width,
height: height,
count: 0
}
}
// insert a transaction into the scene
// bool 'autoLayout' controls whether to calc & update the tx position immediately
insert (tx, autoLayout?) {}
// remove the transaction with the given id, return false if not present
remove (id) {
return !!success
}
// return a list of present transaction ids
getTxList () {
return [
...this.getActiveTxList(),
...this.getHiddenTxList()
...Object.keys(this.hiddenTxs)
]
}
getActiveTxList () {
return Object.keys(this.txs)
}
getHiddenTxList () {
return Object.keys(this.hiddenTxs)
}
// Return a flattened array of all vertex data of active tx sprites
getVertexData () {
return Object.values(this.txs).slice(-1000).flatMap(tx => tx.view.sprite.getVertexData())
}
}

View File

@ -1,5 +0,0 @@
export default class TxPackingSquarez\ {
constructor () {
}
}

View File

@ -1,15 +1,12 @@
import config from '../config.js' import config from '../config.js'
export default class TxPoolScene { export default class TxPoolScene {
constructor ({ width, height, unit, padding, controller, heightStore }) { constructor ({ width, height, unit, padding, controller, heightStore, colorMode }) {
this.colorMode = colorMode || "age"
this.maxHeight = 0 this.maxHeight = 0
this.heightStore = heightStore this.heightStore = heightStore
this.sceneType = 'pool'
this.init({ width, height, unit, padding, controller }) this.init({ width, height, unit, padding, controller })
this.defaultColor = {
h: 0.1809005702490606,
l: 0.47246995247155193,
alpha: 1
}
} }
init ({ width, height, controller }) { init ({ width, height, controller }) {
@ -50,9 +47,41 @@ export default class TxPoolScene {
this.scene.offset.y = (window.innerHeight - (this.blockHeight * this.gridSize)) / 2 this.scene.offset.y = (window.innerHeight - (this.blockHeight * this.gridSize)) / 2
} }
insert (tx, autoLayout=true) { setColorMode (mode) {
this.colorMode = mode
Object.values(this.txs).forEach(tx => {
const txColor = tx.getColor(this.sceneType, mode)
if (txColor.endColor) {
tx.view.update({
display: {
color: txColor.color
},
duration: 0,
delay: 0,
})
tx.view.update({
display: {
color: txColor.endColor
},
start: tx.enteredTime,
duration: txColor.duration,
delay: 0
})
} else {
tx.view.update({
display: {
color: txColor.color
},
duration: 500,
delay: 0,
})
}
})
}
insert (tx, insertDelay, autoLayout=true) {
if (autoLayout) { if (autoLayout) {
this.layoutTx(tx, this.scene.count++) this.layoutTx(tx, this.scene.count++, insertDelay)
this.txs[tx.id] = tx this.txs[tx.id] = tx
} else { } else {
this.hiddenTxs[tx.id] = tx this.hiddenTxs[tx.id] = tx
@ -132,7 +161,7 @@ export default class TxPoolScene {
return 1 return 1
} }
layoutTx (tx, sequence, setOnScreen = true) { layoutTx (tx, sequence, insertDelay, setOnScreen = true) {
const units = this.txSize(tx) const units = this.txSize(tx)
this.place(tx, sequence, units) this.place(tx, sequence, units)
this.saveGridToPixelPosition(tx) this.saveGridToPixelPosition(tx)
@ -148,11 +177,12 @@ export default class TxPoolScene {
if (this.heightStore) this.heightStore.set(this.maxHeight) if (this.heightStore) this.heightStore.set(this.maxHeight)
this.saveGridToPixelPosition(tx) this.saveGridToPixelPosition(tx)
} }
if (setOnScreen) this.setTxOnScreen(tx) if (setOnScreen) this.setTxOnScreen(tx, insertDelay)
} }
setTxOnScreen (tx) { setTxOnScreen (tx, insertDelay=0) {
if (!tx.view.initialised) { if (!tx.view.initialised) {
const txColor = tx.getColor(this.sceneType, this.colorMode)
tx.view.update({ tx.view.update({
display: { display: {
position: this.pixelsToScreen({ position: this.pixelsToScreen({
@ -160,7 +190,10 @@ export default class TxPoolScene {
y: window.innerHeight + 10, y: window.innerHeight + 10,
r: this.unitWidth / 2 r: this.unitWidth / 2
}), }),
color: this.defaultColor color: {
...txColor.color,
alpha: 1
},
}, },
delay: 0, delay: 0,
state: 'ready' state: 'ready'
@ -168,22 +201,22 @@ export default class TxPoolScene {
tx.view.update({ tx.view.update({
display: { display: {
position: this.pixelsToScreen(tx.pixelPosition), position: this.pixelsToScreen(tx.pixelPosition),
color: this.defaultColor color: txColor.color
}, },
duration: 2500, duration: 2500,
delay: 0, delay: insertDelay,
state: 'pool' state: 'pool'
}) })
tx.view.update({ if (txColor.endColor) {
display: { tx.view.update({
color: { display: {
h: 0.4751474394406347, color: txColor.endColor
l: 0.5970385691107944 },
} start: tx.enteredTime,
}, duration: txColor.duration,
duration: 60000, delay: 0
delay: 0 })
}) }
} else { } else {
tx.view.update({ tx.view.update({
display: { display: {

View File

@ -1,4 +1,5 @@
import { writable, derived } from 'svelte/store' import { writable, derived } from 'svelte/store'
import { tweened } from 'svelte/motion';
import { makePollStore } from './utils/pollStore.js' import { makePollStore } from './utils/pollStore.js'
import LocaleCurrency from 'locale-currency' import LocaleCurrency from 'locale-currency'
import { currencies } from './utils/fx.js' import { currencies } from './utils/fx.js'
@ -79,10 +80,9 @@ export const devEvents = writable({
addBlockCallback: null addBlockCallback: null
}) })
export const txQueueLength = createCounter()
export const txCount = createCounter() export const txCount = createCounter()
export const lastBlockId = writable(null) export const lastBlockId = writable(null)
export const mempoolCount = createCounter() export const mempoolCount = tweened(0)
export const mempoolScreenHeight = writable(0) export const mempoolScreenHeight = writable(0)
export const frameRate = writable(null) export const frameRate = writable(null)
export const avgFrameRate = writable(null) export const avgFrameRate = writable(null)
@ -102,10 +102,14 @@ export const settings = createCachedDict('settings', {
currency: localeCurrencyCode, currency: localeCurrencyCode,
showFX: true, showFX: true,
vbytes: false, vbytes: false,
colorByFee: false,
fancyGraphics: true, fancyGraphics: true,
showMessages: true, showMessages: true,
noTrack: false noTrack: false
}) })
export const colorMode = derived([settings], ([$settings]) => {
return $settings.colorByFee ? "fee" : "age"
})
export const devSettings = (config.dev && config.debug) ? createCachedDict('dev-settings', { export const devSettings = (config.dev && config.debug) ? createCachedDict('dev-settings', {
guides: false, guides: false,

26
client/src/utils/color.js Normal file
View File

@ -0,0 +1,26 @@
import { hcl } from 'd3-color'
export function hlToHex ({h, l}) {
return hcl(h * 360, 78.225, l * 150).hex()
}
export function mixColor (startColor, endColor, min, max, value) {
const dx = Math.max(0, Math.min(1, (value - min) / (max - min)))
return {
h: startColor.h + (dx *(endColor.h - startColor.h)),
l: startColor.l + (dx *(endColor.l - startColor.l))
}
}
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 green = { h: 0.37, l: 0.35 }
export const purple = { h: 0.95, l: 0.35 }
export const highlightA = { h: 0.93, l: 0.5 } //pink
export const highlightB = { h: 0.214, l: 0.62 } // green
export const highlightC = { h: 0.30, l: 1.0 } // white
export const highlightD = { h: 0.42, l: 0.35 } // blue
export const highlightE = { h: 0.12, l: 0.375 } // red

View File

@ -10,13 +10,18 @@ export const longBtcFormat = (Intl && Intl.NumberFormat) ? new Intl.NumberFormat
return Number(number).toFixed(8) return Number(number).toFixed(8)
} }
} }
export const feeRateFormat = (Intl && Intl.NumberFormat) ? new Intl.NumberFormat(undefined, { maximumFractionDigits: 2, maximumSignificantDigits: 2 }) : {
format (number) {
return Number(number).toPrecision(2).slice(0,3)
}
}
export const timeFormat = (Intl && Intl.DateTimeFormat) ? new Intl.DateTimeFormat(undefined, { timeStyle: "short"}) : { export const timeFormat = (Intl && Intl.DateTimeFormat) ? new Intl.DateTimeFormat(undefined, { timeStyle: "short"}) : {
format (date) { format (date) {
const d = new Date(date) const d = new Date(date)
return `${('' + d.getHours()).padStart(2, '0')}:${('' + d.getMinutes()).padStart(2, '0')}` return `${('' + d.getHours()).padStart(2, '0')}:${('' + d.getMinutes()).padStart(2, '0')}`
} }
} }
export const integerFormat = (Intl && Intl.NumberFormat) ? new Intl.NumberFormat(undefined) : { export const numberFormat = (Intl && Intl.NumberFormat) ? new Intl.NumberFormat(undefined) : {
format (number) { format (number) {
return Number(number).toLocaleString() return Number(number).toLocaleString()
} }

View File

@ -24,6 +24,7 @@ services:
BITCOIN_HOST: "172.17.0.1" BITCOIN_HOST: "172.17.0.1"
BITCOIN_ZMQ_RAWBLOCK_PORT: "29000" BITCOIN_ZMQ_RAWBLOCK_PORT: "29000"
BITCOIN_ZMQ_RAWTX_PORT: "29001" BITCOIN_ZMQ_RAWTX_PORT: "29001"
BITCOIN_ZMQ_SEQUENCE_PORT: "29002"
BITCOIN_RPC_PORT: "8332" BITCOIN_RPC_PORT: "8332"
BITCOIN_RPC_USER: "bitcoin" BITCOIN_RPC_USER: "bitcoin"
BITCOIN_RPC_PASS: "correcthorsebatterystaple" BITCOIN_RPC_PASS: "correcthorsebatterystaple"

View File

@ -27,6 +27,7 @@ The API server expects the following environment variables to be set:
| BITCOIN_HOST | Bitcoin node host address | | BITCOIN_HOST | Bitcoin node host address |
| BITCOIN_ZMQ_RAWBLOCK_PORT | Bitcoin node ZMQ port for block events (to match `zmqpubrawblock` in bitcoin.conf) | | BITCOIN_ZMQ_RAWBLOCK_PORT | Bitcoin node ZMQ port for block events (to match `zmqpubrawblock` in bitcoin.conf) |
| BITCOIN_ZMQ_RAWTX_PORT | Bitcoin node ZMQ port for transaction events (to match `zmqpubrawtx` in bitcoin.conf) | | BITCOIN_ZMQ_RAWTX_PORT | Bitcoin node ZMQ port for transaction events (to match `zmqpubrawtx` in bitcoin.conf) |
| BITCOIN_ZMQ_SEQUENCE_PORT | Bitcoin node ZMQ port for sequence events (to match `zmqpubsequence` in bitcoin.conf) |
| BITCOIN_RPC_PORT | Bitcoin node RPC port | | BITCOIN_RPC_PORT | Bitcoin node RPC port |
| either | | | either | |
| BITCOIN_RPC_USER | Bitcoin node RPC user | | BITCOIN_RPC_USER | Bitcoin node RPC user |

View File

@ -14,6 +14,12 @@ Environment=LANG=en_US.UTF-8
Environment=PORT=<port> Environment=PORT=<port>
Environment=BITCOIN_RPC_USER=<rpc user> Environment=BITCOIN_RPC_USER=<rpc user>
Environment=BITCOIN_RPC_PASS=<rpc password> Environment=BITCOIN_RPC_PASS=<rpc password>
Environment=BITCOIN_HOST=<rpc host>
Environment=BITCOIN_RPC_PORT=<rpc port>
Environment=BITCOIN_ZMQ_RAWBLOCK_PORT=<zmq rawblock port>
Environment=BITCOIN_ZMQ_RAWTX_PORT=<zmq rawtx port>
Environment=BITCOIN_ZMQ_SEQUENCE_PORT=<zmq sequence port>
WorkingDirectory=<installation root>/server WorkingDirectory=<installation root>/server

0
server/data/.gitkeep Normal file
View File

Binary file not shown.

View File

@ -10,14 +10,16 @@ defmodule BitcoinStream.RPC do
{port, opts} = Keyword.pop(opts, :port); {port, opts} = Keyword.pop(opts, :port);
{host, opts} = Keyword.pop(opts, :host); {host, opts} = Keyword.pop(opts, :host);
IO.puts("Starting Bitcoin RPC server on #{host} port #{port}") IO.puts("Starting Bitcoin RPC server on #{host} port #{port}")
GenServer.start_link(__MODULE__, {host, port, nil}, opts) GenServer.start_link(__MODULE__, {host, port, nil, nil}, opts)
end end
@impl true @impl true
def init(state) do def init({host, port, status, _}) do
# start node monitoring loop # start node monitoring loop
send(self(), :check_status) creds = rpc_creds();
{:ok, state}
send(self(), :check_status);
{:ok, {host, port, status, creds}}
end end
def handle_info(:check_status, state) do def handle_info(:check_status, state) do
@ -28,28 +30,28 @@ defmodule BitcoinStream.RPC do
end end
@impl true @impl true
def handle_call({:request, method, params}, _from, {host, port, status}) do def handle_call({:request, method, params}, _from, {host, port, status, creds}) do
case make_request(host, port, method, params) do case make_request(host, port, creds, method, params) do
{:ok, info} -> {:ok, code, info} ->
{:reply, {:ok, info}, {host, port, status}} {:reply, {:ok, code, info}, {host, port, status, creds}}
{:error, reason} -> {:error, reason} ->
{:reply, {:error, reason}, {host, port, status}} {:reply, {:error, reason}, {host, port, status, creds}}
end end
end end
@impl true @impl true
def handle_call({:get_node_status}, _from, {host, port, status}) do def handle_call({:get_node_status}, _from, {host, port, status, creds}) do
{:reply, {:ok, status}, {host, port, status}} {:reply, {:ok, status}, {host, port, status, creds}}
end end
defp make_request(host, port, method, params) do defp make_request(host, port, creds, method, params) do
with { user, pw } <- rpc_creds(), with { user, pw } <- creds,
{:ok, rpc_request} <- Jason.encode(%{method: method, params: params}), {:ok, rpc_request} <- Jason.encode(%{method: method, params: params}),
{:ok, 200, _headers, body_ref} <- :hackney.request(:post, "http://#{host}:#{port}", [{"content-type", "application/json"}], rpc_request, [basic_auth: { user, pw }]), {:ok, code, _headers, body_ref} <- :hackney.request(:post, "http://#{host}:#{port}", [{"content-type", "application/json"}], rpc_request, [basic_auth: { user, pw }]),
{:ok, body} <- :hackney.body(body_ref), {:ok, body} <- :hackney.body(body_ref),
{:ok, %{"result" => info}} <- Jason.decode(body) do {:ok, %{"result" => info}} <- Jason.decode(body) do
{:ok, info} {:ok, code, info}
else else
{:ok, code, _} -> {:ok, code, _} ->
IO.puts("RPC request #{method} failed with HTTP code #{code}") IO.puts("RPC request #{method} failed with HTTP code #{code}")
@ -66,7 +68,6 @@ defmodule BitcoinStream.RPC do
end end
def request(pid, method, params) do def request(pid, method, params) do
IO.inspect({pid, method, params});
GenServer.call(pid, {:request, method, params}, 60000) GenServer.call(pid, {:request, method, params}, 60000)
catch catch
:exit, reason -> :exit, reason ->
@ -78,18 +79,18 @@ defmodule BitcoinStream.RPC do
GenServer.call(pid, {:get_node_status}) GenServer.call(pid, {:get_node_status})
end end
def check_status({host, port, status}) do def check_status({host, port, status, creds}) do
with {:ok, info} <- make_request(host, port, "getblockchaininfo", []) do with {:ok, 200, info} <- make_request(host, port, creds, "getblockchaininfo", []) do
{host, port, info} {host, port, info, creds}
else else
{:error, reason} -> {:error, reason} ->
IO.puts("node status check failed"); IO.puts("node status check failed");
IO.inspect(reason) IO.inspect(reason)
{host, port, status} {host, port, status, creds}
err -> err ->
IO.puts("node status check failed: (unknown reason)"); IO.puts("node status check failed: (unknown reason)");
IO.inspect(err); IO.inspect(err);
{host, port, status} {host, port, status, creds}
end end
end end

View File

@ -1,3 +1,5 @@
Application.ensure_all_started(BitcoinStream.RPC)
defmodule BitcoinStream.BlockData do defmodule BitcoinStream.BlockData do
@moduledoc """ @moduledoc """
Block data module. Block data module.
@ -10,12 +12,11 @@ defmodule BitcoinStream.BlockData do
def start_link(opts) do def start_link(opts) do
IO.puts("Starting block data link") IO.puts("Starting block data link")
# load block.dat # load block
with {:ok, block_data} <- File.read("data/block.dat"), with {:ok, json} <- File.read("data/last_block.json"),
{:ok, block} <- BitcoinBlock.decode(block_data), {:ok, %{"id" => id}} <- Jason.decode(json) do
{:ok, payload} <- Jason.encode(%{type: "block", block: block}) do GenServer.start_link(__MODULE__, {id, json}, opts)
GenServer.start_link(__MODULE__, {block, payload}, opts)
else else
_ -> GenServer.start_link(__MODULE__, {nil, "null"}, opts) _ -> GenServer.start_link(__MODULE__, {nil, "null"}, opts)
end end
@ -27,41 +28,17 @@ defmodule BitcoinStream.BlockData do
end end
@impl true @impl true
def handle_call(:block_id, _from, {block, json}) do def handle_call(:block_id, _from, {id, json}) do
case block do {:reply, id, {id, json}}
%{id: id} ->
{:reply, id, {block, json}}
_ -> {:reply, nil, {block, json}}
end
end end
@impl true @impl true
def handle_call(:block, _from, {block, json}) do def handle_call(:json_block, _from, {id, json}) do
{:reply, block, {block, json}} {:reply, json, {id, json}}
end end
@impl true @impl true
def handle_call(:json_block, _from, {block, json}) do def handle_cast({:json, {id, json}}, _state) do
{:reply, json, {block, json}} {:noreply, {id, json}}
end end
@impl true
def handle_cast({:block, block}, state) do
with {:ok, json } <- Jason.encode(%{type: "block", block: block}) do
IO.puts("Storing block data");
{:noreply, {block, json}}
else
{:err, reason} ->
IO.puts("Failed to json encode block data");
IO.inspect(reason);
{:noreply, state}
_ ->
IO.puts("Failed to json encode block data");
{:noreply, state}
end
end
def get_block_number() do
end
end end

View File

@ -12,17 +12,21 @@ defmodule BitcoinStream.Bridge do
alias BitcoinStream.Mempool, as: Mempool alias BitcoinStream.Mempool, as: Mempool
alias BitcoinStream.RPC, as: RPC alias BitcoinStream.RPC, as: RPC
def child_spec(host: host, tx_port: tx_port, block_port: block_port) do def child_spec(host: host, tx_port: tx_port, block_port: block_port, sequence_port: sequence_port) do
%{ %{
id: BitcoinStream.Bridge, id: BitcoinStream.Bridge,
start: {BitcoinStream.Bridge, :start_link, [host, tx_port, block_port]} start: {BitcoinStream.Bridge, :start_link, [host, tx_port, block_port, sequence_port]}
} }
end end
def start_link(host, tx_port, block_port) do def start_link(host, tx_port, block_port, sequence_port) do
IO.puts("Starting Bitcoin bridge on #{host} ports #{tx_port}, #{block_port}") IO.puts("Starting Bitcoin bridge on #{host} ports #{tx_port}, #{block_port}, #{sequence_port}")
IO.puts("Mempool loaded, ready to syncronize");
Task.start(fn -> connect_tx(host, tx_port) end); Task.start(fn -> connect_tx(host, tx_port) end);
Task.start(fn -> connect_block(host, block_port) end); Task.start(fn -> connect_block(host, block_port) end);
Task.start(fn -> connect_sequence(host, sequence_port) end);
:timer.sleep(2000);
Task.start(fn -> Mempool.sync(:mempool) end);
GenServer.start_link(__MODULE__, %{}) GenServer.start_link(__MODULE__, %{})
end end
@ -48,7 +52,7 @@ defmodule BitcoinStream.Bridge do
end end
# start tx loop # start tx loop
tx_loop(socket) tx_loop(socket, 0)
end end
defp connect_block(host, port) do defp connect_block(host, port) do
@ -57,9 +61,6 @@ defmodule BitcoinStream.Bridge do
wait_for_ibd(); wait_for_ibd();
IO.puts("Node is fully synced, connecting to block socket"); IO.puts("Node is fully synced, connecting to block socket");
# sync mempool
Mempool.sync(:mempool);
# connect to socket # connect to socket
{:ok, socket} = :chumak.socket(:sub); {:ok, socket} = :chumak.socket(:sub);
IO.puts("Connected block zmq socket on #{host} port #{port}"); IO.puts("Connected block zmq socket on #{host} port #{port}");
@ -72,7 +73,28 @@ defmodule BitcoinStream.Bridge do
end end
# start block loop # start block loop
block_loop(socket) block_loop(socket, 0)
end
defp connect_sequence(host, port) do
# check rpc online & synced
IO.puts("Waiting for node to come online and fully sync before connecting to sequence socket");
wait_for_ibd();
IO.puts("Node is fully synced, connecting to sequence socket");
# connect to socket
{:ok, socket} = :chumak.socket(:sub);
IO.puts("Connected sequence zmq socket on #{host} port #{port}");
:chumak.subscribe(socket, 'sequence')
IO.puts("Subscribed to sequence events")
case :chumak.connect(socket, :tcp, String.to_charlist(host), port) do
{:ok, pid} -> IO.puts("Binding ok to sequence socket pid #{inspect pid}");
{:error, reason} -> IO.puts("Binding sequence socket failed: #{reason}");
_ -> IO.puts("???");
end
# start tx loop
sequence_loop(socket)
end end
defp wait_for_ibd() do defp wait_for_ibd() do
@ -84,9 +106,9 @@ defmodule BitcoinStream.Bridge do
end end
end end
defp sendTxn(txn) do defp send_txn(txn, count) do
# IO.puts("Forwarding transaction to websocket clients") # IO.puts("Forwarding transaction to websocket clients")
case Jason.encode(%{type: "txn", txn: txn}) do case Jason.encode(%{type: "txn", txn: txn, count: count}) do
{:ok, payload} -> {:ok, payload} ->
Registry.dispatch(Registry.BitcoinStream, "txs", fn(entries) -> Registry.dispatch(Registry.BitcoinStream, "txs", fn(entries) ->
for {pid, _} <- entries do for {pid, _} <- entries do
@ -97,12 +119,8 @@ defmodule BitcoinStream.Bridge do
end end
end end
defp incrementMempool() do defp send_block(block, count) do
Mempool.increment(:mempool) case Jason.encode(%{type: "block", block: %{id: block.id}, drop: count}) do
end
defp sendBlock(block) do
case Jason.encode(%{type: "block", block: block}) do
{:ok, payload} -> {:ok, payload} ->
Registry.dispatch(Registry.BitcoinStream, "txs", fn(entries) -> Registry.dispatch(Registry.BitcoinStream, "txs", fn(entries) ->
for {pid, _} <- entries do for {pid, _} <- entries do
@ -114,37 +132,112 @@ defmodule BitcoinStream.Bridge do
end end
end end
defp tx_loop(socket) do defp send_drop_tx(txid, count) do
# IO.puts("client tx loop"); case Jason.encode(%{type: "drop", txid: txid, count: count}) do
with {:ok, message} <- :chumak.recv_multipart(socket), {:ok, payload} ->
[_topic, payload, _size] <- message, Registry.dispatch(Registry.BitcoinStream, "txs", fn(entries) ->
{:ok, txn} <- BitcoinTx.decode(payload) do for {pid, _} <- entries do
sendTxn(txn); Process.send(pid, payload, []);
incrementMempool(); end
else end)
{:error, reason} -> IO.puts("Bitcoin node transaction feed bridge error: #{reason}"); {:error, reason} -> IO.puts("Error json encoding drop message: #{reason}");
_ -> IO.puts("Bitcoin node transaction feed bridge error (unknown reason)");
end end
tx_loop(socket)
end end
defp block_loop(socket) do defp tx_process(payload) do
IO.puts("client block loop"); case BitcoinTx.decode(payload) do
with {:ok, message} <- :chumak.recv_multipart(socket), {:ok, txn} ->
[_topic, payload, _size] <- message, case Mempool.get_tx_status(:mempool, txn.id) do
:ok <- File.write("data/block.dat", payload, [:binary]), # :registered and :new transactions are inflated and inserted into the mempool
{:ok, block} <- BitcoinBlock.decode(payload) do status when (status in [:registered, :new]) ->
GenServer.cast(:block_data, {:block, block}) inflated_txn = BitcoinTx.inflate(txn);
sendBlock(block); case Mempool.insert(:mempool, txn.id, inflated_txn) do
Mempool.sync(:mempool); # Mempool.insert returns the size of the mempool if insertion was successful
IO.puts("new block") # Forward tx to clients in this case
else count when is_integer(count) -> send_txn(inflated_txn, count)
{:error, reason} -> IO.puts("Bitcoin node block feed bridge error: #{reason}");
_ -> IO.puts("Bitcoin node block feed bridge error (unknown reason)");
end
block_loop(socket) _ -> false
end
# other statuses indicate duplicate or dropped transaction
_ -> false
end
{:err, reason} ->
IO.puts("Error decoding tx: #{reason}");
false
error ->
IO.puts("Error decoding tx");
IO.inspect(error);
false
end
end
defp tx_loop(socket, seq) do
with {:ok, message} <- :chumak.recv_multipart(socket),
[_topic, payload, <<sequence::little-size(32)>>] <- message,
true <- (seq != sequence) do
Task.start(fn -> tx_process(payload) end);
tx_loop(socket, sequence)
else
_ -> tx_loop(socket, seq)
end
end
defp block_loop(socket, seq) do
IO.puts("client block loop");
with {:ok, message} <- :chumak.recv_multipart(socket), # wait for the next zmq message in the queue
[_topic, payload, <<sequence::little-size(32)>>] <- message,
true <- (seq != sequence), # discard contiguous duplicate messages
_ <- IO.puts("block received"),
{:ok, block} <- BitcoinBlock.decode(payload),
count <- Mempool.clear_block_txs(:mempool, block),
{:ok, json} <- Jason.encode(block),
:ok <- File.write("data/last_block.json", json) do
IO.puts("processed block #{block.id}");
GenServer.cast(:block_data, {:json, { block.id, json }});
send_block(block, count);
block_loop(socket, sequence)
else
_ -> block_loop(socket, seq)
end
end
defp sequence_loop(socket) do
with {:ok, message} <- :chumak.recv_multipart(socket),
[_topic, <<hash::binary-size(32), type::binary-size(1), seq::little-size(64)>>, <<_sequence::little-size(32)>>] <- message,
txid <- Base.encode16(hash, case: :lower),
event <- to_charlist(type) do
# IO.puts("loop #{sequence}");
case event do
# Transaction added to mempool
'A' ->
case Mempool.register(:mempool, txid, seq, true) do
false -> false
{ txn, count } ->
# IO.puts("*SEQ* #{txid}");
send_txn(txn, count)
end
# Transaction removed from mempool for non block inclusion reason
'R' ->
case Mempool.drop(:mempool, txid) do
count when is_integer(count) ->
send_drop_tx(txid, count);
_ ->
true
end
# Don't care about other events
_ -> true
end
else
_ -> false
end
sequence_loop(socket)
end end
end end

View File

@ -6,6 +6,8 @@ defmodule BitcoinStream.ExometerReportDash do
@impl true @impl true
def exometer_init(opts) do def exometer_init(opts) do
IO.puts("Initialising dashboard exometer reporter") IO.puts("Initialising dashboard exometer reporter")
Registry.BitcoinStream
|> Registry.register("metrics", {})
{:ok, opts} {:ok, opts}
end end

View File

@ -1,9 +1,20 @@
defmodule BitcoinStream.Mempool do defmodule BitcoinStream.Mempool do
@moduledoc """ @moduledoc """
Agent for retrieving and maintaining mempool info (primarily tx count) Agent for retrieving and maintaining mempool info
Used for tracking mempool count, and maintaining an :ets cache of transaction prevouts
Transaction lifecycle:
Register -> a ZMQ sequence 'A' message received with this txid
Insert -> a ZMQ rawtx message is received
Drop -> EITHER a ZMQ sequence 'R' message is received
-> OR the transaction is included in a ZMQ rawblock message
ZMQ 'A' and 'R' messages are guaranteed to arrive in order relative to each other
but rawtx and rawblock messages may arrive in any order
""" """
use Agent use Agent
alias BitcoinStream.Protocol.Transaction, as: BitcoinTx
alias BitcoinStream.RPC, as: RPC alias BitcoinStream.RPC, as: RPC
@doc """ @doc """
@ -12,13 +23,14 @@ defmodule BitcoinStream.Mempool do
""" """
def start_link(opts) do def start_link(opts) do
IO.puts("Starting mempool agent"); IO.puts("Starting mempool agent");
case Agent.start_link(fn -> %{count: 0} end, opts) do # cache of all transactions in the node mempool, mapped to {inputs, total_input_value}
{:ok, pid} -> :ets.new(:mempool_cache, [:set, :public, :named_table]);
sync(pid); # cache of transactions ids in the mempool, but not yet synchronized with the :mempool_cache
{:ok, pid} :ets.new(:sync_cache, [:set, :public, :named_table]);
# cache of transaction ids included in the last block
result -> result # used to avoid allowing confirmed transactions back into the mempool if rawtx events arrive late
end :ets.new(:block_cache, [:set, :public, :named_table]);
Agent.start_link(fn -> %{count: 0, seq: :infinity, queue: [], done: false} end, opts)
end end
def set(pid, n) do def set(pid, n) do
@ -29,27 +41,223 @@ defmodule BitcoinStream.Mempool do
Agent.get(pid, &Map.get(&1, :count)) Agent.get(pid, &Map.get(&1, :count))
end end
def increment(pid) do defp increment(pid) do
Agent.update(pid, &Map.update(&1, :count, 0, fn(x) -> x + 1 end)) Agent.update(pid, &Map.update(&1, :count, 0, fn(x) -> x + 1 end));
end end
def decrement(pid) do defp decrement(pid) do
Agent.update(pid, &Map.update(&1, :count, 0, fn(x) -> x - 1 end)) Agent.update(pid, &Map.update(&1, :count, 0, fn(x) -> x - 1 end));
end end
def add(pid, n) do defp get_seq(pid) do
Agent.update(pid, &Map.update(&1, :count, 0, fn(x) -> x + n end)) Agent.get(pid, &Map.get(&1, :seq))
end end
def subtract(pid, n) do defp set_seq(pid, seq) do
Agent.update(pid, &Map.update(&1, :count, 0, fn(x) -> x - n end)) Agent.update(pid, &Map.put(&1, :seq, seq))
end
defp get_queue(pid) do
Agent.get(pid, &Map.get(&1, :queue))
end
defp set_queue(pid, queue) do
Agent.update(pid, &Map.put(&1, :queue, queue))
end
defp enqueue(pid, txid) do
Agent.update(pid, &Map.update(&1, :queue, [], fn(q) -> [txid | q] end))
end
def is_done(pid) do
Agent.get(pid, &Map.get(&1, :done))
end
defp set_done(pid) do
Agent.update(pid, &Map.put(&1, :done, true))
end
def get_tx_status(_pid, txid) do
case :ets.lookup(:mempool_cache, txid) do
# new transaction, not yet registered
[] ->
case :ets.lookup(:block_cache, txid) do
# not included in the last block
[] -> :new
# already included in the last block
[_] -> :block
end
# new transaction, id already registered
[{_txid, nil, :ready}] ->
:registered
# already dropped
[{_, nil, :drop}] ->
:dropped
# duplicate (ignore)
[_] ->
:duplicate
end
end
def insert(pid, txid, txn) do
case get_tx_status(pid, txid) do
# new transaction, id already registered
: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 }, nil});
get(pid)
else
_ -> false
end
# new transaction, not yet registered
:new ->
:ets.insert(:mempool_cache, {txid, nil, txn})
false
# new transaction, already included in the last block
:block -> false
# already dropped
:dropped ->
:ets.delete(:mempool_cache, txid);
false
# duplicate (ignore)
:duplicate ->
false
end
end
def register(pid, txid, sequence, do_count) do
cond do
# mempool isn't loaded yet - add this tx to the queue
(get_seq(pid) == :infinity) ->
enqueue(pid, {txid, sequence});
false
((sequence == nil) or (sequence >= get_seq(pid))) ->
case :ets.lookup(:mempool_cache, txid) do
# new transaction
[] ->
with [] <- :ets.lookup(:block_cache, txid) do # double check tx isn't included in the last block
:ets.insert(:mempool_cache, {txid, nil, :ready});
:ets.delete(:sync_cache, txid);
if do_count do
increment(pid);
end
else
_ -> false;
end
false
# duplicate sequence message (should never happen)
[{_txid, _, :ready}] -> false
# already dropped
[{_txid, _, :drop}] -> false
# data already received, but tx not registered
[{_txid, _, txn}] ->
:ets.insert(:mempool_cache, {txid, { txn.inputs, txn.value + txn.fee }, nil});
:ets.delete(:sync_cache, txid);
if do_count do
increment(pid);
end
{txn, get(pid)}
# some other invalid state (should never happen)
[_] -> false
end
true -> false
end
end
def drop(pid, txid) do
case :ets.lookup(:mempool_cache, txid) do
# tx not yet registered
[] ->
case :ets.lookup(:sync_cache, txid) do
[] -> false
# tx is in the mempool sync cache, mark to be dropped when processed
_ ->
:ets.insert(:mempool_cache, {txid, nil, :drop});
decrement(pid);
get(pid)
end
# already marked as dropped (should never happen)
[{_txid, nil, :drop}] -> false
# tx registered but not processed, mark to be dropped
[{_txid, nil, :ready}] ->
:ets.insert(:mempool_cache, {txid, nil, :drop});
decrement(pid);
false
# tx data cached but not registered and not already dropped
[{_txid, nil, status}] when status != nil ->
case :ets.lookup(:sync_cache, txid) do
[] -> true;
_ -> decrement(pid);
end
:ets.delete(:mempool_cache, txid);
get(pid)
# tx fully processed and not already dropped
[{_txid, data, _status}] when data != nil ->
:ets.delete(:mempool_cache, txid);
decrement(pid);
get(pid)
_ -> false
end
end
defp send_mempool_count(pid) do
count = get(pid)
case Jason.encode(%{type: "count", count: count}) do
{:ok, payload} ->
Registry.dispatch(Registry.BitcoinStream, "txs", fn(entries) ->
for {pid, _} <- entries do
Process.send(pid, payload, []);
end
end)
{:error, reason} -> IO.puts("Error json encoding count: #{reason}");
end
end
defp sync_queue(_pid, []) do
true
end
defp sync_queue(pid, [{txid, sequence} | tail]) do
register(pid, txid, sequence, true);
sync_queue(pid, tail)
end end
def sync(pid) do def sync(pid) do
IO.puts("Syncing mempool"); IO.puts("Preparing mempool sync");
with {:ok, %{"size" => pool_size}} <- RPC.request(:rpc, "getmempoolinfo", []) do with {:ok, 200, %{"mempool_sequence" => sequence, "txids" => txns}} <- RPC.request(:rpc, "getrawmempool", [false, true]) do
IO.puts("Synced pool: size = #{pool_size}"); set_seq(pid, sequence);
set(pid, pool_size) count = length(txns);
set(pid, count);
cache_sync_ids(pid, txns);
# handle queue accumulated while loading the mempool
queue = get_queue(pid);
sync_queue(pid, queue);
set_queue(pid, []);
IO.puts("Loaded #{count} mempool transactions");
send_mempool_count(pid);
do_sync(pid, txns)
else else
{:error, reason} -> {:error, reason} ->
IO.puts("Pool sync failed"); IO.puts("Pool sync failed");
@ -62,4 +270,84 @@ defmodule BitcoinStream.Mempool do
end end
end end
def do_sync(pid, txns) do
IO.puts("Syncing #{length(txns)} mempool transactions");
sync_mempool(pid, txns);
IO.puts("MEMPOOL SYNC FINISHED");
set_done(pid);
IO.inspect(is_done(pid))
end
defp sync_mempool(pid, txns) do
sync_mempool_txns(pid, txns, 0)
end
defp sync_mempool_txn(pid, txid) do
case :ets.lookup(:mempool_cache, txid) do
[] ->
with {:ok, 200, hextx} <- RPC.request(:rpc, "getrawtransaction", [txid]),
rawtx <- Base.decode16!(hextx, case: :lower),
{:ok, txn } <- BitcoinTx.decode(rawtx),
inflated_txn <- BitcoinTx.inflate(txn) do
register(pid, txid, nil, false);
insert(pid, txid, inflated_txn)
else
_ -> IO.puts("sync_mempool_txn failed #{txid}")
end
[_] -> true
end
end
defp sync_mempool_txns(_, [], count) do
count
end
defp sync_mempool_txns(pid, [head | tail], count) do
IO.puts("Syncing mempool tx #{count}/#{count + length(tail) + 1} | #{head}");
sync_mempool_txn(pid, head);
sync_mempool_txns(pid, tail, count + 1)
end
defp cache_sync_ids(pid, txns) do
:ets.delete_all_objects(:sync_cache);
cache_sync_ids(pid, txns, 0)
end
defp cache_sync_ids(pid, [head | tail], cached) do
:ets.insert(:sync_cache, {head, true});
cache_sync_ids(pid, tail, cached + 1)
end
defp cache_sync_ids(_pid, [], cached) do
cached
end
def clear_block_txs(pid, block) do
:ets.delete_all_objects(:block_cache)
clear_block_txs(pid, block.txns, 0)
end
# clear confirmed transactions
# return the total number removed from the mempool
# i.e. the amount by which to decrement the mempool counter
defp clear_block_txs(pid, [], _cleared) do
get(pid)
end
defp clear_block_txs(pid, [head | tail], cleared) do
:ets.insert(:block_cache, {head.id, true})
if drop(pid, head.id) do # tx was in the mempool
clear_block_txs(pid, tail, cleared + 1)
else
case :ets.lookup(:sync_cache, head.id) do
# tx was not in the mempool nor queued for processing
[] -> clear_block_txs(pid, tail, cleared)
# tx was not in the mempool, but is queued for processing
_ -> clear_block_txs(pid, tail, cleared + 1)
end
end
end
end end

View File

@ -1,3 +1,5 @@
Application.ensure_all_started(BitcoinStream.RPC)
defmodule BitcoinStream.Protocol.Block do defmodule BitcoinStream.Protocol.Block do
@moduledoc """ @moduledoc """
Summarised bitcoin block. Summarised bitcoin block.
@ -6,8 +8,8 @@ defmodule BitcoinStream.Protocol.Block do
and condensing transactions into only id, value and version and condensing transactions into only id, value and version
""" """
alias BitcoinStream.Protocol.Block
alias BitcoinStream.Protocol.Transaction, as: BitcoinTx alias BitcoinStream.Protocol.Transaction, as: BitcoinTx
alias BitcoinStream.Mempool, as: Mempool
@derive Jason.Encoder @derive Jason.Encoder
defstruct [ defstruct [
@ -20,6 +22,7 @@ defstruct [
:nonce, :nonce,
:txn_count, :txn_count,
:txns, :txns,
:fees,
:value, :value,
:id :id
] ]
@ -29,7 +32,7 @@ def decode(block_binary) do
hex <- Base.encode16(block_binary, case: :lower), hex <- Base.encode16(block_binary, case: :lower),
{:ok, raw_block} <- Bitcoinex.Block.decode(hex), {:ok, raw_block} <- Bitcoinex.Block.decode(hex),
id <- Bitcoinex.Block.block_id(block_binary), id <- Bitcoinex.Block.block_id(block_binary),
{summarised_txns, total_value} <- summarise_txns(raw_block.txns) {summarised_txns, total_value, total_fees} <- summarise_txns(raw_block.txns)
do do
{:ok, %__MODULE__{ {:ok, %__MODULE__{
version: raw_block.version, version: raw_block.version,
@ -40,6 +43,7 @@ def decode(block_binary) do
bytes: bytes, bytes: bytes,
txn_count: raw_block.txn_count, txn_count: raw_block.txn_count,
txns: summarised_txns, txns: summarised_txns,
fees: total_fees,
value: total_value, value: total_value,
id: id id: id
}} }}
@ -54,24 +58,28 @@ def decode(block_binary) do
end end
defp summarise_txns(txns) do defp summarise_txns(txns) do
summarise_txns(txns, [], 0) # Mempool.is_done returns false while the mempool is still syncing
if Mempool.is_done(:mempool) do
summarise_txns(txns, [], 0, 0, true)
else
summarise_txns(txns, [], 0, 0, false)
end
end end
defp summarise_txns([], summarised, total) do defp summarise_txns([], summarised, total, fees, _do_inflate) do
{Enum.reverse(summarised), total} {Enum.reverse(summarised), total, fees}
end end
defp summarise_txns([next | rest], summarised, total) do defp summarise_txns([next | rest], summarised, total, fees, do_inflate) do
extended_txn = BitcoinTx.extend(next) extended_txn = BitcoinTx.extend(next)
summarise_txns(rest, [extended_txn | summarised], total + extended_txn.value) # if the mempool is still syncing, inflating txs will take too long, so skip it
end if do_inflate do
inflated_txn = BitcoinTx.inflate(extended_txn)
def test() do summarise_txns(rest, [inflated_txn | summarised], total + inflated_txn.value, fees + inflated_txn.fee, true)
raw_block = File.read!("data/block.dat") else
{:ok, block} = Block.decode(raw_block) summarise_txns(rest, [extended_txn | summarised], nil, nil, false)
end
block
end end
end end

View File

@ -1,3 +1,5 @@
Application.ensure_all_started(BitcoinStream.RPC)
defmodule BitcoinStream.Protocol.Transaction do defmodule BitcoinStream.Protocol.Transaction do
@moduledoc """ @moduledoc """
Extended bitcoin transaction struct Extended bitcoin transaction struct
@ -11,6 +13,8 @@ defmodule BitcoinStream.Protocol.Transaction do
BitcoinStream.Protocol.Transaction BitcoinStream.Protocol.Transaction
alias BitcoinStream.RPC, as: RPC
@derive Jason.Encoder @derive Jason.Encoder
defstruct [ defstruct [
:version, :version,
@ -18,6 +22,7 @@ defmodule BitcoinStream.Protocol.Transaction do
:inputs, :inputs,
:outputs, :outputs,
:value, :value,
:fee,
# :witnesses, # :witnesses,
:lock_time, :lock_time,
:id, :id,
@ -66,6 +71,22 @@ defmodule BitcoinStream.Protocol.Transaction do
} }
end end
def inflate(txn) do
{inputs, in_value } = inflate_inputs(txn.id, txn.inputs);
%__MODULE__{
version: txn.version,
vbytes: txn.vbytes,
inputs: inputs,
outputs: txn.outputs,
value: txn.value,
fee: (in_value - txn.value),
# witnesses: txn.witnesses,
lock_time: txn.lock_time,
id: txn.id,
time: txn.time
}
end
defp count_value([], total) do defp count_value([], total) do
total total
end end
@ -74,6 +95,81 @@ defmodule BitcoinStream.Protocol.Transaction do
count_value(rest, total + next_output.value) count_value(rest, total + next_output.value)
end end
defp inflate_input(input) do
case input.prev_txid do
"0000000000000000000000000000000000000000000000000000000000000000" ->
{:ok, %{
prev_txid: input.prev_txid,
prev_vout: input.prev_vout,
script_sig: input.script_sig,
sequence_no: input.sequence_no,
value: 0,
script_pub_key: nil,
} }
_prev_txid ->
with {:ok, 200, hextx} <- RPC.request(:rpc, "getrawtransaction", [input.prev_txid]),
rawtx <- Base.decode16!(hextx, case: :lower),
{:ok, txn } <- decode(rawtx),
output <- Enum.at(txn.outputs, input.prev_vout) do
{:ok, %{
prev_txid: input.prev_txid,
prev_vout: input.prev_vout,
script_sig: input.script_sig,
sequence_no: input.sequence_no,
value: output.value,
script_pub_key: output.script_pub_key,
} }
else
{:ok, 500, reason} ->
IO.puts("transaction not found #{input.prev_txid}");
IO.inspect(reason)
{:error, reason} ->
IO.puts("Failed to inflate input:");
IO.inspect(reason)
:error
err ->
IO.puts("Failed to inflate input: (unknown reason)");
IO.inspect(err);
:error
end
end
end
defp inflate_inputs([], inflated, total) do
{inflated, total}
end
defp inflate_inputs([next_input | rest], inflated, total) do
case inflate_input(next_input) do
{:ok, inflated_txn} ->
inflate_inputs(rest, [inflated_txn | inflated], total + inflated_txn.value)
_ ->
inflate_inputs(rest, [inflated], total)
end
end
def inflate_inputs([], nil) do
{ nil, 0 }
end
def inflate_inputs(txid, inputs) do
case :ets.lookup(:mempool_cache, txid) do
# cache miss, actually inflate
[] ->
inflate_inputs(inputs, [], 0)
# cache hit, but processed inputs not available
[{_, nil, _}] ->
inflate_inputs(inputs, [], 0)
# cache hit, just return the cached values
[{_, input_state, _}] ->
input_state
other ->
IO.puts("unexpected cached value: ")
IO.inspect(other);
inflate_inputs(inputs, [], 0)
end
end
end end
# defmodule BitcoinStream.Protocol.Transaction.Summary do # defmodule BitcoinStream.Protocol.Transaction.Summary do

View File

@ -12,7 +12,27 @@ defmodule BitcoinStream.Router do
json_decoder: Jason json_decoder: Jason
plug :dispatch plug :dispatch
match "/api/block/:hash" do
case get_block(hash) do
{:ok, block} ->
put_resp_header(conn, "cache-control", "public, max-age=604800, immutable")
|> send_resp(200, block)
_ ->
IO.puts("Error getting block hash")
end
end
match _ do match _ do
send_resp(conn, 404, "404") send_resp(conn, 404, "404")
end end
defp get_block(last_seen) do
last_id = GenServer.call(:block_data, :block_id);
cond do
(last_seen == last_id) ->
payload = GenServer.call(:block_data, :json_block);
{:ok, payload}
true -> :err
end
end
end end

View File

@ -5,13 +5,14 @@ defmodule BitcoinStream.Server do
{ socket_port, "" } = Integer.parse(System.get_env("PORT")); { socket_port, "" } = Integer.parse(System.get_env("PORT"));
{ zmq_tx_port, "" } = Integer.parse(System.get_env("BITCOIN_ZMQ_RAWTX_PORT")); { zmq_tx_port, "" } = Integer.parse(System.get_env("BITCOIN_ZMQ_RAWTX_PORT"));
{ zmq_block_port, "" } = Integer.parse(System.get_env("BITCOIN_ZMQ_RAWBLOCK_PORT")); { zmq_block_port, "" } = Integer.parse(System.get_env("BITCOIN_ZMQ_RAWBLOCK_PORT"));
{ zmq_sequence_port, "" } = Integer.parse(System.get_env("BITCOIN_ZMQ_SEQUENCE_PORT"));
{ rpc_port, "" } = Integer.parse(System.get_env("BITCOIN_RPC_PORT")); { rpc_port, "" } = Integer.parse(System.get_env("BITCOIN_RPC_PORT"));
btc_host = System.get_env("BITCOIN_HOST"); btc_host = System.get_env("BITCOIN_HOST");
children = [ children = [
{ BitcoinStream.BlockData, [name: :block_data] },
{ BitcoinStream.RPC, [host: btc_host, port: rpc_port, name: :rpc] }, { BitcoinStream.RPC, [host: btc_host, port: rpc_port, name: :rpc] },
{ BitcoinStream.Mempool, [name: :mempool] }, { BitcoinStream.Mempool, [name: :mempool] },
{ BitcoinStream.BlockData, [name: :block_data] },
BitcoinStream.Metrics.Probe, BitcoinStream.Metrics.Probe,
Plug.Cowboy.child_spec( Plug.Cowboy.child_spec(
scheme: :http, scheme: :http,
@ -25,7 +26,7 @@ defmodule BitcoinStream.Server do
keys: :duplicate, keys: :duplicate,
name: Registry.BitcoinStream name: Registry.BitcoinStream
), ),
BitcoinStream.Bridge.child_spec(host: btc_host, tx_port: zmq_tx_port, block_port: zmq_block_port) BitcoinStream.Bridge.child_spec(host: btc_host, tx_port: zmq_tx_port, block_port: zmq_block_port, sequence_port: zmq_sequence_port)
] ]
opts = [strategy: :one_for_one, name: BitcoinStream.Application] opts = [strategy: :one_for_one, name: BitcoinStream.Application]

View File

@ -18,10 +18,6 @@ defmodule BitcoinStream.SocketHandler do
{:ok, state} {:ok, state}
end end
# def last_block() do
#
# end
def get_block(last_seen) do def get_block(last_seen) do
IO.puts("getting block with id #{last_seen}") IO.puts("getting block with id #{last_seen}")
last_id = GenServer.call(:block_data, :block_id) last_id = GenServer.call(:block_data, :block_id)
@ -40,10 +36,14 @@ defmodule BitcoinStream.SocketHandler do
def get_mempool_count_msg() do def get_mempool_count_msg() do
count = Mempool.get(:mempool); count = Mempool.get(:mempool);
IO.puts("Count: #{count}");
"{ \"type\": \"count\", \"count\": #{count}}" "{ \"type\": \"count\", \"count\": #{count}}"
end end
def get_block_id_msg() do
last_id = GenServer.call(:block_data, :block_id);
"{ \"type\": \"block_id\", \"block_id\": \"#{last_id}\"}"
end
@timed(key: "timed.function") @timed(key: "timed.function")
def websocket_handle({:text, msg}, state) do def websocket_handle({:text, msg}, state) do
# IO.puts("message received: #{msg} | #{inspect self()}"); # IO.puts("message received: #{msg} | #{inspect self()}");
@ -52,20 +52,20 @@ defmodule BitcoinStream.SocketHandler do
"block" -> "block" ->
IO.puts('block request'); IO.puts('block request');
{:reply, {:text, "null"}, state}; {:reply, {:text, "null"}, state}
"count" -> "count" ->
count = get_mempool_count_msg(); count = get_mempool_count_msg();
{:reply, {:text, count}, state}; {:reply, {:text, count}, state}
"block_id" ->
last_id = get_block_id_msg();
{:reply, {:text, last_id}, state}
json -> json ->
IO.puts("attempting to decode msg as json");
with {:ok, result} <- Jason.decode(json) do with {:ok, result} <- Jason.decode(json) do
IO.puts("decoded ok");
IO.inspect(result);
case result do case result do
%{"last" => block_id, "method" => "get_block"} -> %{"last" => block_id, "method" => "get_block"} ->
IO.puts('block request')
case get_block(block_id) do case get_block(block_id) do
{:ok, block_msg} -> {:ok, block_msg} ->
{:reply, {:text, block_msg}, state}; {:reply, {:text, block_msg}, state};

View File

@ -4,7 +4,7 @@ defmodule BitcoinStream.MixProject do
def project do def project do
[ [
app: :bitcoin_stream, app: :bitcoin_stream,
version: "2.1.3", version: "2.2.0",
elixir: "~> 1.10", elixir: "~> 1.10",
start_permanent: Mix.env() == :prod, start_permanent: Mix.env() == :prod,
deps: deps(), deps: deps(),