diff --git a/package.json b/package.json index 0e718e7..f799248 100644 --- a/package.json +++ b/package.json @@ -4,13 +4,14 @@ "description": "A self-custodial, JS Bitcoin wallet management library.", "main": "dist/index.js", "scripts": { - "test": "yarn build && env mocha --exit -r ts-node/register 'tests/**/*.ts'", + "test": "yarn build && env mocha --exit -r ts-node/register 'tests/**/*.test.ts'", "test:boost": "yarn build && env mocha --exit -r ts-node/register 'tests/boost.test.ts'", "test:wallet": "yarn build && env mocha --exit -r ts-node/register 'tests/wallet.test.ts'", + "test:receive": "yarn build && env mocha --exit -r ts-node/register 'tests/receive.test.ts'", + "test:storage": "yarn build && env mocha --exit -r ts-node/register 'tests/storage.test.ts'", "test:electrum": "yarn build && env mocha --exit -r ts-node/register 'tests/electrum.test.ts'", "test:derivation": "yarn build && env mocha --exit -r ts-node/register 'tests/derivation.test.ts'", "test:transaction": "yarn build && env mocha --exit -r ts-node/register 'tests/transaction.test.ts'", - "test:storage": "yarn build && env mocha --exit -r ts-node/register 'tests/storage.test.ts'", "example": "ts-node example", "lint": "eslint . --ext .js,.jsx,.ts,.tsx", "lint:check": "eslint . --ext .js,.jsx,.ts,.tsx", diff --git a/src/wallet/index.ts b/src/wallet/index.ts index 4f09920..e39ae8b 100644 --- a/src/wallet/index.ts +++ b/src/wallet/index.ts @@ -1,7 +1,10 @@ +import * as bip39 from 'bip39'; import * as bitcoin from 'bitcoinjs-lib'; import { Network, networks } from 'bitcoinjs-lib'; import BIP32Factory, { BIP32Interface } from 'bip32'; import * as ecc from '@bitcoinerlab/secp256k1'; +import cloneDeep from 'lodash.clonedeep'; + import { EAddressType, EAvailableNetworks, @@ -107,9 +110,7 @@ import { import { Electrum } from '../electrum'; import { Transaction } from '../transaction'; import { GAP_LIMIT, GAP_LIMIT_CHANGE, TRANSACTION_DEFAULTS } from './constants'; -import cloneDeep from 'lodash.clonedeep'; import { btcToSats } from '../utils/conversion'; -import * as bip39 from 'bip39'; const bip32 = BIP32Factory(ecc); @@ -1974,7 +1975,6 @@ export class Wallet { [key: string]: number[]; } = {}; for (const tx of receivedTxs) { - this.sendMessage('transactionReceived', tx); // No need to scan an address with a saved UTXO. if (utxoScriptHashes.has(tx.transaction.scriptHash)) continue; for (const addressType in addresses) { @@ -2011,6 +2011,10 @@ export class Wallet { } } + for (const tx of receivedTxs) { + this.sendMessage('transactionReceived', tx); + } + return ok(notificationTxid); } diff --git a/tests/boost.test.ts b/tests/boost.test.ts index 3b784aa..32e4486 100644 --- a/tests/boost.test.ts +++ b/tests/boost.test.ts @@ -25,62 +25,66 @@ let waitForElectrum: TWaitForElectrum; const rpc = new BitcoinJsonRpc(bitcoinURL); const failure = { canBoost: false, rbf: false, cpfp: false }; -beforeEach(async function () { +describe('Boost', async function () { this.timeout(testTimeout); - let balance = await rpc.getBalance(); - const address = await rpc.getNewAddress(); - await rpc.generateToAddress(1, address); - - while (balance < 10) { - await rpc.generateToAddress(10, address); - balance = await rpc.getBalance(); - } - - waitForElectrum = await initWaitForElectrumToSync( - { host: electrumHost, port: electrumPort }, - bitcoinURL - ); - - await waitForElectrum(); - - const mnemonic = generateMnemonic(); - - const res = await Wallet.create({ - rbf: true, - mnemonic, - // mnemonic: 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about', - network: EAvailableNetworks.regtest, - addressType: EAddressType.p2wpkh, - electrumOptions: { - servers: [ - { - host: '127.0.0.1', - ssl: 60002, - tcp: 60001, - protocol: EProtocol.tcp - } - ], - net, - tls - }, - // reduce gap limit to speed up tests - gapLimitOptions: { - lookAhead: 2, - lookBehind: 2, - lookAheadChange: 2, - lookBehindChange: 2 + beforeEach(async function () { + this.timeout(testTimeout); + + let balance = await rpc.getBalance(); + const address = await rpc.getNewAddress(); + await rpc.generateToAddress(1, address); + + while (balance < 10) { + await rpc.generateToAddress(10, address); + balance = await rpc.getBalance(); + } + + waitForElectrum = await initWaitForElectrumToSync( + { host: electrumHost, port: electrumPort }, + bitcoinURL + ); + + await waitForElectrum(); + + const mnemonic = generateMnemonic(); + + const res = await Wallet.create({ + rbf: true, + mnemonic, + // mnemonic: 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about', + network: EAvailableNetworks.regtest, + addressType: EAddressType.p2wpkh, + electrumOptions: { + servers: [ + { + host: '127.0.0.1', + ssl: 60002, + tcp: 60001, + protocol: EProtocol.tcp + } + ], + net, + tls + }, + // reduce gap limit to speed up tests + gapLimitOptions: { + lookAhead: 2, + lookBehind: 2, + lookAheadChange: 2, + lookBehindChange: 2 + } + }); + if (res.isErr()) { + throw res.error; } + wallet = res.value; + await wallet.refreshWallet({}); }); - if (res.isErr()) { - throw res.error; - } - wallet = res.value; - await wallet.refreshWallet({}); -}); -describe('Boost', async function () { - this.timeout(testTimeout); + afterEach(async function () { + await wallet?.electrum?.disconnect(); + }); it('Should fail in some cases.', async () => { // tx not found diff --git a/tests/derivation.test.ts b/tests/derivation.test.ts index d44915e..3983975 100644 --- a/tests/derivation.test.ts +++ b/tests/derivation.test.ts @@ -1,4 +1,4 @@ -import * as chai from 'chai'; +import { expect } from 'chai'; import { EAddressType, EAvailableNetworks, @@ -8,8 +8,6 @@ import { getKeyDerivationPathString } from '../src'; -const expect = chai.expect; - describe('Derivation Methods', () => { it('Should return default derivation path object for p2wpkh', () => { const pathRes = getKeyDerivationPath({ diff --git a/tests/electrum.test.ts b/tests/electrum.test.ts index 0e82680..16a6886 100644 --- a/tests/electrum.test.ts +++ b/tests/electrum.test.ts @@ -1,39 +1,46 @@ -import * as chai from 'chai'; +import { expect } from 'chai'; import net from 'net'; import tls from 'tls'; import { Wallet } from '../'; -import { EAvailableNetworks, EAddressType, IGetUtxosResponse } from '../src'; -import { TEST_MNEMONIC } from './constants'; -import { Result } from '../src'; import { servers } from '../example/helpers'; +import { + EAddressType, + EAvailableNetworks, + IGetUtxosResponse, + Result +} from '../src'; +import { TEST_MNEMONIC } from './constants'; import { EXPECTED_SHARED_RESULTS } from './expected-results'; -const expect = chai.expect; - const testTimeout = 60000; let wallet: Wallet; -before(async function () { +describe('Electrum Methods', async function (): Promise { this.timeout(testTimeout); - const res = await Wallet.create({ - mnemonic: TEST_MNEMONIC, - network: EAvailableNetworks.testnet, - addressType: EAddressType.p2wpkh, - electrumOptions: { - servers: servers[EAvailableNetworks.testnet], - net, - tls - } + + before(async function () { + this.timeout(testTimeout); + const res = await Wallet.create({ + mnemonic: TEST_MNEMONIC, + network: EAvailableNetworks.testnet, + addressType: EAddressType.p2wpkh, + electrumOptions: { + servers: servers[EAvailableNetworks.testnet], + net, + tls + } + }); + if (res.isErr()) throw res.error; + wallet = res.value; + await wallet.refreshWallet({}); + }); + + after(async function () { + await wallet?.electrum?.disconnect(); }); - if (res.isErr()) throw res.error; - wallet = res.value; - await wallet.refreshWallet({}); -}); -describe('Electrum Methods', async function (): Promise { - this.timeout(testTimeout); it('connectToElectrum: Should connect to a random Electrum server', async () => { const connectResponse = await wallet.connectToElectrum(); if (connectResponse.isErr()) throw connectResponse.error; diff --git a/tests/receive.test.ts b/tests/receive.test.ts new file mode 100644 index 0000000..bc3d460 --- /dev/null +++ b/tests/receive.test.ts @@ -0,0 +1,216 @@ +import BitcoinJsonRpc from 'bitcoin-json-rpc'; +import { expect } from 'chai'; +import net from 'net'; +import tls from 'tls'; + +import { + EAddressType, + EAvailableNetworks, + EProtocol, + generateMnemonic, + sleep, + Wallet +} from '../src'; +import { + bitcoinURL, + electrumHost, + electrumPort, + initWaitForElectrumToSync, + MessageListener, + TWaitForElectrum +} from './utils'; + +const testTimeout = 60000; +let wallet: Wallet; +let waitForElectrum: TWaitForElectrum; +const rpc = new BitcoinJsonRpc(bitcoinURL); +const ml = new MessageListener(); + +describe('Receive', async function () { + this.timeout(testTimeout); + + beforeEach(async function () { + this.timeout(testTimeout); + ml.clear(); + + // Ensure sufficient balance in regtest + let balance = await rpc.getBalance(); + const address = await rpc.getNewAddress(); + await rpc.generateToAddress(1, address); + + while (balance < 10) { + await rpc.generateToAddress(10, address); + balance = await rpc.getBalance(); + } + + waitForElectrum = await initWaitForElectrumToSync( + { host: electrumHost, port: electrumPort }, + bitcoinURL + ); + + await waitForElectrum(); + + const mnemonic = generateMnemonic(); + + const res = await Wallet.create({ + mnemonic, + network: EAvailableNetworks.regtest, + addressType: EAddressType.p2wpkh, + electrumOptions: { + servers: [ + { + host: '127.0.0.1', + ssl: 60002, + tcp: 60001, + protocol: EProtocol.tcp + } + ], + net, + tls + }, + // reduce gap limit to speed up tests + gapLimitOptions: { + lookAhead: 2, + lookBehind: 2, + lookAheadChange: 2, + lookBehindChange: 2 + }, + addressTypesToMonitor: [EAddressType.p2wpkh], + onMessage: ml.onMessage + }); + if (res.isErr()) throw res.error; + wallet = res.value; + await wallet.refreshWallet({}); + }); + + afterEach(async function () { + await wallet?.electrum?.disconnect(); + }); + + it('Should generate new receiving address', async () => { + const r = await wallet.getNextAvailableAddress(); + if (r.isErr()) throw r.error; + const address = r.value.addressIndex.address; + expect(address).to.be.a('string'); + expect(address).to.match(/^bcrt1/); // Regtest bech32 prefix + }); + + it('Should receive funds and update balance', async () => { + expect(wallet.data.balance).to.equal(0); + + const r = await wallet.getNextAvailableAddress(); + if (r.isErr()) throw r.error; + const address = r.value.addressIndex.address; + expect(address).to.be.a('string'); + expect(address).to.match(/^bcrt1/); // Regtest bech32 prefix + + const amount = 0.1; + const amountSats = amount * 10e7; + await rpc.sendToAddress(address, amount.toString()); + + await rpc.generateToAddress(1, await rpc.getNewAddress()); + await waitForElectrum(); + + await wallet.refreshWallet({}); + expect(wallet.getBalance()).to.equal(amountSats); + expect(wallet.balance).to.equal(amountSats); + expect(wallet.utxos.length).to.equal(1); + }); + + it('Should track multiple receiving addresses', async () => { + const r1 = await wallet.getNextAvailableAddress(); + if (r1.isErr()) throw r1.error; + const address1 = r1.value.addressIndex.address; + const r2 = await wallet.getNextAvailableAddress(); + if (r2.isErr()) throw r2.error; + const address2 = r2.value.addressIndex.address; + + // Without any transactions addresses should match + expect(address1).to.equal(address2); + + // Send funds, get new address + await rpc.sendToAddress(address1, '0.1'); + await rpc.generateToAddress(1, await rpc.getNewAddress()); + await waitForElectrum(); + const r3 = await wallet.getNextAvailableAddress(); + if (r3.isErr()) throw r3.error; + const address3 = r3.value.addressIndex.address; + + // After a transaction, addresses should differ + expect(address1).to.not.equal(address3); + + // Second transaction + const receivePromise = ml.waitFor('transactionReceived'); + await rpc.sendToAddress(address3, '0.2'); + await receivePromise; + + // unfortinately it is possible that Electrum server still does not + // have second transaction parsed so we need to wait a bit + while (wallet.data.utxos.length === 1) { + await sleep(100); + await wallet.refreshWallet(); + } + + // Check balances + expect(wallet.balance).to.equal(0.3 * 10e7); // 0.3 BTC in sats + + // Test getAddressBalance + const balance1 = await wallet.getAddressBalance(address1); + if (balance1.isErr()) throw balance1.error; + expect(balance1.value.confirmed).to.equal(0.1 * 10e7); + const balance3 = await wallet.getAddressBalance(address3); + if (balance3.isErr()) throw balance3.error; + expect(balance3.value.unconfirmed).to.equal(0.2 * 10e7); + + // Test getAddressesBalance + const combinedBalance = await wallet.getAddressesBalance([ + address1, + address3 + ]); + if (combinedBalance.isErr()) throw combinedBalance.error; + expect(combinedBalance.value).to.equal(0.3 * 10e7); + }); + + it('Should handle unconfirmed transactions', async () => { + const r = await wallet.getNextAvailableAddress(); + if (r.isErr()) throw r.error; + const address = r.value.addressIndex.address; + + await rpc.sendToAddress(address, '0.1'); + await waitForElectrum(); + + // Refresh wallet and check unconfirmed transactions + await wallet.refreshWallet({}); + expect(Object.keys(wallet.data.unconfirmedTransactions)).to.have.length(1); + expect(Object.keys(wallet.data.transactions)).to.have.length(1); + + // Generate blocks to confirm transaction + await rpc.generateToAddress(10, await rpc.getNewAddress()); + await waitForElectrum(); + + // Refresh and check confirmed status + await wallet.refreshWallet({}); + expect(Object.keys(wallet.data.unconfirmedTransactions)).to.have.length(0); + expect(Object.keys(wallet.data.transactions)).to.have.length(1); + }); + + it('Should receive transaction and emit correct messages', async () => { + const r = await wallet.getNextAvailableAddress(); + if (r.isErr()) throw r.error; + const address = r.value.addressIndex.address; + + // test transactionReceived message + const receivePromise = ml.waitFor('transactionReceived'); + const amount = 0.1; + await rpc.sendToAddress(address, amount.toString()); + const txReceivedMessage = await receivePromise; + expect(txReceivedMessage.transaction.value).to.equal(0.1); + + // test transactionConfirmed message + const confirmedPromise = ml.waitFor('transactionConfirmed'); + await rpc.generateToAddress(1, await rpc.getNewAddress()); + await waitForElectrum(); + const txConfirmedMessage = await confirmedPromise; + expect(txConfirmedMessage.transaction.height).to.be.greaterThan(0); + }); +}); diff --git a/tests/storage.test.ts b/tests/storage.test.ts index 10b15da..9025167 100644 --- a/tests/storage.test.ts +++ b/tests/storage.test.ts @@ -1,51 +1,54 @@ -import * as chai from 'chai'; import { validateMnemonic } from 'bip39'; +import { expect } from 'chai'; import net from 'net'; import tls from 'tls'; import { Wallet } from '../'; +import { deleteDirectory, getData, servers, setData } from '../example/helpers'; import { EAddressType, EAvailableNetworks, + generateMnemonic, IGetUtxosResponse, - Result, - generateMnemonic + Result } from '../src'; import { TEST_MNEMONIC } from './constants'; -import { deleteDirectory, getData, servers, setData } from '../example/helpers'; import { EXPECTED_SHARED_RESULTS } from './expected-results'; -const expect = chai.expect; - const testTimeout = 60000; let wallet: Wallet; const WALLET_NAME = 'storagetestwallet0'; -before(async function () { +describe('Storage Test', async function (): Promise { this.timeout(testTimeout); - await deleteDirectory('example/walletData'); // Start test with clean slate. - const res = await Wallet.create({ - mnemonic: TEST_MNEMONIC, - network: EAvailableNetworks.testnet, - name: WALLET_NAME, - addressType: EAddressType.p2wpkh, - storage: { - getData, - setData - }, - electrumOptions: { - servers: servers[EAvailableNetworks.testnet], - net, - tls - } + + before(async function () { + this.timeout(testTimeout); + await deleteDirectory('example/walletData'); // Start test with clean slate. + const res = await Wallet.create({ + mnemonic: TEST_MNEMONIC, + network: EAvailableNetworks.testnet, + name: WALLET_NAME, + addressType: EAddressType.p2wpkh, + storage: { + getData, + setData + }, + electrumOptions: { + servers: servers[EAvailableNetworks.testnet], + net, + tls + } + }); + if (res.isErr()) throw res.error; + wallet = res.value; + }); + + after(async function () { + await wallet?.electrum?.disconnect(); }); - if (res.isErr()) throw res.error; - wallet = res.value; -}); -describe('Storage Test', async function (): Promise { - this.timeout(testTimeout); it('Should successfully create a wallet.', () => { expect(wallet).not.to.be.null; }); diff --git a/tests/transaction.test.ts b/tests/transaction.test.ts index 892d65e..af44cbf 100644 --- a/tests/transaction.test.ts +++ b/tests/transaction.test.ts @@ -1,4 +1,4 @@ -import * as chai from 'chai'; +import { expect } from 'chai'; import net from 'net'; import tls from 'tls'; @@ -8,35 +8,37 @@ import { ISweepPrivateKeyRes, Wallet } from '../'; +import { servers } from '../example/helpers'; import { EAvailableNetworks, Result } from '../src'; import { TRANSACTION_TEST_MNEMONIC } from './constants'; -import { servers } from '../example/helpers'; import { EXPECTED_TRANSACTION_RESULTS } from './expected-results'; -const expect = chai.expect; - const testTimeout = 60000; let wallet: Wallet; -before(async function () { +describe('Transaction Test', async function (): Promise { this.timeout(testTimeout); - const res = await Wallet.create({ - mnemonic: TRANSACTION_TEST_MNEMONIC, - network: EAvailableNetworks.testnet, - electrumOptions: { - servers: servers[EAvailableNetworks.testnet], - net, - tls - } + + before(async function () { + this.timeout(testTimeout); + const res = await Wallet.create({ + mnemonic: TRANSACTION_TEST_MNEMONIC, + network: EAvailableNetworks.testnet, + electrumOptions: { + servers: servers[EAvailableNetworks.testnet], + net, + tls + } + }); + if (res.isErr()) throw res.error; + wallet = res.value; + await wallet.refreshWallet({}); }); - if (res.isErr()) throw res.error; - wallet = res.value; - await wallet.refreshWallet({}); -}); -describe('Transaction Test', async function (): Promise { - this.timeout(testTimeout); + after(async function () { + await wallet?.electrum?.disconnect(); + }); it('Should successfully create a wallet.', (): void => { expect(wallet).not.to.be.null; diff --git a/tests/utils.ts b/tests/utils.ts index 555c142..71ab182 100644 --- a/tests/utils.ts +++ b/tests/utils.ts @@ -1,7 +1,13 @@ import net from 'net'; import BitcoinJsonRpc from 'bitcoin-json-rpc'; import ElectrumClient from 'bw-electrum-client'; -import { EProtocol, sleep } from '../src'; +import { + EProtocol, + sleep, + TMessageDataMap, + TMessageKeys, + TOnMessage +} from '../src'; export const bitcoinURL = 'http://polaruser:polarpass@127.0.0.1:43782'; export const electrumHost = '127.0.0.1'; @@ -82,3 +88,55 @@ export const initWaitForElectrumToSync = async ( return waitForElectrum; }; + +type TMessage = { + key: TMessageKeys; + data: TMessageDataMap[keyof TMessageDataMap]; + timestamp: number; +}; + +export class MessageListener { + public messages: TMessage[] = []; + private resolvers: ((message: TMessage) => void)[] = []; + + onMessage: TOnMessage = (key, data) => { + const message: TMessage = { + key, + data, + timestamp: Date.now() + }; + this.messages.push(message); + this.resolvers.forEach((resolve) => resolve(message)); + }; + + waitFor( + messageKey: K, + timeout = 20000 + ): Promise { + // Check if message already received + const existingMessage = this.messages.find((msg) => msg.key === messageKey); + if (existingMessage) { + return Promise.resolve(existingMessage.data as TMessageDataMap[K]); + } + + // Wait for new message + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + reject(new Error('Timeout waiting for message')); + }, timeout); + + const resolver = (msg: TMessage): void => { + if (msg.key === messageKey) { + clearTimeout(timer); + resolve(msg.data as TMessageDataMap[K]); + } + }; + this.resolvers.push(resolver); + }); + } + + clear(): void { + this.messages = []; + this.resolvers = []; + } +} diff --git a/tests/wallet.test.ts b/tests/wallet.test.ts index b44aa33..17e7926 100644 --- a/tests/wallet.test.ts +++ b/tests/wallet.test.ts @@ -1,16 +1,17 @@ -import * as chai from 'chai'; import { validateMnemonic } from 'bip39'; +import { expect } from 'chai'; import net from 'net'; -import tls from 'tls'; import sinon from 'sinon'; +import tls from 'tls'; import { - filterAddressesObjForGapLimit, filterAddressesObjForAddressesList, + filterAddressesObjForGapLimit, IAddress, IAddresses, Wallet } from '../'; +import { servers } from '../example/helpers'; import { EAddressType, EAvailableNetworks, @@ -22,37 +23,38 @@ import { Result } from '../src'; import { TEST_MNEMONIC } from './constants'; -import { servers } from '../example/helpers'; import { EXPECTED_SHARED_RESULTS, EXPECTED_WALLET_RESULTS } from './expected-results'; -const expect = chai.expect; - const testTimeout = 60000; let wallet: Wallet; -before(async function () { +describe('Wallet Library', async function () { this.timeout(testTimeout); - const res = await Wallet.create({ - mnemonic: TEST_MNEMONIC, - network: EAvailableNetworks.testnet, - addressType: EAddressType.p2wpkh, - electrumOptions: { - servers: servers[EAvailableNetworks.testnet], - net, - tls - } + + before(async function () { + this.timeout(testTimeout); + const res = await Wallet.create({ + mnemonic: TEST_MNEMONIC, + network: EAvailableNetworks.testnet, + addressType: EAddressType.p2wpkh, + electrumOptions: { + servers: servers[EAvailableNetworks.testnet], + net, + tls + } + }); + if (res.isErr()) throw res.error; + wallet = res.value; + await wallet.refreshWallet({}); }); - if (res.isErr()) throw res.error; - wallet = res.value; - await wallet.refreshWallet({}); -}); -describe('Wallet Library', async function () { - this.timeout(testTimeout); + after(async function () { + await wallet?.electrum?.disconnect(); + }); it('Should successfully create a wallet.', () => { expect(wallet).not.to.be.null;