syscoinjs-lib
Version:
A transaction creation library interfacing with coin selection for Syscoin.
1,337 lines (1,250 loc) • 73 kB
JavaScript
const axios = require('axios')
const BN = require('bn.js')
const BIP84 = require('./bip84-replacement')
const bjs = require('bitcoinjs-lib')
// Initialize ECC backend for bitcoinjs-lib v7 (extension-friendly, no WASM)
// Use @bitcoinerlab/secp256k1 exclusively to avoid native tiny-secp256k1 in browser/Next builds
const ecc = require('@bitcoinerlab/secp256k1')
if (ecc && typeof bjs.initEccLib === 'function') {
bjs.initEccLib(ecc)
}
const { BIP32Factory } = require('bip32')
const _ecpair = require('ecpair')
const ECPairFactory = _ecpair && (_ecpair.ECPairFactory || _ecpair.default || _ecpair)
// eslint-disable-next-line new-cap
const bip32 = BIP32Factory(ecc)
// eslint-disable-next-line new-cap
const ECPair = ECPairFactory(ecc)
// Maintain backwards compatibility for downstream code expecting these on bitcoinjs namespace
// Note: bitcoinjs-lib v7 does not export ECPair/bip32; we attach factories for consumers
// that access via syscoinjs.utils.bitcoinjs.ECPair / .bip32
// This is safe as long as ECC is initialized above.
bjs.ECPair = ECPair
bjs.bip32 = bip32
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) => {
if (value == null) return new BN(0)
if (BN.isBN && BN.isBN(value)) return value
// ethers.js BigNumber (v5)
if (typeof value === 'object' && value._isBigNumber && typeof value.toString === 'function') {
return new BN(value.toString())
}
// BigInt support
if (typeof value === 'bigint') {
return new BN(value.toString())
}
// Hex or decimal string
if (typeof value === 'string') {
if (value.startsWith('0x') || value.startsWith('0X')) {
return new BN(value.slice(2), 16)
}
return new BN(value, 10)
}
// Numbers (note: limited precision for large values)
if (typeof value === 'number') {
return new BN(value.toString(), 10)
}
// Buffers / typed arrays / array-like
if (Buffer.isBuffer(value)) {
return new BN(value.toString('hex'), 16)
}
if (value && (value instanceof Uint8Array || Array.isArray(value))) {
return new BN(Buffer.from(value).toString('hex'), 16)
}
// Fallback
return new BN(value)
},
hexToNumberString: (hex) => new BN(hex.replace(/^0x/i, ''), 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: false }
// Detect availability of fetch in a safe, cross-environment way
const hasFetch = (typeof globalThis !== 'undefined' && typeof globalThis.fetch === 'function')
// 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(/\/$/, '')
}
if (hasFetch) {
const response = await globalThis.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(/\/$/, '')
}
if (hasFetch) {
const request = await globalThis.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
if (hasFetch) {
const response = await globalThis.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(/\/$/, '')
}
// Always URL-encode in case identifier is a descriptor containing special characters
let url = blockbookURL + '/api/v2/utxo/' + encodeURIComponent(addressOrXpub)
if (options) {
url += '?' + options
}
if (hasFetch) {
const response = await globalThis.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-encode identifier to support descriptors and special characters
url += encodeURIComponent(addressOrXpub)
if (options) {
url += '?' + options
}
if (hasFetch) {
const response = await globalThis.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(/\/$/, '')
}
if (hasFetch) {
const requestOptions = {
method: 'POST',
body: txHex
}
const response = await globalThis.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(/\/$/, '')
}
if (hasFetch) {
const response = await globalThis.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(/\/$/, '')
}
if (hasFetch) {
const response = await globalThis.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(/\/$/, '')
}
if (hasFetch) {
const response = await globalThis.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
}
if (hasFetch) {
const response = await globalThis.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
})
}
/* checkPubkeyInScript
Purpose: Check if a pubkey is used in a witnessScript or redeemScript
Param script: The witness or redeem script buffer
Param pubkey: The public key buffer to search for
Returns: boolean indicating if pubkey is found in script
*/
function checkPubkeyInScript (script, pubkey) {
if (!script || !pubkey) return false
try {
const scriptBuf = Buffer.from(script)
const pubkeyBuf = Buffer.from(pubkey)
const decompiled = bjs.script.decompile(scriptBuf)
if (!decompiled) return false
for (const chunk of decompiled) {
// Check if chunk is a buffer-like object and matches our pubkey
if (chunk && (Buffer.isBuffer(chunk) || chunk instanceof Uint8Array)) {
const chunkBuf = Buffer.from(chunk)
if (chunkBuf.length === pubkeyBuf.length && chunkBuf.equals(pubkeyBuf)) {
return true
}
}
}
} catch (_) {}
return false
}
/* checkSimpleScriptOwnership
Purpose: Check if a pubkey controls a simple single-key script (P2PKH, P2WPKH, P2SH-P2WPKH)
Param scriptBuffer: The output script to check
Param pubkey: The public key to test
Param network: The network object
Param psbt: The PSBT object (to potentially set redeemScript)
Param inputIndex: The input index (for setting redeemScript)
Returns: boolean indicating if pubkey controls this script
*/
function checkSimpleScriptOwnership (scriptBuffer, pubkey, network, psbt, inputIndex) {
if (!scriptBuffer || !pubkey) return false
const scriptBuf = Buffer.from(scriptBuffer)
const pubkeyBuf = Buffer.from(pubkey)
// Try P2PKH
try {
const p2pkh = bjs.payments.p2pkh({ pubkey: pubkeyBuf, network })
if (p2pkh.output && Buffer.from(p2pkh.output).equals(scriptBuf)) {
return true
}
} catch (_) {}
// Try P2WPKH
try {
const p2wpkh = bjs.payments.p2wpkh({ pubkey: pubkeyBuf, network })
if (p2wpkh.output && Buffer.from(p2wpkh.output).equals(scriptBuf)) {
return true
}
} catch (_) {}
// Try P2SH-P2WPKH
try {
const p2wpkh = bjs.payments.p2wpkh({ pubkey: pubkeyBuf, network })
const p2sh = bjs.payments.p2sh({ redeem: p2wpkh, network })
if (p2sh.output && Buffer.from(p2sh.output).equals(scriptBuf)) {
// Also set the redeemScript for P2SH-P2WPKH if not already set
if (psbt && inputIndex !== undefined && psbt.data && psbt.data.inputs &&
psbt.data.inputs[inputIndex] && !psbt.data.inputs[inputIndex].redeemScript && p2wpkh.output) {
psbt.data.inputs[inputIndex].redeemScript = Buffer.from(p2wpkh.output)
}
return true
}
} catch (_) {}
return false
}
/* shouldAddBip32Derivation
Purpose: Determine if BIP32 derivation should be added for a non-taproot input
Param dataInput: The PSBT input data
Param scriptBuffer: The output script buffer
Param pubkey: The public key
Param network: The network object
Param psbt: The PSBT object (for setting redeemScript)
Param inputIndex: The input index
Returns: boolean indicating if derivation should be added
*/
function shouldAddBip32Derivation (dataInput, scriptBuffer, pubkey, network, psbt, inputIndex) {
// Check multisig/script cases first
if (dataInput.witnessScript || dataInput.redeemScript) {
const script = dataInput.witnessScript || dataInput.redeemScript
return checkPubkeyInScript(script, pubkey)
}
// Check simple single-key cases
return checkSimpleScriptOwnership(scriptBuffer, pubkey, network, psbt, inputIndex)
}
/* setTaprootMetadata
Purpose: Set tapInternalKey for simple single-key P2TR inputs that match the given public key
Note: This only works for simple key-path spends. Complex cases (script paths, MuSig) require
metadata to be set during PSBT creation with full knowledge of the script structure.
Param psbt: Required. Partially signed transaction object
Param pubkey: Required. The public key (33 or 32 bytes)
Param network: Required. bitcoinjs-lib Network object
Returns: void (modifies psbt in place)
*/
function setTaprootMetadata (psbt, pubkey, network) {
if (!psbt || !psbt.data || !Array.isArray(psbt.data.inputs) || !pubkey) return
const xOnly = pubkey.length === 33 ? pubkey.slice(1, 33) : pubkey
// Create P2TR payment to get the expected output script for simple single-key case
const p2tr = bjs.payments.p2tr({
internalPubkey: xOnly,
network
})
// Only set tapInternalKey for inputs that match this pubkey's P2TR address
for (let i = 0; i < psbt.data.inputs.length; i++) {
const dataInput = psbt.data.inputs[i]
const script = dataInput && dataInput.witnessUtxo && dataInput.witnessUtxo.script
// Convert script to Buffer if it's a Uint8Array for consistent comparison
const scriptBuffer = script ? Buffer.from(script) : null
// Check if this is a P2TR input
const isP2TR = scriptBuffer && scriptBuffer.length === 34 &&
scriptBuffer[0] === bjs.opcodes.OP_1 && scriptBuffer[1] === 0x20
if (!isP2TR) continue
// Strategy for non-HD signers (like WIF):
// 1. If tapInternalKey is already set, check if it matches our key
// 2. If not set, check if this is a simple single-key output we control
// 3. For complex cases, tapInternalKey should already be set
if (dataInput.tapInternalKey) {
// Internal key already set - for complex cases (script paths, MuSig),
// we can't determine if we're a participant without additional info
const internalKey = Buffer.from(dataInput.tapInternalKey)
if (!internalKey.equals(xOnly)) {
// Not a simple single-key with our key - skip
continue
}
// Our key matches the internal key, we're good
} else {
// No internal key set - check if this is a simple single-key P2TR
// This check ONLY works for simple key-path spends
// Ensure both are Buffers for proper comparison (p2tr.output might be Uint8Array)
const p2trOutputBuffer = p2tr.output ? Buffer.from(p2tr.output) : null
if (!p2trOutputBuffer || !scriptBuffer.equals(p2trOutputBuffer)) {
// Output doesn't match our single key - could be:
// - Script path spend (tweaked with merkle root)
// - MuSig (aggregated key)
// - Someone else's input
continue
}
// Simple single-key P2TR that matches our key
psbt.data.inputs[i].tapInternalKey = xOnly
}
}
}
/* getPathFromInput
Purpose: Extract the HD path from input's proprietary data
Param dataInput: The PSBT input data
Returns: string path or null if not found
*/
function getPathFromInput (dataInput) {
if (!dataInput?.unknownKeyVals) return null
const pathData = dataInput.unknownKeyVals.find(kv =>
kv.key && kv.key.toString() === 'path'
)
return pathData?.value ? pathData.value.toString() : null
}
/* getScriptFromInput
Purpose: Extract the script from a PSBT input (witness or non-witness)
Param dataInput: The PSBT input data
Param psbt: The PSBT object (for non-witness UTXOs)
Param inputIndex: The input index
Returns: Buffer of the script or null
*/
function getScriptFromInput (dataInput, psbt, inputIndex) {
// Try witness UTXO first
if (dataInput?.witnessUtxo?.script) {
return Buffer.from(dataInput.witnessUtxo.script)
}
// Try non-witness UTXO
if (dataInput?.nonWitnessUtxo) {
try {
const tx = bjs.Transaction.fromBuffer(dataInput.nonWitnessUtxo)
const vout = psbt.txInputs[inputIndex].index
if (tx.outs[vout]?.script) {
return Buffer.from(tx.outs[vout].script)
}
} catch (_) {}
}
return null
}
/* isP2TRScript
Purpose: Check if a script is a P2TR (Taproot) script
Param scriptBuffer: The script buffer to check
Returns: boolean indicating if script is P2TR
*/
function isP2TRScript (scriptBuffer) {
return scriptBuffer && scriptBuffer.length === 34 &&
scriptBuffer[0] === bjs.opcodes.OP_1 && scriptBuffer[1] === 0x20
}
/* handleNonTaprootDerivation
Purpose: Handle BIP32 derivation for non-taproot inputs
Param psbt: The PSBT object
Param inputIndex: The input index
Param dataInput: The input data
Param scriptBuffer: The script buffer
Param child: The derived child key
Param keyPair: The signing keypair
Param path: The derivation path
Param network: The network object
Returns: void (modifies psbt in place)
*/
function handleNonTaprootDerivation (psbt, inputIndex, dataInput, scriptBuffer, child, keyPair, path, network) {
// Check if we already have BIP-32 derivation for this input
const existingDerivation = dataInput.bip32Derivation?.find(d =>
d.masterFingerprint && Buffer.from(d.masterFingerprint).equals(keyPair.fingerprint)
)
if (!existingDerivation) {
// Check if we should add derivation for this input
const shouldAdd = shouldAddBip32Derivation(dataInput, scriptBuffer, child.publicKey, network, psbt, inputIndex)
if (shouldAdd) {
if (!dataInput.bip32Derivation) {
psbt.data.inputs[inputIndex].bip32Derivation = []
}
psbt.data.inputs[inputIndex].bip32Derivation.push({
masterFingerprint: keyPair.fingerprint,
path,
pubkey: child.publicKey
})
}
}
}
/* handleTaprootDerivation
Purpose: Handle taproot (BIP371) derivation for P2TR inputs
Param psbt: The PSBT object
Param inputIndex: The input index
Param dataInput: The input data
Param scriptBuffer: The script buffer
Param child: The derived child key
Param keyPair: The signing keypair
Param path: The derivation path
Param network: The network object
Returns: void (modifies psbt in place)
*/
function handleTaprootDerivation (psbt, inputIndex, dataInput, scriptBuffer, child, keyPair, path, network) {
const xOnly = child.publicKey.length === 33 ? child.publicKey.slice(1, 33) : child.publicKey
// Check if we already have derivation info for this input
const existingDerivation = dataInput.tapBip32Derivation?.find(d =>
d.masterFingerprint && Buffer.from(d.masterFingerprint).equals(keyPair.fingerprint)
)
if (existingDerivation) {
// We're already identified as a signer, ensure tapInternalKey is set if needed
if (!dataInput.tapInternalKey && existingDerivation.pubkey) {
psbt.data.inputs[inputIndex].tapInternalKey = Buffer.from(existingDerivation.pubkey)
}
return
}
// If tapInternalKey is already set, check if we should add our derivation
if (dataInput.tapInternalKey) {
const internalKey = Buffer.from(dataInput.tapInternalKey)
// Check if our derived key matches the internal key (simple key-path case)
if (internalKey.equals(xOnly)) {
// This is a simple key-path spend with our key as the internal key
if (!dataInput.tapBip32Derivation) {
psbt.data.inputs[inputIndex].tapBip32Derivation = []
}
psbt.data.inputs[inputIndex].tapBip32Derivation.push({
masterFingerprint: keyPair.fingerprint,
path,
pubkey: xOnly,
leafHashes: []
})
}
return
}
// No tapInternalKey set - try to detect simple single-key case
const p2tr = bjs.payments.p2tr({
internalPubkey: xOnly,
network
})
// Check if our single key would generate this output (simple case only)
// Ensure both are Buffers for proper comparison (p2tr.output might be Uint8Array)
const p2trOutputBuffer = p2tr.output ? Buffer.from(p2tr.output) : null
if (p2trOutputBuffer && scriptBuffer.equals(p2trOutputBuffer)) {
// This is a simple single-key P2TR that we control
psbt.data.inputs[inputIndex].tapInternalKey = xOnly
if (!dataInput.tapBip32Derivation) {
psbt.data.inputs[inputIndex].tapBip32Derivation = []
}
psbt.data.inputs[inputIndex].tapBip32Derivation.push({
masterFingerprint: keyPair.fingerprint,
path,
pubkey: xOnly,
leafHashes: []
})
}
}
/* setDerivationsForHDSigner
Purpose: Set all BIP32/BIP371 derivations for HD signers
Param psbt: The PSBT object
Param keyPair: The HD signing keypair
Param network: The network object
Returns: void (modifies psbt in place)
*/
function setDerivationsForHDSigner (psbt, keyPair, network) {
if (!keyPair?.fingerprint || !keyPair?.publicKey) return
if (typeof keyPair.derivePath !== 'function') return
for (let i = 0; i < psbt.data.inputs.length; i++) {
const dataInput = psbt.data.inputs[i]
// Get path from proprietary data
const path = getPathFromInput(dataInput)
if (!path) continue
// Derive the child key for this path
const child = keyPair.derivePath(path)
if (!child?.publicKey) continue
// Get the script from the input
const scriptBuffer = getScriptFromInput(dataInput, psbt, i)
if (!scriptBuffer) continue
// Check if this is a taproot input
if (isP2TRScript(scriptBuffer)) {
handleTaprootDerivation(psbt, i, dataInput, scriptBuffer, child, keyPair, path, network)
} else {
handleNonTaprootDerivation(psbt, i, dataInput, scriptBuffer, child, keyPair, path, network)
}
}
}
/* finalizePSBT
Purpose: Finalize PSBT inputs after signing
Param psbt: The PSBT object
Returns: void (modifies psbt in place)
*/
function finalizePSBT (psbt) {
if (psbt._skipFinalization) return
try {
const validator = (pubkey, msghash, signature) => {
// For Schnorr signatures (Taproot), use appropriate verification
const isSchnorr = signature && signature.length === 64 && pubkey && pubkey.length === 32
if (isSchnorr) {
return ecc.verifySchnorr(msghash, pubkey, signature)
}
// ECDSA
try {
return ECPair.fromPublicKey(pubkey).verify(msghash, signature)
} catch (e) {
return false
}
}
// Try to validate and finalize all inputs at once
try {
const allValid = psbt.validateSignaturesOfAllInputs(validator)
if (allValid) {
psbt.finalizeAllInputs()
} else {
// If not all are valid, try to finalize individually (for partial signing)
for (let i = 0; i < psbt.data.inputs.length; i++) {
try {
psbt.finalizeInput(i, psbt.getFinalScripts)
} catch (e) {
// Silent fail - input may already be finalized or not ready
}
}
}
} catch (e) {
// If validation throws, try individual finalization as fallback
for (let i = 0; i < psbt.data.inputs.length; i++) {
try {
psbt.finalizeInput(i)
} catch (e) {
// Silent fail - input may already be finalized or not ready
}
}
}
} catch (err) {
// Silent fail - validation/finalization may not be critical
}
}
async function signWithKeyPair (psbt, keyPair, network) {
// For HD signers, set all derivations before signing
try {
setDerivationsForHDSigner(psbt, keyPair, network)
} catch (_) {}
// Apply polyfill for Taproot signing support
const { applyPR2137 } = require('./polyfills/psbt-pr2137')
applyPR2137(psbt)
// Sign all inputs with the polyfilled method
psbt.signAllInputsHD(keyPair)
// Finalize the transaction (optional)
finalizePSBT(psbt)
return psbt
}
/* 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) {
// Ensure Taproot metadata for key-path spends when missing (WIF/imported cases)
const keyPair = ECPair.fromWIF(wif, network)
try {
setTaprootMetadata(psbt, keyPair.publicKey, network)
} catch (_) {}
return await signWithKeyPair(psbt, keyPair, network)
}
/* 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: [], assets: new Map() }
if (Array.isArray(utxoObj)) {
utxoObj.utxos = utxoObj
}
if (utxoObj.assets) {
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) {
// Normalize to Buffer for v7 (scripts may be Uint8Array)
if (!Buffer.isBuffer(script)) script = Buffer.from(script)
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 Buffer.isBuffer(chunks[1]) ? chunks[1] : Buffer.from(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, BigInt(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, BigInt(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, BigInt(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)
// Copy Taproot and other PSBT input fields
if (dataInput.tapInternalKey) { psbtNew.data.inputs[i].tapInternalKey = dataInput.tapInternalKey }
if (dataInput.tapBip32Derivation) { psbtNew.data.inputs[i].tapBip32Derivation = dataInput.tapBip32Derivation.slice() }
if (dataInput.tapLeafScript) { psbtNew.data.inputs[i].tapLeafScript = dataInput.tapLeafScript.slice() }
if (dataInput.tapMerkleRoot) { psbtNew.data.inputs[i].tapMerkleRoot = dataInput.tapMerkleRoot }
if (dataInput.sighashType !== undefined) { psbtNew.data.inputs[i].sighashType = dataInput.sighashType }
if (dataInput.finalScriptWitness) { psbtNew.data.inputs[i].finalScriptWitness = dataInput.finalScriptWitness }
if (dataInput.finalScriptSig) { psbtNew.data.inputs[i].finalScriptSig = dataInput.finalScriptSig }
if (dataInput.unknownKeyVals) {
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)
// Copy output unknowns and asset metadata if present
const dataOutput = psbt.data.outputs[i]
if (dataOutput) {
if (dataOutput.tapBip32Derivation) { psbtNew.data.outputs[i].tapBip32Derivation = dataOutput.tapBip32Derivation.slice() }
if (dataOutput.unknownKeyVals) {
dataOutput.unknownKeyVals.forEach(unknownKeyVal => {
psbtNew.addUnknownKeyValToOutput(i, unknownKeyVal)
})
}
}
}
}
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: Opti