Add cross-platform build system and CI/CD release pipeline

- Add setup.sh (Unix) and setup.bat (Windows) build scripts that detect
    platform, check dependencies, and build all components (JS, WASM, miner)
  - Add GitHub Actions CI workflow (test on push/PR)
  - Add GitHub Actions release workflow that builds static miner binaries
    for Linux x86_64/aarch64, macOS Intel/Apple Silicon, and Windows
  - Switch miner from OpenSSL to rustls for zero runtime dependencies
  - Fix wallet-sync batch fetching (remove broken .bin binary endpoint,
    use sequential JSON-RPC get_block calls)
  - Add portable storage binary format serializer/deserializer for future
    batch RPC support
  - Add postBinary() to RPC client for binary endpoint communication
  - Update package.json: bun-only runtime, remove node/npm references
This commit is contained in:
Matt Hess
2026-02-01 20:05:24 +00:00
parent 244bfb78a2
commit 6cb5f55d4c
11 changed files with 1537 additions and 465 deletions
+69
View File
@@ -313,6 +313,75 @@ export class RPCClient {
};
}
/**
* POST binary data (portable storage format) and parse binary response
* @param {string} endpoint - API endpoint path
* @param {Uint8Array} body - Binary request body
* @returns {Promise<RPCResponse>}
*/
async postBinary(endpoint, body) {
let lastError = null;
const attempts = this.retries + 1;
for (let attempt = 1; attempt <= attempts; attempt++) {
try {
const url = endpoint.startsWith('/')
? `${this.url}${endpoint}`
: `${this.url}/${endpoint}`;
const response = await this._fetchWithTimeout(url, {
method: 'POST',
headers: {
...this._buildHeaders(),
'Content-Type': 'application/octet-stream'
},
body
});
if (!response.ok) {
if (response.status === 401) {
return {
success: false,
error: {
code: RPC_ERROR_CODES.AUTHENTICATION_ERROR,
message: 'Authentication failed'
}
};
}
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const arrayBuffer = await response.arrayBuffer();
return {
success: true,
result: new Uint8Array(arrayBuffer)
};
} catch (error) {
if (error.name === 'AbortError') {
lastError = {
code: RPC_ERROR_CODES.TIMEOUT_ERROR,
message: `Request timed out after ${this.timeout}ms`
};
} else {
lastError = {
code: RPC_ERROR_CODES.NETWORK_ERROR,
message: error.message || 'Network error'
};
}
if (attempt < attempts) {
await this._sleep(this.retryDelay);
}
}
}
return {
success: false,
error: lastError
};
}
/**
* Make a raw HTTP GET request
* @param {string} endpoint - API endpoint path
+30 -2
View File
@@ -14,6 +14,7 @@
*/
import { RPCClient, RPC_STATUS } from './client.js';
import { serialize as psSerialize, deserialize as psDeserialize } from './portable-storage.js';
/**
* @typedef {import('./client.js').RPCClientOptions} RPCClientOptions
@@ -278,10 +279,37 @@ export class DaemonRPC extends RPCClient {
/**
* Get blocks by height (efficient binary format)
* @param {number[]} heights - Array of block heights
* @returns {Promise<RPCResponse>} Blocks data
* @returns {Promise<RPCResponse>} Blocks data with block and tx blobs
*/
async getBlocksByHeight(heights) {
return this.post('/get_blocks_by_height.bin', { heights });
const body = psSerialize({
heights: { _type: 'uint64_array', values: heights }
});
const resp = await this.postBinary('/get_blocks_by_height.bin', body);
if (!resp.success) return resp;
try {
const parsed = psDeserialize(resp.result);
// parsed.blocks is array of { block: Uint8Array, txs: Uint8Array[] }
// parsed.status is Uint8Array
const status = parsed.status
? new TextDecoder().decode(parsed.status)
: 'OK';
if (status !== 'OK') {
return { success: false, error: { code: -1, message: `Status: ${status}` } };
}
return {
success: true,
result: {
blocks: parsed.blocks || [],
status
}
};
} catch (e) {
return { success: false, error: { code: -1, message: e.message } };
}
}
/**
+387
View File
@@ -0,0 +1,387 @@
/**
* Epee Portable Storage Binary Format
*
* Implements Monero/Salvium's binary serialization format used by .bin RPC endpoints.
* Format: signature (8 bytes) + root section (varint entry count + entries).
*
* Reference: epee/include/storages/portable_storage_{to,from}_bin.h
*/
const SIGNATURE_A = 0x01011101;
const SIGNATURE_B = 0x01020101;
const FORMAT_VER = 1;
// Type tags
const TYPE_INT64 = 1;
const TYPE_INT32 = 2;
const TYPE_INT16 = 3;
const TYPE_INT8 = 4;
const TYPE_UINT64 = 5;
const TYPE_UINT32 = 6;
const TYPE_UINT16 = 7;
const TYPE_UINT8 = 8;
const TYPE_DOUBLE = 9;
const TYPE_STRING = 10;
const TYPE_BOOL = 11;
const TYPE_OBJECT = 12;
const TYPE_ARRAY = 13;
const FLAG_ARRAY = 0x80;
// Varint size masks
const SIZE_MARK_MASK = 0x03;
const SIZE_MARK_BYTE = 0;
const SIZE_MARK_WORD = 1;
const SIZE_MARK_DWORD = 2;
const SIZE_MARK_INT64 = 3;
// ============================================================
// WRITER
// ============================================================
class BinaryWriter {
constructor() {
this.chunks = [];
this.length = 0;
}
writeUint8(v) {
const b = new Uint8Array(1);
b[0] = v & 0xFF;
this.chunks.push(b);
this.length += 1;
}
writeUint32LE(v) {
const b = new Uint8Array(4);
new DataView(b.buffer).setUint32(0, v, true);
this.chunks.push(b);
this.length += 4;
}
writeUint64LE(v) {
const b = new Uint8Array(8);
const dv = new DataView(b.buffer);
const n = BigInt(v);
dv.setUint32(0, Number(n & 0xFFFFFFFFn), true);
dv.setUint32(4, Number((n >> 32n) & 0xFFFFFFFFn), true);
this.chunks.push(b);
this.length += 8;
}
writeBytes(buf) {
const u8 = buf instanceof Uint8Array ? buf : new Uint8Array(buf);
this.chunks.push(u8);
this.length += u8.length;
}
writeVarint(val) {
if (val <= 63) {
this.writeUint8((val << 2) | SIZE_MARK_BYTE);
} else if (val <= 16383) {
const v = (val << 2) | SIZE_MARK_WORD;
const b = new Uint8Array(2);
new DataView(b.buffer).setUint16(0, v, true);
this.chunks.push(b);
this.length += 2;
} else if (val <= 1073741823) {
const v = (val << 2) | SIZE_MARK_DWORD;
const b = new Uint8Array(4);
new DataView(b.buffer).setUint32(0, v, true);
this.chunks.push(b);
this.length += 4;
} else {
const v = BigInt(val) << 2n | BigInt(SIZE_MARK_INT64);
const b = new Uint8Array(8);
const dv = new DataView(b.buffer);
dv.setUint32(0, Number(v & 0xFFFFFFFFn), true);
dv.setUint32(4, Number((v >> 32n) & 0xFFFFFFFFn), true);
this.chunks.push(b);
this.length += 8;
}
}
writeString(s) {
const encoded = typeof s === 'string' ? new TextEncoder().encode(s) : s;
this.writeVarint(encoded.length);
this.writeBytes(encoded);
}
toBuffer() {
const result = new Uint8Array(this.length);
let offset = 0;
for (const chunk of this.chunks) {
result.set(chunk, offset);
offset += chunk.length;
}
return result;
}
}
/**
* Serialize a JS object to portable storage binary format.
* Supports: strings, numbers (uint64), booleans, arrays of uint64, arrays of strings,
* arrays of objects, and nested objects.
*
* For typed arrays, wrap values: { _type: 'uint64_array', values: [...] }
*/
export function serialize(obj) {
const w = new BinaryWriter();
// Header: sig_a (4) + sig_b (4) + ver (1)
w.writeUint32LE(SIGNATURE_A);
w.writeUint32LE(SIGNATURE_B);
w.writeUint8(FORMAT_VER);
writeSection(w, obj);
return w.toBuffer();
}
function writeSection(w, obj) {
const keys = Object.keys(obj);
w.writeVarint(keys.length);
for (const key of keys) {
// Key: length (1 byte) + chars
const keyBytes = new TextEncoder().encode(key);
w.writeUint8(keyBytes.length);
w.writeBytes(keyBytes);
// Value
writeEntry(w, obj[key]);
}
}
function writeEntry(w, val) {
if (val === null || val === undefined) {
// Write as empty string
w.writeUint8(TYPE_STRING);
w.writeVarint(0);
return;
}
// Typed wrapper: { _type: 'uint64_array', values: [...] }
if (val && val._type === 'uint64_array') {
w.writeUint8(TYPE_UINT64 | FLAG_ARRAY);
w.writeVarint(val.values.length);
for (const v of val.values) {
w.writeUint64LE(v);
}
return;
}
if (typeof val === 'boolean') {
w.writeUint8(TYPE_BOOL);
w.writeUint8(val ? 1 : 0);
} else if (typeof val === 'number') {
// Default to uint64 for numbers
w.writeUint8(TYPE_UINT64);
w.writeUint64LE(val);
} else if (typeof val === 'bigint') {
w.writeUint8(TYPE_UINT64);
w.writeUint64LE(val);
} else if (typeof val === 'string') {
w.writeUint8(TYPE_STRING);
w.writeString(val);
} else if (val instanceof Uint8Array || val instanceof ArrayBuffer) {
// Binary data as string
w.writeUint8(TYPE_STRING);
const u8 = val instanceof Uint8Array ? val : new Uint8Array(val);
w.writeVarint(u8.length);
w.writeBytes(u8);
} else if (Array.isArray(val)) {
if (val.length === 0) {
// Empty array — write as string array
w.writeUint8(TYPE_STRING | FLAG_ARRAY);
w.writeVarint(0);
} else if (typeof val[0] === 'object' && val[0] !== null) {
// Array of objects
w.writeUint8(TYPE_OBJECT | FLAG_ARRAY);
w.writeVarint(val.length);
for (const item of val) writeSection(w, item);
} else if (typeof val[0] === 'string') {
w.writeUint8(TYPE_STRING | FLAG_ARRAY);
w.writeVarint(val.length);
for (const s of val) w.writeString(s);
} else {
// Array of numbers → uint64
w.writeUint8(TYPE_UINT64 | FLAG_ARRAY);
w.writeVarint(val.length);
for (const n of val) w.writeUint64LE(n);
}
} else if (typeof val === 'object') {
w.writeUint8(TYPE_OBJECT);
writeSection(w, val);
}
}
// ============================================================
// READER
// ============================================================
class BinaryReader {
constructor(buf) {
this.buf = buf instanceof Uint8Array ? buf : new Uint8Array(buf);
this.dv = new DataView(this.buf.buffer, this.buf.byteOffset, this.buf.byteLength);
this.pos = 0;
}
readUint8() {
return this.buf[this.pos++];
}
readUint16LE() {
const v = this.dv.getUint16(this.pos, true);
this.pos += 2;
return v;
}
readUint32LE() {
const v = this.dv.getUint32(this.pos, true);
this.pos += 4;
return v;
}
readUint64LE() {
const lo = this.dv.getUint32(this.pos, true);
const hi = this.dv.getUint32(this.pos + 4, true);
this.pos += 8;
// Return as Number if safe, else BigInt
const n = BigInt(hi) << 32n | BigInt(lo);
if (n <= BigInt(Number.MAX_SAFE_INTEGER)) return Number(n);
return n;
}
readInt64LE() {
const lo = this.dv.getUint32(this.pos, true);
const hi = this.dv.getInt32(this.pos + 4, true);
this.pos += 8;
const n = BigInt(hi) << 32n | BigInt(lo);
if (n >= BigInt(Number.MIN_SAFE_INTEGER) && n <= BigInt(Number.MAX_SAFE_INTEGER)) return Number(n);
return n;
}
readInt32LE() {
const v = this.dv.getInt32(this.pos, true);
this.pos += 4;
return v;
}
readInt16LE() {
const v = this.dv.getInt16(this.pos, true);
this.pos += 2;
return v;
}
readDouble() {
const v = this.dv.getFloat64(this.pos, true);
this.pos += 8;
return v;
}
readVarint() {
const mark = this.buf[this.pos] & SIZE_MARK_MASK;
switch (mark) {
case SIZE_MARK_BYTE: {
const v = this.readUint8();
return v >> 2;
}
case SIZE_MARK_WORD: {
const v = this.readUint16LE();
return v >> 2;
}
case SIZE_MARK_DWORD: {
const v = this.readUint32LE();
return v >>> 2;
}
case SIZE_MARK_INT64: {
const lo = this.dv.getUint32(this.pos, true);
const hi = this.dv.getUint32(this.pos + 4, true);
this.pos += 8;
return Number((BigInt(hi) << 32n | BigInt(lo)) >> 2n);
}
}
}
readString() {
const len = this.readVarint();
const bytes = this.buf.slice(this.pos, this.pos + len);
this.pos += len;
return bytes;
}
readBytes(n) {
const b = this.buf.slice(this.pos, this.pos + n);
this.pos += n;
return b;
}
}
/**
* Deserialize portable storage binary format to a JS object.
* String fields are returned as Uint8Array (binary blobs).
* Use .toString() or TextDecoder for text strings.
*/
export function deserialize(buf) {
const r = new BinaryReader(buf);
// Verify header
const sigA = r.readUint32LE();
const sigB = r.readUint32LE();
const ver = r.readUint8();
if (sigA !== SIGNATURE_A || sigB !== SIGNATURE_B) {
throw new Error(`Invalid portable storage signature: 0x${sigA.toString(16)} 0x${sigB.toString(16)}`);
}
if (ver !== FORMAT_VER) {
throw new Error(`Unsupported portable storage version: ${ver}`);
}
return readSection(r);
}
function readSection(r) {
const count = r.readVarint();
const obj = {};
for (let i = 0; i < count; i++) {
const keyLen = r.readUint8();
const keyBytes = r.readBytes(keyLen);
const key = new TextDecoder().decode(keyBytes);
obj[key] = readEntry(r);
}
return obj;
}
function readEntry(r) {
const typeTag = r.readUint8();
const isArray = (typeTag & FLAG_ARRAY) !== 0;
const baseType = typeTag & ~FLAG_ARRAY;
if (isArray) {
const count = r.readVarint();
const arr = [];
for (let i = 0; i < count; i++) {
arr.push(readValue(r, baseType));
}
return arr;
}
return readValue(r, baseType);
}
function readValue(r, type) {
switch (type) {
case TYPE_UINT64: return r.readUint64LE();
case TYPE_UINT32: return r.readUint32LE();
case TYPE_UINT16: return r.readUint16LE();
case TYPE_UINT8: return r.readUint8();
case TYPE_INT64: return r.readInt64LE();
case TYPE_INT32: return r.readInt32LE();
case TYPE_INT16: return r.readInt16LE();
case TYPE_INT8: return r.readUint8(); // signed but same read
case TYPE_DOUBLE: return r.readDouble();
case TYPE_BOOL: return r.readUint8() !== 0;
case TYPE_STRING: return r.readString();
case TYPE_OBJECT: return readSection(r);
case TYPE_ARRAY: return readEntry(r); // nested array
default:
throw new Error(`Unknown portable storage type: ${type}`);
}
}
+7 -117
View File
@@ -13,7 +13,7 @@
import { WalletOutput, WalletTransaction } from './wallet-store.js';
import { cnSubaddressSecretKey, carrotIndexExtensionGenerator, carrotSubaddressScalar } from './subaddress.js';
import { scanCarrotOutput, makeInputContext, makeInputContextCoinbase, generateCarrotKeyImage } from './carrot-scanning.js';
import { parseTransaction, extractTxPubKey, extractPaymentId } from './transaction.js';
import { parseTransaction, parseBlock, extractTxPubKey, extractPaymentId } from './transaction.js';
import { bytesToHex, hexToBytes } from './address.js';
import { TX_TYPE } from './wallet.js';
import {
@@ -21,6 +21,7 @@ import {
generateKeyImage, commit as pedersonCommit,
deriveViewTag, computeSharedSecret, ecdhDecodeFull,
deriveSubaddressPublicKey, scalarAdd,
cnFastHash,
} from './crypto/index.js';
// ============================================================================
@@ -359,45 +360,12 @@ export class WalletSync {
const headers = headersResponse.result.headers || [];
// Process blocks in batches using batch RPC for better performance
const BLOCK_BATCH_SIZE = 100; // Fetch 100 blocks per RPC call
for (let i = 0; i < headers.length; i += BLOCK_BATCH_SIZE) {
for (const header of headers) {
if (this._stopRequested) break;
const batch = headers.slice(i, i + BLOCK_BATCH_SIZE);
const heights = batch.map(h => h.height);
// Batch fetch all blocks at once
const blocksResponse = await this.daemon.getBlocksByHeight(heights);
if (!blocksResponse.success || !blocksResponse.result?.blocks) {
// Fallback to individual fetches if batch fails
for (const header of batch) {
if (this._stopRequested) break;
await this._processBlock(header);
await this.storage.putBlockHash(header.height, header.hash);
this.currentHeight = header.height + 1;
this._emit('syncProgress', this.getProgress());
}
continue;
}
// Process each block from batch response
const blocks = blocksResponse.result.blocks;
for (let j = 0; j < blocks.length; j++) {
if (this._stopRequested) break;
const header = batch[j];
const blockData = blocks[j];
await this._processBlockFromBatch(header, blockData);
await this.storage.putBlockHash(header.height, header.hash);
this.currentHeight = header.height + 1;
this._emit('syncProgress', this.getProgress());
}
await this._processBlock(header);
await this.storage.putBlockHash(header.height, header.hash);
this.currentHeight = header.height + 1;
this._emit('syncProgress', this.getProgress());
}
// Save sync height
@@ -439,84 +407,6 @@ export class WalletSync {
});
}
/**
* Process a block from batch response
* @private
*/
async _processBlockFromBatch(header, blockData) {
// blockData from get_blocks_by_height.bin contains block blob
// We need to parse it or use the included transactions
// If blockData has the structure we need, use it directly
// Otherwise fall back to individual fetch
if (!blockData || !blockData.block) {
return this._processBlock(header);
}
try {
// Parse block blob to get miner_tx and protocol_tx
// The block field contains the hex-encoded block
const blockBlob = blockData.block;
// For now, decode the block JSON if available, otherwise parse binary
// Most daemon responses include txs array with the transactions
const txs = blockData.txs || [];
// Process miner_tx from block header data if available
if (blockData.miner_tx_hash) {
// Find miner_tx in txs array or parse from block
const minerTx = txs.find(tx => tx.tx_hash === blockData.miner_tx_hash);
if (minerTx && minerTx.as_json) {
await this._processEmbeddedTransaction(
JSON.parse(minerTx.as_json),
blockData.miner_tx_hash,
header,
{ isMinerTx: true, isProtocolTx: false }
);
}
}
// Process protocol_tx if available
if (blockData.protocol_tx_hash) {
const protocolTx = txs.find(tx => tx.tx_hash === blockData.protocol_tx_hash);
if (protocolTx && protocolTx.as_json) {
await this._processEmbeddedTransaction(
JSON.parse(protocolTx.as_json),
blockData.protocol_tx_hash,
header,
{ isMinerTx: false, isProtocolTx: true }
);
}
}
// Process regular transactions
for (const tx of txs) {
if (tx.tx_hash === blockData.miner_tx_hash || tx.tx_hash === blockData.protocol_tx_hash) {
continue; // Already processed
}
if (tx.as_hex) {
await this._processTransaction(tx, header, { isMinerTx: false, isProtocolTx: false });
}
}
// Emit new block event
this._emit('newBlock', {
height: header.height,
hash: header.hash,
timestamp: header.timestamp,
txCount: txs.length,
hasMinerTx: !!blockData.miner_tx_hash,
hasProtocolTx: !!blockData.protocol_tx_hash
});
} catch (error) {
// Fallback to individual block processing
console.error(`Batch processing failed for block ${header.height}, falling back:`, error.message);
await this._processBlock(header);
}
}
/**
* Process a single block
* @private