tiny-crypto-suite
Version:
Tiny tools, big crypto — seamless encryption and certificate handling for modern web and Node apps.
1,091 lines (1,090 loc) • 80.1 kB
JavaScript
import { Buffer } from 'buffer';
import { TinyPromiseQueue } from 'tiny-essentials';
import { EventEmitter } from 'events';
import TinyCryptoParser from '../lib/TinyCryptoParser.mjs';
import TinyChainEvents from './TinyChainEvents.mjs';
import TinyChainBlock from './TinyChainBlock.mjs';
import TinySecp256k1 from './Secp256k1/index.mjs';
/**
* A mapping of addresses to their balances.
*
* Each key in this object represents an address (usually a string),
* and the corresponding value is a `bigint` representing the starting balance
* for that address in the blockchain.
*
* @example
* {
* alice: 1000000000n,
* bob: 1000000000n,
* charlie: 1000000000n,
* adminUser: 1000000000n
* }
*
* @typedef {Object.<string, bigint>} Balances
*/
/**
* Represents the configuration for gas usage and fee settings in a blockchain block.
* This config is used to define the gas-related parameters when creating or processing a block.
* These values are essential for controlling the transaction cost and prioritization of block inclusion.
*
* @typedef {Object} GasConfig
* @property {bigint} [gasLimit=50000n] - The maximum allowed gas usage for the block (defaults to 50,000).
* @property {bigint} [maxFeePerGas=200n] - The maximum fee per gas unit for the block (defaults to 200).
* @property {bigint} [maxPriorityFeePerGas=this.getDefaultPriorityFee()] - The priority fee per gas unit (defaults to the default value).
*/
/**
* Represents the content payload to be inserted into a blockchain block.
* This object contains the transaction data and its corresponding cryptographic signature.
*
* @typedef {Object} BlockContent
* @property {TransactionData} data - The transaction data that describes the operation or transfer.
* @property {Buffer|string} sig - A cryptographic signature verifying the authenticity of the transaction.
*/
/**
* Represents a complete blockchain instance, managing block creation, mining,
* validation, and balance tracking in optional currency and payload modes.
*
* This class handles a dynamic and extensible blockchain environment with gas fee mechanics,
* custom payloads, transfer restrictions, admin controls, halving logic, and export/import capabilities.
*
* @class
* @beta
*/
class TinyChainInstance {
/** @typedef {import('./TinyChainBlock.mjs').NewTransaction} NewTransaction */
/** @typedef {import('./TinyChainBlock.mjs').Transaction} Transaction */
/** @typedef {import('./TinyChainBlock.mjs').TransactionData} TransactionData */
/** @typedef {import('./TinyChainBlock.mjs').GetTransactionData} GetTransactionData */
/** @typedef {import('./TinyChainBlock.mjs').BlockInitData} BlockInitData */
#signer;
#blockSizeLimit;
#payloadSizeLimit;
#blockContentSizeLimit;
#textEncoder = new TextEncoder();
/**
* Important instance used to make event emitter.
* @type {EventEmitter}
*/
#events = new EventEmitter();
/**
* Important instance used to make system event emitter.
* @type {EventEmitter}
*/
#sysEvents = new EventEmitter();
#sysEventsUsed = false;
/**
* Emits an event with optional arguments to all system emit.
* @param {string | symbol} event - The name of the event to emit.
* @param {...any} args - Arguments passed to event listeners.
*/
#emit(event, ...args) {
this.#events.emit(event, ...args);
if (this.#sysEventsUsed)
this.#sysEvents.emit(event, ...args);
}
/**
* Provides access to a secure internal EventEmitter for subclass use only.
*
* This method exposes a dedicated EventEmitter instance intended specifically for subclasses
* that extend the main class. It prevents subclasses from accidentally or intentionally using
* the primary class's public event system (`emit`), which could lead to unpredictable behavior
* or interference in the base class's event flow.
*
* For security and consistency, this method is designed to be accessed only once.
* Multiple accesses are blocked to avoid leaks or misuse of the internal event bus.
*
* @returns {EventEmitter} A special internal EventEmitter instance for subclass use.
* @throws {Error} If the method is called more than once.
*/
getSysEvents() {
if (this.#sysEventsUsed)
throw new Error('Access denied: getSysEvents() can only be called once. ' +
'This restriction ensures subclass event isolation and prevents accidental interference ' +
'with the main class event emitter.');
this.#sysEventsUsed = true;
return this.#sysEvents;
}
/**
* @typedef {(...args: any[]) => void} ListenerCallback
* A generic callback function used for event listeners.
*/
/**
* Sets the maximum number of listeners for the internal event emitter.
*
* @param {number} max - The maximum number of listeners allowed.
*/
setMaxListeners(max) {
this.#events.setMaxListeners(max);
}
/**
* Emits an event with optional arguments.
* @param {string | symbol} event - The name of the event to emit.
* @param {...any} args - Arguments passed to event listeners.
* @returns {boolean} `true` if the event had listeners, `false` otherwise.
*/
emit(event, ...args) {
return this.#events.emit(event, ...args);
}
/**
* Registers a listener for the specified event.
* @param {string | symbol} event - The name of the event to listen for.
* @param {ListenerCallback} listener - The callback function to invoke.
* @returns {this} The current class instance (for chaining).
*/
on(event, listener) {
this.#events.on(event, listener);
return this;
}
/**
* Registers a one-time listener for the specified event.
* @param {string | symbol} event - The name of the event to listen for once.
* @param {ListenerCallback} listener - The callback function to invoke.
* @returns {this} The current class instance (for chaining).
*/
once(event, listener) {
this.#events.once(event, listener);
return this;
}
/**
* Removes a listener from the specified event.
* @param {string | symbol} event - The name of the event.
* @param {ListenerCallback} listener - The listener to remove.
* @returns {this} The current class instance (for chaining).
*/
off(event, listener) {
this.#events.off(event, listener);
return this;
}
/**
* Alias for `on`.
* @param {string | symbol} event - The name of the event.
* @param {ListenerCallback} listener - The callback to register.
* @returns {this} The current class instance (for chaining).
*/
addListener(event, listener) {
this.#events.addListener(event, listener);
return this;
}
/**
* Alias for `off`.
* @param {string | symbol} event - The name of the event.
* @param {ListenerCallback} listener - The listener to remove.
* @returns {this} The current class instance (for chaining).
*/
removeListener(event, listener) {
this.#events.removeListener(event, listener);
return this;
}
/**
* Removes all listeners for a specific event, or all events if no event is specified.
* @param {string | symbol} [event] - The name of the event. If omitted, all listeners from all events will be removed.
* @returns {this} The current class instance (for chaining).
*/
removeAllListeners(event) {
this.#events.removeAllListeners(event);
return this;
}
/**
* Returns the number of times the given `listener` is registered for the specified `event`.
* If no `listener` is passed, returns how many listeners are registered for the `event`.
* @param {string | symbol} eventName - The name of the event.
* @param {Function} [listener] - Optional listener function to count.
* @returns {number} Number of matching listeners.
*/
listenerCount(eventName, listener) {
return this.#events.listenerCount(eventName, listener);
}
/**
* Adds a listener function to the **beginning** of the listeners array for the specified event.
* The listener is called every time the event is emitted.
* @param {string | symbol} eventName - The event name.
* @param {ListenerCallback} listener - The callback function.
* @returns {this} The current class instance (for chaining).
*/
prependListener(eventName, listener) {
this.#events.prependListener(eventName, listener);
return this;
}
/**
* Adds a **one-time** listener function to the **beginning** of the listeners array.
* The next time the event is triggered, this listener is removed and then invoked.
* @param {string | symbol} eventName - The event name.
* @param {ListenerCallback} listener - The callback function.
* @returns {this} The current class instance (for chaining).
*/
prependOnceListener(eventName, listener) {
this.#events.prependOnceListener(eventName, listener);
return this;
}
/**
* Returns an array of event names for which listeners are currently registered.
* @returns {(string | symbol)[]} Array of event names.
*/
eventNames() {
return this.#events.eventNames();
}
/**
* Gets the current maximum number of listeners allowed for any single event.
* @returns {number} The max listener count.
*/
getMaxListeners() {
return this.#events.getMaxListeners();
}
/**
* Returns a copy of the listeners array for the specified event.
* @param {string | symbol} eventName - The event name.
* @returns {Function[]} An array of listener functions.
*/
listeners(eventName) {
return this.#events.listeners(eventName);
}
/**
* Returns a copy of the internal listeners array for the specified event,
* including wrapper functions like those used by `.once()`.
* @param {string | symbol} eventName - The event name.
* @returns {Function[]} An array of raw listener functions.
*/
rawListeners(eventName) {
return this.#events.rawListeners(eventName);
}
/**
* Important instance used to make request queue.
* @type {TinyPromiseQueue}
*/
#queue = new TinyPromiseQueue();
/**
* Important instance used to validate values.
* @type {TinyCryptoParser}
*/
#parser = new TinyCryptoParser();
#payloadString = true;
/**
* Add a new value type and its converter function.
* @param {string} typeName
* @param {(data: any) => any} getFunction
* @param {(data: any) => { __type: string, value?: any }} convertFunction
*/
addValueType(typeName, getFunction, convertFunction) {
return this.#parser.addValueType(typeName, getFunction, convertFunction);
}
/**
* Checks whether the blockchain has a valid genesis block.
*
* A genesis block is identified by having:
* - index equal to 0n
* - prevHash equal to `'0'`
* - at least one data entry
* - the first data entry's `address` equal to `'0'`
*
* @returns {boolean} `true` if a valid genesis block is present, otherwise `false`.
*/
hasGenesisBlock() {
if (this.#getChain().length === 0)
return false;
const firstBlock = this.getFirstBlock();
return firstBlock.index === 0n && firstBlock.prevHash === '0';
}
/**
* The tiny blockchain.
*
* @type {TinyChainBlock[]}
*/
chain = [];
/**
* The initial balances of accounts, only used if `currencyMode` is enabled.
*
* @type {Balances}
*/
initialBalances = {};
/**
* Validates and returns the internal balances object.
* Throws an error if balances is not a plain object.
*
* @returns {Balances} A reference to the balances object if valid.
* @throws {Error} If balances is not a plain object.
*/
#getBalances() {
if (typeof this.balances !== 'object' || this.balances === null || Array.isArray(this.balances))
throw new Error('balances must be a plain object');
return this.balances;
}
/**
* Safely retrieves the current blockchain array.
* Ensures that `this.chain` is a valid array before returning it.
* If the internal `chain` property is corrupted or invalid, an error is thrown to prevent further logic failures.
*
* @returns {TinyChainBlock[]} The current blockchain as an array of TinyChainBlock instances.
* @throws {Error} If `this.chain` is not an array.
*/
#getChain() {
if (!Array.isArray(this.chain))
throw new Error('chain must be an array');
return this.chain;
}
/**
* Constructs a new blockchain instance with optional configuration parameters.
*
* This constructor initializes core parameters of the blockchain, including gas costs,
* difficulty, reward system, and modes (currency/payload). It also sets up the initial
* balances and admin list. If `currencyMode` is enabled, initial balances will be
* registered immediately.
*
* @param {Object} [options] - Configuration options for the blockchain instance.
* @param {TinySecp256k1} [options.signer] - Signer instance for cryptographic operations.
* @param {string|number|bigint} [options.chainId=0] - The chain ID.
* @param {string|number|bigint} [options.transferGas=15000] - Fixed gas cost per transfer operation (symbolic).
* @param {string|number|bigint} [options.baseFeePerGas=21000] - Base gas fee per unit (in gwei).
* @param {string|number|bigint} [options.priorityFeeDefault=2] - Default priority tip per gas unit (in gwei).
* @param {string|number|bigint} [options.diff=1] - Difficulty for proof-of-work or validation cost.
* @param {boolean} [options.payloadString=true] - If true, treats payloads as strings.
* @param {boolean} [options.currencyMode=false] - Enables balance tracking and gas economics.
* @param {boolean} [options.payloadMode=false] - Enables payload execution mode for blocks.
* @param {string|number|bigint} [options.initialReward=15000000000000000000n] - Reward for the genesis block or first mining.
* @param {string|number|bigint} [options.halvingInterval=100] - Block interval for reward halving logic.
* @param {string|number|bigint} [options.lastBlockReward=1000] - Reward for the last mined block.
* @param {Balances} [options.initialBalances={}] - Optional mapping of initial addresses to balances.
* @param {string[]} [options.admins=[]] - List of admin public keys granted elevated permissions.
*
* @param {number} [options.blockContentSizeLimit=-1] - Defines the maximum size (in bytes) allowed inside a single block's content.
* A value of -1 disables the limit entirely.
*
* @param {number} [options.blockSizeLimit=-1] - Defines the total maximum size (in bytes) for an entire block.
* A value of -1 disables the limit entirely.
*
* @param {number} [options.payloadSizeLimit=-1] - Sets the maximum allowed size (in bytes) for a transaction payload.
* A value of -1 disables the limit entirely.
*
* @throws {Error} Throws an error if any parameter has an invalid type or value.
*/
constructor({ signer, chainId = 0, transferGas = 15000, // symbolic per transfer, varies in real EVM
baseFeePerGas = 21000, priorityFeeDefault = 2, diff = 1, payloadString = true, currencyMode = false, payloadMode = false, initialReward = 15000000000000000000n, halvingInterval = 100, lastBlockReward = 1000, initialBalances = {}, blockSizeLimit = -1, blockContentSizeLimit = -1, payloadSizeLimit = -1, admins = [], } = {}) {
// Validation for each parameter
if (typeof chainId !== 'bigint' && typeof chainId !== 'number' && typeof chainId !== 'string')
throw new Error('Invalid type for chainId. Expected bigint, number, or string.');
if (typeof signer !== 'object')
throw new Error('Invalid type for signer. Expected a TinySecp256k1.');
if (typeof transferGas !== 'bigint' &&
typeof transferGas !== 'number' &&
typeof transferGas !== 'string')
throw new Error('Invalid type for transferGas. Expected bigint, number, or string.');
if (typeof blockContentSizeLimit !== 'number' ||
Number.isNaN(blockContentSizeLimit) ||
!Number.isFinite(blockContentSizeLimit) ||
blockContentSizeLimit < -1)
throw new Error('Invalid type for blockContentSizeLimit. Expected number with min value -1.');
if (typeof blockSizeLimit !== 'number' ||
Number.isNaN(blockSizeLimit) ||
!Number.isFinite(blockSizeLimit) ||
blockSizeLimit < -1)
throw new Error('Invalid type for blockSizeLimit. Expected number with min value -1.');
if (typeof payloadSizeLimit !== 'number' ||
Number.isNaN(payloadSizeLimit) ||
!Number.isFinite(payloadSizeLimit) ||
payloadSizeLimit < -1)
throw new Error('Invalid type for payloadSizeLimit. Expected number with min value -1.');
if (typeof baseFeePerGas !== 'bigint' &&
typeof baseFeePerGas !== 'number' &&
typeof baseFeePerGas !== 'string')
throw new Error('Invalid type for baseFeePerGas. Expected bigint, number, or string.');
if (typeof priorityFeeDefault !== 'bigint' &&
typeof priorityFeeDefault !== 'number' &&
typeof priorityFeeDefault !== 'string')
throw new Error('Invalid type for priorityFeeDefault. Expected bigint, number, or string.');
if (typeof diff !== 'bigint' && typeof diff !== 'number' && typeof diff !== 'string')
throw new Error('Invalid type for difficulty. Expected bigint, number, or string.');
if (typeof payloadString !== 'boolean')
throw new Error('Invalid type for payloadString. Expected boolean.');
if (typeof currencyMode !== 'boolean')
throw new Error('Invalid type for currencyMode. Expected boolean.');
if (typeof payloadMode !== 'boolean')
throw new Error('Invalid type for payloadMode. Expected boolean.');
if (typeof initialReward !== 'bigint' &&
typeof initialReward !== 'number' &&
typeof initialReward !== 'string')
throw new Error('Invalid type for initialReward. Expected bigint, number, or string.');
if (typeof halvingInterval !== 'bigint' &&
typeof halvingInterval !== 'number' &&
typeof halvingInterval !== 'string')
throw new Error('Invalid type for halvingInterval. Expected bigint, number, or string.');
if (typeof lastBlockReward !== 'bigint' &&
typeof lastBlockReward !== 'number' &&
typeof lastBlockReward !== 'string')
throw new Error('Invalid type for lastBlockReward. Expected bigint, number, or string.');
if (typeof initialBalances !== 'object' || initialBalances === null)
throw new Error('Invalid type for initialBalances. Expected object.');
if (!Array.isArray(admins))
throw new Error('Invalid type for admins. Expected array.');
admins.forEach((admin, index) => {
if (typeof admin !== 'string')
throw new Error(`Invalid type for admin at index ${index}. Expected string.`);
});
/**
* The signer instance used for cryptographic operations.
* @type {TinySecp256k1}
*/
this.#signer = signer;
/**
* Defines the maximum allowed size (in bytes) inside a single block's content.
* This is typically used to prevent overly large block contents.
*
* @type {number}
*/
this.#blockContentSizeLimit = blockContentSizeLimit;
/**
* Sets the maximum allowed size (in bytes) for a transaction payload.
* Used to limit the data carried by each transaction to avoid abuse.
*
* @type {number}
*/
this.#payloadSizeLimit = payloadSizeLimit;
/**
* Defines the total maximum size (in bytes) for an entire block.
* This includes headers, content, and metadata.
*
* @type {number}
*/
this.#blockSizeLimit = blockSizeLimit;
/**
* Whether the payload should be stored as a string or not.
* Controls the serialization behavior of block payloads.
*
* @type {boolean}
*/
this.#payloadString = payloadString;
/**
* A set of administrator public keys with elevated privileges.
*
* @type {Set<string>}
*/
this.admins = new Set(admins);
/**
* Enables or disables currency mode, where balances are tracked.
*
* @type {boolean}
*/
this.currencyMode = currencyMode;
/**
* Enables or disables payload mode, which allows block data to carry payloads.
*
* @type {boolean}
*/
this.payloadMode = payloadMode;
/**
* The symbolic gas cost applied per transfer operation.
*
* @type {bigint}
*/
this.transferGas = BigInt(transferGas);
/**
* The chain id applied per transfer operation.
*
* @type {bigint}
*/
this.chainId = BigInt(chainId);
/**
* The base fee per unit of gas, similar to Ethereum's `baseFeePerGas`.
*
* @type {bigint}
*/
this.baseFeePerGas = BigInt(baseFeePerGas);
/**
* The default priority fee (tip) per gas unit offered by transactions.
*
* @type {bigint}
*/
this.priorityFeeDefault = BigInt(priorityFeeDefault);
/**
* The mining difficulty, used to simulate block validation complexity.
*
* @type {bigint}
*/
this.diff = BigInt(diff);
/**
* The initial block reward.
*
* @type {bigint}
*/
this.initialReward = BigInt(initialReward);
/**
* The number of blocks after which the mining reward is halved.
*
* @type {bigint}
*/
this.halvingInterval = BigInt(halvingInterval);
/**
* The reward of the last mined block, used for tracking reward evolution.
*
* @type {bigint}
*/
this.lastBlockReward = BigInt(lastBlockReward);
if (currencyMode)
this.setInitialBalances(initialBalances);
/**
* The `balances` object, where the key is the address (string)
* and the value is the balance (bigint) of that address.
*
* @type {Balances}
*/
this.balances = {};
this.startBalances();
}
/**
* Sets the initial balances for the system.
*
* This method updates the `initialBalances` with a new set of address-balance pairs.
* It validates the input to ensure that each address is a valid string and each balance is a positive `bigint`.
*
* @emits InitialBalancesUpdated - Emitted when the initial balances are updated.
*
* @param {Balances} initialBalances - An object mapping addresses to their corresponding balances.
* @throws {Error} Throws an error if any address is invalid or any balance is not a positive `bigint`.
*
* @returns {void}
*/
setInitialBalances(initialBalances) {
if (typeof initialBalances !== 'object' || initialBalances === null)
throw new Error('Initial balances must be an object.');
// Validate each address and balance
for (const [address, balance] of Object.entries(initialBalances)) {
// Validate address
if (typeof address !== 'string' || address.trim().length === 0)
throw new Error(`Invalid address: "${address}". Address must be a non-empty string.`);
// Validate balance (should be a positive bigint)
if (typeof balance !== 'bigint' || balance < 0n)
throw new Error(`Invalid balance for address "${address}": expected a positive bigint, got "${typeof balance}".`);
}
// Set the new initial balances
this.initialBalances = initialBalances;
// Emit the event
this.#emit(TinyChainEvents.InitialBalancesUpdated, this.initialBalances);
}
/**
* Gets the current initial balances configured in the system.
*
* This method returns the mapping of addresses to their initial balances as last set
* by `setInitialBalances`. It ensures the returned data is a valid object.
*
* @returns {Balances} An object mapping addresses to their corresponding initial balances.
* @throws {Error} Throws an error if the internal initialBalances is not a valid object.
*/
getInitialBalances() {
if (typeof this.initialBalances !== 'object' || this.initialBalances === null)
throw new Error('Initial balances are not properly initialized.');
return this.initialBalances;
}
/**
* Initializes the blockchain by creating and pushing the genesis block.
*
* This method must be called only once. It ensures the blockchain is empty
* before creating the genesis block. If the chain already contains blocks,
* an error is thrown. The created genesis block is added to the chain and returned.
*
* @param {TinySecp256k1} [signer=this.#signer] - The address that is executing the block, typically the transaction sender.
*
* @returns {Promise<TinyChainBlock>} The genesis block that was created and added to the chain.
* @throws {Error} If the blockchain already contains a genesis block.
*
*/
async #init(signer = this.#signer) {
const chain = this.#getChain();
if (chain.length > 0)
throw new Error('Blockchain already initialized with a genesis block');
const data = {
transfers: [],
address: signer.getPublicKeyHex(),
addressType: signer.getType(),
payload: 'Genesis Block',
gasLimit: 0n,
gasUsed: 0n,
maxFeePerGas: 0n,
maxPriorityFeePerGas: 0n,
};
const sig = signer.signECDSA(this.#parser.serializeDeep(data), 'utf-8');
const block = await this.mineBlock(null, this.#createBlockInstance({
signer,
reward: 0n,
baseFeePerGas: 0n,
diff: 0n,
data: [data],
sigs: [sig.toString('hex')],
}));
return block;
}
/**
* Asynchronously initializes the blockchain instance by creating the genesis block.
*
* This method wraps the internal initialization logic in a queue to ensure
* exclusive access during the setup phase. It emits an `Initialized` event
* once the genesis block has been created and added to the chain.
*
* @param {TinySecp256k1} [signer=this.#signer] - The address that is executing the block, typically the transaction sender.
*
* @returns {Promise<void>} A promise that resolves once initialization is complete.
*
* @emits Initialized - When the genesis block is successfully created and added.
*/
async init(signer = this.#signer) {
const block = await this.#init(signer);
this.#emit(TinyChainEvents.Initialized, block);
}
/**
* Creates a new instance of a block with the given options.
*
* This method wraps the creation of a `TinyChainBlock`, automatically injecting
* internal configuration such as `payloadString` and `parser`, and merging any
* additional options provided by the caller.
*
* @param {BlockInitData} options - Additional block options to override or extend defaults.
* @returns {TinyChainBlock} A new instance of the TinyChainBlock.
*
*/
#createBlockInstance(options) {
const blockConfig = {
baseFeePerGas: this.getBaseFeePerGas(),
payloadString: this.#payloadString,
parser: this.#parser,
signer: this.#signer,
chainId: this.getChainId(),
...options,
};
if (this.#blockSizeLimit >= 0) {
const blockSize = this.#textEncoder.encode(this.#parser.serializeDeep(Object.fromEntries(Object.entries(blockConfig).filter(([key, value]) => typeof value !== 'function' && !['parser', 'signer'].includes(key))))).length;
if (blockSize > this.#blockSizeLimit)
throw new Error(`Block size exceeded: block is ${blockSize} bytes, limit is ${this.#blockSizeLimit} bytes`);
}
return new TinyChainBlock(blockConfig);
}
/**
* Simulates gas estimation for an Ethereum-like transaction.
* Considers base cost, data size, and per-transfer cost.
* @param {Transaction[]|NewTransaction[]} transfers - List of transfers (e.g., token or balance movements).
* @param {any} payload - Data to be included in the transaction (e.g., contract call).
* @returns {bigint}
*/
estimateGasUsed(transfers, payload) {
if (typeof payload !== 'undefined' && payload !== null && !Array.isArray(transfers))
throw new Error(`"transfers" in data entry must be a array.`);
if (this.#payloadString && typeof payload !== 'string')
throw new Error(`"payload" in data entry must be a string.`);
const ZERO_BYTE_COST = 4n;
const NONZERO_BYTE_COST = 16n;
// Serialize and calculate gas per byte (mimicking Ethereum rules)
const serialized = this.#parser.serializeDeep(payload);
let dataGas = 0n;
for (let i = 0; i < serialized.length; i++) {
// @ts-ignore
dataGas += serialized[i] === 0 ? ZERO_BYTE_COST : NONZERO_BYTE_COST;
}
// Transfer gas cost (symbolic)
const transferCount = Array.isArray(transfers) ? BigInt(transfers.length) : 0n;
const transfersGas = transferCount * this.getTransferGas();
return dataGas + transfersGas;
}
/**
* Validates a single block against its expected hash and optionally the previous block.
*
* A block is considered valid if:
* - It exists
* - Its stored hash matches its recalculated hash
* - If a previous block is provided, its `prevHash` matches the hash of that block
*
* @param {TinyChainBlock} current - The block to validate.
* @param {TinyChainBlock|null} [previous] - The previous block in the chain (optional).
* @returns {boolean|null} Returns `true` if valid, `false` if invalid, or `null` if block is missing.
*
*/
#isValidBlock(current, previous = null) {
if (!current)
return null;
if (!(current instanceof TinyChainBlock))
throw new Error('Current block is not a valid TinyChainBlock instance.');
let result = false;
try {
if (this.isChainBlockIgnored(current.getIndex(), current.getHash()) ||
this.isChainBlockHashIgnored(current.getPrevHash() || '')) {
result = true;
return true;
}
current.validateSig();
}
catch {
return false;
}
finally {
if (result)
return true;
if (current.hash !== current.calculateHash())
return false;
if (previous) {
if (!(previous instanceof TinyChainBlock))
throw new Error('Previous block is not a valid TinyChainBlock instance.');
try {
if (this.isChainBlockIgnored(previous.getIndex(), previous.getHash()) ||
this.isChainBlockHashIgnored(previous.getPrevHash() || '')) {
result = true;
return true;
}
previous.validateSig();
}
catch {
return false;
}
finally {
if (result)
return true;
if (current.prevHash !== previous.hash)
return false;
}
}
}
return true;
}
/**
* Validates the blockchain from a starting to an ending index.
*
* It checks whether each block has a valid hash and that each block correctly references the previous one.
* Skips the genesis block by default and starts from index 1 unless a different start is provided.
*
* @param {number} [startIndex=0] - The starting index to validate from (inclusive).
* @param {number|null} [endIndex=null] - The ending index to validate up to (inclusive). Defaults to the last block.
* @returns {boolean|null} Returns `true` if the chain is valid, `false` or `null` otherwise.
*/
isValid(startIndex = 0, endIndex = null) {
const chain = this.#getChain();
const end = endIndex ?? chain.length - 1;
for (let i = startIndex; i <= end; i++) {
const current = chain[i];
const previous = chain[i - 1];
const result = this.#isValidBlock(current, previous);
if (!result)
return result;
}
return true;
}
/**
* Checks if a new block is valid in relation to the latest block in the chain.
*
* This is typically used before appending a new block to ensure integrity.
*
* @param {TinyChainBlock} newBlock - The new block to validate.
* @param {TinyChainBlock} [prevBlock=this.getLatestBlock()] - The prev block to validate.
* @returns {boolean|null} Returns `true` if the new block is valid, otherwise `false` or `null`.
*/
isValidNewBlock(newBlock, prevBlock = this.existsLatestBlock() ? this.getLatestBlock() : undefined) {
return this.#isValidBlock(newBlock, prevBlock);
}
/**
* Checks whether the new block has a valid previous hash.
*
* This ensures the block references a previous block and is not the genesis block.
*
* @param {TinyChainBlock} newBlock - The block to check.
* @returns {boolean} Returns `true` if the previous hash is valid, otherwise `false`.
*/
existsPrevBlock(newBlock) {
if (typeof newBlock.prevHash !== 'string' ||
newBlock.prevHash.trim().length < 1 ||
newBlock.prevHash === '0')
return false;
return true;
}
/**
* Calculates the current block reward based on the chain height and halving intervals.
*
* The reward starts at `initialReward` and is halved every `halvingInterval` blocks.
* If the chain height exceeds the reward threshold, the reward becomes zero.
*
* @returns {bigint} The current block reward in the smallest unit of currency (e.g., wei, satoshis).
* Returns `0n` if the reward has been exhausted.
*/
getCurrentReward() {
const height = this.getChainLength();
if (height > this.getLastBlockReward())
return 0n;
const halvings = height / this.getHalvingInterval();
const reward = this.getInitialReward() / 2n ** halvings;
return reward > 0n ? reward : 0n;
}
/** @type {Object<string, string>} */
invalidAddress = {
0: 'NULL',
1: 'UNKNOWN',
};
/**
* Validates the transaction content before inclusion in a block.
* This method checks payload format, transfer structure, gas-related constraints,
* and address validity. Throws detailed errors if any validation fails.
*
* @param {Object} [options={}] - Content data to be validated.
* @param {string} [options.payload] - Raw payload data in string format. Required.
* @param {Transaction[]|NewTransaction[]} [options.transfers] - List of transfers to be validated. Must be an array.
* @param {bigint} [options.gasLimit] - Maximum allowed gas usage for the transaction.
* @param {bigint} [options.maxFeePerGas] - The maximum total fee per unit of gas the sender is willing to pay.
* @param {bigint} [options.maxPriorityFeePerGas] - The tip paid to miners per unit of gas.
* @param {string} [options.address] - Sender address of the transaction.
* @param {string} [options.addressType] - Type of address (e.g., 'user', 'contract'). Must be a non-empty string.
*
* @throws {Error} If payload is not a string when required.
* @throws {Error} If transfers is not an array.
* @throws {Error} If any gas parameter is not a BigInt.
* @throws {Error} If address is not a string.
* @throws {Error} If addressType is invalid.
* @throws {Error} If gas used exceeds the provided gas limit.
* @throws {Error} If block content size exceeds the defined block content size limit.
* @throws {Error} If payload size exceeds the defined payload size limit.
*
* @returns {bigint} Returns the estimated gas used for the transaction.
*/
validateContent({ payload, transfers, gasLimit, maxFeePerGas, maxPriorityFeePerGas, address, addressType, } = {}) {
if (this.#payloadString && typeof payload !== 'string')
throw new Error('Payload must be a string');
if (!Array.isArray(transfers))
throw new Error('Transfers must be an array');
const gasUsed = this.isCurrencyMode() ? this.estimateGasUsed(transfers, payload) : 0n;
if (typeof gasLimit !== 'bigint' ||
typeof maxFeePerGas !== 'bigint' ||
typeof maxPriorityFeePerGas !== 'bigint')
throw new Error('Gas parameters must be BigInt');
if (typeof address !== 'string')
throw new Error('Address value must be string');
if (typeof addressType !== 'string' || addressType.length === 0)
throw new Error('Invalid address type');
const invalidValue = this.invalidAddress[address];
if (typeof invalidValue === 'string')
throw new Error(`Transaction has an invalid address "${address}" (${invalidValue}).`);
const totalGas = gasUsed + this.getBaseFeePerGas();
if (totalGas > gasLimit)
throw new Error(`Gas limit exceeded: used ${totalGas} > limit ${gasLimit}`);
if (Array.isArray(transfers))
this.validateTransfers(address, addressType, transfers);
if (this.#payloadSizeLimit >= 0) {
const payloadSize = this.#textEncoder.encode(this.#payloadString ? payload : this.#parser.serializeDeep(payload)).length;
if (payloadSize > this.#payloadSizeLimit)
throw new Error(`Payload size exceeded: payload is ${payloadSize} bytes, limit is ${this.#payloadSizeLimit} bytes`);
}
if (this.#blockContentSizeLimit >= 0) {
const blockContentSize = this.#textEncoder.encode(this.#parser.serializeDeep({
payload,
transfers,
gasLimit,
maxFeePerGas,
maxPriorityFeePerGas,
address,
addressType,
})).length;
if (blockContentSize > this.#blockContentSizeLimit)
throw new Error(`Block content size exceeded: content is ${blockContentSize} bytes, limit is ${this.#blockContentSizeLimit} bytes`);
}
return gasUsed;
}
/**
* Creates a new block content for the blockchain with the provided transaction data and gas options.
*
* The method will validate the address, estimate the gas used for the transactions, and ensure that the gas
* limit is not exceeded before creating the block. It also includes reward information if `currencyMode` is enabled.
*
* @param {Object} [options={}] - Block options.
* @param {TinySecp256k1} [options.signer=this.#signer] - The address that is executing the block, typically the transaction sender.
* @param {string} [options.payload=''] - The data to be included in the block's payload. Default is an empty string.
* @param {Array<Transaction>} [options.transfers=[]] - The list of transfers (transactions) to be included in the block.
* @param {GasConfig} [options.gasOptions={}] - Optional gas-related configuration.
*
* @return {BlockContent}
* @throws {Error} Throws an error if the `execAddress` is invalid or if the gas limit is exceeded.
*/
createBlockContent({ signer = this.#signer, payload = '', transfers = [], gasOptions = {}, } = {}) {
if (!(signer instanceof TinySecp256k1))
throw new Error('Invalid signer: expected TinySecp256k1-compatible object');
const isCurrencyMode = this.isCurrencyMode();
const { gasLimit = 50000n, maxFeePerGas = 200n, maxPriorityFeePerGas = this.getDefaultPriorityFee(), } = gasOptions;
const address = signer.getPublicKeyHex();
const addressType = signer.getType();
const gasUsed = this.validateContent({
payload,
transfers,
gasLimit,
maxFeePerGas,
maxPriorityFeePerGas,
address,
addressType,
});
const data = {
transfers,
address,
addressType,
payload,
gasLimit: isCurrencyMode ? gasLimit : 0n,
gasUsed,
maxFeePerGas: isCurrencyMode ? maxFeePerGas : 0n,
maxPriorityFeePerGas: isCurrencyMode ? maxPriorityFeePerGas : 0n,
};
const serialized = this.#parser.serializeDeep(data);
if (typeof serialized !== 'string' || serialized.length === 0)
throw new Error('Failed to serialize block content');
const sig = signer.signECDSA(serialized, 'utf-8');
return { data, sig };
}
/**
* Creates a new blockchain block using the provided signed content payloads.
*
* This method aggregates multiple `BlockContent` entries—each containing transaction data and a corresponding signature—into a single block.
* It includes optional reward data when the chain is operating in `currencyMode`, and computes the current difficulty setting (`diff`) at creation time.
*
* The block is finalized by calling an internal method that handles structural assembly and final consistency.
*
* @param {BlockContent[]} content - An array of signed block content objects, each containing transaction data and its signature.
* @param {TinySecp256k1} [signer=this.#signer] - The address that is executing the block, typically the transaction sender.
* @returns {TinyChainBlock} The newly created block instance with all aggregated data, ready to be appended to the chain.
* @throws {Error}
*/
createBlock(content, signer = this.#signer) {
if (!Array.isArray(content) || content.length === 0)
throw new Error('Content must be a non-empty array of BlockContent');
const isCurrencyMode = this.isCurrencyMode();
const reward = isCurrencyMode ? this.getCurrentReward() : 0n;
const dataList = [];
const sigs = [];
for (const [index, item] of content.entries()) {
if (typeof item !== 'object' ||
item === null ||
typeof item.data !== 'object' ||
item.data === null)
throw new Error(`Invalid BlockContent at index ${index}`);
const { sig, data } = item;
const gasUsed = this.validateContent(data);
if (gasUsed !== data.gasUsed)
throw new Error(`Gas used at index ${index} value does not match`);
if (!Buffer.isBuffer(sig) && typeof sig !== 'string')
throw new Error(`Signature at index ${index} must be a Buffer or hex string`);
dataList.push(data);
sigs.push(typeof sig === 'string' ? sig : sig.toString('hex'));
}
return this.#createBlockInstance({
data: dataList,
sigs,
reward,
signer,
diff: this.getDiff(),
});
}
/**
* Mines a new block and adds it to the blockchain after validating and updating balances.
*
* This method will:
* - Validate the block by checking its correctness using the `isValidNewBlock()` method.
* - Update balances if `currencyMode` or `payloadMode` is enabled.
* - Add the mined block to the blockchain and emit an event for the new block.
*
* @param {string|null} minerAddress - The address of the miner who is mining the block.
* @param {TinyChainBlock} newBlock - The new block instance to be mined.
*
* @emits NewBlock - When the new block is added.
*
* @returns {Promise<TinyChainBlock>} A promise that resolves to the mined block once it has been added to the blockchain.
*
* @throws {Error} Throws an error if the mining process fails, or if the block is invalid.
*/
mineBlock(minerAddress, newBlock) {
return this.#queue.enqueue(async () => {
const mineNow = async () => {
const previousBlock = this.existsLatestBlock() ? this.getLatestBlock() : null;
const result = await newBlock.mine(minerAddress, {
prevHash: previousBlock ? previousBlock.hash : '0',
index: previousBlock ? previousBlock.index + 1n : 0n,
});
if (!result.success)
throw new Error('Block mining failed');
const isValid = this.isValidNewBlock(newBlock);
if (!isValid)
throw new Error('Block mining invalid');
if (this.isCurrencyMode() || this.isPayloadMode())
this.updateBalance(newBlock);
this.#getChain().push(newBlock);
this.#emit(TinyChainEvents.NewBlock, newBlock);
return newBlock;
};
return mineNow();
});
}
/**
* Adds a pre-mined block to the blockchain after validating it and updating balances.
*
* This method will:
* - Validate the block by checking its hash, linkage and structure via `isValidNewBlock()`.
* - Update account balances if `currencyMode` or `payloadMode` is enabled.
* - Append the validated block to the blockchain and emit the `NewBlock` event.
*
* @param {TinyChainBlock} minedBlock - The already mined block to be added to the blockchain.
*
* @emits NewBlock - When the new block is added.
*
* @returns {Promise<TinyChainBlock>} A promise that resolves to the block once it has been added.
*
* @throws {Error} Throws an error if the block is invalid or balance update fails.
*/
addMinedBlock(minedBlock) {
return this.#queue.enqueue(async () => {
const isValid = this.isValidNewBlock(minedBlock);
if (!isValid)
throw new Error('Invalid block cannot be added to the chain.');
if (this.isCurrencyMode() || this.isPayloadMode())
this.updateBalance(minedBlock);
this.#getChain().push(minedBlock);
this.#emit(TinyChainEvents.NewBlock, minedBlock);
return minedBlock;
});
}
/**
* Gets the first block in the blockchain.
*
* This method retrieves the first block from the blockchain by calling `getChainBlock(0)`.
*
* @returns {TinyChainBlock} The first block in the blockchain.
*/
getFirstBlock() {
return this.getChainBlock(0);
}
/**
* Gets the latest block in the blockchain.
*
* This method retrieves the most recent block added to the blockchain by calling `getChainBlock(chain.length - 1)`.
*
* @returns {TinyChainBlock} The latest block in the blockchain.
*/
getLatestBlock() {
return this.getChainBlock(this.#getChain().length - 1);
}
/**
* Checks the latest block in the blockchain.
*
* This method checks existence the most recent block added to the blockchain.
*
* @returns {boolean} The latest block in the blockchain exists.
*/
existsLatestBlock() {
return this.#getChain().length - 1 >= 0;
}
/**
* Initializes the `balances` object and emits events related to balance setup.
*
* This method resets the internal `balances` object to an empty state and emits
* a `BalancesInitialized` event. If `currencyMode` is active, it will populate
* the `balances` from the `initialBalances` mapping, converting all balances to BigInt,
* and emit a `BalanceStarted` event for each initialized address.
*
* @emits BalancesInitialized - When the balances object is reset.
* @emits BalanceStarted - For each address initialized when in currency mode.
*/
startBalances() {
this.balances = {};
this.#emit(TinyChainEvents.BalancesInitialized, this.balances);
if (this.isCurrencyMode()) {
const balances = this.#getBalances();
for (const [address, balance] of Object.entries(this.getInitialBalances())) {
balances[address] = BigInt(balance);
this.#emit(TinyChainEvents.BalanceStarted, address, balances[address]);
}
}
}
/**
* Validates a list of transfers for a transaction.