syscointx-js
Version:
A transaction creation library interfacing with coin selection for Syscoin.
501 lines (475 loc) • 19.5 kB
JavaScript
const BN = require('bn.js')
const ext = require('./bn-extensions')
const utils = require('./utils')
const syscoinBufferUtils = require('./bufferutilsassets.js')
const bitcoin = require('bitcoinjs-lib')
const coinSelect = require('coinselectsyscoin')
const bitcoinops = require('bitcoin-ops')
function createTransaction (txOpts, utxos, changeAddress, outputsArr, feeRate, inputsArr) {
let dataBuffer = null
let totalMemoLen = 0
let totalBlobLen = 0
if (txOpts.memo) {
if (!txOpts.memoHeader) {
console.log('No Memo header defined')
return null
}
totalMemoLen = txOpts.memo.length + txOpts.memoHeader.length
}
if (totalMemoLen > 80) {
console.log('Memo too big! Max is 80 bytes, found: ' + totalMemoLen)
return null
}
if (txOpts.memo) {
dataBuffer = Buffer.concat([txOpts.memoHeader, txOpts.memo])
}
let txVersion = 2
inputsArr = inputsArr || []
if (txOpts.blobHash) {
if (!txOpts.blobData) {
console.log('blobHash provided but no blobData in txOptions')
return null
}
totalBlobLen = txOpts.blobData.length
txVersion = utils.SYSCOIN_TX_VERSION_NEVM_DATA
dataBuffer = syscoinBufferUtils.serializePoDA({ blobHash: txOpts.blobHash })
}
let res = coinSelect.coinSelect(utxos.utxos, inputsArr, outputsArr, feeRate, txVersion, totalMemoLen, totalBlobLen)
if (!res.inputs && !res.outputs) {
if (txOpts.blobHash) {
console.log('createTransaction: inputs or outputs are empty after coinSelect creating blob')
return null
}
const assetAllocations = []
console.log('createTransaction: inputs or outputs are empty after coinSelect trying to fund with Syscoin Asset inputs...')
res = coinSelect.coinSelectAssetGas(assetAllocations, utxos.utxos, inputsArr, outputsArr, feeRate, utils.SYSCOIN_TX_VERSION_ALLOCATION_SEND, utxos.assets, null)
if (!res.inputs || !res.outputs) {
console.log('createTransaction: inputs or outputs are empty after coinSelectAssetGas')
return null
}
if (assetAllocations.length > 0) {
txVersion = utils.SYSCOIN_TX_VERSION_ALLOCATION_SEND
// re-use syscoin change outputs for allocation change outputs where we can, this will possible remove one output and save fees
optimizeOutputs(res.outputs, assetAllocations)
const assetAllocationsBuffer = syscoinBufferUtils.serializeAssetAllocations(assetAllocations)
let buffArr
if (dataBuffer) {
buffArr = [assetAllocationsBuffer, dataBuffer]
} else {
buffArr = [assetAllocationsBuffer]
}
// create and add data script for OP_RETURN
const dataScript = bitcoin.payments.embed({ data: [Buffer.concat(buffArr)] }).output
const dataOutput = {
script: dataScript,
value: ext.BN_ZERO
}
res.outputs.push(dataOutput)
}
} else if (dataBuffer) {
const updatedData = [dataBuffer]
const dataScript = bitcoin.payments.embed({ data: [Buffer.concat(updatedData)] }).output
const dataOutput = {
script: dataScript,
value: ext.BN_ZERO
}
res.outputs.push(dataOutput)
}
const inputs = res.inputs
const outputs = res.outputs
optimizeFees(txVersion, inputs, outputs, feeRate)
if (txVersion === utils.SYSCOIN_TX_VERSION_ALLOCATION_SEND) {
// ensure ZDAG is only enable for transactions <= 1100 bytes
const bytesAccum = coinSelect.utils.transactionBytes(inputs, outputs)
// if size too large we ensure ZDAG isn't set by enabling RBF (disable ZDAG)
if (bytesAccum > 1100) {
if (!txOpts.rbf) {
txOpts.rbf = true
}
}
}
if (txOpts.rbf) {
inputs.forEach(input => {
input.sequence = utils.MAX_BIP125_RBF_SEQUENCE
})
}
outputs.forEach(output => {
// watch out, outputs may have been added that you need to provide
// an output address/script for
if (!output.address) {
output.address = changeAddress
}
})
return { txVersion, inputs, outputs }
}
// update all allocations at some index or higher
function updateAllocationIndexes (assetAllocations, index) {
assetAllocations.forEach(voutAsset => {
voutAsset.values.forEach(output => {
if (output.n > index) {
output.n--
}
})
})
}
function optimizeOutputs (outputs, assetAllocations) {
// first find all syscoin outputs that are change (should only be one)
const changeOutputs = outputs.filter(output => output.changeIndex !== undefined)
if (changeOutputs.length > 1) {
console.log('optimizeOutputs: too many change outputs')
return
}
// find all asset change outputs
const assetChangeOutputs = outputs.filter(assetOutput => assetOutput.assetChangeIndex !== undefined && assetOutput.assetInfo.assetGuid > 0)
changeOutputs.forEach(output => {
// for every asset output and find any where the allocation index and change output index don't match
// make the allocation point to the syscoin change output and we can delete the asset output (it sends dust anyway)
for (let i = 0; i < assetChangeOutputs.length; i++) {
const assetOutput = assetChangeOutputs[i]
// get the allocation by looking up from assetChangeIndex which is indexing into the allocations array for this asset guid
const allocations = assetAllocations.find(voutAsset => voutAsset.assetGuid === assetOutput.assetInfo.assetGuid)
const allocation = allocations.values[assetOutput.assetChangeIndex]
// ensure that the output index's don't match between sys change and asset output
if (allocation.n !== output.changeIndex) {
// remove the output, we will recalc and optimize fees after this call
outputs.splice(allocation.n, 1)
// because we deleted this index, it will invalidate any indexes after (we must subtract by one on every index after assetChangeIndex)
updateAllocationIndexes(assetAllocations, allocation.n)
// set them the same and remove asset output
// we reduce index by one because any index > allocation.n would have been reduced by updateAllocationIndexes and so changeIndex should also by reduced by 1 if its above allocation.n
if (output.changeIndex > allocation.n) {
allocation.n = output.changeIndex - 1
} else {
allocation.n = output.changeIndex
}
// add assetInfo to output as its a sys change output which now becomes asset output as well (only needed for further calls which check assetInfo on outputs, not for signing or verifying the transaction)
outputs[allocation.n].assetInfo = assetOutput.assetInfo
// clear change address as it should use sys change address instead (when adding outputs)
allocation.changeAddress = null
return
}
}
})
}
function optimizeFees (txVersion, inputs, outputs, feeRate) {
const changeOutputs = outputs.filter(output => output.changeIndex !== undefined)
if (changeOutputs.length > 1) {
console.log('optimizeFees: too many change outputs')
return
}
if (changeOutputs.length === 0) {
console.log('optimizeFees: no change outputs')
return
}
const changeOutput = changeOutputs[0]
const bytesAccum = coinSelect.utils.transactionBytes(inputs, outputs)
const feeRequired = ext.mul(feeRate, bytesAccum)
let feeFoundInOut = ext.sub(coinSelect.utils.sumOrNaN(inputs), coinSelect.utils.sumOrNaN(outputs))
// first output of burn to sys is not accounted for with inputs, its minted based on sysx asset output to burn
if (txVersion === utils.SYSCOIN_TX_VERSION_ALLOCATION_BURN_TO_SYSCOIN) {
feeFoundInOut = ext.add(feeFoundInOut, outputs[0].value)
}
if (feeFoundInOut && ext.gt(feeFoundInOut, feeRequired)) {
const reduceFee = ext.sub(feeFoundInOut, feeRequired)
console.log('optimizeFees: reducing fees by: ' + reduceFee.toNumber())
// add to change to effectively reduce fee
changeOutput.value = ext.add(changeOutput.value, reduceFee)
} else if (ext.lt(feeFoundInOut, feeRequired)) {
console.log('optimizeFees: warning, not enough fees found in transaction: required: ' + feeRequired.toNumber() + ' found: ' + feeFoundInOut.toNumber())
}
}
function getAllocationsFromOutputs (outputs) {
let opReturnScript = null
for (let i = 0; i < outputs.length; i++) {
const output = outputs[i]
if (!output.script) {
continue
}
// find opreturn
const chunks = bitcoin.script.decompile(output.script)
if (chunks[0] === bitcoinops.OP_RETURN) {
opReturnScript = chunks[1]
break
}
}
if (opReturnScript === null) {
console.log('no OPRETURN script found')
return null
}
const allocation = syscoinBufferUtils.deserializeAssetAllocations(opReturnScript)
if (!allocation) {
return null
}
return allocation
}
function getAllocationsFromTx (tx) {
if (!utils.isSyscoinTx(tx.version)) {
return null
}
return getAllocationsFromOutputs(tx.outs)
}
function getPoDAFromOutputs (outputs) {
let opReturnScript = null
for (let i = 0; i < outputs.length; i++) {
const output = outputs[i]
if (!output.script) {
continue
}
// find opreturn
const chunks = bitcoin.script.decompile(output.script)
if (chunks[0] === bitcoinops.OP_RETURN) {
opReturnScript = chunks[1]
break
}
}
if (opReturnScript === null) {
console.log('no OPRETURN script found')
return null
}
const blob = syscoinBufferUtils.deserializePoDA(opReturnScript)
if (!blob) {
return null
}
return blob
}
function getPoDAFromTx (tx) {
if (!utils.isPoDATx(tx.version)) {
return null
}
return getPoDAFromOutputs(tx.outs)
}
function getAssetsFromOutputs (outputs) {
const allocation = getAllocationsFromOutputs(outputs)
if (!allocation) {
return null
}
const assets = new Map()
allocation.forEach(assetAllocation => {
assets.set(assetAllocation.assetGuid, {})
})
return assets
}
// get all assets found in an asset tx returned in a map of assets keyed by asset guid
function getAssetsFromTx (tx) {
const allocation = getAllocationsFromTx(tx)
if (!allocation) {
return null
}
const assets = new Map()
allocation.forEach(assetAllocation => {
assets.set(assetAllocation.assetGuid, {})
})
return assets
}
function createAssetTransaction (txVersion, txOpts, utxos, dataBuffer, dataAmount, assetMap, sysChangeAddress, feeRate) {
let { inputs, outputs, assetAllocations } = coinSelect.coinSelectAsset(utxos.utxos, assetMap, feeRate, txVersion)
// .inputs and .outputs will be undefined if no solution was found
if (!inputs || !outputs) {
console.log('createAssetTransaction: inputs or outputs are empty after coinSelectAsset')
return null
}
let burnAllocationValue
if (utils.isAllocationBurn(txVersion)) {
// ensure only 1 to 2 outputs (2 if change was required)
if (outputs.length > 2 && outputs.length < 1) {
console.log('Assetallocationburn: expect output of length 1 got: ' + outputs.length)
return null
}
const assetAllocation = assetAllocations.find(voutAsset => voutAsset.assetGuid === outputs[0].assetInfo.assetGuid)
if (assetAllocation === undefined) {
console.log('Assetallocationburn: assetAllocations map does not have key: ' + outputs[0].assetInfo.assetGuid)
return null
}
burnAllocationValue = new BN(assetAllocation.values[0].value)
// remove first output if there is more than one
// it will be size of 1 if you burn the exact right amount you own (ie utxo has 5 sysx and you burn 5 sysx)
// it will be size of 2 if you burn less than what you own (ie utxo has 5 sysx and you burn 4 sysx, 1 sysx should be change)
if (outputs.length > 1) {
outputs.splice(0, 1)
// we removed the first index via slice above, so all N's at index 1 or above should be reduced by 1
updateAllocationIndexes(assetAllocations, 0)
} else {
outputs[0].assetChangeIndex = undefined
}
// point first allocation to next output (burn output)
assetAllocation.values[0].n = outputs.length
}
let assetAllocationsBuffer = syscoinBufferUtils.serializeAssetAllocations(assetAllocations)
let buffArr
if (dataBuffer) {
buffArr = [assetAllocationsBuffer, dataBuffer]
} else {
buffArr = [assetAllocationsBuffer]
}
// create and add data script for OP_RETURN
let dataScript = bitcoin.payments.embed({ data: [Buffer.concat(buffArr)] }).output
const dataOutput = {
script: dataScript,
value: dataAmount
}
outputs.push(dataOutput)
const res = coinSelect.coinSelectAssetGas(assetAllocations, utxos.utxos, inputs, outputs, feeRate, txVersion, utxos.assets, assetMap)
if (!res.inputs || !res.outputs) {
console.log('createAssetTransaction: inputs or outputs are empty after coinSelectAssetGas')
return null
}
inputs = res.inputs
outputs = res.outputs
// once funded we should swap the first output asset amount to sys amount as we are burning sysx to sys in output 0
if (utils.isAllocationBurn(txVersion)) {
if (txVersion === utils.SYSCOIN_TX_VERSION_ALLOCATION_BURN_TO_SYSCOIN) {
// modify output from asset value to syscoin value
// first output is special it is the sys amount being minted
outputs[0].value = burnAllocationValue
}
}
// optimizeOutputs reorganizes outputs and we need to ensure we don't do this with burntosyscoin since its assumed first output has the sys value we need to create
if (txVersion !== utils.SYSCOIN_TX_VERSION_ALLOCATION_BURN_TO_SYSCOIN) {
// re-use syscoin change outputs for allocation change outputs where we can, this will possible remove one output and save fees
optimizeOutputs(outputs, assetAllocations)
}
// serialize allocations again they may have been changed in optimization
assetAllocationsBuffer = syscoinBufferUtils.serializeAssetAllocations(assetAllocations)
if (dataBuffer) {
buffArr = [assetAllocationsBuffer, dataBuffer]
} else {
buffArr = [assetAllocationsBuffer]
}
// update script with new guid
dataScript = bitcoin.payments.embed({ data: [Buffer.concat(buffArr)] }).output
// update output with new data output with new guid
outputs.forEach(output => {
if (output.script) {
output.script = dataScript
}
})
optimizeFees(txVersion, inputs, outputs, feeRate)
if (txVersion === utils.SYSCOIN_TX_VERSION_ALLOCATION_SEND) {
// ensure ZDAG is only enable for transactions <= 1100 bytes
const bytesAccum = coinSelect.utils.transactionBytes(inputs, outputs)
// if size too large we ensure ZDAG isn't set by enabling RBF (disable ZDAG)
if (bytesAccum > 1100) {
if (!txOpts.rbf) {
txOpts.rbf = true
}
}
}
if (txOpts.rbf) {
inputs.forEach(input => {
input.sequence = utils.MAX_BIP125_RBF_SEQUENCE
})
}
outputs.forEach(output => {
// watch out, outputs may have been added that you need to provide
// an output address/script for
if (!output.address) {
if (output.assetInfo) {
if (assetMap.has(output.assetInfo.assetGuid)) {
const changeAddress = assetMap.get(output.assetInfo.assetGuid).changeAddress
if (changeAddress) {
output.address = changeAddress
}
}
}
}
// if we still don't have address set to sys change address
if (!output.address) {
output.address = sysChangeAddress
}
})
return { txVersion, inputs, outputs }
}
function assetAllocationSend (txOpts, utxos, assetMap, sysChangeAddress, feeRate) {
const txVersion = utils.SYSCOIN_TX_VERSION_ALLOCATION_SEND
const dataAmount = ext.BN_ZERO
let dataBuffer = null
if (txOpts.memo) {
if (!Buffer.isBuffer(txOpts.memo)) {
console.log('Memo must be Buffer object')
return
}
const totalLen = txOpts.memo.length + txOpts.memoHeader.length
if (!txOpts.memoHeader) {
console.log('No Memo header defined')
return
}
if (totalLen > 80) {
console.log('Memo too big! Max is 80 bytes, found: ' + totalLen)
return
}
dataBuffer = Buffer.concat([txOpts.memoHeader, txOpts.memo])
}
return createAssetTransaction(txVersion, txOpts, utxos, dataBuffer, dataAmount, assetMap, sysChangeAddress, feeRate)
}
function assetAllocationBurn (assetOpts, txOpts, utxos, assetMap, sysChangeAddress, feeRate) {
let txVersion = 0
if (assetOpts.ethaddress.length > 0) {
txVersion = utils.SYSCOIN_TX_VERSION_ALLOCATION_BURN_TO_ETHEREUM
} else {
txVersion = utils.SYSCOIN_TX_VERSION_ALLOCATION_BURN_TO_SYSCOIN
}
const dataAmount = ext.BN_ZERO
const dataBuffer = syscoinBufferUtils.serializeAllocationBurn(assetOpts)
return createAssetTransaction(txVersion, txOpts, utxos, dataBuffer, dataAmount, assetMap, sysChangeAddress, feeRate)
}
function assetAllocationMint (assetOpts, txOpts, utxos, assetMap, sysChangeAddress, feeRate) {
const txVersion = utils.SYSCOIN_TX_VERSION_ALLOCATION_MINT
const dataAmount = ext.BN_ZERO
if (assetOpts.txparentnodes.length > utils.USHRT_MAX()) {
console.log('tx parent nodes exceeds maximum allowable size of: ', utils.USHRT_MAX(), '. Found size: ', assetOpts.txparentnodes.length)
return
}
if (assetOpts.receiptparentnodes.length > utils.USHRT_MAX()) {
console.log('receipt parent nodes exceeds maximum allowable size of: ', utils.USHRT_MAX(), '. Found size: ', assetOpts.receiptparentnodes.length)
return
}
// find byte offset of tx data in the parent nodes
assetOpts.txpos = assetOpts.txparentnodes.indexOf(assetOpts.txvalue)
if (assetOpts.txpos === -1) {
console.log('Could not find tx value in tx parent nodes')
return
}
// find byte offset of receipt data in the parent nodes
assetOpts.receiptpos = assetOpts.receiptparentnodes.indexOf(assetOpts.receiptvalue)
if (assetOpts.receiptpos === -1) {
console.log('Could not find receipt value in receipt parent nodes')
return
}
const dataBuffer = syscoinBufferUtils.serializeMintSyscoin(assetOpts)
return createAssetTransaction(txVersion, txOpts, utxos, dataBuffer, dataAmount, assetMap, sysChangeAddress, feeRate)
}
function syscoinBurnToAssetAllocation (txOpts, utxos, assetMap, sysChangeAddress, feeRate) {
const txVersion = utils.SYSCOIN_TX_VERSION_SYSCOIN_BURN_TO_ALLOCATION
const dataBuffer = null
let dataAmount = ext.BN_ZERO
const valueAssetObj = assetMap.values().next().value
if (valueAssetObj.outputs.length > 0) {
dataAmount = valueAssetObj.outputs[0].value
}
return createAssetTransaction(txVersion, txOpts, utxos, dataBuffer, dataAmount, assetMap, sysChangeAddress, feeRate)
}
function createPoDA (txOpts, utxos, sysChangeAddress, feeRate) {
if (!txOpts.blobData || !txOpts.blobHash) {
console.log('Could not find blob txOpt fields, cannot create PoDA transaction')
return null
}
return createTransaction(txOpts, utxos, sysChangeAddress, [], feeRate)
}
module.exports = {
utils: utils,
coinSelect: coinSelect,
bufferUtils: syscoinBufferUtils,
createTransaction: createTransaction,
createAssetTransaction: createAssetTransaction,
createPoDA: createPoDA,
assetAllocationSend: assetAllocationSend,
assetAllocationBurn: assetAllocationBurn,
assetAllocationMint: assetAllocationMint,
syscoinBurnToAssetAllocation: syscoinBurnToAssetAllocation,
getAssetsFromTx: getAssetsFromTx,
getAllocationsFromTx: getAllocationsFromTx,
getAllocationsFromOutputs: getAllocationsFromOutputs,
getPoDAFromOutputs: getPoDAFromOutputs,
getPoDAFromTx: getPoDAFromTx,
getAssetsFromOutputs: getAssetsFromOutputs
}