UNPKG

syscoinjs-lib

Version:

A transaction creation library interfacing with coin selection for Syscoin.

1,337 lines (1,250 loc) 73 kB
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