UNPKG

libnexa-ts

Version:

A pure and powerful Nexa SDK library.

790 lines (711 loc) 25.5 kB
import type { IScript, ScriptChunk } from "../../common/interfaces"; import BufferUtils from "../../utils/buffer.utils"; import ValidationUtils from "../../utils/validation.utils"; import BufferReader from "../../encoding/bufferreader"; import BufferWriter from "../../encoding/bufferwriter"; import ScriptOpcode, { Opcode } from "./opcode"; import CommonUtils from "../../utils/common.utils"; import BN from "../../crypto/bn.extension"; export type ScriptElement = string | number | bigint | boolean | Uint8Array | ScriptOpcode | ScriptChunk | Script; /** * A nexa transaction script. Each transaction's inputs and outputs * has a script that is evaluated to validate it's spending. * * @see {@link https://spec.nexa.org/script/1script/} */ export default class Script implements IScript { chunks: ScriptChunk[]; constructor(from?: Uint8Array | string | IScript) { this.chunks = []; if (!from) { return; } if (BufferUtils.isBuffer(from)) { return Script.fromBuffer(from); } else if (from instanceof Script) { return Script.fromBuffer(from.toBuffer()); } else if (typeof from === 'string') { return Script.fromString(from); } else if (Script._isScriptObject(from)) { this.set(from); } } private static _isScriptObject(obj: unknown): obj is IScript { return typeof obj === 'object' && obj !== null && 'chunks' in obj && Array.isArray(obj.chunks); } private static _isScriptChunk(obj: unknown): obj is ScriptChunk { return typeof obj === 'object' && obj !== null && 'opcodenum' in obj; } public set(obj: IScript): this { ValidationUtils.validateArgument(Script._isScriptObject(obj), 'obj'); this.chunks = obj.chunks; return this; } /** * @returns a new empty script */ public static empty(): Script { return new Script(); } public static fromBuffer(buffer: Uint8Array): Script { let script = new Script(); script.chunks = []; let br = new BufferReader(buffer); while (!br.finished()) { try { let opcodenum = br.readUInt8(); let len, buf; if (opcodenum > 0 && opcodenum < Opcode.OP_PUSHDATA1) { len = opcodenum; script.chunks.push({ buf: br.read(len), len: len, opcodenum: opcodenum }); } else if (opcodenum === Opcode.OP_PUSHDATA1) { len = br.readUInt8(); buf = br.read(len); script.chunks.push({ buf: buf, len: len, opcodenum: opcodenum }); } else if (opcodenum === Opcode.OP_PUSHDATA2) { len = br.readUInt16LE(); buf = br.read(len); script.chunks.push({ buf: buf, len: len, opcodenum: opcodenum }); } else if (opcodenum === Opcode.OP_PUSHDATA4) { len = br.readUInt32LE(); buf = br.read(len); script.chunks.push({ buf: buf, len: len, opcodenum: opcodenum }); } else { script.chunks.push({ opcodenum: opcodenum }); } } catch (e) { throw e instanceof RangeError ? new Error(`Invalid script buffer: can't parse valid script from given buffer ${BufferUtils.bufferToHex(buffer)}`) : e; } } return script; } public toBuffer(): Uint8Array { let bw = new BufferWriter(); this.chunks.forEach(chunk => { bw.writeUInt8(chunk.opcodenum); if (chunk.buf) { if (chunk.opcodenum < Opcode.OP_PUSHDATA1) { bw.write(chunk.buf); } else if (chunk.opcodenum === Opcode.OP_PUSHDATA1) { bw.writeUInt8(chunk.len!); bw.write(chunk.buf); } else if (chunk.opcodenum === Opcode.OP_PUSHDATA2) { bw.writeUInt16LE(chunk.len!); bw.write(chunk.buf); } else if (chunk.opcodenum === Opcode.OP_PUSHDATA4) { bw.writeUInt32LE(chunk.len!); bw.write(chunk.buf); } } }); return bw.concat(); } public static fromHex(str: string): Script { return new Script(BufferUtils.hexToBuffer(str)); } public static fromString(str: string): Script { if (CommonUtils.isHexa(str)) { return this.fromHex(str); } else if (str.length === 0) { return Script.empty(); } let script = new Script(); script.chunks = []; let tokens = str.split(' '); let i = 0; while (i < tokens.length) { let token = tokens[i]; let opcode = new ScriptOpcode(token); let opcodenum = opcode.toNumber(); if (opcodenum == null) { opcodenum = parseInt(token); if (opcodenum > 0 && opcodenum < Opcode.OP_PUSHDATA1) { script.chunks.push({ buf: BufferUtils.hexToBuffer(tokens[i + 1].slice(2)), len: opcodenum, opcodenum: opcodenum }); i = i + 2; } else { throw new Error('Invalid script: ' + JSON.stringify(str)); } } else if (opcodenum === Opcode.OP_PUSHDATA1 || opcodenum === Opcode.OP_PUSHDATA2 || opcodenum === Opcode.OP_PUSHDATA4) { if (tokens[i + 2].slice(0, 2) !== '0x') { throw new Error('Pushdata data must start with 0x'); } script.chunks.push({ buf: BufferUtils.hexToBuffer(tokens[i + 2].slice(2)), len: parseInt(tokens[i + 1]), opcodenum: opcodenum }); i = i + 3; } else { script.chunks.push({ opcodenum: opcodenum }); i = i + 1; } } return script; } public static fromASM(str: string): Script { let script = new Script(); script.chunks = []; let tokens = str.split(' '); let i = 0; while (i < tokens.length) { let token = tokens[i]; let opcode = new ScriptOpcode(token); let opcodenum = opcode.toNumber(); if (opcodenum == null) { let buf = BufferUtils.hexToBuffer(tokens[i]); let len = buf.length; if (len >= 0 && len < Opcode.OP_PUSHDATA1) { opcodenum = len; } else if (len < Math.pow(2, 8)) { opcodenum = Opcode.OP_PUSHDATA1; } else if (len < Math.pow(2, 16)) { opcodenum = Opcode.OP_PUSHDATA2; } else if (len < Math.pow(2, 32)) { opcodenum = Opcode.OP_PUSHDATA4; } script.chunks.push({ buf: buf, len: buf.length, opcodenum: opcodenum }); i = i + 1; } else { script.chunks.push({ opcodenum: opcodenum }); i = i + 1; } } return script; } private static _chunkToString(chunk: ScriptChunk, toASM = false): string { let opcodenum = chunk.opcodenum; let str = ''; if (!chunk.buf) { // no data chunk if (typeof Opcode[opcodenum] !== 'undefined') { if (toASM) { // A few cases where the opcode name differs from reverseMap // aside from 1 to 16 data pushes. if (opcodenum === 0) { // OP_0 -> 0 str = str + ' 0'; } else if(opcodenum === 79) { // OP_1NEGATE -> 1 str = str + ' -1'; } else { str = str + ' ' + new ScriptOpcode(opcodenum).toString(); } } else { str = str + ' ' + new ScriptOpcode(opcodenum).toString(); } } else { let numstr = opcodenum.toString(16); if (numstr.length % 2 !== 0) { numstr = '0' + numstr; } if (toASM) { str = str + ' ' + numstr; } else { str = str + ' ' + '0x' + numstr; } } } else { // data chunk if (!toASM && (opcodenum === Opcode.OP_PUSHDATA1 || opcodenum === Opcode.OP_PUSHDATA2 || opcodenum === Opcode.OP_PUSHDATA4)) { str = str + ' ' + new ScriptOpcode(opcodenum).toString(); } if (chunk.len! > 0) { if (toASM) { str = str + ' ' + BufferUtils.bufferToHex(chunk.buf); } else { str = str + ' ' + chunk.len + ' ' + '0x' + BufferUtils.bufferToHex(chunk.buf); } } } return str; } public toASM(): string { return this.chunks.map(chunk => Script._chunkToString(chunk, true)).join('').substring(1); } public toString(): string { return this.chunks.map(chunk => Script._chunkToString(chunk)).join('').substring(1); } public toHex(): string { return BufferUtils.bufferToHex(this.toBuffer()); } public inspect(): string { return `<Script: ${this}>`; } /** * Adds a script element to the end of the script. * * @param param a script element to add * @returns this script instance * */ public add(param: ScriptElement): this { this._addByType(param, false); return this; } public append = this.add; /** * Adds a script element at the start of the script. * @param param a script element to add * @returns this script instance */ public prepend(param: ScriptElement): this { this._addByType(param, true); return this; } private _addByType(obj: ScriptElement, prepend: boolean): void { if (typeof obj === 'string') { this._addOpcode(obj, prepend); } else if (typeof obj === 'number') { this._addOpcode(obj, prepend); } else if (obj instanceof ScriptOpcode) { this._addOpcode(obj, prepend); } else if (typeof obj === 'bigint') { if (obj <= 16n) { this._addOpcode(ScriptOpcode.smallInt(Number(obj)), prepend); } else if (obj === 0x81n) { this._addOpcode(Opcode.OP_1NEGATE, prepend); } else { this._addBuffer(new BN(obj.toString()).toScriptNumBuffer(), prepend); } } else if (typeof obj === 'boolean') { this._addOpcode(obj ? Opcode.OP_TRUE : Opcode.OP_FALSE, prepend); } else if (BufferUtils.isBuffer(obj)) { this._addBuffer(obj, prepend); } else if (obj instanceof Script) { this.chunks = this.chunks.concat(obj.chunks); } else if (Script._isScriptChunk(obj)) { this._insertAtPosition(obj, prepend); } else { throw new Error('Invalid script chunk'); } } private _insertAtPosition(chunk: ScriptChunk, prepend: boolean): void { if (prepend) { this.chunks.unshift(chunk); } else { this.chunks.push(chunk); } } private _addOpcode(opcode: string | number | ScriptOpcode, prepend: boolean): this { let op: number; if (typeof opcode === 'number') { op = opcode; } else if (opcode instanceof ScriptOpcode) { op = opcode.toNumber(); } else { op = new ScriptOpcode(opcode).toNumber(); } this._insertAtPosition({ opcodenum: op }, prepend); return this; } private _addBuffer(buf: Uint8Array, prepend: boolean): this { let opcodenum; let len = buf.length; if (len >= 0 && len < Opcode.OP_PUSHDATA1) { opcodenum = len; } else if (len < Math.pow(2, 8)) { opcodenum = Opcode.OP_PUSHDATA1; } else if (len < Math.pow(2, 16)) { opcodenum = Opcode.OP_PUSHDATA2; } else if (len < Math.pow(2, 32)) { opcodenum = Opcode.OP_PUSHDATA4; } else { throw new Error("You can't push that much data"); } this._insertAtPosition({ buf: buf, len: len, opcodenum: opcodenum }, prepend); return this; } /** * Compares a script with another script */ public equals(script: Script): boolean { ValidationUtils.validateState(script instanceof Script, 'Must provide another script'); if (this.chunks.length !== script.chunks.length) { return false; } for (let i = 0; i < this.chunks.length; i++) { if (BufferUtils.isBuffer(this.chunks[i].buf) && !BufferUtils.isBuffer(script.chunks[i].buf)) { return false; } if (BufferUtils.isBuffer(this.chunks[i].buf) && !BufferUtils.equals(this.chunks[i].buf!, script.chunks[i].buf!)) { return false; } else if (this.chunks[i].opcodenum !== script.chunks[i].opcodenum) { return false; } } return true; } /** * Analogous to nexad's FindAndDelete. Find and delete equivalent chunks, * typically used with push data chunks. Note that this will find and delete * not just the same data, but the same data with the same push data op as * produced by default. i.e., if a pushdata in a tx does not use the minimal * pushdata op, then when you try to remove the data it is pushing, it will not * be removed, because they do not use the same pushdata op. */ public findAndDelete(script: Script): this { let buf = script.toBuffer(); let hex = BufferUtils.bufferToHex(buf); for (let i = 0; i < this.chunks.length; i++) { let script2 = new Script({ chunks: [this.chunks[i]] }); let buf2 = script2.toBuffer(); let hex2 = BufferUtils.bufferToHex(buf2); if (hex === hex2) { this.chunks.splice(i, 1); } } return this } /** * Replace the chunk at the given index with the chunks of `replacement`. * * @param index Zero-based index of the chunk to replace. * @param replacement Script whose chunks will be spliced in at `index`. * * @returns `true` if a replacement was made, otherwise `false`. */ public replaceChunk(index: number, replacement: Script): boolean { if (index < 0 || index >= this.chunks.length) { return false; } this.chunks.splice(index, 1, ...replacement.chunks); return true; } /** * Comes from nexad's script interpreter CheckMinimalPush function * @returns true if the chunk {i} is the smallest way to push that particular data. */ public checkMinimalPush(i: number): boolean { let chunk = this.chunks[i]; let buf = chunk.buf; let opcodenum = chunk.opcodenum; if (!buf) { return true; } if (buf.length === 0) { // Could have used OP_0. return opcodenum === Opcode.OP_0; } else if (buf.length === 1 && buf[0] >= 1 && buf[0] <= 16) { // Could have used OP_1 .. OP_16. // return opcodenum === Opcode.OP_1 + (buf[0] - 1); return false; } else if (buf.length === 1 && buf[0] === 0x81) { // Could have used OP_1NEGATE return false; } else if (buf.length <= 75) { // Could have used a direct push (opcode indicating number of bytes pushed + those bytes). return opcodenum === buf.length; } else if (buf.length <= 255) { // Could have used OP_PUSHDATA. return opcodenum === Opcode.OP_PUSHDATA1; } else if (buf.length <= 65535) { // Could have used OP_PUSHDATA2. return opcodenum === Opcode.OP_PUSHDATA2; } return true; } /** * Comes from bitcoind's script GetSigOpCount(boolean) function * @param accurate default true * @returns number of signature operations required by this script */ public getSignatureOperationsCount(accurate = true): number { let n = 0; let lastOpcode = Opcode.OP_INVALIDOPCODE; this.chunks.forEach(chunk => { let opcode = chunk.opcodenum; if (opcode == Opcode.OP_CHECKSIG || opcode == Opcode.OP_CHECKSIGVERIFY) { n++; } else if (opcode == Opcode.OP_CHECKMULTISIG || opcode == Opcode.OP_CHECKMULTISIGVERIFY) { if (accurate && lastOpcode >= Opcode.OP_1 && lastOpcode <= Opcode.OP_16) { n += ScriptOpcode.decodeOP_N(lastOpcode); } else { n += 20; } } lastOpcode = opcode; }); return n; } /** * @returns true if the script is only composed of data pushing * opcodes or small int opcodes (OP_0, OP_1, ..., OP_16) * * @remarks In the case of an OP_RETURN, it will return true if previous chunks are push only. * Anything beyond the op return is not executed. */ public isPushOnly(): boolean { for (let chunk of this.chunks) { if (chunk.opcodenum === Opcode.OP_RETURN) { return true; } if (chunk.opcodenum > Opcode.OP_16) { return false; } } return true; } /** * Find the index of the first placeholder chunk. * * A placeholder is defined as: * - pushdata present (buf exists) * - length between 64 and 68 bytes * - first 64 bytes are all zero * - The optional trailing 0–4 bytes (positions 65–68) may be any value, ususally the sighash type bytes. * * @returns The zero-based index of the first placeholder, or -1 if not found. */ public findPlaceholder(): number { return this.chunks.findIndex((ch) => this.isPlaceholderChunk(ch)); } private isPlaceholderChunk(chunk: ScriptChunk): boolean { if (!BufferUtils.isBuffer(chunk.buf) || chunk.buf.length < 64 || chunk.buf.length > 68) { return false; } return BufferUtils.equals(chunk.buf.subarray(0, 64), new Uint8Array(64)); } /** * @returns `true` if the script is empty (has no chunks). */ public isEmpty(): boolean { return this.chunks.length === 0; } /** * @returns true if this is a pay to script template output script * @remarks for well-known-1 template use {@link isPublicKeyTemplateOut} */ public isScriptTemplateOut(): boolean { if (this.chunks.length < 3) { return false; } let hasGroup = this.chunks[0].opcodenum !== Opcode.OP_0; let minLength = hasGroup ? 4 : 3; let templateIndex = hasGroup ? 2 : 1; let constraintIndex = hasGroup ? 3 : 2; let isTemplate = this.chunks.length >= minLength && BufferUtils.isHashBuffer(this.chunks[templateIndex].buf) && (BufferUtils.isHashBuffer(this.chunks[constraintIndex].buf) || this.chunks[constraintIndex].opcodenum === Opcode.OP_FALSE); if (hasGroup) { isTemplate &&= !!( this.chunks[0].buf && this.chunks[0].buf.length >= 32 && // group id this.chunks[1].buf && this.chunks[1].buf.length >= 2 && this.chunks[1].buf.length <= 8 // group amount ); } if (isTemplate && this.chunks.length > minLength) { let visibleArgs = new Script({chunks: this.chunks.slice(minLength)}); isTemplate = visibleArgs.isPushOnly(); } return isTemplate; } /** * Checks if this script is a valid pay to script template input script * * @returns true if this is a pay to script template form input script * @remarks for well-known-1 template use {@link isPublicKeyTemplateIn} */ public isScriptTemplateIn(): boolean { // we do not know the length or content of satisfier / template / costraint scripts, // only that must be push-only and template must exist return this.chunks.length > 1 && BufferUtils.isBuffer(this.chunks[0].buf) && this.isPushOnly() } /** * @returns true if this is a pay to pubkey template output script (well-known-1, p2pkt) */ public isPublicKeyTemplateOut(): boolean { if (this.chunks.length < 3) { return false; } let hasGroup = this.chunks[0].opcodenum !== Opcode.OP_0; let minLength = hasGroup ? 4 : 3; let templateIndex = hasGroup ? 2 : 1; let constraintIndex = hasGroup ? 3 : 2; let isTemplate = this.chunks.length === minLength && this.chunks[templateIndex].opcodenum === Opcode.OP_1 && (BufferUtils.isHashBuffer(this.chunks[constraintIndex].buf)); if (hasGroup) { isTemplate &&= !!( this.chunks[0].buf && this.chunks[0].buf.length >= 32 && // group id this.chunks[1].buf && this.chunks[1].buf.length >= 2 && this.chunks[1].buf.length <= 8 // group amount ); } return isTemplate; } /** * @returns true if this is a pay to public key template input script */ public isPublicKeyTemplateIn(): boolean { if (this.chunks.length != 2) { return false; } let pubkeyPushBuf = this.chunks[0].buf; let signatureBuf = this.chunks[1].buf; if (signatureBuf && signatureBuf.length >= 64 && signatureBuf.length <= 68 && pubkeyPushBuf?.length === 34) { let pubkeyBuf = Script.fromBuffer(pubkeyPushBuf).chunks[0].buf; return pubkeyBuf?.length === 33 && (pubkeyBuf[0] === 0x03 || pubkeyBuf[0] === 0x02); } return false; } /** * @returns true if this is a pay to pubkey hash output script */ public isPublicKeyHashOut(): boolean { return !!(this.chunks.length === 5 && this.chunks[0].opcodenum === Opcode.OP_DUP && this.chunks[1].opcodenum === Opcode.OP_HASH160 && this.chunks[2].buf && this.chunks[2].buf.length === 20 && this.chunks[3].opcodenum === Opcode.OP_EQUALVERIFY && this.chunks[4].opcodenum === Opcode.OP_CHECKSIG); } /** * @returns {boolean} if this is a pay to public key hash input script */ public isPublicKeyHashIn(): boolean { if (this.chunks.length != 2) { return false; } let signatureBuf = this.chunks[0].buf; let pubkeyBuf = this.chunks[1].buf; if (signatureBuf && signatureBuf.length && pubkeyBuf && pubkeyBuf.length) { let version = pubkeyBuf[0]; if ((version === 0x04 || version === 0x06 || version === 0x07) && pubkeyBuf.length === 65) { return true; } else if ((version === 0x03 || version === 0x02) && pubkeyBuf.length === 33) { return true; } } return false; } /** * @returns true if this is a valid standard OP_RETURN output */ public isDataOut(): boolean { let step1 = this.chunks.length >= 1 && this.chunks[0].opcodenum === Opcode.OP_RETURN && this.toBuffer().length <= 223; // 223 instead of 220 because (+1 for OP_RETURN, +2 for the pushdata opcodes) if (!step1) return false; let chunks = this.chunks.slice(1); let script2 = new Script({chunks: chunks}); return script2.isPushOnly(); } /** * @returns true if this is a valid Token Description OP_RETURN output */ public isTokenDescriptionOut(): boolean { let step1 = this.chunks.length >= 2 && this.chunks.length < 8 && this.chunks[0].opcodenum === Opcode.OP_RETURN && this.chunks[1].len === 4; return step1 && new Script({chunks: this.chunks.slice(1)}).isPushOnly(); } /** * Will retrieve the Public Key buffer from p2pkt/p2pkh input scriptSig */ public getPublicKey(): Uint8Array { ValidationUtils.validateState(this.isPublicKeyHashIn() || this.isPublicKeyTemplateIn(), "Can't retrieve PublicKey from a non-PKT or non-PKH input"); return this.isPublicKeyHashIn() ? this.chunks[1].buf! : Script.fromBuffer(this.chunks[0].buf!).chunks[0].buf!; } /** * Will retrieve the Public Key Hash buffer from p2pkh output scriptPubKey */ public getPublicKeyHash(): Uint8Array { ValidationUtils.validateState(this.isPublicKeyHashOut(), "Can't retrieve PublicKeyHash from a non-PKH output"); return this.chunks[2].buf!; } /** * Will retrieve the Template Hash from p2pkt/p2st output scriptPubKey * * @returns OP_1 if its p2pkt, otherwise the template hash buffer */ public getTemplateHash(): Uint8Array | Opcode.OP_1 { ValidationUtils.validateState(this.isPublicKeyTemplateOut() || this.isScriptTemplateOut(), "Can't retrieve TemplateHash from a non-PST output"); if (this.isPublicKeyTemplateOut()) { return Opcode.OP_1; } let hasGroup = this.chunks[0].opcodenum !== Opcode.OP_0; return hasGroup ? this.chunks[2].buf! : this.chunks[1].buf!; } /** * Will retrieve the Constraint Hash from p2pkt/p2st output scriptPubKey * * @returns The constraint hash buffer, or OP_FALSE if not included */ public getConstraintHash(): Uint8Array | Opcode.OP_FALSE { ValidationUtils.validateState(this.isPublicKeyTemplateOut() || this.isScriptTemplateOut(), "Can't retrieve ConstraintHash from a non-PST output"); let hasGroup = this.chunks[0].opcodenum !== Opcode.OP_0; let constraintIndex = hasGroup ? 3 : 2; return this.chunks[constraintIndex].opcodenum === Opcode.OP_FALSE ? Opcode.OP_FALSE : this.chunks[constraintIndex].buf!; } /** * Will retrieve the visible Args from p2st output scriptPubKey * * @returns A Script object containing the visible args */ public getVisibleArgs(): Script { ValidationUtils.validateState(this.isScriptTemplateOut(), "Can't retrieve Visible Args from a non-PST output"); return this.chunks[0].opcodenum !== Opcode.OP_0 ? new Script({chunks: this.chunks.slice(4)}) : new Script({chunks: this.chunks.slice(3)}); } /** * Will retrieve the Group Identifier number from Token Description OP_RETURN output * * @remarks This method doesn't check if the group id number is fit to NRC1/NRC2 etc. */ public getGroupIdType(): number { ValidationUtils.validateState(this.isTokenDescriptionOut(), "Can't retrieve GroupIdType from a non Token Description output"); return BN.fromScriptNumBuffer(this.chunks[1].buf!).toNumber(); } /** * Will retrieve the Group Data from p2pkt/p2st output scriptPubKey * * @returns An object with groupId and groupAmount buffers, or undefined if no group data is present */ public getGroupData(): { groupId: Uint8Array, groupAmount: Uint8Array } | undefined { ValidationUtils.validateState(this.isPublicKeyTemplateOut() || this.isScriptTemplateOut(), "Can't retrieve GroupData from a non-PST output"); if (this.chunks[0].opcodenum !== Opcode.OP_0) { return { groupId: this.chunks[0].buf!, groupAmount: this.chunks[1].buf! }; } return undefined; } }