Slave/btc integration #4
@@ -0,0 +1,211 @@
|
|||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import axios from 'axios';
|
||||||
|
import type { ElectrumWalletRpcClientTest } from '../types/ElectrumWalletRpcClientTest';
|
||||||
|
import { ElectrumWalletRpcClient } from './ElectrumWalletRpcClient';
|
||||||
|
|
||||||
|
jest.mock('axios');
|
||||||
|
|
||||||
|
const mockedAxios = axios as jest.Mocked<typeof axios>;
|
||||||
|
|
||||||
|
describe('ElectrumWalletRpcClient', () => {
|
||||||
|
let client: ElectrumWalletRpcClient;
|
||||||
|
let clientTest: ElectrumWalletRpcClientTest;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
client = new ElectrumWalletRpcClient({
|
||||||
|
get: jest.fn().mockReturnValue({
|
||||||
|
rpcUrl: 'http://electrum.test:7777',
|
||||||
|
username: 'electrum',
|
||||||
|
password: 'secret',
|
||||||
|
rpcTimeoutMs: 5000
|
||||||
|
})
|
||||||
|
} as unknown as ConfigService);
|
||||||
|
|
||||||
|
clientTest = client as unknown as ElectrumWalletRpcClientTest;
|
||||||
|
mockedAxios.post.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('mapIncomingTransfer', () => {
|
||||||
|
it('maps confirmed transfers with confirmations derived from block height', () => {
|
||||||
|
expect(
|
||||||
|
clientTest.mapIncomingTransfer(
|
||||||
|
{
|
||||||
|
tx_hash: 'abc123',
|
||||||
|
height: 800_000
|
||||||
|
},
|
||||||
|
'50000',
|
||||||
|
800_002
|
||||||
|
)
|
||||||
|
).toEqual({
|
||||||
|
txHash: 'abc123',
|
||||||
|
amountAtomic: '50000',
|
||||||
|
confirmations: 3
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns zero confirmations for unconfirmed transfers', () => {
|
||||||
|
expect(
|
||||||
|
clientTest.mapIncomingTransfer(
|
||||||
|
{
|
||||||
|
tx_hash: 'abc123',
|
||||||
|
height: 0
|
||||||
|
},
|
||||||
|
'50000',
|
||||||
|
800_002
|
||||||
|
)
|
||||||
|
).toEqual({
|
||||||
|
txHash: 'abc123',
|
||||||
|
amountAtomic: '50000',
|
||||||
|
confirmations: 0
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for non-positive amounts', () => {
|
||||||
|
expect(
|
||||||
|
clientTest.mapIncomingTransfer(
|
||||||
|
{
|
||||||
|
tx_hash: 'abc123',
|
||||||
|
height: 800_000
|
||||||
|
},
|
||||||
|
'0',
|
||||||
|
800_002
|
||||||
|
)
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('sumIncomingOutputValueAtomic', () => {
|
||||||
|
it('sums outputs paying to the target address', () => {
|
||||||
|
expect(
|
||||||
|
clientTest.sumIncomingOutputValueAtomic(
|
||||||
|
{
|
||||||
|
outputs: [
|
||||||
|
{ address: 'bc1qother', value_sats: 10_000 },
|
||||||
|
{ address: 'bc1qtest', value_sats: 50_000 },
|
||||||
|
{ address: 'bc1qtest', value_sats: 25_000 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
'bc1qtest'
|
||||||
|
)
|
||||||
|
).toBe('75000');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns zero when no outputs match the address', () => {
|
||||||
|
expect(
|
||||||
|
clientTest.sumIncomingOutputValueAtomic(
|
||||||
|
{
|
||||||
|
outputs: [{ address: 'bc1qother', value_sats: 10_000 }]
|
||||||
|
},
|
||||||
|
'bc1qtest'
|
||||||
|
)
|
||||||
|
).toBe('0');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getIncomingTransfers', () => {
|
||||||
|
it('resolves incoming amounts from transaction outputs', async () => {
|
||||||
|
mockedAxios.post
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
data: {
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 'nullcart',
|
||||||
|
result: [{ tx_hash: 'abc123', height: 800_000 }]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
data: {
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 'nullcart',
|
||||||
|
result: '01000000'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
data: {
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 'nullcart',
|
||||||
|
result: {
|
||||||
|
outputs: [
|
||||||
|
{ address: 'bc1qother', value_sats: 10_000 },
|
||||||
|
{ address: 'bc1qtest', value_sats: 50_000 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(client.getIncomingTransfers('bc1qtest', 800_002)).resolves.toEqual([
|
||||||
|
{
|
||||||
|
txHash: 'abc123',
|
||||||
|
amountAtomic: '50000',
|
||||||
|
confirmations: 3
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(mockedAxios.post).toHaveBeenNthCalledWith(
|
||||||
|
2,
|
||||||
|
'http://electrum.test:7777',
|
||||||
|
{
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 'nullcart',
|
||||||
|
method: 'gettransaction',
|
||||||
|
params: { txid: 'abc123' }
|
||||||
|
},
|
||||||
|
expect.any(Object)
|
||||||
|
);
|
||||||
|
expect(mockedAxios.post).toHaveBeenNthCalledWith(
|
||||||
|
3,
|
||||||
|
'http://electrum.test:7777',
|
||||||
|
{
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 'nullcart',
|
||||||
|
method: 'deserialize',
|
||||||
|
params: { tx: '01000000' }
|
||||||
|
},
|
||||||
|
expect.any(Object)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createAddress', () => {
|
||||||
|
it('creates an address and sets a label when provided', async () => {
|
||||||
|
mockedAxios.post
|
||||||
|
.mockResolvedValueOnce({ data: { jsonrpc: '2.0', id: 'nullcart', result: 'bc1qtest' } })
|
||||||
|
.mockResolvedValueOnce({ data: { jsonrpc: '2.0', id: 'nullcart', result: true } });
|
||||||
|
|
||||||
|
await expect(client.createAddress('checkout - order-1')).resolves.toBe('bc1qtest');
|
||||||
|
|
||||||
|
expect(mockedAxios.post).toHaveBeenNthCalledWith(
|
||||||
|
2,
|
||||||
|
'http://electrum.test:7777',
|
||||||
|
{
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 'nullcart',
|
||||||
|
method: 'setlabel',
|
||||||
|
params: { key: 'bc1qtest', label: 'checkout - order-1' }
|
||||||
|
},
|
||||||
|
expect.objectContaining({
|
||||||
|
auth: { username: 'electrum', password: 'secret' }
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getBalance', () => {
|
||||||
|
it('returns confirmed and total balances in atomic units', async () => {
|
||||||
|
mockedAxios.post.mockResolvedValueOnce({
|
||||||
|
data: {
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 'nullcart',
|
||||||
|
result: {
|
||||||
|
confirmed: '0.00025',
|
||||||
|
unconfirmed: '0.0001'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(client.getBalance()).resolves.toEqual({
|
||||||
|
balanceAtomic: '35000',
|
||||||
|
confirmedBalanceAtomic: '25000'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import axios from 'axios';
|
||||||
|
import type { Config } from '../../../types/Config';
|
||||||
|
import { addAtomic } from '../../../utils/atomic/addAtomic';
|
||||||
|
import { convertBtcToBtcAtomic } from '../../../utils/bitcoin/convertBtcToBtcAtomic';
|
||||||
|
import type { ElectrumWalletAddressHistoryEntry } from '../types/ElectrumWalletAddressHistoryEntry';
|
||||||
|
import type { ElectrumWalletDeserializedTransaction } from '../types/ElectrumWalletDeserializedTransaction';
|
||||||
|
import type { ElectrumWalletGetBalanceResult } from '../types/ElectrumWalletGetBalanceResult';
|
||||||
|
import type { ElectrumWalletGetInfoResult } from '../types/ElectrumWalletGetInfoResult';
|
||||||
|
import type { ElectrumWalletIncomingTransfer } from '../types/ElectrumWalletIncomingTransfer';
|
||||||
|
import type { ElectrumWalletRpcResponse } from '../types/ElectrumWalletRpcResponse';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ElectrumWalletRpcClient {
|
||||||
|
constructor(private readonly configService: ConfigService) {}
|
||||||
|
|
||||||
|
private async call<T>(
|
||||||
|
method: string,
|
||||||
|
params: Record<string, unknown> | unknown[] = {},
|
||||||
|
options: { timeoutMs?: number } = {}
|
||||||
|
): Promise<T> {
|
||||||
|
const { rpcUrl, username, password, rpcTimeoutMs } = this.configService.get(
|
||||||
|
'electrumWallet'
|
||||||
|
) as Config['electrumWallet'];
|
||||||
|
|
||||||
|
const { data } = await axios.post<ElectrumWalletRpcResponse<T>>(
|
||||||
|
rpcUrl,
|
||||||
|
{
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 'nullcart',
|
||||||
|
method,
|
||||||
|
params
|
||||||
|
},
|
||||||
|
{
|
||||||
|
timeout: options.timeoutMs ?? rpcTimeoutMs,
|
||||||
|
auth: {
|
||||||
|
username,
|
||||||
|
password
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (data.error) {
|
||||||
|
throw new Error(data.error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.result === undefined) {
|
||||||
|
throw new Error(`Electrum wallet RPC ${method} returned no result`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return data.result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getVersion(): Promise<string> {
|
||||||
|
const version = await this.call<string>('version');
|
||||||
|
|
||||||
|
if (!version) {
|
||||||
|
throw new Error('Electrum wallet RPC version returned no version');
|
||||||
|
}
|
||||||
|
|
||||||
|
return version;
|
||||||
|
}
|
||||||
|
|
||||||
|
async isSynchronized(): Promise<boolean> {
|
||||||
|
return this.call<boolean>('is_synchronized');
|
||||||
|
}
|
||||||
|
|
||||||
|
async getInfo(): Promise<ElectrumWalletGetInfoResult> {
|
||||||
|
return this.call<ElectrumWalletGetInfoResult>('getinfo');
|
||||||
|
}
|
||||||
|
|
||||||
|
async getBalance(): Promise<{ balanceAtomic: string; confirmedBalanceAtomic: string }> {
|
||||||
|
const { confirmed, unconfirmed } = await this.call<ElectrumWalletGetBalanceResult>('getbalance');
|
||||||
|
|
||||||
|
if (confirmed === undefined) {
|
||||||
|
throw new Error('Electrum wallet RPC getbalance returned incomplete result');
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmedAtomic = convertBtcToBtcAtomic(confirmed);
|
||||||
|
|
||||||
|
const unconfirmedAtomic =
|
||||||
|
unconfirmed !== undefined && unconfirmed !== '0' ? convertBtcToBtcAtomic(unconfirmed) : '0';
|
||||||
|
|
||||||
|
return {
|
||||||
|
balanceAtomic: addAtomic(confirmedAtomic, unconfirmedAtomic),
|
||||||
|
confirmedBalanceAtomic: confirmedAtomic
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async createAddress(label?: string): Promise<string> {
|
||||||
|
const address = await this.call<string>('createnewaddress');
|
||||||
|
|
||||||
|
if (!address) {
|
||||||
|
throw new Error('Electrum wallet RPC createnewaddress returned no address');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (label) {
|
||||||
|
await this.call('setlabel', { key: address, label });
|
||||||
|
}
|
||||||
|
|
||||||
|
return address;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getIncomingTransfers(address: string, blockHeight: number | null): Promise<ElectrumWalletIncomingTransfer[]> {
|
||||||
|
const history = await this.call<ElectrumWalletAddressHistoryEntry[]>('getaddresshistory', {
|
||||||
|
address
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!Array.isArray(history)) {
|
||||||
|
throw new Error('Electrum wallet RPC getaddresshistory returned invalid result');
|
||||||
|
}
|
||||||
|
|
||||||
|
const transfers = await Promise.all(
|
||||||
|
history.map(async entry => {
|
||||||
|
const txHash = entry.tx_hash;
|
||||||
|
|
||||||
|
if (!txHash) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const serializedTransaction = await this.call<string>('gettransaction', { txid: txHash });
|
||||||
|
|
||||||
|
const transaction = await this.call<ElectrumWalletDeserializedTransaction>('deserialize', {
|
||||||
|
tx: serializedTransaction
|
||||||
|
});
|
||||||
|
|
||||||
|
const amountAtomic = this.sumIncomingOutputValueAtomic(transaction, address);
|
||||||
|
|
||||||
|
return this.mapIncomingTransfer(entry, amountAtomic, blockHeight);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return transfers.filter((transfer): transfer is ElectrumWalletIncomingTransfer => transfer !== null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sumIncomingOutputValueAtomic(transaction: ElectrumWalletDeserializedTransaction, address: string): string {
|
||||||
|
if (!Array.isArray(transaction.outputs)) {
|
||||||
|
return '0';
|
||||||
|
}
|
||||||
|
|
||||||
|
return transaction.outputs.reduce((sum, output) => {
|
||||||
|
if (output.address !== address || !Number.isFinite(output.value_sats) || output.value_sats <= 0) {
|
||||||
|
return sum;
|
||||||
|
}
|
||||||
|
|
||||||
|
return addAtomic(sum, String(output.value_sats));
|
||||||
|
}, '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
private mapIncomingTransfer(
|
||||||
|
entry: ElectrumWalletAddressHistoryEntry,
|
||||||
|
amountAtomic: string,
|
||||||
|
blockHeight: number | null
|
||||||
|
): ElectrumWalletIncomingTransfer | null {
|
||||||
|
const txHash = entry.tx_hash;
|
||||||
|
|
||||||
|
if (!txHash || amountAtomic === '0') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmations =
|
||||||
|
entry.height > 0 && blockHeight !== null ? Math.max(blockHeight - entry.height + 1, 0) : 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
txHash,
|
||||||
|
amountAtomic,
|
||||||
|
confirmations
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||||
|
import { getErrorMessage } from '../../../utils/getErrorMessage';
|
||||||
|
import { ElectrumWalletRpcClient } from './ElectrumWalletRpcClient';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ElectrumWalletRpcConnectionService implements OnModuleInit {
|
||||||
|
private readonly logger = new Logger(ElectrumWalletRpcConnectionService.name);
|
||||||
|
|
||||||
|
constructor(private readonly walletRpcClient: ElectrumWalletRpcClient) {}
|
||||||
|
|
||||||
|
async onModuleInit(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const version = await this.walletRpcClient.getVersion();
|
||||||
|
|
||||||
|
this.logger.log(`Connected to electrum-daemon (version ${version})`);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Failed to reach electrum-daemon at startup: ${getErrorMessage(error)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export type ElectrumWalletAddressHistoryEntry = {
|
||||||
|
tx_hash: string;
|
||||||
|
height: number;
|
||||||
|
};
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
export type ElectrumWalletDeserializedOutput = {
|
||||||
|
address?: string;
|
||||||
|
value_sats: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ElectrumWalletDeserializedTransaction = {
|
||||||
|
outputs: ElectrumWalletDeserializedOutput[];
|
||||||
|
};
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export type ElectrumWalletGetBalanceResult = {
|
||||||
|
confirmed: string;
|
||||||
|
unconfirmed?: string;
|
||||||
|
};
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export type ElectrumWalletGetInfoResult = {
|
||||||
|
blockchain_height?: number;
|
||||||
|
server?: string;
|
||||||
|
server_height?: number;
|
||||||
|
};
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export type ElectrumWalletIncomingTransfer = {
|
||||||
|
txHash: string;
|
||||||
|
amountAtomic: string;
|
||||||
|
confirmations: number;
|
||||||
|
};
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import type { ElectrumWalletAddressHistoryEntry } from './ElectrumWalletAddressHistoryEntry';
|
||||||
|
import type { ElectrumWalletDeserializedTransaction } from './ElectrumWalletDeserializedTransaction';
|
||||||
|
import type { ElectrumWalletIncomingTransfer } from './ElectrumWalletIncomingTransfer';
|
||||||
|
|
||||||
|
export type ElectrumWalletRpcClientTest = {
|
||||||
|
mapIncomingTransfer: (
|
||||||
|
entry: ElectrumWalletAddressHistoryEntry,
|
||||||
|
amountAtomic: string,
|
||||||
|
blockHeight: number | null
|
||||||
|
) => ElectrumWalletIncomingTransfer | null;
|
||||||
|
sumIncomingOutputValueAtomic: (transaction: ElectrumWalletDeserializedTransaction, address: string) => string;
|
||||||
|
};
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
export type ElectrumWalletRpcResponse<T> = {
|
||||||
|
id: string | number;
|
||||||
|
jsonrpc: string;
|
||||||
|
result?: T;
|
||||||
|
error?: {
|
||||||
|
code: number;
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user