From 9cc70095852996a5934d3e089aefed2ebf3abf20 Mon Sep 17 00:00:00 2001 From: michael1011 Date: Sun, 4 Feb 2024 01:20:23 +0100 Subject: [PATCH] feat: batched normal swap claims --- lib/Config.ts | 9 + lib/Core.ts | 55 +- lib/chain/ChainClient.ts | 4 + lib/cli/commands/SweepSwaps.ts | 35 ++ lib/consts/Enums.ts | 1 + lib/db/repositories/SwapRepository.ts | 8 + lib/grpc/GrpcServer.ts | 1 + lib/grpc/GrpcService.ts | 31 ++ lib/notifications/CommandHandler.ts | 15 + lib/proto/boltzrpc_grpc_pb.d.ts | 17 + lib/proto/boltzrpc_grpc_pb.js | 33 ++ lib/proto/boltzrpc_pb.d.ts | 69 +++ lib/proto/boltzrpc_pb.js | 503 ++++++++++++++++++ lib/service/EventHandler.ts | 6 + lib/service/Service.ts | 3 +- lib/service/cooperative/DeferredClaimer.ts | 343 ++++++++++++ lib/service/cooperative/MusigBase.ts | 51 ++ lib/service/{ => cooperative}/MusigSigner.ts | 69 +-- lib/swap/SwapManager.ts | 16 + lib/swap/SwapNursery.ts | 63 +-- proto/boltzrpc.proto | 13 + test/integration/Core.spec.ts | 123 ++++- .../cooperative/DeferredClaimer.spec.ts | 410 ++++++++++++++ .../{ => cooperative}/MusigSigner.spec.ts | 38 +- test/unit/api/v2/ApiV2.spec.ts | 7 +- test/unit/grpc/GrpcService.spec.ts | 53 ++ .../unit/notifications/CommandHandler.spec.ts | 32 +- test/unit/service/EventHandler.spec.ts | 23 + test/unit/service/Service.spec.ts | 1 + test/unit/swap/SwapManager.spec.ts | 4 + 30 files changed, 1917 insertions(+), 119 deletions(-) create mode 100644 lib/cli/commands/SweepSwaps.ts create mode 100644 lib/service/cooperative/DeferredClaimer.ts create mode 100644 lib/service/cooperative/MusigBase.ts rename lib/service/{ => cooperative}/MusigSigner.ts (71%) create mode 100644 test/integration/service/cooperative/DeferredClaimer.spec.ts rename test/integration/service/{ => cooperative}/MusigSigner.spec.ts (91%) diff --git a/lib/Config.ts b/lib/Config.ts index 202e6246..51cc409b 100644 --- a/lib/Config.ts +++ b/lib/Config.ts @@ -13,6 +13,7 @@ import { LndConfig } from './lightning/LndClient'; import { ClnConfig } from './lightning/cln/Types'; import { BlocksConfig } from './service/Blocks'; import { MarkingsConfig } from './service/CountryCodes'; +import { SwapConfig } from './service/cooperative/DeferredClaimer'; import { NodeSwitchConfig } from './swap/NodeSwitch'; type PostgresConfig = { @@ -164,6 +165,8 @@ type ConfigType = { prepayminerfee: boolean; swapwitnessaddress: boolean; + swap: SwapConfig; + marking: MarkingsConfig; blocks: BlocksConfig; @@ -223,6 +226,12 @@ class Config { prepayminerfee: false, swapwitnessaddress: false, + swap: { + deferredClaimSymbols: ['L-BTC'], + batchClaimInterval: '*/15 * * * *', + expiryTolerance: 120, + }, + marking: { ipV4Ranges: 'https://cdn.jsdelivr.net/npm/@ip-location-db/asn-country/asn-country-ipv4-num.csv', diff --git a/lib/Core.ts b/lib/Core.ts index 2399ef7b..88b27dcf 100644 --- a/lib/Core.ts +++ b/lib/Core.ts @@ -10,11 +10,14 @@ import { import { ClaimDetails, Musig, + OutputType, RefundDetails, + SwapTreeSerializer, TaprootUtils, Types, constructClaimTransaction as constructClaimTransactionBitcoin, constructRefundTransaction as constructRefundTransactionBitcoin, + detectSwap, init, targetFee, } from 'boltz-core'; @@ -45,8 +48,10 @@ import { reverseBuffer, } from './Utils'; import ChainClient from './chain/ChainClient'; -import { CurrencyType } from './consts/Enums'; +import { CurrencyType, SwapVersion } from './consts/Enums'; import { liquidSymbol } from './consts/LiquidTypes'; +import Swap from './db/models/Swap'; +import SwapOutputType from './swap/SwapOutputType'; import Wallet from './wallet/Wallet'; import WalletLiquid from './wallet/WalletLiquid'; import { Currency } from './wallet/WalletManager'; @@ -156,6 +161,54 @@ export const getOutputValue = ( return unblinded.isLbtc ? unblinded.value : 0; }; +export const constructClaimDetails = ( + swapOutputType: SwapOutputType, + wallet: Wallet, + swap: Swap, + transaction: Transaction | LiquidTransaction, + preimage: Buffer, +): ClaimDetails | LiquidClaimDetails => { + // Compatibility mode with database schema version 0 in which this column didn't exist + if (swap.lockupTransactionVout === undefined) { + swap.lockupTransactionVout = detectSwap( + getHexBuffer(swap.redeemScript!), + transaction, + )!.vout; + } + + const output = transaction.outs[swap.lockupTransactionVout!]; + const claimDetails = { + ...output, + preimage, + txHash: transaction.getHash(), + vout: swap.lockupTransactionVout!, + keys: wallet.getKeysByIndex(swap.keyIndex!), + } as ClaimDetails | LiquidClaimDetails; + + switch (swap.version) { + case SwapVersion.Taproot: { + claimDetails.type = OutputType.Taproot; + claimDetails.cooperative = false; + claimDetails.swapTree = SwapTreeSerializer.deserializeSwapTree( + swap.redeemScript!, + ); + claimDetails.internalKey = createMusig( + claimDetails.keys, + getHexBuffer(swap.refundPublicKey!), + ).getAggregatedPublicKey(); + break; + } + + default: { + claimDetails.type = swapOutputType.get(wallet.type); + claimDetails.redeemScript = getHexBuffer(swap.redeemScript!); + break; + } + } + + return claimDetails; +}; + export const constructClaimTransaction = ( wallet: Wallet, claimDetails: ClaimDetails[] | LiquidClaimDetails[], diff --git a/lib/chain/ChainClient.ts b/lib/chain/ChainClient.ts index 73058ff7..202a8bbb 100644 --- a/lib/chain/ChainClient.ts +++ b/lib/chain/ChainClient.ts @@ -233,6 +233,10 @@ class ChainClient extends BaseClient { } }; + public getRawMempool = async () => { + return this.client.request('getrawmempool'); + }; + public estimateFee = async (confTarget = 2): Promise => { return this.estimateFeeWithFloor(confTarget); }; diff --git a/lib/cli/commands/SweepSwaps.ts b/lib/cli/commands/SweepSwaps.ts new file mode 100644 index 00000000..90147861 --- /dev/null +++ b/lib/cli/commands/SweepSwaps.ts @@ -0,0 +1,35 @@ +import { Arguments } from 'yargs'; +import { SweepSwapsRequest } from '../../proto/boltzrpc_pb'; +import BuilderComponents from '../BuilderComponents'; +import { callback, loadBoltzClient } from '../Command'; + +export const command = 'sweepswaps [symbol]'; + +export const describe = 'sweeps all deferred swap claims'; + +export const builder = { + symbol: BuilderComponents.symbol, +}; + +export const handler = (argv: Arguments): void => { + const request = new SweepSwapsRequest(); + + if (argv.symbol !== undefined && argv.symbol !== '') { + request.setSymbol(argv.symbol); + } + + loadBoltzClient(argv).sweepSwaps( + request, + callback((res) => { + const sweep: Record = {}; + + for (const [, [symbol, swapIds]] of res + .toObject() + .claimedSymbolsMap.entries()) { + sweep[symbol] = swapIds.claimedIdsList; + } + + return sweep; + }), + ); +}; diff --git a/lib/consts/Enums.ts b/lib/consts/Enums.ts index dd37db13..ce587771 100644 --- a/lib/consts/Enums.ts +++ b/lib/consts/Enums.ts @@ -40,6 +40,7 @@ export enum SwapUpdateEvent { TransactionFailed = 'transaction.failed', TransactionMempool = 'transaction.mempool', + TransactionClaimPending = 'transaction.claim.pending', TransactionClaimed = 'transaction.claimed', TransactionRefunded = 'transaction.refunded', TransactionConfirmed = 'transaction.confirmed', diff --git a/lib/db/repositories/SwapRepository.ts b/lib/db/repositories/SwapRepository.ts index ff26a6fc..d51d3dad 100644 --- a/lib/db/repositories/SwapRepository.ts +++ b/lib/db/repositories/SwapRepository.ts @@ -26,6 +26,14 @@ class SwapRepository { }); }; + public static getSwapsClaimable = () => { + return Swap.findAll({ + where: { + status: SwapUpdateEvent.TransactionClaimPending, + }, + }); + }; + public static getSwap = (options: WhereOptions): Promise => { return Swap.findOne({ where: options, diff --git a/lib/grpc/GrpcServer.ts b/lib/grpc/GrpcServer.ts index 5becf138..d952ed75 100644 --- a/lib/grpc/GrpcServer.ts +++ b/lib/grpc/GrpcServer.ts @@ -26,6 +26,7 @@ class GrpcServer { sendCoins: grpcService.sendCoins, updateTimeoutBlockDelta: grpcService.updateTimeoutBlockDelta, addReferral: grpcService.addReferral, + sweepSwaps: grpcService.sweepSwaps, }); } diff --git a/lib/grpc/GrpcService.ts b/lib/grpc/GrpcService.ts index 1f25afec..95e94406 100644 --- a/lib/grpc/GrpcService.ts +++ b/lib/grpc/GrpcService.ts @@ -172,6 +172,37 @@ class GrpcService { }); }; + public sweepSwaps: handleUnaryCall< + boltzrpc.SweepSwapsRequest, + boltzrpc.SweepSwapsResponse + > = async (call, callback) => { + await this.handleCallback(call, callback, async () => { + const { symbol } = call.request.toObject(); + + const claimed = symbol + ? new Map([ + [ + symbol, + await this.service.swapManager.deferredClaimer.sweepSymbol( + symbol, + ), + ], + ]) + : await this.service.swapManager.deferredClaimer.sweep(); + + const response = new boltzrpc.SweepSwapsResponse(); + const grpcMap = response.getClaimedSymbolsMap(); + + for (const [symbol, swapIds] of claimed) { + const ids = new boltzrpc.SweepSwapsResponse.ClaimedSwaps(); + ids.setClaimedIdsList(swapIds); + grpcMap.set(symbol, ids); + } + + return response; + }); + }; + private handleCallback = async ( call: R, callback: (error: any, res: T | null) => void, diff --git a/lib/notifications/CommandHandler.ts b/lib/notifications/CommandHandler.ts index dc82311d..299c233d 100644 --- a/lib/notifications/CommandHandler.ts +++ b/lib/notifications/CommandHandler.ts @@ -6,6 +6,7 @@ import { formatError, getChainCurrency, getHexString, + mapToObject, splitPairId, stringify, } from '../Utils'; @@ -40,6 +41,7 @@ enum Command { LockedFunds = 'lockedfunds', PendingSwaps = 'pendingswaps', GetReferrals = 'getreferrals', + PendingSweeps = 'pendingsweeps', // Commands that generate a value or trigger a function Backup = 'backup', @@ -142,6 +144,13 @@ class CommandHandler { description: 'gets a list of pending (reverse) swaps', }, ], + [ + Command.PendingSweeps, + { + executor: this.pendingSweeps, + description: 'gets all pending sweeps', + }, + ], [ Command.GetReferrals, { @@ -465,6 +474,12 @@ class CommandHandler { await this.notificationClient.sendMessage(message); }; + private pendingSweeps = async () => { + await this.notificationClient.sendMessage( + `${codeBlock}${stringify(mapToObject(this.service.swapManager.deferredClaimer.pendingSweeps()))}${codeBlock}`, + ); + }; + private getReferrals = async () => { await this.notificationClient.sendMessage( `${codeBlock}${stringify(await ReferralStats.getReferralFees())}${codeBlock}`, diff --git a/lib/proto/boltzrpc_grpc_pb.d.ts b/lib/proto/boltzrpc_grpc_pb.d.ts index 798e76a1..e98f8d8c 100644 --- a/lib/proto/boltzrpc_grpc_pb.d.ts +++ b/lib/proto/boltzrpc_grpc_pb.d.ts @@ -17,6 +17,7 @@ interface IBoltzService extends grpc.ServiceDefinition { @@ -100,6 +101,15 @@ interface IBoltzService_IAddReferral extends grpc.MethodDefinition; responseDeserialize: grpc.deserialize; } +interface IBoltzService_ISweepSwaps extends grpc.MethodDefinition { + path: "/boltzrpc.Boltz/SweepSwaps"; + requestStream: false; + responseStream: false; + requestSerialize: grpc.serialize; + requestDeserialize: grpc.deserialize; + responseSerialize: grpc.serialize; + responseDeserialize: grpc.deserialize; +} export const BoltzService: IBoltzService; @@ -113,6 +123,7 @@ export interface IBoltzServer extends grpc.UntypedServiceImplementation { sendCoins: grpc.handleUnaryCall; updateTimeoutBlockDelta: grpc.handleUnaryCall; addReferral: grpc.handleUnaryCall; + sweepSwaps: grpc.handleUnaryCall; } export interface IBoltzClient { @@ -143,6 +154,9 @@ export interface IBoltzClient { addReferral(request: boltzrpc_pb.AddReferralRequest, callback: (error: grpc.ServiceError | null, response: boltzrpc_pb.AddReferralResponse) => void): grpc.ClientUnaryCall; addReferral(request: boltzrpc_pb.AddReferralRequest, metadata: grpc.Metadata, callback: (error: grpc.ServiceError | null, response: boltzrpc_pb.AddReferralResponse) => void): grpc.ClientUnaryCall; addReferral(request: boltzrpc_pb.AddReferralRequest, metadata: grpc.Metadata, options: Partial, callback: (error: grpc.ServiceError | null, response: boltzrpc_pb.AddReferralResponse) => void): grpc.ClientUnaryCall; + sweepSwaps(request: boltzrpc_pb.SweepSwapsRequest, callback: (error: grpc.ServiceError | null, response: boltzrpc_pb.SweepSwapsResponse) => void): grpc.ClientUnaryCall; + sweepSwaps(request: boltzrpc_pb.SweepSwapsRequest, metadata: grpc.Metadata, callback: (error: grpc.ServiceError | null, response: boltzrpc_pb.SweepSwapsResponse) => void): grpc.ClientUnaryCall; + sweepSwaps(request: boltzrpc_pb.SweepSwapsRequest, metadata: grpc.Metadata, options: Partial, callback: (error: grpc.ServiceError | null, response: boltzrpc_pb.SweepSwapsResponse) => void): grpc.ClientUnaryCall; } export class BoltzClient extends grpc.Client implements IBoltzClient { @@ -174,4 +188,7 @@ export class BoltzClient extends grpc.Client implements IBoltzClient { public addReferral(request: boltzrpc_pb.AddReferralRequest, callback: (error: grpc.ServiceError | null, response: boltzrpc_pb.AddReferralResponse) => void): grpc.ClientUnaryCall; public addReferral(request: boltzrpc_pb.AddReferralRequest, metadata: grpc.Metadata, callback: (error: grpc.ServiceError | null, response: boltzrpc_pb.AddReferralResponse) => void): grpc.ClientUnaryCall; public addReferral(request: boltzrpc_pb.AddReferralRequest, metadata: grpc.Metadata, options: Partial, callback: (error: grpc.ServiceError | null, response: boltzrpc_pb.AddReferralResponse) => void): grpc.ClientUnaryCall; + public sweepSwaps(request: boltzrpc_pb.SweepSwapsRequest, callback: (error: grpc.ServiceError | null, response: boltzrpc_pb.SweepSwapsResponse) => void): grpc.ClientUnaryCall; + public sweepSwaps(request: boltzrpc_pb.SweepSwapsRequest, metadata: grpc.Metadata, callback: (error: grpc.ServiceError | null, response: boltzrpc_pb.SweepSwapsResponse) => void): grpc.ClientUnaryCall; + public sweepSwaps(request: boltzrpc_pb.SweepSwapsRequest, metadata: grpc.Metadata, options: Partial, callback: (error: grpc.ServiceError | null, response: boltzrpc_pb.SweepSwapsResponse) => void): grpc.ClientUnaryCall; } diff --git a/lib/proto/boltzrpc_grpc_pb.js b/lib/proto/boltzrpc_grpc_pb.js index b02ea2fa..ad484ecf 100644 --- a/lib/proto/boltzrpc_grpc_pb.js +++ b/lib/proto/boltzrpc_grpc_pb.js @@ -158,6 +158,28 @@ function deserialize_boltzrpc_SendCoinsResponse(buffer_arg) { return boltzrpc_pb.SendCoinsResponse.deserializeBinary(new Uint8Array(buffer_arg)); } +function serialize_boltzrpc_SweepSwapsRequest(arg) { + if (!(arg instanceof boltzrpc_pb.SweepSwapsRequest)) { + throw new Error('Expected argument of type boltzrpc.SweepSwapsRequest'); + } + return Buffer.from(arg.serializeBinary()); +} + +function deserialize_boltzrpc_SweepSwapsRequest(buffer_arg) { + return boltzrpc_pb.SweepSwapsRequest.deserializeBinary(new Uint8Array(buffer_arg)); +} + +function serialize_boltzrpc_SweepSwapsResponse(arg) { + if (!(arg instanceof boltzrpc_pb.SweepSwapsResponse)) { + throw new Error('Expected argument of type boltzrpc.SweepSwapsResponse'); + } + return Buffer.from(arg.serializeBinary()); +} + +function deserialize_boltzrpc_SweepSwapsResponse(buffer_arg) { + return boltzrpc_pb.SweepSwapsResponse.deserializeBinary(new Uint8Array(buffer_arg)); +} + function serialize_boltzrpc_UnblindOutputsRequest(arg) { if (!(arg instanceof boltzrpc_pb.UnblindOutputsRequest)) { throw new Error('Expected argument of type boltzrpc.UnblindOutputsRequest'); @@ -310,6 +332,17 @@ addReferral: { responseSerialize: serialize_boltzrpc_AddReferralResponse, responseDeserialize: deserialize_boltzrpc_AddReferralResponse, }, + sweepSwaps: { + path: '/boltzrpc.Boltz/SweepSwaps', + requestStream: false, + responseStream: false, + requestType: boltzrpc_pb.SweepSwapsRequest, + responseType: boltzrpc_pb.SweepSwapsResponse, + requestSerialize: serialize_boltzrpc_SweepSwapsRequest, + requestDeserialize: deserialize_boltzrpc_SweepSwapsRequest, + responseSerialize: serialize_boltzrpc_SweepSwapsResponse, + responseDeserialize: deserialize_boltzrpc_SweepSwapsResponse, + }, }; exports.BoltzClient = grpc.makeGenericClientConstructor(BoltzService); diff --git a/lib/proto/boltzrpc_pb.d.ts b/lib/proto/boltzrpc_pb.d.ts index f51f1826..a491c052 100644 --- a/lib/proto/boltzrpc_pb.d.ts +++ b/lib/proto/boltzrpc_pb.d.ts @@ -680,6 +680,75 @@ export namespace AddReferralResponse { } } +export class SweepSwapsRequest extends jspb.Message { + + hasSymbol(): boolean; + clearSymbol(): void; + getSymbol(): string | undefined; + setSymbol(value: string): SweepSwapsRequest; + + serializeBinary(): Uint8Array; + toObject(includeInstance?: boolean): SweepSwapsRequest.AsObject; + static toObject(includeInstance: boolean, msg: SweepSwapsRequest): SweepSwapsRequest.AsObject; + static extensions: {[key: number]: jspb.ExtensionFieldInfo}; + static extensionsBinary: {[key: number]: jspb.ExtensionFieldBinaryInfo}; + static serializeBinaryToWriter(message: SweepSwapsRequest, writer: jspb.BinaryWriter): void; + static deserializeBinary(bytes: Uint8Array): SweepSwapsRequest; + static deserializeBinaryFromReader(message: SweepSwapsRequest, reader: jspb.BinaryReader): SweepSwapsRequest; +} + +export namespace SweepSwapsRequest { + export type AsObject = { + symbol?: string, + } +} + +export class SweepSwapsResponse extends jspb.Message { + + getClaimedSymbolsMap(): jspb.Map; + clearClaimedSymbolsMap(): void; + + serializeBinary(): Uint8Array; + toObject(includeInstance?: boolean): SweepSwapsResponse.AsObject; + static toObject(includeInstance: boolean, msg: SweepSwapsResponse): SweepSwapsResponse.AsObject; + static extensions: {[key: number]: jspb.ExtensionFieldInfo}; + static extensionsBinary: {[key: number]: jspb.ExtensionFieldBinaryInfo}; + static serializeBinaryToWriter(message: SweepSwapsResponse, writer: jspb.BinaryWriter): void; + static deserializeBinary(bytes: Uint8Array): SweepSwapsResponse; + static deserializeBinaryFromReader(message: SweepSwapsResponse, reader: jspb.BinaryReader): SweepSwapsResponse; +} + +export namespace SweepSwapsResponse { + export type AsObject = { + + claimedSymbolsMap: Array<[string, SweepSwapsResponse.ClaimedSwaps.AsObject]>, + } + + + export class ClaimedSwaps extends jspb.Message { + clearClaimedIdsList(): void; + getClaimedIdsList(): Array; + setClaimedIdsList(value: Array): ClaimedSwaps; + addClaimedIds(value: string, index?: number): string; + + serializeBinary(): Uint8Array; + toObject(includeInstance?: boolean): ClaimedSwaps.AsObject; + static toObject(includeInstance: boolean, msg: ClaimedSwaps): ClaimedSwaps.AsObject; + static extensions: {[key: number]: jspb.ExtensionFieldInfo}; + static extensionsBinary: {[key: number]: jspb.ExtensionFieldBinaryInfo}; + static serializeBinaryToWriter(message: ClaimedSwaps, writer: jspb.BinaryWriter): void; + static deserializeBinary(bytes: Uint8Array): ClaimedSwaps; + static deserializeBinaryFromReader(message: ClaimedSwaps, reader: jspb.BinaryReader): ClaimedSwaps; + } + + export namespace ClaimedSwaps { + export type AsObject = { + claimedIdsList: Array, + } + } + +} + export enum OutputType { BECH32 = 0, COMPATIBILITY = 1, diff --git a/lib/proto/boltzrpc_pb.js b/lib/proto/boltzrpc_pb.js index 6d3541cd..67fc3cee 100644 --- a/lib/proto/boltzrpc_pb.js +++ b/lib/proto/boltzrpc_pb.js @@ -43,6 +43,9 @@ goog.exportSymbol('proto.boltzrpc.LightningInfo.Channels', null, global); goog.exportSymbol('proto.boltzrpc.OutputType', null, global); goog.exportSymbol('proto.boltzrpc.SendCoinsRequest', null, global); goog.exportSymbol('proto.boltzrpc.SendCoinsResponse', null, global); +goog.exportSymbol('proto.boltzrpc.SweepSwapsRequest', null, global); +goog.exportSymbol('proto.boltzrpc.SweepSwapsResponse', null, global); +goog.exportSymbol('proto.boltzrpc.SweepSwapsResponse.ClaimedSwaps', null, global); goog.exportSymbol('proto.boltzrpc.UnblindOutputsRequest', null, global); goog.exportSymbol('proto.boltzrpc.UnblindOutputsRequest.TransactionCase', null, global); goog.exportSymbol('proto.boltzrpc.UnblindOutputsResponse', null, global); @@ -595,6 +598,69 @@ if (goog.DEBUG && !COMPILED) { */ proto.boltzrpc.AddReferralResponse.displayName = 'proto.boltzrpc.AddReferralResponse'; } +/** + * Generated by JsPbCodeGenerator. + * @param {Array=} opt_data Optional initial data array, typically from a + * server response, or constructed directly in Javascript. The array is used + * in place and becomes part of the constructed object. It is not cloned. + * If no data is provided, the constructed object will be empty, but still + * valid. + * @extends {jspb.Message} + * @constructor + */ +proto.boltzrpc.SweepSwapsRequest = function(opt_data) { + jspb.Message.initialize(this, opt_data, 0, -1, null, null); +}; +goog.inherits(proto.boltzrpc.SweepSwapsRequest, jspb.Message); +if (goog.DEBUG && !COMPILED) { + /** + * @public + * @override + */ + proto.boltzrpc.SweepSwapsRequest.displayName = 'proto.boltzrpc.SweepSwapsRequest'; +} +/** + * Generated by JsPbCodeGenerator. + * @param {Array=} opt_data Optional initial data array, typically from a + * server response, or constructed directly in Javascript. The array is used + * in place and becomes part of the constructed object. It is not cloned. + * If no data is provided, the constructed object will be empty, but still + * valid. + * @extends {jspb.Message} + * @constructor + */ +proto.boltzrpc.SweepSwapsResponse = function(opt_data) { + jspb.Message.initialize(this, opt_data, 0, -1, null, null); +}; +goog.inherits(proto.boltzrpc.SweepSwapsResponse, jspb.Message); +if (goog.DEBUG && !COMPILED) { + /** + * @public + * @override + */ + proto.boltzrpc.SweepSwapsResponse.displayName = 'proto.boltzrpc.SweepSwapsResponse'; +} +/** + * Generated by JsPbCodeGenerator. + * @param {Array=} opt_data Optional initial data array, typically from a + * server response, or constructed directly in Javascript. The array is used + * in place and becomes part of the constructed object. It is not cloned. + * If no data is provided, the constructed object will be empty, but still + * valid. + * @extends {jspb.Message} + * @constructor + */ +proto.boltzrpc.SweepSwapsResponse.ClaimedSwaps = function(opt_data) { + jspb.Message.initialize(this, opt_data, 0, -1, proto.boltzrpc.SweepSwapsResponse.ClaimedSwaps.repeatedFields_, null); +}; +goog.inherits(proto.boltzrpc.SweepSwapsResponse.ClaimedSwaps, jspb.Message); +if (goog.DEBUG && !COMPILED) { + /** + * @public + * @override + */ + proto.boltzrpc.SweepSwapsResponse.ClaimedSwaps.displayName = 'proto.boltzrpc.SweepSwapsResponse.ClaimedSwaps'; +} @@ -5273,6 +5339,443 @@ proto.boltzrpc.AddReferralResponse.prototype.setApiSecret = function(value) { }; + + + +if (jspb.Message.GENERATE_TO_OBJECT) { +/** + * Creates an object representation of this proto. + * Field names that are reserved in JavaScript and will be renamed to pb_name. + * Optional fields that are not set will be set to undefined. + * To access a reserved field use, foo.pb_, eg, foo.pb_default. + * For the list of reserved names please see: + * net/proto2/compiler/js/internal/generator.cc#kKeyword. + * @param {boolean=} opt_includeInstance Deprecated. whether to include the + * JSPB instance for transitional soy proto support: + * http://goto/soy-param-migration + * @return {!Object} + */ +proto.boltzrpc.SweepSwapsRequest.prototype.toObject = function(opt_includeInstance) { + return proto.boltzrpc.SweepSwapsRequest.toObject(opt_includeInstance, this); +}; + + +/** + * Static version of the {@see toObject} method. + * @param {boolean|undefined} includeInstance Deprecated. Whether to include + * the JSPB instance for transitional soy proto support: + * http://goto/soy-param-migration + * @param {!proto.boltzrpc.SweepSwapsRequest} msg The msg instance to transform. + * @return {!Object} + * @suppress {unusedLocalVariables} f is only used for nested messages + */ +proto.boltzrpc.SweepSwapsRequest.toObject = function(includeInstance, msg) { + var f, obj = { + symbol: jspb.Message.getFieldWithDefault(msg, 1, "") + }; + + if (includeInstance) { + obj.$jspbMessageInstance = msg; + } + return obj; +}; +} + + +/** + * Deserializes binary data (in protobuf wire format). + * @param {jspb.ByteSource} bytes The bytes to deserialize. + * @return {!proto.boltzrpc.SweepSwapsRequest} + */ +proto.boltzrpc.SweepSwapsRequest.deserializeBinary = function(bytes) { + var reader = new jspb.BinaryReader(bytes); + var msg = new proto.boltzrpc.SweepSwapsRequest; + return proto.boltzrpc.SweepSwapsRequest.deserializeBinaryFromReader(msg, reader); +}; + + +/** + * Deserializes binary data (in protobuf wire format) from the + * given reader into the given message object. + * @param {!proto.boltzrpc.SweepSwapsRequest} msg The message object to deserialize into. + * @param {!jspb.BinaryReader} reader The BinaryReader to use. + * @return {!proto.boltzrpc.SweepSwapsRequest} + */ +proto.boltzrpc.SweepSwapsRequest.deserializeBinaryFromReader = function(msg, reader) { + while (reader.nextField()) { + if (reader.isEndGroup()) { + break; + } + var field = reader.getFieldNumber(); + switch (field) { + case 1: + var value = /** @type {string} */ (reader.readString()); + msg.setSymbol(value); + break; + default: + reader.skipField(); + break; + } + } + return msg; +}; + + +/** + * Serializes the message to binary data (in protobuf wire format). + * @return {!Uint8Array} + */ +proto.boltzrpc.SweepSwapsRequest.prototype.serializeBinary = function() { + var writer = new jspb.BinaryWriter(); + proto.boltzrpc.SweepSwapsRequest.serializeBinaryToWriter(this, writer); + return writer.getResultBuffer(); +}; + + +/** + * Serializes the given message to binary data (in protobuf wire + * format), writing to the given BinaryWriter. + * @param {!proto.boltzrpc.SweepSwapsRequest} message + * @param {!jspb.BinaryWriter} writer + * @suppress {unusedLocalVariables} f is only used for nested messages + */ +proto.boltzrpc.SweepSwapsRequest.serializeBinaryToWriter = function(message, writer) { + var f = undefined; + f = /** @type {string} */ (jspb.Message.getField(message, 1)); + if (f != null) { + writer.writeString( + 1, + f + ); + } +}; + + +/** + * optional string symbol = 1; + * @return {string} + */ +proto.boltzrpc.SweepSwapsRequest.prototype.getSymbol = function() { + return /** @type {string} */ (jspb.Message.getFieldWithDefault(this, 1, "")); +}; + + +/** + * @param {string} value + * @return {!proto.boltzrpc.SweepSwapsRequest} returns this + */ +proto.boltzrpc.SweepSwapsRequest.prototype.setSymbol = function(value) { + return jspb.Message.setField(this, 1, value); +}; + + +/** + * Clears the field making it undefined. + * @return {!proto.boltzrpc.SweepSwapsRequest} returns this + */ +proto.boltzrpc.SweepSwapsRequest.prototype.clearSymbol = function() { + return jspb.Message.setField(this, 1, undefined); +}; + + +/** + * Returns whether this field is set. + * @return {boolean} + */ +proto.boltzrpc.SweepSwapsRequest.prototype.hasSymbol = function() { + return jspb.Message.getField(this, 1) != null; +}; + + + + + +if (jspb.Message.GENERATE_TO_OBJECT) { +/** + * Creates an object representation of this proto. + * Field names that are reserved in JavaScript and will be renamed to pb_name. + * Optional fields that are not set will be set to undefined. + * To access a reserved field use, foo.pb_, eg, foo.pb_default. + * For the list of reserved names please see: + * net/proto2/compiler/js/internal/generator.cc#kKeyword. + * @param {boolean=} opt_includeInstance Deprecated. whether to include the + * JSPB instance for transitional soy proto support: + * http://goto/soy-param-migration + * @return {!Object} + */ +proto.boltzrpc.SweepSwapsResponse.prototype.toObject = function(opt_includeInstance) { + return proto.boltzrpc.SweepSwapsResponse.toObject(opt_includeInstance, this); +}; + + +/** + * Static version of the {@see toObject} method. + * @param {boolean|undefined} includeInstance Deprecated. Whether to include + * the JSPB instance for transitional soy proto support: + * http://goto/soy-param-migration + * @param {!proto.boltzrpc.SweepSwapsResponse} msg The msg instance to transform. + * @return {!Object} + * @suppress {unusedLocalVariables} f is only used for nested messages + */ +proto.boltzrpc.SweepSwapsResponse.toObject = function(includeInstance, msg) { + var f, obj = { + claimedSymbolsMap: (f = msg.getClaimedSymbolsMap()) ? f.toObject(includeInstance, proto.boltzrpc.SweepSwapsResponse.ClaimedSwaps.toObject) : [] + }; + + if (includeInstance) { + obj.$jspbMessageInstance = msg; + } + return obj; +}; +} + + +/** + * Deserializes binary data (in protobuf wire format). + * @param {jspb.ByteSource} bytes The bytes to deserialize. + * @return {!proto.boltzrpc.SweepSwapsResponse} + */ +proto.boltzrpc.SweepSwapsResponse.deserializeBinary = function(bytes) { + var reader = new jspb.BinaryReader(bytes); + var msg = new proto.boltzrpc.SweepSwapsResponse; + return proto.boltzrpc.SweepSwapsResponse.deserializeBinaryFromReader(msg, reader); +}; + + +/** + * Deserializes binary data (in protobuf wire format) from the + * given reader into the given message object. + * @param {!proto.boltzrpc.SweepSwapsResponse} msg The message object to deserialize into. + * @param {!jspb.BinaryReader} reader The BinaryReader to use. + * @return {!proto.boltzrpc.SweepSwapsResponse} + */ +proto.boltzrpc.SweepSwapsResponse.deserializeBinaryFromReader = function(msg, reader) { + while (reader.nextField()) { + if (reader.isEndGroup()) { + break; + } + var field = reader.getFieldNumber(); + switch (field) { + case 1: + var value = msg.getClaimedSymbolsMap(); + reader.readMessage(value, function(message, reader) { + jspb.Map.deserializeBinary(message, reader, jspb.BinaryReader.prototype.readString, jspb.BinaryReader.prototype.readMessage, proto.boltzrpc.SweepSwapsResponse.ClaimedSwaps.deserializeBinaryFromReader, "", new proto.boltzrpc.SweepSwapsResponse.ClaimedSwaps()); + }); + break; + default: + reader.skipField(); + break; + } + } + return msg; +}; + + +/** + * Serializes the message to binary data (in protobuf wire format). + * @return {!Uint8Array} + */ +proto.boltzrpc.SweepSwapsResponse.prototype.serializeBinary = function() { + var writer = new jspb.BinaryWriter(); + proto.boltzrpc.SweepSwapsResponse.serializeBinaryToWriter(this, writer); + return writer.getResultBuffer(); +}; + + +/** + * Serializes the given message to binary data (in protobuf wire + * format), writing to the given BinaryWriter. + * @param {!proto.boltzrpc.SweepSwapsResponse} message + * @param {!jspb.BinaryWriter} writer + * @suppress {unusedLocalVariables} f is only used for nested messages + */ +proto.boltzrpc.SweepSwapsResponse.serializeBinaryToWriter = function(message, writer) { + var f = undefined; + f = message.getClaimedSymbolsMap(true); + if (f && f.getLength() > 0) { + f.serializeBinary(1, writer, jspb.BinaryWriter.prototype.writeString, jspb.BinaryWriter.prototype.writeMessage, proto.boltzrpc.SweepSwapsResponse.ClaimedSwaps.serializeBinaryToWriter); + } +}; + + + +/** + * List of repeated fields within this message type. + * @private {!Array} + * @const + */ +proto.boltzrpc.SweepSwapsResponse.ClaimedSwaps.repeatedFields_ = [1]; + + + +if (jspb.Message.GENERATE_TO_OBJECT) { +/** + * Creates an object representation of this proto. + * Field names that are reserved in JavaScript and will be renamed to pb_name. + * Optional fields that are not set will be set to undefined. + * To access a reserved field use, foo.pb_, eg, foo.pb_default. + * For the list of reserved names please see: + * net/proto2/compiler/js/internal/generator.cc#kKeyword. + * @param {boolean=} opt_includeInstance Deprecated. whether to include the + * JSPB instance for transitional soy proto support: + * http://goto/soy-param-migration + * @return {!Object} + */ +proto.boltzrpc.SweepSwapsResponse.ClaimedSwaps.prototype.toObject = function(opt_includeInstance) { + return proto.boltzrpc.SweepSwapsResponse.ClaimedSwaps.toObject(opt_includeInstance, this); +}; + + +/** + * Static version of the {@see toObject} method. + * @param {boolean|undefined} includeInstance Deprecated. Whether to include + * the JSPB instance for transitional soy proto support: + * http://goto/soy-param-migration + * @param {!proto.boltzrpc.SweepSwapsResponse.ClaimedSwaps} msg The msg instance to transform. + * @return {!Object} + * @suppress {unusedLocalVariables} f is only used for nested messages + */ +proto.boltzrpc.SweepSwapsResponse.ClaimedSwaps.toObject = function(includeInstance, msg) { + var f, obj = { + claimedIdsList: (f = jspb.Message.getRepeatedField(msg, 1)) == null ? undefined : f + }; + + if (includeInstance) { + obj.$jspbMessageInstance = msg; + } + return obj; +}; +} + + +/** + * Deserializes binary data (in protobuf wire format). + * @param {jspb.ByteSource} bytes The bytes to deserialize. + * @return {!proto.boltzrpc.SweepSwapsResponse.ClaimedSwaps} + */ +proto.boltzrpc.SweepSwapsResponse.ClaimedSwaps.deserializeBinary = function(bytes) { + var reader = new jspb.BinaryReader(bytes); + var msg = new proto.boltzrpc.SweepSwapsResponse.ClaimedSwaps; + return proto.boltzrpc.SweepSwapsResponse.ClaimedSwaps.deserializeBinaryFromReader(msg, reader); +}; + + +/** + * Deserializes binary data (in protobuf wire format) from the + * given reader into the given message object. + * @param {!proto.boltzrpc.SweepSwapsResponse.ClaimedSwaps} msg The message object to deserialize into. + * @param {!jspb.BinaryReader} reader The BinaryReader to use. + * @return {!proto.boltzrpc.SweepSwapsResponse.ClaimedSwaps} + */ +proto.boltzrpc.SweepSwapsResponse.ClaimedSwaps.deserializeBinaryFromReader = function(msg, reader) { + while (reader.nextField()) { + if (reader.isEndGroup()) { + break; + } + var field = reader.getFieldNumber(); + switch (field) { + case 1: + var value = /** @type {string} */ (reader.readString()); + msg.addClaimedIds(value); + break; + default: + reader.skipField(); + break; + } + } + return msg; +}; + + +/** + * Serializes the message to binary data (in protobuf wire format). + * @return {!Uint8Array} + */ +proto.boltzrpc.SweepSwapsResponse.ClaimedSwaps.prototype.serializeBinary = function() { + var writer = new jspb.BinaryWriter(); + proto.boltzrpc.SweepSwapsResponse.ClaimedSwaps.serializeBinaryToWriter(this, writer); + return writer.getResultBuffer(); +}; + + +/** + * Serializes the given message to binary data (in protobuf wire + * format), writing to the given BinaryWriter. + * @param {!proto.boltzrpc.SweepSwapsResponse.ClaimedSwaps} message + * @param {!jspb.BinaryWriter} writer + * @suppress {unusedLocalVariables} f is only used for nested messages + */ +proto.boltzrpc.SweepSwapsResponse.ClaimedSwaps.serializeBinaryToWriter = function(message, writer) { + var f = undefined; + f = message.getClaimedIdsList(); + if (f.length > 0) { + writer.writeRepeatedString( + 1, + f + ); + } +}; + + +/** + * repeated string claimed_ids = 1; + * @return {!Array} + */ +proto.boltzrpc.SweepSwapsResponse.ClaimedSwaps.prototype.getClaimedIdsList = function() { + return /** @type {!Array} */ (jspb.Message.getRepeatedField(this, 1)); +}; + + +/** + * @param {!Array} value + * @return {!proto.boltzrpc.SweepSwapsResponse.ClaimedSwaps} returns this + */ +proto.boltzrpc.SweepSwapsResponse.ClaimedSwaps.prototype.setClaimedIdsList = function(value) { + return jspb.Message.setField(this, 1, value || []); +}; + + +/** + * @param {string} value + * @param {number=} opt_index + * @return {!proto.boltzrpc.SweepSwapsResponse.ClaimedSwaps} returns this + */ +proto.boltzrpc.SweepSwapsResponse.ClaimedSwaps.prototype.addClaimedIds = function(value, opt_index) { + return jspb.Message.addToRepeatedField(this, 1, value, opt_index); +}; + + +/** + * Clears the list making it empty but non-null. + * @return {!proto.boltzrpc.SweepSwapsResponse.ClaimedSwaps} returns this + */ +proto.boltzrpc.SweepSwapsResponse.ClaimedSwaps.prototype.clearClaimedIdsList = function() { + return this.setClaimedIdsList([]); +}; + + +/** + * map claimed_symbols = 1; + * @param {boolean=} opt_noLazyCreate Do not create the map if + * empty, instead returning `undefined` + * @return {!jspb.Map} + */ +proto.boltzrpc.SweepSwapsResponse.prototype.getClaimedSymbolsMap = function(opt_noLazyCreate) { + return /** @type {!jspb.Map} */ ( + jspb.Message.getMapField(this, 1, opt_noLazyCreate, + proto.boltzrpc.SweepSwapsResponse.ClaimedSwaps)); +}; + + +/** + * Clears values from the map. The map will be non-null. + * @return {!proto.boltzrpc.SweepSwapsResponse} returns this + */ +proto.boltzrpc.SweepSwapsResponse.prototype.clearClaimedSymbolsMap = function() { + this.getClaimedSymbolsMap().clear(); + return this;}; + + /** * @enum {number} */ diff --git a/lib/service/EventHandler.ts b/lib/service/EventHandler.ts index fda21902..48f708e9 100644 --- a/lib/service/EventHandler.ts +++ b/lib/service/EventHandler.ts @@ -199,6 +199,12 @@ class EventHandler extends EventEmitter { this.emit('swap.success', swap, false, channelCreation); }); + this.nursery.on('claim.pending', (swap) => { + this.emit('swap.update', swap.id, { + status: SwapUpdateEvent.TransactionClaimPending, + }); + }); + this.nursery.on('expiration', (swap, isReverse) => { const newStatus = SwapUpdateEvent.SwapExpired; diff --git a/lib/service/Service.ts b/lib/service/Service.ts index 87db3e06..66f831bc 100644 --- a/lib/service/Service.ts +++ b/lib/service/Service.ts @@ -68,12 +68,12 @@ import Blocks from './Blocks'; import ElementsService from './ElementsService'; import Errors from './Errors'; import EventHandler from './EventHandler'; -import MusigSigner from './MusigSigner'; import NodeInfo from './NodeInfo'; import PaymentRequestUtils from './PaymentRequestUtils'; import TimeoutDeltaProvider, { PairTimeoutBlocksDelta, } from './TimeoutDeltaProvider'; +import MusigSigner from './cooperative/MusigSigner'; type NetworkContracts = { network: { @@ -166,6 +166,7 @@ class Service { ), config.retryInterval, blocks, + config.swap, ); this.eventHandler = new EventHandler( diff --git a/lib/service/cooperative/DeferredClaimer.ts b/lib/service/cooperative/DeferredClaimer.ts new file mode 100644 index 00000000..5d195739 --- /dev/null +++ b/lib/service/cooperative/DeferredClaimer.ts @@ -0,0 +1,343 @@ +import AsyncLock from 'async-lock'; +import { Job, scheduleJob } from 'node-schedule'; +import { + ClaimDetails, + LiquidClaimDetails, + calculateTransactionFee, + constructClaimDetails, + constructClaimTransaction, + parseTransaction, +} from '../../Core'; +import Logger from '../../Logger'; +import { + formatError, + getChainCurrency, + getHexBuffer, + getLightningCurrency, + splitPairId, +} from '../../Utils'; +import ChainClient from '../../chain/ChainClient'; +import { SwapUpdateEvent, SwapVersion } from '../../consts/Enums'; +import ChannelCreation from '../../db/models/ChannelCreation'; +import Swap from '../../db/models/Swap'; +import ChannelCreationRepository from '../../db/repositories/ChannelCreationRepository'; +import SwapRepository from '../../db/repositories/SwapRepository'; +import SwapOutputType from '../../swap/SwapOutputType'; +import Wallet from '../../wallet/Wallet'; +import WalletManager, { Currency } from '../../wallet/WalletManager'; +import TimeoutDeltaProvider from '../TimeoutDeltaProvider'; +import MusigBase from './MusigBase'; + +type SwapConfig = { + deferredClaimSymbols: string[]; + batchClaimInterval: string; + expiryTolerance: number; +}; + +type SwapToClaim = { + swap: Swap; + preimage: Buffer; + sweepAddress?: string; +}; + +interface IDeferredClaimer { + on( + event: 'claim', + listener: (swap: Swap, channelCreation?: ChannelCreation) => void, + ): this; + emit(event: 'claim', swap: Swap, channelCreation?: ChannelCreation): boolean; +} + +class DeferredClaimer extends MusigBase implements IDeferredClaimer { + private static readonly batchClaimLock = 'batchClaim'; + private static readonly swapsToClaimLock = 'swapsToClaim'; + + private readonly lock = new AsyncLock(); + private readonly swapsToClaim = new Map>(); + + private batchClaimSchedule?: Job; + + constructor( + private readonly logger: Logger, + private readonly currencies: Map, + walletManager: WalletManager, + private swapOutputType: SwapOutputType, + private readonly config: SwapConfig, + ) { + super(walletManager); + + for (const symbol of config.deferredClaimSymbols) { + this.swapsToClaim.set(symbol, new Map()); + } + } + + public init = async () => { + this.logger.verbose( + `Using deferred claims for: ${this.config.deferredClaimSymbols}`, + ); + this.logger.verbose( + `Batch claim interval: ${this.config.batchClaimInterval} with expiry tolerance of ${this.config.expiryTolerance} minutes`, + ); + + await this.batchClaimLeftovers(); + + this.batchClaimSchedule = scheduleJob( + this.config.batchClaimInterval, + async () => { + await this.sweep(); + }, + ); + }; + + public close = () => { + this.batchClaimSchedule?.cancel(); + this.batchClaimSchedule = undefined; + }; + + public pendingSweeps = () => + new Map( + Array.from(this.swapsToClaim.entries()).map(([currency, swaps]) => [ + currency, + Array.from(swaps.keys()), + ]), + ); + + public sweep = async () => { + const claimed = new Map(); + + for (const symbol of this.config.deferredClaimSymbols) { + const ids = await this.sweepSymbol(symbol); + + if (ids.length > 0) { + claimed.set(symbol, ids); + } + } + + return claimed; + }; + + public sweepSymbol = async (symbol: string) => { + let claimedSwaps: string[] = []; + + await this.lock.acquire(DeferredClaimer.batchClaimLock, async () => { + claimedSwaps = await this.batchClaim(symbol); + }); + + return claimedSwaps; + }; + + public deferClaim = async ( + swap: Swap, + preimage: Buffer, + ): Promise => { + const { base, quote } = splitPairId(swap.pair); + const chainCurrency = getChainCurrency(base, quote, swap.orderSide, false); + if (!this.shouldBeClaimedDeferred(chainCurrency, swap)) { + return false; + } + + this.logger.verbose( + `Deferring claim of ${swap.id} to next ${chainCurrency} batch`, + ); + + swap = await SwapRepository.setSwapStatus( + swap, + SwapUpdateEvent.TransactionClaimPending, + ); + + await this.lock.acquire(DeferredClaimer.swapsToClaimLock, async () => { + this.swapsToClaim.get(chainCurrency)!.set(swap.id, { + swap, + preimage, + }); + }); + + if (await this.expiryTooSoon(chainCurrency, swap)) { + await this.sweepSymbol(chainCurrency); + } + + return true; + }; + + private batchClaim = async (symbol: string) => { + let swapsToClaim: SwapToClaim[] = []; + + await this.lock.acquire(DeferredClaimer.swapsToClaimLock, async () => { + const swaps = this.swapsToClaim.get(symbol); + if (swaps === undefined) { + return; + } + + swapsToClaim = Array.from(swaps.values()); + swaps.clear(); + }); + + try { + if (swapsToClaim.length === 0) { + this.logger.debug( + `Not batch claiming swaps for currency ${symbol}: no swaps to claim`, + ); + return []; + } + + this.logger.verbose(`Batch claiming swaps for currency: ${symbol}`); + return await this.broadcastClaim(symbol, swapsToClaim); + } catch (e) { + this.logger.warn( + `Batch claim for currency ${symbol} failed: ${formatError(e)}`, + ); + await this.lock.acquire(DeferredClaimer.swapsToClaimLock, async () => { + const map = this.swapsToClaim.get(symbol)!; + + for (const toClaim of swapsToClaim) { + map.set(toClaim.swap.id, toClaim); + } + }); + + throw e; + } + }; + + private broadcastClaim = async (currency: string, swaps: SwapToClaim[]) => { + const chainClient = this.currencies.get(currency)!.chainClient!; + const wallet = this.walletManager.wallets.get(currency)!; + + const claimDetails = (await Promise.all( + swaps.map((swap) => + this.constructClaimDetails(chainClient, wallet, swap), + ), + )) as ClaimDetails[] | LiquidClaimDetails[]; + + const claimTransaction = constructClaimTransaction( + wallet, + claimDetails, + await wallet.getAddress(), + await chainClient.estimateFee(), + ); + + const transactionFeePerSwap = Math.ceil( + (await calculateTransactionFee(chainClient, claimTransaction)) / + swaps.length, + ); + + await chainClient.sendRawTransaction(claimTransaction.toHex()); + + this.logger.info( + `Claimed ${wallet.symbol} of Swaps ${swaps + .map((toClaim) => toClaim.swap.id) + .join(', ')} in: ${claimTransaction.getId()}`, + ); + + for (const toClaim of swaps) { + this.emit( + 'claim', + await SwapRepository.setMinerFee(toClaim.swap, transactionFeePerSwap), + (await ChannelCreationRepository.getChannelCreation({ + swapId: toClaim.swap.id, + })) || undefined, + ); + } + + return swaps.map((toClaim) => toClaim.swap.id); + }; + + private constructClaimDetails = async ( + chainClient: ChainClient, + wallet: Wallet, + toClaim: SwapToClaim, + ): Promise => { + const { swap, preimage } = toClaim; + const tx = parseTransaction( + wallet.type, + await chainClient.getRawTransaction(swap.lockupTransactionId!), + ); + + return constructClaimDetails( + this.swapOutputType, + wallet, + swap, + tx, + preimage, + ); + }; + + // TODO: how to handle non Taproot swaps? + private shouldBeClaimedDeferred = (chainCurrency: string, swap: Swap) => { + if (!this.config.deferredClaimSymbols.includes(chainCurrency)) { + this.logNotDeferringReason( + swap, + `${chainCurrency} transactions should not be deferred`, + ); + return false; + } + + if (swap.version !== SwapVersion.Taproot) { + this.logNotDeferringReason(swap, 'version is legacy'); + return false; + } + + return true; + }; + + private expiryTooSoon = async (chainCurrency: string, swap: Swap) => { + const chainClient = this.currencies.get(chainCurrency)!.chainClient!; + const { blocks } = await chainClient.getBlockchainInfo(); + + const minutesLeft = + TimeoutDeltaProvider.blockTimes.get(chainCurrency)! * + (swap.timeoutBlockHeight - blocks); + + return minutesLeft <= this.config.expiryTolerance; + }; + + private logNotDeferringReason = (swap: Swap, reason: string) => { + this.logger.debug(`Not deferring claim of Swap ${swap.id}: ${reason}`); + }; + + private batchClaimLeftovers = async () => { + const swapsToClaim = await SwapRepository.getSwapsClaimable(); + + for (const swap of swapsToClaim) { + const { base, quote } = splitPairId(swap.pair); + const { lndClient, clnClient } = this.currencies.get( + getLightningCurrency(base, quote, swap.orderSide, false), + )!; + + const paymentRes = ( + await Promise.allSettled([ + lndClient + ?.trackPayment(getHexBuffer(swap.preimageHash)) + .then((res) => getHexBuffer(res.paymentPreimage)), + clnClient?.checkPayStatus(swap.invoice!).then((res) => res?.preimage), + ]) + ) + .filter( + (res): res is PromiseFulfilledResult => + res.status === 'fulfilled', + ) + .map((res) => res.value) + .filter((res): res is Buffer => res !== undefined); + + if (paymentRes.length === 0) { + this.logger.warn( + `Could not prepare claim of Swap ${swap.id}: no lightning client has preimage`, + ); + continue; + } + + await this.lock.acquire(DeferredClaimer.swapsToClaimLock, async () => { + this.swapsToClaim + .get(getChainCurrency(base, quote, swap.orderSide, false))! + .set(swap.id, { + swap, + preimage: paymentRes[0], + }); + }); + } + + await this.sweep(); + }; +} + +export default DeferredClaimer; +export { SwapConfig }; diff --git a/lib/service/cooperative/MusigBase.ts b/lib/service/cooperative/MusigBase.ts new file mode 100644 index 00000000..ef0b5fde --- /dev/null +++ b/lib/service/cooperative/MusigBase.ts @@ -0,0 +1,51 @@ +import { Types } from 'boltz-core'; +import { EventEmitter } from 'events'; +import { + createMusig, + hashForWitnessV1, + parseTransaction, + tweakMusig, +} from '../../Core'; +import WalletManager, { Currency } from '../../wallet/WalletManager'; +import Errors from '../Errors'; +import { PartialSignature } from './MusigSigner'; + +abstract class MusigBase extends EventEmitter { + protected constructor(protected readonly walletManager: WalletManager) { + super(); + } + + protected createPartialSignature = async ( + currency: Currency, + swapTree: Types.SwapTree, + keyIndex: number, + theirPublicKey: Buffer, + theirNonce: Buffer, + rawTransaction: Buffer | string, + vin: number, + ): Promise => { + const tx = parseTransaction(currency.type, rawTransaction); + if (vin < 0 || tx.ins.length <= vin) { + throw Errors.INVALID_VIN(); + } + + const wallet = this.walletManager.wallets.get(currency.symbol)!; + + const ourKeys = wallet.getKeysByIndex(keyIndex); + + const musig = createMusig(ourKeys, theirPublicKey); + tweakMusig(currency.type, musig, swapTree); + + musig.aggregateNonces([[theirPublicKey, theirNonce]]); + + const hash = await hashForWitnessV1(currency, tx, vin); + musig.initializeSession(hash); + + return { + signature: Buffer.from(musig.signPartial()), + pubNonce: Buffer.from(musig.getPublicNonce()), + }; + }; +} + +export default MusigBase; diff --git a/lib/service/MusigSigner.ts b/lib/service/cooperative/MusigSigner.ts similarity index 71% rename from lib/service/MusigSigner.ts rename to lib/service/cooperative/MusigSigner.ts index 443f8d10..ec81431d 100644 --- a/lib/service/MusigSigner.ts +++ b/lib/service/cooperative/MusigSigner.ts @@ -1,26 +1,21 @@ import { crypto } from 'bitcoinjs-lib'; -import { SwapTreeSerializer, Types } from 'boltz-core'; -import { - createMusig, - hashForWitnessV1, - parseTransaction, - tweakMusig, -} from '../Core'; -import Logger from '../Logger'; +import { SwapTreeSerializer } from 'boltz-core'; +import Logger from '../../Logger'; import { getChainCurrency, getHexBuffer, getHexString, splitPairId, -} from '../Utils'; -import { FailedSwapUpdateEvents, SwapUpdateEvent } from '../consts/Enums'; -import Swap from '../db/models/Swap'; -import ReverseSwapRepository from '../db/repositories/ReverseSwapRepository'; -import SwapRepository from '../db/repositories/SwapRepository'; -import { Payment } from '../proto/lnd/rpc_pb'; -import SwapNursery from '../swap/SwapNursery'; -import WalletManager, { Currency } from '../wallet/WalletManager'; -import Errors from './Errors'; +} from '../../Utils'; +import { FailedSwapUpdateEvents, SwapUpdateEvent } from '../../consts/Enums'; +import Swap from '../../db/models/Swap'; +import ReverseSwapRepository from '../../db/repositories/ReverseSwapRepository'; +import SwapRepository from '../../db/repositories/SwapRepository'; +import { Payment } from '../../proto/lnd/rpc_pb'; +import SwapNursery from '../../swap/SwapNursery'; +import WalletManager, { Currency } from '../../wallet/WalletManager'; +import Errors from '../Errors'; +import MusigBase from './MusigBase'; type PartialSignature = { pubNonce: Buffer; @@ -29,13 +24,15 @@ type PartialSignature = { // TODO: Should we verify what we are signing? And if so, how strict should we be? -class MusigSigner { +class MusigSigner extends MusigBase { constructor( private readonly logger: Logger, private readonly currencies: Map, - private readonly walletManager: WalletManager, + walletManager: WalletManager, private readonly nursery: SwapNursery, - ) {} + ) { + super(walletManager); + } public signSwapRefund = async ( swapId: string, @@ -135,38 +132,6 @@ class MusigSigner { ); }; - private createPartialSignature = async ( - currency: Currency, - swapTree: Types.SwapTree, - keyIndex: number, - theirPublicKey: Buffer, - theirNonce: Buffer, - rawTransaction: Buffer | string, - vin: number, - ): Promise => { - const tx = parseTransaction(currency.type, rawTransaction); - if (vin < 0 || tx.ins.length <= vin) { - throw Errors.INVALID_VIN(); - } - - const wallet = this.walletManager.wallets.get(currency.symbol)!; - - const ourKeys = wallet.getKeysByIndex(keyIndex); - - const musig = createMusig(ourKeys, theirPublicKey); - tweakMusig(currency.type, musig, swapTree); - - musig.aggregateNonces([[theirPublicKey, theirNonce]]); - - const hash = await hashForWitnessV1(currency, tx, vin); - musig.initializeSession(hash); - - return { - signature: Buffer.from(musig.signPartial()), - pubNonce: Buffer.from(musig.getPublicNonce()), - }; - }; - private hasNonFailedLightningPayment = async ( currency: Currency, swap: Swap, diff --git a/lib/swap/SwapManager.ts b/lib/swap/SwapManager.ts index 72bc02b8..be1c2d75 100644 --- a/lib/swap/SwapManager.ts +++ b/lib/swap/SwapManager.ts @@ -49,6 +49,9 @@ import Blocks from '../service/Blocks'; import InvoiceExpiryHelper from '../service/InvoiceExpiryHelper'; import PaymentRequestUtils from '../service/PaymentRequestUtils'; import TimeoutDeltaProvider from '../service/TimeoutDeltaProvider'; +import DeferredClaimer, { + SwapConfig, +} from '../service/cooperative/DeferredClaimer'; import WalletLiquid from '../wallet/WalletLiquid'; import WalletManager, { Currency } from '../wallet/WalletManager'; import Errors from './Errors'; @@ -121,6 +124,7 @@ class SwapManager { public nursery: SwapNursery; public routingHints!: RoutingHints; + public readonly deferredClaimer: DeferredClaimer; private nodeFallback!: NodeFallback; private invoiceExpiryHelper!: InvoiceExpiryHelper; @@ -135,7 +139,16 @@ class SwapManager { private readonly swapOutputType: SwapOutputType, retryInterval: number, private readonly blocks: Blocks, + swapConfig: SwapConfig, ) { + this.deferredClaimer = new DeferredClaimer( + this.logger, + this.currencies, + this.walletManager, + this.swapOutputType, + swapConfig, + ); + this.nursery = new SwapNursery( this.logger, this.nodeSwitch, @@ -145,6 +158,7 @@ class SwapManager { this.swapOutputType, retryInterval, this.blocks, + this.deferredClaimer, ); } @@ -201,6 +215,8 @@ class SwapManager { pairs, this.timeoutDeltaProvider, ); + + await this.deferredClaimer.init(); }; /** diff --git a/lib/swap/SwapNursery.ts b/lib/swap/SwapNursery.ts index fc8040d7..bbfdafa8 100644 --- a/lib/swap/SwapNursery.ts +++ b/lib/swap/SwapNursery.ts @@ -1,6 +1,6 @@ import AsyncLock from 'async-lock'; import { Transaction } from 'bitcoinjs-lib'; -import { OutputType, SwapTreeSerializer, detectSwap } from 'boltz-core'; +import { OutputType, SwapTreeSerializer } from 'boltz-core'; import { ContractTransactionResponse } from 'ethers'; import { EventEmitter } from 'events'; import { Transaction as LiquidTransaction } from 'liquidjs-lib'; @@ -11,6 +11,7 @@ import { LiquidRefundDetails, RefundDetails, calculateTransactionFee, + constructClaimDetails, constructClaimTransaction, constructRefundTransaction, createMusig, @@ -47,6 +48,7 @@ import FeeProvider from '../rates/FeeProvider'; import RateProvider from '../rates/RateProvider'; import Blocks from '../service/Blocks'; import TimeoutDeltaProvider from '../service/TimeoutDeltaProvider'; +import DeferredClaimer from '../service/cooperative/DeferredClaimer'; import Wallet from '../wallet/Wallet'; import WalletManager, { Currency } from '../wallet/WalletManager'; import ContractHandler from '../wallet/ethereum/ContractHandler'; @@ -111,6 +113,9 @@ interface ISwapNursery { on(event: 'invoice.paid', listener: (swap: Swap) => void): this; emit(event: 'invoice.paid', swap: Swap): boolean; + on(event: 'claim.pending', listener: (swap: Swap) => void): this; + emit(event: 'claim.pending', swap: Swap): boolean; + on( event: 'claim', listener: (swap: Swap, channelCreation?: ChannelCreation) => void, @@ -200,6 +205,7 @@ class SwapNursery extends EventEmitter implements ISwapNursery { private swapOutputType: SwapOutputType, private retryInterval: number, blocks: Blocks, + private readonly claimer: DeferredClaimer, ) { super(); @@ -228,6 +234,10 @@ class SwapNursery extends EventEmitter implements ISwapNursery { this.emit(eventName, ...args); }, ); + + this.claimer.on('claim', (swap, channelCreation) => { + this.emit('claim', swap, channelCreation); + }); } public init = async (currencies: Currency[]): Promise => { @@ -931,49 +941,22 @@ class SwapNursery extends EventEmitter implements ISwapNursery { return; } - // Compatibility mode with database schema version 0 in which this column didn't exist - if (swap.lockupTransactionVout === undefined) { - swap.lockupTransactionVout = detectSwap( - getHexBuffer(swap.redeemScript!), - transaction, - )!.vout; - } - - const output = transaction.outs[swap.lockupTransactionVout!]; - const claimDetails = { - ...output, - preimage, - vout: swap.lockupTransactionVout!, - txHash: transaction.getHash(), - keys: wallet.getKeysByIndex(swap.keyIndex!), - } as ClaimDetails | LiquidClaimDetails; - - switch (swap.version) { - case SwapVersion.Taproot: { - claimDetails.type = OutputType.Taproot; - claimDetails.cooperative = false; - claimDetails.swapTree = SwapTreeSerializer.deserializeSwapTree( - swap.redeemScript!, - ); - claimDetails.internalKey = createMusig( - claimDetails.keys, - getHexBuffer(swap.refundPublicKey!), - ).getAggregatedPublicKey(); - - break; - } - - default: { - claimDetails.type = this.swapOutputType.get(wallet.type); - claimDetails.redeemScript = getHexBuffer(swap.redeemScript!); - - break; - } + if (await this.claimer.deferClaim(swap, preimage)) { + this.emit('claim.pending', swap); + return; } const claimTransaction = constructClaimTransaction( wallet, - [claimDetails] as ClaimDetails[] | LiquidClaimDetails[], + [ + constructClaimDetails( + this.swapOutputType, + wallet, + swap, + transaction, + preimage, + ), + ] as ClaimDetails[] | LiquidClaimDetails[], await wallet.getAddress(), await chainClient.estimateFee(), ); diff --git a/proto/boltzrpc.proto b/proto/boltzrpc.proto index 2032b90d..6c66767a 100644 --- a/proto/boltzrpc.proto +++ b/proto/boltzrpc.proto @@ -26,6 +26,8 @@ service Boltz { /* Adds a new referral ID to the database */ rpc AddReferral (AddReferralRequest) returns (AddReferralResponse); + + rpc SweepSwaps (SweepSwapsRequest) returns (SweepSwapsResponse); } enum OutputType { @@ -170,3 +172,14 @@ message AddReferralResponse { string api_key = 1; string api_secret = 2; } + +message SweepSwapsRequest { + optional string symbol = 1; +} +message SweepSwapsResponse { + message ClaimedSwaps { + repeated string claimed_ids = 1; + } + + map claimed_symbols = 1; +} diff --git a/test/integration/Core.spec.ts b/test/integration/Core.spec.ts index 73117975..85dbffbb 100644 --- a/test/integration/Core.spec.ts +++ b/test/integration/Core.spec.ts @@ -1,9 +1,20 @@ import { BIP32Factory } from 'bip32'; import { generateMnemonic, mnemonicToSeedSync } from 'bip39'; -import { Transaction, address } from 'bitcoinjs-lib'; +import { Transaction, address, crypto } from 'bitcoinjs-lib'; import { toXOnly } from 'bitcoinjs-lib/src/psbt/bip371'; -import { Networks, Scripts, SwapTreeSerializer } from 'boltz-core'; +import { + ClaimDetails, + Networks, + OutputType, + Scripts, + SwapTreeSerializer, + detectSwap, + swapScript, + swapTree, +} from 'boltz-core'; import { Networks as LiquidNetworks } from 'boltz-core/dist/lib/liquid'; +import { p2trOutput, p2wshOutput } from 'boltz-core/dist/lib/swap/Scripts'; +import { randomBytes } from 'crypto'; import { Creator, CreatorInput, @@ -17,6 +28,7 @@ import { import { SLIP77Factory } from 'slip77'; import * as ecc from 'tiny-secp256k1'; import { + constructClaimDetails, createMusig, fromOutputScript, getOutputValue, @@ -28,9 +40,11 @@ import { } from '../../lib/Core'; import { ECPair } from '../../lib/ECPairHelper'; import Logger from '../../lib/Logger'; -import { getHexBuffer } from '../../lib/Utils'; -import { CurrencyType } from '../../lib/consts/Enums'; +import { getHexBuffer, getHexString } from '../../lib/Utils'; +import { CurrencyType, SwapVersion } from '../../lib/consts/Enums'; import Database from '../../lib/db/Database'; +import Swap from '../../lib/db/models/Swap'; +import SwapOutputType from '../../lib/swap/SwapOutputType'; import Wallet from '../../lib/wallet/Wallet'; import WalletLiquid from '../../lib/wallet/WalletLiquid'; import { Currency } from '../../lib/wallet/WalletManager'; @@ -168,6 +182,107 @@ describe('Core', () => { walletLiquid['network'] = networks.regtest; }); + test('should construct legacy claim details', async () => { + const preimage = randomBytes(32); + const claimKeys = wallet.getNewKeys(); + const refundKeys = ECPair.makeRandom(); + + const redeemScript = swapScript( + crypto.sha256(preimage), + claimKeys.keys.publicKey, + refundKeys.publicKey, + 2, + ); + const outputScript = p2wshOutput(redeemScript); + + const tx = Transaction.fromHex( + await bitcoinClient.getRawTransaction( + await bitcoinClient.sendToAddress( + wallet.encodeAddress(outputScript), + 100_00, + ), + ), + ); + const output = detectSwap(redeemScript, tx); + + const claimDetails = constructClaimDetails( + { + get: jest.fn().mockReturnValue(OutputType.Bech32), + } as unknown as SwapOutputType, + wallet, + { + keyIndex: claimKeys.index, + version: SwapVersion.Legacy, + redeemScript: getHexString(redeemScript), + } as unknown as Swap, + tx, + preimage, + ) as ClaimDetails; + + expect(claimDetails).toEqual({ + ...output, + preimage, + redeemScript, + txHash: tx.getHash(), + type: OutputType.Bech32, + keys: wallet.getKeysByIndex(claimKeys.index), + }); + }); + + test('should construct Taproot claim details', async () => { + const preimage = randomBytes(32); + const claimKeys = wallet.getNewKeys(); + const refundKeys = ECPair.makeRandom(); + + const tree = swapTree( + false, + crypto.sha256(preimage), + claimKeys.keys.publicKey, + refundKeys.publicKey, + 2, + ); + const musig = createMusig(claimKeys.keys, refundKeys.publicKey); + const tweakedKey = tweakMusig(CurrencyType.BitcoinLike, musig, tree); + const outputScript = p2trOutput(tweakedKey); + + const tx = Transaction.fromHex( + await bitcoinClient.getRawTransaction( + await bitcoinClient.sendToAddress( + wallet.encodeAddress(outputScript), + 100_00, + ), + ), + ); + const output = detectSwap(tweakedKey, tx)!; + + const claimDetails = constructClaimDetails( + {} as unknown as SwapOutputType, + wallet, + { + keyIndex: claimKeys.index, + version: SwapVersion.Taproot, + lockupTransactionVout: output.vout, + refundPublicKey: refundKeys.publicKey, + redeemScript: SwapTreeSerializer.serializeSwapTree(tree), + } as unknown as Swap, + tx, + preimage, + ) as ClaimDetails; + + expect(claimDetails.vout).toEqual(output.vout); + expect(claimDetails.preimage).toEqual(preimage); + expect(claimDetails.value).toEqual(output.value); + expect(claimDetails.keys).toEqual(claimKeys.keys); + expect(claimDetails.txHash).toEqual(tx.getHash()); + expect(claimDetails.script).toEqual(output.script); + expect(claimDetails.type).toEqual(OutputType.Taproot); + expect(claimDetails.cooperative).toEqual(false); + expect(claimDetails.internalKey).toEqual(musig.getAggregatedPublicKey()); + expect( + SwapTreeSerializer.serializeSwapTree(claimDetails.swapTree!), + ).toEqual(SwapTreeSerializer.serializeSwapTree(tree)); + }); + test('should create Musig', () => { const ourKeys = ECPair.fromPrivateKey( getHexBuffer( diff --git a/test/integration/service/cooperative/DeferredClaimer.spec.ts b/test/integration/service/cooperative/DeferredClaimer.spec.ts new file mode 100644 index 00000000..5657dc3a --- /dev/null +++ b/test/integration/service/cooperative/DeferredClaimer.spec.ts @@ -0,0 +1,410 @@ +import { BIP32Factory } from 'bip32'; +import { generateMnemonic, mnemonicToSeedSync } from 'bip39'; +import { Transaction, crypto } from 'bitcoinjs-lib'; +import { + Networks, + OutputType, + SwapTreeSerializer, + detectSwap, + swapTree, +} from 'boltz-core'; +import { p2trOutput } from 'boltz-core/dist/lib/swap/Scripts'; +import { randomBytes } from 'crypto'; +import * as ecc from 'tiny-secp256k1'; +import { createMusig, setup, tweakMusig } from '../../../../lib/Core'; +import { ECPair } from '../../../../lib/ECPairHelper'; +import Logger from '../../../../lib/Logger'; +import { + generateSwapId, + getHexBuffer, + getHexString, +} from '../../../../lib/Utils'; +import { + CurrencyType, + OrderSide, + SwapUpdateEvent, + SwapVersion, +} from '../../../../lib/consts/Enums'; +import Swap from '../../../../lib/db/models/Swap'; +import SwapRepository from '../../../../lib/db/repositories/SwapRepository'; +import DeferredClaimer from '../../../../lib/service/cooperative/DeferredClaimer'; +import SwapOutputType from '../../../../lib/swap/SwapOutputType'; +import Wallet from '../../../../lib/wallet/Wallet'; +import WalletManager, { Currency } from '../../../../lib/wallet/WalletManager'; +import CoreWalletProvider from '../../../../lib/wallet/providers/CoreWalletProvider'; +import { + bitcoinClient, + bitcoinLndClient, + bitcoinLndClient2, + clnClient, +} from '../../Nodes'; + +jest.mock('../../../../lib/db/repositories/ChannelCreationRepository'); +jest.mock('../../../../lib/db/repositories/ChainTipRepository'); +jest.mock('../../../../lib/db/repositories/KeyRepository', () => ({ + setHighestUsedIndex: jest.fn().mockResolvedValue(undefined), +})); +jest.mock('../../../../lib/db/repositories/SwapRepository', () => ({ + getSwapsClaimable: jest.fn().mockResolvedValue([]), + setMinerFee: jest.fn().mockImplementation(async (swap, fee) => ({ + ...swap, + minerFee: fee, + })), + setSwapStatus: jest + .fn() + .mockImplementation(async (swap: Swap, status: string) => ({ + ...swap, + status, + })), +})); + +describe('DeferredClaimer', () => { + const bip32 = BIP32Factory(ecc); + + const btcCurrency = { + clnClient, + symbol: 'BTC', + chainClient: bitcoinClient, + lndClient: bitcoinLndClient, + type: CurrencyType.BitcoinLike, + } as Currency; + + const btcWallet = new Wallet( + Logger.disabledLogger, + CurrencyType.BitcoinLike, + new CoreWalletProvider(Logger.disabledLogger, bitcoinClient), + ); + const walletManager = { + wallets: new Map([['BTC', btcWallet]]), + } as WalletManager; + + const claimer = new DeferredClaimer( + Logger.disabledLogger, + new Map([['BTC', btcCurrency]]), + walletManager, + new SwapOutputType(OutputType.Bech32), + { + deferredClaimSymbols: ['BTC', 'DOGE'], + expiryTolerance: 10, + batchClaimInterval: '*/15 * * * *', + }, + ); + + const createClaimableOutput = async ( + timeoutBlockHeight?: number, + preimage?: Buffer, + pair?: string, + invoice?: string, + ) => { + const swap = { + invoice, + pair: pair || 'L-BTC/BTC', + orderSide: OrderSide.BUY, + version: SwapVersion.Taproot, + id: generateSwapId(SwapVersion.Taproot), + refundPublicKey: getHexString(ECPair.makeRandom().publicKey), + timeoutBlockHeight: + timeoutBlockHeight || + (await bitcoinClient.getBlockchainInfo()).blocks + 100, + } as Partial as Swap; + + preimage = preimage || randomBytes(32); + swap.preimageHash = getHexString(crypto.sha256(preimage)); + const claimKeys = btcWallet.getNewKeys(); + swap.keyIndex = claimKeys.index; + + const musig = createMusig( + claimKeys.keys, + getHexBuffer(swap.refundPublicKey!), + ); + const tree = swapTree( + false, + crypto.sha256(preimage), + claimKeys.keys.publicKey, + getHexBuffer(swap.refundPublicKey!), + 1, + ); + swap.redeemScript = JSON.stringify( + SwapTreeSerializer.serializeSwapTree(tree), + ); + + const tweakedKey = tweakMusig(CurrencyType.BitcoinLike, musig, tree); + const tx = Transaction.fromHex( + await bitcoinClient.getRawTransaction( + await bitcoinClient.sendToAddress( + btcWallet.encodeAddress(p2trOutput(tweakedKey)), + 100_000, + ), + ), + ); + swap.lockupTransactionId = tx.getId(); + swap.lockupTransactionVout = detectSwap(tweakedKey, tx)!.vout; + + return { swap, preimage }; + }; + + beforeAll(async () => { + btcWallet.initKeyProvider( + Networks.bitcoinRegtest, + 'm/0/0', + 0, + bip32.fromSeed(mnemonicToSeedSync(generateMnemonic())), + ); + + await Promise.all([ + setup(), + bitcoinClient.connect(), + clnClient.connect(true), + bitcoinLndClient.connect(false), + bitcoinLndClient2.connect(false), + ]); + + await bitcoinClient.generate(1); + }); + + beforeEach(async () => { + jest.clearAllMocks(); + claimer['swapsToClaim'].get('BTC')?.clear(); + }); + + afterAll(() => { + claimer.close(); + + bitcoinClient.disconnect(); + clnClient.disconnect(); + bitcoinLndClient.disconnect(); + bitcoinLndClient2.disconnect(); + }); + + test('should init', async () => { + await claimer.init(); + + expect(SwapRepository.getSwapsClaimable).toHaveBeenCalledTimes(1); + expect(claimer['batchClaimSchedule']).not.toBeUndefined(); + }); + + test('should close', () => { + expect(claimer['batchClaimSchedule']).not.toBeUndefined(); + claimer.close(); + expect(claimer['batchClaimSchedule']).toBeUndefined(); + }); + + test('should defer claim transactions', async () => { + const swap = { + id: 'swapId', + pair: 'L-BTC/BTC', + orderSide: OrderSide.BUY, + version: SwapVersion.Taproot, + } as Partial as Swap; + const preimage = randomBytes(32); + + await expect(claimer.deferClaim(swap, preimage)).resolves.toEqual(true); + + expect(SwapRepository.setSwapStatus).toHaveBeenCalledTimes(1); + expect(SwapRepository.setSwapStatus).toHaveBeenCalledWith( + swap, + SwapUpdateEvent.TransactionClaimPending, + ); + + expect(claimer['swapsToClaim'].get('BTC')!.size).toEqual(1); + expect(claimer['swapsToClaim'].get('BTC')!.get(swap.id)).toEqual({ + preimage, + swap: { + ...swap, + status: SwapUpdateEvent.TransactionClaimPending, + }, + }); + }); + + test('should trigger sweep when deferring claim transaction with close expiry', async () => { + const { swap, preimage } = await createClaimableOutput( + (await bitcoinClient.getBlockchainInfo()).blocks, + ); + + const emitPromise = new Promise((resolve) => { + claimer.once('claim', (emittedSwap, channelCreation) => { + expect(emittedSwap).toEqual({ + ...swap, + status: SwapUpdateEvent.TransactionClaimPending, + minerFee: expect.anything(), + }); + expect(channelCreation).toBeUndefined(); + resolve(); + }); + }); + + await expect(claimer.deferClaim(swap, preimage)).resolves.toEqual(true); + await emitPromise; + + expect(claimer.pendingSweeps().get('BTC')!.length).toEqual(0); + }); + + test('should not defer claim transactions on chains that were not configured', async () => { + const swap = { + pair: 'L-BTC/BTC', + orderSide: OrderSide.SELL, + } as Partial as Swap; + const preimage = randomBytes(32); + + await expect(claimer.deferClaim(swap, preimage)).resolves.toEqual(false); + }); + + test('should not defer claim transactions of legacy swaps', async () => { + const swap = { + pair: 'BTC/BTC', + orderSide: OrderSide.SELL, + version: SwapVersion.Legacy, + } as Partial as Swap; + const preimage = randomBytes(32); + + await expect(claimer.deferClaim(swap, preimage)).resolves.toEqual(false); + }); + + test('should get ids of pending sweeps', async () => { + const swap = { + id: 'swapId', + pair: 'L-BTC/BTC', + orderSide: OrderSide.BUY, + version: SwapVersion.Taproot, + } as Partial as Swap; + const preimage = randomBytes(32); + + await expect(claimer.deferClaim(swap, preimage)).resolves.toEqual(true); + + expect(claimer.pendingSweeps()).toEqual( + new Map([ + ['DOGE', []], + ['BTC', [swap.id]], + ]), + ); + }); + + test('should sweep all configured currencies', async () => { + const spy = jest.spyOn(claimer, 'sweepSymbol'); + await expect(claimer.sweep()).resolves.toEqual(new Map()); + + expect(spy).toHaveBeenCalledTimes(2); + expect(spy).toHaveBeenCalledWith('BTC'); + expect(spy).toHaveBeenCalledWith('DOGE'); + }); + + test('should sweep multiple swaps of a currency currency', async () => { + await bitcoinClient.generate(1); + const swaps = await Promise.all([ + createClaimableOutput(), + createClaimableOutput(), + ]); + + for (const { swap, preimage } of swaps) { + await expect(claimer.deferClaim(swap, preimage)).resolves.toEqual(true); + } + + await claimer.sweepSymbol('BTC'); + + const lockupTxs = swaps.map(({ swap }) => swap.lockupTransactionId!); + const claimTx = Transaction.fromHex( + await bitcoinClient.getRawTransaction( + (await bitcoinClient.getRawMempool()).find( + (txId) => !lockupTxs.includes(txId), + )!, + ), + ); + + expect(claimTx.ins.length).toEqual(swaps.length); + }); + + test('should sweep currency with no pending swaps', async () => { + await expect(claimer.sweepSymbol('BTC')).resolves.toEqual([]); + }); + + test('should sweep currency that is not configured', async () => { + await expect(claimer.sweepSymbol('notConfigured')).resolves.toEqual([]); + }); + + test('should claim leftovers on startup', async () => { + await bitcoinClient.generate(1); + + // Wait for CLN to catch up with the chain + await new Promise((resolve) => { + const timeout = setInterval(async () => { + if ( + (await bitcoinClient.getBlockchainInfo()).blocks === + (await clnClient.getInfo()).blockHeight + ) { + clearTimeout(timeout); + resolve(); + } + }); + }); + + const clnInvoicePreimage = randomBytes(32); + const clnInvoice = await btcCurrency.clnClient!.addHoldInvoice( + 10_000, + crypto.sha256(clnInvoicePreimage), + ); + btcCurrency.clnClient!.once('htlc.accepted', async () => { + await btcCurrency.clnClient!.settleHoldInvoice(clnInvoicePreimage); + }); + + await btcCurrency.lndClient!.sendPayment(clnInvoice); + + const lndInvoice = await btcCurrency.lndClient!.addInvoice(10_00); + const clnPayRes = await btcCurrency.clnClient!.sendPayment( + lndInvoice.paymentRequest, + 100, + lndInvoice.paymentRequest, + ); + + const lndPaidSwap = await createClaimableOutput( + undefined, + clnInvoicePreimage, + 'BTC/BTC', + clnInvoice, + ); + const clnPaidSwap = await createClaimableOutput( + undefined, + clnPayRes.preimage, + 'BTC/BTC', + lndInvoice.paymentRequest, + ); + + SwapRepository.getSwapsClaimable = jest + .fn() + .mockResolvedValue([lndPaidSwap.swap, clnPaidSwap.swap]); + + await claimer.init(); + + const lockupTxs = [lndPaidSwap, clnPaidSwap].map( + ({ swap }) => swap.lockupTransactionId!, + ); + const claimTx = Transaction.fromHex( + await bitcoinClient.getRawTransaction( + (await bitcoinClient.getRawMempool()).find( + (txId) => !lockupTxs.includes(txId), + )!, + ), + ); + + expect(claimTx.ins.length).toEqual(2); + }); + + test('should keep pending swaps in map when claim fails', async () => { + const rejection = 'no good'; + btcCurrency.chainClient!.sendRawTransaction = jest + .fn() + .mockRejectedValue(rejection); + + const { swap, preimage } = await createClaimableOutput(); + await expect(claimer.deferClaim(swap, preimage)).resolves.toEqual(true); + + await expect(claimer.sweep()).rejects.toEqual(rejection); + expect(claimer['swapsToClaim'].get('BTC')!.size).toEqual(1); + expect(claimer['swapsToClaim'].get('BTC')!.get(swap.id)).toEqual({ + preimage, + swap: { + ...swap, + status: SwapUpdateEvent.TransactionClaimPending, + }, + }); + }); +}); diff --git a/test/integration/service/MusigSigner.spec.ts b/test/integration/service/cooperative/MusigSigner.spec.ts similarity index 91% rename from test/integration/service/MusigSigner.spec.ts rename to test/integration/service/cooperative/MusigSigner.spec.ts index 560bc5a9..ffac3f08 100644 --- a/test/integration/service/MusigSigner.spec.ts +++ b/test/integration/service/cooperative/MusigSigner.spec.ts @@ -14,32 +14,32 @@ import { } from 'boltz-core'; import { SwapTree } from 'boltz-core/dist/lib/consts/Types'; import { randomBytes } from 'crypto'; -import { hashForWitnessV1, setup, tweakMusig, zkp } from '../../../lib/Core'; -import { ECPair } from '../../../lib/ECPairHelper'; -import Logger from '../../../lib/Logger'; -import { getHexString } from '../../../lib/Utils'; -import { CurrencyType, SwapUpdateEvent } from '../../../lib/consts/Enums'; -import { NodeType } from '../../../lib/db/models/ReverseSwap'; -import Swap from '../../../lib/db/models/Swap'; -import ReverseSwapRepository from '../../../lib/db/repositories/ReverseSwapRepository'; -import SwapRepository from '../../../lib/db/repositories/SwapRepository'; -import Errors from '../../../lib/service/Errors'; -import MusigSigner from '../../../lib/service/MusigSigner'; -import SwapNursery from '../../../lib/swap/SwapNursery'; -import Wallet from '../../../lib/wallet/Wallet'; -import WalletManager, { Currency } from '../../../lib/wallet/WalletManager'; -import { waitForFunctionToBeTrue } from '../../Utils'; +import { hashForWitnessV1, setup, tweakMusig, zkp } from '../../../../lib/Core'; +import { ECPair } from '../../../../lib/ECPairHelper'; +import Logger from '../../../../lib/Logger'; +import { getHexString } from '../../../../lib/Utils'; +import { CurrencyType, SwapUpdateEvent } from '../../../../lib/consts/Enums'; +import { NodeType } from '../../../../lib/db/models/ReverseSwap'; +import Swap from '../../../../lib/db/models/Swap'; +import ReverseSwapRepository from '../../../../lib/db/repositories/ReverseSwapRepository'; +import SwapRepository from '../../../../lib/db/repositories/SwapRepository'; +import Errors from '../../../../lib/service/Errors'; +import MusigSigner from '../../../../lib/service/cooperative/MusigSigner'; +import SwapNursery from '../../../../lib/swap/SwapNursery'; +import Wallet from '../../../../lib/wallet/Wallet'; +import WalletManager, { Currency } from '../../../../lib/wallet/WalletManager'; +import { waitForFunctionToBeTrue } from '../../../Utils'; import { bitcoinClient, bitcoinLndClient, bitcoinLndClient2, clnClient, -} from '../Nodes'; +} from '../../Nodes'; -jest.mock('../../../lib/db/repositories/ChainTipRepository'); -jest.mock('../../../lib/db/repositories/ReverseSwapRepository'); +jest.mock('../../../../lib/db/repositories/ChainTipRepository'); +jest.mock('../../../../lib/db/repositories/ReverseSwapRepository'); -jest.mock('../../../lib/db/repositories/SwapRepository', () => ({ +jest.mock('../../../../lib/db/repositories/SwapRepository', () => ({ getSwap: jest.fn().mockResolvedValue(undefined), })); diff --git a/test/unit/api/v2/ApiV2.spec.ts b/test/unit/api/v2/ApiV2.spec.ts index 9764c796..1938cb1c 100644 --- a/test/unit/api/v2/ApiV2.spec.ts +++ b/test/unit/api/v2/ApiV2.spec.ts @@ -68,7 +68,12 @@ describe('ApiV2', () => { use: jest.fn(), } as any; - new ApiV2(Logger.disabledLogger, {} as any, {} as any).registerRoutes(app); + new ApiV2( + Logger.disabledLogger, + {} as any, + {} as any, + {} as any, + ).registerRoutes(app); expect(mockGetInfoRouter).toHaveBeenCalledTimes(1); expect(mockSwapGetRouter).toHaveBeenCalledTimes(1); diff --git a/test/unit/grpc/GrpcService.spec.ts b/test/unit/grpc/GrpcService.spec.ts index 92f09c99..dbda579b 100644 --- a/test/unit/grpc/GrpcService.spec.ts +++ b/test/unit/grpc/GrpcService.spec.ts @@ -110,6 +110,17 @@ jest.mock('../../../lib/service/Service', () => { deriveBlindingKeys: mockDeriveBlindingKeys, unblindOutputsFromId: mockUnblindOutputsFromId, }, + swapManager: { + deferredClaimer: { + sweep: jest.fn().mockResolvedValue( + new Map([ + ['BTC', ['everything1', 'everything2']], + ['L-BTC', ['everything3']], + ]), + ), + sweepSymbol: jest.fn().mockResolvedValue(['currency1', 'currency2']), + }, + }, getInfo: mockGetInfo, getBalance: mockGetBalance, deriveKeys: mockDeriveKeys, @@ -389,6 +400,48 @@ describe('GrpcService', () => { }); }); + test('should sweep swaps of one currency', async () => { + const symbol = 'BTC'; + + await new Promise((resolve) => { + grpcService.sweepSwaps( + createCall({ symbol }), + createCallback((error, response: boltzrpc.SweepSwapsResponse) => { + expect(error).toEqual(null); + expect(response.toObject().claimedSymbolsMap).toEqual([ + ['BTC', { claimedIdsList: ['currency1', 'currency2'] }], + ]); + resolve(); + }), + ); + }); + + expect( + service.swapManager.deferredClaimer.sweepSymbol, + ).toHaveBeenCalledTimes(1); + expect( + service.swapManager.deferredClaimer.sweepSymbol, + ).toHaveBeenCalledWith(symbol); + }); + + test('should sweep swaps of all currencies', async () => { + await new Promise((resolve) => { + grpcService.sweepSwaps( + createCall({}), + createCallback((error, response: boltzrpc.SweepSwapsResponse) => { + expect(error).toEqual(null); + expect(response.toObject().claimedSymbolsMap).toEqual([ + ['BTC', { claimedIdsList: ['everything1', 'everything2'] }], + ['L-BTC', { claimedIdsList: ['everything3'] }], + ]); + resolve(); + }), + ); + }); + + expect(service.swapManager.deferredClaimer.sweep).toHaveBeenCalledTimes(1); + }); + test('should handle resolved callbacks', async () => { const call = randomBytes(32); const cb = jest.fn(); diff --git a/test/unit/notifications/CommandHandler.spec.ts b/test/unit/notifications/CommandHandler.spec.ts index 7b28fb0e..9300f5af 100644 --- a/test/unit/notifications/CommandHandler.spec.ts +++ b/test/unit/notifications/CommandHandler.spec.ts @@ -4,7 +4,12 @@ import { satoshisToSatcomma, } from '../../../lib/DenominationConverter'; import Logger from '../../../lib/Logger'; -import { getHexBuffer, getHexString, stringify } from '../../../lib/Utils'; +import { + getHexBuffer, + getHexString, + mapToObject, + stringify, +} from '../../../lib/Utils'; import BackupScheduler from '../../../lib/backup/BackupScheduler'; import ReferralStats from '../../../lib/data/ReferralStats'; import Stats from '../../../lib/data/Stats'; @@ -14,6 +19,7 @@ import PairRepository from '../../../lib/db/repositories/PairRepository'; import ReverseSwapRepository from '../../../lib/db/repositories/ReverseSwapRepository'; import SwapRepository from '../../../lib/db/repositories/SwapRepository'; import CommandHandler from '../../../lib/notifications/CommandHandler'; +import { codeBlock } from '../../../lib/notifications/Markup'; import DiscordClient from '../../../lib/notifications/clients/DiscordClient'; import { Balances, GetBalanceResponse } from '../../../lib/proto/boltzrpc_pb'; import Service from '../../../lib/service/Service'; @@ -118,6 +124,16 @@ const mockSendCoins = jest jest.mock('../../../lib/service/Service', () => { return jest.fn().mockImplementation(() => { return { + swapManager: { + deferredClaimer: { + pendingSweeps: jest.fn().mockReturnValue( + new Map([ + ['BTC', ['everything1', 'everything2']], + ['L-BTC', ['everything3']], + ]), + ), + }, + }, getBalance: async () => { const res = new GetBalanceResponse(); @@ -229,6 +245,7 @@ describe('CommandHandler', () => { '**getbalance**: gets the balance of the wallets and channels\n' + '**lockedfunds**: gets funds locked up by Boltz\n' + '**pendingswaps**: gets a list of pending (reverse) swaps\n' + + '**pendingsweeps**: gets all pending sweeps\n' + '**getreferrals**: gets stats for all referral IDs\n' + '**backup**: uploads a backup of the databases\n' + '**withdraw**: withdraws coins from Boltz\n' + @@ -430,6 +447,19 @@ describe('CommandHandler', () => { ); }); + test('should get pending sweeps', async () => { + sendMessage('pendingsweeps'); + await wait(50); + + expect( + service.swapManager.deferredClaimer.pendingSweeps, + ).toHaveBeenCalledTimes(1); + expect(mockSendMessage).toHaveBeenCalledTimes(1); + expect(mockSendMessage).toHaveBeenCalledWith( + `${codeBlock}${stringify(mapToObject(service.swapManager.deferredClaimer.pendingSweeps()))}${codeBlock}`, + ); + }); + test('should get referral stats', async () => { sendMessage('getreferrals'); await wait(50); diff --git a/test/unit/service/EventHandler.spec.ts b/test/unit/service/EventHandler.spec.ts index 1f12d678..2336d40b 100644 --- a/test/unit/service/EventHandler.spec.ts +++ b/test/unit/service/EventHandler.spec.ts @@ -16,6 +16,7 @@ type channelBackupCallback = (channelBackup: string) => void; type lockupFailedCallback = (swap: Swap) => void; type invoicePaidCallback = (swap: Swap) => void; +type claimPendingCallback = (swap: Swap) => void; type invoiceFailedCallback = (swap: Swap) => void; type invoicePendingCallback = (swap: Swap) => void; type refundCallback = (reverseSwap: ReverseSwap) => void; @@ -70,6 +71,7 @@ let emitTransaction: transactionCallback; let emitInvoicePaid: invoicePaidCallback; let emitLockupFailed: lockupFailedCallback; let emitMinerfeePaid: minerfeePaidCallback; +let emitClaimPending: claimPendingCallback; let emitInvoiceExpired: invoiceExpiredCallback; let emitInvoicePending: invoicePendingCallback; let emitInvoiceSettled: invoiceSettledCallback; @@ -133,6 +135,10 @@ jest.mock('../../../lib/swap/SwapNursery', () => { case 'invoice.expired': emitInvoiceExpired = callback; break; + + case 'claim.pending': + emitClaimPending = callback; + break; } }, channelNursery: { @@ -601,6 +607,23 @@ describe('EventHandler', () => { eventsEmitted = 0; }); + test('should subscribe to claim.pending', async () => { + const swap = { id: 'swapId' } as unknown as Swap; + + const emitPromise = new Promise((resolve) => { + eventHandler.once('swap.update', (id, msg) => { + expect(id).toEqual(swap.id); + expect(msg).toEqual({ + status: SwapUpdateEvent.TransactionClaimPending, + }); + resolve(); + }); + }); + + emitClaimPending(swap); + await emitPromise; + }); + test('should subscribe to channel backups', () => { let eventEmitted = false; diff --git a/test/unit/service/Service.spec.ts b/test/unit/service/Service.spec.ts index 63c23abe..15c79fa4 100644 --- a/test/unit/service/Service.spec.ts +++ b/test/unit/service/Service.spec.ts @@ -618,6 +618,7 @@ describe('Service', () => { mockedWalletManager(), new NodeSwitch(Logger.disabledLogger), currencies, + {} as any, ); // Inject a mocked SwapManager diff --git a/test/unit/swap/SwapManager.spec.ts b/test/unit/swap/SwapManager.spec.ts index 4ead3359..630cd8bb 100644 --- a/test/unit/swap/SwapManager.spec.ts +++ b/test/unit/swap/SwapManager.spec.ts @@ -345,6 +345,7 @@ describe('SwapManager', () => { SwapRepository.addSwap = mockAddSwap; SwapRepository.getSwaps = mockGetSwaps; SwapRepository.setInvoice = mockSetInvoice; + SwapRepository.getSwapsClaimable = jest.fn().mockResolvedValue([]); ReverseSwapRepository.addReverseSwap = mockAddReverseSwap; ReverseSwapRepository.getReverseSwaps = mockGetReverseSwaps; @@ -374,6 +375,9 @@ describe('SwapManager', () => { new SwapOutputType(OutputType.Compatibility), 0, blocks, + { + deferredClaimSymbols: [], + } as any, ); manager['currencies'].set(btcCurrency.symbol, btcCurrency);