From 7eda150eea296780ae6a3ab5feb1a5257c51dabd Mon Sep 17 00:00:00 2001 From: michael1011 Date: Fri, 1 Feb 2019 21:43:23 +0100 Subject: [PATCH] feat: automatically refund failed swaps --- lib/chain/ChainClient.ts | 6 +- lib/chain/ChainClientInterface.ts | 9 +- lib/cli/BuilderComponents.ts | 1 + lib/lightning/LndClient.ts | 42 +++++--- lib/service/Service.ts | 4 +- lib/swap/Errors.ts | 2 +- lib/swap/SwapManager.ts | 147 ++++++---------------------- lib/swap/SwapNursery.ts | 154 ++++++++++++++++++++++++++++++ lib/wallet/Wallet.ts | 2 +- 9 files changed, 229 insertions(+), 138 deletions(-) create mode 100644 lib/swap/SwapNursery.ts diff --git a/lib/chain/ChainClient.ts b/lib/chain/ChainClient.ts index 0fc63cd6..27f030f1 100644 --- a/lib/chain/ChainClient.ts +++ b/lib/chain/ChainClient.ts @@ -23,15 +23,17 @@ class ChainClient extends BaseClient implements ChainClientInterface { switch (data.method) { // Emits an event on mempool acceptance case 'relevanttxaccepted': - data.params.forEach((transaction) => { + data.params.forEach((transaction: string) => { this.emit('transaction.relevant.mempool', transaction); }); break; - // Emits an event on block acceptance + // Emits an event when a blocks gets added case 'filteredblockconnected': const params: any[] = data.params; + this.emit('block.connected', params[0]); + if (params[2] !== null) { const transactions = params[2] as string[]; diff --git a/lib/chain/ChainClientInterface.ts b/lib/chain/ChainClientInterface.ts index 6be7ada4..f70d6742 100644 --- a/lib/chain/ChainClientInterface.ts +++ b/lib/chain/ChainClientInterface.ts @@ -53,10 +53,15 @@ interface ChainClientInterface { generate(blocks: number): Promise; on(event: 'error', listener: (error: string) => void): this; - on(event: 'transaction.relevant.mempool', listener: (transactionHex: string) => void): this; - on(event: 'transaction.relevant.block', listener: (transactionhex: string, blockHeigh: number) => void): this; emit(event: 'error', error: string): boolean; + + on(event: 'block.connected', listener: (height: number) => void): this; + emit(event: 'block.connected', height: number): boolean; + + on(event: 'transaction.relevant.mempool', listener: (transactionHex: string) => void): this; emit(event: 'transaction.relevant.mempool', transactionHex: string): boolean; + + on(event: 'transaction.relevant.block', listener: (transactionhex: string, blockHeigh: number) => void): this; emit(event: 'transaction.relevant.block', transactionhex: string, blockHeigh: number): boolean; } diff --git a/lib/cli/BuilderComponents.ts b/lib/cli/BuilderComponents.ts index 7ba4bd49..c30ccd96 100644 --- a/lib/cli/BuilderComponents.ts +++ b/lib/cli/BuilderComponents.ts @@ -49,6 +49,7 @@ export default { }, timeoutBlockNumber: { describe: 'after how my blocks the onchain script of the swap should time out', + default: '10', type: 'number', }, }; diff --git a/lib/lightning/LndClient.ts b/lib/lightning/LndClient.ts index ece0255b..ce65f85a 100644 --- a/lib/lightning/LndClient.ts +++ b/lib/lightning/LndClient.ts @@ -8,7 +8,9 @@ import { ClientStatus } from '../consts/Enums'; import LightningClient from './LightningClient'; import { LightningClient as GrpcClient } from '../proto/lndrpc_grpc_pb'; -/** The configurable options for the lnd client. */ +/** + * The configurable options for the lnd client + */ type LndConfig = { host: string; port: number; @@ -16,7 +18,9 @@ type LndConfig = { macaroonpath: string; }; -/** General information about the state of this lnd client. */ +/** + * General information about the state of this lnd client + */ type Info = { version?: string; syncedtochain?: boolean; @@ -40,6 +44,7 @@ interface GrpcResponse { interface LndClient { on(event: 'invoice.paid', listener: (invoice: string) => void): this; emit(event: 'invoice.paid', invoice: string): boolean; + on(event: 'invoice.failed', listener: (invoice: string) => void): this; emit(event: 'invoice.failed', invoice: string): boolean; @@ -51,7 +56,9 @@ interface LightningMethodIndex extends GrpcClient { [methodName: string]: Function; } -/** A class representing a client to interact with LND */ +/** + * A class representing a client to interact with LND + */ class LndClient extends BaseClient implements LightningClient { public static readonly serviceName = 'LND'; private uri!: string; @@ -132,7 +139,9 @@ class LndClient extends BaseClient implements LightningClient { return true; } - /** End all subscriptions and reconnection attempts. */ + /** + * End all subscriptions and reconnection attempts + */ public disconnect = () => { this.clearReconnectTimer(); @@ -160,17 +169,21 @@ class LndClient extends BaseClient implements LightningClient { let uris: string[] | undefined; let version: string | undefined; let syncedtochain: boolean | undefined; + try { const lnd = await this.getInfo(); + channels = { active: lnd.numActiveChannels, pending: lnd.numPendingChannels, }; + chainsList = lnd.chainsList, blockheight = lnd.blockHeight, uris = lnd.urisList, version = lnd.version; syncedtochain = lnd.syncedToChain; + return { version, syncedtochain, @@ -181,6 +194,7 @@ class LndClient extends BaseClient implements LightningClient { }; } catch (err) { this.logger.error(`LND error: ${err}`); + return { version, syncedtochain, @@ -220,18 +234,16 @@ class LndClient extends BaseClient implements LightningClient { public payInvoice = async (invoice: string) => { const request = new lndrpc.SendRequest(); request.setPaymentRequest(invoice); - try { - const response = await this.unaryCall('sendPaymentSync', request); - if (response.paymentError === '') { - this.emit('invoice.paid', invoice); - } else { - this.emit('invoice.failed', invoice); - } - return response; - } catch (error) { - this.logger.warn(`Failed to pay invoice ${invoice}: ${error}`); - return error; + + const response = await this.unaryCall('sendPaymentSync', request); + + if (response.paymentError === '') { + this.emit('invoice.paid', invoice); + } else { + this.emit('invoice.failed', invoice); } + + return response; } /** diff --git a/lib/service/Service.ts b/lib/service/Service.ts index c9dedc3a..b59066d1 100644 --- a/lib/service/Service.ts +++ b/lib/service/Service.ts @@ -120,7 +120,9 @@ class Service extends EventEmitter { * Gets the balance for either all wallets or just a single one if specified */ public getBalance = async (args: { currency: string }) => { - argChecks.VALID_CURRENCY(args); + if (args.currency !== '') { + argChecks.VALID_CURRENCY(args); + } const { walletManager } = this.serviceComponents; diff --git a/lib/swap/Errors.ts b/lib/swap/Errors.ts index 30f20847..00930f37 100644 --- a/lib/swap/Errors.ts +++ b/lib/swap/Errors.ts @@ -1,6 +1,6 @@ import { Error } from '../consts/Types'; -import { ErrorCodePrefix } from '../consts/Enums'; import { concatErrorCode } from '../Utils'; +import { ErrorCodePrefix } from '../consts/Enums'; export default { CURRENCY_NOT_FOUND: (currency: string): Error => ({ diff --git a/lib/swap/SwapManager.ts b/lib/swap/SwapManager.ts index c981adbb..26c9ed03 100644 --- a/lib/swap/SwapManager.ts +++ b/lib/swap/SwapManager.ts @@ -1,49 +1,24 @@ -import { BIP32 } from 'bip32'; -import { Transaction, address } from 'bitcoinjs-lib'; -import { OutputType, TransactionOutput, Scripts, pkRefundSwap, constructClaimTransaction } from 'boltz-core'; +import { OutputType, Scripts, pkRefundSwap } from 'boltz-core'; import Errors from './Errors'; import Logger from '../Logger'; -import LndClient from '../lightning/LndClient'; import { OrderSide } from '../proto/boltzrpc_pb'; import WalletManager, { Currency } from '../wallet/WalletManager'; -import { getHexBuffer, getHexString, getScriptHashEncodeFunction, reverseString } from '../Utils'; +import { getHexBuffer, getHexString, getScriptHashEncodeFunction } from '../Utils'; +import SwapNursery, { SwapMaps, SwapDetails, ReverseSwapDetails } from './SwapNursery'; -const { p2shP2wshOutput } = Scripts; - -type BaseSwapDetails = { - redeemScript: Buffer; -}; - -type SwapDetails = BaseSwapDetails & { - lndClient: LndClient; - expectedAmount: number; - invoice: string; - claimKeys: BIP32; - outputType: OutputType; -}; - -type ReverseSwapDetails = BaseSwapDetails & { - refundKeys: BIP32; - output: TransactionOutput; -}; - -type SwapMaps = { - // A map between an output script and the SwapDetails - swaps: Map; - - // A map between an invoice and the ReverseSwapDetails - reverseSwaps: Map; -}; +const { p2wshOutput } = Scripts; class SwapManager { public currencies = new Map(); constructor(private logger: Logger, private walletManager: WalletManager, currencies: Currency[]) { + const nursery = new SwapNursery(this.logger, this.walletManager); + currencies.forEach((currency) => { if (!this.currencies.get(currency.symbol)) { const swapMaps = { swaps: new Map(), - reverseSwaps: new Map(), + reverseSwaps: new Map(), }; this.currencies.set(currency.symbol, { @@ -51,7 +26,7 @@ class SwapManager { ...swapMaps, }); - this.bindCurrency(currency, swapMaps); + nursery.bindCurrency(currency, swapMaps); } }); } @@ -71,7 +46,7 @@ class SwapManager { * @returns an onchain address */ public createSwap = async (baseCurrency: string, quoteCurrency: string, orderSide: OrderSide, rate: number, - invoice: string, refundPublicKey: Buffer, outputType: OutputType, timeoutBlockNumber = 10) => { + invoice: string, refundPublicKey: Buffer, outputType: OutputType, timeoutBlockNumber: number) => { const { sendingCurrency, receivingCurrency } = this.getCurrencies(baseCurrency, quoteCurrency, orderSide); @@ -131,7 +106,7 @@ class SwapManager { * @returns a Lightning invoice, the lockup transaction and its hash */ public createReverseSwap = async (baseCurrency: string, quoteCurrency: string, orderSide: OrderSide, rate: number, - claimPublicKey: Buffer, amount: number, timeoutBlockNumber = 10) => { + claimPublicKey: Buffer, amount: number, timeoutBlockNumber: number) => { const { sendingCurrency, receivingCurrency } = this.getCurrencies(baseCurrency, quoteCurrency, orderSide); @@ -139,9 +114,10 @@ class SwapManager { this.logger.verbose(`Creating new reverse Swap from ${receivingCurrency.symbol} to ${sendingCurrency.symbol} ` + `for public key: ${getHexString(claimPublicKey)}`); - const bestBlock = await sendingCurrency.chainClient.getBestBlock(); const { rHash, paymentRequest } = await receivingCurrency.lndClient.addInvoice(amount); const { keys } = sendingCurrency.wallet.getNewKeys(); + + const bestBlock = await sendingCurrency.chainClient.getBestBlock(); const timeoutBlockHeight = bestBlock.height + timeoutBlockNumber; const redeemScript = pkRefundSwap( @@ -151,29 +127,42 @@ class SwapManager { timeoutBlockHeight, ); - const outputScript = p2shP2wshOutput(redeemScript); + const outputScript = p2wshOutput(redeemScript); const address = sendingCurrency.wallet.encodeAddress(outputScript); const sendingAmount = this.calculateExpectedAmount(amount, 1 / this.getRate(rate, orderSide)); - const { tx, vout } = await sendingCurrency.wallet.sendToAddress(address, OutputType.Compatibility, true, sendingAmount); + const { tx, vout } = await sendingCurrency.wallet.sendToAddress(address, OutputType.Bech32, true, sendingAmount); this.logger.debug(`Sending ${sendingAmount} on ${sendingCurrency.symbol} to swap address ${address}: ${tx.getId()}`); const rawTx = tx.toHex(); await sendingCurrency.chainClient.sendRawTransaction(rawTx); - sendingCurrency.reverseSwaps.set(paymentRequest, { + // Get the array of swaps that time out at the same block + const pendingReverseSwaps = sendingCurrency.reverseSwaps.get(timeoutBlockHeight); + + const reverseSwapDetails = { redeemScript, refundKeys: keys, output: { vout, txHash: tx.getHash(), - type: OutputType.Compatibility, + type: OutputType.Bech32, script: outputScript, value: sendingAmount, }, - }); + }; + + // Push the new swap to the array or create a new array if it doesn't exist yet + if (pendingReverseSwaps) { + pendingReverseSwaps.push(reverseSwapDetails); + } else { + sendingCurrency.reverseSwaps.set(timeoutBlockHeight, [reverseSwapDetails]); + } + + console.log(timeoutBlockHeight); + console.log(sendingCurrency.reverseSwaps.get(timeoutBlockHeight)); return { invoice: paymentRequest, @@ -184,81 +173,6 @@ class SwapManager { }; } - private bindCurrency = (currency: Currency, maps: SwapMaps) => { - currency.chainClient.on('transaction.relevant.block', async (transactionHex: string) => { - const transaction = Transaction.fromHex(transactionHex); - - let vout = 0; - - for (const output of transaction.outs) { - const hexScript = getHexString(output.script); - const swapDetails = maps.swaps.get(hexScript); - - if (swapDetails) { - maps.swaps.delete(hexScript); - await this.claimSwap( - currency, - swapDetails.lndClient, - transaction.getHash(), - output.script, - output.value, - vout, - swapDetails, - ); - } - - vout += 1; - } - }); - } - - private claimSwap = async (currency: Currency, lndClient: LndClient, - txHash: Buffer, outpuScript: Buffer, outputValue: number, vout: number, details: SwapDetails) => { - - const swapOutput = `${reverseString(getHexString(txHash))}:${vout}`; - - if (outputValue < details.expectedAmount) { - this.logger.warn(`Value ${outputValue} of ${swapOutput} is less than expected ${details.expectedAmount}. Aborting swap`); - return; - } - - const { symbol, chainClient } = currency; - - // The ID of the transaction is used by wallets, block explorers and node software and is the reversed hash of the transaction - this.logger.info(`Claiming swap output of ${symbol} transaction ${swapOutput}`); - - const payInvoice = await lndClient.payInvoice(details.invoice); - - if (payInvoice.paymentError !== '') { - this.logger.warn(`Could not pay invoice ${details.invoice}: ${payInvoice.paymentError}`); - return; - } - - const preimage = payInvoice.paymentPreimage as string; - this.logger.verbose(`Got preimage: ${preimage}`); - - const destinationAddress = await this.walletManager.wallets.get(currency.symbol)!.getNewAddress(OutputType.Bech32); - - const claimTx = constructClaimTransaction( - [{ - vout, - txHash, - value: outputValue, - script: outpuScript, - keys: details.claimKeys, - type: details.outputType, - redeemScript: details.redeemScript, - preimage: Buffer.from(preimage, 'base64'), - }], - address.toOutputScript(destinationAddress, currency.network), - 1, - true, - ); - - this.logger.silly(`Broadcasting claim transaction: ${claimTx.getId()}`); - await chainClient.sendRawTransaction(claimTx.toHex()); - } - private getCurrencies = (baseCurrency: string, quoteCurrency: string, orderSide: OrderSide) => { const base = this.getCurrency(baseCurrency); const quote = this.getCurrency(quoteCurrency); @@ -300,3 +214,4 @@ class SwapManager { } export default SwapManager; +export { SwapMaps, SwapDetails, ReverseSwapDetails }; diff --git a/lib/swap/SwapNursery.ts b/lib/swap/SwapNursery.ts new file mode 100644 index 00000000..ba2fc3c8 --- /dev/null +++ b/lib/swap/SwapNursery.ts @@ -0,0 +1,154 @@ +import { BIP32 } from 'bip32'; +import { Transaction, address } from 'bitcoinjs-lib'; +import { constructClaimTransaction, OutputType, TransactionOutput, constructRefundTransaction } from 'boltz-core'; +import Logger from '../Logger'; +import LndClient from '../lightning/LndClient'; +import { getHexString, reverseString } from '../Utils'; +import WalletManager, { Currency } from '../wallet/WalletManager'; + +type BaseSwapDetails = { + redeemScript: Buffer; +}; + +type SwapDetails = BaseSwapDetails & { + lndClient: LndClient; + expectedAmount: number; + invoice: string; + claimKeys: BIP32; + outputType: OutputType; +}; + +type ReverseSwapDetails = BaseSwapDetails & { + refundKeys: BIP32; + output: TransactionOutput; +}; + +type SwapMaps = { + // A map between the output scripts and the swaps details + swaps: Map; + + // A map between the timeout block heights and the reverse swaps details + reverseSwaps: Map; +}; + +class SwapNursery { + constructor(private logger: Logger, private walletManager: WalletManager) {} + + public bindCurrency = (currency: Currency, maps: SwapMaps) => { + currency.chainClient.on('transaction.relevant.block', async (transactionHex: string) => { + const transaction = Transaction.fromHex(transactionHex); + + let vout = 0; + + for (const output of transaction.outs) { + const hexScript = getHexString(output.script); + const swapDetails = maps.swaps.get(hexScript); + + if (swapDetails) { + maps.swaps.delete(hexScript); + + await this.claimSwap( + currency, + swapDetails.lndClient, + transaction.getHash(), + output.script, + output.value, + vout, + swapDetails, + ); + } + + vout += 1; + } + }); + + currency.chainClient.on('block.connected', async (height: number) => { + const reverseSwaps = maps.reverseSwaps.get(height); + + console.log(height); + console.log(reverseSwaps); + if (reverseSwaps) { + await this.refundSwap(currency, reverseSwaps, height); + } + }); + } + + private claimSwap = async (currency: Currency, lndClient: LndClient, txHash: Buffer, + outputScript: Buffer, outputValue: number, vout: number, details: SwapDetails) => { + + const swapOutput = `${this.getTransactionId(txHash)}:${vout}`; + + if (outputValue < details.expectedAmount) { + this.logger.warn(`Value ${outputValue} of ${swapOutput} is less than expected ${details.expectedAmount}. Aborting swap`); + return; + } + + this.logger.info(`Claiming ${currency.symbol} swap output: ${swapOutput}`); + + const payInvoice = await lndClient.payInvoice(details.invoice); + + if (payInvoice.paymentError !== '') { + this.logger.warn(`Could not pay invoice ${details.invoice}: ${payInvoice.paymentError}`); + return; + } + + const preimage = payInvoice.paymentPreimage as string; + this.logger.debug(`Got preimage: ${preimage}`); + + const destinationAddress = await this.walletManager.wallets.get(currency.symbol)!.getNewAddress(OutputType.Bech32); + + const claimTx = constructClaimTransaction( + [{ + vout, + txHash, + value: outputValue, + script: outputScript, + keys: details.claimKeys, + type: details.outputType, + redeemScript: details.redeemScript, + preimage: Buffer.from(preimage, 'base64'), + }], + address.toOutputScript(destinationAddress, currency.network), + 1, + true, + ); + + this.logger.verbose(`Broadcasting claim transaction: ${claimTx.getId()}`); + await currency.chainClient.sendRawTransaction(claimTx.toHex()); + } + + private refundSwap = async (currency: Currency, reverseSwapDetails: ReverseSwapDetails[], timeoutBlockHeight: number) => { + for (const details of reverseSwapDetails) { + this.logger.info(`Refunding ${currency.symbol} swap output: ` + + `${this.getTransactionId(details.output.txHash)}:${details.output.vout}`); + + const destinationAddress = await this.walletManager.wallets.get(currency.symbol)!.getNewAddress(OutputType.Bech32); + + const refundTx = constructRefundTransaction( + [{ + ...details.output, + keys: details.refundKeys, + redeemScript: details.redeemScript, + }], + address.toOutputScript(destinationAddress, currency.network), + timeoutBlockHeight, + 1, + ); + + this.logger.verbose(`Broadcasting refund transaction: ${refundTx.getId()}`); + + try { + await currency.chainClient.sendRawTransaction(refundTx.toHex()); + } catch (error) { + this.logger.warn(`Could not broadcast refund transaction: ${error}`); + } + } + } + + private getTransactionId = (hash: Buffer) => { + return reverseString(getHexString(hash)); + } +} + +export default SwapNursery; +export { SwapMaps, SwapDetails, ReverseSwapDetails }; diff --git a/lib/wallet/Wallet.ts b/lib/wallet/Wallet.ts index f6aed325..73fb1265 100644 --- a/lib/wallet/Wallet.ts +++ b/lib/wallet/Wallet.ts @@ -243,7 +243,7 @@ class Wallet { * * @returns the transaction itself and the vout of the provided address */ - public sendToAddress = async (address: string, type: OutputType, isScriptHash: boolean, amount: number, feePerByte = 10): + public sendToAddress = async (address: string, type: OutputType, isScriptHash: boolean, amount: number, feePerByte = 2): Promise<{ tx: Transaction, vout: number }> => { const utxos = await this.utxoRepository.getUtxosSorted(this.symbol);