From e543cd23bb5dbd89b60fbe084d469b46c2bc9b10 Mon Sep 17 00:00:00 2001 From: noiach Date: Mon, 13 Jul 2020 15:12:45 +1000 Subject: [PATCH] feat: add fetchUTXO --- package.json | 2 +- src/common/apis/bitcoinDotCom.ts | 82 ++++++++++++++++++++++-- src/common/apis/blockchair.ts | 68 +++++++++++++++++--- src/common/apis/blockstream.ts | 44 +++++++------ src/common/apis/insight.ts | 95 +++++++++++++++++++++------- src/common/apis/sochain.ts | 6 +- src/common/libraries/bitgoUtxoLib.ts | 13 +++- src/handlers/BCH/BCHHandler.spec.ts | 11 ++-- src/handlers/BCH/BCHHandler.ts | 5 ++ src/handlers/BTC/BTCHandler.spec.ts | 3 + src/handlers/BTC/BTCHandler.ts | 7 ++ src/handlers/ZEC/ZECHandler.spec.ts | 8 +++ src/handlers/ZEC/ZECHandler.ts | 17 ++++- src/index.spec.ts | 4 +- src/lib/utxo.ts | 49 +++++++++++--- src/spec/specUtils.ts | 5 +- 16 files changed, 335 insertions(+), 84 deletions(-) diff --git a/package.json b/package.json index ed5507c..5090599 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,7 @@ "fix:tslint": "tslint --fix --project .", "test": "run-s build test:*", "test:lint": "tslint --project .", - "test:unit": "nyc --silent ava -T 100s --verbose", + "test:unit": "(. ./.env); nyc --silent ava -T 100s --verbose", "lint": "yarn test:lint", "lint:strict": "yarn lint && prettier \"src/**/*.ts\" --list-different --tab-width 4", "watch": "yarn clean && yarn build:main -- -w", diff --git a/src/common/apis/bitcoinDotCom.ts b/src/common/apis/bitcoinDotCom.ts index 595a272..d88986d 100644 --- a/src/common/apis/bitcoinDotCom.ts +++ b/src/common/apis/bitcoinDotCom.ts @@ -1,14 +1,81 @@ import axios from "axios"; -import { fixValues, sortUTXOs, UTXO } from "../../lib/utxo"; +import { fixUTXO, fixUTXOs, fixValue, sortUTXOs, UTXO } from "../../lib/utxo"; import { DEFAULT_TIMEOUT } from "./timeout"; +export interface ScriptSig { + hex: string; + asm: string; +} + +export interface Vin { + txid: string; // "4f72fce028ff1e99459393232e8bf8a430815ec5cea8700676c101b02be3649c", + vout: number; // 1, + sequence: number; // 4294967295, + n: number; // 0, + scriptSig: ScriptSig; + value: number; // 7223963, + legacyAddress: string; // "1D4NXvNvjucShZeyLsDzYz1ky2W8gYKQH7", + cashAddress: string; // "bitcoincash:qzzyfwmnz3dlld7svwzn53xzr6ycz5kwavpd9uqf4l" +} + +export interface ScriptPubKey { + hex: string; // "76a91427bad06158841621ed33eb91efdb1cc4af4996cb88ac", + asm: string; // "OP_DUP OP_HASH160 27bad06158841621ed33eb91efdb1cc4af4996cb OP_EQUALVERIFY OP_CHECKSIG", + addresses: string[]; // ["14d59YbC2D9W9y5kozabCDhxkY3eDQq7B3"], + type: string; // "pubkeyhash", + cashAddrs: string[]; // ["bitcoincash:qqnm45rptzzpvg0dx04erm7mrnz27jvkevaf3ys3c5"] +} + +export interface Vout { + value: string; // "0.04159505", + n: number; // 0, + scriptPubKey: ScriptPubKey; + spentTxId: string; // "9fa7bc86ad4729cbd5c182a8cd5cfc5eb457608fe430ce673f33ca52bfb1a187", + spentIndex: number; // 1, + spentHeight: number; // 617875 +} + +export interface QueryTransaction { + vin: Vin[]; + vout: Vout[]; + txid: string; // "03e29b07bb98b1e964296289dadb2fb034cb52e178cc306d20cc9ddc951d2a31", + version: number; // 1, + locktime: number; // 0, + blockhash: string; // "0000000000000000011c71094699e3ba47c43da76d775cf5eb5fbea1787fafb5", + blockheight: number; // 616200, + confirmations: number; // 27433, + time: number; // 1578054427, + blocktime: number; // 1578054427, + firstSeenTime: number; // 1578054360, + valueOut: number; // 0.07213963, + size: number; // 226, + valueIn: number; // 0.07223963, + fees: number; // 0.0001 +} + const endpoint = (testnet: boolean) => testnet ? "https://trest.bitcoin.com/v2/" : "https://rest.bitcoin.com/v2/"; -const fetchConfirmations = (testnet: boolean) => async (txid: string): Promise => { - const url = `${endpoint(testnet).replace(/\/$/, "")}/transaction/details/${txid}`; +const fetchUTXO = (testnet: boolean) => async (txHash: string, vOut: number): Promise => { + const url = `${endpoint(testnet).replace(/\/$/, "")}/transaction/details/${txHash}`; - const response = await axios.get<{ + const response = await axios.get(`${url}`, { timeout: DEFAULT_TIMEOUT }); + + const utxo = response.data; + + return fixUTXO({ + txHash, + amount: parseFloat(utxo.vout[vOut].value), + // script_hex: utxo.scriptPubKey, + vOut, + confirmations: utxo.confirmations, + }, 8); +}; + +const fetchConfirmations = (testnet: boolean) => async (txHash: string): Promise => { + const url = `${endpoint(testnet).replace(/\/$/, "")}/transaction/details/${txHash}`; + + const tx = (await axios.get<{ txid: string, // 'cacec549d9f1f67e9835889a2ce3fc0d593bd78d63f63f45e4c28a59e004667d', version: number, // 4, locktime: number, // 0, @@ -22,9 +89,9 @@ const fetchConfirmations = (testnet: boolean) => async (txid: string): Promise(`${url}`, { timeout: DEFAULT_TIMEOUT }); + }>(`${url}`, { timeout: DEFAULT_TIMEOUT })).data; - return response.data.confirmations; + return tx.confirmations; }; const fetchUTXOs = (testnet: boolean) => async (address: string, confirmations: number): Promise => { @@ -41,7 +108,7 @@ const fetchUTXOs = (testnet: boolean) => async (address: string, confirmations: ts: number, }> }>(url, { timeout: DEFAULT_TIMEOUT }); - return fixValues(response.data.utxos.map(utxo => ({ + return fixUTXOs(response.data.utxos.map(utxo => ({ txHash: utxo.txid, amount: utxo.amount, // script_hex: utxo.scriptPubKey, @@ -66,6 +133,7 @@ export const broadcastTransaction = (testnet: boolean) => async (txHex: string): }; export const BitcoinDotCom = { + fetchUTXO, fetchUTXOs, fetchConfirmations, broadcastTransaction, diff --git a/src/common/apis/blockchair.ts b/src/common/apis/blockchair.ts index 8536a3c..be4168c 100644 --- a/src/common/apis/blockchair.ts +++ b/src/common/apis/blockchair.ts @@ -1,29 +1,76 @@ import axios from "axios"; -import BigNumber from "bignumber.js"; -import { fixValues, sortUTXOs, UTXO } from "../../lib/utxo"; +import { sortUTXOs, UTXO } from "../../lib/utxo"; import { DEFAULT_TIMEOUT } from "./timeout"; -const fetchConfirmations = (network: string) => async (txid: string): Promise => { - const url = `https://api.blockchair.com/${network}/dashboards/transaction/${txid}`; +const fetchUTXO = (network: string) => async (txHash: string, vOut: number): Promise => { + const url = `https://api.blockchair.com/${network}/dashboards/transaction/${txHash}`; const response = (await axios.get(`${url}`, { timeout: DEFAULT_TIMEOUT })).data; - const txBlock = response.data[txid].transaction.block_id; + if (!response.data[txHash]) { + throw new Error(`Transaction not found.`); + } + + const tx = response.data[txHash]; + const txBlock = tx.transaction.block_id; + + let latestBlock = response.context.state; + if (latestBlock === 0) { + const statsUrl = `https://api.blockchair.com/${network}/stats`; + const statsResponse = (await axios.get(statsUrl)).data; + latestBlock = statsResponse.data.blocks - 1; + } + + const confirmations = txBlock === -1 ? 0 : Math.max(latestBlock - txBlock + 1, 0); + + return { + txHash, + vOut, + amount: tx.outputs[vOut].value, + confirmations, + }; +}; + +const fetchConfirmations = (network: string) => async (txHash: string): Promise => { + const url = `https://api.blockchair.com/${network}/dashboards/transaction/${txHash}`; + + const response = (await axios.get(`${url}`, { timeout: DEFAULT_TIMEOUT })).data; + + if (!response.data[txHash]) { + throw new Error(`Transaction not found.`); + } - return txBlock === -1 ? 0 : response.context.state - txBlock + 1; + const txBlock = response.data[txHash].transaction.block_id; + + let latestBlock = response.context.state; + if (latestBlock === 0) { + const statsUrl = `https://api.blockchair.com/${network}/stats`; + const statsResponse = (await axios.get(statsUrl)).data; + latestBlock = statsResponse.data.blocks - 1; + } + + return txBlock === -1 ? 0 : Math.max(latestBlock - txBlock + 1, 0); }; const fetchUTXOs = (network: string) => async (address: string, confirmations: number): Promise => { const url = `https://api.blockchair.com/${network}/dashboards/address/${address}?limit=0,100`; const response = (await axios.get(url, { timeout: DEFAULT_TIMEOUT })).data; - return fixValues(response.data[address].utxo.map(utxo => ({ + + let latestBlock = response.context.state; + if (latestBlock === 0) { + const statsUrl = `https://api.blockchair.com/${network}/stats`; + const statsResponse = (await axios.get(statsUrl)).data; + latestBlock = statsResponse.data.blocks - 1; + } + + return response.data[address].utxo.map(utxo => ({ txHash: utxo.transaction_hash, - amount: new BigNumber(utxo.value).div(100000000).toNumber(), + amount: utxo.value, vOut: utxo.index, - confirmations: utxo.block_id === -1 ? 0 : response.context.state - utxo.block_id + 1, + confirmations: utxo.block_id === -1 ? 0 : latestBlock - utxo.block_id + 1, })) - .filter(utxo => confirmations === 0 || utxo.confirmations >= confirmations), 8) + .filter(utxo => confirmations === 0 || utxo.confirmations >= confirmations) .sort(sortUTXOs); }; @@ -53,6 +100,7 @@ enum Networks { export const Blockchair = { networks: Networks, + fetchUTXO, fetchUTXOs, fetchConfirmations, broadcastTransaction, diff --git a/src/common/apis/blockstream.ts b/src/common/apis/blockstream.ts index 41928f1..719c3ed 100644 --- a/src/common/apis/blockstream.ts +++ b/src/common/apis/blockstream.ts @@ -15,15 +15,15 @@ interface BlockstreamUTXO { }; txid: string; value: number; - vout: vout; + vout: vout; // vout is a number for utxos, or an array of utxos for a tx }; -interface BlockstreamDetailsUTXO extends BlockstreamUTXO> { locktime: number; vin: Array<{ @@ -40,32 +40,37 @@ interface BlockstreamDetailsUTXO extends BlockstreamUTXO async (txHash: string, vOut: number): Promise => { + const apiUrl = `https://blockstream.info/${testnet ? "testnet/" : ""}api`; + + const utxo = (await axios.get(`${apiUrl}/tx/${txHash}`, { timeout: DEFAULT_TIMEOUT })).data; + + const heightResponse = (await axios.get(`${apiUrl}/blocks/tip/height`, { timeout: DEFAULT_TIMEOUT })).data; -const fetchConfirmations = (testnet: boolean) => async (txid: string): Promise => { + const confirmations = utxo.status.confirmed ? Math.max(1 + parseInt(heightResponse, 10) - utxo.status.block_height, 0) : 0; + + return { + txHash, + amount: utxo.vout[vOut].value, + vOut, + confirmations, + }; +}; + +const fetchConfirmations = (testnet: boolean) => async (txHash: string): Promise => { const apiUrl = `https://blockstream.info/${testnet ? "testnet/" : ""}api`; - const response = await axios.get(`${apiUrl}/tx/${txid}`, { timeout: DEFAULT_TIMEOUT }); + const utxo = (await axios.get(`${apiUrl}/tx/${txHash}`, { timeout: DEFAULT_TIMEOUT })).data; const heightResponse = () => axios.get(`${apiUrl}/blocks/tip/height`, { timeout: DEFAULT_TIMEOUT }); - const utxo = response.data; - return utxo.status.confirmed ? 1 + parseInt((await heightResponse()).data, 10) - utxo.status.block_height : 0; + return utxo.status.confirmed ? Math.max(1 + parseInt((await heightResponse()).data, 10) - utxo.status.block_height, 0) : 0; }; const fetchUTXOs = (testnet: boolean) => async (address: string, confirmations: number): Promise => { const apiUrl = `https://blockstream.info/${testnet ? "testnet/" : ""}api`; - const response = await axios.get>(`${apiUrl}/address/${address}/utxo`, { timeout: DEFAULT_TIMEOUT }); + const response = await axios.get>(`${apiUrl}/address/${address}/utxo`, { timeout: DEFAULT_TIMEOUT }); const heightResponse = await axios.get(`${apiUrl}/blocks/tip/height`, { timeout: DEFAULT_TIMEOUT }); @@ -86,7 +91,8 @@ const broadcastTransaction = (testnet: boolean) => async (txHex: string): Promis }; export const Blockstream = { - fetchConfirmations, + fetchUTXO, fetchUTXOs, + fetchConfirmations, broadcastTransaction, } \ No newline at end of file diff --git a/src/common/apis/insight.ts b/src/common/apis/insight.ts index e6feb08..33556a4 100644 --- a/src/common/apis/insight.ts +++ b/src/common/apis/insight.ts @@ -1,7 +1,7 @@ import axios from "axios"; import https from "https"; -import { fixValue, sortUTXOs, UTXO } from "../../lib/utxo"; +import { fixUTXO, fixValue, sortUTXOs, UTXO } from "../../lib/utxo"; import { DEFAULT_TIMEOUT } from "./timeout"; type FetchUTXOResult = ReadonlyArray<{ @@ -38,34 +38,80 @@ const fetchUTXOs = (insightURL: string) => async (address: string, confirmations .sort(sortUTXOs); }; -const fetchConfirmations = (insightURL: string) => async (txid: string): Promise => { - const url = `${insightURL.replace(/\/$/, "")}/tx/${txid}`; +export interface ScriptSig { + hex: string; + asm: string; +} - const response = await axios.get<{ - txid: string, // 'cacec549d9f1f67e9835889a2ce3fc0d593bd78d63f63f45e4c28a59e004667d', - version: number, // 4, - locktime: number, // 0, - vin: any, // [[Object]], - vout: any, // [[Object]], - vjoinsplit: any[], // [], - blockheight: number, // -1, - confirmations: number, // 0, - time: number, // 1574895240, - valueOut: number, // 225.45779926, - size: number, // 211, - valueIn: number, // 225.45789926, - fees: number, // 0.0001, - fOverwintered: boolean, // true, - nVersionGroupId: number, // 2301567109, - nExpiryHeight: number, // 0, - valueBalance: number, // 0, - spendDescs: any[], // [], - outputDescs: any[], // [] - }>(url, { timeout: DEFAULT_TIMEOUT }); +export interface Vin { + txid: string; + vout: number; + sequence: number; + n: number; + scriptSig: ScriptSig; + addr: string; + valueSat: number; + value: number; + doubleSpentTxID?: any; +} +export interface ScriptPubKey { + hex: string; // "76a914ea06cb7aaf2b21e97ea9f43736731ee6a33366db88ac", + asm: string; // "OP_DUP OP_HASH160 ea06cb7aaf2b21e97ea9f43736731ee6a33366db OP_EQUALVERIFY OP_CHECKSIG", + addresses: string[]; // ["tmX3mbB2iAtGftpyp4BTmryma2REmuw8h8G"] + type: string; // "pubkeyhash" +} + +export interface Vout { + value: string; // "0.00020000", + n: number; // 0, + scriptPubKey: ScriptPubKey; + spentTxId: string; // "265760587a0631d613f13949a45bef1ec4c5fc38912081f4b58b4df51799ffb5", + spentIndex: number; // 0, + spentHeight: number; // 756027 +} + +export interface TxResponse { + txid: string; // "fcc25c1a1f7df38ce15211b324385d837540dc0a97c3056f7497dacabef77c3f", + version: number; // 4, + locktime: number; // 0, + vin: Vin[]; + vout: Vout[]; + vjoinsplit: any[]; // [], + blockhash: string; // "0029b9051d06402b546532c1d0288684368fce5cc42c0b3e5aa032a35b74014b", + blockheight: number; // 735468, + confirmations: number; // 259430, + time: number; // 1577073296, + blocktime: number; // 1577073296, + valueOut: number; // 0.0002, + size: number; // 211, + valueIn: number; // 0.0003, + fees: number; // 0.0001, + fOverwintered: boolean; // true, + nVersionGroupId: number; // 2301567109, + nExpiryHeight: number; // 0, + valueBalance: number; // 0, + spendDescs: any[]; // [], + outputDescs: any[]; // [] +} + +const fetchConfirmations = (insightURL: string) => async (txHash: string): Promise => { + const url = `${insightURL.replace(/\/$/, "")}/tx/${txHash}`; + const response = await axios.get(url, { timeout: DEFAULT_TIMEOUT }); return response.data.confirmations; }; +const fetchUTXO = (insightURL: string) => async (txHash: string, vOut: number): Promise => { + const url = `${insightURL.replace(/\/$/, "")}/tx/${txHash}`; + const tx = (await axios.get(url, { timeout: DEFAULT_TIMEOUT })).data; + return fixUTXO({ + txHash, + amount: parseFloat(tx.vout[vOut].value), + vOut, + confirmations: tx.confirmations, + }, 8); +}; + export const broadcastTransaction = (insightURL: string) => async (txHex: string): Promise => { const url = `${insightURL.replace(/\/$/, "")}/tx/send`; const response = await axios.post<{ error: string | null, id: null, txid: string }>( @@ -80,6 +126,7 @@ export const broadcastTransaction = (insightURL: string) => async (txHex: string }; export const Insight = { + fetchUTXO, fetchUTXOs, fetchConfirmations, broadcastTransaction, diff --git a/src/common/apis/sochain.ts b/src/common/apis/sochain.ts index 84249ee..9179d8a 100644 --- a/src/common/apis/sochain.ts +++ b/src/common/apis/sochain.ts @@ -1,6 +1,6 @@ import axios from "axios"; -import { fixValues, sortUTXOs, UTXO } from "../../lib/utxo"; +import { fixUTXOs, sortUTXOs, UTXO } from "../../lib/utxo"; import { DEFAULT_TIMEOUT } from "./timeout"; export interface SoChainUTXO { @@ -17,10 +17,10 @@ const fetchUTXOs = (network: string) => async (address: string, confirmations: n const url = `https://sochain.com/api/v2/get_tx_unspent/${network}/${address}/${confirmations}`; const response = await axios.get<{ readonly data: { readonly txs: readonly SoChainUTXO[] } }>(url, { timeout: DEFAULT_TIMEOUT }); - return fixValues(response.data.data.txs.map(utxo => ({ + return fixUTXOs(response.data.data.txs.map(utxo => ({ txHash: utxo.txid, amount: utxo.value, - scriptPubKey: utxo.script_hex, + // scriptPubKey: utxo.script_hex, vOut: utxo.output_no, confirmations: utxo.confirmations, })), 8) diff --git a/src/common/libraries/bitgoUtxoLib.ts b/src/common/libraries/bitgoUtxoLib.ts index a850b27..47d840f 100644 --- a/src/common/libraries/bitgoUtxoLib.ts +++ b/src/common/libraries/bitgoUtxoLib.ts @@ -7,7 +7,10 @@ import { UTXO } from "../../lib/utxo"; const buildUTXO = async ( network: typeof bitcoin.networks.bitcoin, privateKey: any, changeAddress: string, toAddress: string, valueIn: BigNumber, utxos: UTXO[], - options?: { subtractFee?: boolean, fee?: number, signFlag?: number, version?: number, versionGroupID?: number } + options?: { + subtractFee?: boolean, fee?: number, signFlag?: number, + version?: number, versionGroupID?: number, expiryHeight?: number, lockTime?: number, + } ): Promise<{ toHex: () => string }> => { const fees = new BigNumber(options && options.fee !== undefined ? options.fee : 10000); @@ -21,7 +24,13 @@ const buildUTXO = async ( tx.setVersion(options.version); } if (options && options.versionGroupID) { - tx.setVersionGroupId(options.versionGroupID); + tx.setVersionGroupId(0xf5b9230b); + } + if (options && options.expiryHeight) { + tx.setExpiryHeight(options.expiryHeight); + } + if (options && options.lockTime) { + tx.setLockTime(options.lockTime); } // Only use the required utxos diff --git a/src/handlers/BCH/BCHHandler.spec.ts b/src/handlers/BCH/BCHHandler.spec.ts index b318b5b..20d46ef 100644 --- a/src/handlers/BCH/BCHHandler.spec.ts +++ b/src/handlers/BCH/BCHHandler.spec.ts @@ -8,12 +8,15 @@ const testnetAddress = "bchtest:qqnm45rptzzpvg0dx04erm7mrnz27jvkevem4rjxlg"; const mainnetAddress = "bitcoincash:qqnm45rptzzpvg0dx04erm7mrnz27jvkevaf3ys3c5"; const confirmations = 6; -test.skip("Testnet BCH", testEndpoints, _apiFallbacks.fetchUTXOs(true, testnetAddress, confirmations)); -test.skip("Mainnet BCH", testEndpoints, _apiFallbacks.fetchUTXOs(false, mainnetAddress, confirmations)); +test("Testnet BCH", testEndpoints, _apiFallbacks.fetchUTXOs(true, testnetAddress, confirmations)); +test("Mainnet BCH", testEndpoints, _apiFallbacks.fetchUTXOs(false, mainnetAddress, confirmations)); // Test confirmations endpoint ///////////////////////////////////////////////// const testnetHash = "69f277b26d6192754ac884b69223f0f5212082bf8ede1ab2dbd6b4383fd1d583"; const mainnetHash = "03e29b07bb98b1e964296289dadb2fb034cb52e178cc306d20cc9ddc951d2a31"; -test.skip("Testnet BCH confirmations", testEndpoints, _apiFallbacks.fetchConfirmations(true, testnetHash)); -test.skip("Mainnet BCH confirmations", testEndpoints, _apiFallbacks.fetchConfirmations(false, mainnetHash)); +test("Testnet BCH confirmations", testEndpoints, _apiFallbacks.fetchConfirmations(true, testnetHash)); +test("Mainnet BCH confirmations", testEndpoints, _apiFallbacks.fetchConfirmations(false, mainnetHash)); + +test("Testnet BCH UTXO", testEndpoints, _apiFallbacks.fetchUTXO(true, testnetHash, 0)); +test("Mainnet BCH UTXO", testEndpoints, _apiFallbacks.fetchUTXO(false, mainnetHash, 0)); diff --git a/src/handlers/BCH/BCHHandler.ts b/src/handlers/BCH/BCHHandler.ts index 8bc85f9..8334b28 100644 --- a/src/handlers/BCH/BCHHandler.ts +++ b/src/handlers/BCH/BCHHandler.ts @@ -37,6 +37,11 @@ export const _apiFallbacks = { testnet ? undefined : () => Blockchair.fetchConfirmations(Blockchair.networks.BITCOIN_CASH)(txHash), ], + fetchUTXO: (testnet: boolean, txHash: string, vOut: number) => [ + () => BitcoinDotCom.fetchUTXO(testnet)(txHash, vOut), + testnet ? undefined : () => Blockchair.fetchUTXO(Blockchair.networks.BITCOIN_CASH)(txHash, vOut), + ], + fetchUTXOs: (testnet: boolean, address: string, confirmations: number) => [ () => BitcoinDotCom.fetchUTXOs(testnet)(address, confirmations), testnet ? undefined : () => Blockchair.fetchUTXOs(Blockchair.networks.BITCOIN_CASH)(address, confirmations), diff --git a/src/handlers/BTC/BTCHandler.spec.ts b/src/handlers/BTC/BTCHandler.spec.ts index 17baed8..a321424 100644 --- a/src/handlers/BTC/BTCHandler.spec.ts +++ b/src/handlers/BTC/BTCHandler.spec.ts @@ -17,3 +17,6 @@ const mainnetHash = "0e3e2357e806b6cdb1f70b54c3a3a17b6714ee1f0e68bebb44a74b1efd5 test("Testnet BTC confirmations", testEndpoints, _apiFallbacks.fetchConfirmations(true, testnetHash)); test("Mainnet BTC confirmations", testEndpoints, _apiFallbacks.fetchConfirmations(false, mainnetHash)); + +test("Testnet BTC UTXO", testEndpoints, _apiFallbacks.fetchUTXO(true, testnetHash, 0)); +test("Mainnet BTC UTXO", testEndpoints, _apiFallbacks.fetchUTXO(false, mainnetHash, 0)); diff --git a/src/handlers/BTC/BTCHandler.ts b/src/handlers/BTC/BTCHandler.ts index 7168e7c..512cb75 100644 --- a/src/handlers/BTC/BTCHandler.ts +++ b/src/handlers/BTC/BTCHandler.ts @@ -32,6 +32,13 @@ export const _apiFallbacks = { ]), ], + fetchUTXO: (testnet: boolean, txHash: string, vOut: number) => [ + ...shuffleArray([ + () => Blockstream.fetchUTXO(testnet)(txHash, vOut), + () => Blockchair.fetchUTXO(testnet ? Blockchair.networks.BITCOIN_TESTNET : Blockchair.networks.BITCOIN)(txHash, vOut), + ]), + ], + fetchUTXOs: (testnet: boolean, address: string, confirmations: number) => [ ...shuffleArray([ () => Blockstream.fetchUTXOs(testnet)(address, confirmations), diff --git a/src/handlers/ZEC/ZECHandler.spec.ts b/src/handlers/ZEC/ZECHandler.spec.ts index 0099f4c..5d12a0a 100644 --- a/src/handlers/ZEC/ZECHandler.spec.ts +++ b/src/handlers/ZEC/ZECHandler.spec.ts @@ -25,3 +25,11 @@ test("Testnet ZEC confirmations", testEndpoints, [ test("Mainnet ZEC confirmations", testEndpoints, [ () => Insight.fetchConfirmations("https://explorer.z.cash/api")(mainnetHash), ]); + +test("Testnet ZEC UTXO", testEndpoints, [ + () => Insight.fetchUTXO("https://explorer.testnet.z.cash/api")(testnetHash, 0), +]); + +test("Mainnet ZEC UTXO", testEndpoints, [ + () => Insight.fetchUTXO("https://explorer.z.cash/api")(mainnetHash, 0), +]); diff --git a/src/handlers/ZEC/ZECHandler.ts b/src/handlers/ZEC/ZECHandler.ts index 244cc4a..e3c1ba2 100644 --- a/src/handlers/ZEC/ZECHandler.ts +++ b/src/handlers/ZEC/ZECHandler.ts @@ -42,6 +42,16 @@ export const _apiFallbacks = { () => Insight.fetchConfirmations(InsightEndpoints.ZecBlockExplorer)(txHash), ], + fetchUTXO: (testnet: boolean, txHash: string, vOut: number) => testnet ? + [ + () => Insight.fetchUTXO(InsightEndpoints.TestnetZCash)(txHash, vOut), + ] : [ + () => Insight.fetchUTXO(InsightEndpoints.ZCash)(txHash, vOut), + () => Insight.fetchUTXO(InsightEndpoints.ZecChain)(txHash, vOut), + () => Insight.fetchUTXO(InsightEndpoints.BlockExplorer)(txHash, vOut), + () => Insight.fetchUTXO(InsightEndpoints.ZecBlockExplorer)(txHash, vOut), + ], + fetchUTXOs: (testnet: boolean, address: string, confirmations: number) => testnet ? [ () => Insight.fetchUTXOs(InsightEndpoints.TestnetZCash)(address, confirmations), () => Sochain.fetchUTXOs("ZECTEST")(address, confirmations), @@ -127,10 +137,15 @@ export class ZECHandler implements Handler { const changeAddress = fromAddress; const utxos = List(await getUTXOs(this.testnet, { ...options, address: fromAddress })).sortBy(utxo => utxo.amount).reverse().toArray(); + if (this.testnet) { + // tslint:disable-next-line: no-object-mutation + bitcoin.networks.zcashTest.consensusBranchId["4"] = 0xf5b9230b; + } + const built = await BitgoUTXOLib.buildUTXO( this.testnet ? bitcoin.networks.zcashTest : bitcoin.networks.zcash, this.privateKey, changeAddress, to, valueIn, utxos, - { ...options, version: bitcoin.Transaction.ZCASH_SAPLING_VERSION, versionGroupID: parseInt("0x892F2085", 16) }, + { ...options, version: 4, versionGroupID: this.testnet ? 0xf5b9230b : 0x892F2085 }, ); txHash = await retryNTimes(() => fallback(_apiFallbacks.broadcastTransaction(this.testnet, built.toHex())), 5); diff --git a/src/index.spec.ts b/src/index.spec.ts index 7134ee9..867c243 100644 --- a/src/index.spec.ts +++ b/src/index.spec.ts @@ -59,8 +59,8 @@ const s = (asset: string | { type: "ERC20", name: string, address?: string }) => t.is(0, 0); } - test.only("send BTC", sendToken, "BTC", 8, "testnet"); - test.only("send ZEC", sendToken, "ZEC", 8, "testnet"); + test("send BTC", sendToken, "BTC", 8, "testnet"); + test("send ZEC", sendToken, "ZEC", 8, "testnet"); test("send BCH", sendToken, "BCH", 8, "testnet"); test.serial("send ETH", sendToken, "ETH", 18, "kovan"); test.serial("send REN", sendToken, { type: "ERC20", name: "REN", address: "0x2cd647668494c1b15743ab283a0f980d90a87394" }, 18, "kovan"); diff --git a/src/lib/utxo.ts b/src/lib/utxo.ts index 78e563f..01c6ad2 100644 --- a/src/lib/utxo.ts +++ b/src/lib/utxo.ts @@ -8,22 +8,53 @@ export interface UTXO { readonly confirmations: number; } -export const sortUTXOs = (a: UTXO, b: UTXO) => { +/** + * sortUTXOs compares two UTXOs by amount, then confirmations and then hash. + * + * @example + * sortUTXOs({amount: 1, confirmations: 1}, {amount: 2, confirmations: 0}); + * // -1, representing that the first parameter should be ordered first. + * + * @returns a negative value to represent that a should come before b or a + * positive value to represent that b should come before a. + */ +export const sortUTXOs = (a: UTXO, b: UTXO): number => { // Sort greater values first if (a.amount !== b.amount) { return b.amount - a.amount }; // Sort older UTXOs first - if (a.confirmations !== b.confirmations) { return a.amount - b.amount } + if (a.confirmations !== b.confirmations) { return a.confirmations - b.confirmations } return a.txHash <= b.txHash ? -1 : 1; } -export const fixValue = (value: number, decimals: number) => new BigNumber(value).multipliedBy(new BigNumber(10).exponentiatedBy(decimals)).decimalPlaces(0).toNumber(); +/** + * fixValue turns a readable value, e.g. `0.0001` BTC, to the value in the smallest + * unit, e.g. `10000` sats. + * + * @example + * fixValue(0.0001, 8) = 10000; + * + * @param value Value in the readable representation, e.g. `0.0001` BTC. + * @param decimals The number of decimals to shift by, e.g. 8. + */ +export const fixValue = (value: number, decimals: number) => + new BigNumber(value) + .multipliedBy(new BigNumber(10).exponentiatedBy(decimals)) + .decimalPlaces(0) + .toNumber(); -// Convert values to correct unit -export const fixValues = (utxos: readonly UTXO[], decimals: number) => { - return utxos.map((utxo: UTXO): UTXO => ({ - ...utxo, - amount: fixValue(utxo.amount, decimals), - })); +/** + * fixUTXO calls {{fixValue}} on the value of the UTXO. + */ +export const fixUTXO = (utxo: UTXO, decimals: number): UTXO => ({ + ...utxo, + amount: fixValue(utxo.amount, decimals), +}); + +/** + * fixUTXOs maps over an array of UTXOs and calls {{fixValue}}. + */ +export const fixUTXOs = (utxos: readonly UTXO[], decimals: number) => { + return utxos.map((utxo) => fixUTXO(utxo, decimals)); }; /** diff --git a/src/spec/specUtils.ts b/src/spec/specUtils.ts index c9157f5..d928189 100644 --- a/src/spec/specUtils.ts +++ b/src/spec/specUtils.ts @@ -4,7 +4,8 @@ import { ExecutionContext } from "ava"; export const testEndpoints = async (t: ExecutionContext, endpoints: ReadonlyArray Promise)>) => { let expectedResult = null; - for (const endpoint of endpoints) { + for (let i = 0; i < endpoints.length; i++) { + const endpoint = endpoints[i]; if (!endpoint) { continue; } try { const result = await endpoint(); @@ -16,7 +17,7 @@ export const testEndpoints = async (t: ExecutionContext, endpoints: Rea // // ignore error // } expectedResult = expectedResult || result; - t.deepEqual(expectedResult, result); + t.deepEqual(result, expectedResult, `Comparison failed for endpoint #${i}`); } catch (error) { if (error.message.match(/Request failed with status code 503/)) { continue;