bitcoincashwallet/scripts/bchOperator.js
void-57 b9d76ea9f0 feat(bch-wallet): complete Bitcoin Cash wallet migration
Implemented full wallet functionality for Bitcoin Cash:
- Balance Checking: Real-time balance fetching via redundant APIs (Blockchain.com, Blockcypher, FullStack.cash).
- Address Compatibility: Full support for both CashAddr (Bech32) and Legacy formats with automatic bidirectional conversion.
- Transaction Signing: Implemented BIP-143 signature hashing with SIGHASH_FORKID (0x41) for replay protection.
- Transaction History: Robust fetching from multi-provider APIs with detailed transaction formatting.
- Sending Functionality: Secure BCH transfers with dynamic fee estimation and manual ECDSA signing.
- UI Integration: Updated currency selectors and address management modals for Bitcoin Cash specifics.
2026-01-31 03:46:07 +05:30

1222 lines
50 KiB
JavaScript

(function (EXPORTS) { //bchOperator v1.0.0
/* BCH Crypto and API Operator */
/* Based on btcOperator, modified for Bitcoin Cash (BCH) */
const bchOperator = EXPORTS;
const SATOSHI_IN_BCH = 1e8;
// BCH uses SIGHASH_FORKID (0x40) combined with SIGHASH_ALL (0x01) = 0x41
const SIGHASH_ALL = 0x01;
const SIGHASH_FORKID = 0x40;
const BCH_SIGHASH = SIGHASH_ALL | SIGHASH_FORKID; // 0x41
const util = bchOperator.util = {};
util.Sat_to_BCH = value => parseFloat((value / SATOSHI_IN_BCH).toFixed(8));
util.BCH_to_Sat = value => parseInt(value * SATOSHI_IN_BCH);
const ALPHABET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l";
const parseDate = util.parseDate = (d) => {
if (!d) return null;
if (typeof d === 'number') return d;
let s = String(d);
if (s.includes(' ') && !s.includes('Z') && !s.includes('+')) {
return new Date(s.replace(' ', 'T') + 'Z').getTime();
}
return new Date(d).getTime();
};
const checkIfTor = bchOperator.checkIfTor = () => {
return fetch('https://check.torproject.org/api/ip')
.then(res => res.json())
.then(res => {
return res.IsTor
}).catch(e => {
console.error(e)
return false
})
}
let isTor = false;
checkIfTor().then(result => isTor = result);
async function post(url, data, { asText = false, contentType = 'application/json' } = {}) {
try {
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': contentType
},
body: contentType === 'application/json' && typeof data !== 'string' ? JSON.stringify(data) : data
})
if (response.ok) {
return asText ? await response.text() : await response.json()
} else {
throw response
}
} catch (e) {
throw e
}
}
const APIs = bchOperator.APIs = [
{
url: 'https://api.blockchain.info/haskoin-store/bch/',
name: 'Blockchain.com (Haskoin)',
balance({ addr }) {
const cashAddr = bchOperator.toCashAddr(addr).replace('bitcoincash:', '');
return fetch_api(`address/${cashAddr}/balance`, { url: this.url })
.then(result => util.Sat_to_BCH(result.confirmed + result.unconfirmed))
},
unspent({ addr, allowUnconfirmedUtxos = false }) {
const cashAddr = bchOperator.toCashAddr(addr).replace('bitcoincash:', '');
return fetch_api(`address/${cashAddr}/unspent`, { url: this.url })
.then(result => {
const utxos = (result || []).map(u => ({
tx_hash: u.txid,
tx_output_n: u.output !== undefined ? u.index : u.index, // Haskoin uses index
value: u.value,
date: u.block ? u.block.timestamp : Date.now(),
confirmations: u.block ? u.block.height : 0,
script: u.pkscript
}));
return formatUtxos(utxos, allowUnconfirmedUtxos);
})
},
tx({ txid }) {
return fetch_api(`transaction/${txid}`, { url: this.url })
.then(result => formatTx(result))
},
txs({ addr }) {
const cashAddr = bchOperator.toCashAddr(addr).replace('bitcoincash:', '');
return fetch_api(`address/${cashAddr}/transactions`, { url: this.url })
.then(async result => {
const txs = result || [];
// We'll fetch all transaction details in parallel
// Fetch full details for each transaction
const fullTxs = await Promise.all(txs.map(t =>
fetch_api(`transaction/${t.txid}`, { url: this.url })
.catch(e => null) // Ignore failed fetches
));
return fullTxs.filter(t => t).map(res => formatTx(res));
})
},
latestBlock() {
return fetch_api(`block/best`, { url: this.url })
.then(result => result.height);
}
},
{
url: 'https://api.blockcypher.com/v1/bch/main/',
name: 'Blockcypher',
balance({ addr }) {
return fetch_api(`addrs/${addr}/balance`, { url: this.url })
.then(result => util.Sat_to_BCH(result.balance + result.unconfirmed_balance))
},
unspent({ addr, allowUnconfirmedUtxos = false }) {
return fetch_api(`addrs/${addr}?unspentOnly=true`, { url: this.url })
.then(result => {
const utxos = (result.txrefs || []).concat(result.unconfirmed_txrefs || []).map(u => ({
tx_hash: u.tx_hash,
tx_output_n: u.tx_output_n,
value: u.value,
confirmations: u.confirmations
}));
return formatUtxos(utxos, allowUnconfirmedUtxos);
})
},
tx({ txid }) {
return fetch_api(`txs/${txid}`, { url: this.url }).then(formatTx)
},
txs({ addr }) {
return fetch_api(`addrs/${addr}/full`, { url: this.url })
.then(result => (result.txs || []).map(formatTx))
},
latestBlock() {
return Promise.resolve([])
}
},
{
url: 'https://api.fullstack.cash/v5/',
name: 'FullStack.cash',
balance({ addr }) {
const cashAddr = bchOperator.toCashAddr(addr);
return fetch_api(`electrumx/balance/${cashAddr}`, { url: this.url })
.then(result => {
if (result.success && result.balance) {
return util.Sat_to_BCH(result.balance.confirmed + result.balance.unconfirmed);
}
throw result;
})
},
unspent({ addr, allowUnconfirmedUtxos = false }) {
const cashAddr = bchOperator.toCashAddr(addr);
return fetch_api(`electrumx/utxos/${cashAddr}`, { url: this.url })
.then(result => {
if (result.success && result.utxos) {
const utxos = result.utxos.map(u => ({
tx_hash: u.tx_hash,
tx_output_n: u.tx_pos,
value: u.value,
confirmations: u.height ? 1 : 0 // Height works as a good enough proxy for confirmation count.
}));
return formatUtxos(utxos, allowUnconfirmedUtxos);
}
throw result;
})
},
tx({ txid }) {
return fetch_api(`rawtransactions/getRawTransaction/${txid}?verbose=true`, { url: this.url })
.then(result => formatTx(result))
},
txs({ addr }) {
const cashAddr = bchOperator.toCashAddr(addr);
return fetch_api(`electrumx/transactions/${cashAddr}`, { url: this.url })
.then(async result => {
if (result.success && result.transactions) {
const txs = result.transactions;
const fullTxs = await Promise.all(txs.map(t =>
fetch_api(`rawtransactions/getRawTransaction/${t.tx_hash}?verbose=true`, { url: this.url })
.catch(e => null)
));
return fullTxs.filter(t => t).map(res => formatTx(res));
}
return [];
})
},
latestBlock() {
return fetch_api(`blockchain/getBlockchainInfo`, { url: this.url })
.then(result => result.blocks || 0);
},
broadcast({ rawTxHex }) {
return fetch_api(`rawtransactions/sendRawTransaction/${rawTxHex}`, { url: this.url })
.then(result => result); // Returns txid directly usually
}
}
]
bchOperator.util.format = {}
const formatBlock = bchOperator.util.format.block = async (block) => {
try {
const { height, hash, id, time, timestamp, mrkl_root, merkle_root, prev_block, next_block, size } = block;
const details = {
height,
hash: hash || id,
time: (time || timestamp) * 1000,
merkle_root: merkle_root || mrkl_root,
size,
}
if (prev_block)
details.prev_block = prev_block
if (next_block)
details.next_block = next_block[0]
return details
} catch (e) {
throw e
}
}
const formatUtxos = bchOperator.util.format.utxos = async (utxos, allowUnconfirmedUtxos = false) => {
try {
if (!utxos || !Array.isArray(utxos))
throw {
message: "No utxos found",
code: 1000
}
return utxos.filter(utxo => {
if (allowUnconfirmedUtxos) return true;
return utxo.confirmations || utxo.block_id;
}).map(utxo => {
console.log('UTXO raw:', utxo);
const { tx_hash, tx_hash_big_endian, txid, transaction_hash, tx_output_n, vout, index, value, script, confirmations, block_id } = utxo;
return {
confirmations: confirmations || (block_id ? 1 : 0),
tx_hash_big_endian: tx_hash_big_endian || tx_hash || txid || transaction_hash,
tx_output_n: tx_output_n !== undefined ? tx_output_n : (vout !== undefined ? vout : index),
value,
script
}
})
} catch (e) {
throw e
}
}
const formatTx = bchOperator.util.format.tx = (tx) => {
try {
let { txid, hash, time, block_height, fee, fees, received,
confirmed, size, double_spend, block_hash, confirmations,
transaction, inputs: txInputs, outputs: txOutputs, block
} = tx;
const normalizedBlockHeight = block_height || (block && typeof block === 'object' ? block.height : block);
// Handle Blockchair format
if (transaction) {
return {
hash: transaction.hash,
size: transaction.size,
fee: transaction.fee,
time: new Date(transaction.time).getTime(),
block_height: transaction.block_id,
confirmations: transaction.confirmations || (transaction.block_id ? 1 : 0),
inputs: (txInputs || []).map(input => ({
index: input.index,
prev_out: {
addr: input.recipient,
value: input.value,
},
})),
out: (txOutputs || []).map(output => ({
addr: output.recipient,
value: output.value,
}))
}
}
// Handle Blockcypher format
const inputs = tx.vin || tx.inputs || [];
const outputs = tx.vout || tx.outputs || tx.out || [];
const txTime = (time || parseDate(confirmed || received) || Date.now());
return {
hash: hash || txid,
size: size,
fee: fee || fees,
double_spend,
time: String(txTime).length < 13 ? txTime * 1000 : txTime,
block_height: normalizedBlockHeight,
block_hash: block_hash,
confirmations: confirmations,
inputs: inputs.map(input => {
return {
index: input.output || input.n || input.output_index || input.vout,
prev_out: {
addr: input.address || input.addr || input.prev_out?.addr || input.addresses?.[0],
value: input.value || input.prev_out?.value || input.output_value,
},
}
}),
out: outputs.map(output => {
return {
addr: output.address || output.addr || output.addresses?.[0],
value: output.value,
}
})
}
} catch (e) {
throw e
}
}
const multiApi = bchOperator.multiApi = async (fnName, { index = 0, ...args } = {}) => {
try {
while (index < APIs.length) {
if (!APIs[index][fnName] || (APIs[index].coolDownTime && APIs[index].coolDownTime > new Date().getTime())) {
if (APIs[index].coolDownTime) console.log(`Skipping ${APIs[index].name} due to cooldown`);
index += 1;
continue;
}
console.log(`Calling ${fnName} on ${APIs[index].name}`);
return await APIs[index][fnName](args);
}
throw "No API available"
} catch (error) {
console.error(error)
if (!APIs[index])
throw "No API available"
APIs[index].coolDownTime = new Date().getTime() + 5000; // 5 seconds cooldown
return multiApi(fnName, { index: index + 1, ...args });
}
};
function parseTx(tx, addressOfTx) {
const { txid, hash, time, block_height, block, inputs, outputs, out, vin, vout, fee, fees, received, confirmed } = tx;
const normalize = (addr) => {
if (!addr) return null;
let n = bchOperator.fromCashAddr(addr) || addr;
return String(n); // Keeping it Legacy internally makes comparisons way easier.
};
const normalizedAddressOfTx = normalize(addressOfTx);
const txTime = (time || parseDate(confirmed || received) || Date.now());
let parsedTx = {
txid: hash || txid,
time: String(txTime).length < 13 ? txTime * 1000 : txTime,
block: block_height || block?.height || (block && typeof block === 'number' ? block : undefined),
}
parsedTx.tx_senders = {};
(inputs || vin || []).forEach(i => {
let address = normalize(i.address || i.addr || i.prev_out?.addr || i.addresses?.[0]);
if (!address) return;
const value = i.value || i.prev_out?.value || i.output_value;
parsedTx.tx_senders[address] = (parsedTx.tx_senders[address] || 0) + value;
});
parsedTx.tx_input_value = 0;
for (let senderAddr in parsedTx.tx_senders) {
let val = parsedTx.tx_senders[senderAddr];
parsedTx.tx_senders[senderAddr] = util.Sat_to_BCH(val);
parsedTx.tx_input_value += val;
}
parsedTx.tx_input_value = util.Sat_to_BCH(parsedTx.tx_input_value);
parsedTx.tx_receivers = {};
(outputs || out || vout || []).forEach(o => {
let address = normalize(o.address || o.addr || o.addresses?.[0]);
if (!address) return;
const value = o.value;
parsedTx.tx_receivers[address] = (parsedTx.tx_receivers[address] || 0) + value;
});
parsedTx.tx_output_value = 0;
for (let receiverAddr in parsedTx.tx_receivers) {
let val = parsedTx.tx_receivers[receiverAddr];
parsedTx.tx_receivers[receiverAddr] = util.Sat_to_BCH(val);
parsedTx.tx_output_value += val;
}
parsedTx.tx_output_value = util.Sat_to_BCH(parsedTx.tx_output_value);
if (fee || fees) {
parsedTx.tx_fee = util.Sat_to_BCH(fee || fees);
} else {
parsedTx.tx_fee = parseFloat((parsedTx.tx_input_value - parsedTx.tx_output_value).toFixed(8));
}
if (Object.keys(parsedTx.tx_receivers).length === 1 && Object.keys(parsedTx.tx_senders).length === 1 && Object.keys(parsedTx.tx_senders)[0] === Object.keys(parsedTx.tx_receivers)[0]) {
parsedTx.type = 'self';
parsedTx.amount = parsedTx.tx_receivers[normalizedAddressOfTx];
parsedTx.address = normalizedAddressOfTx;
} else if (normalizedAddressOfTx in parsedTx.tx_senders) {
parsedTx.type = 'out';
parsedTx.receiver = Object.keys(parsedTx.tx_receivers).filter(addr => addr != normalizedAddressOfTx);
// If it's an OUT transaction, the amount should be what was sent out (Total Out - Change)
parsedTx.amount = parseFloat((parsedTx.tx_output_value - (parsedTx.tx_receivers[normalizedAddressOfTx] || 0)).toFixed(8));
} else {
parsedTx.type = 'in';
parsedTx.sender = Object.keys(parsedTx.tx_senders).filter(addr => addr != normalizedAddressOfTx);
parsedTx.amount = parsedTx.tx_receivers[normalizedAddressOfTx];
}
return parsedTx;
}
const DUST_AMT = 546,
MIN_FEE_UPDATE = 219;
const fetch_api = bchOperator.fetch = function (api, { asText = false, url = 'https://api.blockchair.com/bitcoin-cash/' } = {}) {
return new Promise((resolve, reject) => {
console.debug(url + api);
fetch(url + api).then(response => {
if (response.ok) {
(asText ? response.text() : response.json())
.then(result => resolve(result))
.catch(error => reject("Failed to parse response: " + error.message))
} else {
response.json()
.then(result => reject(result))
.catch(() => reject(`API Error: ${response.status} ${response.statusText}`))
}
}).catch(error => {
// This handles CORS errors and network failures
console.error("Network or CORS error:", error);
reject("Service unavailable or blocked (CORS)");
})
})
};
const get_fee_rate = bchOperator.get_fee_rate = function () {
return new Promise((resolve) => {
// Try Blockchair first
fetch('https://api.blockchair.com/bitcoin-cash/stats').then(response => {
if (response.ok) {
response.json().then(result => {
const feeRate = result.data?.suggested_transaction_fee_per_byte_sat || 1;
resolve(util.Sat_to_BCH(feeRate));
}).catch(() => resolve(util.Sat_to_BCH(1)));
} else {
// Fallback to Blockchain.com
fetch('https://api.blockchain.info/haskoin-store/bch/block/best').then(res => {
// Blockchain.com is a bit stingy with fee data, so we default to a safe 1 sat/vbyte if Blockchair is down.
resolve(util.Sat_to_BCH(1));
}).catch(() => resolve(util.Sat_to_BCH(1)));
}
}).catch(() => resolve(util.Sat_to_BCH(1)))
})
}
const broadcastTx = bchOperator.broadcastTx = rawTxHex => new Promise((resolve, reject) => {
console.log('txHex:', rawTxHex)
multiApi('broadcast', { rawTxHex })
.then(result => resolve(result))
.catch(error => reject(error))
});
// --- CashAddr Implementation ---
function polymod(values) {
let c = 1n;
for (let v of values) {
let b = c >> 35n;
c = ((c & 0x07ffffffffn) << 5n) ^ BigInt(v);
if (b & 1n) c ^= 0x98f2bc8e61n;
if (b & 2n) c ^= 0x79b76d99e2n;
if (b & 4n) c ^= 0xf33e5fb3c4n;
if (b & 8n) c ^= 0xae2eabe2a8n;
if (b & 16n) c ^= 0x1e4f43e470n;
}
return c ^ 1n;
}
function expandPrefix(prefix) {
let ret = [];
for (let i = 0; i < prefix.length; i++) {
ret.push(prefix.charCodeAt(i) & 0x1f);
}
ret.push(0);
return ret;
}
function convertBits(data, from, to, pad = true) {
let acc = 0;
let bits = 0;
const ret = [];
const maxv = (1 << to) - 1;
for (let i = 0; i < data.length; ++i) {
const value = data[i] & 0xff;
acc = (acc << from) | value;
bits += from;
while (bits >= to) {
bits -= to;
ret.push((acc >> bits) & maxv);
}
acc &= (1 << bits) - 1;
}
if (pad) {
if (bits > 0) {
ret.push((acc << (to - bits)) & maxv);
}
} else if (bits >= from || ((acc << (to - bits)) & maxv)) {
return null;
}
return ret;
}
const toCashAddr = bchOperator.toCashAddr = function (legacyAddr) {
if (!legacyAddr || typeof legacyAddr !== 'string') return legacyAddr;
// If it's already a CashAddr (with or without prefix), return it
if (legacyAddr.includes(':') || (legacyAddr.startsWith('q') && legacyAddr.length >= 42))
return legacyAddr;
try {
// Robust decoding: try current coinjs settings, but also explicitly check common legacy versions (BCH: 0/5, FLO: 35/94)
let decoded = coinjs.addressDecode(legacyAddr);
if (!decoded || !decoded.bytes || (decoded.type !== 'standard' && decoded.type !== 'multisig')) {
// Fallback: manually decode if the global settings don't match what we need.
const bytes = coinjs.base58decode(legacyAddr);
if (bytes && bytes.length > 4) {
const version = bytes[0];
const hash = bytes.slice(1, -4);
const type = (version === 0 || version === 35) ? "standard" : ((version === 5 || version === 94) ? "multisig" : null);
if (type) {
decoded = { bytes: hash, type: type, version: version };
}
}
}
if (!decoded || !decoded.bytes || (decoded.type !== "standard" && decoded.type !== "multisig")) {
console.warn("toCashAddr: Failed to decode or invalid type", decoded);
return legacyAddr;
}
let prefix = "bitcoincash";
let type = (decoded.type === "standard" ? 0 : 1); // 0 for P2PKH, 1 for P2SH
let hash = Array.from(decoded.bytes);
// Version byte: (type << 3) | size_bit (0 for 160 bits)
let versionByte = type << 3;
let payload = [versionByte].concat(hash);
let payload5bit = convertBits(payload, 8, 5, true);
let checksumData = expandPrefix(prefix).concat(payload5bit).concat([0, 0, 0, 0, 0, 0, 0, 0]);
let checksum = polymod(checksumData);
let checksum5bit = [];
for (let i = 0; i < 8; i++) {
checksum5bit.push(Number((checksum >> (5n * BigInt(7 - i))) & 0x1fn));
}
let combined = payload5bit.concat(checksum5bit);
let ret = "";
for (let v of combined) {
ret += ALPHABET[v];
}
return prefix + ":" + ret;
} catch (e) {
console.error("CashAddr conversion error:", e);
return legacyAddr;
}
}
const fromCashAddr = bchOperator.fromCashAddr = function (cashaddr) {
try {
let str = cashaddr.trim();
// CashAddr must be all-lowercase or all-uppercase
const isLower = str === str.toLowerCase();
const isUpper = str === str.toUpperCase();
if (!isLower && !isUpper) return null;
str = str.toLowerCase();
// Handle prefix
let prefix = "bitcoincash";
if (str.includes(":")) {
let parts = str.split(":");
prefix = parts[0];
str = parts[1];
}
let payload5bit = [];
for (let char of str) {
let idx = ALPHABET.indexOf(char);
if (idx === -1) return null;
payload5bit.push(idx);
}
let checksumData = expandPrefix(prefix).concat(payload5bit);
if (polymod(checksumData) !== 0n) return null;
let combined = payload5bit.slice(0, -8);
let payload = convertBits(combined, 5, 8, false);
if (!payload) return null;
let versionByte = payload[0];
let typeBit = versionByte >> 3;
let hash = payload.slice(1);
// Making sure we get the right Legacy format for BCH.
// type 0 = P2PKH (coinjs.pub), type 1 = P2SH (coinjs.multisig)
let version = (typeBit === 0 ? coinjs.pub : coinjs.multisig);
let r = [version].concat(Array.from(hash));
let legacyChecksumData = Crypto.SHA256(Crypto.SHA256(r, { asBytes: true }), { asBytes: true });
let checksum = legacyChecksumData.slice(0, 4);
return coinjs.base58encode(r.concat(checksum));
} catch (e) {
console.error("fromCashAddr error:", e);
return null;
}
}
// --- End CashAddr ---
// BCH only uses legacy and cashaddr
Object.defineProperties(bchOperator, {
newKeys: {
get: () => {
let r = coinjs.newKeys();
return {
privkey: r.privkey,
pubkey: r.pubkey,
address: r.address,
cashaddr: toCashAddr(r.address),
wif: r.wif,
compressed: r.compressed
};
}
},
pubkey: {
value: key => key.length >= 66 ? key : (key.length == 64 ? coinjs.newPubkey(key) : coinjs.wif2pubkey(key).pubkey)
},
address: {
value: (key, prefix = undefined) => coinjs.pubkey2address(bchOperator.pubkey(key), prefix)
}
});
const convert = bchOperator.convert = {
wif: (key, version = coinjs.priv) => {
if (key.length === 64) return coinjs.privkey2wif(key, version);
let res = coinjs.wif2privkey(key);
return coinjs.privkey2wif(res.privkey, version);
},
legacy2cash: addr => toCashAddr(addr),
cash2legacy: addr => fromCashAddr(addr)
}
coinjs.compressed = true;
const verifyKey = bchOperator.verifyKey = function (addr, key) {
if (!addr || !key)
return undefined;
try {
// Check legacy
let decoded = coinjs.addressDecode(addr);
if (decoded && decoded.type !== false) {
return bchOperator.address(key) === addr;
}
// Check cashaddr
let legacy = fromCashAddr(addr);
if (legacy) {
return bchOperator.address(key) === legacy;
}
} catch (e) {
console.error(e);
}
return null;
}
const validateAddress = bchOperator.validateAddress = function (addr) {
if (!addr) return false;
try {
// Try legacy
let decoded = coinjs.addressDecode(addr);
if (decoded && decoded.type !== false) return decoded.type;
// Try cashaddr
let legacy = fromCashAddr(addr);
if (legacy) {
let decodedLegacy = coinjs.addressDecode(legacy);
return decodedLegacy ? decodedLegacy.type : false;
}
} catch (e) {
console.error(e);
}
return false;
}
function hashPrevouts(tx) {
let buffer = [];
for (let i = 0; i < tx.ins.length; i++) {
buffer = buffer.concat(Crypto.util.hexToBytes(tx.ins[i].outpoint.hash).reverse());
buffer = buffer.concat(coinjs.numToBytes(tx.ins[i].outpoint.index, 4));
}
return Crypto.SHA256(Crypto.SHA256(buffer, { asBytes: true }), { asBytes: true });
}
function hashSequence(tx) {
let buffer = [];
for (let i = 0; i < tx.ins.length; i++) {
buffer = buffer.concat(coinjs.numToBytes(tx.ins[i].sequence, 4));
}
return Crypto.SHA256(Crypto.SHA256(buffer, { asBytes: true }), { asBytes: true });
}
function hashOutputs(tx) {
let buffer = [];
for (let i = 0; i < tx.outs.length; i++) {
buffer = buffer.concat(coinjs.numToBytes(tx.outs[i].value, 8));
let script = tx.outs[i].script.buffer;
buffer = buffer.concat(coinjs.numToVarInt(script.length));
buffer = buffer.concat(script);
}
return Crypto.SHA256(Crypto.SHA256(buffer, { asBytes: true }), { asBytes: true });
}
// Using BIP-143 specifically for Bitcoin Cash signatures.
function bip143Sighash(tx, inputIndex, scriptCode, value, sighashType) {
let buffer = [];
// 1. nVersion (4 bytes)
buffer = buffer.concat(coinjs.numToBytes(tx.version, 4));
// 2. hashPrevouts (32 bytes)
buffer = buffer.concat(hashPrevouts(tx));
// 3. hashSequence (32 bytes)
buffer = buffer.concat(hashSequence(tx));
// 4. outpoint (32+4 bytes)
buffer = buffer.concat(Crypto.util.hexToBytes(tx.ins[inputIndex].outpoint.hash).reverse());
buffer = buffer.concat(coinjs.numToBytes(tx.ins[inputIndex].outpoint.index, 4));
// 5. scriptCode (varInt + script)
buffer = buffer.concat(coinjs.numToVarInt(scriptCode.length));
buffer = buffer.concat(scriptCode);
// 6. value (8 bytes)
buffer = buffer.concat(coinjs.numToBytes(value, 8));
// 7. nSequence (4 bytes)
buffer = buffer.concat(coinjs.numToBytes(tx.ins[inputIndex].sequence, 4));
// 8. hashOutputs (32 bytes)
buffer = buffer.concat(hashOutputs(tx));
// 9. nLocktime (4 bytes)
buffer = buffer.concat(coinjs.numToBytes(tx.lock_time, 4));
// 10. sighash type (4 bytes) - includes FORKID
buffer = buffer.concat(coinjs.numToBytes(sighashType, 4));
return Crypto.SHA256(Crypto.SHA256(buffer, { asBytes: true }), { asBytes: true });
}
// This helper manages the signing process for a single input.
function signBCHInput(tx, inputIndex, wif, value) {
const ecKey = coinjs.wif2privkey(wif);
const privateKey = ecKey.privkey;
const pubkey = coinjs.newPubkey(privateKey);
// Create P2PKH scriptCode: OP_DUP OP_HASH160 <pubKeyHash> OP_EQUALVERIFY OP_CHECKSIG
const pubKeyHash = ripemd160(Crypto.SHA256(Crypto.util.hexToBytes(pubkey), { asBytes: true }), { asBytes: true });
let scriptCode = [0x76, 0xa9, 0x14]; // OP_DUP OP_HASH160 PUSH20
scriptCode = scriptCode.concat(pubKeyHash);
scriptCode = scriptCode.concat([0x88, 0xac]); // OP_EQUALVERIFY OP_CHECKSIG
// Calculate BIP-143 sighash
const sighash = bip143Sighash(tx, inputIndex, scriptCode, value, BCH_SIGHASH);
// Sign using manual ECDSA (since coinjs.ECDSA is not available)
const curve = EllipticCurve.getSECCurveByName("secp256k1");
const key = new BigInteger(privateKey, 16);
const n = curve.getN();
const e = BigInteger.fromByteArrayUnsigned(sighash);
let r, s;
// We're using a simple random generator for 'k'. RFC6979 is technically safer, but this is secure enough for our needs.
// Repeat until valid r and s are found
do {
const kBytes = Crypto.util.randomBytes(32);
const kHex = Crypto.util.bytesToHex(kBytes);
const k = new BigInteger(kHex, 16);
const G = curve.getG();
const Q = G.multiply(k);
r = Q.getX().toBigInteger().mod(n);
s = k.modInverse(n).multiply(e.add(key.multiply(r))).mod(n);
} while (r.compareTo(BigInteger.ZERO) <= 0 || s.compareTo(BigInteger.ZERO) <= 0);
// Force lower s values per BIP62
const halfn = n.shiftRight(1);
if (s.compareTo(halfn) > 0) {
s = n.subtract(s);
}
// DER Serialize
const rBa = r.toByteArraySigned();
const sBa = s.toByteArraySigned();
let sequence = [];
sequence.push(0x02); // INTEGER
sequence.push(rBa.length);
sequence = sequence.concat(rBa);
sequence.push(0x02); // INTEGER
sequence.push(sBa.length);
sequence = sequence.concat(sBa);
sequence.unshift(sequence.length);
sequence.unshift(0x30); // SEQUENCE
const signature = sequence;
// Add sighash type byte
signature.push(BCH_SIGHASH);
// Create scriptSig: <sig> <pubkey>
let scriptSig = coinjs.script();
scriptSig.writeBytes(signature);
scriptSig.writeBytes(Crypto.util.hexToBytes(pubkey));
tx.ins[inputIndex].script = scriptSig;
}
// Size constants for legacy only (no SegWit)
const BASE_TX_SIZE = 10,
BASE_INPUT_SIZE = 41,
LEGACY_INPUT_SIZE = 107,
BASE_OUTPUT_SIZE = 9,
LEGACY_OUTPUT_SIZE = 25;
function _sizePerInput(addr) {
let legacy = fromCashAddr(addr) || addr;
if (coinjs.addressDecode(legacy).type === "standard") {
return BASE_INPUT_SIZE + LEGACY_INPUT_SIZE;
}
return null;
}
function _sizePerOutput(addr) {
let legacy = fromCashAddr(addr) || addr;
if (coinjs.addressDecode(legacy).type === "standard") {
return BASE_OUTPUT_SIZE + LEGACY_OUTPUT_SIZE;
}
return null;
}
function validateTxParameters(parameters) {
let invalids = [];
if (parameters.senders) {
if (!Array.isArray(parameters.senders))
parameters.senders = [parameters.senders];
parameters.senders.forEach(id => !validateAddress(id) ? invalids.push(id) : null);
if (invalids.length)
throw "Invalid senders:" + invalids;
}
if (parameters.privkeys) {
if (!Array.isArray(parameters.privkeys))
parameters.privkeys = [parameters.privkeys];
if (parameters.senders.length != parameters.privkeys.length)
throw "Array length for senders and privkeys should be equal";
parameters.senders.forEach((id, i) => {
let key = parameters.privkeys[i];
if (!verifyKey(id, key))
invalids.push(id);
if (key.length === 64)
parameters.privkeys[i] = coinjs.privkey2wif(key);
});
if (invalids.length)
throw "Invalid private key for address:" + invalids;
}
if (!Array.isArray(parameters.receivers))
parameters.receivers = [parameters.receivers];
parameters.receivers.forEach(id => !validateAddress(id) ? invalids.push(id) : null);
if (invalids.length)
throw "Invalid receivers:" + invalids;
if (parameters.change_address && !validateAddress(parameters.change_address))
throw "Invalid change_address:" + parameters.change_address;
if ((typeof parameters.fee !== "number" || parameters.fee <= 0) && parameters.fee !== null)
throw "Invalid fee:" + parameters.fee;
if (!Array.isArray(parameters.amounts))
parameters.amounts = [parameters.amounts];
if (parameters.receivers.length != parameters.amounts.length)
throw "Array length for receivers and amounts should be equal";
parameters.amounts.forEach(a => typeof a !== "number" || a <= 0 ? invalids.push(a) : null);
if (invalids.length)
throw "Invalid amounts:" + invalids;
return parameters;
}
bchOperator.validateTxParameters = validateTxParameters;
const createTransaction = bchOperator.createTransaction = ({
senders, receivers, amounts, fee, change_address,
fee_from_receiver, allowUnconfirmedUtxos = false, sendingTx = false,
utxoValues = []
}) => {
return new Promise((resolve, reject) => {
let total_amount = parseFloat(amounts.reduce((t, a) => t + a, 0).toFixed(8));
const tx = coinjs.transaction();
let output_size = addOutputs(tx, receivers, amounts, change_address);
addInputs(tx, senders, total_amount, fee, output_size, fee_from_receiver, allowUnconfirmedUtxos, utxoValues).then(result => {
if (result.change_amount > 0 && result.change_amount > result.fee)
tx.outs[tx.outs.length - 1].value = util.BCH_to_Sat(result.change_amount);
if (fee_from_receiver) {
let fee_remaining = util.BCH_to_Sat(result.fee);
for (let i = 0; i < tx.outs.length - 1 && fee_remaining > 0; i++) {
if (fee_remaining < tx.outs[i].value) {
tx.outs[i].value -= fee_remaining;
fee_remaining = 0;
} else {
fee_remaining -= tx.outs[i].value;
tx.outs[i].value = 0;
}
}
if (fee_remaining > 0)
return reject("Send amount is less than fee");
}
let filtered_outputs = [], dust_value = 0;
tx.outs.forEach(o => o.value >= DUST_AMT ? filtered_outputs.push(o) : dust_value += o.value);
tx.outs = filtered_outputs;
result.fee += util.Sat_to_BCH(dust_value);
result.output_size = output_size;
result.output_amount = total_amount - (fee_from_receiver ? result.fee : 0);
result.total_size = BASE_TX_SIZE + output_size + result.input_size;
result.transaction = tx;
if (sendingTx && result.hasOwnProperty('hasInsufficientBalance') && result.hasInsufficientBalance)
reject({
message: "Insufficient balance",
...result
});
else
resolve(result);
}).catch(error => reject(error))
})
}
function addInputs(tx, senders, total_amount, fee, output_size, fee_from_receiver, allowUnconfirmedUtxos = false, utxoValues = []) {
return new Promise((resolve, reject) => {
if (fee !== null) {
addUTXOs(tx, senders, fee_from_receiver ? total_amount : total_amount + fee, false, { allowUnconfirmedUtxos }, utxoValues).then(result => {
result.fee = fee;
resolve(result);
}).catch(error => reject(error))
} else {
get_fee_rate().then(fee_rate => {
let net_fee = BASE_TX_SIZE * fee_rate;
net_fee += (output_size * fee_rate);
(fee_from_receiver ?
addUTXOs(tx, senders, total_amount, false, { allowUnconfirmedUtxos }, utxoValues) :
addUTXOs(tx, senders, total_amount + net_fee, fee_rate, { allowUnconfirmedUtxos }, utxoValues)
).then(result => {
result.fee = parseFloat((net_fee + (result.input_size * fee_rate)).toFixed(8));
result.fee_rate = fee_rate;
resolve(result);
}).catch(error => reject(error))
}).catch(error => reject(error))
}
})
}
bchOperator.addInputs = addInputs;
function addUTXOs(tx, senders, required_amount, fee_rate, rec_args = { allowUnconfirmedUtxos: false }, utxoValues = []) {
return new Promise((resolve, reject) => {
required_amount = parseFloat(required_amount.toFixed(8));
if (typeof rec_args.n === "undefined") {
rec_args.n = 0;
rec_args.input_size = 0;
rec_args.input_amount = 0;
}
if (required_amount <= 0)
return resolve({
input_size: rec_args.input_size,
input_amount: rec_args.input_amount,
change_amount: required_amount * -1,
utxoValues: utxoValues
});
else if (rec_args.n >= senders.length) {
return resolve({
hasInsufficientBalance: true,
input_size: rec_args.input_size,
input_amount: rec_args.input_amount,
change_amount: required_amount * -1,
utxoValues: utxoValues
});
}
let addr = senders[rec_args.n];
let size_per_input = _sizePerInput(addr);
multiApi('unspent', { addr, allowUnconfirmedUtxos: rec_args.allowUnconfirmedUtxos }).then(utxos => {
for (let i = 0; i < utxos.length && required_amount > 0; i++) {
const isUnconfirmed = !utxos[i].confirmations;
if (isUnconfirmed && !rec_args.allowUnconfirmedUtxos) {
continue;
}
// BCH Legacy needs manual script derivation from the address so verification passes.
let legacyAddr = fromCashAddr(addr) || addr;
let addr_decode = coinjs.addressDecode(legacyAddr);
let s = coinjs.script();
s.writeOp(118); //OP_DUP
s.writeOp(169); //OP_HASH160
s.writeBytes(addr_decode.bytes);
s.writeOp(136); //OP_EQUALVERIFY
s.writeOp(172); //OP_CHECKSIG
let script = Crypto.util.bytesToHex(s.buffer);
tx.addinput(utxos[i].tx_hash_big_endian, utxos[i].tx_output_n, script, 0xfffffffd);
// We need to remember the value of this UTXO. BIP-143 signing requires it later.
utxoValues.push(utxos[i].value);
rec_args.input_size += size_per_input;
rec_args.input_amount += util.Sat_to_BCH(utxos[i].value);
required_amount -= util.Sat_to_BCH(utxos[i].value);
if (fee_rate)
required_amount += size_per_input * fee_rate;
}
rec_args.n += 1;
addUTXOs(tx, senders, required_amount, fee_rate, rec_args, utxoValues)
.then(result => resolve(result))
.catch(error => reject(error))
}).catch(error => reject(error))
})
}
bchOperator.addUTXOs = addUTXOs;
function addOutputs(tx, receivers, amounts, change_address) {
let size = 0;
for (let i in receivers) {
let addr = fromCashAddr(receivers[i]) || receivers[i];
tx.addoutput(addr, amounts[i]);
size += _sizePerOutput(addr);
}
let change = fromCashAddr(change_address) || change_address;
tx.addoutput(change, 0);
size += _sizePerOutput(change);
return size;
}
bchOperator.addOutputs = addOutputs;
bchOperator.sendTx = function (senders, privkeys, receivers, amounts, fee = null, options = {}) {
options.sendingTx = true;
return new Promise((resolve, reject) => {
createSignedTx(senders, privkeys, receivers, amounts, fee, options).then(result => {
broadcastTx(result.transaction.serialize())
.then(txid => resolve(txid))
.catch(error => reject(error));
}).catch(error => reject(error))
})
}
const createSignedTx = bchOperator.createSignedTx = function (senders, privkeys, receivers, amounts, fee = null, options = {}) {
return new Promise((resolve, reject) => {
try {
({
senders,
privkeys,
receivers,
amounts
} = validateTxParameters({
senders,
privkeys,
receivers,
amounts,
fee,
...options
}));
} catch (e) {
return reject(e)
}
// Create transaction
createTransaction({
senders, receivers, amounts, fee,
change_address: options.change_address || senders[0],
...options
}).then(result => {
let tx = result.transaction;
let utxoValues = result.utxoValues || [];
// Now for the hard part: Sign each input using the BCH secure algorithm (BIP-143).
for (let i = 0; i < tx.ins.length; i++) {
// Match the input to the right private key so we can sign it.
let wif = privkeys[0];
for (let j = 0; j < senders.length; j++) {
if (verifyKey(senders[j], privkeys[j])) {
wif = privkeys[j];
break;
}
}
// Get the UTXO value for this input
const value = utxoValues[i] || 0;
// Sign using BIP-143
signBCHInput(tx, i, wif, value);
}
resolve(result);
}).catch(error => reject(error));
})
}
// Wrapper for index.html compatibility
const createTx = bchOperator.createTx = function (senders, receivers, amounts, fee = null, options = {}) {
return createTransaction({
senders,
receivers,
amounts,
fee,
change_address: senders[0], // Send change back to where it came from.
allowUnconfirmedUtxos: true, // Allow spending unconfirmed funds
...options
});
}
const deserializeTx = bchOperator.deserializeTx = function (tx) {
if (typeof tx === 'string' || Array.isArray(tx)) {
try {
tx = coinjs.transaction().deserialize(tx);
} catch {
throw "Invalid transaction hex";
}
} else if (typeof tx !== 'object' || typeof tx.sign !== 'function')
throw "Invalid transaction object";
return tx;
}
bchOperator.signTx = function (tx, privkeys, utxoValues = []) {
tx = deserializeTx(tx);
if (!Array.isArray(privkeys))
privkeys = [privkeys];
for (let i in privkeys)
if (privkeys[i].length === 64)
privkeys[i] = coinjs.privkey2wif(privkeys[i]);
// Sign each input using BIP-143
for (let i = 0; i < tx.ins.length; i++) {
const value = utxoValues[i] || 0;
signBCHInput(tx, i, privkeys[i % privkeys.length], value);
}
return tx.serialize();
}
bchOperator.getBalance = addr => new Promise((resolve, reject) => {
if (!validateAddress(addr))
return reject("Invalid address");
multiApi('balance', { addr })
.then(result => resolve(result))
.catch(error => reject(error))
});
const getTx = bchOperator.getTx = txid => new Promise(async (resolve, reject) => {
try {
const result = await multiApi('tx', { txid });
let { time, confirmations, block_height, block, fee, fees, inputs, out, outputs } = result;
const txTime = parseDate(time || result.confirmed || result.received);
const confirmedBlock = Number(block_height || (block && typeof block === 'object' ? block.height : block));
if (confirmedBlock && !isNaN(confirmedBlock) && confirmedBlock > 0) {
try {
const latestHeight = await bchOperator.latestBlock();
if (latestHeight && latestHeight >= confirmedBlock) {
confirmations = latestHeight - confirmedBlock + 1;
}
} catch (e) {
console.warn("Could not fetch latest block for confirmation count:", e);
}
}
resolve({
confirmations: confirmations || 0,
block: confirmedBlock,
txid: result.hash || txid,
time: txTime,
size: result.size,
fee: util.Sat_to_BCH(fee || fees || 0),
inputs: (inputs || []).map(i => Object({ address: i.prev_out?.addr || i.address || i.addr, value: util.Sat_to_BCH(i.prev_out?.value || i.value || 0) })),
total_input_value: util.Sat_to_BCH((inputs || []).reduce((a, i) => a + (i.prev_out?.value || i.value || 0), 0)),
outputs: (out || outputs || []).map(o => Object({ address: o.addr || o.address, value: util.Sat_to_BCH(o.value) })),
total_output_value: util.Sat_to_BCH((out || outputs || []).reduce((a, o) => a += o.value, 0)),
})
} catch (error) {
reject(error)
}
})
bchOperator.latestBlock = async () => {
try {
return await multiApi('latestBlock');
} catch (e) {
return null;
}
}
bchOperator.getAddressData = address => new Promise((resolve, reject) => {
if (!validateAddress(address))
return reject("Invalid address");
const legacyAddress = fromCashAddr(address) || address;
Promise.all([
multiApi('balance', { addr: address }),
multiApi('txs', { addr: address })
]).then(([balance, txs]) => {
const parsedTxs = Array.isArray(txs) ? txs.map(tx => typeof tx === 'string' ? { txid: tx } : parseTx(tx, legacyAddress)) : [];
resolve({
address,
balance,
txs: parsedTxs
});
}).catch(error => reject(error))
});
})('object' === typeof module ? module.exports : window.bchOperator = {});