Compare commits
3
Commits
c1e28c7545
...
6c894d58ba
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c894d58ba | ||
|
|
3a72d0521a | ||
|
|
8d9054b119 |
@@ -218,6 +218,7 @@ export const getElectrumWalletConfig = (): ElectrumWalletConfig => ({
|
|||||||
rpcUrl: `http://${env('ELECTRUM_DAEMON_HOST')}:${envInt('ELECTRUM_DAEMON_PORT')}`,
|
rpcUrl: `http://${env('ELECTRUM_DAEMON_HOST')}:${envInt('ELECTRUM_DAEMON_PORT')}`,
|
||||||
username: env('ELECTRUM_DAEMON_RPC_USER'),
|
username: env('ELECTRUM_DAEMON_RPC_USER'),
|
||||||
password: env('ELECTRUM_DAEMON_RPC_PASSWORD'),
|
password: env('ELECTRUM_DAEMON_RPC_PASSWORD'),
|
||||||
|
walletPassword: env('ELECTRUM_WALLET_PASSWORD'),
|
||||||
rpcTimeoutMs: envInt('ELECTRUM_DAEMON_RPC_TIMEOUT_MS')
|
rpcTimeoutMs: envInt('ELECTRUM_DAEMON_RPC_TIMEOUT_MS')
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -374,6 +374,10 @@ class EnvironmentVariables {
|
|||||||
@Min(1000)
|
@Min(1000)
|
||||||
ELECTRUM_DAEMON_RPC_TIMEOUT_MS: number;
|
ELECTRUM_DAEMON_RPC_TIMEOUT_MS: number;
|
||||||
|
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsString()
|
||||||
|
ELECTRUM_WALLET_PASSWORD: string;
|
||||||
|
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
@IsString()
|
@IsString()
|
||||||
SIMPLEX_WS_URL: string;
|
SIMPLEX_WS_URL: string;
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
import { AuthModule } from '../auth/AuthModule';
|
||||||
import { BitcoinWalletController } from './controllers/BitcoinWalletController';
|
import { BitcoinWalletController } from './controllers/BitcoinWalletController';
|
||||||
import { BitcoinWalletAdminService } from './services/BitcoinWalletAdminService';
|
import { BitcoinWalletAdminService } from './services/BitcoinWalletAdminService';
|
||||||
import { ElectrumWalletRpcClient } from './services/ElectrumWalletRpcClient';
|
import { ElectrumWalletRpcClient } from './services/ElectrumWalletRpcClient';
|
||||||
import { ElectrumWalletRpcConnectionService } from './services/ElectrumWalletRpcConnectionService';
|
import { ElectrumWalletRpcConnectionService } from './services/ElectrumWalletRpcConnectionService';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
imports: [AuthModule],
|
||||||
controllers: [BitcoinWalletController],
|
controllers: [BitcoinWalletController],
|
||||||
providers: [ElectrumWalletRpcClient, ElectrumWalletRpcConnectionService, BitcoinWalletAdminService],
|
providers: [ElectrumWalletRpcClient, ElectrumWalletRpcConnectionService, BitcoinWalletAdminService],
|
||||||
exports: [ElectrumWalletRpcClient]
|
exports: [ElectrumWalletRpcClient]
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import { Controller, Get, UseGuards } from '@nestjs/common';
|
import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common';
|
||||||
|
import { Throttle } from '@nestjs/throttler';
|
||||||
import { JwtGuard } from '../../../guards/JwtGuard';
|
import { JwtGuard } from '../../../guards/JwtGuard';
|
||||||
|
import { throttleProfiles } from '../../../config/throttleProfiles';
|
||||||
|
import { BitcoinWalletRevealSeedDto } from '../dto/BitcoinWalletRevealSeedDto';
|
||||||
|
import { BitcoinWalletWithdrawDto } from '../dto/BitcoinWalletWithdrawDto';
|
||||||
import { BitcoinWalletAdminService } from '../services/BitcoinWalletAdminService';
|
import { BitcoinWalletAdminService } from '../services/BitcoinWalletAdminService';
|
||||||
|
|
||||||
@Controller('bitcoin-wallet')
|
@Controller('bitcoin-wallet')
|
||||||
@@ -11,4 +15,16 @@ export class BitcoinWalletController {
|
|||||||
getStatus() {
|
getStatus() {
|
||||||
return this.walletAdminService.getStatus();
|
return this.walletAdminService.getStatus();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('/withdraw')
|
||||||
|
@Throttle(throttleProfiles.walletWithdraw)
|
||||||
|
withdraw(@Body() { destinationAddress, feeRateSatVbyte, password }: BitcoinWalletWithdrawDto) {
|
||||||
|
return this.walletAdminService.withdrawAll(destinationAddress, feeRateSatVbyte, password);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('/reveal-seed')
|
||||||
|
@Throttle(throttleProfiles.walletRevealSeed)
|
||||||
|
revealSeed(@Body() { password }: BitcoinWalletRevealSeedDto) {
|
||||||
|
return this.walletAdminService.revealSeed(password);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { IsNotEmpty, IsString } from 'class-validator';
|
||||||
|
|
||||||
|
export class BitcoinWalletRevealSeedDto {
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { Transform } from 'class-transformer';
|
||||||
|
import { IsInt, IsNotEmpty, IsString, Max, Min } from 'class-validator';
|
||||||
|
import { IsBitcoinAddress } from '../../../validation/decorators/isBitcoinAddress';
|
||||||
|
|
||||||
|
export class BitcoinWalletWithdrawDto {
|
||||||
|
@Transform(({ value }: { value: unknown }) => (typeof value === 'string' ? value.trim() : value))
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsBitcoinAddress()
|
||||||
|
destinationAddress: string;
|
||||||
|
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@Max(100)
|
||||||
|
feeRateSatVbyte: number;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { ServiceUnavailableException } from '@nestjs/common';
|
import { BadRequestException, ServiceUnavailableException } from '@nestjs/common';
|
||||||
import type { ConfigService } from '@nestjs/config';
|
import type { ConfigService } from '@nestjs/config';
|
||||||
|
import type { AuthService } from '../../auth/services/AuthService';
|
||||||
import { WalletSyncStatus } from '../../../types/wallet/WalletSyncStatus';
|
import { WalletSyncStatus } from '../../../types/wallet/WalletSyncStatus';
|
||||||
import type { ElectrumWalletRpcClient } from './ElectrumWalletRpcClient';
|
import type { ElectrumWalletRpcClient } from './ElectrumWalletRpcClient';
|
||||||
import { BitcoinWalletAdminService } from './BitcoinWalletAdminService';
|
import { BitcoinWalletAdminService } from './BitcoinWalletAdminService';
|
||||||
@@ -11,6 +12,11 @@ describe('BitcoinWalletAdminService', () => {
|
|||||||
isSynchronized: jest.Mock;
|
isSynchronized: jest.Mock;
|
||||||
getInfo: jest.Mock;
|
getInfo: jest.Mock;
|
||||||
getBalance: jest.Mock;
|
getBalance: jest.Mock;
|
||||||
|
sweepAll: jest.Mock;
|
||||||
|
getSeed: jest.Mock;
|
||||||
|
};
|
||||||
|
let authService: {
|
||||||
|
verifyPassword: jest.Mock;
|
||||||
};
|
};
|
||||||
let configService: {
|
let configService: {
|
||||||
get: jest.Mock;
|
get: jest.Mock;
|
||||||
@@ -27,7 +33,16 @@ describe('BitcoinWalletAdminService', () => {
|
|||||||
getBalance: jest.fn().mockResolvedValue({
|
getBalance: jest.fn().mockResolvedValue({
|
||||||
balanceAtomic: '150000',
|
balanceAtomic: '150000',
|
||||||
confirmedBalanceAtomic: '150000'
|
confirmedBalanceAtomic: '150000'
|
||||||
})
|
}),
|
||||||
|
sweepAll: jest.fn().mockResolvedValue({
|
||||||
|
txHash: 'tx-hash-1',
|
||||||
|
amountAtomic: '140000'
|
||||||
|
}),
|
||||||
|
getSeed: jest.fn().mockResolvedValue('seed words')
|
||||||
|
};
|
||||||
|
|
||||||
|
authService = {
|
||||||
|
verifyPassword: jest.fn()
|
||||||
};
|
};
|
||||||
|
|
||||||
configService = {
|
configService = {
|
||||||
@@ -38,6 +53,7 @@ describe('BitcoinWalletAdminService', () => {
|
|||||||
|
|
||||||
service = new BitcoinWalletAdminService(
|
service = new BitcoinWalletAdminService(
|
||||||
walletRpcClient as unknown as ElectrumWalletRpcClient,
|
walletRpcClient as unknown as ElectrumWalletRpcClient,
|
||||||
|
authService as unknown as AuthService,
|
||||||
configService as unknown as ConfigService
|
configService as unknown as ConfigService
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -71,4 +87,61 @@ describe('BitcoinWalletAdminService', () => {
|
|||||||
|
|
||||||
await expect(service.getStatus()).rejects.toBeInstanceOf(ServiceUnavailableException);
|
await expect(service.getStatus()).rejects.toBeInstanceOf(ServiceUnavailableException);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('rejects withdrawals when there is no confirmed balance', async () => {
|
||||||
|
walletRpcClient.getBalance.mockResolvedValue({
|
||||||
|
balanceAtomic: '0',
|
||||||
|
confirmedBalanceAtomic: '0'
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(service.withdrawAll('bc1qdestination', 8, 'password')).rejects.toThrow(
|
||||||
|
new BadRequestException('No confirmed balance to withdraw.')
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(authService.verifyPassword).toHaveBeenCalledWith('password');
|
||||||
|
expect(walletRpcClient.sweepAll).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects withdrawals while the wallet is still syncing', async () => {
|
||||||
|
walletRpcClient.isSynchronized.mockResolvedValue(false);
|
||||||
|
|
||||||
|
await expect(service.withdrawAll('bc1qdestination', 8, 'password')).rejects.toThrow(
|
||||||
|
new BadRequestException('Wallet is still syncing. Try again after sync completes.')
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sweeps confirmed funds when the wallet is synced', async () => {
|
||||||
|
const result = await service.withdrawAll('bc1qdestination', 12, 'password');
|
||||||
|
|
||||||
|
expect(walletRpcClient.sweepAll).toHaveBeenCalledWith('bc1qdestination', 12);
|
||||||
|
expect(result).toEqual({
|
||||||
|
txHash: 'tx-hash-1',
|
||||||
|
amountBtc: '0.00140000'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reveals the wallet seed after password verification', async () => {
|
||||||
|
await expect(service.revealSeed('password')).resolves.toEqual({ mnemonic: 'seed words' });
|
||||||
|
|
||||||
|
expect(authService.verifyPassword).toHaveBeenCalledWith('password');
|
||||||
|
expect(walletRpcClient.getSeed).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws when seed reveal fails', async () => {
|
||||||
|
walletRpcClient.getSeed.mockRejectedValue(new Error('rpc down'));
|
||||||
|
|
||||||
|
await expect(service.revealSeed('password')).rejects.toThrow(
|
||||||
|
new ServiceUnavailableException('Could not reach the Bitcoin wallet. Try again in a moment.')
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws when withdrawal fails after prechecks pass', async () => {
|
||||||
|
walletRpcClient.sweepAll.mockRejectedValue(new Error('payto failed'));
|
||||||
|
|
||||||
|
await expect(service.withdrawAll('bc1qdestination', 8, 'password')).rejects.toThrow(
|
||||||
|
new ServiceUnavailableException(
|
||||||
|
'Withdrawal failed. Funds may be unspendable dust, still unconfirmed, or the wallet may be out of sync. Refresh status and try again.'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,15 +1,19 @@
|
|||||||
import { Injectable, ServiceUnavailableException } from '@nestjs/common';
|
import { BadRequestException, Injectable, ServiceUnavailableException } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { AuthService } from '../../auth/services/AuthService';
|
||||||
import { convertBtcAtomicToBtc } from '../../../utils/bitcoin/convertBtcAtomicToBtc';
|
import { convertBtcAtomicToBtc } from '../../../utils/bitcoin/convertBtcAtomicToBtc';
|
||||||
import type { Config } from '../../../types/Config';
|
import type { Config } from '../../../types/Config';
|
||||||
import { WalletSyncStatus } from '../../../types/wallet/WalletSyncStatus';
|
import { WalletSyncStatus } from '../../../types/wallet/WalletSyncStatus';
|
||||||
|
import type { BitcoinWalletRevealSeedResult } from '../types/BitcoinWalletRevealSeedResult';
|
||||||
import type { BitcoinWalletStatusView } from '../types/BitcoinWalletStatusView';
|
import type { BitcoinWalletStatusView } from '../types/BitcoinWalletStatusView';
|
||||||
|
import type { BitcoinWalletWithdrawResult } from '../types/BitcoinWalletWithdrawResult';
|
||||||
import { ElectrumWalletRpcClient } from './ElectrumWalletRpcClient';
|
import { ElectrumWalletRpcClient } from './ElectrumWalletRpcClient';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class BitcoinWalletAdminService {
|
export class BitcoinWalletAdminService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly walletRpcClient: ElectrumWalletRpcClient,
|
private readonly walletRpcClient: ElectrumWalletRpcClient,
|
||||||
|
private readonly authService: AuthService,
|
||||||
private readonly configService: ConfigService
|
private readonly configService: ConfigService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -43,6 +47,64 @@ export class BitcoinWalletAdminService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async withdrawAll(
|
||||||
|
destinationAddress: string,
|
||||||
|
feeRateSatVbyte: number,
|
||||||
|
password: string
|
||||||
|
): Promise<BitcoinWalletWithdrawResult> {
|
||||||
|
this.authService.verifyPassword(password);
|
||||||
|
|
||||||
|
let isSynchronized: boolean;
|
||||||
|
let confirmedBalanceAtomic: string;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [syncResult, balanceResult] = await Promise.all([
|
||||||
|
this.walletRpcClient.isSynchronized(),
|
||||||
|
this.walletRpcClient.getBalance()
|
||||||
|
]);
|
||||||
|
|
||||||
|
isSynchronized = syncResult;
|
||||||
|
confirmedBalanceAtomic = balanceResult.confirmedBalanceAtomic;
|
||||||
|
} catch {
|
||||||
|
throw new ServiceUnavailableException('Could not reach the Bitcoin wallet. Try again in a moment.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (confirmedBalanceAtomic === '0') {
|
||||||
|
throw new BadRequestException('No confirmed balance to withdraw.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isSynchronized) {
|
||||||
|
throw new BadRequestException('Wallet is still syncing. Try again after sync completes.');
|
||||||
|
}
|
||||||
|
|
||||||
|
let sweepResult: { txHash: string; amountAtomic: string };
|
||||||
|
|
||||||
|
try {
|
||||||
|
sweepResult = await this.walletRpcClient.sweepAll(destinationAddress, feeRateSatVbyte);
|
||||||
|
} catch {
|
||||||
|
throw new ServiceUnavailableException(
|
||||||
|
'Withdrawal failed. Funds may be unspendable dust, still unconfirmed, or the wallet may be out of sync. Refresh status and try again.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
txHash: sweepResult.txHash,
|
||||||
|
amountBtc: convertBtcAtomicToBtc(sweepResult.amountAtomic)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async revealSeed(password: string): Promise<BitcoinWalletRevealSeedResult> {
|
||||||
|
this.authService.verifyPassword(password);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const mnemonic = await this.walletRpcClient.getSeed();
|
||||||
|
|
||||||
|
return { mnemonic };
|
||||||
|
} catch {
|
||||||
|
throw new ServiceUnavailableException('Could not reach the Bitcoin wallet. Try again in a moment.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private resolveSyncStatus(
|
private resolveSyncStatus(
|
||||||
isSynchronized: boolean,
|
isSynchronized: boolean,
|
||||||
blockHeight: number | null,
|
blockHeight: number | null,
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ describe('ElectrumWalletRpcClient', () => {
|
|||||||
rpcUrl: 'http://electrum.test:7777',
|
rpcUrl: 'http://electrum.test:7777',
|
||||||
username: 'electrum',
|
username: 'electrum',
|
||||||
password: 'secret',
|
password: 'secret',
|
||||||
|
walletPassword: 'wallet-secret',
|
||||||
rpcTimeoutMs: 5000
|
rpcTimeoutMs: 5000
|
||||||
})
|
})
|
||||||
} as unknown as ConfigService);
|
} as unknown as ConfigService);
|
||||||
@@ -74,10 +75,10 @@ describe('ElectrumWalletRpcClient', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('sumIncomingOutputValueAtomic', () => {
|
describe('sumOutputValueAtomic', () => {
|
||||||
it('sums outputs paying to the target address', () => {
|
it('sums outputs paying to the target address', () => {
|
||||||
expect(
|
expect(
|
||||||
clientTest.sumIncomingOutputValueAtomic(
|
clientTest.sumOutputValueAtomic(
|
||||||
{
|
{
|
||||||
outputs: [
|
outputs: [
|
||||||
{ address: 'bc1qother', value_sats: 10_000 },
|
{ address: 'bc1qother', value_sats: 10_000 },
|
||||||
@@ -92,7 +93,7 @@ describe('ElectrumWalletRpcClient', () => {
|
|||||||
|
|
||||||
it('returns zero when no outputs match the address', () => {
|
it('returns zero when no outputs match the address', () => {
|
||||||
expect(
|
expect(
|
||||||
clientTest.sumIncomingOutputValueAtomic(
|
clientTest.sumOutputValueAtomic(
|
||||||
{
|
{
|
||||||
outputs: [{ address: 'bc1qother', value_sats: 10_000 }]
|
outputs: [{ address: 'bc1qother', value_sats: 10_000 }]
|
||||||
},
|
},
|
||||||
@@ -208,4 +209,80 @@ describe('ElectrumWalletRpcClient', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('sweepAll', () => {
|
||||||
|
it('creates, signs, and broadcasts a max-payment transaction', async () => {
|
||||||
|
mockedAxios.post
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
data: {
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 'nullcart',
|
||||||
|
result: 'signed-tx'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
data: {
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 'nullcart',
|
||||||
|
result: {
|
||||||
|
outputs: [{ address: 'bc1qtest', value_sats: 140_000 }]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
data: {
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 'nullcart',
|
||||||
|
result: 'tx-hash-1'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(client.sweepAll('bc1qtest', 10)).resolves.toEqual({
|
||||||
|
txHash: 'tx-hash-1',
|
||||||
|
amountAtomic: '140000'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockedAxios.post).toHaveBeenNthCalledWith(
|
||||||
|
1,
|
||||||
|
'http://electrum.test:7777',
|
||||||
|
{
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 'nullcart',
|
||||||
|
method: 'payto',
|
||||||
|
params: {
|
||||||
|
destination: 'bc1qtest',
|
||||||
|
amount: '!',
|
||||||
|
feerate: '10',
|
||||||
|
password: 'wallet-secret'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
expect.objectContaining({
|
||||||
|
timeout: 120_000
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(mockedAxios.post).toHaveBeenNthCalledWith(
|
||||||
|
2,
|
||||||
|
'http://electrum.test:7777',
|
||||||
|
{
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 'nullcart',
|
||||||
|
method: 'deserialize',
|
||||||
|
params: { tx: 'signed-tx' }
|
||||||
|
},
|
||||||
|
expect.any(Object)
|
||||||
|
);
|
||||||
|
expect(mockedAxios.post).toHaveBeenNthCalledWith(
|
||||||
|
3,
|
||||||
|
'http://electrum.test:7777',
|
||||||
|
{
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 'nullcart',
|
||||||
|
method: 'broadcast',
|
||||||
|
params: { tx: 'signed-tx' }
|
||||||
|
},
|
||||||
|
expect.any(Object)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -88,6 +88,85 @@ export class ElectrumWalletRpcClient {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async sweepAll(
|
||||||
|
destinationAddress: string,
|
||||||
|
feeRateSatVbyte: number
|
||||||
|
): Promise<{ txHash: string; amountAtomic: string }> {
|
||||||
|
const signedTransaction = await this.payToMax(destinationAddress, feeRateSatVbyte);
|
||||||
|
const transaction = await this.deserializeTransaction(signedTransaction);
|
||||||
|
|
||||||
|
const amountAtomic = this.sumOutputValueAtomic(transaction, destinationAddress);
|
||||||
|
|
||||||
|
if (amountAtomic === '0') {
|
||||||
|
throw new Error('Withdrawal transaction has no spendable output to the destination address');
|
||||||
|
}
|
||||||
|
|
||||||
|
const txHash = await this.broadcast(signedTransaction);
|
||||||
|
|
||||||
|
return { txHash, amountAtomic };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async payToMax(destinationAddress: string, feeRateSatVbyte: number): Promise<string> {
|
||||||
|
const { walletPassword } = this.configService.get('electrumWallet') as Config['electrumWallet'];
|
||||||
|
|
||||||
|
const signedTransaction = await this.call<string>(
|
||||||
|
'payto',
|
||||||
|
{
|
||||||
|
destination: destinationAddress,
|
||||||
|
amount: '!',
|
||||||
|
feerate: String(feeRateSatVbyte),
|
||||||
|
password: walletPassword
|
||||||
|
},
|
||||||
|
{ timeoutMs: 120_000 }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!signedTransaction) {
|
||||||
|
throw new Error('Electrum wallet RPC payto returned no transaction');
|
||||||
|
}
|
||||||
|
|
||||||
|
return signedTransaction;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async broadcast(signedTransaction: string): Promise<string> {
|
||||||
|
const txHash = await this.call<string>('broadcast', { tx: signedTransaction });
|
||||||
|
|
||||||
|
if (!txHash) {
|
||||||
|
throw new Error('Electrum wallet RPC broadcast returned no transaction hash');
|
||||||
|
}
|
||||||
|
|
||||||
|
return txHash;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async deserializeTransaction(signedTransaction: string): Promise<ElectrumWalletDeserializedTransaction> {
|
||||||
|
return this.call<ElectrumWalletDeserializedTransaction>('deserialize', { tx: signedTransaction });
|
||||||
|
}
|
||||||
|
|
||||||
|
private sumOutputValueAtomic(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');
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSeed(): Promise<string> {
|
||||||
|
const { walletPassword } = this.configService.get('electrumWallet') as Config['electrumWallet'];
|
||||||
|
|
||||||
|
const mnemonic = await this.call<string>('getseed', { password: walletPassword });
|
||||||
|
|
||||||
|
if (!mnemonic) {
|
||||||
|
throw new Error('Electrum wallet RPC getseed returned no mnemonic');
|
||||||
|
}
|
||||||
|
|
||||||
|
return mnemonic;
|
||||||
|
}
|
||||||
|
|
||||||
async createAddress(label?: string): Promise<string> {
|
async createAddress(label?: string): Promise<string> {
|
||||||
const address = await this.call<string>('createnewaddress');
|
const address = await this.call<string>('createnewaddress');
|
||||||
|
|
||||||
@@ -120,12 +199,8 @@ export class ElectrumWalletRpcClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const serializedTransaction = await this.call<string>('gettransaction', { txid: txHash });
|
const serializedTransaction = await this.call<string>('gettransaction', { txid: txHash });
|
||||||
|
const transaction = await this.deserializeTransaction(serializedTransaction);
|
||||||
const transaction = await this.call<ElectrumWalletDeserializedTransaction>('deserialize', {
|
const amountAtomic = this.sumOutputValueAtomic(transaction, address);
|
||||||
tx: serializedTransaction
|
|
||||||
});
|
|
||||||
|
|
||||||
const amountAtomic = this.sumIncomingOutputValueAtomic(transaction, address);
|
|
||||||
|
|
||||||
return this.mapIncomingTransfer(entry, amountAtomic, blockHeight);
|
return this.mapIncomingTransfer(entry, amountAtomic, blockHeight);
|
||||||
})
|
})
|
||||||
@@ -134,20 +209,6 @@ export class ElectrumWalletRpcClient {
|
|||||||
return transfers.filter((transfer): transfer is ElectrumWalletIncomingTransfer => transfer !== null);
|
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(
|
private mapIncomingTransfer(
|
||||||
entry: ElectrumWalletAddressHistoryEntry,
|
entry: ElectrumWalletAddressHistoryEntry,
|
||||||
amountAtomic: string,
|
amountAtomic: string,
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export interface BitcoinWalletRevealSeedResult {
|
||||||
|
mnemonic: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export interface BitcoinWalletWithdrawResult {
|
||||||
|
txHash: string;
|
||||||
|
amountBtc: string;
|
||||||
|
}
|
||||||
@@ -8,5 +8,5 @@ export type ElectrumWalletRpcClientTest = {
|
|||||||
amountAtomic: string,
|
amountAtomic: string,
|
||||||
blockHeight: number | null
|
blockHeight: number | null
|
||||||
) => ElectrumWalletIncomingTransfer | null;
|
) => ElectrumWalletIncomingTransfer | null;
|
||||||
sumIncomingOutputValueAtomic: (transaction: ElectrumWalletDeserializedTransaction, address: string) => string;
|
sumOutputValueAtomic: (transaction: ElectrumWalletDeserializedTransaction, address: string) => string;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,5 +6,6 @@ export interface ElectrumWalletConfig {
|
|||||||
rpcUrl: string;
|
rpcUrl: string;
|
||||||
username: string;
|
username: string;
|
||||||
password: string;
|
password: string;
|
||||||
|
walletPassword: string;
|
||||||
rpcTimeoutMs: number;
|
rpcTimeoutMs: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { Validate, ValidatorConstraint, type ValidatorConstraintInterface } from 'class-validator';
|
||||||
|
import { getElectrumWalletConfig } from '../../config';
|
||||||
|
import { ElectrumNetwork } from '../../types/ElectrumNetwork';
|
||||||
|
|
||||||
|
// Soft validation only: prefix/length checks to reject obvious garbage and wrong-network
|
||||||
|
// addresses early. Checksums and spendability are validated by Electrum on payto.
|
||||||
|
const BASE58 = '[1-9A-HJ-NP-Za-km-z]';
|
||||||
|
|
||||||
|
const NETWORK_ADDRESS_PATTERNS: Record<ElectrumNetwork, RegExp[]> = {
|
||||||
|
[ElectrumNetwork.Mainnet]: [
|
||||||
|
new RegExp(`^1${BASE58}{25,34}$`),
|
||||||
|
new RegExp(`^3${BASE58}{25,34}$`),
|
||||||
|
/^bc1[a-z0-9]{25,87}$/
|
||||||
|
],
|
||||||
|
[ElectrumNetwork.Testnet4]: [
|
||||||
|
new RegExp(`^[mn]${BASE58}{25,34}$`),
|
||||||
|
new RegExp(`^2${BASE58}{25,34}$`),
|
||||||
|
/^(?:tb1|bcrt1)[a-z0-9]{25,87}$/
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
@ValidatorConstraint({ name: 'isBitcoinAddress' })
|
||||||
|
class IsBitcoinAddressConstraint implements ValidatorConstraintInterface {
|
||||||
|
validate(value: unknown): boolean {
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { network } = getElectrumWalletConfig();
|
||||||
|
const trimmed = value.trim();
|
||||||
|
|
||||||
|
return NETWORK_ADDRESS_PATTERNS[network].some(pattern => pattern.test(trimmed));
|
||||||
|
}
|
||||||
|
|
||||||
|
defaultMessage(): string {
|
||||||
|
const { network } = getElectrumWalletConfig();
|
||||||
|
|
||||||
|
return `Enter a valid ${network} Bitcoin address.`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const IsBitcoinAddress = () => Validate(IsBitcoinAddressConstraint);
|
||||||
Reference in New Issue
Block a user