syscoinjs-lib
Version:
A transaction creation library interfacing with coin selection for Syscoin.
1,359 lines (1,289 loc) • 55.2 kB
JavaScript
const axios = require('axios')
const BN = require('bn.js')
const BIP84 = require('bip84')
const bjs = require('bitcoinjs-lib')
const bitcoinops = require('bitcoin-ops')
const varuint = require('varuint-bitcoin')
const { VerifyProof, GetProof } = require('eth-proof')
const { encode } = require('eth-util-lite')
const { Log } = require('eth-object')
const { keccak256 } = require('@ethersproject/keccak256')
const { AbiCoder } = require('@ethersproject/abi')
const { JsonRpcProvider } = require('@ethersproject/providers')
const syscointx = require('syscointx-js')
// Web3 utility replacements using ethers and BN.js
const web3Utils = {
BN,
toBN: (value) => new BN(value),
hexToNumberString: (hex) => new BN(hex.replace('0x', ''), 16).toString(10),
sha3: (data) => keccak256(data)
}
// Web3 ABI replacement using ethers
const web3Eth = {
abi: {
decodeParameters: (types, data) => {
const abiCoder = new AbiCoder()
return abiCoder.decode(types, data)
}
}
}
// Create web3-compatible object for backward compatibility
const web3 = {
utils: web3Utils,
eth: web3Eth
}
const bitcoinNetworks = { mainnet: bjs.networks.bitcoin, testnet: bjs.networks.testnet }
const syscoinNetworks = {
mainnet: {
messagePrefix: '\x18Syscoin Signed Message:\n',
bech32: 'sys',
bip32: {
public: 0x0488b21e,
private: 0x0488ade4
},
pubKeyHash: 0x3f,
scriptHash: 0x05,
wif: 0x80
},
testnet: {
messagePrefix: '\x18Syscoin Signed Message:\n',
bech32: 'tsys',
bip32: {
public: 0x043587cf,
private: 0x04358394
},
pubKeyHash: 0x41,
scriptHash: 0xc4,
wif: 0xef
}
}
const bitcoinZPubTypes = { mainnet: { zprv: '04b2430c', zpub: '04b24746' }, testnet: { vprv: '045f18bc', vpub: '045f1cf6' } }
const bitcoinXPubTypes = { mainnet: { zprv: bitcoinNetworks.mainnet.bip32.private, zpub: bitcoinNetworks.mainnet.bip32.public }, testnet: { vprv: bitcoinNetworks.testnet.bip32.private, vpub: bitcoinNetworks.testnet.bip32.public } }
const syscoinZPubTypes = { mainnet: { zprv: '04b2430c', zpub: '04b24746' }, testnet: { vprv: '045f18bc', vpub: '045f1cf6' } }
const syscoinXPubTypes = { mainnet: { zprv: syscoinNetworks.mainnet.bip32.private, zpub: syscoinNetworks.mainnet.bip32.public }, testnet: { vprv: syscoinNetworks.testnet.bip32.private, vpub: syscoinNetworks.testnet.bip32.public } }
const syscoinSLIP44 = 57
const bitcoinSLIP44 = 0
const VaultManager = '0x7904299b3D3dC1b03d1DdEb45E9fDF3576aCBd5f'
const tokenFreezeFunction = '0b8914e27c9a6c88836bc5547f82ccf331142c761f84e9f1d36934a6a31eefad' // token freeze function signature
const axiosConfig = {
withCredentials: true
}
// Retry configuration for blockbook API calls
const MAX_RETRIES = 3
const INITIAL_RETRY_DELAY = 1000 // 1 second
/* retryWithBackoff
Purpose: Generic retry function with exponential backoff for handling rate limiting (503 errors)
Param fn: Required. Function to retry
Param retryCount: Internal. Current retry attempt (starts at 0)
Returns: Returns the result of the function or throws error after max retries
*/
async function retryWithBackoff (fn, retryCount = 0) {
try {
return await fn()
} catch (error) {
// Check if it's a retryable error (503 Service Unavailable or rate limiting)
const isRetryableError =
(error.response && (error.response.status === 503 || error.response.status === 429)) ||
(error.status === 503 || error.status === 429) ||
(error.message && (
error.message.includes('503') ||
error.message.includes('429') ||
error.message.includes('Service Unavailable') ||
error.message.includes('Too many requests') ||
error.message.includes('rate limit')
))
if (isRetryableError && retryCount < MAX_RETRIES) {
const delay = INITIAL_RETRY_DELAY * Math.pow(2, retryCount)
console.log(`[syscoinjs-lib] Blockbook API rate limited, retrying after ${delay}ms... (attempt ${retryCount + 1}/${MAX_RETRIES})`)
await new Promise(resolve => setTimeout(resolve, delay))
return retryWithBackoff(fn, retryCount + 1)
}
// If not retryable or max retries reached, throw the error
throw error
}
}
/* fetchBackendAsset
Purpose: Fetch asset information from backend Blockbook provider
Param backendURL: Required. Fully qualified URL for blockbook
Param assetGuid: Required. Asset to fetch
Returns: Returns JSON object in response, asset information object in JSON
*/
async function fetchBackendAsset (backendURL, assetGuid) {
return retryWithBackoff(async () => {
let blockbookURL = backendURL.slice()
if (blockbookURL) {
blockbookURL = blockbookURL.replace(/\/$/, '')
}
// eslint-disable-next-line no-undef
if (fetch) {
// eslint-disable-next-line no-undef
const response = await fetch(`${blockbookURL}/api/v2/asset/${assetGuid}?details=basic`)
if (response.ok) {
const data = await response.json()
if (data.asset) {
return data.asset
}
} else if (response.status === 503 || response.status === 429) {
const error = new Error(`HTTP ${response.status}: ${response.statusText}`)
error.status = response.status
throw error
}
} else {
const request = await axios.get(blockbookURL + '/api/v2/asset/' + assetGuid + '?details=basic', axiosConfig)
if (request && request.data && request.data.asset) {
return request.data.asset
}
}
return null
})
}
/* fetchBackendListAssets
Purpose: Fetch list of assets from backend Blockbook provider via a filter
Param backendURL: Required. Fully qualified URL for blockbook
Param filter: Required. Asset to fetch via filter, will filter contract or symbol fields
Returns: Returns JSON array in response, asset information objects in JSON
*/
async function fetchBackendListAssets (backendURL, filter) {
return retryWithBackoff(async () => {
let blockbookURL = backendURL.slice()
if (blockbookURL) {
blockbookURL = blockbookURL.replace(/\/$/, '')
}
// eslint-disable-next-line no-undef
if (fetch) {
// eslint-disable-next-line no-undef
const request = await fetch(blockbookURL + '/api/v2/assets/' + filter)
if (request.ok) {
const data = await request.json()
if (data && data.asset) {
return data.asset
}
} else if (request.status === 503 || request.status === 429) {
const error = new Error(`HTTP ${request.status}: ${request.statusText}`)
error.status = request.status
throw error
}
} else {
const request = await axios.get(blockbookURL + '/api/v2/assets/' + filter, axiosConfig)
if (request && request.data && request.data.asset) {
return request.data.asset
}
}
return null
})
}
/* fetchBackendSPVProof
Purpose: Fetch SPV Proof from backend Blockbook provider. To be used to create a proof for the NEVM bridge.
Param backendURL: Required. Fully qualified URL for blockbook
Param addressOrXpub: Required. An address or XPUB to fetch UTXO's for
Param options: Optional. Optional queries based on https://github.com/syscoin/blockbook/blob/master/docs/api.md#get-utxo
Returns: Returns JSON object in response, UTXO object array in JSON
*/
async function fetchBackendSPVProof (backendURL, txid) {
return retryWithBackoff(async () => {
let blockbookURL = backendURL.slice()
if (blockbookURL) {
blockbookURL = blockbookURL.replace(/\/$/, '')
}
const url = blockbookURL + '/api/v2/getspvproof/' + txid
// eslint-disable-next-line no-undef
if (fetch) {
// eslint-disable-next-line no-undef
const response = await fetch(url)
if (response.ok) {
const data = await response.json()
return data
} else if (response.status === 503 || response.status === 429) {
const error = new Error(`HTTP ${response.status}: ${response.statusText}`)
error.status = response.status
throw error
}
} else {
const request = await axios.get(url, axiosConfig)
if (request && request.data) {
return request.data
}
}
return null
})
}
/* fetchBackendUTXOS
Purpose: Fetch UTXO's for an address or XPUB from backend Blockbook provider
Param backendURL: Required. Fully qualified URL for blockbook
Param addressOrXpub: Required. An address or XPUB to fetch UTXO's for
Param options: Optional. Optional queries based on https://github.com/syscoin/blockbook/blob/master/docs/api.md#get-utxo
Returns: Returns JSON object in response, UTXO object array in JSON
*/
async function fetchBackendUTXOS (backendURL, addressOrXpub, options) {
return retryWithBackoff(async () => {
let blockbookURL = backendURL.slice()
if (blockbookURL) {
blockbookURL = blockbookURL.replace(/\/$/, '')
}
let url = blockbookURL + '/api/v2/utxo/' + addressOrXpub
if (options) {
url += '?' + options
}
// eslint-disable-next-line no-undef
if (fetch) {
// eslint-disable-next-line no-undef
const response = await fetch(url)
if (response.ok) {
const data = await response.json()
if (data) {
data.addressOrXpub = addressOrXpub
return data
}
} else if (response.status === 503 || response.status === 429) {
const error = new Error(`HTTP ${response.status}: ${response.statusText}`)
error.status = response.status
throw error
}
} else {
const request = await axios.get(url, axiosConfig)
if (request && request.data) {
request.data.addressOrXpub = addressOrXpub
return request.data
}
}
return null
})
}
/* fetchBackendAccount
Purpose: Fetch address or XPUB information including transactions and balance information (based on options) from backend Blockbook provider
Param backendURL: Required. Fully qualified URL for blockbook
Param addressOrXpub: Required. An address or XPUB to fetch UTXO's for
Param options: Optional. Optional queries based on https://github.com/syscoin/blockbook/blob/master/docs/api.md#get-xpub
Param xpub: Optional. If addressOrXpub is an XPUB set to true.
Param mySignerObj: Optional. Signer object if you wish to update change/receiving indexes from backend provider (and XPUB token information is provided in response)
Returns: Returns JSON object in response, account object in JSON
*/
async function fetchBackendAccount (backendURL, addressOrXpub, options, xpub, mySignerObj) {
return retryWithBackoff(async () => {
let blockbookURL = backendURL.slice()
if (blockbookURL) {
blockbookURL = blockbookURL.replace(/\/$/, '')
}
let url = blockbookURL
if (xpub) {
url += '/api/v2/xpub/'
} else {
url += '/api/v2/address/'
}
url += addressOrXpub
if (options) {
url += '?' + options
}
// eslint-disable-next-line no-undef
if (fetch) {
// eslint-disable-next-line no-undef
const response = await fetch(url)
if (response.ok) {
const data = await response.json()
if (xpub && data.tokens && mySignerObj) {
mySignerObj.setLatestIndexesFromXPubTokens(data.tokens)
}
data.addressOrXpub = addressOrXpub
return data
} else if (response.status === 503 || response.status === 429) {
const error = new Error(`HTTP ${response.status}: ${response.statusText}`)
error.status = response.status
throw error
}
} else {
const request = await axios.get(url, axiosConfig)
if (request && request.data) {
// if fetching xpub data
if (xpub && request.data.tokens && mySignerObj) {
mySignerObj.setLatestIndexesFromXPubTokens(request.data.tokens)
}
return request.data
}
}
return null
}).catch(e => {
console.log('Exception: ' + e.message)
return null
})
}
/* sendRawTransaction
Purpose: Send raw transaction to backend Blockbook provider to send to the network
Param backendURL: Required. Fully qualified URL for blockbook
Param txHex: Required. Raw transaction hex
Param mySignerObj: Optional. Signer object if you wish to update change/receiving indexes from backend provider through fetchBackendAccount()
Returns: Returns txid in response or error
*/
async function sendRawTransaction (backendURL, txHex, mySignerObj) {
return retryWithBackoff(async () => {
let blockbookURL = backendURL.slice()
if (blockbookURL) {
blockbookURL = blockbookURL.replace(/\/$/, '')
}
// eslint-disable-next-line no-undef
if (fetch) {
const requestOptions = {
method: 'POST',
body: txHex
}
// eslint-disable-next-line no-undef
const response = await fetch(blockbookURL + '/api/v2/sendtx/', requestOptions)
if (response.ok) {
const data = await response.json()
if (mySignerObj) {
await fetchBackendAccount(blockbookURL, mySignerObj.getAccountXpub(), 'tokens=used&details=tokens', true, mySignerObj)
}
return data
} else if (response.status === 503 || response.status === 429) {
const error = new Error(`HTTP ${response.status}: ${response.statusText}`)
error.status = response.status
throw error
} else {
// Handle other error status codes (400, 404, 500, etc.)
let errorText = response.statusText
try {
const errorData = await response.text()
if (errorData) {
errorText = errorData
}
} catch (e) {
// If we can't read the response body, use the status text
}
const error = new Error(`HTTP error! Status: ${response.status}. Details: ${errorText}`)
error.status = response.status
throw error
}
} else {
const request = await axios.post(blockbookURL + '/api/v2/sendtx/', txHex, axiosConfig)
if (request && request.data) {
if (mySignerObj) {
await fetchBackendAccount(blockbookURL, mySignerObj.getAccountXpub(), 'tokens=used&details=tokens', true, mySignerObj)
}
return request.data
}
}
return null
})
}
/* fetchBackendRawTx
Purpose: Get transaction from txid from backend Blockbook provider
Param backendURL: Required. Fully qualified URL for blockbook
Param txid: Required. Transaction ID to get information for
Returns: Returns JSON object in response, transaction object in JSON
*/
async function fetchBackendRawTx (backendURL, txid) {
return retryWithBackoff(async () => {
let blockbookURL = backendURL.slice()
if (blockbookURL) {
blockbookURL = blockbookURL.replace(/\/$/, '')
}
// eslint-disable-next-line no-undef
if (fetch) {
// eslint-disable-next-line no-undef
const response = await fetch(blockbookURL + '/api/v2/tx/' + txid)
if (response.ok) {
const data = await response.json()
if (data) {
return data
}
} else if (response.status === 503 || response.status === 429) {
const error = new Error(`HTTP ${response.status}: ${response.statusText}`)
error.status = response.status
throw error
}
} else {
const request = await axios.get(blockbookURL + '/api/v2/tx/' + txid, axiosConfig)
if (request && request.data) {
return request.data
}
}
return null
})
}
/* fetchProviderInfo
Purpose: Get prover info including blockbook and backend data
Returns: Returns JSON object in response, provider object in JSON
*/
async function fetchProviderInfo (backendURL) {
return retryWithBackoff(async () => {
let blockbookURL = backendURL.slice()
if (blockbookURL) {
blockbookURL = blockbookURL.replace(/\/$/, '')
}
// eslint-disable-next-line no-undef
if (fetch) {
// eslint-disable-next-line no-undef
const response = await fetch(blockbookURL + '/api/v2')
if (response.ok) {
const data = await response.json()
if (data) {
return data
}
} else if (response.status === 503 || response.status === 429) {
const error = new Error(`HTTP ${response.status}: ${response.statusText}`)
error.status = response.status
throw error
}
} else {
const request = await axios.get(blockbookURL + '/api/v2', axiosConfig)
if (request && request.data) {
return request.data
}
}
return null
})
}
/* fetchBackendBlock
Purpose: Get block from backend
Returns: Returns JSON object in response, block object in JSON
*/
async function fetchBackendBlock (backendURL, blockhash) {
return retryWithBackoff(async () => {
let blockbookURL = backendURL.slice()
if (blockbookURL) {
blockbookURL = blockbookURL.replace(/\/$/, '')
}
// eslint-disable-next-line no-undef
if (fetch) {
// eslint-disable-next-line no-undef
const response = await fetch(blockbookURL + '/api/v2/block/' + blockhash)
if (response.ok) {
const data = await response.json()
if (data) {
return data
}
} else if (response.status === 503 || response.status === 429) {
const error = new Error(`HTTP ${response.status}: ${response.statusText}`)
error.status = response.status
throw error
}
} else {
const request = await axios.get(blockbookURL + '/api/v2/block/' + blockhash, axiosConfig)
if (request && request.data) {
return request.data
}
}
return null
})
}
/* fetchEstimateFee
Purpose: Get estimated fee from backend
Returns: Returns JSON object in response, fee object in JSON
Param blocks: Required. How many blocks to estimate fee for.
Param options: Optional. possible value conservative=true or false for conservative fee. Default is true.
Returns: Returns fee response in integer. Fee rate in coins per kilobytes.
*/
async function fetchEstimateFee (backendURL, blocks, options) {
return retryWithBackoff(async () => {
let blockbookURL = backendURL.slice()
if (blockbookURL) {
blockbookURL = blockbookURL.replace(/\/$/, '')
}
let url = blockbookURL + '/api/v2/estimatefee/' + blocks
if (options) {
url += '?' + options
}
// eslint-disable-next-line no-undef
if (fetch) {
// eslint-disable-next-line no-undef
const response = await fetch(url)
if (response.ok) {
const data = await response.json()
if (data && data.result) {
// Parse as float since API returns coins per KB, not satoshis per KB
let feeInSysPerKB = parseFloat(data.result)
// if fee is 0 or negative, use minimum
if (feeInSysPerKB <= 0) {
feeInSysPerKB = 0.001 // 0.001 SYS/KB minimum
}
// Return coins(SYS) per KB as-is (the existing code will divide by 1024)
return feeInSysPerKB
}
} else if (response.status === 503 || response.status === 429) {
const error = new Error(`HTTP ${response.status}: ${response.statusText}`)
error.status = response.status
throw error
}
} else {
const request = await axios.get(url, axiosConfig)
if (request && request.data && request.data.result) {
let feeInSysPerKB = parseFloat(request.data.result)
if (feeInSysPerKB <= 0) {
feeInSysPerKB = 0.001
}
return feeInSysPerKB
}
}
return 0.001 // Default fallback: 0.001 SYS/KB
}).catch(e => {
return 0.001
})
}
/* signPSBTWithWIF
Purpose: Sign PSBT with WiF
Param psbt: Required. Partially signed transaction object
Param wif: Required. Private key in WIF format to sign inputs with
Param network: Required. bitcoinjs-lib Network object
Returns: psbt from bitcoinjs-lib
*/
async function signPSBTWithWIF (psbt, wif, network) {
const wifObject = bjs.ECPair.fromWIF(
wif,
network
)
// sign inputs with wif
await psbt.signAllInputsAsync(wifObject)
try {
if (psbt.validateSignaturesOfAllInputs()) {
psbt.finalizeAllInputs()
}
} catch (err) {
}
return psbt
}
/* signWithWIF
Purpose: Sign Result object with WiF
Param res: Required. The resulting object passed in which is assigned from syscointx.createTransaction()/syscointx.createAssetTransaction()
Param wif: Required. Private key in WIF format to sign inputs with, can be array of keys
Param network: Required. bitcoinjs-lib Network object
Returns: psbt from bitcoinjs-lib
*/
async function signWithWIF (psbt, wif, network) {
if (Array.isArray(wif)) {
for (const wifKey of wif) {
psbt = await signPSBTWithWIF(psbt, wifKey, network)
}
return psbt
} else {
return await signPSBTWithWIF(psbt, wif, network)
}
}
/* buildEthProof
Purpose: Build Ethereum SPV proof using eth-proof library
Param assetOpts: Required. Object containing web3url and ethtxid fields populated
Returns: Returns JSON object in response, SPV proof object in JSON
*/
async function buildEthProof (assetOpts) {
const ethProof = new GetProof(assetOpts.web3url)
const web3Provider = new JsonRpcProvider(assetOpts.web3url)
try {
const txHash = assetOpts.ethtxid.startsWith('0x') ? assetOpts.ethtxid : `0x${assetOpts.ethtxid}`
let result = await ethProof.transactionProof(txHash)
const txObj = await VerifyProof.getTxFromTxProofAt(result.txProof, result.txIndex)
const txvalue = txObj.hex.substring(2) // remove hex prefix
let destinationaddress
const txroot = result.header[4].toString('hex')
const txRootFromProof = VerifyProof.getRootFromProof(result.txProof)
if (txroot !== txRootFromProof.toString('hex')) {
throw new Error('TxRoot mismatch')
}
const txparentnodes = encode(result.txProof).toString('hex')
const txpath = encode(result.txIndex).toString('hex')
const blocknumber = parseInt(result.header[8].toString('hex'), 16)
const block = await web3Provider.getBlock(blocknumber)
const blockhash = block.hash.substring(2) // remove hex prefix
const receiptroot = result.header[5].toString('hex')
result = await ethProof.receiptProof(txHash)
const txReceipt = await VerifyProof.getReceiptFromReceiptProofAt(result.receiptProof, result.txIndex)
const receiptRootFromProof = VerifyProof.getRootFromProof(result.receiptProof)
if (receiptroot !== receiptRootFromProof.toString('hex')) {
throw new Error('ReceiptRoot mismatch')
}
const receiptparentnodes = encode(result.receiptProof).toString('hex')
const blockHashFromHeader = VerifyProof.getBlockHashFromHeader(result.header)
if (blockhash !== blockHashFromHeader.toString('hex')) {
throw new Error('BlockHash mismatch')
}
const receiptvalue = txReceipt.hex.substring(2) // remove hex prefix
let amount = new web3.utils.BN(0)
let assetguid
for (let i = 0; i < txReceipt.setOfLogs.length; i++) {
const log = Log.fromRaw(txReceipt.setOfLogs[i]).toObject()
if (!log.topics || log.topics.length !== 3) {
continue
}
if (
log.topics[0].toString('hex').toLowerCase() === tokenFreezeFunction.toLowerCase() &&
log.address.toLowerCase() === VaultManager.toLowerCase()
) {
// Decode indexed parameters from topics
assetguid = web3.utils.hexToNumberString('0x' + log.topics[1].toString('hex'))
// Decode non-indexed parameters from data
const paramResults = web3.eth.abi.decodeParameters(
['uint', 'string'],
log.data
)
amount = web3.utils.toBN(paramResults[0]).toString()
destinationaddress = paramResults[1].trim()
break
}
}
const ethtxid = web3.utils.sha3(Buffer.from(txvalue, 'hex')).substring(2) // not txid but txhash of the tx object used for calculating tx commitment without requiring transaction deserialization
return { ethtxid, blockhash, assetguid, destinationaddress, amount, txvalue, txroot, txparentnodes, txpath, blocknumber, receiptvalue, receiptroot, receiptparentnodes }
} catch (e) {
console.log('Exception: ' + e.message)
return e
}
}
/* sanitizeBlockbookUTXOs
Purpose: Sanitize backend provider UTXO objects to be useful for this library
Param sysFromXpubOrAddress: Required. The XPUB or address that was called to fetch UTXOs
Param utxoObj: Required. Backend provider UTXO JSON object to be sanitized
Param network: Optional. Defaults to Syscoin Mainnet.
Param txOpts: Optional. If its passed in we use assetWhiteList field of options to skip over (if assetWhiteList is null) UTXO's
Param assetMap: Optional. Destination outputs for transaction requiring UTXO sanitizing, used in assetWhiteList check described above
Param excludeZeroConf: Optional. False by default. Filtering out 0 conf UTXO, new/update/send asset transactions must use confirmed inputs only as per Syscoin Core mempool policy
Returns: Returns sanitized UTXO object for use internally in this library
*/
function sanitizeBlockbookUTXOs (sysFromXpubOrAddress, utxoObj, network, txOpts, assetMap, excludeZeroConf) {
if (!txOpts) {
txOpts = { rbf: false }
}
const sanitizedUtxos = { utxos: [] }
if (Array.isArray(utxoObj)) {
utxoObj.utxos = utxoObj
}
if (utxoObj.assets) {
sanitizedUtxos.assets = new Map()
utxoObj.assets.forEach(asset => {
const assetObj = {}
if (asset.contract) {
asset.contract = asset.contract.replace(/^0x/, '')
assetObj.contract = Buffer.from(asset.contract, 'hex')
}
assetObj.maxsupply = new BN(asset.maxSupply)
assetObj.precision = asset.decimals
sanitizedUtxos.assets.set(asset.assetGuid, assetObj)
})
}
if (utxoObj.utxos) {
utxoObj.utxos.forEach(utxo => {
// xpub queries will return utxo.address and address queries should use sysFromXpubOrAddress as address is not provided
utxo.address = utxo.address || sysFromXpubOrAddress
if (excludeZeroConf && utxo.confirmations <= 0) {
return
}
const newUtxo = { type: 'LEGACY', address: utxo.address, txId: utxo.txid, path: utxo.path, vout: utxo.vout, value: new BN(utxo.value), locktime: utxo.locktime }
if (newUtxo.address.startsWith(network.bech32)) {
newUtxo.type = 'BECH32'
}
if (utxo.assetInfo) {
newUtxo.assetInfo = { assetGuid: utxo.assetInfo.assetGuid, value: new BN(utxo.assetInfo.value) }
const assetObj = sanitizedUtxos.assets.get(utxo.assetInfo.assetGuid)
// sanity check to ensure sanitizedUtxos.assets has all of the assets being added to UTXO that are assets
if (!assetObj) {
return
}
// not sending this asset (assetMap) and assetWhiteList option if set with this asset will skip this check, by default this check is done and inputs will be skipped
if ((!assetMap || !assetMap.has(utxo.assetInfo.assetGuid)) && (txOpts.assetWhiteList && !txOpts.assetWhiteList.has(utxo.assetInfo.assetGuid))) {
console.log('SKIPPING utxo')
return
}
}
sanitizedUtxos.utxos.push(newUtxo)
})
}
return sanitizedUtxos
}
/* getMemoFromScript
Purpose: Return memo from a script, null otherwise
Param script: Required. OP_RETURN script output
Param memoHeader: Required. Memo prefix, application specific
*/
function getMemoFromScript (script, memoHeader) {
const pos = script.indexOf(memoHeader)
if (pos >= 0) {
return script.slice(pos + memoHeader.length)
}
return null
}
/* getMemoFromOpReturn
Purpose: Return memo from an array of outputs by finding the OP_RETURN output and extracting the memo from the script, return null if not found
Param outputs: Required. Tx output array
Param memoHeader: Optional. Memo prefix, application specific. If not passed in just return the raw opreturn script if found.
*/
function getMemoFromOpReturn (outputs, memoHeader) {
for (let i = 0; i < outputs.length; i++) {
const output = outputs[i]
if (output.script) {
// find opreturn
const chunks = bjs.script.decompile(output.script)
if (chunks[0] === bitcoinops.OP_RETURN) {
if (memoHeader) {
return getMemoFromScript(chunks[1], memoHeader)
} else {
return chunks[1]
}
}
}
}
return null
}
/* getAllocationsFromTx
Purpose: Return allocation information for an asset transaction. Pass through to syscointx-js
Param tx: Required. bitcoinjs transaction
*/
function getAllocationsFromTx (tx) {
return syscointx.getAllocationsFromTx(tx) || []
}
/* setTransactionMemo
Purpose: Return transaction with memo appended to the inside of the OP_RETURN output, return null if not found
Param rawHex: Required. Raw transaction hex
Param memoHeader: Required. Memo prefix, application specific
Param buffMemo: Required. Buffer memo to put into the transaction
*/
function setTransactionMemo (rawHex, memoHeader, buffMemo) {
const txn = bjs.Transaction.fromHex(rawHex)
let processed = false
if (!buffMemo) {
return txn
}
for (let key = 0; key < txn.outs.length; key++) {
const out = txn.outs[key]
const chunksIn = bjs.script.decompile(out.script)
if (chunksIn[0] !== bjs.opcodes.OP_RETURN) {
continue
}
txn.outs.splice(key, 1)
const updatedData = [chunksIn[1], memoHeader, buffMemo]
txn.addOutput(bjs.payments.embed({ data: [Buffer.concat(updatedData)] }).output, 0)
processed = true
break
}
if (processed) {
const memoRet = getMemoFromOpReturn(txn.outs, memoHeader)
if (!memoRet || !memoRet.equals(buffMemo)) {
return null
}
return txn
}
const updatedData = [memoHeader, buffMemo]
txn.addOutput(bjs.payments.embed({ data: [Buffer.concat(updatedData)] }).output, 0)
const memoRet = getMemoFromOpReturn(txn.outs, memoHeader)
if (!memoRet || !memoRet.equals(buffMemo)) {
return null
}
return txn
}
function setPoDA (bjstx, blobData) {
if (!blobData) {
return
}
for (let key = 0; key < bjstx.outs.length; key++) {
const out = bjstx.outs[key]
const chunksIn = bjs.script.decompile(out.script)
if (chunksIn[0] !== bjs.opcodes.OP_RETURN) {
continue
}
bjstx.outs.splice(key, 1)
const updatedData = [chunksIn[1], blobData]
bjstx.addOutput(bjs.payments.embed({ data: [Buffer.concat(updatedData)] }).output, 0)
}
}
function copyPSBT (psbt, networkIn, outputIndexToModify, outputScript) {
const psbtNew = new bjs.Psbt({ network: networkIn })
psbtNew.setVersion(psbt.version)
const txInputs = psbt.txInputs
for (let i = 0; i < txInputs.length; i++) {
const input = txInputs[i]
const dataInput = psbt.data.inputs[i]
const inputObj = {
hash: input.hash,
index: input.index,
sequence: input.sequence,
bip32Derivation: dataInput.bip32Derivation || []
}
if (dataInput.nonWitnessUtxo) {
inputObj.nonWitnessUtxo = dataInput.nonWitnessUtxo
} else if (dataInput.witnessUtxo) {
inputObj.witnessUtxo = dataInput.witnessUtxo
}
psbtNew.addInput(inputObj)
dataInput.unknownKeyVals.forEach(unknownKeyVal => {
psbtNew.addUnknownKeyValToInput(i, unknownKeyVal)
})
}
const txOutputs = psbt.txOutputs
for (let i = 0; i < txOutputs.length; i++) {
const output = txOutputs[i]
if (i === outputIndexToModify) {
psbtNew.addOutput({
script: outputScript,
address: outputScript,
value: output.value
})
} else {
psbtNew.addOutput(output)
}
}
return psbtNew
}
/* HDSigner
Purpose: Manage HD wallet and accounts, connects to SyscoinJS object
Param mnemonic: Required. Bip32 seed phrase
Param password: Optional. Encryption password for local storage on web clients
Param isTestnet: Optional. Is using testnet network?
Param networks: Optional. Defaults to Syscoin network. bitcoinjs-lib network settings for coin being used.
Param SLIP44: Optional. SLIP44 value for the coin, see: https://github.com/satoshilabs/slips/blob/master/slip-0044.md
Param pubTypes: Optional. Defaults to Syscoin ZPub/VPub types. Specific ZPub for bip84 and VPub for testnet
*/
function Signer (password, isTestnet, networks, SLIP44, pubTypes) {
this.isTestnet = isTestnet || false
this.networks = networks || syscoinNetworks
this.password = password
this.SLIP44 = this.isTestnet ? 1 : (SLIP44 !== undefined ? SLIP44 : syscoinSLIP44)
if (!this.isTestnet) {
this.network = this.networks.mainnet || syscoinNetworks.mainnet
} else {
this.network = this.networks.testnet || syscoinNetworks.testnet
}
this.pubTypes = pubTypes || syscoinZPubTypes
this.accounts = new Map() // Map<number, BIP84Account>
this.changeIndex = -1
this.receivingIndex = -1
this.accountIndex = 0
this.setIndexFlag = 0
}
function HDSigner (mnemonicOrZprv, password, isTestnet, networks, SLIP44, pubTypes, bipNum) {
this.Signer = new Signer(password, isTestnet, networks, SLIP44, pubTypes)
this.mnemonicOrZprv = mnemonicOrZprv // can be mnemonic or zprv
this.changeIndex = -1
this.receivingIndex = -1
this.importMethod = 'fromSeed'
// Check if input is an extended private key (zprv/tprv/vprv/xprv)
const isZprv = mnemonicOrZprv.startsWith('zprv') || mnemonicOrZprv.startsWith('tprv') ||
mnemonicOrZprv.startsWith('vprv') || mnemonicOrZprv.startsWith('xprv')
if (isZprv) {
this.importMethod = 'fromBase58'
/* eslint new-cap: ["error", { "newIsCap": false }] */
this.fromMnemonic = new BIP84.fromZPrv(
mnemonicOrZprv,
this.Signer.pubTypes,
this.Signer.networks || syscoinNetworks
)
} else {
/* eslint new-cap: ["error", { "newIsCap": false }] */
this.fromMnemonic = new BIP84.fromMnemonic(mnemonicOrZprv, this.Signer.password, this.Signer.isTestnet, this.Signer.SLIP44, this.Signer.pubTypes, this.Signer.network)
}
// Always initialize with account 0 - state management handled by parent application
this.createAccountAtIndex(0, bipNum, isZprv ? mnemonicOrZprv : undefined)
}
/* signPSBT
Purpose: Sign PSBT with XPUB information from HDSigner
Param psbt: Required. Partially signed transaction object
Param pathIn: Optional. Custom HD Bip32 path useful if signing from a specific address like a multisig
Returns: psbt from bitcoinjs-lib
*/
HDSigner.prototype.signPSBT = async function (psbt, pathIn) {
const txInputs = psbt.txInputs
const rootNode = this.getRootNode()
for (let i = 0; i < txInputs.length; i++) {
const dataInput = psbt.data.inputs[i]
if (pathIn || (dataInput.unknownKeyVals && dataInput.unknownKeyVals.length > 1 && dataInput.unknownKeyVals[1].key.equals(Buffer.from('path')) && (!dataInput.bip32Derivation || dataInput.bip32Derivation.length === 0))) {
const keyPath = pathIn || dataInput.unknownKeyVals[1].value.toString()
// For zprv imports, we need to adjust the path by removing the account-level prefix
const path = this.importMethod === 'fromBase58' ? keyPath.slice(13) : keyPath
const pubkey = this.derivePubKey(path)
const address = this.getAddressFromPubKey(pubkey)
if (pubkey && (pathIn || dataInput.unknownKeyVals[0].value.toString() === address)) {
dataInput.bip32Derivation = [
{
masterFingerprint: rootNode.fingerprint,
path,
pubkey
}]
}
}
}
await psbt.signAllInputsHDAsync(rootNode)
try {
if (psbt.validateSignaturesOfAllInputs()) {
psbt.finalizeAllInputs()
}
} catch (err) {
console.log({ err })
}
return psbt
}
/* sign
Purpose: Create signing information based on HDSigner (if set) and call signPSBT() to actually sign
Param psbt: Required. PSBT object from bitcoinjs-lib
Returns: psbt from bitcoinjs-lib
*/
HDSigner.prototype.sign = async function (psbt, pathIn) {
return await this.signPSBT(psbt, pathIn)
}
/* getMasterFingerprint
Purpose: Get master seed fingerprint used for signing with bitcoinjs-lib PSBT's
Returns: bip32 root master fingerprint
*/
HDSigner.prototype.getMasterFingerprint = function () {
return this.getRootNode().fingerprint
}
HDSigner.prototype.deriveAccount = function (index, bipNum) {
if (bipNum === undefined) {
bipNum = 44
}
if (this.Signer.pubTypes === syscoinZPubTypes ||
this.Signer.pubTypes === bitcoinZPubTypes) {
bipNum = 84
}
return this.fromMnemonic.deriveAccount(index, bipNum)
}
/* setAccountIndex
Purpose: Set HD account based on accountIndex number passed in so HD indexes (change/receiving) will be updated accordingly to this account
Param accountIndex: Required. Account number to use
*/
Signer.prototype.setAccountIndex = function (accountIndex) {
if (!this.accounts.has(accountIndex)) {
console.log('Account does not exist, use createAccountAtIndex to create it first...')
return
}
if (this.accountIndex !== accountIndex) {
this.changeIndex = -1
this.receivingIndex = -1
this.accountIndex = accountIndex
}
}
HDSigner.prototype.setAccountIndex = function (accountIndex) {
this.Signer.setAccountIndex(accountIndex)
}
/* restore method removed - state management handled by parent application */
/* backup method removed - state management handled by parent application */
/* getNewChangeAddress
Purpose: Get new address for sending change to
Param skipIncrement: Optional. If we should not count the internal change index counter (if you want to get the same change address in the future)
Param bipNum: Optional. If you want the address derivated in regard of an specific bip number
Returns: string address used for change outputs
*/
Signer.prototype.getNewChangeAddress = async function (skipIncrement, bipNum) {
if (this.changeIndex === -1 && this.blockbookURL) {
let res = await fetchBackendAccount(this.blockbookURL, this.getAccountXpub(), 'tokens=used&details=tokens', true, this)
if (res === null) {
// try once more in case it fails for some reason
res = await fetchBackendAccount(this.blockbookURL, this.getAccountXpub(), 'tokens=used&details=tokens', true, this)
if (res === null) {
throw new Error('Could not update XPUB change index')
}
}
}
const address = this.createAddress(this.changeIndex + 1, true, bipNum)
if (address) {
if (!skipIncrement) {
this.changeIndex++
}
return address
}
return null
}
HDSigner.prototype.getNewChangeAddress = async function (skipIncrement, bipNum) {
return this.Signer.getNewChangeAddress(skipIncrement, bipNum)
}
/* getNewReceivingAddress
Purpose: Get new address for sending coins to
Param skipIncrement: Optional. If we should not count the internal receiving index counter (if you want to get the same address in the future)
Param bipNum: Optional. If you want the address derivated in regard of an specific bip number
Returns: string address used for receiving outputs
*/
Signer.prototype.getNewReceivingAddress = async function (skipIncrement, bipNum) {
if (this.receivingIndex === -1 && this.blockbookURL) {
let res = await fetchBackendAccount(this.blockbookURL, this.getAccountXpub(), 'tokens=used&details=tokens', true, this)
if (res === null) {
// try once more in case it fails for some reason
res = await fetchBackendAccount(this.blockbookURL, this.getAccountXpub(), 'tokens=used&details=tokens', true, this)
if (res === null) {
throw new Error('Could not update XPUB receiving index')
}
}
}
const address = this.createAddress(this.receivingIndex + 1, false, bipNum)
if (address) {
if (!skipIncrement) {
this.receivingIndex++
}
return address
}
return null
}
HDSigner.prototype.getNewReceivingAddress = async function (skipIncrement, bipNum) {
return this.Signer.getNewReceivingAddress(skipIncrement, bipNum)
}
HDSigner.prototype.createAccountAtIndex = function (index, bipNum, zprv) {
if (index < 0 || index > 2147483647) { // BIP32 limit
throw new Error('Invalid account index')
}
this.Signer.changeIndex = -1
this.Signer.receivingIndex = -1
const zPrivate = zprv || (this.mnemonicOrZprv.startsWith('zprv') || this.mnemonicOrZprv.startsWith('tprv') ||
this.mnemonicOrZprv.startsWith('vprv') || this.mnemonicOrZprv.startsWith('xprv'))
? this.mnemonicOrZprv
: null
const child = zPrivate || this.deriveAccount(index, bipNum)
this.Signer.accountIndex = index
/* eslint new-cap: ["error", { "newIsCap": false }] */
this.Signer.accounts.set(index, new BIP84.fromZPrv(child, this.Signer.pubTypes, this.Signer.networks || syscoinNetworks))
return index
}
HDSigner.prototype.createAccount = function (bipNum, zprv) {
// Find the next index after the highest existing index
let nextIndex = 0
if (this.Signer.accounts.size > 0) {
const maxIndex = Math.max(...this.Signer.accounts.keys())
nextIndex = maxIndex + 1
}
return this.createAccountAtIndex(nextIndex, bipNum, zprv)
}
/* getAccountXpub
Purpose: Get XPUB for account, useful for public provider lookups based on XPUB accounts
Returns: string representing hex XPUB
*/
Signer.prototype.getAccountXpub = function () {
const account = this.accounts.get(this.accountIndex)
if (!account) {
throw new Error(`Account ${this.accountIndex} not found`)
}
return account.getAccountPublicKey()
}
HDSigner.prototype.getAccountXpub = function () {
return this.Signer.getAccountXpub()
}
/* setLatestIndexesFromXPubTokens
Purpose: Sets the change and receiving indexes from XPUB tokens passed in, from a backend provider response
Param tokens: Required. XPUB tokens from provider response to XPUB account details.
*/
Signer.prototype.setLatestIndexesFromXPubTokens = function (tokens) {
this.setIndexFlag++
// concurrency check make sure you don't execute this logic while it is already running as signer state is updated here
// also in case there is some bug in the code that prevents it ever from being called because this.setIndexFlag = 0 doesn't happen we
// stop worrying about the flag after it reached 100 attempts
if (this.setIndexFlag > 1 && this.setIndexFlag < 100) {
return
}
if (tokens) {
tokens.forEach(token => {
if (!token.transfers || !token.path) {
return
}
const transfers = parseInt(token.transfers, 10)
if (token.path && transfers > 0) {
const splitPath = token.path.split('/')
if (splitPath.length >= 6) {
const change = parseInt(splitPath[4], 10)
const index = parseInt(splitPath[5], 10)
if (change === 1) {
if (index > this.changeIndex) {
this.changeIndex = index
}
} else if (index > this.receivingIndex) {
this.receivingIndex = index
}
}
}
})
}
this.setIndexFlag = 0
}
HDSigner.prototype.setLatestIndexesFromXPubTokens = function (tokens) {
this.Signer.setLatestIndexesFromXPubTokens(tokens)
}
/* getAccountIndexes
Purpose: Get all account indexes that have been created
Returns: Array of account indexes sorted numerically
*/
Signer.prototype.getAccountIndexes = function () {
return Array.from(this.accounts.keys()).sort((a, b) => a - b)
}
HDSigner.prototype.getAccountIndexes = function () {
return this.Signer.getAccountIndexes()
}
Signer.prototype.createAddress = function (addressIndex, isChange, bipNum) {
if (bipNum === undefined) {
bipNum = 44
}
if (this.pubTypes === syscoinZPubTypes ||
this.pubTypes === bitcoinZPubTypes) {
bipNum = 84
}
const account = this.accounts.get(this.accountIndex)
if (!account) {
throw new Error(`Account ${this.accountIndex} not found`)
}
return account.getAddress(addressIndex, isChange, bipNum)
}
HDSigner.prototype.createAddress = function (addressIndex, isChange, bipNum) {
return this.Signer.createAddress(addressIndex, isChange, bipNum)
}
/* createKeypair
Purpose: Sets the change and receiving indexes from XPUB tokens passed in, from a backend provider response
Param addressIndex: Optional. HD path address index. If not provided uses the stored change/recv indexes for the last path prefix
Param isChange: Optional. HD path change marker
Returns: bitcoinjs-lib keypair derived from address index and change market
*/
HDSigner.prototype.createKeypair = function (addressIndex, isChange) {
let recvIndex = isChange ? this.changeIndex : this.receivingIndex
if (addressIndex) {
recvIndex = addressIndex
}
const account = this.Signer.accounts.get(this.Signer.accountIndex)
if (!account) {
throw new Error(`Account ${this.Signer.accountIndex} not found`)
}
return account.getKeypair(recvIndex, isChange)
}
/* getHDPath
Purpose: Gets current HDPath from signer context
Param addressIndex: Optional. HD path address index. If not provided uses the stored change/recv indexes for the last path prefix
Param isChange: Optional. HD path change marker
Param bipNum: Optional. BIP number to use for HD path. Defaults to 44
Returns: bip32 path string
*/
Signer.prototype.getHDPath = function (addressIndex, isChange, bipNum) {
const changeNum = isChange ? '1' : '0'
if (bipNum === undefined) {
bipNum = 44
}
if (this.pubTypes === syscoinZPubTypes ||
this.pubTypes === bitcoinZPubTypes) {
bipNum = 84
}
let recvIndex = isChange ? this.changeIndex : this.receivingIndex
if (addressIndex) {
recvIndex = addressIndex
}
const keypath = 'm/' + bipNum + "'/" + this.SLIP44 + "'/" + this.accountIndex + "'/" + changeNum + '/' + recvIndex
return keypath
}
HDSigner.prototype.getHDPath = function (addressIndex, isChange, bipNum) {
return this.Signer.getHDPath(addressIndex, isChange, bipNum)
}
/* getAddressFromKeypair
Purpose: Takes keypair and gives back a p2wpkh address
Param keyPair: Required. bitcoinjs-lib keypair
Returns: string p2wpkh address
*/
HDSigner.prototype.getAddressFromKeypair = function (keyPair) {
const payment = bjs.payments.p2wpkh({
pubkey: keyPair.publicKey,
network: this.Signer.network
})
return payment.address
}
/* getAddressFromPubKey
Purpose: Takes pubkey and gives back a p2wpkh address
Param pubkey: Required. bitcoinjs-lib public key
Returns: string p2wpkh address
*/
Signer.prototype.getAddressFromPubKey = function (pubkey) {
const payment = bjs.payments.p2wpkh({
pubkey,
network: this.network
})
return payment.address
}
HDSigner.prototype.getAddressFromPubKey = function (pubkey) {
return this.Signer.getAddressFromPubKey(pubkey)
}
/* deriveKeypair
Purpose: Takes an HD path and derives keypair from it
Param keypath: Required. HD BIP32 path of key desired based on internal seed and network
Returns: bitcoinjs-lib keypair
*/
HDSigner.prototype.deriveKeypair = function (keypath) {
const rootNode = this.getRootNode()
const keyPair = rootNode.derivePath(keypath)
return !keyPair ? null : keyPair
}
/* derivePubKey
Purpose: Takes an HD path and derives keypair from it, returns pubkey
Param keypath: Required. HD BIP32 path of key desired based on internal seed and network
Returns: bitcoinjs-lib pubkey
*/
HDSigner.prototype.derivePubKey = function (keypath) {
const rootNode = this.getRootNode()
const keyPair = rootNode.derivePath(keypath)
return !keyPair ? null : keyPair.publicKey
}
/* getRootNode
Purpose: Returns HDSigner's BIP32 root node
Returns: BIP32 root node representing the seed
*/
HDSigner.prototype.getRootNode = function () {
// For fromBase58 (extended private keys), create network with appropriate version bytes
let network = this.Signer.network
if (this.importMethod === 'fromBase58') {
// Create a custom network with the zprv/zpub version bytes for parsing
const baseNetwork = this.Signer.isTestnet ? bitcoinNetworks.testnet : bitcoinNetworks.mainnet
const pubTypesAll = this.Signer.pubTypes || bitcoinZPubTypes
const pubTypes = this.Signer.isTestnet ? pubTypesAll.testnet : pubTypesAll.mainnet
network = {
...baseNetwork,
bip32: {
public: parseInt(pubTypes.vpub || pubTypes.zpub, 16),
private: parseInt(pubTypes.vprv || pubTypes.zprv, 16)
}
}
}
return bjs.bip32[this.importMethod](
this.fromMnemonic.seed || this.mnemonicOrZprv,
network
)
}
/* Override PSBT stuff so fee check isn't done as Syscoin Allocation burns outputs > inputs */
function scriptWitnessToWitnessStack (buffer) {
let offset = 0
function readSlice (n) {
offset += n
return buffer.slice(offset - n, offset)
}
function readVarInt () {
const vi = varuint.decode(buffer, offset)
offset += varuint.decode.bytes
return vi
}
function readVarSlice () {
return readSlice(readVarInt())
}
function readVector () {
const count = readVarInt()
const vector = []
for (let i = 0; i < count; i++) vector.push(readVarSlice())
return vector
}
return readVector()
}
function addNonWitnessTxCache (cache, input, inputIndex) {
cache.__NON_WITNESS_UTXO_BUF_CACHE[inputIndex] = input.nonWitnessUtxo
const tx = bjs.Transaction.fromBuffer(input.nonWitnessUtxo)
cache.__NON_WITNESS_UTXO_TX_CACHE[inputIndex] = tx
const self = cache
const selfIndex = inputIndex
delete input.nonWitnessUtxo
Object.defineProperty(input, 'nonWitnessUtxo', {
enumerable: true,
get () {
const buf = self.__NON_WITNESS_UTXO_BUF_CACHE[selfIndex]
const txCache = self.__NON_WITNESS_UTXO_TX_CACHE[selfIndex]
if (buf !== undefined) {
return buf
} else {
const newBuf = txCache.toBuffer()
self.__NON_WITNESS_UTXO_BUF_CACHE[selfIndex] = newBuf
return newBuf
}
},
set (data) {
self.__NON_WITNESS_UTXO_BUF_CACHE[selfIndex] = data
}
})
}
function nonWitnessUtxoTxFromCache (cache, input, inputIndex) {
const c = cache.__NON_WITNESS_UTXO_TX_CACHE
if (!c[inputIndex]) {
addNonWitnessTxCache(cache, input, inputIndex)
}
return c[inputIndex]
}
// override of psbt.js inputFinalizeGetAmts without fee < 0 check
function inputFinalizeGetAmts (inputs, tx, cache, mustFinalize) {
let inputAmount = 0
inputs.forEach((input, idx) => {
if (mustFinalize && input.f