This commit is contained in:
Pigges 2023-10-09 20:54:19 +02:00
commit 83e164ef95
10 changed files with 647 additions and 0 deletions

179
.gitignore vendored Normal file
View file

@ -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

21
LICENSE Normal file
View file

@ -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.

55
README.md Normal file
View file

@ -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
```

BIN
bun.lockb Executable file

Binary file not shown.

22
jsconfig.json Normal file
View file

@ -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
]
}
}

20
package.json Normal file
View file

@ -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"
}
}

83
src/Cli.js Normal file
View file

@ -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));
}
}
}

60
src/Client.js Normal file
View file

@ -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);
}
}
}

161
src/Spv.js Normal file
View file

@ -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;
}
}

46
src/index.js Normal file
View file

@ -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();
}