libnexa-ts
Version:
A pure and powerful Nexa SDK library.
671 lines (586 loc) • 21.3 kB
text/typescript
import BufferUtils from "../../utils/buffer.utils";
import CommonUtils from "../../utils/common.utils";
import Script from "../script/script";
import Input from "./input/input";
import Output from "./output";
import type { ITransaction, TxVerifyOptions } from "./interfaces";
import BufferWriter from "../../encoding/bufferwriter";
import { Opcode } from "../script/opcode";
import Hash from "../../crypto/hash";
import ValidationUtils from "../../utils/validation.utils";
import type Address from "../address/address";
import ScriptFactory from "../script/script.factory";
import type { IScript } from "../../common/interfaces";
import BufferReader from "../../encoding/bufferreader";
import PublicKeyHashInput from "./input/publickeyhash";
import PublicKeyTemplateInput from "./input/publickeytemplate";
import ScriptTemplateInput from "./input/scripttemplate";
/**
* Represents a transaction, a set of inputs and outputs to change ownership of tokens
*/
export default class Transaction implements ITransaction {
public static readonly CURRENT_VERSION = 0;
public static readonly FEE_PER_BYTE = 3;
// Minimum amount for an output for it not to be considered a dust output
public static readonly DUST_AMOUNT = 546;
// Max amount of satoshis in circulation
public static readonly MAX_MONEY = 21000000 * 1e8;
// nlocktime limit to be considered block height rather than a timestamp
public static readonly NLOCKTIME_BLOCKHEIGHT_LIMIT = 5e8;
// Max value for an unsigned 32 bit value
public static readonly NLOCKTIME_MAX_VALUE = 4294967295;
public version: number;
public inputs: Input[];
public outputs: Output[];
public nLockTime: number;
private _feePerByte?: number;
private _fee?: number
private _changeIndex?: number;
private _changeScript?: Script;
constructor(serializedTx?: ITransaction | string | Uint8Array) {
this.version = Transaction.CURRENT_VERSION;
this.inputs = [];
this.outputs = [];
this.nLockTime = 0;
if (serializedTx) {
if (BufferUtils.isBuffer(serializedTx)) {
this.fromBuffer(serializedTx);
} else if (CommonUtils.isHexa(serializedTx as string)) {
this.fromString(serializedTx as string);
} else if (typeof serializedTx === 'object' && serializedTx !== null) {
this.fromObject(serializedTx);
} else {
throw new TypeError("Must provide an object or string to deserialize a transaction");
}
}
}
public get id(): string {
let buf = new BufferWriter().write(this._getTxIdem()).write(this._getTxSatisfier()).toBuffer();
return BufferUtils.bufferToHex(Hash.sha256sha256(buf).reverse());
}
public get idem(): string {
return BufferUtils.bufferToHex(this._getTxIdem().reverse());
}
public get outputAmount(): bigint {
return this.outputs.reduce((total, output) => total + output.value, 0n);
}
public get inputAmount(): bigint {
return this.inputs.reduce((total, input) => total + input.amount, 0n);
}
private _getTxIdem(): Uint8Array {
return Hash.sha256sha256(this._toIdemBuffer());
}
private _getTxSatisfier(): Uint8Array {
return Hash.sha256sha256(this._toSatisfierBuffer());
}
/**
* Create a 'shallow' copy of the transaction, by serializing and deserializing.
* it dropping any additional information that inputs and outputs may have hold
*
* @param transaction
*/
public static shallowCopy(transaction: Transaction): Transaction {
return new Transaction(transaction.toBuffer());
}
/**
* Analogous to nexad's IsCoinBase function in transaction.h
*/
public isCoinbase(): boolean {
return this.inputs.length === 0;
}
/**
* Retrieve a possible error that could appear when trying to serialize and
* broadcast this transaction.
*
* @param opts allows to skip certain tests.
*/
public getSerializationError(opts?: TxVerifyOptions): Error | undefined {
if (this._invalidAmount()) {
return new Error('Output satoshis are invalid');
}
if (this.outputs.length > 256) {
return new Error('Too many outputs (> 256)');
}
if (this.inputs.length > 256) {
return new Error('Too many inputs (> 256)');
}
let unspent = this.getUnspentValue();
let unspentError: Error | undefined;
if (unspent < 0) {
if (!opts?.disableMoreOutputThanInput) {
unspentError = new Error('Invalid outputs amount sum');
}
} else {
unspentError = this._hasFeeError(unspent);
}
return unspentError ||
this._hasDustOutputs(opts) ||
this._isMissingSignatures(opts);
}
private _invalidAmount(): boolean {
return this.outputs.some(out => out.invalidValue());
}
private _hasDustOutputs(opts?: TxVerifyOptions): Error | undefined {
if (opts?.disableDustOutputs) {
return;
}
let hasDust = this.outputs.some(output => output.value < Transaction.DUST_AMOUNT && !output.scriptPubKey.isDataOut());
return hasDust ? new Error('Dust amount detected in one output') : undefined;
}
private _hasFeeError(unspent: bigint): Error | undefined {
if (this._fee != null && BigInt(this._fee) !== unspent) {
return new Error(`Unspent value is ${unspent} but specified fee is ${this._fee}`);
}
}
private _estimateFee(): number {
let estimatedSize = this._estimateSize();
let available = this.getUnspentValue();
let feeRate = this._feePerByte || Transaction.FEE_PER_BYTE;
const calcFee = (size: number): number => {
return size * feeRate;
}
let feeWithChange = Math.ceil(calcFee(estimatedSize) + calcFee(this._estimateSizeOfChangeOutput()));
if (!this._changeScript || available <= feeWithChange || available - BigInt(feeWithChange) < Transaction.DUST_AMOUNT) {
return Number(available);
}
return feeWithChange;
}
private _estimateSizeOfChangeOutput(): number {
/* c8 ignore start */
if (!this._changeScript) {
return 0; // should not reach here
}
/* c8 ignore stop */
let scriptLen = this._changeScript.toBuffer().length;
// 1 byte for type + 8 bytes for amount + script size + actual script size
return 1 + 8 + BufferWriter.varintBufNum(scriptLen).length + scriptLen;
}
private _estimateSize(): number {
let result = 4 + 1; // locktime + version
result += this.inputs.length < 253 ? 1 : 3;
this.inputs.forEach(input => {
result += input.estimateSize();
});
result += this.outputs.length < 253 ? 1 : 3;
this.outputs.forEach(output => {
result += output.toBufferWriter().toBuffer().length;
});
return result;
}
private _isMissingSignatures(opts?: TxVerifyOptions): Error | undefined {
if (opts?.disableIsFullySigned) {
return;
}
if (!this.isFullySigned()) {
return new Error('Some inputs have not been fully signed');
}
}
public isFullySigned(): boolean {
if (this.inputs.some(input => input.isFullySigned === Input.prototype.isFullySigned)) {
throw new Error("Unable to verify signature: Unrecognized script kind, or not enough information to execute script."
+ " This usually happens when creating a transaction from a serialized transaction")
}
return this.inputs.every(input => input.isFullySigned());
}
/**
* @returns true if the transaction has enough info on all inputs to be correctly validated
*/
public hasAllUtxoInfo(): boolean {
return this.inputs.every(input => input.output != null);
}
public getUnspentValue(): bigint {
return this.inputAmount - this.outputAmount;
}
/**
* Calculates the fee of the transaction.
*
* If there's a fixed fee set, return that.
*
* If there is no change output set, the fee is the
* total value of the outputs minus inputs. Note that
* a serialized transaction only specifies the value
* of its outputs. (The value of inputs are recorded
* in the previous transaction outputs being spent.)
* This method therefore raises a "MissingPreviousOutput"
* error when called on a serialized transaction.
*
* If there's no fee set and no change address,
* estimate the fee based on size.
*
* @return fee of this transaction in satoshis
*/
public getFee(): number {
if (this.isCoinbase()) {
return 0;
}
if (this._fee != null) {
return this._fee;
}
// if no change output is set, fees should equal all the unspent amount
if (!this._changeScript) {
return Number(this.getUnspentValue());
}
return this._estimateFee();
}
/**
* Calculates the required fee of the transaction.
*
* @remarks this method is different than getFee.
* while getFee return the current fee estimation, this method return how much fee is required according to the fee rate.
*
* @returns the required fees of this transaction in satoshis
*/
public estimateRequiredFee(): number {
let feeRate = this._feePerByte || Transaction.FEE_PER_BYTE;
return feeRate * this._estimateSize();
}
public clearSignatures(): void {
this.inputs.forEach(input => input.clearSignatures());
}
/**
* Retrieve a hexa string that can be used with nexad's CLI interface
* (decoderawtransaction, sendrawtransaction)
*
* @param opts allows to skip certain tests.
*/
public checkedSerialize(opts?: TxVerifyOptions): string {
let serializationError = this.getSerializationError(opts);
if (serializationError) {
throw serializationError;
}
return this.toString();
}
public uncheckedSerialize = this.toString;
public toString(): string {
return BufferUtils.bufferToHex(this.toBuffer());
}
public inspect(): string {
return `<Transaction: ${this}>`;
}
public fromString(string: string): this {
return this.fromBuffer(BufferUtils.hexToBuffer(string));
}
/**
* Retrieve a hexa string that can be used with nexad's CLI interface
* (decoderawtransaction, sendrawtransaction)
*
* @param unsafe if true, skip all tests. if it's an object,
* it's expected to contain a set of flags to skip certain tests.
*
* @see {@link TxVerifyOptions}
*/
public serialize(unsafe?: boolean | TxVerifyOptions): string {
if (true === unsafe || (typeof unsafe === 'object' && unsafe !== null && unsafe.disableAll)) {
return this.uncheckedSerialize();
} else {
return this.checkedSerialize(typeof unsafe === 'object' && unsafe !== null ? unsafe : undefined);
}
}
/**
* Manually set the fee for this transaction. Beware that this resets all the signatures
* for inputs.
*
* @param amount satoshis to be set as fees
* @return this, for chaining
*/
public setFee(amount: number): this {
ValidationUtils.validateArgument(typeof amount === 'number', 'amount must be a number');
this._fee = amount;
this._updateChangeOutput();
return this;
}
/**
* Manually set the fee per Byte for this transaction. Beware that this resets all the signatures
* for inputs.
* fee per Byte will be ignored if fee property was set manually
*
* @param amount satoshis per Byte to be used as fee rate
* @return this, for chaining
*/
public setFeePerByte(amount: number): this {
ValidationUtils.validateArgument(typeof amount === 'number', 'amount must be a number');
this._feePerByte = amount;
this._updateChangeOutput();
return this;
}
/**
* Add an output to the transaction.
*
* @param output the output to add.
* @return this, for chaining
*/
public addOutput(output: Output): this {
ValidationUtils.validateArgumentType(output, Output, 'output');
this.outputs.push(output);
this._updateChangeOutput();
return this;
}
public removeOutput(index: number): this {
this._removeOutput(index);
this._updateChangeOutput();
return this;
}
private _removeOutput(index: number): void {
this.outputs = this.outputs.filter((_, i) => i !== index);
}
/**
* Remove all outputs from the transaction.
*
* @return this, for chaining
*/
public clearOutputs(): this {
this.outputs = [];
this.clearSignatures();
this._updateChangeOutput();
return this;
}
public updateOutputAmount(index: number, sats: bigint | number): void {
this.outputs[index].value = BigInt(sats);
this._updateChangeOutput();
}
/**
* Set the change address for this transaction
*
* Beware that this resets all the signatures for inputs.
*
* @param address An address for change to be sent to.
* @return this, for chaining
*/
public setChangeOutput(address: string | Address): this {
ValidationUtils.validateArgument(address != null, 'address is required.');
this._changeScript = ScriptFactory.buildOutFromAddress(address);
this._updateChangeOutput();
return this;
}
/**
* @returns change output, if it exists
*/
public getChangeOutput(): Output | undefined {
if (this._changeIndex != null) {
return this.outputs[this._changeIndex];
}
return undefined;
}
private _updateChangeOutput(): void {
if (!this._changeScript) {
return;
}
this.clearSignatures();
if (this._changeIndex != null) {
this._removeOutput(this._changeIndex);
}
let available = this.getUnspentValue();
let fee = this.getFee();
let changeAmount = available - BigInt(fee);
if (changeAmount >= Transaction.DUST_AMOUNT) {
this._changeIndex = this.outputs.length;
this.outputs.push(new Output(changeAmount, this._changeScript));
} else {
this._changeIndex = undefined;
}
}
/**
* Add an input to this transaction, without checking that the input has information about
* the output that it's spending.
*
* @param input the input to add
* @return this, for chaining
*/
public uncheckedAddInput(input: Input): this {
ValidationUtils.validateArgumentType(input, Input, 'input');
this.inputs.push(input);
this._updateChangeOutput();
return this;
}
/**
* Add an input to this transaction. The input must be an instance of the `Input` class.
* It should have information about the Output that it's spending, but if it's not already
* set, two additional parameters, `outputScript` and `amount` can be provided.
*
* @param input
* @param outputScript
* @param amount
* @return this, for chaining
*/
public addInput(input: Input, outputScript?: IScript | Uint8Array | string, amount?: bigint | number): this {
ValidationUtils.validateArgumentType(input, Input, 'input');
if (input.output == null && (outputScript == null || amount == null)) {
throw new Error('Need information about the UTXO script and amount');
}
if (input.output == null && outputScript != null && amount != null) {
let scriptPubKey = outputScript instanceof Script ? outputScript : new Script(outputScript);
input.output = new Output(BigInt(amount), scriptPubKey);
}
return this.uncheckedAddInput(input);
}
public removeInput(outpoint: string): this {
this.inputs = this.inputs.filter(input => BufferUtils.bufferToHex(input.outpoint) != outpoint);
this._updateChangeOutput();
return this;
}
/**
* Sets nLockTime so that transaction is not valid until the desired date or height.
* Beware that this method will also set the inputs sequence number to max_int - 1
*
* @remarks nLockTime considered as height if the value is between 0 - 499,999,999.
* above that considered as date (unix timestamp).
*
* @see {@link NLOCKTIME_BLOCKHEIGHT_LIMIT}
*
* @param locktime
* @returns
*/
public setLockTime(locktime: number): this {
ValidationUtils.validateArgument(typeof locktime === 'number', 'locktime', 'must be a number');
this.inputs.forEach(input => {
if (input.sequenceNumber === Input.SEQUENCE_FINAL) {
input.sequenceNumber = Input.SEQUENCE_FINAL - 1;
}
});
this.nLockTime = locktime;
return this;
}
/**
* Returns a semantic version of the transaction's nLockTime.
* If nLockTime is 0, it returns null,
* if it is < 500000000, it returns a block height (number)
* else it returns a Date object.
*/
public getLockTime(): number | Date | null {
if (!this.nLockTime) {
return null;
}
if (this.nLockTime < Transaction.NLOCKTIME_BLOCKHEIGHT_LIMIT) {
return this.nLockTime;
}
return new Date(1000 * this.nLockTime);
}
public toBuffer(): Uint8Array {
return this.toBufferWriter().toBuffer();
}
public toBufferWriter(writer?: BufferWriter, withInputsScripts = true): BufferWriter {
if (!writer) {
writer = new BufferWriter();
}
writer.writeUInt8(this.version);
writer.writeVarintNum(this.inputs.length);
this.inputs.forEach(input => input.toBufferWriter(writer, withInputsScripts));
writer.writeVarintNum(this.outputs.length);
this.outputs.forEach(output => output.toBufferWriter(writer));
writer.writeUInt32LE(this.nLockTime);
return writer;
}
private _toIdemBuffer(): Uint8Array {
let writer = new BufferWriter();
return this.toBufferWriter(writer, false).toBuffer();
}
private _toSatisfierBuffer(): Uint8Array {
let writer = new BufferWriter();
writer.writeInt32LE(this.inputs.length);
this.inputs.forEach(input => {
writer.write(input.scriptSig.toBuffer());
writer.writeUInt8(Opcode.OP_INVALIDOPCODE);
});
return writer.toBuffer();
}
public fromBuffer(buffer: Uint8Array): this {
let reader = new BufferReader(buffer);
return this.fromBufferReader(reader);
}
public fromBufferReader(reader: BufferReader): this {
ValidationUtils.validateState(!reader.finished(), 'No transaction data received');
this.version = reader.readUInt8();
let sizeTxIns = reader.readVarintNum();
for (let i = 0; i < sizeTxIns; i++) {
this.inputs.push(Input.fromBufferReader(reader));
}
let sizeTxOuts = reader.readVarintNum();
for (let i = 0; i < sizeTxOuts; i++) {
this.outputs.push(Output.fromBufferReader(reader));
}
this.nLockTime = reader.readUInt32LE();
return this;
}
public toJSON = this.toObject;
public toObject(): ITransaction {
let obj: ITransaction = {
id: this.id,
idem: this.idem,
version: this.version,
inputs: this.inputs.map(input => input.toObject()),
outputs: this.outputs.map(output => output.toObject()),
nLockTime: this.nLockTime
};
if (this._changeScript != null) {
obj.changeScript = this._changeScript.toHex();
}
if (this._changeIndex != null) {
obj.changeIndex = this._changeIndex;
}
if (this._fee != null) {
obj.fee = this._fee;
}
if (this._feePerByte != null) {
obj.feePerByte = this._feePerByte;
}
return obj;
}
public fromObject(transaction: ITransaction): this {
ValidationUtils.validateArgument(typeof transaction === 'object' && transaction != null, 'transaction');
if (transaction instanceof Transaction) {
transaction = transaction.toObject();
}
this.nLockTime = transaction.nLockTime;
this.version = transaction.version;
for (let input of transaction.inputs) {
if (input.output?.scriptPubKey == null) {
this.uncheckedAddInput(new Input(input));
continue;
}
let script = new Script(input.output.scriptPubKey);
let txin: Input;
if (script.isPublicKeyHashOut()) {
txin = new PublicKeyHashInput(input);
} else if (script.isPublicKeyTemplateOut()) {
txin = new PublicKeyTemplateInput(input);
} else if (script.isScriptTemplateOut()) {
if ('templateData' in input) {
txin = new ScriptTemplateInput(input);
} else {
txin = new Input(input);
}
} else {
throw new Error(`Unsupported input script type: ${script}`);
}
this.addInput(txin);
}
for(let output of transaction.outputs) {
this.addOutput(Output.fromObject(output));
}
if (transaction.changeIndex != null) {
this._changeIndex = transaction.changeIndex;
}
if (transaction.changeScript != null) {
this._changeScript = new Script(transaction.changeScript);
}
if (transaction.fee != null) {
this._fee = transaction.fee;
}
if (transaction.feePerByte != null) {
this._feePerByte = transaction.feePerByte;
}
this._checkConsistency(transaction);
return this;
}
private _checkConsistency(transaction: ITransaction): void {
if (this._changeIndex != null) {
ValidationUtils.validateState(this._changeScript != null, 'Change script is expected.');
ValidationUtils.validateState(this.outputs[this._changeIndex] != null, 'Change index points to undefined output.');
ValidationUtils.validateState(this.outputs[this._changeIndex].scriptPubKey.toHex() ===
this._changeScript?.toHex(), 'Change output has an unexpected script.');
}
if (transaction?.id) {
ValidationUtils.validateState(transaction.id === this.id, 'Id in object does not match transaction id.');
}
}
}