strictencode
Version:
Deterministic binary encoding for RGB protocol compliance - JavaScript implementation of StrictEncode
333 lines (308 loc) • 11.1 kB
JavaScript
/**
* @fileoverview StrictEncode - Deterministic binary encoding for RGB protocol compliance
*
* This module provides a JavaScript implementation of StrictEncode, a deterministic binary
* serialization format designed for consensus-critical applications like RGB smart contracts.
*
* Based on the StrictEncode specification developed by LNP/BP Standards Association.
*
* @author RGB Community
* @license Apache-2.0
* @see https://github.com/rgbjs/strictencode
*/
/**
* StrictEncoder provides deterministic binary encoding for primitive types and collections.
* All encoding follows the StrictEncode specification with little-endian byte order and
* LEB128 variable-length integer encoding.
*
* @example
* const encoder = new StrictEncoder();
* encoder.encodeU32(1000000).encodeString("RGB20");
* console.log(encoder.toHex()); // "40420f000552474232"
*/
export class StrictEncoder {
/**
* Create a new StrictEncoder instance.
*/
constructor() {
/** @type {Uint8Array} */
this.buffer = new Uint8Array(0);
}
/**
* Append bytes to the internal buffer.
* @private
* @param {Uint8Array} bytes - Bytes to append
*/
_appendBytes(bytes) {
const newBuffer = new Uint8Array(this.buffer.length + bytes.length);
newBuffer.set(this.buffer);
newBuffer.set(bytes, this.buffer.length);
this.buffer = newBuffer;
}
/**
* Encode an unsigned 8-bit integer.
* @param {number} value - Integer value (0-255)
* @returns {StrictEncoder} This encoder for chaining
* @throws {Error} If value is invalid
*
* @example
* encoder.encodeU8(255); // Encodes to: ff
*/
encodeU8(value) {
if (value < 0 || value > 255 || !Number.isInteger(value)) {
throw new Error(`Invalid u8 value: ${value}. Must be integer 0-255`);
}
this._appendBytes(new Uint8Array([value]));
return this;
}
/**
* Encode an unsigned 16-bit integer in little-endian format.
* @param {number} value - Integer value (0-65535)
* @returns {StrictEncoder} This encoder for chaining
* @throws {Error} If value is invalid
*
* @example
* encoder.encodeU16(65535); // Encodes to: ffff
*/
encodeU16(value) {
if (value < 0 || value > 65535 || !Number.isInteger(value)) {
throw new Error(`Invalid u16 value: ${value}. Must be integer 0-65535`);
}
const buffer = new ArrayBuffer(2);
const view = new DataView(buffer);
view.setUint16(0, value, true); // little-endian
this._appendBytes(new Uint8Array(buffer));
return this;
}
/**
* Encode an unsigned 32-bit integer in little-endian format.
* @param {number} value - Integer value (0-4294967295)
* @returns {StrictEncoder} This encoder for chaining
* @throws {Error} If value is invalid
*
* @example
* encoder.encodeU32(1000000); // Encodes to: 40420f00
*/
encodeU32(value) {
if (value < 0 || value > 4294967295 || !Number.isInteger(value)) {
throw new Error(`Invalid u32 value: ${value}. Must be integer 0-4294967295`);
}
const buffer = new ArrayBuffer(4);
const view = new DataView(buffer);
view.setUint32(0, value, true); // little-endian
this._appendBytes(new Uint8Array(buffer));
return this;
}
/**
* Encode an unsigned 64-bit integer in little-endian format.
* @param {number|bigint|string} value - Integer value (0-18446744073709551615)
* @returns {StrictEncoder} This encoder for chaining
* @throws {Error} If value is invalid
*
* @example
* encoder.encodeU64(1000000n); // Encodes to: 40420f0000000000
*/
encodeU64(value) {
const bigIntValue = BigInt(value);
if (bigIntValue < 0n || bigIntValue > 18446744073709551615n) {
throw new Error(`Invalid u64 value: ${value}. Must be 0-18446744073709551615`);
}
const buffer = new ArrayBuffer(8);
const view = new DataView(buffer);
view.setBigUint64(0, bigIntValue, true); // little-endian
this._appendBytes(new Uint8Array(buffer));
return this;
}
/**
* Encode a boolean value (0x00 for false, 0x01 for true).
* @param {boolean} value - Boolean value
* @returns {StrictEncoder} This encoder for chaining
*
* @example
* encoder.encodeBool(true); // Encodes to: 01
* encoder.encodeBool(false); // Encodes to: 00
*/
encodeBool(value) {
this._appendBytes(new Uint8Array([value ? 0x01 : 0x00]));
return this;
}
/**
* Encode a usize value using LEB128 variable-length encoding.
* LEB128 uses 7 bits per byte for data and 1 bit for continuation.
*
* @param {number} value - Non-negative integer
* @returns {StrictEncoder} This encoder for chaining
* @throws {Error} If value is invalid
*
* @example
* encoder.encodeLeb128(7); // Encodes to: 07
* encoder.encodeLeb128(128); // Encodes to: 8001
* encoder.encodeLeb128(200); // Encodes to: c801
*/
encodeLeb128(value) {
if (value < 0 || !Number.isInteger(value)) {
throw new Error(`Invalid LEB128 value: ${value}. Must be non-negative integer`);
}
const result = [];
do {
let byte = value & 0x7F;
value >>>= 7;
if (value !== 0) {
byte |= 0x80;
}
result.push(byte);
} while (value !== 0);
this._appendBytes(new Uint8Array(result));
return this;
}
/**
* Encode a string using LEB128 length prefix + UTF-8 bytes.
* @param {string} str - String to encode
* @returns {StrictEncoder} This encoder for chaining
* @throws {Error} If string contains invalid UTF-8
*
* @example
* encoder.encodeString("RGB"); // Encodes to: 03524742
* encoder.encodeString("NIATCKR"); // Encodes to: 074e494154434b52
*/
encodeString(str) {
if (typeof str !== 'string') {
throw new Error(`Invalid string: ${typeof str}. Must be string`);
}
try {
const utf8Bytes = new TextEncoder().encode(str);
this.encodeLeb128(utf8Bytes.length);
this._appendBytes(utf8Bytes);
return this;
} catch (error) {
throw new Error(`Invalid UTF-8 string: ${str}. ${error.message}`);
}
}
/**
* Encode an Option<T> (nullable value).
* Uses tag 0x00 for None, 0x01 + encoded value for Some.
*
* @param {*} value - Value to encode (null/undefined for None)
* @param {Function} encoderFunc - Function to encode the inner value if Some
* @returns {StrictEncoder} This encoder for chaining
*
* @example
* encoder.encodeOption(null, () => {}); // Encodes to: 00
* encoder.encodeOption("test", (v) => encoder.encodeString(v)); // Encodes to: 010474657374
*/
encodeOption(value, encoderFunc) {
if (value === null || value === undefined) {
this.encodeU8(0x00); // None
} else {
this.encodeU8(0x01); // Some
if (typeof encoderFunc === 'function') {
encoderFunc.call(this, value);
} else {
throw new Error('encoderFunc must be a function for Some values');
}
}
return this;
}
/**
* Encode a Vec<T> (vector/array) using LEB128 length + encoded items.
* @param {Array} array - Array to encode
* @param {Function} encoderFunc - Function to encode each item
* @returns {StrictEncoder} This encoder for chaining
*
* @example
* encoder.encodeVec(["RGB", "20"], (item) => encoder.encodeString(item));
*/
encodeVec(array, encoderFunc) {
if (!Array.isArray(array)) {
throw new Error(`Invalid Vec: ${typeof array}. Must be array`);
}
this.encodeLeb128(array.length);
array.forEach(item => {
if (typeof encoderFunc === 'function') {
encoderFunc.call(this, item);
} else {
throw new Error('encoderFunc must be a function');
}
});
return this;
}
/**
* Encode a HashMap<usize, T> as sorted Vec<T>.
* Keys are sorted numerically, then values are encoded as Vec<T>.
*
* @param {Map|Object} map - Map or object to encode
* @param {Function} encoderFunc - Function to encode each value
* @returns {StrictEncoder} This encoder for chaining
*
* @example
* const stateMap = { 2000: "spec", 2001: "terms", 2002: "amount" };
* encoder.encodeHashMap(stateMap, (value) => encoder.encodeString(value));
*/
encodeHashMap(map, encoderFunc) {
let entries;
if (map instanceof Map) {
entries = Array.from(map.entries());
} else if (typeof map === 'object' && map !== null) {
entries = Object.entries(map).map(([k, v]) => [parseInt(k), v]);
} else {
throw new Error(`Invalid HashMap: ${typeof map}. Must be Map or Object`);
}
// Validate keys are usize (non-negative integers)
entries.forEach(([k, v]) => {
if (!Number.isInteger(k) || k < 0) {
throw new Error(`Invalid HashMap key: ${k}. Keys must be non-negative integers`);
}
});
// Sort by key (usize)
entries.sort((a, b) => a[0] - b[0]);
// Encode as Vec of values
const values = entries.map(([k, v]) => v);
this.encodeVec(values, encoderFunc);
return this;
}
/**
* Get the encoded data as a hex string.
* @returns {string} Hexadecimal representation of encoded data
*
* @example
* encoder.encodeU8(255).toHex(); // "ff"
*/
toHex() {
return Array.from(this.buffer)
.map(b => b.toString(16).padStart(2, '0'))
.join('');
}
/**
* Get the encoded data as a Uint8Array.
* @returns {Uint8Array} The encoded bytes
*/
toBytes() {
return this.buffer.slice(); // Return copy
}
/**
* Get the length of encoded data in bytes.
* @returns {number} Number of bytes
*/
length() {
return this.buffer.length;
}
/**
* Reset the encoder to empty state.
* @returns {StrictEncoder} This encoder for chaining
*/
reset() {
this.buffer = new Uint8Array(0);
return this;
}
/**
* Create a copy of this encoder.
* @returns {StrictEncoder} New encoder with same data
*/
clone() {
const newEncoder = new StrictEncoder();
newEncoder.buffer = this.buffer.slice();
return newEncoder;
}
}
// Re-export RGB20 utilities
export { RGB20Encoder, RGB20_TYPE_IDS, encodeAssetSpec, encodeContractTerms, encodeAmount } from './rgb20.js';