init
This commit is contained in:
commit
83e164ef95
10 changed files with 647 additions and 0 deletions
179
.gitignore
vendored
Normal file
179
.gitignore
vendored
Normal 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
21
LICENSE
Normal 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
55
README.md
Normal 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
BIN
bun.lockb
Executable file
Binary file not shown.
22
jsconfig.json
Normal file
22
jsconfig.json
Normal 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
20
package.json
Normal 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
83
src/Cli.js
Normal 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
60
src/Client.js
Normal 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
161
src/Spv.js
Normal 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
46
src/index.js
Normal 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();
|
||||
}
|
Loading…
Reference in a new issue