UNPKG

@atomiqlabs/sdk

Version:

atomiq labs SDK for cross-chain swaps between smart chains and bitcoin

1,098 lines (1,097 loc) 49.8 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.IToBTCSwap = exports.ToBTCSwapState = exports.isIToBTCSwapInit = void 0; const base_1 = require("@atomiqlabs/base"); const IntermediaryAPI_1 = require("../../../intermediaries/apis/IntermediaryAPI"); const IntermediaryError_1 = require("../../../errors/IntermediaryError"); const Utils_1 = require("../../../utils/Utils"); const IEscrowSelfInitSwap_1 = require("../IEscrowSelfInitSwap"); const FeeType_1 = require("../../../enums/FeeType"); const PercentagePPM_1 = require("../../../types/fees/PercentagePPM"); const TokenAmount_1 = require("../../../types/TokenAmount"); const TimeoutUtils_1 = require("../../../utils/TimeoutUtils"); function isIToBTCSwapInit(obj) { return typeof (obj.networkFee) === "bigint" && typeof (obj.networkFeeBtc) === "bigint" && (obj.signatureData == null || (typeof (obj.signatureData) === 'object' && typeof (obj.signatureData.prefix) === "string" && typeof (obj.signatureData.timeout) === "string" && typeof (obj.signatureData.signature) === "string")) && typeof (obj.data) === 'object' && (0, IEscrowSelfInitSwap_1.isIEscrowSelfInitSwapInit)(obj); } exports.isIToBTCSwapInit = isIToBTCSwapInit; /** * State enum for escrow-based Smart chain -> Bitcoin (on-chain & lightning) swaps * * @category Swaps/Smart chain → Bitcoin */ var ToBTCSwapState; (function (ToBTCSwapState) { /** * Intermediary (LP) was unable to process the swap and the funds were refunded on the * source chain */ ToBTCSwapState[ToBTCSwapState["REFUNDED"] = -3] = "REFUNDED"; /** * Swap has expired for good and there is no way how it can be executed anymore */ ToBTCSwapState[ToBTCSwapState["QUOTE_EXPIRED"] = -2] = "QUOTE_EXPIRED"; /** * A swap is almost expired, and it should be presented to the user as expired, though * there is still a chance that it will be processed */ ToBTCSwapState[ToBTCSwapState["QUOTE_SOFT_EXPIRED"] = -1] = "QUOTE_SOFT_EXPIRED"; /** * Swap was created, use the {@link IToBTCSwap.commit} or {@link IToBTCSwap.txsCommit} to * initiate it by creating the swap escrow on the source chain */ ToBTCSwapState[ToBTCSwapState["CREATED"] = 0] = "CREATED"; /** * Swap escrow was initiated (committed) on the source chain, the intermediary (LP) will * now process the swap. You can wait till that happens with the {@link IToBTCSwap.waitForPayment} * function. */ ToBTCSwapState[ToBTCSwapState["COMMITED"] = 1] = "COMMITED"; /** * The intermediary (LP) has processed the transaction and sent out the funds on the destination chain, * but hasn't yet settled the escrow on the source chain. */ ToBTCSwapState[ToBTCSwapState["SOFT_CLAIMED"] = 2] = "SOFT_CLAIMED"; /** * Swap was successfully settled by the intermediary (LP) on the source chain */ ToBTCSwapState[ToBTCSwapState["CLAIMED"] = 3] = "CLAIMED"; /** * Intermediary (LP) was unable to process the swap and the swap escrow on the source chain * is refundable, call {@link IToBTCSwap.refund} or {@link IToBTCSwap.txsRefund} to refund */ ToBTCSwapState[ToBTCSwapState["REFUNDABLE"] = 4] = "REFUNDABLE"; })(ToBTCSwapState = exports.ToBTCSwapState || (exports.ToBTCSwapState = {})); const ToBTCSwapStateDescription = { [ToBTCSwapState.REFUNDED]: "Intermediary (LP) was unable to process the swap and the funds were refunded on the source chain", [ToBTCSwapState.QUOTE_EXPIRED]: "Swap has expired for good and there is no way how it can be executed anymore", [ToBTCSwapState.QUOTE_SOFT_EXPIRED]: "A swap is expired, though there is still a chance that it will be processed", [ToBTCSwapState.CREATED]: "Swap was created, initiate it by creating the swap escrow on the source chain", [ToBTCSwapState.COMMITED]: "Swap escrow was initiated (committed) on the source chain, the intermediary (LP) will now process the swap.", [ToBTCSwapState.SOFT_CLAIMED]: "The intermediary (LP) has processed the transaction and sent out the funds on the destination chain, but hasn't yet settled the escrow on the source chain.", [ToBTCSwapState.CLAIMED]: "Swap was successfully settled by the intermediary (LP) on the source chain", [ToBTCSwapState.REFUNDABLE]: "Intermediary (LP) was unable to process the swap and the swap escrow on the source chain is refundable." }; /** * Base class for escrow-based Smart chain -> Bitcoin (on-chain & lightning) swaps * * @category Swaps/Smart chain → Bitcoin */ class IToBTCSwap extends IEscrowSelfInitSwap_1.IEscrowSelfInitSwap { constructor(wrapper, initOrObject) { super(wrapper, initOrObject); /** * @internal */ this.swapStateDescription = ToBTCSwapStateDescription; /** * @internal */ this.swapStateName = (state) => ToBTCSwapState[state]; if (isIToBTCSwapInit(initOrObject)) { this._state = ToBTCSwapState.CREATED; this.networkFee = initOrObject.networkFee; this.networkFeeBtc = initOrObject.networkFeeBtc; this._data = initOrObject.data; this.signatureData = initOrObject.signatureData; } else { this.networkFee = (0, Utils_1.toBigInt)(initOrObject.networkFee); this.networkFeeBtc = (0, Utils_1.toBigInt)(initOrObject.networkFeeBtc); } } /** * @inheritDoc * @internal */ getSwapData() { return this._data; } /** * @inheritDoc * @internal */ upgradeVersion() { if (this.version == null) { switch (this._state) { case -2: this._state = ToBTCSwapState.REFUNDED; break; case -1: this._state = ToBTCSwapState.QUOTE_EXPIRED; break; case 0: this._state = ToBTCSwapState.CREATED; break; case 1: this._state = ToBTCSwapState.COMMITED; break; case 2: this._state = ToBTCSwapState.CLAIMED; break; case 3: this._state = ToBTCSwapState.REFUNDABLE; break; } this.version = 1; } } /** * @inheritDoc * @internal */ tryRecomputeSwapPrice() { const output = this.getOutput(); if (output.rawAmount != null) { if (this.swapFeeBtc == null) { this.swapFeeBtc = this.swapFee * output.rawAmount / this.getInputWithoutFee().rawAmount; } if (this.networkFeeBtc == null) { this.networkFeeBtc = this.networkFee * output.rawAmount / this.getInputWithoutFee().rawAmount; } } super.tryRecomputeSwapPrice(); } /** * Returns the payment hash identifier to be sent to the LP for getStatus and getRefund * @internal */ getLpIdentifier() { return this.getClaimHash(); } ////////////////////////////// //// Getters & utils /** * @inheritDoc */ getInputAddress() { return this._getInitiator(); } /** * @inheritDoc */ getInputTxId() { return this._commitTxId ?? null; } /** * @inheritDoc */ requiresAction() { return this.isRefundable(); } /** * @inheritDoc */ isFinished() { return this._state === ToBTCSwapState.CLAIMED || this._state === ToBTCSwapState.REFUNDED || this._state === ToBTCSwapState.QUOTE_EXPIRED; } /** * @inheritDoc */ isRefundable() { return this._state === ToBTCSwapState.REFUNDABLE; } /** * @inheritDoc */ isQuoteExpired() { return this._state === ToBTCSwapState.QUOTE_EXPIRED; } /** * @inheritDoc */ isQuoteSoftExpired() { return this._state === ToBTCSwapState.QUOTE_EXPIRED || this._state === ToBTCSwapState.QUOTE_SOFT_EXPIRED; } /** * @inheritDoc */ isSuccessful() { return this._state === ToBTCSwapState.CLAIMED; } /** * @inheritDoc */ isFailed() { return this._state === ToBTCSwapState.REFUNDED; } /** * @inheritDoc */ isInProgress() { return this._state === ToBTCSwapState.COMMITED || this._state === ToBTCSwapState.SOFT_CLAIMED; } /** * Returns the time (in UNIX milliseconds) at which the swap expires and the user is able to unilaterally * refund it with the {@link refund} or {@link txsRefund} function. */ getExpiry() { return Number(this._data.getExpiry()) * 1000; } /** * @inheritDoc * @internal */ _getInitiator() { return this._data.getOfferer(); } ////////////////////////////// //// Amounts & fees /** * Returns the swap fee charged by the intermediary (LP) on this swap * * @internal */ getSwapFee() { if (this.pricingInfo == null) throw new Error("No pricing info known, cannot estimate fee!"); const feeWithoutBaseFee = this.swapFeeBtc - this.pricingInfo.satsBaseFee; const output = this.getOutput(); const swapFeePPM = output.rawAmount == null ? 0n : feeWithoutBaseFee * 1000000n / output.rawAmount; const amountInDstToken = (0, TokenAmount_1.toTokenAmount)(this.swapFeeBtc, this.outputToken, this.wrapper._prices, this.pricingInfo); return { amountInSrcToken: (0, TokenAmount_1.toTokenAmount)(this.swapFee, this.wrapper._tokens[this._data.getToken()], this.wrapper._prices, this.pricingInfo), amountInDstToken, currentUsdValue: amountInDstToken.currentUsdValue, usdValue: amountInDstToken.usdValue, pastUsdValue: amountInDstToken.pastUsdValue, composition: { base: (0, TokenAmount_1.toTokenAmount)(this.pricingInfo.satsBaseFee, this.outputToken, this.wrapper._prices, this.pricingInfo), percentage: (0, PercentagePPM_1.ppmToPercentage)(swapFeePPM) } }; } /** * Returns network fee for on the destination chain for the swap * * @internal */ getNetworkFee() { const amountInDstToken = (0, TokenAmount_1.toTokenAmount)(this.networkFeeBtc, this.outputToken, this.wrapper._prices, this.pricingInfo); return { amountInSrcToken: (0, TokenAmount_1.toTokenAmount)(this.networkFee, this.wrapper._tokens[this._data.getToken()], this.wrapper._prices, this.pricingInfo), amountInDstToken, currentUsdValue: amountInDstToken.currentUsdValue, usdValue: amountInDstToken.usdValue, pastUsdValue: amountInDstToken.pastUsdValue }; } /** * @inheritDoc */ getFee() { const amountInDstToken = (0, TokenAmount_1.toTokenAmount)(this.swapFeeBtc + this.networkFeeBtc, this.outputToken, this.wrapper._prices, this.pricingInfo); return { amountInSrcToken: (0, TokenAmount_1.toTokenAmount)(this.swapFee + this.networkFee, this.wrapper._tokens[this._data.getToken()], this.wrapper._prices, this.pricingInfo), amountInDstToken, currentUsdValue: amountInDstToken.currentUsdValue, usdValue: amountInDstToken.usdValue, pastUsdValue: amountInDstToken.pastUsdValue }; } /** * @inheritDoc */ getFeeBreakdown() { return [ { type: FeeType_1.FeeType.SWAP, fee: this.getSwapFee() }, { type: FeeType_1.FeeType.NETWORK_OUTPUT, fee: this.getNetworkFee() } ]; } /** * @inheritDoc */ getInputToken() { return this.wrapper._tokens[this._data.getToken()]; } /** * @inheritDoc */ getInput() { return (0, TokenAmount_1.toTokenAmount)(this._data.getAmount(), this.wrapper._tokens[this._data.getToken()], this.wrapper._prices, this.pricingInfo); } /** * @inheritDoc */ getInputWithoutFee() { return (0, TokenAmount_1.toTokenAmount)(this._data.getAmount() - (this.swapFee + this.networkFee), this.wrapper._tokens[this._data.getToken()], this.wrapper._prices, this.pricingInfo); } /** * Checks if the initiator/sender on the source chain has enough balance to go through with the swap */ async hasEnoughBalance() { const [balance, commitFee] = await Promise.all([ this._contract.getBalance(this._getInitiator(), this._data.getToken(), false), this._data.getToken() === this.wrapper._chain.getNativeCurrencyAddress() ? this.getCommitFee() : Promise.resolve(null) ]); let required = this._data.getAmount(); if (commitFee != null) required = required + commitFee; return { enoughBalance: balance >= required, balance: (0, TokenAmount_1.toTokenAmount)(balance, this.wrapper._tokens[this._data.getToken()], this.wrapper._prices, this.pricingInfo), required: (0, TokenAmount_1.toTokenAmount)(required, this.wrapper._tokens[this._data.getToken()], this.wrapper._prices, this.pricingInfo) }; } /** * Checks if the initiator/sender on the source chain has enough native token balance * to cover the transaction fee of initiating the swap */ async hasEnoughForTxFees() { const [balance, commitFee] = await Promise.all([ this._contract.getBalance(this._getInitiator(), this.wrapper._chain.getNativeCurrencyAddress(), false), this.getCommitFee() ]); return { enoughBalance: balance >= commitFee, balance: (0, TokenAmount_1.toTokenAmount)(balance, this.wrapper._getNativeToken(), this.wrapper._prices), required: (0, TokenAmount_1.toTokenAmount)(commitFee, this.wrapper._getNativeToken(), this.wrapper._prices) }; } ////////////////////////////// //// Execution /** * Executes the swap with the provided smart chain wallet/signer * * @param signer Smart chain wallet/signer to use to sign the transaction on the source chain * @param callbacks Callbacks to track the progress of the swap * @param options Optional options for the swap like feeRate, AbortSignal, and timeouts/intervals * * @returns {boolean} Whether the swap was successfully processed by the LP, in case `false` is returned * the user can refund their funds back on the source chain by calling {@link refund} */ async execute(signer, callbacks, options) { if (this._state === ToBTCSwapState.QUOTE_EXPIRED || this._state === ToBTCSwapState.QUOTE_SOFT_EXPIRED) throw new Error("Quote expired"); if (this._state === ToBTCSwapState.REFUNDED) throw new Error("Swap already refunded"); if (this._state === ToBTCSwapState.REFUNDABLE) throw new Error("Swap refundable, refund with swap.refund()"); if (this._state === ToBTCSwapState.SOFT_CLAIMED || this._state === ToBTCSwapState.CLAIMED) throw new Error("Swap already settled!"); if (this._state === ToBTCSwapState.CREATED) { const txId = await this.commit(signer, options?.abortSignal, false, callbacks?.onSourceTransactionSent); if (callbacks?.onSourceTransactionConfirmed != null) callbacks.onSourceTransactionConfirmed(txId); } // @ts-ignore if (this._state === ToBTCSwapState.CLAIMED || this._state === ToBTCSwapState.SOFT_CLAIMED) return true; if (this._state === ToBTCSwapState.COMMITED) { const success = await this.waitForPayment(options?.maxWaitTillSwapProcessedSeconds ?? 120, options?.paymentCheckIntervalSeconds, options?.abortSignal); if (success) { if (callbacks?.onSwapSettled != null) callbacks.onSwapSettled(this.getOutputTxId()); return true; } else { return false; } } throw new Error("Unexpected state reached!"); } /** * @internal */ async _getExecutionStatus() { const state = this._state; let sourcePaymentStatus = "inactive"; let destinationPayoutStatus = "inactive"; let refundStatus = "inactive"; let buildCurrentAction = async () => undefined; switch (state) { case ToBTCSwapState.CREATED: const quoteValid = await this._verifyQuoteValid(); sourcePaymentStatus = quoteValid ? "awaiting" : "soft_expired"; if (this.signatureData != null && quoteValid) { buildCurrentAction = this._buildInitSmartChainTxAction.bind(this); } break; case ToBTCSwapState.QUOTE_SOFT_EXPIRED: sourcePaymentStatus = "soft_expired"; break; case ToBTCSwapState.QUOTE_EXPIRED: sourcePaymentStatus = "expired"; break; case ToBTCSwapState.COMMITED: sourcePaymentStatus = "confirmed"; destinationPayoutStatus = "waiting_lp"; buildCurrentAction = this._buildWaitLpAction.bind(this); break; case ToBTCSwapState.SOFT_CLAIMED: sourcePaymentStatus = "confirmed"; destinationPayoutStatus = "soft_settled"; break; case ToBTCSwapState.CLAIMED: sourcePaymentStatus = "confirmed"; destinationPayoutStatus = "settled"; break; case ToBTCSwapState.REFUNDABLE: sourcePaymentStatus = "confirmed"; destinationPayoutStatus = "expired"; refundStatus = "awaiting"; buildCurrentAction = this._buildRefundSmartChainTxAction.bind(this); break; case ToBTCSwapState.REFUNDED: sourcePaymentStatus = "confirmed"; destinationPayoutStatus = "expired"; refundStatus = "refunded"; break; } return { steps: [ { type: "Payment", side: "source", chain: this.chainIdentifier, title: "Source payment", description: `Initiate the swap by funding the escrow on the ${this.chainIdentifier} side`, status: sourcePaymentStatus, initTxId: this._commitTxId, settleTxId: this._claimTxId }, { type: "Settlement", side: "destination", chain: this.outputToken.chainId, title: "Destination payout", description: `Wait for the LP to process the swap and send the payout on the ${this.outputToken.chainId} side`, status: destinationPayoutStatus, initTxId: destinationPayoutStatus === "settled" || destinationPayoutStatus === "soft_settled" ? this.getOutputTxId() : undefined, settleTxId: destinationPayoutStatus === "settled" ? this.getOutputTxId() : undefined, }, { type: "Refund", side: "source", chain: this.chainIdentifier, title: "Source refund", description: `Refund escrowed funds on the ${this.chainIdentifier} side, after LP failed to execute`, status: refundStatus, refundTxId: this._refundTxId } ], buildCurrentAction, state }; } /** * @inheritDoc * @internal */ async _submitExecutionTransactions(txs, abortSignal, requiredStates, idempotent) { const parsedTxs = []; for (let tx of txs) { parsedTxs.push(typeof (tx) === "string" ? await this.wrapper._chain.deserializeSignedTx(tx) : tx); } if (idempotent) { // Handle idempotent calls if (this.wrapper._chain.getTxId != null) { const txIds = await Promise.all(parsedTxs.map(tx => this.wrapper._chain.getTxId(tx))); const foundTxId = txIds.find(txId => this._commitTxId === txId || this._refundTxId === txId); if (foundTxId != null) return txIds; } } if (requiredStates != null && !requiredStates.includes(this._state)) throw new Error("Swap state has changed before transactions were submitted!"); if (this._state === ToBTCSwapState.CREATED || this._state === ToBTCSwapState.QUOTE_SOFT_EXPIRED) { if (!await this._verifyQuoteValid()) throw new Error("Quote is already expired!"); const txIds = await this.wrapper._chain.sendSignedAndConfirm(parsedTxs, true, abortSignal, false); await this.waitTillCommited(abortSignal); return txIds; } if (this._state === ToBTCSwapState.REFUNDABLE) { const txIds = await this.wrapper._chain.sendSignedAndConfirm(parsedTxs, true, abortSignal, false); await this.waitTillRefunded(abortSignal); return txIds; } throw new Error("Invalid swap state for transaction submission!"); } /** * @internal */ async _buildInitSmartChainTxAction(actionOptions) { return { type: "SignSmartChainTransaction", name: "Initiate swap", description: `Initiates the swap by commiting the funds to the escrow on the ${this.chainIdentifier} side`, chain: this.chainIdentifier, txs: await this.prepareTransactions(this.txsCommit(actionOptions?.skipChecks)), submitTransactions: async (txs, abortSignal, idempotent) => { return this._submitExecutionTransactions(txs, abortSignal, [ToBTCSwapState.CREATED, ToBTCSwapState.QUOTE_SOFT_EXPIRED], idempotent); }, requiredSigner: this._getInitiator() }; } /** * @internal */ async _buildWaitLpAction() { return { type: "Wait", name: "Awaiting LP payout", description: "Wait for the intermediary to process the swap and either send the payout or make the swap refundable", pollTimeSeconds: 5, expectedTimeSeconds: 10, wait: async (maxWaitTimeSeconds, pollIntervalSeconds, abortSignal) => { await this.waitForPayment(maxWaitTimeSeconds, pollIntervalSeconds, abortSignal); } }; } /** * @internal */ async _buildRefundSmartChainTxAction(actionOptions) { const signerAddress = await this.wrapper._getSignerAddress(actionOptions?.refundSmartChainSigner); return { type: "SignSmartChainTransaction", name: "Refund", description: "Refund the swap after it failed to execute", chain: this.chainIdentifier, txs: await this.prepareTransactions(this.txsRefund(actionOptions?.refundSmartChainSigner)), submitTransactions: async (txs, abortSignal, idempotent) => { return this._submitExecutionTransactions(txs, abortSignal, [ToBTCSwapState.REFUNDABLE], idempotent); }, requiredSigner: signerAddress ?? this._getInitiator() }; } /** * @inheritDoc * * @param options.skipChecks Skip checks like making sure init signature is still valid and swap wasn't commited yet * (this is handled on swap creation, if you commit right after quoting, you can use `skipChecks=true`) * @param options.refundSmartChainSigner Optional smart chain signer to use when creating refunds transactions */ async getExecutionAction(options) { const executionStatus = await this._getExecutionStatus(); return executionStatus.buildCurrentAction(options); } /** * @inheritDoc */ async getExecutionStatus(options) { const executionStatus = await this._getExecutionStatus(); return { steps: executionStatus.steps, currentAction: options?.skipBuildingAction ? undefined : await executionStatus.buildCurrentAction(options), stateInfo: this._getStateInfo(executionStatus.state) }; } /** * @inheritDoc */ async getExecutionSteps() { return (await this._getExecutionStatus()).steps; } ////////////////////////////// //// Commit /** * @inheritDoc * * @throws {Error} When in invalid state (not {@link ToBTCSwapState.CREATED}) */ async txsCommit(skipChecks) { if (this._state !== ToBTCSwapState.CREATED && (!skipChecks || this._state !== ToBTCSwapState.QUOTE_SOFT_EXPIRED)) throw new Error("Must be in CREATED state!"); if (this.signatureData == null) throw new Error("Init signature data not known, cannot commit!"); if (!this.initiated) { this.initiated = true; await this._saveAndEmit(); } return await this._contract.txsInit(this._getInitiator(), this._data, this.signatureData, skipChecks, this.feeRate).catch(e => Promise.reject(e instanceof base_1.SignatureVerificationError ? new Error("Request timed out") : e)); } /** * @inheritDoc * * @throws {Error} If invalid signer is provided that doesn't match the swap data */ async commit(_signer, abortSignal, skipChecks, onBeforeTxSent) { const signer = (0, base_1.isAbstractSigner)(_signer) ? _signer : await this.wrapper._chain.wrapSigner(_signer); this.checkSigner(signer); const txs = await this.txsCommit(skipChecks); let txCount = 0; const result = await this.wrapper._chain.sendAndConfirm(signer, txs, true, abortSignal, false, (txId, rawTx) => { txCount++; if (onBeforeTxSent != null && txCount === txs.length) onBeforeTxSent(txId); return Promise.resolve(); }); this._commitTxId = result[result.length - 1]; if (this._state === ToBTCSwapState.CREATED || this._state === ToBTCSwapState.QUOTE_SOFT_EXPIRED || this._state === ToBTCSwapState.QUOTE_EXPIRED) { await this._saveAndEmit(ToBTCSwapState.COMMITED); } return this._commitTxId; } /** * @inheritDoc * * @throws {Error} If swap is not in the correct state (must be {@link ToBTCSwapState.CREATED}) */ async waitTillCommited(abortSignal) { if (this._state === ToBTCSwapState.COMMITED || this._state === ToBTCSwapState.CLAIMED) return Promise.resolve(); if (this._state !== ToBTCSwapState.CREATED && this._state !== ToBTCSwapState.QUOTE_SOFT_EXPIRED) throw new Error("Invalid state (not CREATED)"); const abortController = (0, Utils_1.extendAbortController)(abortSignal); let result; try { result = await Promise.race([ this.watchdogWaitTillCommited(undefined, abortController.signal), this.waitTillState(ToBTCSwapState.COMMITED, "gte", abortController.signal).then(() => 0) ]); abortController.abort(); } catch (e) { abortController.abort(); throw e; } if (result === 0) { this.logger.debug("waitTillCommited(): Resolved from state change"); } else if (result != null) { this.logger.debug("waitTillCommited(): Resolved from watchdog - commited"); } if (result === null) { this.logger.debug("waitTillCommited(): Resolved from watchdog - signature expiry"); if (this._state === ToBTCSwapState.QUOTE_SOFT_EXPIRED || this._state === ToBTCSwapState.CREATED) { await this._saveAndEmit(ToBTCSwapState.QUOTE_EXPIRED); } throw new Error("Quote expired while waiting for transaction confirmation!"); } if (this._state === ToBTCSwapState.QUOTE_SOFT_EXPIRED || this._state === ToBTCSwapState.CREATED || this._state === ToBTCSwapState.QUOTE_EXPIRED) { if (typeof (result) === "object" && result.getInitTxId != null && this._commitTxId == null) this._commitTxId = await result.getInitTxId(); await this._saveAndEmit(ToBTCSwapState.COMMITED); } } ////////////////////////////// //// Payment /** * Waits till the swap is processed by the intermediary (LP) * * @param checkIntervalSeconds How often to poll the intermediary for status (5 seconds default) * @param abortSignal Abort signal * @internal */ async waitTillIntermediarySwapProcessed(checkIntervalSeconds, abortSignal) { if (this.url == null) throw new Error("LP URL not specified!"); checkIntervalSeconds ??= 5; let resp = { code: IntermediaryAPI_1.RefundAuthorizationResponseCodes.PENDING, msg: "" }; while (!abortSignal?.aborted && (resp.code === IntermediaryAPI_1.RefundAuthorizationResponseCodes.PENDING || resp.code === IntermediaryAPI_1.RefundAuthorizationResponseCodes.NOT_FOUND)) { resp = await this.wrapper._lpApi.getRefundAuthorization(this.url, this.getLpIdentifier(), this._data.getSequence()); if (resp.code === IntermediaryAPI_1.RefundAuthorizationResponseCodes.PAID) { const validResponse = await this._setPaymentResult(resp.data, true); if (validResponse) { if (this._state === ToBTCSwapState.COMMITED || this._state === ToBTCSwapState.REFUNDABLE) { await this._saveAndEmit(ToBTCSwapState.SOFT_CLAIMED); } } else { resp = { code: IntermediaryAPI_1.RefundAuthorizationResponseCodes.PENDING, msg: "" }; } } if (resp.code === IntermediaryAPI_1.RefundAuthorizationResponseCodes.PENDING || resp.code === IntermediaryAPI_1.RefundAuthorizationResponseCodes.NOT_FOUND) await (0, TimeoutUtils_1.timeoutPromise)(checkIntervalSeconds * 1000, abortSignal); } return resp; } /** * Checks whether the swap was already processed by the LP and is either successful (requires proof which is * either a HTLC pre-image for LN swaps or valid txId for on-chain swap) or failed and we can cooperatively * refund. * * @param save whether to save the data * @returns `true` if swap is processed, `false` if the swap is still ongoing * * @internal */ async checkIntermediarySwapProcessed(save = true) { if (this._state === ToBTCSwapState.CREATED || this._state == ToBTCSwapState.QUOTE_EXPIRED || this.url == null) return false; if (this.isFinished() || this.isRefundable()) return true; //Check if that maybe already concluded according to the LP const resp = await this.wrapper._lpApi.getRefundAuthorization(this.url, this.getLpIdentifier(), this._data.getSequence()); switch (resp.code) { case IntermediaryAPI_1.RefundAuthorizationResponseCodes.PAID: const processed = await this._setPaymentResult(resp.data, true); if (processed) { this._state = ToBTCSwapState.SOFT_CLAIMED; if (save) await this._saveAndEmit(); } return processed; case IntermediaryAPI_1.RefundAuthorizationResponseCodes.REFUND_DATA: await this._contract.isValidRefundAuthorization(this._data, resp.data); this._state = ToBTCSwapState.REFUNDABLE; if (save) await this._saveAndEmit(); return true; default: return false; } } /** * A blocking promise resolving when swap was concluded by the intermediary (LP), * rejecting in case of failure * * @param maxWaitTimeSeconds Maximum time in seconds to wait for the swap to be settled, an error is thrown if the * swap is taking too long to claim * @param checkIntervalSeconds How often to poll the intermediary for answer * @param abortSignal Abort signal * @returns `true` if swap was successful, `false` if swap failed and we can refund * * @throws {IntermediaryError} If a swap is determined expired by the intermediary, but it is actually still valid * @throws {SignatureVerificationError} If the swap should be cooperatively refundable but the intermediary returned * invalid refund signature * @throws {Error} When swap expires or if the swap has invalid state (must be {@link ToBTCSwapState.COMMITED}) */ async waitForPayment(maxWaitTimeSeconds, checkIntervalSeconds, abortSignal) { if (this._state === ToBTCSwapState.CLAIMED) return Promise.resolve(true); if (this._state !== ToBTCSwapState.COMMITED && this._state !== ToBTCSwapState.SOFT_CLAIMED) throw new Error("Invalid state (not COMMITED)"); const abortController = (0, Utils_1.extendAbortController)(abortSignal); let timedOut = false; if (maxWaitTimeSeconds != null) { const timeout = setTimeout(() => { timedOut = true; abortController.abort(); }, maxWaitTimeSeconds * 1000); abortController.signal.addEventListener("abort", () => clearTimeout(timeout)); } let result; try { result = await Promise.race([ this.waitTillState(ToBTCSwapState.CLAIMED, "gte", abortController.signal), this.waitTillIntermediarySwapProcessed(checkIntervalSeconds, abortController.signal) ]); abortController.abort(); } catch (e) { abortController.abort(); if (timedOut) { throw new Error("Timed out while waiting for LP to process the swap, the LP might be unresponsive or offline!" + ` Please check later or wait till ${new Date(Number(this._data.getExpiry()) * 1000).toLocaleString()} to refund unilaterally!`); } throw e; } if (typeof result !== "object") { if (this._state === ToBTCSwapState.REFUNDABLE) throw new Error("Swap expired"); this.logger.debug("waitTillRefunded(): Resolved from state change"); return true; } this.logger.debug("waitTillRefunded(): Resolved from intermediary response"); switch (result.code) { case IntermediaryAPI_1.RefundAuthorizationResponseCodes.PAID: return true; case IntermediaryAPI_1.RefundAuthorizationResponseCodes.REFUND_DATA: const resultData = result.data; await this._contract.isValidRefundAuthorization(this._data, resultData); await this._saveAndEmit(ToBTCSwapState.REFUNDABLE); return false; case IntermediaryAPI_1.RefundAuthorizationResponseCodes.EXPIRED: if (await this._contract.isExpired(this._getInitiator(), this._data)) throw new Error("Swap expired"); throw new IntermediaryError_1.IntermediaryError("Swap expired"); case IntermediaryAPI_1.RefundAuthorizationResponseCodes.NOT_FOUND: if (this._state === ToBTCSwapState.CLAIMED) return true; throw new Error("LP swap not found"); } throw new Error("Invalid response code returned by the LP"); } ////////////////////////////// //// Refund /** * Get the estimated smart chain transaction fee of the refund transaction */ async getRefundNetworkFee() { const swapContract = this._contract; return (0, TokenAmount_1.toTokenAmount)(await swapContract.getRefundFee(this._getInitiator(), this._data), this.wrapper._getNativeToken(), this.wrapper._prices); } /** * @inheritDoc * * @throws {IntermediaryError} If intermediary returns invalid response in case cooperative refund should be used * @throws {SignatureVerificationError} If intermediary returned invalid cooperative refund signature * @throws {Error} When state is not refundable */ async txsRefund(_signer) { if (!this.isRefundable()) throw new Error("Must be in REFUNDABLE state or expired!"); let signer; if (_signer != null) { if (typeof (_signer) === "string") { signer = _signer; } else if ((0, base_1.isAbstractSigner)(_signer)) { signer = _signer.getAddress(); } else { signer = (await this.wrapper._chain.wrapSigner(_signer)).getAddress(); } } else { signer = this._getInitiator(); } if (await this._contract.isExpired(this._getInitiator(), this._data)) { return await this._contract.txsRefund(signer, this._data, true, true); } else { if (this.url == null) throw new Error("LP URL not known, cannot get cooperative refund message, wait till expiry to refund!"); const res = await this.wrapper._lpApi.getRefundAuthorization(this.url, this.getLpIdentifier(), this._data.getSequence()); if (res.code === IntermediaryAPI_1.RefundAuthorizationResponseCodes.REFUND_DATA) { return await this._contract.txsRefundWithAuthorization(signer, this._data, res.data, true, true); } throw new IntermediaryError_1.IntermediaryError("Invalid intermediary cooperative message returned"); } } /** * @inheritDoc * * @throws {Error} If invalid signer is provided that doesn't match the swap data */ async refund(_signer, abortSignal) { const signer = (0, base_1.isAbstractSigner)(_signer) ? _signer : await this.wrapper._chain.wrapSigner(_signer); const result = await this.wrapper._chain.sendAndConfirm(signer, await this.txsRefund(signer.getAddress()), true, abortSignal); this._refundTxId = result[0]; if (this._state === ToBTCSwapState.COMMITED || this._state === ToBTCSwapState.REFUNDABLE || this._state === ToBTCSwapState.SOFT_CLAIMED) { await this._saveAndEmit(ToBTCSwapState.REFUNDED); } return result[0]; } /** * @inheritDoc * * @throws {Error} When swap is not in a valid state (must be {@link ToBTCSwapState.COMMITED} or * {@link ToBTCSwapState.REFUNDABLE}) * @throws {Error} If we tried to refund but claimer was able to claim first */ async waitTillRefunded(abortSignal) { if (this._state === ToBTCSwapState.REFUNDED) return Promise.resolve(); if (this._state !== ToBTCSwapState.COMMITED && this._state !== ToBTCSwapState.SOFT_CLAIMED && this._state !== ToBTCSwapState.REFUNDABLE) throw new Error("Invalid state (not COMMITED)"); const abortController = new AbortController(); if (abortSignal != null) abortSignal.addEventListener("abort", () => abortController.abort(abortSignal.reason)); const res = await Promise.race([ this.watchdogWaitTillResult(undefined, abortController.signal), this.waitTillState(ToBTCSwapState.REFUNDED, "eq", abortController.signal).then(() => 0), this.waitTillState(ToBTCSwapState.CLAIMED, "eq", abortController.signal).then(() => 1), ]); abortController.abort(); if (res === 0) { this.logger.debug("waitTillRefunded(): Resolved from state change (REFUNDED)"); return; } if (res === 1) { this.logger.debug("waitTillRefunded(): Resolved from state change (CLAIMED)"); throw new Error("Tried to refund swap, but claimer claimed it in the meantime!"); } this.logger.debug("waitTillRefunded(): Resolved from watchdog"); if (res?.type === base_1.SwapCommitStateType.PAID) { if (this._claimTxId == null) this._claimTxId = await res.getClaimTxId(); await this._saveAndEmit(ToBTCSwapState.CLAIMED); throw new Error("Tried to refund swap, but claimer claimed it in the meantime!"); } if (res?.type === base_1.SwapCommitStateType.NOT_COMMITED) { if (this._refundTxId == null && res.getRefundTxId != null) this._refundTxId = await res.getRefundTxId(); await this._saveAndEmit(ToBTCSwapState.REFUNDED); } } ////////////////////////////// //// Storage /** * @inheritDoc */ serialize() { const obj = super.serialize(); return { ...obj, networkFee: this.networkFee == null ? null : this.networkFee.toString(10), networkFeeBtc: this.networkFeeBtc == null ? null : this.networkFeeBtc.toString(10) }; } ////////////////////////////// //// Swap ticks & sync /** * Checks the swap's state on-chain and compares it to its internal state, updates/changes it according to on-chain * data * * @private */ async syncStateFromChain(quoteDefinitelyExpired, commitStatus) { if (this._state === ToBTCSwapState.CREATED || this._state === ToBTCSwapState.QUOTE_SOFT_EXPIRED || this._state === ToBTCSwapState.COMMITED || this._state === ToBTCSwapState.SOFT_CLAIMED || this._state === ToBTCSwapState.REFUNDABLE) { let quoteExpired = false; if (this._state === ToBTCSwapState.CREATED || this._state === ToBTCSwapState.QUOTE_SOFT_EXPIRED) { //Check if quote is still valid quoteExpired = quoteDefinitelyExpired ?? await this._verifyQuoteDefinitelyExpired(); } commitStatus ??= await this._contract.getCommitStatus(this._getInitiator(), this._data); if (commitStatus != null && await this._forciblySetOnchainState(commitStatus)) return true; if ((this._state === ToBTCSwapState.CREATED || this._state === ToBTCSwapState.QUOTE_SOFT_EXPIRED)) { if (quoteExpired) { this._state = ToBTCSwapState.QUOTE_EXPIRED; return true; } } } return false; } /** * @inheritDoc * @internal */ _shouldFetchOnchainState() { return this._state === ToBTCSwapState.CREATED || this._state === ToBTCSwapState.QUOTE_SOFT_EXPIRED || this._state === ToBTCSwapState.COMMITED || this._state === ToBTCSwapState.SOFT_CLAIMED || this._state === ToBTCSwapState.REFUNDABLE; } /** * @inheritDoc * @internal */ _shouldFetchExpiryStatus() { return this._state === ToBTCSwapState.CREATED || this._state === ToBTCSwapState.QUOTE_SOFT_EXPIRED; } /** * @inheritDoc * @internal */ async _sync(save, quoteDefinitelyExpired, commitStatus) { let changed = await this.syncStateFromChain(quoteDefinitelyExpired, commitStatus); if (this._state === ToBTCSwapState.COMMITED || this._state === ToBTCSwapState.SOFT_CLAIMED) { //Check if that maybe already concluded try { if (await this.checkIntermediarySwapProcessed(false)) changed = true; } catch (e) { this.logger.error("_sync(): Failed to synchronize swap, error: ", e); } } if (save && changed) await this._saveAndEmit(); return changed; } /** * @inheritDoc * @internal */ async _forciblySetOnchainState(commitStatus) { switch (commitStatus.type) { case base_1.SwapCommitStateType.PAID: if (this._commitTxId == null && commitStatus.getInitTxId != null) this._commitTxId = await commitStatus.getInitTxId(); if (this._claimTxId == null && commitStatus.getClaimTxId) this._claimTxId = await commitStatus.getClaimTxId(); const eventResult = await commitStatus.getClaimResult(); try { await this._setPaymentResult({ secret: eventResult, txId: Buffer.from(eventResult, "hex").reverse().toString("hex") }); } catch (e) { this.logger.error(`Failed to set payment result ${eventResult} on the swap!`); } this._state = ToBTCSwapState.CLAIMED; return true; case base_1.SwapCommitStateType.REFUNDABLE: if (this._commitTxId == null && commitStatus.getInitTxId != null) this._commitTxId = await commitStatus.getInitTxId(); this._state = ToBTCSwapState.REFUNDABLE; return true; case base_1.SwapCommitStateType.EXPIRED: if (this._commitTxId == null && commitStatus.getInitTxId != null) this._commitTxId = await commitStatus.getInitTxId(); if (this._refundTxId == null && commitStatus.getRefundTxId) this._refundTxId = await commitStatus.getRefundTxId(); this._state = this._refundTxId == null ? ToBTCSwapState.QUOTE_EXPIRED : ToBTCSwapState.REFUNDED; return true; case base_1.SwapCommitStateType.NOT_COMMITED: let changed = false; if (this._commitTxId == null && commitStatus.getInitTxId != null) { this._commitTxId = await commitStatus.getInitTxId(); changed = true; } if (this._refundTxId == null && commitStatus.getRefundTxId) { this._refundTxId = await commitStatus.getRefundTxId(); changed = true; } if (this._refundTxId != null) { this._state = ToBTCSwapState.REFUNDED; changed = true; } return changed; case base_1.SwapCommitStateType.COMMITED: let save = false; if (this._commitTxId == null && commitStatus.getInitTxId != null) { this._commitTxId = await commitStatus.getInitTxId(); save = true; } if (this._state !== ToBTCSwapState.COMMITED && this._state !== ToBTCSwapState.REFUNDABLE && this._state !== ToBTCSwapState.SOFT_CLAIMED) { this._state = ToBTCSwapState.COMMITED; save = true; } return save; } return false; } /** * @inheritDoc * @internal */ async _tick(save) { switch (this._state) { case ToBTCSwapState.CREATED: if (this.expiry < Date.now()) { this._state = ToBTCSwapState.QUOTE_SOFT_EXPIRED; if (save) await this._saveAndEmit(); return true; } break; case ToBTCSwapState.COMMITED: case ToBTCSwapState.SOFT_CLAIMED: const expired = await this._contract.isExpired(this._getInitiator(), this._data); if (expired) { this._state = ToBTCSwapState.REFUNDABLE; if (save) await this._saveAndEmit(); return true; } break; } return false; } } exports.IToBTCSwap = IToBTCSwap;