commit 83e164ef9537c92dfd9f974c695334397c1da0c6 Author: Pigges Date: Mon Oct 9 20:54:19 2023 +0200 init diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8e7d83d --- /dev/null +++ b/.gitignore @@ -0,0 +1,179 @@ +# Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore + +# Build +a.out + +# Logs + +logs +_.log +npm-debug.log_ +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) + +report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json + +# Runtime data + +pids +_.pid +_.seed +\*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover + +lib-cov + +# Coverage directory used by tools like istanbul + +coverage +\*.lcov + +# nyc test coverage + +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) + +.grunt + +# Bower dependency directory (https://bower.io/) + +bower_components + +# node-waf configuration + +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) + +build/Release + +# Dependency directories + +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) + +web_modules/ + +# TypeScript cache + +\*.tsbuildinfo + +# Optional npm cache directory + +.npm + +# Optional eslint cache + +.eslintcache + +# Optional stylelint cache + +.stylelintcache + +# Microbundle cache + +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history + +.node_repl_history + +# Output of 'npm pack' + +\*.tgz + +# Yarn Integrity file + +.yarn-integrity + +# dotenv environment variable files + +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# parcel-bundler cache (https://parceljs.org/) + +.cache +.parcel-cache + +# Next.js build output + +.next +out + +# Nuxt.js build / generate output + +.nuxt +dist + +# Gatsby files + +.cache/ + +# Comment in the public line in if your project uses Gatsby and not Next.js + +# https://nextjs.org/blog/next-9-1#public-directory-support + +# public + +# vuepress build output + +.vuepress/dist + +# vuepress v2.x temp and cache directory + +.temp +.cache + +# Docusaurus cache and generated files + +.docusaurus + +# Serverless directories + +.serverless/ + +# FuseBox cache + +.fusebox/ + +# DynamoDB Local files + +.dynamodb/ + +# TernJS port file + +.tern-port + +# Stores VSCode versions used for testing VSCode extensions + +.vscode-test + +# yarn v2 + +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.\* + +# IntelliJ based IDEs +.idea + +# Finder (MacOS) folder config +.DS_Store + diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..c9ba30f --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 Philip Ahlqvist + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..7f2d071 --- /dev/null +++ b/README.md @@ -0,0 +1,55 @@ +# Simple SPV Client + +A simple SPV client that aims for easy integration primarily with LBRY Hub servers. + +To see what methods that the SPV will answer on, check [this website](https://electrumx.readthedocs.io/en/latest/protocol-methods.html). +This client doesn't try to decode the data recieved, rather it gives you the raw JSONRPC that the SPV replied with. + +The claimtrie methods specific to LBRY will return a Base64 encoded protobuf as the result. You'll need to decode the result according to the [LBRY types](https://github.com/lbryio/types). + +## Good Resources +* [Protocol Methods](https://electrumx.readthedocs.io/en/latest/protocol-methods.html) +* [Documentation of the full Electrum Protocol](https://github.com/ben221199/Electrum-Protocol) +* [SPV Monitor](https://1209k.com/bitcoin-eye/ele.php?chain=lbc) +* [LBRY types](https://github.com/lbryio/types). + + +## Usage + +Get started: +> You need to have [Bun](https://bun.sh/) installed + +```bash +git clone https://github.com/pigges/simple-spv-client.git +cd simple-spv-client +bun install +bun start +``` + +The startup looks like this: + +```bash +|-------------------| +| Simple SPV Client | +|-------------------| + +Which SPV server should be used? +1. Default (a-hub1.odysee.com:50001) +2. Custom +Answer: +``` + +You can also choose to use arguments to directly get a response: +```bash +bun start --server "server" command {your command} +``` + +## Build + +You can build the project to get an executable: +> This will give you a file named a.out +```bash +bun run build +``` + + diff --git a/bun.lockb b/bun.lockb new file mode 100755 index 0000000..5fc2bb0 Binary files /dev/null and b/bun.lockb differ diff --git a/jsconfig.json b/jsconfig.json new file mode 100644 index 0000000..7556e1d --- /dev/null +++ b/jsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "lib": ["ESNext"], + "module": "esnext", + "target": "esnext", + "moduleResolution": "bundler", + "moduleDetection": "force", + "allowImportingTsExtensions": true, + "noEmit": true, + "composite": true, + "strict": true, + "downlevelIteration": true, + "skipLibCheck": true, + "jsx": "react-jsx", + "allowSyntheticDefaultImports": true, + "forceConsistentCasingInFileNames": true, + "allowJs": true, + "types": [ + "bun-types" // add Bun global + ] + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..67a260c --- /dev/null +++ b/package.json @@ -0,0 +1,20 @@ +{ + "name": "simple-spv-client", + "module": "src/index.js", + "type": "module", + "scripts": { + "start": "bun run src/index.js", + "dev": "bun --watch run src/index.js", + "build": "bun build src/index.js --compile --outfile a.out" + }, + "devDependencies": { + "bun-types": "latest" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "dependencies": { + "minimist": "^1.2.8", + "protobufjs": "^7.2.5" + } +} \ No newline at end of file diff --git a/src/Cli.js b/src/Cli.js new file mode 100644 index 0000000..7f6deec --- /dev/null +++ b/src/Cli.js @@ -0,0 +1,83 @@ +/* + * Class to handle the CLI interface +*/ + +const DEFAULT_SPV = ["a-hub1.odysee.com", 50001]; + +async function input(msg) { + process.stdout.write(msg); + for await (const line of console) { + return line; + } +} + +async function options(question, opts) { + let answer; + + while (!answer) { + console.log(question); + for (let i = 0; i < opts.length; i++) console.log(`${i+1}. ${opts[i]}`); + + answer = await input("Answer: "); + + if (isNaN(answer) || (answer < 1 || answer > opts.length)) { + console.log("\nInvalid input!\n"); + answer = null; + } + } + + console.log(""); + + return parseInt(answer); +} + +export default class Cli { + + constructor(client) { + this.client = client; + + // this.start(); + } + + async start() { + this.server = await this.askServer(); + + await this.client.connect(this.server); + await this.client.ping(); + + this.mainLoop(); + } + + async askServer() { + let server; + + server = await options("Which SPV server should be used?", [`Default (${DEFAULT_SPV.join(':')})`, "Custom"]); + + if (server === 1) return DEFAULT_SPV; + server = null; + + while (!server) { + let resp = await input("Choose an SPV server: "); + resp = resp.split(':'); + if (resp.length !== 2 || isNaN(resp[1])) continue; + resp[1] = parseInt(resp[1]); + + server = resp; + } + + return server; + } + + async mainLoop() { + let cmd; + while (true) { + cmd = (await input("Send command: ")).split(' '); + const method = cmd[0]; + + const params = cmd.length > 1 ? cmd.shift() && cmd : ""; + + console.log(await this.client.sendRequest(method, params)); + } + } + +} diff --git a/src/Client.js b/src/Client.js new file mode 100644 index 0000000..d4e02af --- /dev/null +++ b/src/Client.js @@ -0,0 +1,60 @@ +/* + * Class that manages the SPV connection +*/ + +import Spv from "./Spv.js"; + +export default class Client { + + constructor(log=true, timeout=5) { + this.timeout = timeout; + this.running = false; + this.log = log; + this.keepAliveId; + } + + async connect(server) { + this.connection = new Spv(server, this.log, this.timeout); + await this.connection.connect(); + this.running = true; + this.keepAlive(); + } + + disconnect() { + this.running = false; + clearInterval(this.keepAliveId); + this.connection.disconnect(); + } + + kill() { + delete this.connection; + delete this; + } + + async ping() { + const p = await this.connection.ping(); + console.log(`Latency to ${this.connection.server.join(':')} was ${p}ms.`); + return p; + } + + async sendRequest(method, params=[]) { + const startTime = performance.now(); + const resp = this.connection.sendRequest(method, params); + const endTime = performance.now(); + // this.responseTime = endTime - startTime; + + return resp; + } + + async keepAlive() { + try { + this.keepAliveId = setInterval(async ()=>{ + // if (!this.running) return clearInterval(this.keepAliveId); + await this.sendRequest('server.ping'); + // logger.info("sent keepalive ping"); + }, 1000 * 30); + } catch (err) { + logger.err(err); + } + } +} diff --git a/src/Spv.js b/src/Spv.js new file mode 100644 index 0000000..372e912 --- /dev/null +++ b/src/Spv.js @@ -0,0 +1,161 @@ +/* + * Class that connects to the SPV server and handles communication +*/ + +function genHexString(len) { + let output = ''; + for (let i = 0; i < len; ++i) { + output += (Math.floor(Math.random() * 16)).toString(16); + } + return output; +} + +export default class Spv { + #socket; + + constructor(server, log=true, timeout) { + this.server = server; + this.timeout = timeout * 1000; + this.log = log; + } + + async connect() { + + let t; // Id for the connection timeout + + // Establish a connection to the server + this.#socket = await Promise.race([ + Bun.connect({ + hostname: this.server[0], + port: this.server[1], + + socket: { + // Handle incomming data stream + data(socket, data) { + // Clear old data + if ((socket.lastData - performance.now()) / 1000 > 5) return socket.data = new Array(); + + // Append new data + socket.data.push(data); + + // Only do more if the data is the end of a message + if (!data.includes(Buffer.from('\n'))) return; + + // Concatenate the full data into one Buffer + const buffer = Buffer.concat(socket.data); + socket.data = new Array(); // Clear data + + // Try to recreate a JSONRPC response from the recieved data; + let rpc; + try { + rpc = JSON.parse(buffer.toString()); + } catch (err) { + console.error("Recieved data was corrupt!"); + return; + } + + if (Object.keys(socket.events).includes(rpc.method)) { + socket.events[rpc.method](rpc.params[0]); + return socket.data = []; + } + + if (Object.keys(socket.requests).includes(rpc.id)) { + // console.log(rpc); + socket.requests[rpc.id](rpc); + return socket.data = []; + } + }, + open(socket) { + socket.requests = {}; // Store handlers for waiting requests + socket.events = {}; // Store handlers for subscribed events + socket.data = new Array(); // Temporary hold incomming data + socket.lastData = performance.now(); + }, + close(socket) { + //logger.info(`connection lost to ${socket.remoteAddress}`) + //console.log(socket); + }, + drain(socket) { + //console.log(socket); + }, + error(socket, error) { + console.log(error); + }, + + // client-specific handlers + connectError(socket, error) { + console.log("connection error"); + }, // connection failed + end(socket) { + console.log(`connection to ${socket.remoteAddress} closed by server`); + // console.log("end"); + }, // connection closed by server + timeout(socket) { + console.log(`connection to ${socket.remoteAddress} timed out`); + }, // connection timed out + } + }), + new Promise((_, reject) => + t = setTimeout(() => { + if (log) logger.warn(`could not reach ${this.hostname}:${this.port}`) + reject(); + }, this.timeout) + ) + ]); + + clearTimeout(t); + + if (this.log && this.available()) console.log(`Connected to ${this.server.join(':')}`); + } + + // Close the TCP connection + disconnect(log=true) { + this.#socket.flush(); + this.#socket.end(); + if (this.log) console.log(`Closed connection to ${this.server.join(':')}`) + } + + available() { + return this.#socket.readyState === 1; + } + + getRemoteAddress() { + return this.#socket.remoteAddress; + } + + // Time the time it takes to ping the server (returns in milliseconds) + async ping() { + const startTime = performance.now(); + await this.sendRequest('server.ping'); + const endTime = performance.now(); + const time = endTime - startTime; + return Math.floor((time - Math.floor(time)) * 10) > 4 ? Math.ceil(time) : Math.floor(time); // Round up if first decimal is over 4 + } + + async sendRequest(method, params) { + const id = genHexString(32); + let data; + + const resp = new Promise((resolve, reject) => { + this.#socket.requests[id] = (e) => { + data = e; + resolve(); + // TODO you might want to reject in case an error occurs here, so that your application won't halt + } + + this.#socket.write(JSON.stringify({ + "jsonrpc": "2.0", + "method": method, + "params": params, + "id": id + }) + '\n'); + + setTimeout(() => reject(new Error("Timeout")), this.timeout); + }) + + await resp; + this.#socket.requests[id] = undefined; + return data; + } + +} diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..482ffaf --- /dev/null +++ b/src/index.js @@ -0,0 +1,46 @@ +/* + * A simple SPV client that aims for easy integration primarily with LBRY Hub servers. + * The codebase for the hub server: https://github.com/lbryio/hub +*/ + +import minimist from "minimist"; +import Client from "./Client.js"; +import Cli from "./Cli.js"; + +main(); +async function main() { + const args = minimist(Bun.argv); + + // Handle command directly with flags + // Command structure: bin --server "serverAddress" command "command" + if (args['server'] && args._.includes('command')) { + const server = args['server'].split(':'); + const cmd = args._.splice(args._.indexOf('command')+1); + + const method = cmd[0]; + + const params = cmd.length > 1 ? cmd.shift() && cmd : ""; + + const client = new Client(false); + await client.connect([server[0], parseInt(server[1])]); + + console.log(await client.sendRequest(method, params)); + + client.disconnect(); + // client.kill(); + + return ; + } + + // Initialize the Client + const client = new Client(); + + // Initialize the CLI interface + const cli = new Cli(client); + + console.log("|-------------------|"); + console.log("| Simple SPV Client |"); + console.log("|-------------------|\n"); + + cli.start(); +} \ No newline at end of file