first commit

This commit is contained in:
Chris Troutner
2026-06-02 16:47:09 -07:00
commit 94cfcd930c
44 changed files with 11797 additions and 0 deletions
+10
View File
@@ -0,0 +1,10 @@
PSF_MEMO_DB_URL=http://localhost:5021
RPC_IP=172.17.0.1
RPC_PORT=8332
RPC_USER=bitcoin
RPC_PASS=password
ZMQ_PORT=28332
TX_REST_API_PORT=5455
TX_REST_API_IP=localhost
START_BLOCK_HEIGHT=525000
SEEN_TX_MAX=100000
+4
View File
@@ -0,0 +1,4 @@
node_modules/
.env
coverage/
logs/
+10
View File
@@ -0,0 +1,10 @@
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
CMD ["node", "--max-old-space-size=8192", "psf-memo-block-indexer.js"]
+10
View File
@@ -0,0 +1,10 @@
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
CMD ["node", "--max-old-space-size=4096", "psf-memo-tx-indexer.js"]
+17
View File
@@ -0,0 +1,17 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2026 Permissionless Software Foundation
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
+69
View File
@@ -0,0 +1,69 @@
# psf-memo-indexer
Indexes [Memo protocol](https://memo.cash) transactions on Bitcoin Cash. Architecture mirrors [psf-slp-indexer-g2](https://github.com/Permissionless-Software-Foundation/psf-slp-indexer-g2).
## Overview
Two processes:
- **Block indexer** — IBD from block 525000, then ZMQ new-block processing
- **TX indexer** — mempool transactions via ZMQ after IBD signals `/tx-start`
Data is stored in [psf-memo-db](../psf-memo-db) via REST.
## Requirements
- node ^20
- npm ^10
- BCH full node (RPC + ZMQ)
- Running psf-memo-db
## Installation
```bash
cd psf-memo-indexer
npm install
cp .env-example .env
```
## Usage
Start the database:
```bash
cd ../psf-memo-db && npm start
```
Block indexer:
```bash
npm run block-indexer
```
TX indexer (separate terminal):
```bash
npm run tx-indexer
```
## Configuration
See `.env-example`. Key variables:
| Variable | Default | Description |
|----------|---------|-------------|
| `PSF_MEMO_DB_URL` | `http://localhost:5021` | psf-memo-db URL |
| `START_BLOCK_HEIGHT` | `525000` | First block to index |
| `RPC_IP` / `RPC_PORT` | `172.17.0.1` / `8332` | Full node RPC |
| `ZMQ_PORT` | `28332` | Full node ZMQ |
| `TX_REST_API_PORT` | `5455` | TX indexer control API |
## Tests
```bash
npm test
```
## License
GPL v3
+24
View File
@@ -0,0 +1,24 @@
import 'dotenv/config'
export default {
psfMemoDbUrl: process.env.PSF_MEMO_DB_URL || 'http://localhost:5021',
rpcIp: process.env.RPC_IP || '172.17.0.1',
rpcPort: process.env.RPC_PORT || '8332',
zmqPort: process.env.ZMQ_PORT || '28332',
rpcUser: process.env.RPC_USER || 'bitcoin',
rpcPass: process.env.RPC_PASS || 'password',
txRestApiPort: process.env.TX_REST_API_PORT ? parseInt(process.env.TX_REST_API_PORT) : 5455,
txRestApiIp: process.env.TX_REST_API_IP || 'localhost',
seenTxMax: process.env.SEEN_TX_MAX ? parseInt(process.env.SEEN_TX_MAX) : 100000,
zmqTxQueueMax: process.env.ZMQ_TX_QUEUE_MAX ? parseInt(process.env.ZMQ_TX_QUEUE_MAX) : 50000,
zmqBlockQueueMax: process.env.ZMQ_BLOCK_QUEUE_MAX ? parseInt(process.env.ZMQ_BLOCK_QUEUE_MAX) : 1000,
txCacheMax: process.env.TX_CACHE_MAX ? parseInt(process.env.TX_CACHE_MAX) : 100000,
startBlockHeight: process.env.START_BLOCK_HEIGHT
? parseInt(process.env.START_BLOCK_HEIGHT)
: 525000,
exitOnMissingBackup: process.env.EXIT_ON_MISSING_BACKUP === 'true'
}
+10131
View File
File diff suppressed because it is too large Load Diff
+32
View File
@@ -0,0 +1,32 @@
{
"name": "psf-memo-indexer",
"version": "1.0.0",
"description": "Indexes Memo protocol transactions on Bitcoin Cash.",
"type": "module",
"scripts": {
"block-indexer": "node --max-old-space-size=8192 psf-memo-block-indexer.js",
"tx-indexer": "node --max-old-space-size=4096 psf-memo-tx-indexer.js",
"test": "c8 --reporter=text mocha --exit --recursive test/unit/",
"lint": "standard --env mocha --fix"
},
"author": "Chris Troutner",
"license": "GPLV3",
"dependencies": {
"@chris.troutner/retry-queue": "1.0.11",
"@psf/bch-js": "7.1.11",
"@psf/bitcoincash-zmq-decoder": "0.1.5",
"axios": "1.12.2",
"dotenv": "17.2.3",
"express": "5.1.0",
"p-queue": "8.1.1",
"p-retry": "6.2.1",
"zeromq": "6.5.0"
},
"devDependencies": {
"c8": "10.1.3",
"chai": "6.2.0",
"mocha": "11.7.4",
"sinon": "21.0.0",
"standard": "17.1.2"
}
}
+105
View File
@@ -0,0 +1,105 @@
/*
Entry point for the Memo block indexer.
*/
import RetryQueue from '@chris.troutner/retry-queue'
import 'dotenv/config'
import Adapters from './src/adapters/adapters-index.js'
import UseCases from './src/use-cases/use-cases-index.js'
import Controllers from './src/controllers/controllers-index.js'
const EPOCH = 1000
async function start () {
try {
const adapters = new Adapters()
await adapters.initAdapters()
const useCases = new UseCases({ adapters })
await useCases.initUseCases()
const controllers = new Controllers({ useCases, adapters })
await controllers.initControllers()
const queue = new RetryQueue()
console.log('Starting Memo block indexer...')
const status = await useCases.state.getStatus()
console.log('Indexer State:', status)
let nextBlockHeight = status.syncedBlockHeight + 1
let biggestBlockHeight = await queue.addToQueue(adapters.rpc.getBlockCount, {})
if (nextBlockHeight <= biggestBlockHeight) {
do {
const blockStart = new Date()
await useCases.indexBlocks.processBlock(nextBlockHeight)
const blockProcessTime = new Date().getTime() - blockStart.getTime()
console.log(`Block ${nextBlockHeight} processed in ${blockProcessTime / 1000}s`)
nextBlockHeight = await useCases.state.updateIndexedBlockHeight({
lastIndexedBlockHeight: nextBlockHeight
})
if (controllers.keyboard.stopStatus()) {
console.log(`Stopped at block ${nextBlockHeight - 1}`)
process.exit(1)
}
if (nextBlockHeight % EPOCH === 0) {
console.log(`Creating DB backup at block ${nextBlockHeight}`)
await adapters.dbCtrl.backupDb(nextBlockHeight, EPOCH)
}
biggestBlockHeight = await queue.addToQueue(adapters.rpc.getBlockCount, {})
} while (nextBlockHeight <= biggestBlockHeight)
} else {
console.log(`Already at tip (block ${status.syncedBlockHeight}).`)
}
console.log(`\nIBD complete. Last block: ${nextBlockHeight - 1}`)
await adapters.zmq.connect()
console.log('Connected to ZMQ.')
await adapters.txIndexerAdapter.startTxIndexer()
console.log('TX indexer started.')
let loopCnt = 0
let liveStatus = status
do {
let blockHeight = await queue.addToQueue(adapters.rpc.getBlockCount, {})
const block = adapters.zmq.getBlock()
if (block) {
const blockHeader = await queue.addToQueue(
adapters.rpc.getBlockHeader,
block.hash
)
blockHeight = blockHeader.height
liveStatus.syncedBlockHeight = blockHeight
liveStatus.chainBlockHeight = blockHeight
await adapters.statusDb.updateStatus(liveStatus)
await useCases.indexBlocks.processBlock(blockHeight)
}
loopCnt++
if (loopCnt > 100) {
loopCnt = 0
console.log(`ZMQ alive. Block height: ${blockHeight}`)
}
await useCases.utils.sleep(500)
} while (1)
} catch (err) {
console.error('Error in psf-memo-block-indexer:', err)
process.exit(1)
}
}
start()
+69
View File
@@ -0,0 +1,69 @@
/*
Entry point for the Memo TX indexer (mempool).
*/
import RetryQueue from '@chris.troutner/retry-queue'
import 'dotenv/config'
import Adapters from './src/adapters/adapters-index.js'
import UseCases from './src/use-cases/use-cases-index.js'
import Controllers from './src/controllers/controllers-index.js'
import config from './config/index.js'
async function start () {
try {
const queue = new RetryQueue()
const adapters = new Adapters()
const useCases = new UseCases({ adapters })
const controllers = new Controllers({ useCases, adapters })
await controllers.initControllers()
await controllers.startTxRESTController()
console.log('Starting Memo TX indexer...')
let runTxIndexer = false
const seenTxs = new Set()
const seenTxQueue = []
do {
await useCases.utils.sleep(2000)
runTxIndexer = controllers.txRESTController.runTxIndexing
if (runTxIndexer) {
console.log('TX Indexer triggered from REST API!')
break
}
} while (1)
await adapters.zmq.connect()
console.log('Connected to ZMQ.')
do {
const blockHeight = await queue.addToQueue(adapters.rpc.getBlockCount, {})
const tx = adapters.zmq.getTx()
if (tx) {
if (seenTxs.has(tx)) continue
seenTxs.add(tx)
seenTxQueue.push(tx)
if (seenTxQueue.length > config.seenTxMax) {
const oldTxid = seenTxQueue.shift()
seenTxs.delete(oldTxid)
}
try {
await useCases.indexBlocks.processMemoTx(tx, blockHeight + 1)
} catch (err) {
console.error(`Error indexing mempool tx ${tx}:`, err.message)
}
}
await useCases.utils.sleep(100)
} while (1)
} catch (err) {
console.error('Error in psf-memo-tx-indexer:', err)
process.exit(1)
}
}
start()
+43
View File
@@ -0,0 +1,43 @@
/*
Top-level adapters index.
*/
import StatusDb from './status-db.js'
import RPC from './rpc.js'
import Transaction from './transaction.js'
import ZMQ from './zmq.js'
import TxIndexerAdapter from './tx-indexer.js'
import DbCtrl from './backup-db.js'
import { createEntityDb } from './entity-db.js'
class Adapters {
constructor (localConfig = {}) {
this.statusDb = new StatusDb()
this.rpc = new RPC()
this.transaction = new Transaction(localConfig)
this.zmq = new ZMQ()
this.txIndexerAdapter = new TxIndexerAdapter()
this.dbCtrl = new DbCtrl()
this.postDb = createEntityDb('post', 'txid', 'postData')
this.postParentDb = createEntityDb('postparent', 'txid', 'parentData')
this.postChildDb = createEntityDb('postchild', 'txid', 'childData')
this.likeDb = createEntityDb('like', 'txid', 'likeData')
this.nameDb = createEntityDb('name', 'addr', 'nameData')
this.profileDb = createEntityDb('profile', 'addr', 'profileData')
this.profilePicDb = createEntityDb('profilepic', 'addr', 'profilePicData')
this.followDb = createEntityDb('follow', 'key', 'followData')
this.roomDb = createEntityDb('room', 'key', 'roomData')
this.processErrorDb = createEntityDb('processerror', 'txid', 'errorData')
this.ptxDb = createEntityDb('ptx', 'txid', 'ptxData')
this.initAdapters = this.initAdapters.bind(this)
}
async initAdapters () {
console.log('Adapter libraries initialized.')
return true
}
}
export default Adapters
+21
View File
@@ -0,0 +1,21 @@
/*
Database backup/restore via psf-memo-db REST API.
*/
import axios from 'axios'
import config from '../../config/index.js'
class DbCtrl {
constructor () {
this.axios = axios
this.config = config
this.backupDb = this.backupDb.bind(this)
}
async backupDb (height, epoch) {
await this.axios.post(`${this.config.psfMemoDbUrl}/level/backup`, { height, epoch })
return true
}
}
export default DbCtrl
+30
View File
@@ -0,0 +1,30 @@
/*
Generic axios client for psf-memo-db /level CRUD routes.
*/
import axios from 'axios'
import config from '../../config/index.js'
export function createEntityDb (route, idField, dataField) {
return {
async get (key) {
const response = await axios.get(`${config.psfMemoDbUrl}/level/${route}/${key}`)
return response.data
},
async create (key, data) {
const body = { [idField]: key, [dataField]: data }
const response = await axios.post(`${config.psfMemoDbUrl}/level/${route}`, body)
return response.data
},
async update (key, data) {
const response = await axios.put(`${config.psfMemoDbUrl}/level/${route}/${key}`, {
[dataField]: data
})
return response.data
},
async delete (key) {
const response = await axios.delete(`${config.psfMemoDbUrl}/level/${route}/${key}`)
return response.data
}
}
}
+85
View File
@@ -0,0 +1,85 @@
/*
JSON RPC adapter for BCH full node.
*/
import axios from 'axios'
import config from '../../config/index.js'
class RPC {
constructor () {
this.axios = axios
this.config = config
this.getAxiosOptions = this.getAxiosOptions.bind(this)
this.getBlockCount = this.getBlockCount.bind(this)
this.getBlockHeader = this.getBlockHeader.bind(this)
this.getBlock = this.getBlock.bind(this)
this.getBlockHash = this.getBlockHash.bind(this)
this.getRawTransaction = this.getRawTransaction.bind(this)
}
getAxiosOptions () {
return {
method: 'post',
baseURL: `http://${this.config.rpcIp}:${this.config.rpcPort}/`,
timeout: 15000,
auth: {
username: this.config.rpcUser,
password: this.config.rpcPass
},
data: { jsonrpc: '1.0' }
}
}
async getBlockCount () {
const options = this.getAxiosOptions()
options.data.id = 'getblockcount'
options.data.method = 'getblockcount'
options.data.params = []
const response = await this.axios.request(options)
return response.data.result
}
async getBlockHeader (hash, verbose = true) {
if (!hash) throw new Error('Block hash must be provided')
const options = this.getAxiosOptions()
options.data.id = 'getblockheader'
options.data.method = 'getblockheader'
options.data.params = [hash, verbose]
const response = await this.axios.request(options)
return response.data.result
}
async getBlock (inObj = {}) {
const { hash, verbose = true } = inObj
if (!hash) throw new Error('Block hash must be provided')
const options = this.getAxiosOptions()
options.data.id = 'getblock'
options.data.method = 'getblock'
options.data.params = [hash, verbose]
const response = await this.axios.request(options)
return response.data.result
}
async getBlockHash (inObj = {}) {
const { height } = inObj
if (height === undefined) throw new Error('Block height must be provided')
const options = this.getAxiosOptions()
options.data.id = 'getblockhash'
options.data.method = 'getblockhash'
options.data.params = [parseInt(height)]
const response = await this.axios.request(options)
return response.data.result
}
async getRawTransaction (txid, verbose = true) {
if (!txid) throw new Error('txid must be provided')
const options = this.getAxiosOptions()
options.data.id = 'getrawtransaction'
options.data.method = 'getrawtransaction'
options.data.params = [txid, verbose]
const response = await this.axios.request(options)
return response.data.result
}
}
export default RPC
+58
View File
@@ -0,0 +1,58 @@
/*
Adapter for indexer status in psf-memo-db.
*/
import axios from 'axios'
import RetryQueue from '@chris.troutner/retry-queue'
import config from '../../config/index.js'
import RPC from './rpc.js'
class StatusDb {
constructor () {
this.axios = axios
this.config = config
this.rpc = new RPC()
this.retryQueue = new RetryQueue()
this.getStatus = this.getStatus.bind(this)
this.updateStatus = this.updateStatus.bind(this)
}
async getStatus () {
try {
const response = await this.axios.get(`${this.config.psfMemoDbUrl}/level/status/status`)
return response.data
} catch (err) {
console.log('State not found. Creating fresh state.')
if (this.config.exitOnMissingBackup) {
console.log('EXIT_ON_MISSING_BACKUP set. Exiting.')
process.exit(1)
}
const biggestBlockHeight = await this.retryQueue.addToQueue(this.rpc.getBlockCount, {})
const start = this.config.startBlockHeight - 1
const statusData = {
startBlockHeight: start,
syncedBlockHeight: start,
chainBlockHeight: biggestBlockHeight
}
await this.axios.post(`${this.config.psfMemoDbUrl}/level/status`, {
statusKey: 'status',
statusData
})
return statusData
}
}
async updateStatus (status) {
const { startBlockHeight, syncedBlockHeight, chainBlockHeight } = status
await this.axios.put(`${this.config.psfMemoDbUrl}/level/status`, {
statusData: { startBlockHeight, syncedBlockHeight, chainBlockHeight }
})
return true
}
}
export default StatusDb
+63
View File
@@ -0,0 +1,63 @@
/*
Transaction adapter: fetch TX data and detect Memo OP_RETURN outputs.
*/
import RetryQueue from '@chris.troutner/retry-queue'
import RPC from './rpc.js'
import { findMemoOutputs } from '../lib/memo-parser.js'
import config from '../../config/index.js'
class Transaction {
constructor () {
this.rpc = new RPC()
this.queue = new RetryQueue()
this.config = config
this.txCache = {}
this.txCacheKeys = []
this.get = this.get.bind(this)
this.isMemoTx = this.isMemoTx.bind(this)
this.getTxData = this.getTxData.bind(this)
}
async getTxData (txid) {
const cached = this.txCache[txid]
if (cached) return cached
const txDetails = await this.queue.addToQueue(this.rpc.getRawTransaction, txid)
if (txDetails.blockhash) {
const blockHeader = await this.rpc.getBlockHeader(txDetails.blockhash)
txDetails.blockheight = blockHeader.height
} else {
const blockHeight = await this.rpc.getBlockCount()
txDetails.blockheight = blockHeight + 1
}
this.txCache[txid] = txDetails
this.txCacheKeys.push(txid)
if (this.txCacheKeys.length > this.config.txCacheMax) {
const old = this.txCacheKeys.shift()
delete this.txCache[old]
}
return txDetails
}
async get (txid) {
if (typeof txid !== 'string') {
throw new Error('Input to Transaction.get() must be a string TXID.')
}
return this.getTxData(txid)
}
async isMemoTx (txid) {
try {
const tx = await this.getTxData(txid)
return findMemoOutputs(tx).length > 0
} catch (err) {
return false
}
}
}
export default Transaction
+23
View File
@@ -0,0 +1,23 @@
/*
Trigger TX indexer after block IBD completes.
*/
import axios from 'axios'
import config from '../../config/index.js'
class TxIndexerAdapter {
constructor () {
this.axios = axios
this.config = config
}
async startTxIndexer () {
const response = await this.axios.get(
`http://${this.config.txRestApiIp}:${this.config.txRestApiPort}/tx-start`
)
console.log('TX indexer start response:', response.data)
return true
}
}
export default TxIndexerAdapter
+70
View File
@@ -0,0 +1,70 @@
/*
ZMQ adapter for full node notifications.
*/
import BitcoinCashZmqDecoder from '@psf/bitcoincash-zmq-decoder'
import * as zmq from 'zeromq'
import config from '../../config/index.js'
class ZMQ {
constructor () {
this.sock = new zmq.Subscriber()
this.bchZmqDecoder = new BitcoinCashZmqDecoder('mainnet')
this.config = config
this.txQueue = []
this.blockQueue = []
this.connect = this.connect.bind(this)
this.monitorZmq = this.monitorZmq.bind(this)
this.getTx = this.getTx.bind(this)
this.getBlock = this.getBlock.bind(this)
this.decodeMsg = this.decodeMsg.bind(this)
}
async connect () {
this.sock.connect(`tcp://${this.config.rpcIp}:${this.config.zmqPort}`)
this.sock.subscribe('raw')
this.monitorZmq()
return true
}
async monitorZmq () {
for await (const [topic, msg] of this.sock) {
this.decodeMsg(topic, msg)
}
}
decodeMsg (topic, message) {
try {
const decoded = topic.toString('ascii')
if (decoded === 'rawtx') {
const txd = this.bchZmqDecoder.decodeTransaction(message)
this.txQueue.push(txd.format.txid)
if (this.txQueue.length > this.config.zmqTxQueueMax) {
this.txQueue.shift()
}
} else if (decoded === 'rawblock') {
const blk = this.bchZmqDecoder.decodeBlock(message)
this.blockQueue.push(blk)
if (this.blockQueue.length > this.config.zmqBlockQueueMax) {
this.blockQueue.shift()
}
}
return true
} catch (err) {
console.error('Error in decodeMsg: ', err)
return false
}
}
getTx () {
const nextTx = this.txQueue.shift()
return nextTx === undefined ? false : nextTx
}
getBlock () {
const nextBlock = this.blockQueue.shift()
return nextBlock === undefined ? false : nextBlock
}
}
export default ZMQ
+25
View File
@@ -0,0 +1,25 @@
import Keyboard from './keyboard.js'
import TxRESTController from './tx-rest-api.js'
class Controllers {
constructor (localConfig = {}) {
if (!localConfig.useCases) throw new Error('Use cases required.')
if (!localConfig.adapters) throw new Error('Adapters required.')
this.useCases = localConfig.useCases
this.adapters = localConfig.adapters
this.keyboard = new Keyboard()
this.txRESTController = new TxRESTController(localConfig)
this.initControllers = this.initControllers.bind(this)
this.startTxRESTController = this.startTxRESTController.bind(this)
}
async initControllers () {
this.keyboard.initKeyboard()
}
async startTxRESTController () {
this.txRESTController.start()
}
}
export default Controllers
+28
View File
@@ -0,0 +1,28 @@
import readline from 'readline'
class Keyboard {
constructor () {
this.stopIndexing = false
this.initKeyboard = this.initKeyboard.bind(this)
this.stopStatus = this.stopStatus.bind(this)
}
initKeyboard () {
readline.emitKeypressEvents(process.stdin)
if (process.stdin.isTTY) process.stdin.setRawMode(true)
process.stdin.on('keypress', (str, key) => {
if (key && key.name === 'q') {
this.stopIndexing = true
}
if (key && key.ctrl && key.name === 'c') {
process.exit(0)
}
})
}
stopStatus () {
return this.stopIndexing
}
}
export default Keyboard
+28
View File
@@ -0,0 +1,28 @@
import express from 'express'
import config from '../../config/index.js'
const port = config.txRestApiPort
class TxRESTController {
constructor (localConfig = {}) {
this.adapters = localConfig.adapters
this.useCases = localConfig.useCases
this.app = express()
this.runTxIndexing = false
this.start = this.start.bind(this)
}
start () {
this.app.get('/tx-start', (req, res) => {
this.runTxIndexing = true
console.log('Starting TX Indexer...')
res.send({ report: { success: true } })
})
this.app.listen(port, () => {
console.log(`TX Indexer listening at http://localhost:${port}`)
})
}
}
export default TxRESTController
+57
View File
@@ -0,0 +1,57 @@
/*
Memo protocol action codes (mirrors Go ref/bitcoin/memo/codes.go).
*/
export const CODE_PREFIX = 0x6d
export const CODE_SET_NAME = 0x01
export const CODE_POST = 0x02
export const CODE_REPLY = 0x03
export const CODE_LIKE = 0x04
export const CODE_SET_PROFILE = 0x05
export const CODE_FOLLOW = 0x06
export const CODE_UNFOLLOW = 0x07
export const CODE_SET_PROFILE_PIC = 0x0a
export const CODE_TOPIC_MESSAGE = 0x0c
export const CODE_TOPIC_FOLLOW = 0x0d
export const CODE_TOPIC_UNFOLLOW = 0x0e
export const PREFIX_SET_NAME = Buffer.from([CODE_PREFIX, CODE_SET_NAME])
export const PREFIX_POST = Buffer.from([CODE_PREFIX, CODE_POST])
export const PREFIX_REPLY = Buffer.from([CODE_PREFIX, CODE_REPLY])
export const PREFIX_LIKE = Buffer.from([CODE_PREFIX, CODE_LIKE])
export const PREFIX_SET_PROFILE = Buffer.from([CODE_PREFIX, CODE_SET_PROFILE])
export const PREFIX_FOLLOW = Buffer.from([CODE_PREFIX, CODE_FOLLOW])
export const PREFIX_UNFOLLOW = Buffer.from([CODE_PREFIX, CODE_UNFOLLOW])
export const PREFIX_SET_PROFILE_PIC = Buffer.from([CODE_PREFIX, CODE_SET_PROFILE_PIC])
export const PREFIX_TOPIC_MESSAGE = Buffer.from([CODE_PREFIX, CODE_TOPIC_MESSAGE])
export const PREFIX_TOPIC_FOLLOW = Buffer.from([CODE_PREFIX, CODE_TOPIC_FOLLOW])
export const PREFIX_TOPIC_UNFOLLOW = Buffer.from([CODE_PREFIX, CODE_TOPIC_UNFOLLOW])
export const MAX_POST_SIZE = 65000
export const MAX_REPLY_SIZE = 65000
export const TX_HASH_LENGTH = 32
export const PK_HASH_LENGTH = 20
export const ACTION_NAMES = {
[`${CODE_PREFIX}-${CODE_SET_NAME}`]: 'setName',
[`${CODE_PREFIX}-${CODE_POST}`]: 'post',
[`${CODE_PREFIX}-${CODE_REPLY}`]: 'reply',
[`${CODE_PREFIX}-${CODE_LIKE}`]: 'like',
[`${CODE_PREFIX}-${CODE_SET_PROFILE}`]: 'setProfile',
[`${CODE_PREFIX}-${CODE_FOLLOW}`]: 'follow',
[`${CODE_PREFIX}-${CODE_UNFOLLOW}`]: 'unfollow',
[`${CODE_PREFIX}-${CODE_SET_PROFILE_PIC}`]: 'setProfilePic',
[`${CODE_PREFIX}-${CODE_TOPIC_MESSAGE}`]: 'topicMessage',
[`${CODE_PREFIX}-${CODE_TOPIC_FOLLOW}`]: 'topicFollow',
[`${CODE_PREFIX}-${CODE_TOPIC_UNFOLLOW}`]: 'topicUnfollow'
}
export function isMemoPrefix (buf) {
return buf && buf.length >= 2 && buf[0] === CODE_PREFIX
}
export function getActionFromPrefix (prefixBuf) {
if (!isMemoPrefix(prefixBuf)) return null
return ACTION_NAMES[`${prefixBuf[0]}-${prefixBuf[1]}`] || null
}
+143
View File
@@ -0,0 +1,143 @@
/*
Parse Memo OP_RETURN scripts and extract signer addresses from transactions.
*/
import BCHJS from '@psf/bch-js'
import {
isMemoPrefix,
getActionFromPrefix,
CODE_PREFIX
} from './memo-codes.js'
let bchjs
function getBchjs () {
if (!bchjs) {
bchjs = new BCHJS({
restURL: process.env.RESTURL || 'https://api.fullstack.cash/v5/'
})
}
return bchjs
}
/**
* Parse push data chunks from a script hex string.
*/
export function parseScriptPushDatas (scriptHex) {
if (!scriptHex || typeof scriptHex !== 'string') return []
const buf = Buffer.from(scriptHex, 'hex')
const pushDatas = []
let i = 0
while (i < buf.length) {
const op = buf[i]
if (op === 0x00) {
pushDatas.push(Buffer.alloc(0))
i++
continue
}
if (op >= 0x01 && op <= 0x4b) {
const len = op
pushDatas.push(buf.slice(i + 1, i + 1 + len))
i += 1 + len
continue
}
if (op === 0x4c) {
const len = buf[i + 1]
pushDatas.push(buf.slice(i + 2, i + 2 + len))
i += 2 + len
continue
}
if (op === 0x4d) {
const len = buf.readUInt16LE(i + 1)
pushDatas.push(buf.slice(i + 3, i + 3 + len))
i += 3 + len
continue
}
if (op === 0x4e) {
const len = buf.readUInt32LE(i + 1)
pushDatas.push(buf.slice(i + 5, i + 5 + len))
i += 5 + len
continue
}
// Skip opcode (OP_RETURN 0x6a, etc.)
i++
}
return pushDatas
}
/**
* Decode Memo OP_RETURN from a scriptPubKey hex string.
*/
export function decodeMemoOpReturn (scriptHex) {
const pushDatas = parseScriptPushDatas(scriptHex)
if (!pushDatas.length) return null
// OP_RETURN scripts: first push may be after OP_RETURN opcode parsing;
// find first memo prefix in pushes
for (const data of pushDatas) {
if (!isMemoPrefix(data)) continue
const action = getActionFromPrefix(data)
if (!action) continue
return { action, prefix: data, pushDatas }
}
return null
}
export function isMemoScript (scriptHex) {
return decodeMemoOpReturn(scriptHex) !== null
}
export function findMemoOutputs (txDetails) {
const matches = []
if (!txDetails || !txDetails.vout) return matches
for (let i = 0; i < txDetails.vout.length; i++) {
const vout = txDetails.vout[i]
const hex = vout.scriptPubKey && vout.scriptPubKey.hex
const decoded = decodeMemoOpReturn(hex)
if (decoded) {
matches.push({ voutIndex: i, ...decoded })
}
}
return matches
}
export function getSignerAddress (txDetails) {
if (!txDetails || !txDetails.vin) return null
for (const vin of txDetails.vin) {
if (!vin.scriptSig || !vin.scriptSig.hex) continue
try {
const scriptBuf = Buffer.from(vin.scriptSig.hex, 'hex')
const addr = getBchjs().Script.getAddressFromScriptSig(scriptBuf)
if (addr) return addr
} catch (err) {
continue
}
}
return null
}
export function getPkHashFromAddress (address) {
try {
const decoded = getBchjs().Address.decode(address)
return Buffer.from(decoded.hash)
} catch (err) {
return null
}
}
export function prefixToHex (prefixBuf) {
return prefixBuf.toString('hex')
}
export { CODE_PREFIX, isMemoPrefix }
+29
View File
@@ -0,0 +1,29 @@
import { logProcessError } from './helpers.js'
import { PK_HASH_LENGTH, PREFIX_UNFOLLOW } from '../../lib/memo-codes.js'
export async function handleFollow (ctx) {
const { adapters, txid, signerAddr, decoded, seen } = ctx
const { pushDatas, prefix } = decoded
if (pushDatas.length !== 2) {
await logProcessError(adapters, txid, `invalid follow push data count ${pushDatas.length}`)
return
}
if (pushDatas[1].length !== PK_HASH_LENGTH) {
await logProcessError(adapters, txid, 'follow pk hash wrong size')
return
}
const unfollow = prefix[1] === PREFIX_UNFOLLOW[1]
const followeePkHash = pushDatas[1].toString('hex')
const key = `${signerAddr}:${followeePkHash}`
await adapters.followDb.create(key, {
followerAddr: signerAddr,
followeePkHash,
unfollow,
txid,
seen
})
}
+28
View File
@@ -0,0 +1,28 @@
/*
Shared helpers for Memo action handlers.
*/
export async function logProcessError (adapters, txid, error) {
try {
await adapters.processErrorDb.create(txid, { error, ts: Date.now() })
} catch (err) {
console.error('Failed to log process error:', err.message)
}
}
export function utf8FromPush (buf) {
return buf.toString('utf8')
}
export function txHashFromPush (buf) {
if (!buf || buf.length !== 32) return null
return buf.toString('hex')
}
export function followKey (followerAddr, followeeAddr) {
return `${followerAddr}:${followeeAddr}`
}
export function roomKey (roomName, txid) {
return `${roomName}:${txid}`
}
+33
View File
@@ -0,0 +1,33 @@
import { handleSetName } from './set-name.js'
import { handlePost } from './post.js'
import { handleReply } from './reply.js'
import { handleLike } from './like.js'
import { handleSetProfile } from './set-profile.js'
import { handleFollow } from './follow.js'
import { handleSetProfilePic } from './set-profile-pic.js'
import { handleTopicMessage } from './topic-message.js'
import { handleTopicFollow } from './topic-follow.js'
export const ACTION_HANDLERS = {
setName: handleSetName,
post: handlePost,
reply: handleReply,
like: handleLike,
setProfile: handleSetProfile,
follow: handleFollow,
unfollow: handleFollow,
setProfilePic: handleSetProfilePic,
topicMessage: handleTopicMessage,
topicFollow: handleTopicFollow,
topicUnfollow: handleTopicFollow
}
export async function dispatchMemoAction (ctx) {
const handler = ACTION_HANDLERS[ctx.decoded.action]
if (!handler) {
console.log(`No handler for action ${ctx.decoded.action}`)
return false
}
await handler(ctx)
return true
}
+42
View File
@@ -0,0 +1,42 @@
import { txHashFromPush, logProcessError } from './helpers.js'
import { TX_HASH_LENGTH } from '../../lib/memo-codes.js'
export async function handleLike (ctx) {
const { adapters, txid, signerAddr, decoded, seen, txDetails } = ctx
const { pushDatas } = decoded
if (pushDatas.length !== 2) {
await logProcessError(adapters, txid, `invalid like push data count ${pushDatas.length}`)
return
}
if (pushDatas[1].length !== TX_HASH_LENGTH) {
await logProcessError(adapters, txid, 'like post tx hash wrong size')
return
}
const postTxid = txHashFromPush(pushDatas[1])
let tip = 0
try {
const post = await adapters.postDb.get(postTxid)
if (post && post.addr !== signerAddr) {
for (const vout of txDetails.vout) {
const addrs = vout.scriptPubKey && vout.scriptPubKey.addresses
if (addrs && addrs[0] === post.addr) {
tip += Math.round(vout.value * 1e8)
}
}
}
} catch (err) {
// post may not exist yet
}
const likeData = {
addr: signerAddr,
postTxid,
seen,
tip
}
await adapters.likeDb.create(txid, likeData)
}
+29
View File
@@ -0,0 +1,29 @@
import { utf8FromPush, logProcessError } from './helpers.js'
import { MAX_POST_SIZE } from '../../lib/memo-codes.js'
export async function handlePost (ctx) {
const { adapters, txid, signerAddr, decoded, seen } = ctx
const { pushDatas } = decoded
if (pushDatas.length !== 2) {
await logProcessError(adapters, txid, `invalid post push data count ${pushDatas.length}`)
return
}
const text = utf8FromPush(pushDatas[1])
if (!text.length) {
await logProcessError(adapters, txid, 'empty post')
return
}
if (text.length > MAX_POST_SIZE) {
await logProcessError(adapters, txid, 'post too large')
return
}
const postData = { addr: signerAddr, text, seen }
try {
await adapters.postDb.get(txid)
} catch (err) {
await adapters.postDb.create(txid, postData)
}
}
+30
View File
@@ -0,0 +1,30 @@
import { utf8FromPush, txHashFromPush, logProcessError } from './helpers.js'
import { MAX_REPLY_SIZE } from '../../lib/memo-codes.js'
import { handlePost } from './post.js'
export async function handleReply (ctx) {
const { adapters, txid, signerAddr, decoded, seen } = ctx
const { pushDatas } = decoded
if (pushDatas.length !== 3) {
await logProcessError(adapters, txid, `invalid reply push data count ${pushDatas.length}`)
return
}
const parentTxid = txHashFromPush(pushDatas[1])
if (!parentTxid) {
await logProcessError(adapters, txid, 'invalid parent tx hash for reply')
return
}
const text = utf8FromPush(pushDatas[2])
if (text.length > MAX_REPLY_SIZE) {
await logProcessError(adapters, txid, 'reply too large')
return
}
await adapters.postParentDb.create(txid, { parentTxid, childTxid: txid })
await adapters.postChildDb.create(parentTxid, { parentTxid, childTxid: txid })
await handlePost({ ...ctx, decoded: { ...decoded, pushDatas: [pushDatas[0], pushDatas[2]] } })
}
+20
View File
@@ -0,0 +1,20 @@
import { utf8FromPush, logProcessError } from './helpers.js'
import { MAX_POST_SIZE } from '../../lib/memo-codes.js'
export async function handleSetName (ctx) {
const { adapters, txid, signerAddr, decoded, seen } = ctx
const { pushDatas } = decoded
if (pushDatas.length !== 2) {
await logProcessError(adapters, txid, `invalid set name push data count ${pushDatas.length}`)
return
}
const name = utf8FromPush(pushDatas[1])
if (name.length > MAX_POST_SIZE) {
await logProcessError(adapters, txid, 'set name too large')
return
}
await adapters.nameDb.create(signerAddr, { name, txid, seen, addr: signerAddr })
}
@@ -0,0 +1,20 @@
import { utf8FromPush, logProcessError } from './helpers.js'
import { MAX_POST_SIZE } from '../../lib/memo-codes.js'
export async function handleSetProfilePic (ctx) {
const { adapters, txid, signerAddr, decoded, seen } = ctx
const { pushDatas } = decoded
if (pushDatas.length !== 2) {
await logProcessError(adapters, txid, `invalid profile pic push data count ${pushDatas.length}`)
return
}
const url = utf8FromPush(pushDatas[1])
if (url.length > MAX_POST_SIZE) {
await logProcessError(adapters, txid, 'profile pic url too large')
return
}
await adapters.profilePicDb.create(signerAddr, { url, txid, seen, addr: signerAddr })
}
+20
View File
@@ -0,0 +1,20 @@
import { utf8FromPush, logProcessError } from './helpers.js'
import { MAX_POST_SIZE } from '../../lib/memo-codes.js'
export async function handleSetProfile (ctx) {
const { adapters, txid, signerAddr, decoded, seen } = ctx
const { pushDatas } = decoded
if (pushDatas.length !== 2) {
await logProcessError(adapters, txid, `invalid profile push data count ${pushDatas.length}`)
return
}
const text = utf8FromPush(pushDatas[1])
if (text.length > MAX_POST_SIZE) {
await logProcessError(adapters, txid, 'profile too large')
return
}
await adapters.profileDb.create(signerAddr, { text, txid, seen, addr: signerAddr })
}
@@ -0,0 +1,24 @@
import { utf8FromPush, logProcessError, roomKey } from './helpers.js'
import { PREFIX_TOPIC_UNFOLLOW } from '../../lib/memo-codes.js'
export async function handleTopicFollow (ctx) {
const { adapters, txid, signerAddr, decoded, seen } = ctx
const { pushDatas, prefix } = decoded
if (pushDatas.length !== 2) {
await logProcessError(adapters, txid, `invalid topic follow push data count ${pushDatas.length}`)
return
}
const room = utf8FromPush(pushDatas[1])
const unfollow = prefix[1] === PREFIX_TOPIC_UNFOLLOW[1]
await adapters.roomDb.create(roomKey(room, signerAddr), {
room,
addr: signerAddr,
unfollow,
txid,
seen,
type: 'follow'
})
}
@@ -0,0 +1,27 @@
import { utf8FromPush, logProcessError, roomKey } from './helpers.js'
import { MAX_POST_SIZE } from '../../lib/memo-codes.js'
import { handlePost } from './post.js'
export async function handleTopicMessage (ctx) {
const { adapters, txid, decoded, seen } = ctx
const { pushDatas } = decoded
if (pushDatas.length !== 3) {
await logProcessError(adapters, txid, `invalid topic message push data count ${pushDatas.length}`)
return
}
const room = utf8FromPush(pushDatas[1])
const message = utf8FromPush(pushDatas[2])
if ((room.length + message.length) > MAX_POST_SIZE) {
await logProcessError(adapters, txid, 'topic message too large')
return
}
await handlePost({
...ctx,
decoded: { ...decoded, pushDatas: [pushDatas[0], pushDatas[2]] }
})
await adapters.roomDb.create(roomKey(room, txid), { room, txid, seen, type: 'post' })
}
+44
View File
@@ -0,0 +1,44 @@
/*
Filter block transactions for Memo OP_RETURN outputs.
*/
import PQueue from 'p-queue'
import pRetry from 'p-retry'
class FilterBlock {
constructor (localConfig = {}) {
if (!localConfig.adapters) {
throw new Error('Adapters required for filter-block.js')
}
this.adapters = localConfig.adapters
this.pQueue = new PQueue({ concurrency: 20 })
this.pRetry = pRetry
this.attempts = 5
this.filterMemoTxs = this.filterMemoTxs.bind(this)
}
async retryWrapper (funcHandle, inputObj) {
return this.pRetry(async () => funcHandle(inputObj), {
retries: this.attempts,
onFailedAttempt: (error) => {
console.log(`Attempt ${error.attemptNumber} failed. ${error.retriesLeft} retries left.`)
}
})
}
async filterMemoTxs (txids) {
const memoTxs = []
const tasks = txids.map((txid) => async () => {
const isMemo = await this.retryWrapper(
this.adapters.transaction.isMemoTx.bind(this.adapters.transaction),
txid
)
if (isMemo) memoTxs.push(txid)
})
await this.pQueue.addAll(tasks)
return memoTxs
}
}
export default FilterBlock
+98
View File
@@ -0,0 +1,98 @@
/*
Business logic for indexing blocks with Memo transactions.
*/
import RetryQueue from '@chris.troutner/retry-queue'
import FilterBlock from './filter-block.js'
import { findMemoOutputs, getSignerAddress } from '../lib/memo-parser.js'
import { dispatchMemoAction } from './action-types/index.js'
class IndexBlocks {
constructor (localConfig = {}) {
if (!localConfig.adapters) {
throw new Error('Adapters required for index-blocks.js')
}
this.adapters = localConfig.adapters
this.filterBlock = new FilterBlock({ adapters: this.adapters })
this.retryQueue = new RetryQueue()
this.processBlock = this.processBlock.bind(this)
this.processMemoTx = this.processMemoTx.bind(this)
this.processMemoTxs = this.processMemoTxs.bind(this)
}
async processMemoTx (txid, blockHeight) {
try {
try {
await this.adapters.ptxDb.get(txid)
return true
} catch (err) {
// not processed yet
}
const txDetails = await this.adapters.transaction.get(txid)
const signerAddr = getSignerAddress(txDetails)
if (!signerAddr) {
await this.adapters.processErrorDb.create(txid, {
error: 'could not find input address for memo tx',
ts: Date.now()
})
return false
}
const memoOutputs = findMemoOutputs(txDetails)
const seen = Date.now()
for (const decoded of memoOutputs) {
await dispatchMemoAction({
adapters: this.adapters,
txid,
txDetails,
signerAddr,
decoded,
seen,
blockHeight
})
}
await this.adapters.ptxDb.create(txid, { processedAt: seen, blockHeight })
return true
} catch (err) {
console.error(`Error processing memo tx ${txid}:`, err.message)
throw err
}
}
async processMemoTxs (txids, blockHeight) {
for (let i = 0; i < txids.length; i++) {
await this.processMemoTx(txids[i], blockHeight)
}
return true
}
async processBlock (blockHeight) {
const blockHash = await this.retryQueue.addToQueue(
this.adapters.rpc.getBlockHash,
{ height: blockHeight }
)
const block = await this.retryQueue.addToQueue(
this.adapters.rpc.getBlock,
{ hash: blockHash }
)
const txs = block.tx
const now = new Date()
console.log(
`\nIndexing block ${blockHeight} with ${txs.length} txs. ${now.toLocaleString()}`
)
const memoTxs = await this.filterBlock.filterMemoTxs(txs)
if (memoTxs.length) {
console.log(`Memo txs in block: ${memoTxs.length}`)
await this.processMemoTxs(memoTxs, blockHeight)
}
return true
}
}
export default IndexBlocks
+34
View File
@@ -0,0 +1,34 @@
import RetryQueue from '@chris.troutner/retry-queue'
class State {
constructor (localConfig = {}) {
if (!localConfig.adapters) {
throw new Error('Adapters required for state.js')
}
this.adapters = localConfig.adapters
this.retryQueue = new RetryQueue()
this.getStatus = this.getStatus.bind(this)
this.updateIndexedBlockHeight = this.updateIndexedBlockHeight.bind(this)
}
async getStatus () {
return this.adapters.statusDb.getStatus()
}
async updateIndexedBlockHeight (inObj = {}) {
const { lastIndexedBlockHeight } = inObj
const status = await this.adapters.statusDb.getStatus()
if (status.syncedBlockHeight !== (lastIndexedBlockHeight - 1)) {
throw new Error(
`Expected synced block height ${lastIndexedBlockHeight - 1}, got ${status.syncedBlockHeight}`
)
}
status.syncedBlockHeight = lastIndexedBlockHeight
await this.adapters.statusDb.updateStatus(status)
return lastIndexedBlockHeight + 1
}
}
export default State
+25
View File
@@ -0,0 +1,25 @@
import RetryQueue from '@chris.troutner/retry-queue'
import IndexBlocks from './index-blocks.js'
import State from './state.js'
import Utils from './utils.js'
class UseCases {
constructor (localConfig = {}) {
if (!localConfig.adapters) {
throw new Error('Adapters required for use cases.')
}
this.adapters = localConfig.adapters
this.indexBlocks = new IndexBlocks({ adapters: this.adapters })
this.state = new State({ adapters: this.adapters })
this.utils = new Utils()
this.retryQueue = new RetryQueue()
this.initUseCases = this.initUseCases.bind(this)
}
async initUseCases () {
console.log('Use cases initialized.')
return true
}
}
export default UseCases
+7
View File
@@ -0,0 +1,7 @@
class Utils {
sleep (ms = 1000) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
}
export default Utils
@@ -0,0 +1,29 @@
import { assert } from 'chai'
import sinon from 'sinon'
import Transaction from '../../../src/adapters/transaction.js'
describe('#Transaction', () => {
let uut
let sandbox
beforeEach(() => {
sandbox = sinon.createSandbox()
uut = new Transaction()
})
afterEach(() => sandbox.restore())
it('should detect memo tx', async () => {
sandbox.stub(uut, 'getTxData').resolves({
vout: [{
scriptPubKey: {
hex: '6a026d020474657374'
}
}],
vin: []
})
const result = await uut.isMemoTx('testtx')
assert.equal(result, true)
})
})
+41
View File
@@ -0,0 +1,41 @@
import { assert } from 'chai'
import {
decodeMemoOpReturn,
parseScriptPushDatas,
isMemoScript
} from '../../../src/lib/memo-parser.js'
import { CODE_POST, CODE_PREFIX } from '../../../src/lib/memo-codes.js'
// OP_RETURN post from Go post_test.go: 6d02 + "Post message"
const POST_OP_RETURN_HEX = '6a026d020c506f7374206d657373616765'
describe('#memo-parser', () => {
describe('#parseScriptPushDatas', () => {
it('should parse OP_RETURN push data', () => {
const pushDatas = parseScriptPushDatas(POST_OP_RETURN_HEX)
assert.equal(pushDatas.length, 2)
assert.equal(pushDatas[0][0], CODE_PREFIX)
assert.equal(pushDatas[0][1], CODE_POST)
assert.equal(pushDatas[1].toString('utf8'), 'Post message')
})
})
describe('#decodeMemoOpReturn', () => {
it('should decode a memo post script', () => {
const decoded = decodeMemoOpReturn(POST_OP_RETURN_HEX)
assert.equal(decoded.action, 'post')
assert.equal(decoded.pushDatas[1].toString('utf8'), 'Post message')
})
it('should return null for non-memo script', () => {
const decoded = decodeMemoOpReturn('76a91400')
assert.equal(decoded, null)
})
})
describe('#isMemoScript', () => {
it('should detect memo script', () => {
assert.equal(isMemoScript(POST_OP_RETURN_HEX), true)
})
})
})
@@ -0,0 +1,33 @@
import { assert } from 'chai'
import sinon from 'sinon'
import { handlePost } from '../../../../src/use-cases/action-types/post.js'
import { PREFIX_POST } from '../../../../src/lib/memo-codes.js'
describe('#handlePost', () => {
it('should save a post to the database', async () => {
const create = sinon.stub().resolves({ success: true })
const get = sinon.stub().rejects(new Error('not found'))
const adapters = {
postDb: { create, get },
processErrorDb: { create: sinon.stub() }
}
const message = Buffer.from('hello memo')
await handlePost({
adapters,
txid: 'abc123',
signerAddr: 'bitcoincash:qptest',
seen: 1000,
decoded: {
action: 'post',
prefix: PREFIX_POST,
pushDatas: [PREFIX_POST, message]
}
})
assert.equal(create.callCount, 1)
assert.equal(create.firstCall.args[0], 'abc123')
assert.equal(create.firstCall.args[1].text, 'hello memo')
})
})
+29
View File
@@ -0,0 +1,29 @@
import { assert } from 'chai'
import sinon from 'sinon'
import FilterBlock from '../../../src/use-cases/filter-block.js'
describe('#FilterBlock', () => {
let uut
let sandbox
beforeEach(() => {
sandbox = sinon.createSandbox()
const adapters = {
transaction: {
isMemoTx: sandbox.stub()
}
}
adapters.transaction.isMemoTx.onCall(0).resolves(true)
adapters.transaction.isMemoTx.onCall(1).resolves(false)
adapters.transaction.isMemoTx.onCall(2).resolves(true)
uut = new FilterBlock({ adapters })
})
afterEach(() => sandbox.restore())
it('should filter memo txs from block tx list', async () => {
const result = await uut.filterMemoTxs(['tx1', 'tx2', 'tx3'])
assert.deepEqual(result, ['tx1', 'tx3'])
})
})