From 83e164ef9537c92dfd9f974c695334397c1da0c6 Mon Sep 17 00:00:00 2001 From: Pigges Date: Mon, 9 Oct 2023 20:54:19 +0200 Subject: [PATCH] init --- .gitignore | 179 ++++++++++++++++++++++++++++++++++++++++++++++++++ LICENSE | 21 ++++++ README.md | 55 ++++++++++++++++ bun.lockb | Bin 0 -> 6474 bytes jsconfig.json | 22 +++++++ package.json | 20 ++++++ src/Cli.js | 83 +++++++++++++++++++++++ src/Client.js | 60 +++++++++++++++++ src/Spv.js | 161 +++++++++++++++++++++++++++++++++++++++++++++ src/index.js | 46 +++++++++++++ 10 files changed, 647 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100755 bun.lockb create mode 100644 jsconfig.json create mode 100644 package.json create mode 100644 src/Cli.js create mode 100644 src/Client.js create mode 100644 src/Spv.js create mode 100644 src/index.js 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 0000000000000000000000000000000000000000..5fc2bb08c98c8c8a88f3c018c84308100d3bac54 GIT binary patch literal 6474 zcmeHLd010d7JpzA1Z5FaMq!F5SSxv1SOf%$C<9VZv4|E04IzPONJ0{Jl~6y0qST@+ z6-Bh76-q@^6mhB4y5WeUECNp4MWkwxP0LuAb0HU=^Em?b`<(g1`+YCx-R1W?=bn4+ zy)S3-Bu0cx$_NsO7(7v=X;6en3m&>q%w5G72zhkASQ^HW(<7N$>I6YF*3X}q=Q_gc zxp7<87sJ@ctae|0=9e&1BJR?Uy>xx|{Zbjf)0324t8( zBnT785FPyw~LlC`(vpp}B;h$nr>}0RKN|gKahNPQb zS;-DAD>~MEe`(%~Y$D@uqa9Z^ZA*)0cWXm(Qv2+u58CXLHbgy*HYzOD^L7k%$l}LT zZqq%JH}PU}9JABxl=(D|d(oom;oFpHM}0OlFyo$tTuB=9s~Jr1oeEkNRHnk?2=mbZ ziQ0z4Gnj($=3odD@Cb+6h7AU+8jFT50=ymIkzdiO4;7yRcw|5F2YLouu>F_b5kDM= zsQNMQKz879v*5+`9qeBRcxM$J>BPK))n7)z{~7;v1O9Im`-iIE4Hn#sf*;6k+>iC7 z0^SwyxK6_Z29S7sCE#5s`f>XJ@IF5qfI<|%$ae^fPp-k@Ie_<3;SmP!zt5n39JIH| zeraRc-re{nJ~UVKjDiOrjDJQVyWx!MDT9@0@c&kSmu}Zk0=9R01OBQJy}VRvnu2$2 zZrt7}Q@!05+_~c>_tCN*y`4!7_4Vv!Hk8NzWEBUmJ!b?3jX`79Z~dpg)1rhqMQsk2sX<2~vy~k?3l2_( z`?Jqpw8ps5{C^NWZNCn2oLT4wDPHg@dQcyT6+c%VL#wFN8GgQv$R8llSYjxl+sEnJX)(>WJmn4 zDkaf^#oeoZNB4}zj&RSbCe@$pX^AB*_Zf$qnLB$I1o$ou4C%o7$x1crR89 zxOc7lt10{AzBclo8;150win%xxX@CocCICuh2F(Y8mL0WYEE%oGKCwGLIgL zTo6{&{Ba~Fowd?v?O~rtlbnNU7t*YcGcFgDYqtBO&Oglyo*p&PFlKmzREOM6;YBfr z3vKg_>};*Po0%9-DA0%i@R3v-s?%H{(fRWQ zY`>Mizx~A3Exyk8Q-fcY|4TzFJwI=y;@l}K6kFI{T`F&b;j1NM>*_ZtEp8QDk9m}s z^iAN}qVH=?9Ll&BWSci*%pUisDW9d=E?ztHqxj6dd&XY5TK~i;sWz{C|GJ9mk8HP4 zxIVnT9}iIPqxYlngO(2-cz+)FR`uapppsgSGAWl7h-7k(P)Opm0_iK|@tjTBOjDU4 zmM7+0np&H3gn|%}m`}Nwy1IJ{vL2m#wJJ_S_`{EimScn!+Zmv5AVK>KeWRi8DfI1w zzEjXQ1-k#yd5Z2;qzc_v=x#yx06Mdg9v2mV(jfF83lRokkxs;kxDY4eMtYDwq!;N& zb|8C@UC2H(9`%)|bZvrU1u9yOOWC#o5q97t+rIg+`kjPu@F;T36_(T2^Mdr$0&Pep z$s`Dz*}xeFO?4qh&XdUr0_Q$(4nn=}BNk_0a27;eEpP_OCT$3U3wlsK1?M%OhefhT zHbLNw3eIfw_ap-b&?1A5qCn>9h;tPIYMs4!r4F<`(23YVpP!I_qx9wxE? z=YVi7rXSM;7zh>x&eo`U0E6>LIG@vx!I>tU@#*iuxhR|yQde_!Vp)+hNG8r!;j9s0 zRIBh`{Kk5l)itWM$MW^PBjbfd@}#6&w45guafF}Cr2kWYNB#X>41Le95y>2U!=1g5-Y z_9y~i-bm1GV9Qvc8NX#bg%x0koX%#Fb_3%mPDIuL7zsMsjLd;_Qm|;IXq`X|9SdGV zSP(r}_7L={76IU@w;^liqjd#*j|4Ov0YlK=6DA0a!ez+1`@$9&`re1^18N8Z5w$vG zi&Q%VXjE_Xo+EY$9_ih6K+yX#sM+8f1W@=DaBPTQh 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