├── _config.yml ├── .eslintignore ├── .dockerignore ├── src ├── types │ ├── jsonrpc │ │ ├── IMessage.ts │ │ ├── IRequestMessage.ts │ │ └── IResponseMessage.ts │ ├── cyphernode │ │ ├── IOutput.ts │ │ ├── IBatcherIdent.ts │ │ ├── IBatchState.ts │ │ ├── IRespSpend.ts │ │ ├── IRespGetBatcher.ts │ │ ├── IRespBatchSpend.ts │ │ ├── IRespAddToBatch.ts │ │ ├── IRespGetBatchDetails.ts │ │ ├── IBatchTx.ts │ │ ├── IReqSpend.ts │ │ ├── ITx.ts │ │ ├── IBatcher.ts │ │ ├── IReqGetBatchDetails.ts │ │ ├── IAddToBatchResult.ts │ │ ├── IReqBatchSpend.ts │ │ ├── IReqAddToBatch.ts │ │ └── IBatchDetails.ts │ ├── IReqGetBatchDetails.ts │ ├── IReqExecuteBatch.ts │ ├── IGetBatchDetailsResult.ts │ ├── IExecuteBatchResult.ts │ ├── IReqDequeueAndPay.ts │ ├── IRespBatchRequest.ts │ ├── IRespExecuteBatch.ts │ ├── IRespDequeueAndPay.ts │ ├── IReqBatchRequest.ts │ ├── IBatchRequestResult.ts │ ├── IDequeueAndPayResult.ts │ └── IRespGetBatchDetails.ts ├── index.ts ├── validators │ ├── DequeueAndPayValidator.ts │ ├── ExecuteBatchValidator.ts │ ├── GetBatchDetailsValidator.ts │ └── QueueForNextBatchValidator.ts ├── config │ ├── BatcherConfig.ts │ └── batcher.sql ├── lib │ ├── Log2File.ts │ ├── Utils.ts │ ├── Scheduler.ts │ ├── BatcherDB.ts │ ├── HttpServer.ts │ ├── CyphernodeClient.ts │ └── Batcher.ts └── entity │ ├── Batch.ts │ └── BatchRequest.ts ├── .github └── dependabot.yml ├── .eslintrc.json ├── Dockerfile ├── cypherapps ├── data │ └── config.json └── docker-compose.yaml ├── .vscode └── launch.json ├── LICENSE ├── package.json ├── .gitignore ├── docker-build.sh ├── doc ├── CONTRIBUTING.md └── INSTALL.md ├── tsconfig.json └── README.md /_config.yml: -------------------------------------------------------------------------------- 1 | theme: jekyll-theme-minimal -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | build 3 | 4 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | Dockerfile 2 | node_modules 3 | build 4 | -------------------------------------------------------------------------------- /src/types/jsonrpc/IMessage.ts: -------------------------------------------------------------------------------- 1 | export default interface IMessage { 2 | jsonrpc: string; 3 | } 4 | -------------------------------------------------------------------------------- /src/types/cyphernode/IOutput.ts: -------------------------------------------------------------------------------- 1 | export default interface IOutput { 2 | outputId?: number; 3 | } 4 | -------------------------------------------------------------------------------- /src/types/cyphernode/IBatcherIdent.ts: -------------------------------------------------------------------------------- 1 | export default interface IBatcherIdent { 2 | batcherId?: number; 3 | batcherLabel?: string; 4 | } 5 | -------------------------------------------------------------------------------- /src/types/IReqGetBatchDetails.ts: -------------------------------------------------------------------------------- 1 | export default interface IReqGetBatchDetails { 2 | batchRequestId?: number; 3 | batchId?: number; 4 | } 5 | -------------------------------------------------------------------------------- /src/types/cyphernode/IBatchState.ts: -------------------------------------------------------------------------------- 1 | export default interface IBatchState { 2 | nbOutputs?: number; 3 | oldest?: Date; 4 | total?: number; 5 | } 6 | -------------------------------------------------------------------------------- /src/types/IReqExecuteBatch.ts: -------------------------------------------------------------------------------- 1 | export default interface IReqExecuteBatch { 2 | batchId?: number; 3 | batchRequestId?: number; 4 | confTarget?: number; 5 | } 6 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "npm" 4 | directory: "/" 5 | schedule: 6 | interval: "daily" 7 | target-branch: "dev" 8 | -------------------------------------------------------------------------------- /src/types/IGetBatchDetailsResult.ts: -------------------------------------------------------------------------------- 1 | import { Batch } from "../entity/Batch"; 2 | 3 | export default interface IGetBatchDetailsResult { 4 | batch: Batch; 5 | etaSeconds?: number; 6 | } 7 | -------------------------------------------------------------------------------- /src/types/cyphernode/IRespSpend.ts: -------------------------------------------------------------------------------- 1 | import { IResponseError } from "../jsonrpc/IResponseMessage"; 2 | import ITx from "./ITx"; 3 | 4 | export default interface IRespSpend { 5 | result?: ITx; 6 | error?: IResponseError; 7 | } 8 | -------------------------------------------------------------------------------- /src/types/IExecuteBatchResult.ts: -------------------------------------------------------------------------------- 1 | import IBatchDetails from "./cyphernode/IBatchDetails"; 2 | import { Batch } from "../entity/Batch"; 3 | 4 | export default interface IExecuteBatchResult { 5 | batch: Batch; 6 | cnResult: IBatchDetails; 7 | } 8 | -------------------------------------------------------------------------------- /src/types/IReqDequeueAndPay.ts: -------------------------------------------------------------------------------- 1 | export default interface IReqDequeueAndPay { 2 | batchRequestId: number; 3 | address?: string; 4 | amount?: number; 5 | confTarget?: number; 6 | replaceable?: boolean; 7 | subtractfeefromamount?: boolean; 8 | } 9 | -------------------------------------------------------------------------------- /src/types/cyphernode/IRespGetBatcher.ts: -------------------------------------------------------------------------------- 1 | import IBatcher from "./IBatcher"; 2 | import { IResponseError } from "../jsonrpc/IResponseMessage"; 3 | 4 | export default interface IRespGetBatcher { 5 | result?: IBatcher; 6 | error?: IResponseError; 7 | } 8 | -------------------------------------------------------------------------------- /src/types/cyphernode/IRespBatchSpend.ts: -------------------------------------------------------------------------------- 1 | import IBatchDetails from "./IBatchDetails"; 2 | import { IResponseError } from "../jsonrpc/IResponseMessage"; 3 | 4 | export default interface IRespBatchSpend { 5 | result?: IBatchDetails; 6 | error?: IResponseError; 7 | } 8 | -------------------------------------------------------------------------------- /src/types/IRespBatchRequest.ts: -------------------------------------------------------------------------------- 1 | import IBatchRequestResult from "./IBatchRequestResult"; 2 | import { IResponseError } from "./jsonrpc/IResponseMessage"; 3 | 4 | export default interface IRespBatchRequest { 5 | result?: IBatchRequestResult; 6 | error?: IResponseError; 7 | } 8 | -------------------------------------------------------------------------------- /src/types/IRespExecuteBatch.ts: -------------------------------------------------------------------------------- 1 | import { IResponseError } from "./jsonrpc/IResponseMessage"; 2 | import IExecuteBatchResult from "./IExecuteBatchResult"; 3 | 4 | export default interface IRespExecuteBatch { 5 | result?: IExecuteBatchResult; 6 | error?: IResponseError; 7 | } 8 | -------------------------------------------------------------------------------- /src/types/cyphernode/IRespAddToBatch.ts: -------------------------------------------------------------------------------- 1 | import { IResponseError } from "../jsonrpc/IResponseMessage"; 2 | import IAddToBatchResult from "./IAddToBatchResult"; 3 | 4 | export default interface IRespAddToBatch { 5 | result?: IAddToBatchResult; 6 | error?: IResponseError; 7 | } 8 | -------------------------------------------------------------------------------- /src/types/cyphernode/IRespGetBatchDetails.ts: -------------------------------------------------------------------------------- 1 | import IBatchDetails from "./IBatchDetails"; 2 | import { IResponseError } from "../jsonrpc/IResponseMessage"; 3 | 4 | export default interface IRespGetBatchDetails { 5 | result?: IBatchDetails; 6 | error?: IResponseError; 7 | } 8 | -------------------------------------------------------------------------------- /src/types/IRespDequeueAndPay.ts: -------------------------------------------------------------------------------- 1 | import { IResponseError } from "./jsonrpc/IResponseMessage"; 2 | import IDequeueAndPayResult from "./IDequeueAndPayResult"; 3 | 4 | export default interface IRespDequeueAndPay { 5 | result?: IDequeueAndPayResult; 6 | error?: IResponseError; 7 | } 8 | -------------------------------------------------------------------------------- /src/types/cyphernode/IBatchTx.ts: -------------------------------------------------------------------------------- 1 | export default interface IBatchTx { 2 | txid?: string; 3 | hash?: string; 4 | details?: { 5 | firstseen: Date; 6 | size: number; 7 | vsize: number; 8 | replaceable: boolean; 9 | fee: number; 10 | }; 11 | outputs?: []; 12 | } 13 | -------------------------------------------------------------------------------- /src/types/IReqBatchRequest.ts: -------------------------------------------------------------------------------- 1 | import IBatcherIdent from "./cyphernode/IBatcherIdent"; 2 | 3 | export default interface IReqBatchRequest extends IBatcherIdent { 4 | externalId?: string; 5 | description?: string; 6 | address: string; 7 | amount: number; 8 | webhookUrl?: string; 9 | } 10 | -------------------------------------------------------------------------------- /src/types/IBatchRequestResult.ts: -------------------------------------------------------------------------------- 1 | import IAddToBatchResult from "./cyphernode/IAddToBatchResult"; 2 | 3 | export default interface IBatchRequestResult { 4 | batchRequestId: number; 5 | batchId: number; 6 | etaSeconds: number; 7 | cnResult: IAddToBatchResult; 8 | address: string; 9 | amount: number; 10 | } 11 | -------------------------------------------------------------------------------- /src/types/cyphernode/IReqSpend.ts: -------------------------------------------------------------------------------- 1 | export default interface IReqSpend { 2 | // - address, required, desination address 3 | // - amount, required, amount to send to the destination address 4 | 5 | address: string; 6 | amount: number; 7 | confTarget?: number; 8 | replaceable?: boolean; 9 | subtractfeefromamount?: boolean; 10 | } 11 | -------------------------------------------------------------------------------- /src/types/cyphernode/ITx.ts: -------------------------------------------------------------------------------- 1 | export default interface ITx { 2 | txid?: string; 3 | hash?: string; 4 | details?: { 5 | address: string; 6 | amount: number; 7 | firstseen: Date; 8 | size: number; 9 | vsize: number; 10 | replaceable: boolean; 11 | fee: number; 12 | subtractfeefromamount: boolean; 13 | }; 14 | } 15 | -------------------------------------------------------------------------------- /src/types/IDequeueAndPayResult.ts: -------------------------------------------------------------------------------- 1 | import IBatchRequestResult from "./IBatchRequestResult"; 2 | import ITx from "./cyphernode/ITx"; 3 | import { IResponseError } from "./jsonrpc/IResponseMessage"; 4 | 5 | export default interface IDequeueAndPayResult { 6 | dequeueResult: IBatchRequestResult; 7 | spendResult: { result?: ITx; error?: IResponseError }; 8 | } 9 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { HttpServer } from "./lib/HttpServer"; 2 | import logger from "./lib/Log2File"; 3 | 4 | const setup = async (): Promise => { 5 | logger.debug("setup"); 6 | }; 7 | 8 | const main = async (): Promise => { 9 | await setup(); 10 | 11 | const httpServer = new HttpServer(); 12 | httpServer.start(); 13 | }; 14 | 15 | main(); 16 | -------------------------------------------------------------------------------- /src/validators/DequeueAndPayValidator.ts: -------------------------------------------------------------------------------- 1 | import IReqDequeueAndPay from "../types/IReqDequeueAndPay"; 2 | 3 | class DequeueAndPayValidator { 4 | static validateRequest(request: IReqDequeueAndPay): boolean { 5 | if (request.batchRequestId) { 6 | return true; 7 | } else { 8 | return false; 9 | } 10 | } 11 | } 12 | 13 | export { DequeueAndPayValidator }; 14 | -------------------------------------------------------------------------------- /src/types/jsonrpc/IRequestMessage.ts: -------------------------------------------------------------------------------- 1 | import IMessage from "./IMessage"; 2 | 3 | export interface IRequestMessage extends IMessage { 4 | /** 5 | * The request id. 6 | */ 7 | id: number | string; 8 | 9 | /** 10 | * The method to be invoked. 11 | */ 12 | method: string; 13 | 14 | /** 15 | * The method's params. 16 | */ 17 | params?: Array | object; 18 | } 19 | -------------------------------------------------------------------------------- /src/types/cyphernode/IBatcher.ts: -------------------------------------------------------------------------------- 1 | import IBatcherIdent from "./IBatcherIdent"; 2 | import IBatchState from "./IBatchState"; 3 | 4 | export default interface IBatcher extends IBatcherIdent, IBatchState { 5 | // "batcherId":1, 6 | // "batcherLabel":"default", 7 | // "confTarget":6, 8 | // "nbOutputs":12, 9 | // "oldest":123123, 10 | // "total":0.86990143 11 | 12 | confTarget?: number; 13 | } 14 | -------------------------------------------------------------------------------- /src/types/IRespGetBatchDetails.ts: -------------------------------------------------------------------------------- 1 | import { IResponseError } from "./jsonrpc/IResponseMessage"; 2 | // import IGetBatchDetailsResult from "./IGetBatchDetailsResult"; 3 | // import { Batch } from "../entity/Batch"; 4 | import IGetBatchDetailsResult from "./IGetBatchDetailsResult"; 5 | 6 | export default interface IRespGetBatchDetails { 7 | result?: IGetBatchDetailsResult; 8 | // result?: Batch; 9 | error?: IResponseError; 10 | } 11 | -------------------------------------------------------------------------------- /src/validators/ExecuteBatchValidator.ts: -------------------------------------------------------------------------------- 1 | import IReqExecuteBatch from "../types/IReqExecuteBatch"; 2 | 3 | class ExecuteBatchValidator { 4 | static validateRequest(request: IReqExecuteBatch): boolean { 5 | // For now, there's no validation really, if nothing supplied we'll use default batch 6 | if (request.batchId || request.batchRequestId) { 7 | return true; 8 | } else { 9 | return true; 10 | } 11 | } 12 | } 13 | 14 | export { ExecuteBatchValidator }; 15 | -------------------------------------------------------------------------------- /src/config/BatcherConfig.ts: -------------------------------------------------------------------------------- 1 | export default interface BatcherConfig { 2 | LOG: string; 3 | BASE_DIR: string; 4 | DATA_DIR: string; 5 | DB_NAME: string; 6 | URL_SERVER: string; 7 | URL_PORT: number; 8 | URL_CTX_WEBHOOKS: string; 9 | SESSION_TIMEOUT: number; 10 | CN_URL: string; 11 | CN_API_ID: string; 12 | CN_API_KEY: string; 13 | DEFAULT_BATCHER_ID: number; 14 | BATCH_TIMEOUT_MINUTES: number; 15 | CHECK_THRESHOLD_MINUTES: number; 16 | BATCH_THRESHOLD_AMOUNT: number; 17 | BATCH_CONF_TARGET: number; 18 | } 19 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "parserOptions": { 5 | }, 6 | "plugins": [ 7 | "@typescript-eslint", 8 | "prettier" 9 | ], 10 | "rules": { 11 | "prettier/prettier": "error", 12 | "@typescript-eslint/interface-name-prefix": ["off"] 13 | }, 14 | "extends": [ 15 | "eslint:recommended", 16 | "plugin:@typescript-eslint/eslint-recommended", 17 | "plugin:@typescript-eslint/recommended", 18 | "plugin:prettier/recommended", 19 | "prettier/@typescript-eslint" 20 | ] 21 | } 22 | -------------------------------------------------------------------------------- /src/validators/GetBatchDetailsValidator.ts: -------------------------------------------------------------------------------- 1 | import IReqGetBatchDetails from "../types/IReqGetBatchDetails"; 2 | 3 | class GetBatchDetailsValidator { 4 | static validateRequest(request: IReqGetBatchDetails): boolean { 5 | // For now, there's no validation really, if nothing supplied we'll use the ongoing batch on the default batcher 6 | if (request.batchId) { 7 | return true; 8 | } else if (request.batchRequestId) { 9 | return true; 10 | } else { 11 | return true; 12 | } 13 | } 14 | } 15 | 16 | export { GetBatchDetailsValidator }; 17 | -------------------------------------------------------------------------------- /src/types/cyphernode/IReqGetBatchDetails.ts: -------------------------------------------------------------------------------- 1 | import IBatcherIdent from "./IBatcherIdent"; 2 | 3 | export default interface IReqGetBatchDetails extends IBatcherIdent { 4 | // - batcherId, optional, id of the batcher, overrides batcherLabel, default batcher will be spent if not supplied 5 | // - batcherLabel, optional, label of the batcher, default batcher will be used if not supplied 6 | // - txid, optional, if you want the details of an executed batch, supply the batch txid, will return current pending batch 7 | // if not supplied 8 | 9 | txid?: string; 10 | } 11 | -------------------------------------------------------------------------------- /src/types/cyphernode/IAddToBatchResult.ts: -------------------------------------------------------------------------------- 1 | import IBatcherIdent from "./IBatcherIdent"; 2 | import IOutput from "./IOutput"; 3 | import IBatchState from "./IBatchState"; 4 | 5 | export default interface IAddToBatchResult 6 | extends IBatcherIdent, 7 | IOutput, 8 | IBatchState { 9 | // - batcherId, the id of the batcher 10 | // - outputId, the id of the added output 11 | // - nbOutputs, the number of outputs currently in the batch 12 | // - oldest, the timestamp of the oldest output in the batch 13 | // - total, the current sum of the batch's output amounts 14 | } 15 | -------------------------------------------------------------------------------- /src/lib/Log2File.ts: -------------------------------------------------------------------------------- 1 | import { ILogObject, Logger } from "tslog"; 2 | import { appendFileSync } from "fs"; 3 | 4 | function logToTransport(logObject: ILogObject): void { 5 | appendFileSync("logs/batcher.log", JSON.stringify(logObject) + "\n"); 6 | } 7 | 8 | const logger = new Logger(); 9 | logger.attachTransport( 10 | { 11 | silly: logToTransport, 12 | debug: logToTransport, 13 | trace: logToTransport, 14 | info: logToTransport, 15 | warn: logToTransport, 16 | error: logToTransport, 17 | fatal: logToTransport, 18 | }, 19 | "debug" 20 | ); 21 | 22 | export default logger; 23 | -------------------------------------------------------------------------------- /src/types/cyphernode/IReqBatchSpend.ts: -------------------------------------------------------------------------------- 1 | import IBatcherIdent from "./IBatcherIdent"; 2 | 3 | export default interface IReqBatchSpend extends IBatcherIdent { 4 | // - batcherId, optional, id of the batcher to execute, overrides batcherLabel, default batcher will be spent if not supplied 5 | // - batcherLabel, optional, label of the batcher to execute, default batcher will be executed if not supplied 6 | // - confTarget, optional, overrides default value of createbatcher, default to value of createbatcher, default Bitcoin Core conf_target will be used if not supplied 7 | 8 | confTarget?: number; 9 | } 10 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:14.11.0-alpine3.11 as build-base 2 | 3 | WORKDIR /batcher 4 | 5 | COPY package.json /batcher 6 | 7 | RUN apk add --update --no-cache --virtual .gyp \ 8 | python \ 9 | make \ 10 | g++ 11 | RUN npm install 12 | 13 | #-------------------------------------------------------------- 14 | 15 | FROM node:14.11.0-alpine3.11 16 | WORKDIR /batcher 17 | 18 | COPY --from=build-base /batcher/node_modules/ /batcher/node_modules/ 19 | COPY package.json /batcher 20 | COPY tsconfig.json /batcher 21 | COPY src /batcher/src 22 | 23 | RUN npm run build 24 | 25 | EXPOSE 9229 3000 26 | 27 | ENTRYPOINT [ "npm", "run", "start" ] 28 | -------------------------------------------------------------------------------- /cypherapps/data/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "LOG": "DEBUG", 3 | "BASE_DIR": "/batcher", 4 | "DATA_DIR": "data", 5 | "DB_NAME": "batcher.sqlite", 6 | "URL_SERVER": "http://batcher", 7 | "URL_PORT": 8000, 8 | "URL_CTX_WEBHOOKS": "webhooks", 9 | "SESSION_TIMEOUT": 600, 10 | "CN_URL": "https://gatekeeper:2009/v0", 11 | "CN_API_ID": "003", 12 | "CN_API_KEY": "39b83c35972aeb81a242bfe189dc0a22da5ac6cbb64072b492f2d46519a97618", 13 | "DEFAULT_BATCHER_ID": 1, 14 | "BATCH_TIMEOUT_MINUTES": 5, 15 | "CHECK_THRESHOLD_MINUTES": 1, 16 | "BATCH_THRESHOLD_AMOUNT": 0.1, 17 | "BATCH_CONF_TARGET": 6 18 | } 19 | -------------------------------------------------------------------------------- /src/validators/QueueForNextBatchValidator.ts: -------------------------------------------------------------------------------- 1 | import IReqBatchRequest from "../types/IReqBatchRequest"; 2 | 3 | class QueueForNextBatchValidator { 4 | static validateRequest(request: IReqBatchRequest): boolean { 5 | if (request.address && request.amount) { 6 | // Make sure there's not more than 8 decimals... 7 | // This makes sense when dealing with Lightning Network amounts... 8 | const nbDecimals = ((request.amount + "").split(".")[1] || []).length; 9 | if (nbDecimals > 8) { 10 | return false; 11 | } 12 | return true; 13 | } else { 14 | return false; 15 | } 16 | } 17 | } 18 | 19 | export { QueueForNextBatchValidator }; 20 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "node", 9 | "request": "attach", 10 | "name": "Attach to remote", 11 | "protocol": "inspector", 12 | "address": "localhost", 13 | "port": 9229, 14 | "sourceMaps": true, 15 | "localRoot": "${workspaceRoot}", 16 | "remoteRoot": "/batcher", 17 | "sourceMapPathOverrides": { 18 | "/usr/src/app/*": "${workspaceRoot}/*" 19 | } 20 | } 21 | ] 22 | } -------------------------------------------------------------------------------- /src/types/cyphernode/IReqAddToBatch.ts: -------------------------------------------------------------------------------- 1 | import IBatcherIdent from "./IBatcherIdent"; 2 | 3 | export default interface IReqAddToBatch extends IBatcherIdent { 4 | // - address, required, desination address 5 | // - amount, required, amount to send to the destination address 6 | // - outputLabel, optional, if you want to reference this output 7 | // - batcherId, optional, the id of the batcher to which the output will be added, default batcher if not supplied, overrides batcherLabel 8 | // - batcherLabel, optional, the label of the batcher to which the output will be added, default batcher if not supplied 9 | // - webhookUrl, optional, the webhook to call when the batch is broadcast 10 | 11 | address: string; 12 | amount: number; 13 | outputLabel?: string; 14 | webhookUrl?: string; 15 | } 16 | -------------------------------------------------------------------------------- /src/types/cyphernode/IBatchDetails.ts: -------------------------------------------------------------------------------- 1 | import IBatcher from "./IBatcher"; 2 | import IBatchTx from "./IBatchTx"; 3 | 4 | export default interface IBatchDetails extends IBatcher, IBatchTx { 5 | // "batcherId":34, 6 | // "batcherLabel":"Special batcher for a special client", 7 | // "confTarget":6, 8 | // "nbOutputs":83, 9 | // "oldest":123123, 10 | // "total":10.86990143, 11 | // "txid":"af867c86000da76df7ddb1054b273ca9e034e8c89d049b5b2795f9f590f67648", 12 | // "hash":"af867c86000da76df7ddb1054b273ca9e034e8c89d049b5b2795f9f590f67648", 13 | // "details":{ 14 | // "firstseen":123123, 15 | // "size":424, 16 | // "vsize":371, 17 | // "replaceable":true, 18 | // "fee":0.00004112 19 | // }, 20 | // "outputs":[ 21 | // "1abc":0.12, 22 | // "3abc":0.66, 23 | // "bc1abc":2.848, 24 | // ... 25 | // ] 26 | } 27 | -------------------------------------------------------------------------------- /cypherapps/docker-compose.yaml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | 3 | services: 4 | batcher: 5 | environment: 6 | - "TRACING=1" 7 | - "CYPHERNODE_URL=https://gatekeeper:${GATEKEEPER_PORT}" 8 | image: cyphernode/batcher:v0.2.1-local 9 | entrypoint: [ "npm", "run", "start:dev" ] 10 | volumes: 11 | - "$APP_SCRIPT_PATH/data:/batcher/data" 12 | - "$GATEKEEPER_DATAPATH/certs/cert.pem:/batcher/cert.pem:ro" 13 | - "$LOGS_DATAPATH:/batcher/logs" 14 | networks: 15 | - cyphernodeappsnet 16 | restart: always 17 | labels: 18 | - "traefik.docker.network=cyphernodeappsnet" 19 | - "traefik.frontend.rule=PathPrefixStrip:/batcher" 20 | - "traefik.frontend.passHostHeader=true" 21 | - "traefik.enable=true" 22 | - "traefik.port=8000" 23 | - "traefik.frontend.auth.basic.users=:$$2y$$05$$LFKGjKBkmWbI5RUFBqwonOWEcen4Yu.mU139fvD3flWcP8gUqLLaC" 24 | networks: 25 | cyphernodeappsnet: 26 | external: true 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Satoshi Portal 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/config/batcher.sql: -------------------------------------------------------------------------------- 1 | PRAGMA foreign_keys = ON; 2 | 3 | CREATE TABLE batch ( 4 | id INTEGER PRIMARY KEY AUTOINCREMENT, 5 | cn_batcher_id INTEGER, 6 | txid TEXT, 7 | spent_details TEXT, 8 | spent_ts INTEGER, 9 | created_ts INTEGER DEFAULT CURRENT_TIMESTAMP, 10 | updated_ts INTEGER DEFAULT CURRENT_TIMESTAMP 11 | ); 12 | CREATE INDEX idx_batch_cn_batcher_id ON batch (cn_batcher_id); 13 | CREATE INDEX idx_batch_txid ON batch (txid); 14 | 15 | CREATE TABLE batch_request ( 16 | id INTEGER PRIMARY KEY AUTOINCREMENT, 17 | external_id TEXT, 18 | description TEXT, 19 | address TEXT, 20 | amount REAL, 21 | cn_batcher_id INTEGER, 22 | cn_batcher_label TEXT, 23 | webhook_url TEXT, 24 | calledback INTEGER DEFAULT NULL, 25 | calledback_ts INTEGER, 26 | batch_id INTEGER REFERENCES batch, 27 | cn_output_id INTEGER, 28 | merged_output INTEGER, 29 | created_ts INTEGER DEFAULT CURRENT_TIMESTAMP, 30 | updated_ts INTEGER DEFAULT CURRENT_TIMESTAMP 31 | ); 32 | CREATE INDEX idx_batch_request_external_id ON batch_request (external_id); 33 | CREATE INDEX idx_batch_request_cn_batcher_id ON batch_request (cn_batcher_id); 34 | CREATE INDEX idx_batch_request_cn_output_id ON batch_request (cn_output_id); 35 | -------------------------------------------------------------------------------- /src/entity/Batch.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Entity, 3 | Column, 4 | PrimaryGeneratedColumn, 5 | OneToMany, 6 | Index, 7 | CreateDateColumn, 8 | UpdateDateColumn, 9 | } from "typeorm"; 10 | import { BatchRequest } from "./BatchRequest"; 11 | 12 | // CREATE TABLE batch ( 13 | // id INTEGER PRIMARY KEY AUTOINCREMENT, 14 | // cn_batcher_id INTEGER, 15 | // txid TEXT, 16 | // spent_details TEXT, 17 | // spent_ts INTEGER, 18 | // created_ts INTEGER DEFAULT CURRENT_TIMESTAMP, 19 | // updated_ts INTEGER DEFAULT CURRENT_TIMESTAMP 20 | // ); 21 | // CREATE INDEX idx_batch_cn_batcher_id ON batch (cn_batcher_id); 22 | // CREATE INDEX idx_batch_txid ON batch (txid); 23 | 24 | @Entity() 25 | export class Batch { 26 | @PrimaryGeneratedColumn({ name: "id" }) 27 | batchId!: number; 28 | 29 | @Index("idx_batch_cn_batcher_id") 30 | @Column({ type: "integer", name: "cn_batcher_id" }) 31 | cnBatcherId!: number; 32 | 33 | @Index("idx_batch_txid") 34 | @Column({ type: "text", name: "txid", nullable: true }) 35 | txid?: string; 36 | 37 | @Column({ type: "text", name: "spent_details", nullable: true }) 38 | spentDetails?: string; 39 | 40 | @Column({ type: "integer", name: "spent_ts", nullable: true }) 41 | spentTimestamp?: Date; 42 | 43 | @CreateDateColumn({ type: "integer", name: "created_ts" }) 44 | createdAt?: Date; 45 | 46 | @UpdateDateColumn({ type: "integer", name: "updated_ts" }) 47 | updatedAt?: Date; 48 | 49 | @OneToMany(() => BatchRequest, (batchRequest) => batchRequest.batch) 50 | batchRequests!: BatchRequest[]; 51 | } 52 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "batcher", 3 | "version": "0.1.0", 4 | "description": "", 5 | "main": "app.ts", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1", 8 | "build": "rimraf ./build && tsc", 9 | "start:dev": "node --inspect=0.0.0.0:9229 --require ts-node/register ./src/index.ts", 10 | "start": "npm run build && node build/index.js", 11 | "lint": "eslint . --ext .ts", 12 | "lintfix": "eslint . --ext .ts --fix" 13 | }, 14 | "repository": { 15 | "type": "git", 16 | "url": "git+https://github.com/SatoshiPortal/batcher.git" 17 | }, 18 | "author": "", 19 | "license": "MIT", 20 | "bugs": { 21 | "url": "https://github.com/SatoshiPortal/batcher/issues" 22 | }, 23 | "homepage": "https://github.com/SatoshiPortal/batcher#readme", 24 | "dependencies": { 25 | "@types/async-lock": "^1.1.2", 26 | "async-lock": "^1.2.4", 27 | "axios": "^0.26.1", 28 | "better-sqlite3": "^7.5.0", 29 | "express": "^4.17.1", 30 | "http-status-codes": "^2.2.0", 31 | "reflect-metadata": "^0.1.13", 32 | "tslog": "^3.2.0", 33 | "typeorm": "^0.3.4" 34 | }, 35 | "devDependencies": { 36 | "@types/express": "^4.17.6", 37 | "@types/node": "^17.0.23", 38 | "@types/sqlite3": "^3.1.6", 39 | "@typescript-eslint/eslint-plugin": "^2.24.0", 40 | "@typescript-eslint/parser": "^2.24.0", 41 | "eslint": "^6.8.0", 42 | "eslint-config-prettier": "^6.11.0", 43 | "eslint-plugin-prettier": "^3.1.4", 44 | "prettier": "2.6.1", 45 | "rimraf": "^3.0.2", 46 | "ts-node": "^10.7.0", 47 | "typescript": "^4.6.3" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/types/jsonrpc/IResponseMessage.ts: -------------------------------------------------------------------------------- 1 | import IMessage from "./IMessage"; 2 | 3 | export interface IResponseMessage extends IMessage { 4 | /** 5 | * The request id. 6 | */ 7 | id: number | string | null; 8 | 9 | /** 10 | * The result of a request. This member is REQUIRED on success. 11 | * This member MUST NOT exist if there was an error invoking the method. 12 | */ 13 | result?: string | number | boolean | object | null; 14 | 15 | /** 16 | * The error object in case a request fails. 17 | */ 18 | error?: IResponseError; 19 | } 20 | 21 | export interface IResponseError { 22 | /** 23 | * A number indicating the error type that occurred. 24 | */ 25 | code: number; 26 | 27 | /** 28 | * A string providing a short description of the error. 29 | */ 30 | message: string; 31 | 32 | /** 33 | * A Primitive or Structured value that contains additional 34 | * information about the error. Can be omitted. 35 | */ 36 | data?: D; 37 | } 38 | 39 | // eslint-disable-next-line @typescript-eslint/no-namespace 40 | export namespace ErrorCodes { 41 | // Defined by JSON RPC 42 | export const ParseError = -32700; 43 | export const InvalidRequest = -32600; 44 | export const MethodNotFound = -32601; 45 | export const InvalidParams = -32602; 46 | export const InternalError = -32603; 47 | export const serverErrorStart = -32099; 48 | export const serverErrorEnd = -32000; 49 | export const ServerNotInitialized = -32002; 50 | export const UnknownErrorCode = -32001; 51 | 52 | // Defined by the protocol. 53 | export const RequestCancelled = -32800; 54 | export const ContentModified = -32801; 55 | } 56 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # TypeScript v1 declaration files 45 | typings/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Microbundle cache 57 | .rpt2_cache/ 58 | .rts2_cache_cjs/ 59 | .rts2_cache_es/ 60 | .rts2_cache_umd/ 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env 73 | .env.test 74 | 75 | # parcel-bundler cache (https://parceljs.org/) 76 | .cache 77 | 78 | # Next.js build output 79 | .next 80 | 81 | # Nuxt.js build / generate output 82 | .nuxt 83 | dist 84 | 85 | # Gatsby files 86 | .cache/ 87 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 88 | # https://nextjs.org/blog/next-9-1#public-directory-support 89 | # public 90 | 91 | # vuepress build output 92 | .vuepress/dist 93 | 94 | # Serverless directories 95 | .serverless/ 96 | 97 | # FuseBox cache 98 | .fusebox/ 99 | 100 | # DynamoDB Local files 101 | .dynamodb/ 102 | 103 | # TernJS port file 104 | .tern-port 105 | 106 | build 107 | data 108 | -------------------------------------------------------------------------------- /src/lib/Utils.ts: -------------------------------------------------------------------------------- 1 | import logger from "./Log2File"; 2 | import axios, { AxiosError, AxiosRequestConfig } from "axios"; 3 | 4 | class Utils { 5 | static async post( 6 | url: string, 7 | postdata: unknown, 8 | addedOptions?: unknown 9 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 10 | ): Promise { 11 | logger.info("Utils.post", url, JSON.stringify(postdata), addedOptions); 12 | 13 | let configs: AxiosRequestConfig = { 14 | baseURL: url, 15 | method: "post", 16 | data: postdata, 17 | }; 18 | if (addedOptions) { 19 | configs = Object.assign(configs, addedOptions); 20 | } 21 | 22 | try { 23 | const response = await axios.request(configs); 24 | logger.debug( 25 | "Utils.post :: response.data =", 26 | JSON.stringify(response.data) 27 | ); 28 | 29 | return { status: response.status, data: response.data }; 30 | } catch (err) { 31 | if (axios.isAxiosError(err)) { 32 | const error: AxiosError = err; 33 | 34 | if (error.response) { 35 | // The request was made and the server responded with a status code 36 | // that falls out of the range of 2xx 37 | logger.info( 38 | "Utils.post :: error.response.data =", 39 | JSON.stringify(error.response.data) 40 | ); 41 | logger.info( 42 | "Utils.post :: error.response.status =", 43 | error.response.status 44 | ); 45 | logger.info( 46 | "Utils.post :: error.response.headers =", 47 | error.response.headers 48 | ); 49 | 50 | return { status: error.response.status, data: error.response.data }; 51 | } else if (error.request) { 52 | // The request was made but no response was received 53 | // `error.request` is an instance of XMLHttpRequest in the browser and an instance of 54 | // http.ClientRequest in node.js 55 | logger.info("Utils.post :: error.message =", error.message); 56 | 57 | return { status: -1, data: error.message }; 58 | } else { 59 | // Something happened in setting up the request that triggered an Error 60 | logger.info("Utils.post :: Error:", error.message); 61 | 62 | return { status: -2, data: error.message }; 63 | } 64 | } else { 65 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 66 | return { status: -2, data: (err as any).message }; 67 | } 68 | } 69 | } 70 | } 71 | 72 | export { Utils }; 73 | -------------------------------------------------------------------------------- /docker-build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Must be logged to docker hub: 4 | # docker login -u cyphernode 5 | 6 | # Must enable experimental cli features 7 | # "experimental": "enabled" in ~/.docker/config.json 8 | 9 | image() { 10 | local arch=$1 11 | 12 | echo "Building and pushing cyphernode/batcher for $arch tagging as ${version}..." 13 | 14 | docker build --no-cache -t cyphernode/batcher:${arch}-${version} . \ 15 | && docker push cyphernode/batcher:${arch}-${version} 16 | 17 | return $? 18 | } 19 | 20 | manifest() { 21 | echo "Creating and pushing manifest for cyphernode/batcher for version ${version}..." 22 | 23 | docker manifest create cyphernode/batcher:${version} \ 24 | cyphernode/batcher:${x86_docker}-${version} \ 25 | cyphernode/batcher:${arm_docker}-${version} \ 26 | cyphernode/batcher:${aarch64_docker}-${version} \ 27 | && docker manifest annotate cyphernode/batcher:${version} cyphernode/batcher:${arm_docker}-${version} --os linux --arch ${arm_docker} \ 28 | && docker manifest annotate cyphernode/batcher:${version} cyphernode/batcher:${x86_docker}-${version} --os linux --arch ${x86_docker} \ 29 | && docker manifest annotate cyphernode/batcher:${version} cyphernode/batcher:${aarch64_docker}-${version} --os linux --arch ${aarch64_docker} \ 30 | && docker manifest push -p cyphernode/batcher:${version} 31 | 32 | return $? 33 | } 34 | 35 | x86_docker="amd64" 36 | arm_docker="arm" 37 | aarch64_docker="arm64" 38 | 39 | version="v0.2.1" 40 | 41 | # Build amd64 and arm64 first, building for arm will trigger the manifest creation and push on hub 42 | 43 | echo -e "\nBuild ${v3} for:\n" 44 | echo "1) AMD 64 bits (Most PCs)" 45 | echo "2) ARM 64 bits (RPi4, Mac M1)" 46 | echo "3) ARM 32 bits (RPi2-3)" 47 | echo -en "\nYour choice (1, 2, 3): " 48 | read arch_input 49 | 50 | case "${arch_input}" in 51 | 1) 52 | arch_docker=${x86_docker} 53 | ;; 54 | 2) 55 | arch_docker=${aarch64_docker} 56 | ;; 57 | 3) 58 | arch_docker=${arm_docker} 59 | ;; 60 | *) 61 | echo "Not a valid choice." 62 | exit 1 63 | ;; 64 | esac 65 | 66 | echo -e "\nBuilding Batcher cypherapp\n" 67 | echo -e "arch_docker=$arch_docker\n" 68 | 69 | image ${arch_docker} 70 | 71 | [ $? -ne 0 ] && echo "Error" && exit 1 72 | 73 | [ "${arch_docker}" = "${x86_docker}" ] && echo "Built and pushed ${arch_docker} only" && exit 0 74 | [ "${arch_docker}" = "${aarch64_docker}" ] && echo "Built and pushed ${arch_docker} only" && exit 0 75 | [ "${arch_docker}" = "${arm_docker}" ] && echo "Built and pushed images, now building and pushing manifest for all archs..." 76 | 77 | manifest 78 | 79 | [ $? -ne 0 ] && echo "Error" && exit 1 80 | -------------------------------------------------------------------------------- /src/entity/BatchRequest.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Entity, 3 | Column, 4 | PrimaryGeneratedColumn, 5 | ManyToOne, 6 | JoinColumn, 7 | Index, 8 | CreateDateColumn, 9 | UpdateDateColumn, 10 | } from "typeorm"; 11 | import { Batch } from "./Batch"; 12 | 13 | // CREATE TABLE batch_request ( 14 | // id INTEGER PRIMARY KEY AUTOINCREMENT, 15 | // external_id TEXT, 16 | // description TEXT, 17 | // address TEXT, 18 | // amount REAL, 19 | // cn_batcher_id INTEGER, 20 | // cn_batcher_label TEXT, 21 | // webhook_url TEXT, 22 | // calledback INTEGER DEFAULT NULL, 23 | // calledback_ts INTEGER, 24 | // batch_id INTEGER REFERENCES batch, 25 | // cn_output_id INTEGER, 26 | // merged_output INTEGER, 27 | // created_ts INTEGER DEFAULT CURRENT_TIMESTAMP, 28 | // updated_ts INTEGER DEFAULT CURRENT_TIMESTAMP 29 | // ); 30 | // CREATE INDEX idx_batch_request_external_id ON batch_request (external_id); 31 | // CREATE INDEX idx_batch_request_cn_batcher_id ON batch_request (cn_batcher_id); 32 | // CREATE INDEX idx_batch_request_cn_output_id ON batch_request (cn_output_id); 33 | 34 | @Entity() 35 | export class BatchRequest { 36 | @PrimaryGeneratedColumn({ name: "id" }) 37 | batchRequestId!: number; 38 | 39 | @Index("idx_batch_request_external_id") 40 | @Column({ type: "text", name: "external_id", nullable: true }) 41 | externalId?: string; 42 | 43 | @Column({ type: "text", name: "description", nullable: true }) 44 | description?: string; 45 | 46 | @Column({ type: "text", name: "address" }) 47 | address!: string; 48 | 49 | @Column({ type: "real", name: "amount" }) 50 | amount!: number; 51 | 52 | @Index("idx_batch_request_cn_batcher_id") 53 | @Column({ type: "integer", name: "cn_batcher_id", nullable: true }) 54 | cnBatcherId?: number; 55 | 56 | @Column({ type: "text", name: "cn_batcher_label", nullable: true }) 57 | cnBatcherLabel?: string; 58 | 59 | @Column({ type: "text", name: "webhook_url", nullable: true }) 60 | webhookUrl?: string; 61 | 62 | @Column({ type: "integer", name: "calledback", nullable: true }) 63 | calledback?: boolean; 64 | 65 | @Column({ type: "integer", name: "calledback_ts", nullable: true }) 66 | calledbackTimestamp?: Date; 67 | 68 | @ManyToOne(() => Batch, (batch) => batch.batchRequests) 69 | @JoinColumn({ name: "batch_id" }) 70 | batch!: Batch; 71 | 72 | @Index("idx_batch_request_cn_output_id") 73 | @Column({ type: "integer", name: "cn_output_id", nullable: true }) 74 | cnOutputId?: number; 75 | 76 | @Column({ type: "integer", name: "merged_output", nullable: true }) 77 | mergedOutput?: boolean; 78 | 79 | @CreateDateColumn({ type: "integer", name: "created_ts" }) 80 | createdAt?: Date; 81 | 82 | @UpdateDateColumn({ type: "integer", name: "updated_ts" }) 83 | updatedAt?: Date; 84 | } 85 | -------------------------------------------------------------------------------- /doc/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # batcher 2 | 3 | - To remote debug the app, use start:dev entrypoint in docker-compose.yaml instead of default start 4 | - src/index.ts is the app entrypoint, it instantiates an Express HTTP server and start it 5 | - src/lib/HttpServer.ts is the main piece where the magic happens 6 | 7 | ## DBMS / ORM 8 | 9 | TypeORM and sqlite3 are used for persistance. The ER model has been automatically created by TypeORM from `entity/Batch.ts` and `entity/BatchRequest.ts`. 10 | 11 | ```asciimodel 12 | Batch 1---n BatchRequest 13 | ``` 14 | 15 | ## Types and JSON-RPC 16 | 17 | All the defined types can be found in `types/`. The types inside `cyphernode/` are used for Cyphernode's requests and responses. The types inside `jsonrpc/` are the based for the json-rpc standard for all the requests and responses. The other types are the ones related to this Batcher. 18 | 19 | ## Business logic 20 | 21 | The interesting code is found inside the `lib/` directory. 22 | 23 | ## Validators 24 | 25 | The validating classes can be found in `validators/` and are used to validate the inputs. 26 | 27 | ### HttpServer.ts 28 | 29 | The Express HTTP server, listening to `URL_PORT` port. There is two endpoints: 30 | 31 | - `/api`: used for batching functionalities. The `method` property is used to dispatch to the good endpoint. 32 | - `/webhooks`: used for when Cyphernode calls the webhooks. Delegates the webhooks to the upstream caller, which is usually the Batcher client. If batch requests have been merged because they had the same destination address, this is taken care here and multiple upstream webhooks can be triggered by one Cyphernode webhook. The exact URL context is set by the `URL_CTX_WEBHOOKS` config property. 33 | 34 | ### Batcher 35 | 36 | Baching business logic. We receive requests, we validate them, process them, persist, call Cyphernode, marshall responses and update the DB. 37 | 38 | ### BatcherDB 39 | 40 | Batcher database façade, everything DB related is in there. Batcher is using it. The corresponding sqlite file will be found in `DATA_DIR/DB_NAME` file. 41 | 42 | ### CyphernodeClient 43 | 44 | The Cyphernode client-side code, used to call our Cyphernode instance. It will use `CN_URL` as the gatekeeper's URL with `CN_API_ID` and `CN_API_KEY` for authentication/authorization. 45 | 46 | ### Scheduler 47 | 48 | Every `BATCH_TIMEOUT_MINUTES` minutes, the Scheduler will call executeBatch on the Batcher. Every `CHECK_THRESHOLD_MINUTES` minutes, the Scheduler will check if we have at least `BATCH_THRESHOLD_AMOUNT` bitcoins queued for the next batch and if so, it will call executeBatch on the Batcher. It will use `BATCH_CONF_TARGET` as the confTarget. 49 | 50 | ### Utils 51 | 52 | Currently only a post utility. Used by the Batcher to delegate-call the webhooks. 53 | 54 | ### logger 55 | 56 | Winston logging framework is used for logging. 57 | -------------------------------------------------------------------------------- /src/lib/Scheduler.ts: -------------------------------------------------------------------------------- 1 | import logger from "./Log2File"; 2 | import BatcherConfig from "../config/BatcherConfig"; 3 | import { Batcher } from "./Batcher"; 4 | import { Utils } from "./Utils"; 5 | 6 | class Scheduler { 7 | private _batcherConfig: BatcherConfig; 8 | private _startedAt = new Date().getTime(); 9 | 10 | constructor(batcherConfig: BatcherConfig) { 11 | this._batcherConfig = batcherConfig; 12 | } 13 | 14 | async configureScheduler(batcherConfig: BatcherConfig): Promise { 15 | this._batcherConfig = batcherConfig; 16 | this._startedAt = new Date().getTime(); 17 | } 18 | 19 | timeout(scheduler: Scheduler): void { 20 | logger.info("Scheduler.timeout"); 21 | 22 | scheduler._startedAt = new Date().getTime(); 23 | logger.debug( 24 | "Scheduler.timeout this._startedAt =", 25 | scheduler._startedAt 26 | ); 27 | 28 | const postdata = { 29 | id: 0, 30 | method: "executeBatch", 31 | params: { 32 | confTarget: scheduler._batcherConfig.BATCH_CONF_TARGET, 33 | }, 34 | }; 35 | 36 | Utils.post( 37 | scheduler._batcherConfig.URL_SERVER + 38 | ":" + 39 | scheduler._batcherConfig.URL_PORT + 40 | "/api", 41 | postdata 42 | ).then((res) => { 43 | logger.debug("Scheduler.timeout, res=", JSON.stringify(res)); 44 | }); 45 | } 46 | 47 | getTimeLeft(): number { 48 | logger.info("Scheduler.getTimeLeft"); 49 | 50 | const now = new Date().getTime(); 51 | logger.debug("Scheduler.getTimeLeft now =", now); 52 | logger.debug("Scheduler.getTimeLeft this._startedAt =", this._startedAt); 53 | 54 | const delta = now - this._startedAt; 55 | logger.debug("Scheduler.getTimeLeft delta =", delta); 56 | logger.debug( 57 | "Scheduler.getTimeLeft this._batcherConfig.BATCH_TIMEOUT_MINUTES * 60000 =", 58 | this._batcherConfig.BATCH_TIMEOUT_MINUTES * 60000 59 | ); 60 | 61 | return Math.round( 62 | (this._batcherConfig.BATCH_TIMEOUT_MINUTES * 60000 - delta) / 1000 63 | ); 64 | } 65 | 66 | checkThreshold(scheduler: Scheduler, batcher: Batcher): void { 67 | logger.info("Scheduler.checkThreshold"); 68 | 69 | batcher 70 | .getOngoingBatch(scheduler._batcherConfig.DEFAULT_BATCHER_ID, false) 71 | .then((ongoingBatch) => { 72 | if (ongoingBatch) { 73 | logger.debug("Scheduler.checkThreshold, ongoing batch!"); 74 | let total = 0.0; 75 | 76 | ongoingBatch.batchRequests.forEach((br) => { 77 | total += br.amount; 78 | }); 79 | logger.debug("Scheduler.checkThreshold, total =", total); 80 | 81 | if (total >= scheduler._batcherConfig.BATCH_THRESHOLD_AMOUNT) { 82 | logger.debug("Scheduler.checkThreshold, total >= threshold!"); 83 | 84 | scheduler._startedAt = new Date().getTime(); 85 | logger.debug( 86 | "Scheduler.checkThreshold this._startedAt =", 87 | scheduler._startedAt 88 | ); 89 | 90 | const postdata = { 91 | id: 0, 92 | method: "executeBatch", 93 | params: { 94 | confTarget: scheduler._batcherConfig.BATCH_CONF_TARGET, 95 | }, 96 | }; 97 | 98 | Utils.post( 99 | scheduler._batcherConfig.URL_SERVER + 100 | ":" + 101 | scheduler._batcherConfig.URL_PORT + 102 | "/api", 103 | postdata 104 | ).then((res) => { 105 | logger.debug( 106 | "Scheduler.checkThreshold, res=", 107 | JSON.stringify(res) 108 | ); 109 | }); 110 | } 111 | } 112 | }); 113 | } 114 | } 115 | 116 | export { Scheduler }; 117 | -------------------------------------------------------------------------------- /src/lib/BatcherDB.ts: -------------------------------------------------------------------------------- 1 | import logger from "./Log2File"; 2 | import path from "path"; 3 | import BatcherConfig from "../config/BatcherConfig"; 4 | import { Batch } from "../entity/Batch"; 5 | import { DataSource, IsNull } from "typeorm"; 6 | import { BatchRequest } from "../entity/BatchRequest"; 7 | 8 | class BatcherDB { 9 | private _db?: DataSource; 10 | 11 | constructor(batcherConfig: BatcherConfig) { 12 | this.configureDB(batcherConfig); 13 | } 14 | 15 | async configureDB(batcherConfig: BatcherConfig): Promise { 16 | logger.info("BatcherDB.configureDB", batcherConfig); 17 | 18 | if (this._db?.isInitialized) { 19 | await this._db.destroy(); 20 | } 21 | this._db = await this.initDatabase( 22 | path.resolve( 23 | batcherConfig.BASE_DIR, 24 | batcherConfig.DATA_DIR, 25 | batcherConfig.DB_NAME 26 | ) 27 | ); 28 | this._db.initialize(); 29 | } 30 | 31 | async initDatabase(dbName: string): Promise { 32 | logger.info("BatcherDB.initDatabase", dbName); 33 | 34 | return await new DataSource({ 35 | type: "better-sqlite3", 36 | database: dbName, 37 | entities: [Batch, BatchRequest], 38 | synchronize: true, 39 | logging: true, 40 | }); 41 | } 42 | 43 | async saveRequest(batchRequest: BatchRequest): Promise { 44 | const br = await this._db?.manager 45 | .getRepository(BatchRequest) 46 | .save(batchRequest); 47 | 48 | return br as BatchRequest; 49 | } 50 | 51 | async saveRequests(batchRequests: BatchRequest[]): Promise { 52 | const brs = await this._db?.manager 53 | .getRepository(BatchRequest) 54 | .save(batchRequests); 55 | 56 | return brs as BatchRequest[]; 57 | } 58 | 59 | async getRequest(batchRequestId: number): Promise { 60 | const br = await this._db?.manager 61 | .getRepository(BatchRequest) 62 | .findOne({ where: { batchRequestId }, relations: ["batch"] }); 63 | 64 | return br as BatchRequest; 65 | } 66 | 67 | async getRequestsByCnOutputId(cnOutputId: number): Promise { 68 | const br = await this._db?.manager 69 | .getRepository(BatchRequest) 70 | .find({ where: { cnOutputId }, relations: ["batch"] }); 71 | 72 | return br as BatchRequest[]; 73 | } 74 | 75 | async getRequestCountByBatchId(batchId: number): Promise { 76 | logger.info("BatcherDB.getRequestCountByBatchId, batchId:", batchId); 77 | 78 | const batch = await this.getBatch(batchId); 79 | 80 | if (batch && batch.batchRequests) { 81 | logger.debug("Batch found:", batch); 82 | 83 | const nb = batch.batchRequests.length; 84 | 85 | return nb as number; 86 | } 87 | return 0; 88 | } 89 | 90 | async removeRequest(batchRequest: BatchRequest): Promise { 91 | const br = await this._db?.manager 92 | .getRepository(BatchRequest) 93 | .remove(batchRequest); 94 | 95 | return br as BatchRequest; 96 | } 97 | 98 | async saveBatch(batch: Batch): Promise { 99 | const b = await this._db?.manager.getRepository(Batch).save(batch); 100 | 101 | return b as Batch; 102 | } 103 | 104 | async getBatch(batchId: number): Promise { 105 | const b = await this._db?.manager 106 | .getRepository(Batch) 107 | .findOne({ where: { batchId }, relations: ["batchRequests"] }); 108 | 109 | return b as Batch; 110 | } 111 | 112 | async getBatchByRequest(batchRequestId: number): Promise { 113 | const br = await this._db?.manager 114 | .getRepository(BatchRequest) 115 | .findOne({ where: { batchRequestId }, relations: ["batch"] }); 116 | 117 | const b = await this._db?.manager 118 | .getRepository(Batch) 119 | .findOne({ where: { batchId: br?.batch.batchId }, relations: ["batchRequests"] }); 120 | 121 | return b as Batch; 122 | } 123 | 124 | async getOngoingBatchByBatcherId(cnBatcherId: number): Promise { 125 | logger.info( 126 | "BatcherDB.getOngoingBatchByBatcherId, cnBatcherId:", 127 | cnBatcherId 128 | ); 129 | 130 | const b = await this._db?.manager 131 | .getRepository(Batch) 132 | .findOne({ where: { cnBatcherId, txid: IsNull() }, relations: ["batchRequests"] }); 133 | 134 | return b as Batch; 135 | } 136 | 137 | async getOngoingBatchRequestsByAddressAndBatcherId( 138 | address: string, 139 | cnBatcherId: number 140 | ): Promise { 141 | logger.info( 142 | "BatcherDB.getOngoingBatchRequestsByAddressAndBatcherId, address:", address, " cnBatcherId:", 143 | cnBatcherId 144 | ); 145 | 146 | const br = await this._db?.manager 147 | .getRepository(BatchRequest) 148 | .createQueryBuilder("batch_request") 149 | .innerJoin("batch_request.batch", "batch") 150 | .where({ address, cnBatcherId }) 151 | .andWhere("batch.txid IS NULL") 152 | .getMany(); 153 | 154 | return br as BatchRequest[]; 155 | } 156 | 157 | async getOngoingBatchRequestsByAddressAndBatcherLabel( 158 | address: string, 159 | cnBatcherLabel: string 160 | ): Promise { 161 | logger.info( 162 | "BatcherDB.getOngoingBatchRequestsByAddressAndBatcherLabel, address:", address, " cnBatcherLabel:", 163 | cnBatcherLabel 164 | ); 165 | 166 | const br = await this._db?.manager 167 | .getRepository(BatchRequest) 168 | .createQueryBuilder("batch_request") 169 | .innerJoin("batch_request.batch", "batch") 170 | .where({ address, cnBatcherLabel }) 171 | .andWhere("batch.txid IS NULL") 172 | .getMany(); 173 | 174 | return br as BatchRequest[]; 175 | } 176 | 177 | async getOngoingBatches(): Promise { 178 | const b = await this._db?.manager 179 | .getRepository(Batch) 180 | .find({ where: { txid: IsNull() }, relations: ["batchRequests"] }); 181 | 182 | return b as Batch[]; 183 | } 184 | } 185 | 186 | export { BatcherDB }; 187 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig.json to read more about this file */ 4 | 5 | /* Basic Options */ 6 | // "incremental": true, /* Enable incremental compilation */ 7 | "target": "es5", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */ 8 | "module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */ 9 | "lib": ["ES6"], /* Specify library files to be included in the compilation. */ 10 | // "allowJs": true, /* Allow javascript files to be compiled. */ 11 | // "checkJs": true, /* Report errors in .js files. */ 12 | // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ 13 | // "declaration": true, /* Generates corresponding '.d.ts' file. */ 14 | // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ 15 | "sourceMap": true, /* Generates corresponding '.map' file. */ 16 | // "outFile": "./", /* Concatenate and emit output to single file. */ 17 | "outDir": "./build", /* Redirect output structure to the directory. */ 18 | "rootDir": "./src", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 19 | // "composite": true, /* Enable project compilation */ 20 | // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ 21 | // "removeComments": true, /* Do not emit comments to output. */ 22 | // "noEmit": true, /* Do not emit outputs. */ 23 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 24 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 25 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 26 | 27 | /* Strict Type-Checking Options */ 28 | "strict": true, /* Enable all strict type-checking options. */ 29 | // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 30 | // "strictNullChecks": true, /* Enable strict null checks. */ 31 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */ 32 | // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ 33 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ 34 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 35 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 36 | 37 | /* Additional Checks */ 38 | // "noUnusedLocals": true, /* Report errors on unused locals. */ 39 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 40 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 41 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 42 | 43 | /* Module Resolution Options */ 44 | // "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 45 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 46 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 47 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 48 | // "typeRoots": [], /* List of folders to include type definitions from. */ 49 | // "types": [], /* Type declaration files to be included in compilation. */ 50 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 51 | "resolveJsonModule": true, 52 | "esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ 53 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 54 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 55 | 56 | /* Source Map Options */ 57 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 58 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 59 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 60 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 61 | 62 | /* Experimental Options */ 63 | "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 64 | "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 65 | 66 | /* Advanced Options */ 67 | "skipLibCheck": true, /* Skip type checking of declaration files. */ 68 | "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */ 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/lib/HttpServer.ts: -------------------------------------------------------------------------------- 1 | // lib/HttpServer.ts 2 | import express from "express"; 3 | import logger from "./Log2File"; 4 | import AsyncLock from "async-lock"; 5 | import BatcherConfig from "../config/BatcherConfig"; 6 | import fs from "fs"; 7 | import { Batcher } from "./Batcher"; 8 | import IReqBatchRequest from "../types/IReqBatchRequest"; 9 | import { 10 | IResponseMessage, 11 | ErrorCodes, 12 | } from "../types/jsonrpc/IResponseMessage"; 13 | import { IRequestMessage } from "../types/jsonrpc/IRequestMessage"; 14 | import IRespBatchRequest from "../types/IRespBatchRequest"; 15 | import IRespGetBatchDetails from "../types/IRespGetBatchDetails"; 16 | import IReqGetBatchDetails from "../types/IReqGetBatchDetails"; 17 | import IReqExecuteBatch from "../types/IReqExecuteBatch"; 18 | import IRespExecuteBatch from "../types/IRespExecuteBatch"; 19 | import { Batch } from "../entity/Batch"; 20 | import IRespDequeueAndPay from "../types/IRespDequeueAndPay"; 21 | import IReqDequeueAndPay from "../types/IReqDequeueAndPay"; 22 | 23 | class HttpServer { 24 | // Create a new express application instance 25 | private readonly _httpServer: express.Application = express(); 26 | private readonly _lock = new AsyncLock(); 27 | private _batcherConfig: BatcherConfig = JSON.parse( 28 | fs.readFileSync("data/config.json", "utf8") 29 | ); 30 | private _batcher: Batcher = new Batcher(this._batcherConfig); 31 | 32 | setup(): void { 33 | logger.debug("setup"); 34 | this._httpServer.use(express.json()); 35 | } 36 | 37 | async loadConfig(): Promise { 38 | logger.debug("loadConfig"); 39 | 40 | this._batcherConfig = JSON.parse( 41 | fs.readFileSync("data/config.json", "utf8") 42 | ); 43 | 44 | this._batcher.configureBatcher(this._batcherConfig); 45 | } 46 | 47 | async queueForNextBatch( 48 | params: object | undefined 49 | ): Promise { 50 | logger.debug("/queueForNextBatch params:", params); 51 | 52 | // - address, required, desination address 53 | // - amount, required, amount to send to the destination address 54 | // - batcherId, optional, the id of the batcher to which the output will be added, default batcher if not supplied, overrides batcherLabel 55 | // - batcherLabel, optional, the label of the batcher to which the output will be added, default batcher if not supplied 56 | // - webhookUrl, optional, the webhook to call when the batch is broadcast 57 | 58 | const reqBatchRequest: IReqBatchRequest = params as IReqBatchRequest; 59 | logger.debug("reqBatchRequest:", reqBatchRequest); 60 | 61 | // Convert address to lowercase only if bech32 62 | const lowercased = reqBatchRequest.address.toLowerCase(); 63 | if ( 64 | lowercased.startsWith("bc") || 65 | lowercased.startsWith("tb") || 66 | lowercased.startsWith("bcrt") 67 | ) { 68 | reqBatchRequest.address = lowercased; 69 | } 70 | 71 | return await this._batcher.queueForNextBatch(reqBatchRequest); 72 | } 73 | 74 | async dequeueFromNextBatch( 75 | params: object | undefined 76 | ): Promise { 77 | logger.debug("/dequeueFromNextBatch params:", params); 78 | 79 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 80 | const batchRequestId = parseInt((params as any).batchRequestId); 81 | logger.debug("batchRequestId:", batchRequestId); 82 | 83 | return await this._batcher.dequeueFromNextBatch(batchRequestId); 84 | } 85 | 86 | async dequeueAndPay(params: object | undefined): Promise { 87 | logger.debug("/dequeueAndPay params:", params); 88 | 89 | const reqDequeueAndPay: IReqDequeueAndPay = params as IReqDequeueAndPay; 90 | logger.debug("reqDequeueAndPay:", reqDequeueAndPay); 91 | 92 | // Convert address to lowercase only if bech32 93 | if (reqDequeueAndPay.address) { 94 | const lowercased = reqDequeueAndPay.address.toLowerCase(); 95 | if ( 96 | lowercased.startsWith("bc") || 97 | lowercased.startsWith("tb") || 98 | lowercased.startsWith("bcrt") 99 | ) { 100 | reqDequeueAndPay.address = lowercased; 101 | } 102 | } 103 | 104 | return await this._batcher.dequeueAndPay(reqDequeueAndPay); 105 | } 106 | 107 | async getBatchDetails( 108 | params: object | undefined 109 | ): Promise { 110 | logger.debug("/getBatchDetails params:", params); 111 | 112 | const reqGetBatchDetails: IReqGetBatchDetails = params as IReqGetBatchDetails; 113 | 114 | return await this._batcher.getBatchDetails(reqGetBatchDetails); 115 | } 116 | 117 | async executeBatch(params: object | undefined): Promise { 118 | logger.debug("/executeBatch params:", params); 119 | 120 | // - batcherId, optional, id of the batcher to execute, overrides batcherLabel, default batcher will be spent if not supplied 121 | // - batcherLabel, optional, label of the batcher to execute, default batcher will be executed if not supplied 122 | // - confTarget, optional, overrides default value of createbatcher, default to value of createbatcher, default Bitcoin Core conf_target will be used if not supplied 123 | 124 | const reqExecuteBatch: IReqExecuteBatch = params as IReqExecuteBatch; 125 | 126 | return await this._batcher.executeBatch(reqExecuteBatch); 127 | } 128 | 129 | async getOngoingBatches(): Promise { 130 | logger.debug("/getOngoingBatches"); 131 | 132 | return await this._batcher.getOngoingBatches(); 133 | } 134 | 135 | async start(): Promise { 136 | logger.info("Starting incredible service"); 137 | 138 | this.setup(); 139 | 140 | this._httpServer.post("/api", async (req, res) => { 141 | logger.debug("/api"); 142 | 143 | const reqMessage: IRequestMessage = req.body; 144 | logger.debug("reqMessage.method:", reqMessage.method); 145 | logger.debug("reqMessage.params:", reqMessage.params); 146 | 147 | const response: IResponseMessage = { 148 | id: reqMessage.id, 149 | } as IResponseMessage; 150 | 151 | // Check the method and call the corresponding function 152 | switch (reqMessage.method) { 153 | case "queueForNextBatch": { 154 | let result: IRespBatchRequest = {}; 155 | 156 | result = await this._lock.acquire( 157 | "batchModif", 158 | async (): Promise => { 159 | logger.debug("acquired lock batchModif in queueForNextBatch"); 160 | return await this.queueForNextBatch(reqMessage.params || {}); 161 | } 162 | ); 163 | logger.debug("released lock batchModif in queueForNextBatch"); 164 | 165 | response.result = result.result; 166 | response.error = result.error; 167 | break; 168 | } 169 | 170 | case "dequeueFromNextBatch": { 171 | let result: IRespBatchRequest = {}; 172 | 173 | result = await this._lock.acquire( 174 | "batchModif", 175 | async (): Promise => { 176 | logger.debug("acquired lock batchModif in dequeueFromNextBatch"); 177 | return await this.dequeueFromNextBatch(reqMessage.params || {}); 178 | } 179 | ); 180 | logger.debug("released lock batchModif in dequeueFromNextBatch"); 181 | 182 | response.result = result.result; 183 | response.error = result.error; 184 | break; 185 | } 186 | 187 | case "dequeueAndPay": { 188 | let result: IRespDequeueAndPay = {}; 189 | 190 | result = await this._lock.acquire( 191 | "batchModif", 192 | async (): Promise => { 193 | logger.debug("acquired lock batchModif in dequeueAndPay"); 194 | return await this.dequeueAndPay(reqMessage.params || {}); 195 | } 196 | ); 197 | logger.debug("released lock batchModif in dequeueAndPay"); 198 | 199 | response.result = result.result; 200 | response.error = result.error; 201 | break; 202 | } 203 | 204 | case "getBatchDetails": { 205 | const result: IRespGetBatchDetails = await this.getBatchDetails( 206 | reqMessage.params || {} 207 | ); 208 | response.result = result.result; 209 | response.error = result.error; 210 | break; 211 | } 212 | 213 | case "executeBatch": { 214 | let result: IRespExecuteBatch = {}; 215 | 216 | result = await this._lock.acquire( 217 | "batchModif", 218 | async (): Promise => { 219 | logger.debug("acquired lock batchModif in executeBatch"); 220 | return await this.executeBatch(reqMessage.params || {}); 221 | } 222 | ); 223 | logger.debug("released lock batchModif in executeBatch"); 224 | 225 | response.result = result.result; 226 | response.error = result.error; 227 | break; 228 | } 229 | 230 | case "getOngoingBatches": { 231 | const result: Batch[] = await this.getOngoingBatches(); 232 | response.result = result; 233 | break; 234 | } 235 | 236 | case "reloadConfig": 237 | await this.loadConfig(); 238 | 239 | // eslint-disable-next-line no-fallthrough 240 | case "getConfig": 241 | response.result = this._batcherConfig; 242 | break; 243 | 244 | default: 245 | response.error = { 246 | code: ErrorCodes.MethodNotFound, 247 | message: "No such method!", 248 | }; 249 | break; 250 | } 251 | 252 | if (response.error) { 253 | response.error.data = reqMessage.params as never; 254 | res.status(400).json(response); 255 | } else { 256 | res.status(200).json(response); 257 | } 258 | }); 259 | 260 | this._httpServer.post( 261 | "/" + this._batcherConfig.URL_CTX_WEBHOOKS, 262 | async (req, res) => { 263 | logger.info( 264 | "/" + this._batcherConfig.URL_CTX_WEBHOOKS + ":", 265 | req.body 266 | ); 267 | 268 | const response = await this._batcher.processWebhooks(req.body); 269 | 270 | if (response.error) { 271 | res.status(400).json(response); 272 | } else { 273 | res.status(200).json(response); 274 | } 275 | } 276 | ); 277 | 278 | this._httpServer.listen(this._batcherConfig.URL_PORT, () => { 279 | logger.info( 280 | "Express HTTP server listening on port", 281 | this._batcherConfig.URL_PORT 282 | ); 283 | }); 284 | } 285 | } 286 | 287 | export { HttpServer }; 288 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Batcher Cypherapp 2 | 3 | Created by Kexkey and Francis from bullbitcoin.com 4 | 5 | ## batcher cypherapp API intro 6 | 7 | Batcher is Bitcoin transaction batching and automation plugin for cyphernode users (a cypherapp) designed for high-volume enterprise users performing multiple Bitcoin transactions per day. Instead of making one Bitcoin transaction for each Bitcoin payment (e.g. a user withdrawing Bitcoin from an exchange) it allows you to schedule and manage batches of multiple Bitcoins payments sent to the Bitcoin Blockchain as a single transaction. 8 | 9 | Cypherapps can be conceived as cyphernode "plugins". Running a Cyphernode instance is thus required to use the Batcher cypherapp. Instead of communicating directly with the Cyphernode API, users will connect to the Batcher API. Batcher will then manage how and when Cyphernode will be creating Bitcoin transactions. 10 | 11 | Batcher is currently implemented in the Bull Bitcoin exchange 12 | 13 | ### Benefits 14 | 15 | - Significantly lower transaction fee expenses 16 | - Fewer change outputs 17 | - Smaller chains of unconfirned ancestor UTXOs 18 | 19 | All of this makes you spend up to 80% less on transaction fees overall. The hot wallets have fewer errors. It makes your on-chain non-custodial Bitcoin payout solutions seemlessly automated for optimal results according to your configs. 20 | 21 | ### Downsides 22 | 23 | - Payments are not instant. You should notify the end-users when they can expect the transaction to be done. 24 | - UTXO clusters easier to detect with chainalysis software or human trackers. Users that are in the bitcoin transaction will know that the other addresses of that transaction are highly likely to be users of the same service. 25 | 26 | ## Concept and workflow 27 | 28 | ### Step 1: Creating a batching schedule 29 | 30 | Create a batching schedule via the configuration file. You can opt for : 31 | 32 | - Amount-based batch threshold (e.g. everytime the batch reaches at least 0.5 Bitcoin) 33 | - Time-based batch schedule (e.g. execute the current batches ever 4 hours). 34 | - We recommend using both. For example, execute the batch every time the amount exceeds 1 Bitcoin or every hour, whichever comes first. 35 | 36 | Edit the config here 37 | 38 | - `BATCH_TIMEOUT_MINUTES`: set this as the maximum frequency. If the threshold amount is not reached it will execute regardless at this frequency. 39 | 40 | - `CHECK_THRESHOLD_MINUTES`: frequency of checking the threshold. 41 | 42 | - `BATCH_THRESHOLD_AMOUNT`: the target batch threshold. When this amount is reached, the batch will be executed as a Bitcoin transaction. If it is not reached, the batch will be executed according to the batch timeout setting. 43 | 44 | - `BATCH_CONF_TARGET`: when the batch is executed, this setting will determine which network fee level the Bitcoin Core wallet will use for the payments. You can for example have 2 batches, one with batch_conf_target of 6 for express withdrawals and one of batch_conf_target of 100 for non-urgent transactions. You can override this when you call `executeBatch` 45 | 46 | **Sample configs** 47 | 48 | This is Batcher 1. Batches every 60 minutes or whenever the batch reaches 1 BTC (whichever is soonest). Check every minute. The conf target is 6 blocks. 49 | 50 | ``` 51 | { 52 | "LOG": "DEBUG", 53 | "BASE_DIR": "/batcher", 54 | "DATA_DIR": "data", 55 | "DB_NAME": "batcher.sqlite", 56 | "URL_SERVER": "http://batcher", 57 | "URL_PORT": 8000, 58 | "URL_CTX_WEBHOOKS": "webhooks", 59 | "SESSION_TIMEOUT": 600, 60 | "CN_URL": "https://gatekeeper:2009/v0", 61 | "CN_API_ID": "003", 62 | "CN_API_KEY": "39b83c35972aeb81a242bfe189dc0a22da5ac6cbb64072b492f2d46519a97618", 63 | "DEFAULT_BATCHER_ID": 1, 64 | "BATCH_TIMEOUT_MINUTES": 60, 65 | "CHECK_THRESHOLD_MINUTES": 1, 66 | "BATCH_THRESHOLD_AMOUNT": 1, 67 | "BATCH_CONF_TARGET": 6 68 | } 69 | ``` 70 | 71 | 72 | ### API Workflow 73 | 74 | - `Add to batch`: submit a Bitcoin address and amount of a payment to a batching queue via API. 75 | - For each payment (output + amount) you should add a callback URL that will receive the webhook notification when the transaction is sent (0-conf). This is useful for notifying users that their withdrawal has been processed. You will receive detailed transaction info. 76 | - You can remove a payment from a batch at any time, for example if the user wants to have an instant withdrawal. We would suggest to then send the Bitcoin using the normal `sendtoaddress` API call. To make the end-user pay for the transaction fee instead of you (for example as a premium for opting out of transaction) you can subtract the fee from the amount. 77 | - Specify which batching schedule you want that payment to be queued in when submitting Bitcoin payments to the Batcher API. You may want to have different batching schedules, some more frequent than others, and some with lower confirmation targets (lower fees) than others. 78 | 79 | #### Adding a Bitcoin payment to a batch via API 80 | 81 | ```curl -d '{"id":1,"method":"queueForNextBatch","params":{"address":"bcrt1q0jrfsg98jakmuz0xc0mmxp2ewmqpz0tuh273fy","amount":0.0001,"webhookUrl":"http://webhookserver:1111/indiv"}}' -H "Content-Type: application/json" -k -u ":
" https://localhost/batcher/api | jq``` 82 | 83 | #### API response after adding a payment to a batch 84 | 85 | ```TypeScript 86 | { 87 | result?: { 88 | batchRequestId: number; 89 | batchId: number; 90 | etaSeconds: number; 91 | cnResult: { 92 | batcherId?: number; 93 | batcherLabel?: string; 94 | outputId?: number; 95 | nbOutputs?: number; 96 | oldest?: Date; 97 | total?: number; 98 | } 99 | } 100 | error?: { 101 | code: number; 102 | message: string; 103 | data?: D; 104 | } 105 | } 106 | ``` 107 | 108 | - In the API response above, you get the time of the next batch etaSeconds. You can notify the user that the batch will be executed at the latest at that time (and possibly earlier). 109 | - If multiple payments are being made to a single Bitcoin address, batcher will aggregate the amounts and make a single payment to that Bitcoin address. This is not optional because Bitcoin Core would otherwise reject the transaction. 110 | - Once the amount or expiry time thresholds have been reached, Batcher will dispatch a request to the Bitcoin Core instance running in cyphernode to create and broadcast the transaction using the `sendmany` Bitcoin Core RPC call. 111 | - In the API webhook notification, you will receive the information related to each Bitcoin payment as if it had been its own transaction (see below). 112 | 113 | #### Webhook notification sent to all the callback URLs submitted with payments to a batch 114 | 115 | ```TypeScript 116 | { 117 | "error": null, 118 | "result": { 119 | "batchRequestId": 48, 120 | "batchId": 8, 121 | "cnBatcherId": 1, 122 | "txid": "fc02518e32c22574158b96a513be92739ecb02d0caa463bb273e28d2efead8be", 123 | "hash": "fc02518e32c22574158b96a513be92739ecb02d0caa463bb273e28d2efead8be", 124 | "spentDetails": { 125 | "address": "2N8DcqzfkYi8CkYzvNNS5amoq3SbAcQNXKp", 126 | "amount": 0.0001, 127 | "firstseen": 1584568841, 128 | "size": 222, 129 | "vsize": 141, 130 | "replaceable": false, 131 | "fee": 0.00000141, 132 | "subtractfeefromamount": false 133 | } 134 | } 135 | } 136 | ``` 137 | 138 | #### Remove a payment from a batch using the api call below 139 | 140 | This is useful for example of one of your users opted for the batch payment option and changes his mind after. 141 | 142 | ```Request: 143 | dequeueFromNextBatch 144 | { 145 | batchRequestId: number; 146 | } 147 | ``` 148 | 149 | #### The information you get on a batch is 150 | 151 | ```TypeScript 152 | getBatchDetails 153 | Request: 154 | { 155 | batchRequestId?: number; 156 | batchId?: number; 157 | } 158 | ``` 159 | 160 | #### Info on batch as API response 161 | 162 | ```TypeScript 163 | { 164 | result?: { 165 | batchId: number; 166 | cnBatcherId: number; 167 | txid?: string; 168 | spentDetails?: string; 169 | spentTimestamp?: Date; 170 | createdAt?: Date; 171 | updatedAt?: Date; 172 | batchRequests: [ 173 | { 174 | batchRequestId: number; 175 | externalId?: number; 176 | description?: string; 177 | address: string; 178 | amount: number; 179 | cnBatcherId?: number; 180 | cnBatcherLabel?: string; 181 | webhookUrl?: string; 182 | calledback?: boolean; 183 | calledbackTimestamp?: Date; 184 | cnOutputId?: number; 185 | mergedOutput?: boolean; 186 | createdAt?: Date; 187 | updatedAt?: Date; 188 | }, 189 | ] 190 | } 191 | error?: { 192 | code: number; 193 | message: string; 194 | data?: D; 195 | } 196 | } 197 | ``` 198 | 199 | See the [technical doc and installation notes](doc/INSTALL.md). 200 | 201 | See the [technical architecture document](doc/CONTRIBUTING.md). 202 | 203 | ## Discussion: an on-chain solutions for Bitcoin scaling 204 | 205 | The Bitcoin network's transaction throughput is limited to ~ 2500 transactions per block because of the maximum block weight limit of about 4MB. The supply of block space is thus limited to 576MB per day, which can fit ~360,000 transactions. 206 | 207 | This limit keeps Bitcoin decentralized and preserves its core features: scarcity, censorship-resistance, auditability and sovereignty. 208 | Because of the growing demand for block space, and the fact that blocks are produced every 10 minutes, users that wish to have their transactions included have two options: 209 | 210 | - Pay a higher transaction fee to get priority (faster confirmation) 211 | - Wait a longer amount of time to get your transaction confirmed 212 | 213 | There are generally four things we can do to alleviate this problem: 214 | 215 | 1. **Transactions off-chain using second-layer networks (Lightning, Liquid)**: these technologies are great, but they are still experimental and have their own issues. We believe they are the right long-term approach for Bitcoin scaling, but will take time before they are mature enough for wider network effects to take hold. 216 | 217 | 2. **Transactions off-chain using custodial platforms** this is completely against the point of Bitcoin. This is a lazy, bad solution. 218 | 219 | 3. **Optimize the transactions to make their size smaller**: this is what Bitcoin Core is working on, on many levels, Segwit was a big step in the right direction. More technologies will come over the next years. 220 | 221 | 👉 4. **Batch multiple Bitcoin payments are a single transaction**: this is the lowest hanging fruit. It is an obvious and intuitive solution (e.g. fit more people in a bus on the highway instead of adding more lanes). However, it requires a lot of hard work to build software that allows this. Most exchange operators are lazy or simply don't care about the Bitcoin network outside of their immediate short-term interests. 222 | 223 | There was a market niche for a solution that automates all aspects of transaction batching, which is self-hosted and free, connected to Bitcoin Core (via Cyphernode). So we built it! 224 | 225 | Before moving on to 2nd-layer networks, we think other exchanges should first optimize as much as possible their transaction batching. Batcher is free, open-source and self-hosted (your own keys, your own node). So just use it! 226 | 227 | ## Why Bitcoin transaction batching is helpful 228 | 229 | The concept of entreprise Bitcoin transaction batching is simple: instead of doing a single transaction each time you sent a payment to a recipient, you queue these payments and aggregate them as different outputs (recipients) of a single Bitcoin transaction. There are three main benefits 230 | 231 | ### Do the math: save up to 80% 232 | 233 | - You do 100 transactions per hour. 234 | - 2400 transactions per day! 235 | - Each of them costs you 50 sat/byte. 236 | - Your average transaction size per payment is 250 bytes. 237 | - You're adding at minimum 600,000 bytes to the blockchain every day! 238 | - Paying 30,000,000 sats per day! (0.3 BTC) 239 | - But instead, now you batch them every hour. 240 | - Now you do 24 transactions per day 241 | - Each with 100 outputs. Size of 4000 bytes. 242 | - You only add 100,000 bytes to the blockchain. 243 | - It costs you only 5,000,000 sats per day. (0.05 BTC) 244 | - And you save 0.5MB of block space for everyone else, every day! 245 | 246 | **No brainer!** 247 | 248 | ### Minimize your change UTXOs 249 | 250 | Same math as above. You go from adding 2400 change utxos per day to 24 change utxos per day. 251 | This makes the hot wallet management so much easier and less prone to errors. 252 | 253 | ### Minimize ancestor count 254 | 255 | This is a very niche problem that only high-volume Bitcoin services operators will understand. Basically, if you do a lot of transactions and the previous inputs don't get confirmed, and you go over 25 unconfirmed transactions in a chain of transactions, other nodes will consider it as invalid and it won't be included in a block. Batcher helps significantly here, because there are far fewer utxos created. 256 | -------------------------------------------------------------------------------- /doc/INSTALL.md: -------------------------------------------------------------------------------- 1 | # Technical docs 2 | 3 | ## Preparation 4 | 5 | ### Password generation 6 | 7 | Generate a random password, used by the batcher client (your web app): 8 | 9 | ```bash 10 | dd if=/dev/urandom bs=32 count=1 2> /dev/null | xxd -ps -c 32 11 | ``` 12 | 13 | Construct the bcrypt hash of the password, to put in the docker-compose.yaml file as traefik.frontend.auth.basic.users value: 14 | 15 | ```bash 16 | htpasswd -bnB '
' | sed 's/\$/\$\$/g' 17 | :$$2y$$05$$LFKGjKBkmWbI5RUFBqwonOWEcen4Yu.mU139fvD3flWcP8gUqLLaC 18 | ``` 19 | 20 | ### Development setup 21 | 22 | Port 9229 is used to remote debug the TS app. Optional. 23 | 24 | ```bash 25 | docker build -t batcher . 26 | docker run --rm -it -v $PWD/cypherapps/data:/batcher/data -v $PWD/logs:/batcher/logs -v $GATEKEEPER_DATAPATH/certs/cert.pem:/batcher/cert.pem:ro -p 9229:9229 -p 8000:8000 --network cyphernodeappsnet --entrypoint ash batcher 27 | npm run start:dev 28 | ``` 29 | 30 | ```bash 31 | DOCKER_BUILDKIT=0 docker build -t batcher . 32 | take image before npm install 33 | docker run --rm -it -v "$PWD:/batcher" --entrypoint ash 627afd335255 34 | npm install 35 | ``` 36 | 37 | ### Deployment setup 38 | 39 | Service: 40 | 41 | ```bash 42 | docker build -t cyphernode/batcher:v0.2.1-local . 43 | 44 | CYPHERNODE_DIST=~/.cyphernode/cyphernode/dist 45 | 46 | sudo mkdir -p $CYPHERNODE_DIST/apps/batcher/data 47 | sudo cp cypherapps/docker-compose.yaml $CYPHERNODE_DIST/apps/batcher 48 | sudo cp -r cypherapps/data $CYPHERNODE_DIST/apps/batcher 49 | sudo chown -R cyphernode:cyphernode $CYPHERNODE_DIST/apps/batcher 50 | ``` 51 | 52 | Change `$CYPHERNODE_DIST/apps/batcher/data/config.json` 53 | 54 | ### Rebuild and redeploy one-liner 55 | 56 | ```bash 57 | docker build -t cyphernode/batcher:v0.2.1-local . ; docker stop `docker ps -q -f "name=batcher"` 58 | ``` 59 | 60 | ## Notes 61 | 62 | How to listen and simulate the webhook server: 63 | 64 | ```bash 65 | docker run --rm -it -p 1111:1111 --network cyphernodeappsnet --name webhookserver alpine ash 66 | ``` 67 | 68 | ```bash 69 | nc -vlkp1111 -e sh -c 'echo -en "HTTP/1.1 200 OK\r\n\r\n" ; timeout -t 1 tee /dev/tty | cat ; echo 1>&2' 70 | ``` 71 | 72 | ## Usage 73 | 74 | ### Add to batch 75 | 76 | ```bash 77 | curl -d '{"id":1,"method":"queueForNextBatch","params":{"address":"bcrt1q0jrfsg98jakmuz0xc0mmxp2ewmqpz0tuh273fy","amount":0.0001,"webhookUrl":"http://webhookserver:1111/indiv"}}' -H "Content-Type: application/json" -k -u ":
" https://localhost/batcher/api | jq 78 | ``` 79 | 80 | ### Remove from batch (replace batchRequestId value by the one you got with above command) 81 | 82 | ```bash 83 | curl -d '{"id":1,"method":"dequeueFromNextBatch","params":{"batchRequestId":8}}' -H "Content-Type: application/json" -k -u ":
" https://localhost/batcher/api | jq 84 | ``` 85 | 86 | ### Check the result and see that the removed output is absent (replace batchId value by the one you got with first command) 87 | 88 | ```bash 89 | curl -d '{"id":1,"method":"getBatchDetails"}' -H "Content-Type: application/json" -k -u ":
" https://localhost/batcher/api | jq 90 | ``` 91 | 92 | ### Add to batch exactly the same output as first 93 | 94 | ```bash 95 | curl -d '{"id":1,"method":"queueForNextBatch","params":{"address":"bcrt1q0jrfsg98jakmuz0xc0mmxp2ewmqpz0tuh273fy","amount":0.0001,"webhookUrl":"http://webhookserver:1111/indiv"}}' -H "Content-Type: application/json" -k -u ":
" https://localhost/batcher/api | jq 96 | ``` 97 | 98 | ### Check the result and see that the added output is there (replace batchRequestId value by the one you got with above command) 99 | 100 | ```bash 101 | curl -d '{"id":1,"method":"getBatchDetails"}' -H "Content-Type: application/json" -k -u ":
" https://localhost/batcher/api | jq 102 | ``` 103 | 104 | ### Add to batch for the same destination address 105 | 106 | ```bash 107 | curl -d '{"id":1,"method":"queueForNextBatch","params":{"address":"bcrt1q0jrfsg98jakmuz0xc0mmxp2ewmqpz0tuh273fy","amount":0.0002,"webhookUrl":"http://webhookserver:1111/indiv"}}' -H "Content-Type: application/json" -k -u ":
" https://localhost/batcher/api | jq 108 | ``` 109 | 110 | ### Check the details and see the merged outputs by looking at the cnOutputId fields have the same value (...) 111 | 112 | ```bash 113 | curl -d '{"id":1,"method":"getBatchDetails"}' -H "Content-Type: application/json" -k -u ":
" https://localhost/batcher/api | jq 114 | ``` 115 | 116 | ### Add to batch again for another address 117 | 118 | ```bash 119 | curl -d '{"id":1,"method":"queueForNextBatch","params":{"address":"bcrt1qgg7uag4v5y3c96qkdt6lg2tzz9a680a2exeqrs","amount":0.0003,"webhookUrl":"http://webhookserver:1111/indiv"}}' -H "Content-Type: application/json" -k -u ":
" https://localhost/batcher/api | jq 120 | ``` 121 | 122 | ### Check the details with new output (...) 123 | 124 | ```bash 125 | curl -d '{"id":1,"method":"getBatchDetails"}' -H "Content-Type: application/json" -k -u ":
" https://localhost/batcher/api | jq 126 | ``` 127 | 128 | ### Add to batch once again for another address 129 | 130 | ```bash 131 | curl -d '{"id":1,"method":"queueForNextBatch","params":{"address":"bcrt1qwuacwtj8l7y74ty4y3hjjf825ycp0pnwgsq9xp","amount":0.0004,"webhookUrl":"http://webhookserver:1111/indiv"}}' -H "Content-Type: application/json" -k -u ":
" https://localhost/batcher/api | jq 132 | ``` 133 | 134 | ### Check the details with the new output (...) 135 | 136 | ```bash 137 | curl -d '{"id":1,"method":"getBatchDetails"}' -H "Content-Type: application/json" -k -u ":
" https://localhost/batcher/api | jq 138 | ``` 139 | 140 | ### Let's dequeueAndPay the output before the last one 141 | 142 | ```bash 143 | curl -d '{"id":1,"method":"dequeueAndPay","params":{"batchRequestId":21,"confTarget":4}}' -H "Content-Type: application/json" -k -u ":
" https://localhost/batcher/api | jq 144 | ``` 145 | 146 | ### Execute the batch! The resulting tx should have 3 outputs: the 2 batched requests and the change output 147 | 148 | ```bash 149 | curl -d '{"id":1,"method":"executeBatch","params":{"batchRequestId":21}}' -H "Content-Type: application/json" -k -u ":
" https://localhost/batcher/api | jq 150 | ``` 151 | 152 | ### Look at the "webhook simulator output" to see the webhooks being called for the 3 requests (even if two of them have been merged) 153 | 154 | Simply running the `webhookserver` container above and look at the stdout. 155 | 156 | ## Requests and Responses 157 | 158 | ### queueForNextBatch 159 | 160 | Request: 161 | 162 | ```TypeScript 163 | { 164 | batcherId?: number; 165 | batcherLabel?: string; 166 | externalId?: number; 167 | description?: string; 168 | address: string; 169 | amount: number; 170 | webhookUrl?: string; 171 | } 172 | ``` 173 | 174 | Response: 175 | 176 | ```TypeScript 177 | { 178 | result?: { 179 | batchRequestId: number; 180 | batchId: number; 181 | etaSeconds: number; 182 | cnResult: { 183 | batcherId?: number; 184 | batcherLabel?: string; 185 | outputId?: number; 186 | nbOutputs?: number; 187 | oldest?: Date; 188 | total?: number; 189 | }, 190 | address: string, 191 | amount: number 192 | } 193 | error?: { 194 | code: number; 195 | message: string; 196 | data?: D; 197 | } 198 | } 199 | ``` 200 | 201 | ### dequeueFromNextBatch 202 | 203 | Request: 204 | 205 | ```TypeScript 206 | { 207 | batchRequestId: number; 208 | } 209 | ``` 210 | 211 | Response: 212 | 213 | ```TypeScript 214 | { 215 | result?: { 216 | batchRequestId: number; 217 | batchId: number; 218 | cnResult: { 219 | batcherId?: number; 220 | batcherLabel?: string; 221 | outputId?: number; 222 | nbOutputs?: number; 223 | oldest?: Date; 224 | total?: number; 225 | }, 226 | address: string, 227 | amount: number 228 | } 229 | error?: { 230 | code: number; 231 | message: string; 232 | data?: D; 233 | } 234 | } 235 | ``` 236 | 237 | ### dequeueAndPay 238 | 239 | Note: If the spend fails, the output will be dequeued from the batch and the client must deal with reexecuting the spend. 240 | 241 | Request: 242 | 243 | ```TypeScript 244 | { 245 | batchRequestId: number; 246 | address?: string; 247 | amount?: number; 248 | confTarget?: number; 249 | replaceable?: boolean; 250 | subtractfeefromamount?: boolean; 251 | } 252 | ``` 253 | 254 | Response: 255 | 256 | ```TypeScript 257 | { 258 | result?: { 259 | dequeueResult: { 260 | batchRequestId: number; 261 | batchId: number; 262 | cnResult: { 263 | batcherId?: number; 264 | batcherLabel?: string; 265 | outputId?: number; 266 | nbOutputs?: number; 267 | oldest?: Date; 268 | total?: number; 269 | }; 270 | address: string, 271 | amount: number 272 | } 273 | spendResult: { 274 | result?: { 275 | txid?: string; 276 | hash?: string; 277 | details?: { 278 | address: string; 279 | amount: number; 280 | firstseen: Date; 281 | size: number; 282 | vsize: number; 283 | replaceable: boolean; 284 | fee: number; 285 | subtractfeefromamount: boolean; 286 | } 287 | }; 288 | error?: { 289 | code: number; 290 | message: string; 291 | data?: D; 292 | } 293 | } 294 | } 295 | error?: { 296 | code: number; 297 | message: string; 298 | data?: D; 299 | } 300 | } 301 | ``` 302 | 303 | ### getBatchDetails 304 | 305 | Request: 306 | 307 | ```TypeScript 308 | { 309 | batchRequestId?: number; 310 | batchId?: number; 311 | } 312 | ``` 313 | 314 | Response: 315 | 316 | ```TypeScript 317 | { 318 | result?: { 319 | batchId: number; 320 | cnBatcherId: number; 321 | txid?: string; 322 | spentDetails?: string; 323 | spentTimestamp?: Date; 324 | createdAt?: Date; 325 | updatedAt?: Date; 326 | batchRequests: [ 327 | { 328 | batchRequestId: number; 329 | externalId?: number; 330 | description?: string; 331 | address: string; 332 | amount: number; 333 | cnBatcherId?: number; 334 | cnBatcherLabel?: string; 335 | webhookUrl?: string; 336 | calledback?: boolean; 337 | calledbackTimestamp?: Date; 338 | cnOutputId?: number; 339 | mergedOutput?: boolean; 340 | createdAt?: Date; 341 | updatedAt?: Date; 342 | }, 343 | ... 344 | ] 345 | } 346 | error?: { 347 | code: number; 348 | message: string; 349 | data?: D; 350 | } 351 | } 352 | ``` 353 | 354 | ### executeBatch 355 | 356 | Request: 357 | 358 | ```TypeScript 359 | { 360 | batchId?: number; 361 | batchRequestId?: number; 362 | confTarget?: number; 363 | } 364 | ``` 365 | 366 | Response: 367 | 368 | ```TypeScript 369 | { 370 | result?: { 371 | batch: { 372 | batchId: number; 373 | cnBatcherId: number; 374 | txid?: string; 375 | spentDetails?: string; 376 | spentTimestamp?: Date; 377 | createdAt?: Date; 378 | updatedAt?: Date; 379 | batchRequests: [ 380 | { 381 | batchRequestId: number; 382 | externalId?: number; 383 | description?: string; 384 | address: string; 385 | amount: number; 386 | cnBatcherId?: number; 387 | cnBatcherLabel?: string; 388 | webhookUrl?: string; 389 | calledback?: boolean; 390 | calledbackTimestamp?: Date; 391 | cnOutputId?: number; 392 | mergedOutput?: boolean; 393 | createdAt?: Date; 394 | updatedAt?: Date; 395 | }, 396 | ... 397 | ] 398 | }, 399 | cnResult: { 400 | batcherId?: number; 401 | batcherLabel?: string; 402 | confTarget?: number; 403 | nbOutputs?: number; 404 | oldest?: Date; 405 | total?: number; 406 | txid?: string; 407 | hash?: string; 408 | details?: { 409 | firstseen: Date; 410 | size: number; 411 | vsize: number; 412 | replaceable: boolean; 413 | fee: number; 414 | } 415 | } 416 | }, 417 | error?: { 418 | code: number; 419 | message: string; 420 | data?: D; 421 | } 422 | } 423 | ``` 424 | 425 | ### getOngoingBatches 426 | 427 | Request: N/A 428 | 429 | Response: 430 | 431 | ```TypeScript 432 | { 433 | result?: { 434 | [ 435 | { 436 | batchId: number; 437 | cnBatcherId: number; 438 | txid?: string; 439 | spentDetails?: string; 440 | spentTimestamp?: Date; 441 | createdAt?: Date; 442 | updatedAt?: Date; 443 | batchRequests: [ 444 | { 445 | batchRequestId: number; 446 | externalId?: number; 447 | description?: string; 448 | address: string; 449 | amount: number; 450 | cnBatcherId?: number; 451 | cnBatcherLabel?: string; 452 | webhookUrl?: string; 453 | calledback?: boolean; 454 | calledbackTimestamp?: Date; 455 | cnOutputId?: number; 456 | mergedOutput?: boolean; 457 | createdAt?: Date; 458 | updatedAt?: Date; 459 | }, 460 | ... 461 | ] 462 | }, 463 | ... 464 | ] 465 | }, 466 | error?: { 467 | code: number; 468 | message: string; 469 | data?: D; 470 | } 471 | } 472 | ``` 473 | 474 | ### reloadConfig, getConfig 475 | 476 | Request: N/A 477 | 478 | Response: 479 | 480 | ```TypeScript 481 | { 482 | result?: { 483 | LOG: string; 484 | BASE_DIR: string; 485 | DATA_DIR: string; 486 | DB_NAME: string; 487 | URL_SERVER: string; 488 | URL_PORT: number; 489 | URL_CTX_WEBHOOKS: string; 490 | SESSION_TIMEOUT: number; 491 | CN_URL: string; 492 | CN_API_ID: string; 493 | CN_API_KEY: string; 494 | CN_MQTT_BROKER: string; 495 | DEFAULT_BATCHER_ID: number; 496 | BATCH_TIMEOUT_MINUTES: number; 497 | BATCH_THRESHOLD_AMOUNT: number; 498 | }, 499 | error?: { 500 | code: number; 501 | message: string; 502 | data?: D; 503 | } 504 | } 505 | ``` 506 | 507 | ### Spent Webhook 508 | 509 | Webhook BODY: 510 | 511 | ```json 512 | { 513 | "error": null, 514 | "result": { 515 | "batchRequestId": 48, 516 | "batchId": 8, 517 | "cnBatcherId": 1, 518 | "requestCountInBatch": 12, 519 | "status": "accepted", 520 | "txid": "fc02518e32c22574158b96a513be92739ecb02d0caa463bb273e28d2efead8be", 521 | "hash": "fc02518e32c22574158b96a513be92739ecb02d0caa463bb273e28d2efead8be", 522 | "details": { 523 | "address": "2N8DcqzfkYi8CkYzvNNS5amoq3SbAcQNXKp", 524 | "amount": 0.0001, 525 | "firstseen": 1584568841, 526 | "size": 222, 527 | "vsize": 141, 528 | "replaceable": false, 529 | "fee": 0.00000141 530 | } 531 | } 532 | } 533 | ``` 534 | -------------------------------------------------------------------------------- /src/lib/CyphernodeClient.ts: -------------------------------------------------------------------------------- 1 | import logger from "./Log2File"; 2 | import crypto from "crypto"; 3 | import axios, { AxiosError, AxiosRequestConfig } from "axios"; 4 | import https from "https"; 5 | import path from "path"; 6 | import fs from "fs"; 7 | import BatcherConfig from "../config/BatcherConfig"; 8 | import IRespGetBatchDetails from "../types/cyphernode/IRespGetBatchDetails"; 9 | import IRespAddToBatch from "../types/cyphernode/IRespAddToBatch"; 10 | import IReqBatchSpend from "../types/cyphernode/IReqBatchSpend"; 11 | import IReqGetBatchDetails from "../types/cyphernode/IReqGetBatchDetails"; 12 | import IRespBatchSpend from "../types/cyphernode/IRespBatchSpend"; 13 | import IReqAddToBatch from "../types/cyphernode/IReqAddToBatch"; 14 | import { IResponseError, ErrorCodes } from "../types/jsonrpc/IResponseMessage"; 15 | import IReqSpend from "../types/cyphernode/IReqSpend"; 16 | import IRespSpend from "../types/cyphernode/IRespSpend"; 17 | 18 | class CyphernodeClient { 19 | private baseURL: string; 20 | // echo -n '{"alg":"HS256","typ":"JWT"}' | basenc --base64url | tr -d '=' 21 | private readonly h64: string = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"; 22 | private apiId: string; 23 | private apiKey: string; 24 | private caFile: string; 25 | 26 | constructor(batcherConfig: BatcherConfig) { 27 | this.baseURL = batcherConfig.CN_URL; 28 | this.apiId = batcherConfig.CN_API_ID; 29 | this.apiKey = batcherConfig.CN_API_KEY; 30 | this.caFile = path.resolve(batcherConfig.BASE_DIR, "cert.pem"); 31 | } 32 | 33 | configureCyphernode(batcherConfig: BatcherConfig): void { 34 | this.baseURL = batcherConfig.CN_URL; 35 | this.apiId = batcherConfig.CN_API_ID; 36 | this.apiKey = batcherConfig.CN_API_KEY; 37 | this.caFile = path.resolve(batcherConfig.BASE_DIR, "cert.pem"); 38 | } 39 | 40 | _generateToken(): string { 41 | logger.info("CyphernodeClient._generateToken"); 42 | 43 | const current = Math.round(new Date().getTime() / 1000) + 10; 44 | const p = '{"id":"' + this.apiId + '","exp":' + current + "}"; 45 | const re1 = /\+/g; 46 | const re2 = /\//g; 47 | const p64 = Buffer.from(p) 48 | .toString("base64") 49 | .replace(re1, "-") 50 | .replace(re2, "_") 51 | .split("=")[0]; 52 | const msg = this.h64 + "." + p64; 53 | const s = crypto 54 | .createHmac("sha256", this.apiKey) 55 | .update(msg) 56 | .digest("base64") 57 | .replace(re1, "-") 58 | .replace(re2, "_") 59 | .split("=")[0]; 60 | const token = msg + "." + s; 61 | 62 | logger.debug("CyphernodeClient._generateToken :: token=" + token); 63 | 64 | return token; 65 | } 66 | 67 | async _post( 68 | url: string, 69 | postdata: unknown, 70 | addedOptions?: unknown 71 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 72 | ): Promise { 73 | logger.info("CyphernodeClient._post:", url, postdata, addedOptions); 74 | 75 | let configs: AxiosRequestConfig = { 76 | url: url, 77 | method: "post", 78 | baseURL: this.baseURL, 79 | timeout: 60000, 80 | headers: { 81 | Authorization: "Bearer " + this._generateToken(), 82 | }, 83 | data: postdata, 84 | httpsAgent: new https.Agent({ 85 | ca: fs.readFileSync(this.caFile), 86 | // rejectUnauthorized: false, 87 | }), 88 | }; 89 | if (addedOptions) { 90 | configs = Object.assign(configs, addedOptions); 91 | } 92 | 93 | // logger.debug( 94 | // "CyphernodeClient._post :: configs: %s", 95 | // JSON.stringify(configs) 96 | // ); 97 | 98 | try { 99 | const response = await axios.request(configs); 100 | logger.debug("CyphernodeClient._post :: response.data:", response.data); 101 | 102 | return { status: response.status, data: response.data }; 103 | } catch (err) { 104 | if (axios.isAxiosError(err)) { 105 | const error: AxiosError = err; 106 | 107 | if (error.response) { 108 | // The request was made and the server responded with a status code 109 | // that falls out of the range of 2xx 110 | logger.info( 111 | "CyphernodeClient._post :: error.response.data:", 112 | error.response.data 113 | ); 114 | logger.info( 115 | "CyphernodeClient._post :: error.response.status:", 116 | error.response.status 117 | ); 118 | logger.info( 119 | "CyphernodeClient._post :: error.response.headers:", 120 | error.response.headers 121 | ); 122 | 123 | return { status: error.response.status, data: error.response.data }; 124 | } else if (error.request) { 125 | // The request was made but no response was received 126 | // `error.request` is an instance of XMLHttpRequest in the browser and an instance of 127 | // http.ClientRequest in node.js 128 | logger.info( 129 | "CyphernodeClient._post :: error.message:", 130 | error.message 131 | ); 132 | 133 | return { status: -1, data: error.message }; 134 | } else { 135 | // Something happened in setting up the request that triggered an Error 136 | logger.info("CyphernodeClient._post :: Error:", error.message); 137 | 138 | return { status: -2, data: error.message }; 139 | } 140 | } else { 141 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 142 | return { status: -2, data: (err as any).message }; 143 | } 144 | } 145 | } 146 | 147 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 148 | async _get(url: string, addedOptions?: unknown): Promise { 149 | logger.info("CyphernodeClient._get:", url, addedOptions); 150 | 151 | let configs: AxiosRequestConfig = { 152 | url: url, 153 | method: "get", 154 | baseURL: this.baseURL, 155 | timeout: 30000, 156 | headers: { 157 | Authorization: "Bearer " + this._generateToken(), 158 | }, 159 | httpsAgent: new https.Agent({ 160 | ca: fs.readFileSync(this.caFile), 161 | // rejectUnauthorized: false, 162 | }), 163 | }; 164 | if (addedOptions) { 165 | configs = Object.assign(configs, addedOptions); 166 | } 167 | 168 | try { 169 | const response = await axios.request(configs); 170 | logger.debug("CyphernodeClient._get :: response.data:", response.data); 171 | 172 | return { status: response.status, data: response.data }; 173 | } catch (err) { 174 | if (axios.isAxiosError(err)) { 175 | const error: AxiosError = err; 176 | 177 | if (error.response) { 178 | // The request was made and the server responded with a status code 179 | // that falls out of the range of 2xx 180 | logger.info( 181 | "CyphernodeClient._get :: error.response.data:", 182 | error.response.data 183 | ); 184 | logger.info( 185 | "CyphernodeClient._get :: error.response.status:", 186 | error.response.status 187 | ); 188 | logger.info( 189 | "CyphernodeClient._get :: error.response.headers:", 190 | error.response.headers 191 | ); 192 | 193 | return { status: error.response.status, data: error.response.data }; 194 | } else if (error.request) { 195 | // The request was made but no response was received 196 | // `error.request` is an instance of XMLHttpRequest in the browser and an instance of 197 | // http.ClientRequest in node.js 198 | logger.info("CyphernodeClient._get :: error.message:", error.message); 199 | 200 | return { status: -1, data: error.message }; 201 | } else { 202 | // Something happened in setting up the request that triggered an Error 203 | logger.info("CyphernodeClient._get :: Error:", error.message); 204 | 205 | return { status: -2, data: error.message }; 206 | } 207 | } else { 208 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 209 | return { status: -2, data: (err as any).message }; 210 | } 211 | } 212 | } 213 | 214 | async addToBatch(batchRequestTO: IReqAddToBatch): Promise { 215 | // POST http://192.168.111.152:8080/addtobatch 216 | 217 | // args: 218 | // - address, required, desination address 219 | // - amount, required, amount to send to the destination address 220 | // - batchId, optional, the id of the batch to which the output will be added, default batch if not supplied, overrides batchLabel 221 | // - batchLabel, optional, the label of the batch to which the output will be added, default batch if not supplied 222 | // - webhookUrl, optional, the webhook to call when the batch is broadcast 223 | 224 | // response: 225 | // - batcherId, the id of the batcher 226 | // - outputId, the id of the added output 227 | // - nbOutputs, the number of outputs currently in the batch 228 | // - oldest, the timestamp of the oldest output in the batch 229 | // - total, the current sum of the batch's output amounts 230 | 231 | // BODY {"address":"2N8DcqzfkYi8CkYzvNNS5amoq3SbAcQNXKp","amount":0.00233} 232 | // BODY {"address":"2N8DcqzfkYi8CkYzvNNS5amoq3SbAcQNXKp","amount":0.00233,"batchId":34,"webhookUrl":"https://myCypherApp:3000/batchExecuted"} 233 | // BODY {"address":"2N8DcqzfkYi8CkYzvNNS5amoq3SbAcQNXKp","amount":0.00233,"batchLabel":"lowfees","webhookUrl":"https://myCypherApp:3000/batchExecuted"} 234 | // BODY {"address":"2N8DcqzfkYi8CkYzvNNS5amoq3SbAcQNXKp","amount":0.00233,"batchId":34,"webhookUrl":"https://myCypherApp:3000/batchExecuted"} 235 | 236 | logger.info("CyphernodeClient.addToBatch:", batchRequestTO); 237 | 238 | let result: IRespAddToBatch; 239 | const response = await this._post("/addtobatch", batchRequestTO); 240 | 241 | if (response.status >= 200 && response.status < 400) { 242 | result = { result: response.data.result }; 243 | } else { 244 | result = { 245 | error: { 246 | code: response.data.error.code, 247 | message: response.data.error.message, 248 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 249 | } as IResponseError, 250 | } as IRespBatchSpend; 251 | } 252 | return result; 253 | } 254 | 255 | async removeFromBatch(outputId: number): Promise { 256 | // POST http://192.168.111.152:8080/removefrombatch 257 | // 258 | // args: 259 | // - outputId, required, id of the output to remove 260 | // 261 | // response: 262 | // - batcherId, the id of the batcher 263 | // - outputId, the id of the removed output if found 264 | // - nbOutputs, the number of outputs currently in the batch 265 | // - oldest, the timestamp of the oldest output in the batch 266 | // - total, the current sum of the batch's output amounts 267 | // 268 | // BODY {"id":72} 269 | 270 | logger.info("CyphernodeClient.removeFromBatch:", outputId); 271 | 272 | let result: IRespAddToBatch; 273 | const response = await this._post("/removefrombatch", { 274 | outputId, 275 | }); 276 | 277 | if (response.status >= 200 && response.status < 400) { 278 | result = { result: response.data.result }; 279 | } else { 280 | result = { 281 | error: { 282 | code: response.data.error.code, 283 | message: response.data.error.message, 284 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 285 | } as IResponseError, 286 | } as IRespBatchSpend; 287 | } 288 | return result; 289 | } 290 | 291 | async getBatchDetails( 292 | batchIdent: IReqGetBatchDetails 293 | ): Promise { 294 | // POST (GET) http://192.168.111.152:8080/getbatchdetails 295 | // 296 | // args: 297 | // - batcherId, optional, id of the batcher, overrides batcherLabel, default batcher will be spent if not supplied 298 | // - batcherLabel, optional, label of the batcher, default batcher will be used if not supplied 299 | // - txid, optional, if you want the details of an executed batch, supply the batch txid, will return current pending batch 300 | // if not supplied 301 | // 302 | // response: 303 | // {"result":{ 304 | // "batcherId":34, 305 | // "batcherLabel":"Special batcher for a special client", 306 | // "confTarget":6, 307 | // "nbOutputs":83, 308 | // "oldest":123123, 309 | // "total":10.86990143, 310 | // "txid":"af867c86000da76df7ddb1054b273ca9e034e8c89d049b5b2795f9f590f67648", 311 | // "hash":"af867c86000da76df7ddb1054b273ca9e034e8c89d049b5b2795f9f590f67648", 312 | // "details":{ 313 | // "firstseen":123123, 314 | // "size":424, 315 | // "vsize":371, 316 | // "replaceable":true, 317 | // "fee":0.00004112 318 | // }, 319 | // "outputs":[ 320 | // "1abc":0.12, 321 | // "3abc":0.66, 322 | // "bc1abc":2.848, 323 | // ... 324 | // ] 325 | // } 326 | // },"error":null} 327 | // 328 | // BODY {} 329 | // BODY {"batcherId":34} 330 | 331 | logger.info("CyphernodeClient.getBatchDetails:", batchIdent); 332 | 333 | let result: IRespGetBatchDetails; 334 | const response = await this._post("/getbatchdetails", batchIdent); 335 | 336 | if (response.status >= 200 && response.status < 400) { 337 | result = { result: response.data.result }; 338 | } else { 339 | result = { 340 | error: { 341 | code: response.data.error.code, 342 | message: response.data.error.message, 343 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 344 | } as IResponseError, 345 | } as IRespBatchSpend; 346 | } 347 | return result; 348 | } 349 | 350 | async batchSpend(batchSpendTO: IReqBatchSpend): Promise { 351 | // POST http://192.168.111.152:8080/batchspend 352 | // 353 | // args: 354 | // - batcherId, optional, id of the batcher to execute, overrides batcherLabel, default batcher will be spent if not supplied 355 | // - batcherLabel, optional, label of the batcher to execute, default batcher will be executed if not supplied 356 | // - confTarget, optional, overrides default value of createbatcher, default to value of createbatcher, default Bitcoin Core conf_target will be used if not supplied 357 | // NOTYET - feeRate, optional, overrides confTarget if supplied, overrides default value of createbatcher, default to value of createbatcher, default Bitcoin Core value will be used if not supplied 358 | // 359 | // response: 360 | // - txid, the transaction txid 361 | // - hash, the transaction hash 362 | // - nbOutputs, the number of outputs spent in the batch 363 | // - oldest, the timestamp of the oldest output in the spent batch 364 | // - total, the sum of the spent batch's output amounts 365 | // - tx details: size, vsize, replaceable, fee 366 | // - outputs 367 | // 368 | // {"result":{ 369 | // "batcherId":34, 370 | // "batcherLabel":"Special batcher for a special client", 371 | // "confTarget":6, 372 | // "nbOutputs":83, 373 | // "oldest":123123, 374 | // "total":10.86990143, 375 | // "txid":"af867c86000da76df7ddb1054b273ca9e034e8c89d049b5b2795f9f590f67648", 376 | // "hash":"af867c86000da76df7ddb1054b273ca9e034e8c89d049b5b2795f9f590f67648", 377 | // "details":{ 378 | // "firstseen":123123, 379 | // "size":424, 380 | // "vsize":371, 381 | // "replaceable":true, 382 | // "fee":0.00004112 383 | // }, 384 | // "outputs":{ 385 | // "1abc":0.12, 386 | // "3abc":0.66, 387 | // "bc1abc":2.848, 388 | // ... 389 | // } 390 | // } 391 | // },"error":null} 392 | // 393 | // BODY {} 394 | // BODY {"batcherId":34,"confTarget":12} 395 | // NOTYET BODY {"batcherLabel":"highfees","feeRate":233.7} 396 | // BODY {"batcherId":411,"confTarget":6} 397 | 398 | logger.info("CyphernodeClient.batchSpend:", batchSpendTO); 399 | 400 | let result: IRespBatchSpend; 401 | const response = await this._post("/batchspend", batchSpendTO); 402 | if (response.status >= 200 && response.status < 400) { 403 | result = { result: response.data.result }; 404 | } else { 405 | result = { 406 | error: { 407 | code: response.data.error.code, 408 | message: response.data.error.message, 409 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 410 | } as IResponseError, 411 | } as IRespBatchSpend; 412 | } 413 | return result; 414 | } 415 | 416 | async spend(spendTO: IReqSpend): Promise { 417 | // POST http://192.168.111.152:8080/spend 418 | // BODY {"address":"2N8DcqzfkYi8CkYzvNNS5amoq3SbAcQNXKp","amount":0.00233,"confTarget":6,"replaceable":true,"subtractfeefromamount":false} 419 | 420 | // args: 421 | // - address, required, desination address 422 | // - amount, required, amount to send to the destination address 423 | // - confTarget, optional, overrides default value, default Bitcoin Core conf_target will be used if not supplied 424 | // - replaceable, optional, overrides default value, default Bitcoin Core walletrbf will be used if not supplied 425 | // - subtractfeefromamount, optional, if true will subtract fee from the amount sent instead of adding to it 426 | // 427 | // response: 428 | // - txid, the transaction txid 429 | // - hash, the transaction hash 430 | // - tx details: address, aount, firstseen, size, vsize, replaceable, fee, subtractfeefromamount 431 | // 432 | // {"result":{ 433 | // "status":"accepted", 434 | // "txid":"af867c86000da76df7ddb1054b273ca9e034e8c89d049b5b2795f9f590f67648", 435 | // "hash":"af867c86000da76df7ddb1054b273ca9e034e8c89d049b5b2795f9f590f67648", 436 | // "details":{ 437 | // "address":"2N8DcqzfkYi8CkYzvNNS5amoq3SbAcQNXKp", 438 | // "amount":0.00233, 439 | // "firstseen":123123, 440 | // "size":424, 441 | // "vsize":371, 442 | // "replaceable":true, 443 | // "fee":0.00004112, 444 | // "subtractfeefromamount":true 445 | // } 446 | // } 447 | // },"error":null} 448 | // 449 | // BODY {"address":"2N8DcqzfkYi8CkYzvNNS5amoq3SbAcQNXKp","amount":0.00233} 450 | 451 | logger.info("CyphernodeClient.spend:", spendTO); 452 | 453 | let result: IRespSpend; 454 | const response = await this._post("/spend", spendTO); 455 | if (response.status >= 200 && response.status < 400) { 456 | result = { result: response.data }; 457 | } else { 458 | result = { 459 | error: { 460 | code: ErrorCodes.InternalError, 461 | message: response.data.message, 462 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 463 | } as IResponseError, 464 | } as IRespSpend; 465 | } 466 | return result; 467 | } 468 | } 469 | 470 | export { CyphernodeClient }; 471 | -------------------------------------------------------------------------------- /src/lib/Batcher.ts: -------------------------------------------------------------------------------- 1 | import logger from "./Log2File"; 2 | import BatcherConfig from "../config/BatcherConfig"; 3 | import { CyphernodeClient } from "./CyphernodeClient"; 4 | import { BatcherDB } from "./BatcherDB"; 5 | import { BatchRequest } from "../entity/BatchRequest"; 6 | import IReqBatchRequest from "../types/IReqBatchRequest"; 7 | import IRespAddToBatch from "../types/cyphernode/IRespAddToBatch"; 8 | import IReqBatchSpend from "../types/cyphernode/IReqBatchSpend"; 9 | import IRespBatchSpend from "../types/cyphernode/IRespBatchSpend"; 10 | import { Batch } from "../entity/Batch"; 11 | import { 12 | ErrorCodes, 13 | IResponseMessage, 14 | } from "../types/jsonrpc/IResponseMessage"; 15 | import IRespBatchRequest from "../types/IRespBatchRequest"; 16 | import IReqGetBatchDetails from "../types/IReqGetBatchDetails"; 17 | import IRespGetBatchDetails from "../types/IRespGetBatchDetails"; 18 | import { GetBatchDetailsValidator } from "../validators/GetBatchDetailsValidator"; 19 | import { QueueForNextBatchValidator } from "../validators/QueueForNextBatchValidator"; 20 | import IReqExecuteBatch from "../types/IReqExecuteBatch"; 21 | import { ExecuteBatchValidator } from "../validators/ExecuteBatchValidator"; 22 | import IRespExecuteBatch from "../types/IRespExecuteBatch"; 23 | import IReqAddToBatch from "../types/cyphernode/IReqAddToBatch"; 24 | import { Utils } from "./Utils"; 25 | import { Scheduler } from "./Scheduler"; 26 | import IReqDequeueAndPay from "../types/IReqDequeueAndPay"; 27 | import IRespDequeueAndPay from "../types/IRespDequeueAndPay"; 28 | import IRespSpend from "../types/cyphernode/IRespSpend"; 29 | import IReqSpend from "../types/cyphernode/IReqSpend"; 30 | import { DequeueAndPayValidator } from "../validators/DequeueAndPayValidator"; 31 | 32 | class Batcher { 33 | private _batcherConfig: BatcherConfig; 34 | private _cyphernodeClient: CyphernodeClient; 35 | private _batcherDB: BatcherDB; 36 | private _scheduler: Scheduler; 37 | private _intervalTimeout?: NodeJS.Timeout; 38 | private _intervalThreshold?: NodeJS.Timeout; 39 | 40 | constructor(batcherConfig: BatcherConfig) { 41 | this._batcherConfig = batcherConfig; 42 | this._cyphernodeClient = new CyphernodeClient(this._batcherConfig); 43 | this._batcherDB = new BatcherDB(this._batcherConfig); 44 | this._scheduler = new Scheduler(this._batcherConfig); 45 | this.startIntervals(); 46 | } 47 | 48 | configureBatcher(batcherConfig: BatcherConfig): void { 49 | this._batcherConfig = batcherConfig; 50 | this._batcherDB.configureDB(this._batcherConfig).then(() => { 51 | this._cyphernodeClient.configureCyphernode(this._batcherConfig); 52 | this._scheduler.configureScheduler(this._batcherConfig).then(() => { 53 | this.startIntervals(); 54 | }); 55 | }); 56 | } 57 | 58 | startIntervals(): void { 59 | if (this._intervalTimeout) { 60 | clearInterval(this._intervalTimeout); 61 | } 62 | this._intervalTimeout = setInterval( 63 | this._scheduler.timeout, 64 | this._batcherConfig.BATCH_TIMEOUT_MINUTES * 60000, 65 | this._scheduler 66 | ); 67 | 68 | if (this._intervalThreshold) { 69 | clearInterval(this._intervalThreshold); 70 | } 71 | this._intervalThreshold = setInterval( 72 | this._scheduler.checkThreshold, 73 | this._batcherConfig.CHECK_THRESHOLD_MINUTES * 60000, 74 | this._scheduler, 75 | this 76 | ); 77 | } 78 | 79 | async getBatchDetails( 80 | getBatchDetailsTO: IReqGetBatchDetails 81 | ): Promise { 82 | logger.info( 83 | "Batcher.getBatchDetails, batchRequestId:", 84 | getBatchDetailsTO.batchRequestId 85 | ); 86 | 87 | const response: IRespGetBatchDetails = {}; 88 | 89 | if (GetBatchDetailsValidator.validateRequest(getBatchDetailsTO)) { 90 | // Inputs are valid. 91 | logger.debug("Batcher.getBatchDetails, Inputs are valid."); 92 | 93 | let batch: Batch; 94 | if (getBatchDetailsTO.batchRequestId) { 95 | logger.debug( 96 | "Batcher.getBatchDetails, getting Batch By BatchRequest ID." 97 | ); 98 | 99 | batch = await this._batcherDB.getBatchByRequest( 100 | getBatchDetailsTO.batchRequestId 101 | ); 102 | } else if (getBatchDetailsTO.batchId) { 103 | logger.debug("Batcher.getBatchDetails, getting Batch By Batch ID."); 104 | 105 | batch = await this._batcherDB.getBatch( 106 | getBatchDetailsTO.batchId as number 107 | ); 108 | } else { 109 | logger.debug( 110 | "Batcher.getBatchDetails, getting ongoing batch on default batcher." 111 | ); 112 | 113 | batch = await this.getOngoingBatch(); 114 | } 115 | 116 | if (batch) { 117 | logger.debug("Batcher.getBatchDetails, batch found."); 118 | 119 | if (batch.txid) { 120 | response.result = { batch }; 121 | } else { 122 | response.result = { 123 | batch, 124 | etaSeconds: this._scheduler.getTimeLeft(), 125 | }; 126 | } 127 | } else { 128 | // Batch not found 129 | logger.debug("Batcher.getBatchDetails, Batch not found."); 130 | 131 | response.error = { 132 | code: ErrorCodes.InvalidRequest, 133 | message: "Batch not found", 134 | }; 135 | } 136 | } else { 137 | // There is an error with inputs 138 | logger.debug("Batcher.getBatchDetails, there is an error with inputs."); 139 | 140 | response.error = { 141 | code: ErrorCodes.InvalidRequest, 142 | message: "Invalid arguments", 143 | }; 144 | } 145 | 146 | return response; 147 | } 148 | 149 | async queueForNextBatch( 150 | batchRequestTO: IReqBatchRequest 151 | ): Promise { 152 | logger.info( 153 | "Batcher.queueForNextBatch, batchRequestTO:", 154 | batchRequestTO 155 | ); 156 | 157 | const response: IRespBatchRequest = {}; 158 | 159 | if (QueueForNextBatchValidator.validateRequest(batchRequestTO)) { 160 | // Inputs are valid. 161 | logger.debug("Batcher.queueForNextBatch, inputs are valid"); 162 | 163 | // We need to check if the destination address is already queued, because 164 | // Bitcoin Core's sendmany RPC doesn't support duplicated addresses. 165 | 166 | let currentBatchRequests: BatchRequest[]; 167 | let currentBatchRequestsTotal = 0.0; 168 | 169 | const reqAddToBatch: IReqAddToBatch = Object.assign({}, batchRequestTO); 170 | let batchRequestLabel = ""; 171 | 172 | if (batchRequestTO.batcherId) { 173 | currentBatchRequests = await this._batcherDB.getOngoingBatchRequestsByAddressAndBatcherId( 174 | batchRequestTO.address, 175 | batchRequestTO.batcherId 176 | ); 177 | } else if (batchRequestTO.batcherLabel) { 178 | currentBatchRequests = await this._batcherDB.getOngoingBatchRequestsByAddressAndBatcherLabel( 179 | batchRequestTO.address, 180 | batchRequestTO.batcherLabel 181 | ); 182 | } else { 183 | currentBatchRequests = await this._batcherDB.getOngoingBatchRequestsByAddressAndBatcherId( 184 | batchRequestTO.address, 185 | this._batcherConfig.DEFAULT_BATCHER_ID 186 | ); 187 | } 188 | if (currentBatchRequests.length > 0) { 189 | // There's already an output to this address in ongoing batch, let's merge them! 190 | logger.debug( 191 | "Batcher.queueForNextBatch, there's already an output to this address in ongoing batch, let's merge them." 192 | ); 193 | 194 | currentBatchRequests.forEach((currentBatchRequest) => { 195 | currentBatchRequestsTotal += currentBatchRequest.amount; 196 | batchRequestLabel += currentBatchRequest.description + " "; 197 | }); 198 | 199 | // First, remove the existing output in Cyphernode's batch. 200 | logger.debug( 201 | "Batcher.queueForNextBatch, first, remove the existing output in Cyphernode's batch." 202 | ); 203 | 204 | const addToBatchResp = await this._cyphernodeClient.removeFromBatch( 205 | currentBatchRequests[0].cnOutputId as number 206 | ); 207 | if (addToBatchResp.result) { 208 | reqAddToBatch.amount = 209 | Math.round( 210 | (reqAddToBatch.amount + 211 | currentBatchRequestsTotal + 212 | Number.EPSILON) * 213 | 1e9 214 | ) / 1e9; 215 | } 216 | } 217 | 218 | // Let's now add the new output to the next Cyphernode batch and get the batcher id back. 219 | logger.debug( 220 | "Batcher.queueForNextBatch, let's now add the new output to the next Cyphernode batch and get the batcher id back." 221 | ); 222 | 223 | reqAddToBatch.outputLabel = 224 | batchRequestLabel + batchRequestTO.description; 225 | 226 | reqAddToBatch.webhookUrl = 227 | this._batcherConfig.URL_SERVER + 228 | ":" + 229 | this._batcherConfig.URL_PORT + 230 | "/" + 231 | this._batcherConfig.URL_CTX_WEBHOOKS; 232 | 233 | const addToBatchResp = await this._cyphernodeClient.addToBatch( 234 | reqAddToBatch 235 | ); 236 | 237 | // Parse Cyphernode response 238 | 239 | if (addToBatchResp?.result?.batcherId) { 240 | // There is a result, let's create the request row in the database 241 | logger.debug( 242 | "Batcher.queueForNextBatch, there is a result from Cyphernode, let's create the request row in the database" 243 | ); 244 | 245 | let batchRequest = new BatchRequest(); 246 | batchRequest.externalId = batchRequestTO.externalId; 247 | batchRequest.description = batchRequestTO.description; 248 | batchRequest.address = batchRequestTO.address; 249 | batchRequest.amount = batchRequestTO.amount; 250 | batchRequest.cnBatcherId = addToBatchResp.result.batcherId; 251 | batchRequest.cnBatcherLabel = batchRequestTO.batcherLabel; 252 | batchRequest.webhookUrl = batchRequestTO.webhookUrl; 253 | batchRequest.cnOutputId = addToBatchResp.result.outputId; 254 | 255 | if (currentBatchRequests.length > 0) { 256 | // If there was already this address as output in ongoing batch, 257 | // we need to save the new outputId received from Cyphernode 258 | logger.debug( 259 | "Batcher.queueForNextBatch, if there was already this address as output in ongoing batch, we need to save the new outputId received from Cyphernode." 260 | ); 261 | 262 | batchRequest.mergedOutput = true; 263 | currentBatchRequests.forEach((currentBatchRequest) => { 264 | currentBatchRequest.mergedOutput = true; 265 | currentBatchRequest.cnOutputId = addToBatchResp?.result?.outputId; 266 | }); 267 | this._batcherDB.saveRequests(currentBatchRequests); 268 | } 269 | 270 | // Let's see if there's already an ongoing batch. If not, we create one. 271 | logger.debug( 272 | "Batcher.queueForNextBatch, let's see if there's already an ongoing batch. If not, we create one." 273 | ); 274 | 275 | const batch: Batch = await this.getOngoingBatch( 276 | addToBatchResp.result.batcherId, 277 | true 278 | ); 279 | 280 | logger.debug( 281 | "Batcher.queueForNextBatch, we modify and save the batch request." 282 | ); 283 | 284 | batchRequest.batch = batch; 285 | batchRequest = await this._batcherDB.saveRequest(batchRequest); 286 | 287 | response.result = { 288 | batchId: batchRequest.batch.batchId, 289 | batchRequestId: batchRequest.batchRequestId, 290 | etaSeconds: this._scheduler.getTimeLeft(), 291 | cnResult: addToBatchResp.result, 292 | address: batchRequest.address, 293 | amount: batchRequest.amount, 294 | }; 295 | } else if (addToBatchResp.error) { 296 | // There was an error on Cyphernode end, return that. 297 | logger.debug( 298 | "Batcher.queueForNextBatch, there was an error on Cyphernode end, return that." 299 | ); 300 | 301 | response.error = addToBatchResp.error; 302 | } else { 303 | // There was an error calling Cyphernode. 304 | logger.debug( 305 | "Batcher.queueForNextBatch, there was an error calling Cyphernode." 306 | ); 307 | 308 | response.error = { 309 | code: ErrorCodes.InvalidRequest, 310 | message: "An unknown error occurred", 311 | }; 312 | } 313 | } else { 314 | // There is an error with inputs 315 | logger.debug("Batcher.queueForNextBatch, there is an error with inputs."); 316 | 317 | response.error = { 318 | code: ErrorCodes.InvalidRequest, 319 | message: "Invalid arguments", 320 | }; 321 | } 322 | 323 | return response; 324 | } 325 | 326 | async getOngoingBatch( 327 | batcherId?: number, 328 | createNew?: boolean 329 | ): Promise { 330 | logger.info("Batcher.getOngoingBatch, batcherId:", batcherId); 331 | 332 | // Let's see if there's already an ongoing batch. If not, we create one. 333 | 334 | let batch: Batch; 335 | 336 | if (!batcherId) { 337 | batcherId = this._batcherConfig.DEFAULT_BATCHER_ID; 338 | } 339 | batch = await this._batcherDB.getOngoingBatchByBatcherId(batcherId); 340 | 341 | logger.debug("Batcher.getOngoingBatch, batch:", batch); 342 | logger.debug("Batcher.getOngoingBatch, createNew:", createNew); 343 | 344 | if (batch == null && createNew) { 345 | batch = new Batch(); 346 | batch.cnBatcherId = batcherId; 347 | batch = await this._batcherDB.saveBatch(batch); 348 | } 349 | 350 | return batch; 351 | } 352 | 353 | async dequeueFromNextBatch( 354 | batchRequestId: number 355 | ): Promise { 356 | logger.info( 357 | "Batcher.dequeueFromNextBatch, batchRequestId:", 358 | batchRequestId 359 | ); 360 | 361 | // First of all, get the request. 362 | logger.debug("Batcher.dequeueFromNextBatch, first of all, get the request"); 363 | 364 | const batchRequest: BatchRequest = await this._batcherDB.getRequest( 365 | batchRequestId 366 | ); 367 | 368 | const response: IRespBatchRequest = {}; 369 | 370 | // We don't want to dequeue an already spent batch 371 | if (batchRequest?.cnOutputId && !batchRequest?.batch?.txid) { 372 | logger.debug( 373 | "Batcher.dequeueFromNextBatch, cnOutputId found, remove it in Cyphernode." 374 | ); 375 | 376 | const removeFromBatchResp: IRespAddToBatch = await this._cyphernodeClient.removeFromBatch( 377 | batchRequest.cnOutputId 378 | ); 379 | 380 | if (removeFromBatchResp.error) { 381 | // There was an error on Cyphernode end, return that. 382 | logger.debug( 383 | "Batcher.dequeueFromNextBatch, there was an error on Cyphernode end, return that." 384 | ); 385 | 386 | response.error = removeFromBatchResp.error; 387 | } else if (removeFromBatchResp.result?.batcherId) { 388 | // Let's remove this batch request from our database 389 | logger.debug( 390 | "Batcher.dequeueFromNextBatch, let's remove this batch request from our database." 391 | ); 392 | 393 | response.result = { 394 | batchId: batchRequest.batch.batchId, 395 | batchRequestId: batchRequestId, 396 | etaSeconds: this._scheduler.getTimeLeft(), 397 | cnResult: removeFromBatchResp.result, 398 | address: batchRequest.address, 399 | amount: batchRequest.amount, 400 | }; 401 | 402 | await this._batcherDB.removeRequest(batchRequest); 403 | 404 | if (batchRequest.mergedOutput) { 405 | // If output was merged, we need to just remove this request from the total 406 | // and add new output to batch 407 | logger.debug( 408 | "Batcher.dequeueFromNextBatch, output was merged, let's get all requests." 409 | ); 410 | 411 | let currentBatchRequestsTotal = 0.0; 412 | let batchRequestLabel = ""; 413 | const currentBatchRequests = await this._batcherDB.getRequestsByCnOutputId( 414 | batchRequest.cnOutputId 415 | ); 416 | 417 | if (currentBatchRequests.length == 1) { 418 | // One left, not merged anymore 419 | logger.debug( 420 | "Batcher.dequeueFromNextBatch, one output left after dequeue, not merged anymore." 421 | ); 422 | currentBatchRequests[0].mergedOutput = false; 423 | } 424 | 425 | currentBatchRequests.forEach((currentBatchRequest) => { 426 | currentBatchRequestsTotal += currentBatchRequest.amount; 427 | batchRequestLabel += currentBatchRequest.description + " "; 428 | }); 429 | 430 | const reqAddToBatch: IReqAddToBatch = { 431 | amount: 432 | Math.round((currentBatchRequestsTotal + Number.EPSILON) * 1e9) / 433 | 1e9, 434 | address: batchRequest.address, 435 | }; 436 | reqAddToBatch.outputLabel = batchRequestLabel.trim(); 437 | reqAddToBatch.batcherId = batchRequest.cnBatcherId; 438 | reqAddToBatch.batcherLabel = batchRequest.cnBatcherLabel; 439 | reqAddToBatch.webhookUrl = 440 | this._batcherConfig.URL_SERVER + 441 | ":" + 442 | this._batcherConfig.URL_PORT + 443 | "/" + 444 | this._batcherConfig.URL_CTX_WEBHOOKS; 445 | 446 | logger.debug( 447 | "Batcher.dequeueFromNextBatch, now add the new output to Cyphernode batch." 448 | ); 449 | const addToBatchResp = await this._cyphernodeClient.addToBatch( 450 | reqAddToBatch 451 | ); 452 | 453 | // Parse Cyphernode response 454 | 455 | if (addToBatchResp?.result?.batcherId) { 456 | // There is a result, let's create the request row in the database 457 | logger.debug( 458 | "Batcher.queueForNextBatch, there is a result from Cyphernode, let's update the request rows in the database" 459 | ); 460 | 461 | currentBatchRequests.forEach((currentBatchRequest) => { 462 | currentBatchRequest.cnOutputId = addToBatchResp?.result?.outputId; 463 | }); 464 | this._batcherDB.saveRequests(currentBatchRequests); 465 | } 466 | } 467 | } else { 468 | // There was an error calling Cyphernode. 469 | logger.debug( 470 | "Batcher.dequeueFromNextBatch, there was an error calling Cyphernode." 471 | ); 472 | 473 | response.error = { 474 | code: ErrorCodes.InvalidRequest, 475 | message: "An unknown error occurred", 476 | }; 477 | } 478 | } else { 479 | // Batch request not found! 480 | logger.debug( 481 | "Batcher.dequeueFromNextBatch, batch request not found or already spent." 482 | ); 483 | 484 | response.error = { 485 | code: ErrorCodes.InvalidParams, 486 | message: "Batch request does not exist or is already spent", 487 | }; 488 | } 489 | return response; 490 | } 491 | 492 | async dequeueAndPay( 493 | dequeueAndPayReq: IReqDequeueAndPay 494 | ): Promise { 495 | logger.info("Batcher.dequeueAndPay", dequeueAndPayReq); 496 | 497 | const response: IRespDequeueAndPay = {}; 498 | 499 | if (DequeueAndPayValidator.validateRequest(dequeueAndPayReq)) { 500 | const dequeueResp = await this.dequeueFromNextBatch( 501 | dequeueAndPayReq.batchRequestId 502 | ); 503 | 504 | if (dequeueResp?.error) { 505 | // Could not dequeue request from batch 506 | logger.debug( 507 | "Batcher.dequeueAndPay, could not dequeue request from batch." 508 | ); 509 | 510 | response.error = { 511 | code: ErrorCodes.InternalError, 512 | message: "Could not dequeue request from batch", 513 | }; 514 | } else if (dequeueResp?.result) { 515 | const address = dequeueAndPayReq.address 516 | ? dequeueAndPayReq.address 517 | : dequeueResp.result.address; 518 | const amount = dequeueAndPayReq.amount 519 | ? dequeueAndPayReq.amount 520 | : dequeueResp.result.amount; 521 | 522 | const spendRequestTO: IReqSpend = { 523 | address, 524 | amount, 525 | confTarget: dequeueAndPayReq.confTarget, 526 | replaceable: dequeueAndPayReq.replaceable, 527 | subtractfeefromamount: dequeueAndPayReq.subtractfeefromamount, 528 | }; 529 | 530 | const spendResp: IRespSpend = await this._cyphernodeClient.spend( 531 | spendRequestTO 532 | ); 533 | 534 | if (spendResp?.error) { 535 | // There was an error on Cyphernode end, return that. 536 | // Note: If the spend fails, the output will be dequeued from the batch and the client must deal with reexecuting the spend. 537 | logger.debug( 538 | "Batcher.dequeueAndPay: There was an error on Cyphernode spend." 539 | ); 540 | 541 | response.result = { 542 | dequeueResult: dequeueResp.result, 543 | spendResult: { error: spendResp.error }, 544 | }; 545 | } else if (spendResp?.result) { 546 | logger.debug( 547 | "Batcher.dequeueAndPay: Cyphernode spent: ", 548 | spendResp.result 549 | ); 550 | response.result = { 551 | dequeueResult: dequeueResp.result, 552 | spendResult: { result: spendResp.result }, 553 | }; 554 | } 555 | } 556 | } else { 557 | // There is an error with inputs 558 | logger.debug("Batcher.dequeueAndPay: There is an error with inputs."); 559 | 560 | response.error = { 561 | code: ErrorCodes.InvalidRequest, 562 | message: "Invalid arguments", 563 | }; 564 | } 565 | 566 | return response; 567 | } 568 | 569 | async executeBatch( 570 | executeBatchReq: IReqExecuteBatch 571 | ): Promise { 572 | logger.info("Batcher.executeBatch", executeBatchReq); 573 | 574 | const response: IRespExecuteBatch = {}; 575 | 576 | if (ExecuteBatchValidator.validateRequest(executeBatchReq)) { 577 | // Inputs are valid. 578 | logger.debug("Batcher.executeBatch: Inputs are valid"); 579 | 580 | let batchToSpend: Batch; 581 | 582 | // Get batch 583 | if (executeBatchReq.batchId) { 584 | // ...by batch id 585 | logger.debug("Batcher.executeBatch: by batch id"); 586 | 587 | batchToSpend = await this._batcherDB.getBatch(executeBatchReq.batchId); 588 | } else if (executeBatchReq.batchRequestId) { 589 | // ...by batch request id 590 | logger.debug("Batcher.executeBatch: by batch request id"); 591 | 592 | batchToSpend = await this._batcherDB.getBatchByRequest( 593 | executeBatchReq.batchRequestId 594 | ); 595 | } else { 596 | // Spend ongoing batch on default batcher 597 | logger.debug( 598 | "Batcher.executeBatch: Spend ongoing batch on default batcher" 599 | ); 600 | 601 | batchToSpend = await this.getOngoingBatch(); 602 | } 603 | 604 | if (batchToSpend) { 605 | if (batchToSpend.txid) { 606 | // Batch already executed! 607 | logger.debug("Batcher.executeBatch: Batch already executed."); 608 | 609 | response.error = { 610 | code: ErrorCodes.InvalidRequest, 611 | message: "Batch already executed", 612 | }; 613 | return response; 614 | } 615 | } else { 616 | // No ongoing batch! 617 | logger.debug("Batcher.executeBatch: No ongoing batch."); 618 | 619 | response.error = { 620 | code: ErrorCodes.InvalidRequest, 621 | message: "No ongoing batch", 622 | }; 623 | return response; 624 | } 625 | 626 | const batchSpendRequestTO: IReqBatchSpend = { 627 | batcherId: batchToSpend.cnBatcherId, 628 | confTarget: executeBatchReq.confTarget, 629 | }; 630 | 631 | const batchSpendResult: IRespBatchSpend = await this._cyphernodeClient.batchSpend( 632 | batchSpendRequestTO 633 | ); 634 | 635 | if (batchSpendResult?.error) { 636 | // There was an error on Cyphernode end, return that. 637 | logger.debug( 638 | "Batcher.executeBatch: There was an error on Cyphernode end, return that." 639 | ); 640 | 641 | response.error = batchSpendResult.error; 642 | } else if (batchSpendResult?.result) { 643 | logger.debug("Batcher.executeBatch: There's a result for batchSpend."); 644 | 645 | batchToSpend.spentDetails = JSON.stringify(batchSpendResult.result); 646 | batchToSpend.txid = batchSpendResult.result.txid; 647 | batchToSpend.spentTimestamp = new Date(); 648 | batchToSpend = await this._batcherDB.saveBatch(batchToSpend); 649 | 650 | // Remove the output array, we already have them as batch requests 651 | batchSpendResult.result.outputs = undefined; 652 | 653 | response.result = { 654 | batch: batchToSpend, 655 | cnResult: batchSpendResult.result, 656 | }; 657 | } else { 658 | // There was an error calling Cyphernode. 659 | logger.debug( 660 | "Batcher.executeBatch: There was an error calling Cyphernode." 661 | ); 662 | 663 | response.error = { 664 | code: ErrorCodes.InvalidRequest, 665 | message: "An unknown error occurred", 666 | }; 667 | } 668 | } else { 669 | // There is an error with inputs 670 | logger.debug("Batcher.executeBatch: There is an error with inputs."); 671 | 672 | response.error = { 673 | code: ErrorCodes.InvalidRequest, 674 | message: "Invalid arguments", 675 | }; 676 | } 677 | 678 | return response; 679 | } 680 | 681 | async getOngoingBatches(): Promise { 682 | return await this._batcherDB.getOngoingBatches(); 683 | } 684 | 685 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 686 | async processWebhooks(webhookBody: any): Promise { 687 | logger.info("Batcher.processWebhooks:", webhookBody); 688 | 689 | const brs = await this._batcherDB.getRequestsByCnOutputId( 690 | webhookBody.outputId 691 | ); 692 | 693 | const nbRequestsInBatch = await this._batcherDB.getRequestCountByBatchId( 694 | brs[0].batch.batchId 695 | ); 696 | 697 | const result: IResponseMessage = { id: webhookBody.id } as IResponseMessage; 698 | let response; 699 | 700 | brs.forEach(async (br) => { 701 | if (br.webhookUrl && !br.calledback) { 702 | const postdata = { 703 | batchRequestId: br.batchRequestId, 704 | batchId: br.batch.batchId, 705 | cnBatcherId: br.batch.cnBatcherId, 706 | requestCountInBatch: nbRequestsInBatch, 707 | status: webhookBody.status, 708 | txid: webhookBody.txid, 709 | hash: webhookBody.hash, 710 | details: Object.assign(webhookBody.details, { 711 | address: br.address, 712 | amount: br.amount, 713 | }), 714 | }; 715 | response = await Utils.post(br.webhookUrl, postdata); 716 | if (response.status >= 200 && response.status < 400) { 717 | result.result = response.data; 718 | br.calledback = true; 719 | br.calledbackTimestamp = new Date(); 720 | this._batcherDB.saveRequest(br); 721 | } else { 722 | result.error = { 723 | code: ErrorCodes.InternalError, 724 | message: response.data, 725 | }; 726 | } 727 | } 728 | }); 729 | return result; 730 | } 731 | } 732 | 733 | export { Batcher }; 734 | --------------------------------------------------------------------------------