mirror of
https://github.com/Permissionless-Software-Foundation/psf-memo-indexer.git
synced 2026-09-21 16:52:02 -07:00
first commit
This commit is contained in:
@@ -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
|
||||
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
.env
|
||||
coverage/
|
||||
logs/
|
||||
@@ -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"]
|
||||
@@ -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"]
|
||||
@@ -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/>.
|
||||
@@ -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
|
||||
@@ -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'
|
||||
}
|
||||
Generated
+10131
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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}`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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]] } })
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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' })
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user