From e472d684561f211bd5b5ce03f4549097d1164b4a Mon Sep 17 00:00:00 2001 From: Anmol Sharma Date: Sun, 21 Jan 2024 23:56:40 +0530 Subject: [PATCH 1/2] feat: added support to receive payments to wallet Signed-off-by: Anmol Sharma --- src/wallet/coin.ts | 46 +++++++++++++++++++++++++ src/wallet/db/db.interface.ts | 4 +++ src/wallet/db/level/db.ts | 14 ++++++++ src/wallet/db/level/layout.ts | 1 + src/wallet/network/esplora.ts | 10 ++++++ src/wallet/network/network.interface.ts | 2 ++ src/wallet/wallet.ts | 16 +++++++++ 7 files changed, 93 insertions(+) create mode 100644 src/wallet/coin.ts diff --git a/src/wallet/coin.ts b/src/wallet/coin.ts new file mode 100644 index 0000000..04dc7d3 --- /dev/null +++ b/src/wallet/coin.ts @@ -0,0 +1,46 @@ +import { toOutputScript } from 'bitcoinjs-lib/src/address'; +import { Network } from 'bitcoinjs-lib'; + +type CoinStatus = { + isConfirmed: boolean; + blockHeight?: number; + blockHash?: string; + blockTime?: number; +}; + +export class Coin { + txid: string; // previous transaction id + vout: number; // index of the output in the previous transaction + value: number; + address: string; + status: CoinStatus; + + constructor(partial: Partial) { + Object.assign(this, partial); + } + + static fromJSON(json: string): Coin { + return new Coin(JSON.parse(json)); + } + + toJSON(): string { + return JSON.stringify({ + txid: this.txid, + vout: this.vout, + value: this.value, + address: this.address, + status: this.status, + }); + } + + toInput(network: Network) { + return { + hash: this.txid, + index: this.vout, + witnessUtxo: { + script: toOutputScript(this.address, network), + value: this.value, + }, + }; + } +} diff --git a/src/wallet/db/db.interface.ts b/src/wallet/db/db.interface.ts index ef34af9..e29140a 100644 --- a/src/wallet/db/db.interface.ts +++ b/src/wallet/db/db.interface.ts @@ -1,4 +1,5 @@ import { Buffer } from 'buffer'; +import { Coin } from '../coin.ts'; export type DbInterface = { open(): Promise; @@ -15,4 +16,7 @@ export type DbInterface = { setReceiveDepth(depth: number): Promise; getChangeDepth(): Promise; setChangeDepth(depth: number): Promise; + getAllAddresses(): Promise; + saveUnspentCoins(coins: Coin[]): Promise; + getUnspentCoins(): Promise; }; diff --git a/src/wallet/db/level/db.ts b/src/wallet/db/level/db.ts index 974ae90..01ae128 100644 --- a/src/wallet/db/level/db.ts +++ b/src/wallet/db/level/db.ts @@ -2,6 +2,7 @@ import { Level } from 'level'; import { wdb } from './layout.ts'; import { DbInterface } from '../db.interface.ts'; import { Buffer } from 'buffer'; +import { Coin } from '../../coin.ts'; export type LevelDBConfigOptions = { location: string; @@ -86,4 +87,17 @@ export class WalletDB implements DbInterface { async setChangeDepth(depth: number): Promise { await this.db.sublevel(wdb.A).put('changeDepth', depth.toString()); } + + async getAllAddresses(): Promise { + return await this.db.sublevel(wdb.A).keys().all(); + } + + async saveUnspentCoins(coins: Coin[]): Promise { + await this.db.sublevel(wdb.C).put('unspent', JSON.stringify(coins)); + } + + async getUnspentCoins(): Promise { + const coins = JSON.parse(await this.db.sublevel(wdb.C).get('unspent')); + return coins.map((coin: string) => Coin.fromJSON(coin)); + } } diff --git a/src/wallet/db/level/layout.ts b/src/wallet/db/level/layout.ts index 6aa40be..375f583 100644 --- a/src/wallet/db/level/layout.ts +++ b/src/wallet/db/level/layout.ts @@ -3,4 +3,5 @@ export const wdb = { V: 'V', // Version M: 'M', // Master key A: 'A', // Address + C: 'C', // Coins }; diff --git a/src/wallet/network/esplora.ts b/src/wallet/network/esplora.ts index 0b65230..c4b3616 100644 --- a/src/wallet/network/esplora.ts +++ b/src/wallet/network/esplora.ts @@ -3,6 +3,7 @@ import { URL } from 'url'; import axios, { AxiosError, AxiosRequestConfig } from 'axios'; import { Network } from 'bitcoinjs-lib'; import { regtest, testnet, bitcoin } from 'bitcoinjs-lib/src/networks'; +import { Coin } from '../coin.ts'; export type EsploraConfigOptions = { network: 'testnet' | 'main' | 'regtest'; @@ -82,4 +83,13 @@ export class EsploraClient implements NetworkInterface { url: `${this.url}//block-height/${height}`, }); } + + async getUTXOs(address: string): Promise { + return ( + await this.request({ + method: 'GET', + url: `${this.url}/address/${address}/utxo`, + }) + ).map((utxo: object) => new Coin({ ...utxo, address })); + } } diff --git a/src/wallet/network/network.interface.ts b/src/wallet/network/network.interface.ts index bb9ab62..6b73997 100644 --- a/src/wallet/network/network.interface.ts +++ b/src/wallet/network/network.interface.ts @@ -1,8 +1,10 @@ import { Network } from 'bitcoinjs-lib'; +import { Coin } from '../coin.ts'; export type NetworkInterface = { get network(): Network; getLatestBlockHeight(): Promise; getLatestBlockHash(): Promise; getBlockHash(height: number): Promise; + getUTXOs(address: string): Promise; }; diff --git a/src/wallet/wallet.ts b/src/wallet/wallet.ts index c6189ca..6927279 100644 --- a/src/wallet/wallet.ts +++ b/src/wallet/wallet.ts @@ -73,4 +73,20 @@ export class Wallet { this.changeDepth++; return address; } + + async scan() { + const addresses = await this.db.getAllAddresses(); + const coins = ( + await Promise.all( + addresses.map((address) => this.network.getUTXOs(address)), + ) + ).reduce((acc, utxos) => [...acc, ...utxos], []); + + await this.db.saveUnspentCoins(coins); + } + + async getBalance(): Promise { + const coins = await this.db.getUnspentCoins(); + return coins.reduce((acc, coin) => acc + coin.value, 0); + } } From eb6102a158778d05fe437145233586c2845a6202 Mon Sep 17 00:00:00 2001 From: Anmol Sharma Date: Sun, 21 Jan 2024 23:58:04 +0530 Subject: [PATCH 2/2] test: added test for scan() and getBalance() Signed-off-by: Anmol Sharma --- .github/workflows/test.yml | 4 + jest.config.ts | 1 + test/helpers/bitcoin-rpc-client.ts | 148 +++++++++++++++++++++++++++++ test/wallet.spec.ts | 38 +++++++- 4 files changed, 190 insertions(+), 1 deletion(-) create mode 100644 test/helpers/bitcoin-rpc-client.ts diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c45295e..394b2e6 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -38,6 +38,10 @@ jobs: command: curl --fail -X GET http://localhost:8094/regtest/api/blocks/tip/height - name: Run unit tests run: npm run test + env: + BITCOIN_RPC_USER: alice + BITCOIN_RPC_PASSWORD: password + BITCOIN_RPC_HOST: localhost:18443 - name: Fetch esplora logs if: always() run: docker-compose -f "./test/helpers/docker-compose.yaml" logs esplora diff --git a/jest.config.ts b/jest.config.ts index 43bbc6c..361fb92 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -11,4 +11,5 @@ module.exports = { collectCoverageFrom: ['/src/**/*.(t|j)s'], coveragePathIgnorePatterns: ['.*.spec.ts'], coverageDirectory: './coverage', + testTimeout: 30000, }; diff --git a/test/helpers/bitcoin-rpc-client.ts b/test/helpers/bitcoin-rpc-client.ts new file mode 100644 index 0000000..ac899b0 --- /dev/null +++ b/test/helpers/bitcoin-rpc-client.ts @@ -0,0 +1,148 @@ +import axios, { AxiosError, AxiosRequestConfig } from 'axios'; + +export class BitcoinRpcClient { + url: string; + config: AxiosRequestConfig; + + constructor() { + const user = process.env.BITCOIN_RPC_USER; + const password = process.env.BITCOIN_RPC_PASSWORD; + const host = process.env.BITCOIN_RPC_HOST; + this.url = `http://${user}:${password}@${host}`; + this.config = { + method: 'POST', + headers: { + 'content-type': 'application/json', + }, + }; + } + + async init() { + let loadWallet = false; + try { + await this.createWallet('default'); + } catch (e) { + if ( + e instanceof AxiosError && + e.response?.data.error.message.includes( + 'Database already exists.', + ) + ) { + loadWallet = true; + } else { + throw e; + } + } + try { + const result = await this.getWalletInfo(); + if (result['walletname'] === 'default') { + loadWallet = false; + } + } catch (e) { + if ( + e instanceof AxiosError && + !e.response?.data.error.message.includes('No wallet is loaded.') + ) { + throw e; + } + } + try { + if (loadWallet) { + await this.loadWallet('default'); + const address = await this.getNewAddress(); + await this.mineToAddress(150, address); + } + } catch (e) { + if ( + e instanceof AxiosError && + !e.response?.data.error.message.includes( + 'Unable to obtain an exclusive lock on the database', + ) + ) { + throw e; + } + } + } + + private async request(config: AxiosRequestConfig) { + try { + const response = await axios.request({ + ...this.config, + ...config, + }); + return response.data?.result; + } catch (e) { + if (e instanceof AxiosError) { + if (e.response?.data.error) { + // eslint-disable-next-line no-console + console.log(e.response.data); + throw new Error(e.response.data.error.message); + } else { + throw new Error(e.message); + } + } else { + throw e; + } + } + } + + async createWallet(walletName: string) { + return await this.request({ + url: this.url, + data: { + method: 'createwallet', + params: [walletName], + }, + }); + } + + async getWalletInfo() { + return await this.request({ + url: this.url, + data: { + method: 'getwalletinfo', + params: [], + }, + }); + } + + async loadWallet(walletName: string) { + return await this.request({ + url: this.url, + data: { + method: 'loadwallet', + params: [walletName], + }, + }); + } + + async getNewAddress() { + return await this.request({ + url: this.url, + data: { + method: 'getnewaddress', + params: [], + }, + }); + } + + async mineToAddress(numBlocks: number, address: string) { + return await this.request({ + url: this.url, + data: { + method: 'generatetoaddress', + params: [numBlocks, address], + }, + }); + } + + async sendToAddress(address: string, amount: number) { + return await this.request({ + url: this.url, + data: { + method: 'sendtoaddress', + params: [address, amount], + }, + }); + } +} diff --git a/test/wallet.spec.ts b/test/wallet.spec.ts index b08aaab..9d90e92 100644 --- a/test/wallet.spec.ts +++ b/test/wallet.spec.ts @@ -1,8 +1,11 @@ import { EsploraClient, Wallet, WalletDB } from '../src/wallet'; import * as fs from 'fs'; +import { BitcoinRpcClient } from './helpers/bitcoin-rpc-client'; describe('Wallet', () => { let wallet: Wallet; + let address: string; + let bitcoinRpcClient: BitcoinRpcClient; beforeAll(async () => { const walletDB = new WalletDB({ @@ -17,6 +20,14 @@ describe('Wallet', () => { network: 'regtest', }), }); + + bitcoinRpcClient = new BitcoinRpcClient(); + await bitcoinRpcClient.init(); + + await bitcoinRpcClient.mineToAddress( + 101, + await bitcoinRpcClient.getNewAddress(), + ); }); it('should initialise the wallet', async () => { @@ -26,7 +37,7 @@ describe('Wallet', () => { }); it('should derive first receive address', async () => { - const address = await wallet.deriveReceiveAddress(); + address = await wallet.deriveReceiveAddress(); expect(address).toBe('bcrt1qcr8te4kr609gcawutmrza0j4xv80jy8zeqchgx'); }); @@ -40,6 +51,31 @@ describe('Wallet', () => { expect(address).toBe('bcrt1q8c6fshw2dlwun7ekn9qwf37cu2rn755ufhry49'); }); + it('should rescan all addresses', async () => { + await bitcoinRpcClient.sendToAddress(address, 0.1); + await bitcoinRpcClient.mineToAddress( + 3, + await bitcoinRpcClient.getNewAddress(), + ); + + await wallet.scan(); + }); + + it('should get balance', async () => { + // we have to do this because esplora is not always in sync with the node + let retryCount = 5; + while (retryCount > 0) { + const balance = await wallet.getBalance(); + if (balance === 0) { + await new Promise((resolve) => setTimeout(resolve, 1000)); + await wallet.scan(); + } + retryCount--; + } + + expect(await wallet.getBalance()).toBe(10000000); + }); + afterAll(async () => { await wallet.close(); fs.rmSync('./test/wallet', { recursive: true, force: true });