UNPKG

bch-slpjs

Version:

Simple Ledger Protocol (SLP) JavaScript Library

764 lines (671 loc) 36.3 kB
import BITBOX from 'bitbox-sdk/lib/bitbox-sdk'; import * as bchaddr from 'bchaddrjs-slp'; import BigNumber from 'bignumber.js'; import { SlpAddressUtxoResult, SlpTransactionDetails, SlpTransactionType, SlpUtxoJudgement, SlpBalancesResult, utxo } from './slpjs'; import { SlpTokenType1 } from './slptokentype1'; import { Utils } from './utils'; export interface PushDataOperation { opcode: number, data: Buffer|null } export interface configBuildGenesisOpReturn { ticker: string|null; name: string|null; documentUri: string|null; hash: Buffer|null, decimals: number; batonVout: number|null; // normally this is null (for fixed supply) or 2+ for flexible initialQuantity: BigNumber } export interface configBuildMintOpReturn { tokenIdHex: string; batonVout: number|null; // normally this is null (for fixed supply) or 2+ for flexible mintQuantity: BigNumber; } export interface configBuildSendOpReturn { tokenIdHex: string; outputQtyArray: BigNumber[] } export interface configBuildRawGenesisTx { slpGenesisOpReturn: Buffer; mintReceiverAddress: string; mintReceiverSatoshis?: BigNumber; batonReceiverAddress: string; batonReceiverSatoshis?: BigNumber; bchChangeReceiverAddress: string; input_utxos: utxo[]; } export interface configBuildRawSendTx { slpSendOpReturn: Buffer; input_token_utxos: utxo[];//AddressUtxoResultExtended[]; tokenReceiverAddressArray: string[]; bchChangeReceiverAddress: string; } export interface configBuildRawMintTx { slpMintOpReturn: Buffer; mintReceiverAddress: string; mintReceiverSatoshis?: BigNumber; batonReceiverAddress: string|null; batonReceiverSatoshis?: BigNumber; bchChangeReceiverAddress: string; input_baton_utxos: utxo[]; } export interface SlpValidator { isValidSlpTxid(txid: string): Promise<boolean>; getRawTransactions: (txid: string[]) => Promise<string[]>; validateSlpTransactions(txids: string[]): Promise<string[]>; } export interface SlpProxyValidator extends SlpValidator { validatorUrl: string; } export class Slp { BITBOX: BITBOX; networkstring: string; constructor(BITBOX) { this.BITBOX = BITBOX; } get lokadIdHex() { return "534c5000" } buildGenesisOpReturn(config: configBuildGenesisOpReturn, type = 0x01) { let hash; try { hash = config.hash.toString('hex') } catch (_) { hash = null } return SlpTokenType1.buildGenesisOpReturn( config.ticker, config.name, config.documentUri, hash, config.decimals, config.batonVout, config.initialQuantity ) } buildMintOpReturn(config: configBuildMintOpReturn, type = 0x01) { return SlpTokenType1.buildMintOpReturn( config.tokenIdHex, config.batonVout, config.mintQuantity ) } buildSendOpReturn(config: configBuildSendOpReturn, type = 0x01) { return SlpTokenType1.buildSendOpReturn( config.tokenIdHex, config.outputQtyArray ) } buildRawGenesisTx(config: configBuildRawGenesisTx, type = 0x01) { if(config.mintReceiverSatoshis === undefined) config.mintReceiverSatoshis = new BigNumber(546); if(config.batonReceiverSatoshis === undefined) config.batonReceiverSatoshis = new BigNumber(546); // Make sure we're not spending any token or baton UTXOs config.input_utxos.forEach(txo => { if(txo.slpUtxoJudgement === SlpUtxoJudgement.NOT_SLP) return if(txo.slpUtxoJudgement === SlpUtxoJudgement.SLP_TOKEN) { throw Error("Input UTXOs included a token for another tokenId.") } if(txo.slpUtxoJudgement === SlpUtxoJudgement.SLP_BATON) throw Error("Cannot spend a minting baton.") if(txo.slpUtxoJudgement === SlpUtxoJudgement.INVALID_TOKEN_DAG || txo.slpUtxoJudgement === SlpUtxoJudgement.INVALID_BATON_DAG) throw Error("Cannot currently spend tokens and baton with invalid DAGs.") throw Error("Cannot spend utxo with no SLP judgement.") }) // Check for slp formatted addresses if (!bchaddr.isSlpAddress(config.mintReceiverAddress)) throw new Error("Not an SLP address."); if (config.batonReceiverAddress != null && !bchaddr.isSlpAddress(config.batonReceiverAddress)) throw new Error("Not an SLP address."); config.mintReceiverAddress = bchaddr.toCashAddress(config.mintReceiverAddress); let transactionBuilder = new this.BITBOX.TransactionBuilder(Utils.txnBuilderString(config.mintReceiverAddress)); let satoshis = new BigNumber(0); config.input_utxos.forEach(token_utxo => { transactionBuilder.addInput(token_utxo.txid, token_utxo.vout); satoshis = satoshis.plus(token_utxo.satoshis); }); let genesisCost = this.calculateGenesisCost(config.slpGenesisOpReturn.length, config.input_utxos.length, config.batonReceiverAddress, config.bchChangeReceiverAddress); let bchChangeAfterFeeSatoshis: BigNumber = satoshis.minus(genesisCost); // Genesis OpReturn transactionBuilder.addOutput(config.slpGenesisOpReturn, 0); // Genesis token mint transactionBuilder.addOutput(config.mintReceiverAddress, config.mintReceiverSatoshis.toNumber()); //bchChangeAfterFeeSatoshis -= config.mintReceiverSatoshis; // Baton address (optional) if (config.batonReceiverAddress != null) { config.batonReceiverAddress = bchaddr.toCashAddress(config.batonReceiverAddress); if(this.parseSlpOutputScript(config.slpGenesisOpReturn).batonVout !== 2) throw Error("batonVout in transaction does not match OP_RETURN data.") transactionBuilder.addOutput(config.batonReceiverAddress, config.batonReceiverSatoshis.toNumber()); //bchChangeAfterFeeSatoshis -= config.batonReceiverSatoshis; } // Change (optional) if (config.bchChangeReceiverAddress != null && bchChangeAfterFeeSatoshis.isGreaterThan(new BigNumber(546))) { config.bchChangeReceiverAddress = bchaddr.toCashAddress(config.bchChangeReceiverAddress); transactionBuilder.addOutput(config.bchChangeReceiverAddress, bchChangeAfterFeeSatoshis.toNumber()); } // sign inputs let i = 0; for (const txo of config.input_utxos) { let paymentKeyPair = this.BITBOX.ECPair.fromWIF(txo.wif); transactionBuilder.sign(i, paymentKeyPair, null, transactionBuilder.hashTypes.SIGHASH_ALL, txo.satoshis.toNumber()); i++; } let tx = transactionBuilder.build().toHex(); // Check For Low Fee let outValue: number = transactionBuilder.transaction.tx.outs.reduce((v,o)=>v+=o.value, 0); let inValue: BigNumber = config.input_utxos.reduce((v,i)=>v=v.plus(i.satoshis), new BigNumber(0)) if(inValue.minus(outValue).isLessThanOrEqualTo(tx.length/2)) throw Error("Transaction fee is not high enough.") // TODO: Check for fee too large or send leftover to target address return tx; } buildRawSendTx(config: configBuildRawSendTx, type = 0x01) { const sendMsg = this.parseSlpOutputScript(config.slpSendOpReturn); config.tokenReceiverAddressArray.forEach(outputAddress => { if (!bchaddr.isSlpAddress(outputAddress)) throw new Error("Token receiver address not in SLP format."); }); // Make sure not spending any other tokens or baton UTXOs let tokenInputQty = new BigNumber(0); config.input_token_utxos.forEach(txo => { if(txo.slpUtxoJudgement === SlpUtxoJudgement.NOT_SLP) return if(txo.slpUtxoJudgement === SlpUtxoJudgement.SLP_TOKEN) { if(txo.slpTransactionDetails.tokenIdHex !== sendMsg.tokenIdHex) throw Error("Input UTXOs included a token for another tokenId.") tokenInputQty = tokenInputQty.plus(txo.slpUtxoJudgementAmount); return } if(txo.slpUtxoJudgement === SlpUtxoJudgement.SLP_BATON) throw Error("Cannot spend a minting baton.") if(txo.slpUtxoJudgement === SlpUtxoJudgement.INVALID_TOKEN_DAG || txo.slpUtxoJudgement === SlpUtxoJudgement.INVALID_BATON_DAG) throw Error("Cannot currently spend UTXOs with invalid DAGs.") throw Error("Cannot spend utxo with no SLP judgement.") }) // Make sure the number of output receivers matches the outputs in the OP_RETURN message. let chgAddr = config.bchChangeReceiverAddress ? 1 : 0; if(config.tokenReceiverAddressArray.length + chgAddr !== sendMsg.sendOutputs.length) throw Error("Number of token receivers in config does not match the OP_RETURN outputs") // Make sure token inputs equals token outputs in OP_RETURN let outputTokenQty = sendMsg.sendOutputs.reduce((v,o)=>v=v.plus(o), new BigNumber(0)); if(!tokenInputQty.isEqualTo(outputTokenQty)) throw Error("Token input quantity does not match token outputs.") let transactionBuilder = new this.BITBOX.TransactionBuilder(Utils.txnBuilderString(config.tokenReceiverAddressArray[0])); let inputSatoshis = new BigNumber(0); config.input_token_utxos.forEach(token_utxo => { transactionBuilder.addInput(token_utxo.txid, token_utxo.vout); inputSatoshis = inputSatoshis.plus(token_utxo.satoshis); }); let sendCost = this.calculateSendCost(config.slpSendOpReturn.length, config.input_token_utxos.length, config.tokenReceiverAddressArray.length, config.bchChangeReceiverAddress); let bchChangeAfterFeeSatoshis = inputSatoshis.minus(sendCost); // Genesis OpReturn transactionBuilder.addOutput(config.slpSendOpReturn, 0); // Token distribution outputs config.tokenReceiverAddressArray.forEach((outputAddress) => { outputAddress = bchaddr.toCashAddress(outputAddress); transactionBuilder.addOutput(outputAddress, 546); }) // Change if (config.bchChangeReceiverAddress != null && bchChangeAfterFeeSatoshis.isGreaterThan(new BigNumber(546))) { config.bchChangeReceiverAddress = bchaddr.toCashAddress(config.bchChangeReceiverAddress); transactionBuilder.addOutput(config.bchChangeReceiverAddress, bchChangeAfterFeeSatoshis.toNumber()); } // sign inputs let i = 0; for (const txo of config.input_token_utxos) { let paymentKeyPair = this.BITBOX.ECPair.fromWIF(txo.wif); transactionBuilder.sign(i, paymentKeyPair, null, transactionBuilder.hashTypes.SIGHASH_ALL, txo.satoshis.toNumber()); i++; } let tx = transactionBuilder.build().toHex(); // Check For Low Fee let outValue: number = transactionBuilder.transaction.tx.outs.reduce((v,o)=>v+=o.value, 0); let inValue: BigNumber = config.input_token_utxos.reduce((v,i)=>v=v.plus(i.satoshis), new BigNumber(0)) if(inValue.minus(outValue).isLessThanOrEqualTo(tx.length/2)) throw Error("Transaction fee is not high enough.") // TODO: Check for fee too large or send leftover to target address return tx; } buildRawMintTx(config: configBuildRawMintTx, type = 0x01) { let mintMsg = this.parseSlpOutputScript(config.slpMintOpReturn); if(config.mintReceiverSatoshis === undefined) config.mintReceiverSatoshis = new BigNumber(546); if(config.batonReceiverSatoshis === undefined) config.batonReceiverSatoshis = new BigNumber(546); // Check for slp formatted addresses if (!bchaddr.isSlpAddress(config.mintReceiverAddress)) { throw new Error("Mint receiver address not in SLP format."); } if (config.batonReceiverAddress != null && !bchaddr.isSlpAddress(config.batonReceiverAddress)) { throw new Error("Baton receiver address not in SLP format."); } config.mintReceiverAddress = bchaddr.toCashAddress(config.mintReceiverAddress); config.batonReceiverAddress = bchaddr.toCashAddress(config.batonReceiverAddress); // Make sure inputs don't include spending any tokens or batons for other tokenIds config.input_baton_utxos.forEach(txo => { if(txo.slpUtxoJudgement === SlpUtxoJudgement.NOT_SLP) return if(txo.slpUtxoJudgement === SlpUtxoJudgement.SLP_TOKEN) throw Error("Input UTXOs should not include any tokens.") if(txo.slpUtxoJudgement === SlpUtxoJudgement.SLP_BATON) { if(txo.slpTransactionDetails.tokenIdHex !== mintMsg.tokenIdHex) throw Error("Cannot spend a minting baton.") return } if(txo.slpUtxoJudgement === SlpUtxoJudgement.INVALID_TOKEN_DAG || txo.slpUtxoJudgement === SlpUtxoJudgement.INVALID_BATON_DAG) throw Error("Cannot currently spend UTXOs with invalid DAGs.") throw Error("Cannot spend utxo with no SLP judgement.") }) // Make sure inputs include the baton for this tokenId if(!config.input_baton_utxos.find(o => o.slpUtxoJudgement === SlpUtxoJudgement.SLP_BATON)) Error("There is no baton included with the input UTXOs.") let transactionBuilder = new this.BITBOX.TransactionBuilder(Utils.txnBuilderString(config.mintReceiverAddress)); let satoshis = new BigNumber(0); config.input_baton_utxos.forEach(baton_utxo => { transactionBuilder.addInput(baton_utxo.txid, baton_utxo.vout); satoshis = satoshis.plus(baton_utxo.satoshis); }); let mintCost = this.calculateGenesisCost(config.slpMintOpReturn.length, config.input_baton_utxos.length, config.batonReceiverAddress, config.bchChangeReceiverAddress); let bchChangeAfterFeeSatoshis = satoshis.minus(mintCost); // Mint OpReturn transactionBuilder.addOutput(config.slpMintOpReturn, 0); // Mint token mint transactionBuilder.addOutput(config.mintReceiverAddress, config.mintReceiverSatoshis.toNumber()); //bchChangeAfterFeeSatoshis -= config.mintReceiverSatoshis; // Baton address (optional) if (config.batonReceiverAddress !== null) { config.batonReceiverAddress = bchaddr.toCashAddress(config.batonReceiverAddress); if(this.parseSlpOutputScript(config.slpMintOpReturn).batonVout !== 2) throw Error("batonVout in transaction does not match OP_RETURN data.") transactionBuilder.addOutput(config.batonReceiverAddress, config.batonReceiverSatoshis.toNumber()); //bchChangeAfterFeeSatoshis -= config.batonReceiverSatoshis; } // Change (optional) if (config.bchChangeReceiverAddress !== null && bchChangeAfterFeeSatoshis.isGreaterThan(new BigNumber(546))) { config.bchChangeReceiverAddress = bchaddr.toCashAddress(config.bchChangeReceiverAddress); transactionBuilder.addOutput(config.bchChangeReceiverAddress, bchChangeAfterFeeSatoshis.toNumber()); } // sign inputs let i = 0; for (const txo of config.input_baton_utxos) { let paymentKeyPair = this.BITBOX.ECPair.fromWIF(txo.wif); transactionBuilder.sign(i, paymentKeyPair, null, transactionBuilder.hashTypes.SIGHASH_ALL, txo.satoshis.toNumber()); i++; } let tx = transactionBuilder.build().toHex(); // Check For Low Fee let outValue: number = transactionBuilder.transaction.tx.outs.reduce((v,o)=>v+=o.value, 0); let inValue: BigNumber = config.input_baton_utxos.reduce((v,i)=>v=v.plus(i.satoshis), new BigNumber(0)) if(inValue.minus(outValue).isLessThanOrEqualTo(tx.length/2)) throw Error("Transaction fee is not high enough.") // TODO: Check for fee too large or send leftover to target address return tx; } parseSlpOutputScript(outputScript: Buffer) { let slpMsg = <SlpTransactionDetails>{}; let chunks: (Buffer|null)[]; try { chunks = this.parseOpReturnToChunks(outputScript); } catch(e) { //console.log(e); throw Error('Bad OP_RETURN'); } if(chunks.length === 0) throw Error('Empty OP_RETURN'); if(!chunks[0].equals(Buffer.from(this.lokadIdHex, 'hex'))) throw Error('No SLP'); if(chunks.length === 1) throw Error("Missing token_type"); // # check if the token version is supported slpMsg.versionType = Slp.parseChunkToInt(chunks[1], 1, 2, true); // if(slpMsg.type !== SlpTypeVersion.TokenVersionType1) // throw Error('Unsupported token type:' + slpMsg.type); if(chunks.length === 2) throw Error('Missing SLP transaction type'); try { slpMsg.transactionType = SlpTransactionType[chunks[2].toString('ascii')] } catch(_){ throw Error('Bad transaction type'); } if(slpMsg.transactionType === SlpTransactionType.GENESIS) { if(chunks.length !== 10) throw Error('GENESIS with incorrect number of parameters'); slpMsg.symbol = chunks[3] ? chunks[3].toString('utf8') : ''; slpMsg.name = chunks[4] ? chunks[4].toString('utf8') : ''; slpMsg.documentUri = chunks[5] ? chunks[5].toString('utf8') : ''; slpMsg.documentSha256 = chunks[6] ? chunks[6] : null; if(slpMsg.documentSha256) { if(slpMsg.documentSha256.length !== 0 && slpMsg.documentSha256.length !== 32) throw Error('Token document hash is incorrect length'); } slpMsg.decimals = Slp.parseChunkToInt(chunks[7], 1, 1, true); if(slpMsg.decimals > 9) throw Error('Too many decimals') slpMsg.batonVout = chunks[8] ? Slp.parseChunkToInt(chunks[8], 1, 1) : null; if(slpMsg.batonVout !== null){ if (slpMsg.batonVout < 2) throw Error('Mint baton cannot be on vout=0 or 1'); slpMsg.containsBaton = true; } slpMsg.genesisOrMintQuantity = (new BigNumber(chunks[9].readUInt32BE(0).toString())).multipliedBy(2**32).plus(chunks[9].readUInt32BE(4).toString()); } else if(slpMsg.transactionType === SlpTransactionType.SEND) { if(chunks.length < 4) throw Error('SEND with too few parameters'); if(chunks[3].length !== 32) throw Error('token_id is wrong length'); slpMsg.tokenIdHex = chunks[3].toString('hex'); // # Note that we put an explicit 0 for ['token_output'][0] since it // # corresponds to vout=0, which is the OP_RETURN tx output. // # ['token_output'][1] is the first token output given by the SLP // # message, i.e., the number listed as `token_output_quantity1` in the // # spec, which goes to tx output vout=1. slpMsg.sendOutputs = []; slpMsg.sendOutputs.push(new BigNumber(0)); chunks.slice(4).forEach(chunk => { if(chunk.length !== 8) throw Error('SEND quantities must be 8-bytes each.'); slpMsg.sendOutputs.push((new BigNumber(chunk.readUInt32BE(0).toString())).multipliedBy(2**32).plus(new BigNumber(chunk.readUInt32BE(4).toString()))); }); // # maximum 19 allowed token outputs, plus 1 for the explicit [0] we inserted. if(slpMsg.sendOutputs.length < 2) throw Error('Missing output amounts'); if(slpMsg.sendOutputs.length > 20) throw Error('More than 19 output amounts'); } else if(slpMsg.transactionType === SlpTransactionType.MINT) { if(chunks.length != 6) throw Error('MINT with incorrect number of parameters'); if(chunks[3].length != 32) throw Error('token_id is wrong length'); slpMsg.tokenIdHex = chunks[3].toString('hex'); slpMsg.batonVout = chunks[4] ? Slp.parseChunkToInt(chunks[4],1,1) : null; if(slpMsg.batonVout !== null){ if(slpMsg.batonVout < 2) throw Error('Mint baton cannot be on vout=0 or 1'); slpMsg.containsBaton = true; } slpMsg.genesisOrMintQuantity = (new BigNumber(chunks[5].readUInt32BE(0).toString())).multipliedBy(2**32).plus((new BigNumber(chunks[5].readUInt32BE(4).toString()))); } else throw Error('Bad transaction type'); return slpMsg; } static parseChunkToInt(intBytes: Buffer, minByteLen: number, maxByteLen: number, raise_on_Null = false) { // # Parse data as unsigned-big-endian encoded integer. // # For empty data different possibilities may occur: // # minByteLen <= 0 : return 0 // # raise_on_Null == False and minByteLen > 0: return None // # raise_on_Null == True and minByteLen > 0: raise SlpInvalidOutputMessage if(intBytes.length >= minByteLen && intBytes.length <= maxByteLen) return intBytes.readUIntBE(0, intBytes.length) if(intBytes.length === 0 && !raise_on_Null) return null; throw Error('Field has wrong length'); } // get list of data chunks resulting from data push operations parseOpReturnToChunks(script: Buffer, allow_op_0=false, allow_op_number=false) { // """Extract pushed bytes after opreturn. Returns list of bytes() objects, // one per push. let ops: PushDataOperation[]; // Strict refusal of non-push opcodes; bad scripts throw OpreturnError.""" try { ops = this.getScriptOperations(script); } catch(e) { //console.log(e); throw Error('Script error'); } if(ops[0].opcode != this.BITBOX.Script.opcodes.OP_RETURN) throw Error('No OP_RETURN'); let chunks: (Buffer|null)[] = []; ops.slice(1).forEach(opitem => { if(opitem.opcode > this.BITBOX.Script.opcodes.OP_16) throw Error("Non-push opcode"); if(opitem.opcode > this.BITBOX.Script.opcodes.OP_PUSHDATA4) { if(opitem.opcode === 80) throw Error('Non-push opcode'); if(!allow_op_number) throw Error('OP_1NEGATE to OP_16 not allowed'); if(opitem.opcode === this.BITBOX.Script.opcodes.OP_1NEGATE) opitem.data = Buffer.from([0x81]); else // OP_1 - OP_16 opitem.data = Buffer.from([opitem.opcode - 80]); } if(opitem.opcode === this.BITBOX.Script.opcodes.OP_0 && !allow_op_0){ throw Error('OP_0 not allowed'); } chunks.push(opitem.data) }); //console.log(chunks); return chunks } // Get a list of operations with accompanying push data (if a push opcode) getScriptOperations(script: Buffer) { let ops: PushDataOperation[] = []; try { let n = 0; let dlen: number; while (n < script.length) { let op: PushDataOperation = { opcode: script[n], data: null } n += 1; if(op.opcode <= this.BITBOX.Script.opcodes.OP_PUSHDATA4) { if(op.opcode < this.BITBOX.Script.opcodes.OP_PUSHDATA1) dlen = op.opcode; else if(op.opcode === this.BITBOX.Script.opcodes.OP_PUSHDATA1) { dlen = script[n]; n += 1; } else if(op.opcode === this.BITBOX.Script.opcodes.OP_PUSHDATA2) { dlen = script.slice(n, n + 2).readUIntLE(0,2); n += 2; } else { dlen = script.slice(n, n + 4).readUIntLE(0,4); n += 4; } if((n + dlen) > script.length) { throw Error('IndexError'); } if(dlen > 0) op.data = script.slice(n, n + dlen); n += dlen } ops.push(op); } } catch(e) { //console.log(e); throw Error('truncated script') } return ops; } calculateGenesisCost(genesisOpReturnLength: number, inputUtxoSize: number, batonAddress?: string, bchChangeAddress?: string, feeRate = 1) { return this.calculateMintOrGenesisCost(genesisOpReturnLength, inputUtxoSize, batonAddress, bchChangeAddress, feeRate); } calculateMintCost(mintOpReturnLength: number, inputUtxoSize: number, batonAddress?: string, bchChangeAddress?: string, feeRate = 1) { return this.calculateMintOrGenesisCost(mintOpReturnLength, inputUtxoSize, batonAddress, bchChangeAddress, feeRate); } calculateMintOrGenesisCost(mintOpReturnLength: number, inputUtxoSize: number, batonAddress?: string, bchChangeAddress?: string, feeRate: number = 1) { let outputs = 1 let nonfeeoutputs = 546 if (batonAddress !== null && batonAddress !== undefined) { nonfeeoutputs += 546 outputs += 1 } if (bchChangeAddress !== null && bchChangeAddress !== undefined) { outputs += 1 } let fee = this.BITBOX.BitcoinCash.getByteCount({ P2PKH: inputUtxoSize }, { P2PKH: outputs }) fee += mintOpReturnLength fee += 10 // added to account for OP_RETURN ammount of 0000000000000000 fee *= feeRate //console.log("MINT/GENESIS cost before outputs: " + fee.toString()); fee += nonfeeoutputs //console.log("MINT/GENESIS cost after outputs are added: " + fee.toString()); return fee } calculateSendCost(sendOpReturnLength: number, inputUtxoSize: number, outputAddressArraySize: number, bchChangeAddress?: string, feeRate = 1) { let outputs = outputAddressArraySize let nonfeeoutputs = outputAddressArraySize * 546 if (bchChangeAddress != null) { outputs += 1 } let fee = this.BITBOX.BitcoinCash.getByteCount({ P2PKH: inputUtxoSize }, { P2PKH: outputs }) fee += sendOpReturnLength fee += 10 // added to account for OP_RETURN ammount of 0000000000000000 fee *= feeRate //console.log("SEND cost before outputs: " + fee.toString()); fee += nonfeeoutputs //console.log("SEND cost after outputs are added: " + fee.toString()); return fee } static preSendSlpJudgementCheck(txo: SlpAddressUtxoResult, tokenId: string){ if (txo.slpUtxoJudgement === undefined || txo.slpUtxoJudgement === null || txo.slpUtxoJudgement === SlpUtxoJudgement.UNKNOWN) throw Error("There at least one input UTXO that does not have a proper SLP judgement") if (txo.slpUtxoJudgement === SlpUtxoJudgement.SLP_BATON) throw Error("There is at least one input UTXO that is a baton. You can only spend batons in a MINT transaction.") if (txo.slpTransactionDetails) { if(txo.slpUtxoJudgement === SlpUtxoJudgement.SLP_TOKEN) { if(!txo.slpUtxoJudgementAmount) throw Error("There is at least one input token that does not have the 'slpUtxoJudgementAmount' property set.") if(txo.slpTransactionDetails.tokenIdHex !== tokenId) throw Error("There is at least one input UTXO that is a different SLP token than the one specified.") return txo.slpTransactionDetails.tokenIdHex === tokenId; } } return false; } async processUtxosForSlpAbstract(utxos: SlpAddressUtxoResult[], asyncSlpValidator: SlpValidator) { // 1) parse SLP OP_RETURN and cast initial SLP judgement, based on OP_RETURN only. for(let txo of utxos) { this.applyInitialSlpJudgement(txo); if(txo.slpUtxoJudgement === SlpUtxoJudgement.UNKNOWN || txo.slpUtxoJudgement === undefined) throw Error('Utxo SLP judgement has not been set, unknown error.') } // 2) Cast final SLP judgement using the supplied async validator await this.applyFinalSlpJudgement(asyncSlpValidator, utxos); // 3) Prepare results object const result: SlpBalancesResult = this.computeSlpBalances(utxos); // 4) Check that all UTXOs have been categorized let tokenTxoCount = 0; for(let id in result.slpTokenUtxos) tokenTxoCount += result.slpTokenUtxos[id].length; let batonTxoCount = 0; for(let id in result.slpBatonUtxos) batonTxoCount += result.slpBatonUtxos[id].length; if(utxos.length !== (tokenTxoCount + batonTxoCount + result.nonSlpUtxos.length + result.invalidBatonUtxos.length + result.invalidTokenUtxos.length)) throw Error('Not all UTXOs have been categorized. Unknown Error.'); return result; } private computeSlpBalances(utxos: SlpAddressUtxoResult[]) { const result: SlpBalancesResult = { satoshis_available_bch: 0, satoshis_in_slp_baton: 0, satoshis_in_slp_token: 0, satoshis_in_invalid_token_dag: 0, satoshis_in_invalid_baton_dag: 0, slpTokenBalances: {}, slpTokenUtxos: {}, slpBatonUtxos: {}, nonSlpUtxos: [], invalidTokenUtxos: [], invalidBatonUtxos: [] }; // 5) Loop through UTXO set and accumulate balances for type of utxo, organize the Utxos into their categories. for (const txo of utxos) { if (txo.slpUtxoJudgement === SlpUtxoJudgement.SLP_TOKEN) { if (!(txo.slpTransactionDetails.tokenIdHex in result.slpTokenBalances)) result.slpTokenBalances[txo.slpTransactionDetails.tokenIdHex] = new BigNumber(0); if (txo.slpTransactionDetails.transactionType === SlpTransactionType.GENESIS || txo.slpTransactionDetails.transactionType === SlpTransactionType.MINT) { result.slpTokenBalances[txo.slpTransactionDetails.tokenIdHex] = result.slpTokenBalances[txo.slpTransactionDetails.tokenIdHex].plus(<BigNumber>txo.slpTransactionDetails.genesisOrMintQuantity); } else if (txo.slpTransactionDetails.transactionType === SlpTransactionType.SEND && txo.slpTransactionDetails.sendOutputs) { let qty = txo.slpTransactionDetails.sendOutputs[txo.vout]; result.slpTokenBalances[txo.slpTransactionDetails.tokenIdHex] = result.slpTokenBalances[txo.slpTransactionDetails.tokenIdHex].plus(qty); } else { throw Error('Unknown Error: cannot have an SLP_TOKEN that is not from GENESIS, MINT, or SEND.'); } result.satoshis_in_slp_token += txo.satoshis; if(!(txo.slpTransactionDetails.tokenIdHex in result.slpTokenUtxos)) result.slpTokenUtxos[txo.slpTransactionDetails.tokenIdHex] = []; result.slpTokenUtxos[txo.slpTransactionDetails.tokenIdHex].push(txo); } else if (txo.slpUtxoJudgement === SlpUtxoJudgement.SLP_BATON) { result.satoshis_in_slp_baton += txo.satoshis; if(!(txo.slpTransactionDetails.tokenIdHex in result.slpBatonUtxos)) result.slpBatonUtxos[txo.slpTransactionDetails.tokenIdHex] = []; result.slpBatonUtxos[txo.slpTransactionDetails.tokenIdHex].push(txo); } else if (txo.slpUtxoJudgement === SlpUtxoJudgement.INVALID_TOKEN_DAG) { result.satoshis_in_invalid_token_dag += txo.satoshis; result.invalidTokenUtxos.push(txo); } else if (txo.slpUtxoJudgement === SlpUtxoJudgement.INVALID_BATON_DAG) { result.satoshis_in_invalid_baton_dag += txo.satoshis; result.invalidBatonUtxos.push(txo); } else { result.satoshis_available_bch += txo.satoshis; result.nonSlpUtxos.push(txo); } } return result; } private applyInitialSlpJudgement(txo: SlpAddressUtxoResult) { try { let vout = txo.tx.vout.find(vout => vout.n === 0); if (!vout) throw 'Utxo contains no Vout!'; let vout0script = Buffer.from(vout.scriptPubKey.hex, 'hex'); txo.slpTransactionDetails = this.parseSlpOutputScript(vout0script); // populate txid for GENESIS if (txo.slpTransactionDetails.transactionType === SlpTransactionType.GENESIS) txo.slpTransactionDetails.tokenIdHex = txo.txid; // apply initial SLP judgement to the UTXO (based on OP_RETURN parsing ONLY! Still need to validate the DAG for possible tokens and batons!) if (txo.slpTransactionDetails.transactionType === SlpTransactionType.GENESIS || txo.slpTransactionDetails.transactionType === SlpTransactionType.MINT) { if (txo.slpTransactionDetails.containsBaton && txo.slpTransactionDetails.batonVout === txo.vout) { txo.slpUtxoJudgement = SlpUtxoJudgement.SLP_BATON; } else if (txo.vout === 1 && txo.slpTransactionDetails.genesisOrMintQuantity.isGreaterThan(0)) { txo.slpUtxoJudgement = SlpUtxoJudgement.SLP_TOKEN; txo.slpUtxoJudgementAmount = txo.slpTransactionDetails.genesisOrMintQuantity; } else txo.slpUtxoJudgement = SlpUtxoJudgement.NOT_SLP; } else if (txo.slpTransactionDetails.transactionType === SlpTransactionType.SEND && txo.slpTransactionDetails.sendOutputs) { if (txo.vout > 0 && txo.vout < txo.slpTransactionDetails.sendOutputs.length) { txo.slpUtxoJudgement = SlpUtxoJudgement.SLP_TOKEN; txo.slpUtxoJudgementAmount = txo.slpTransactionDetails.sendOutputs[txo.vout]; } else txo.slpUtxoJudgement = SlpUtxoJudgement.NOT_SLP; } else { txo.slpUtxoJudgement = SlpUtxoJudgement.NOT_SLP; } } catch (e) { // any errors in parsing SLP OP_RETURN means the TXN is NOT SLP. txo.slpUtxoJudgement = SlpUtxoJudgement.NOT_SLP; } } private async applyFinalSlpJudgement(asyncSlpValidator: SlpValidator, utxos: SlpAddressUtxoResult[]) { let validSLPTx: string[] = await asyncSlpValidator.validateSlpTransactions([ ...new Set(utxos.filter(txOut => { if (txOut.slpTransactionDetails && txOut.slpUtxoJudgement !== SlpUtxoJudgement.UNKNOWN && txOut.slpUtxoJudgement !== SlpUtxoJudgement.NOT_SLP) return true; return false; }).map(txOut => txOut.txid)) ]); utxos.forEach(utxo => { if (!(validSLPTx.includes(utxo.txid))) { if (utxo.slpUtxoJudgement === SlpUtxoJudgement.SLP_TOKEN) { utxo.slpUtxoJudgement = SlpUtxoJudgement.INVALID_TOKEN_DAG; } else if (utxo.slpUtxoJudgement === SlpUtxoJudgement.SLP_BATON) { utxo.slpUtxoJudgement = SlpUtxoJudgement.INVALID_BATON_DAG; } } }); } }