UNPKG

@atomiqlabs/sdk-lib

Version:

Basic SDK functionality library for atomiq

561 lines (560 loc) 27.7 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.ToBTCSwapState = exports.IToBTCSwap = exports.isIToBTCSwapInit = void 0; const ISwap_1 = require("../ISwap"); const base_1 = require("@atomiqlabs/base"); const IntermediaryAPI_1 = require("../../intermediaries/IntermediaryAPI"); const IntermediaryError_1 = require("../../errors/IntermediaryError"); const Utils_1 = require("../../utils/Utils"); const Tokens_1 = require("../Tokens"); function isIToBTCSwapInit(obj) { return typeof (obj.networkFee) === "bigint" && (obj.networkFeeBtc == null || typeof (obj.networkFeeBtc) === "bigint") && (0, ISwap_1.isISwapInit)(obj); } exports.isIToBTCSwapInit = isIToBTCSwapInit; class IToBTCSwap extends ISwap_1.ISwap { constructor(wrapper, initOrObject) { super(wrapper, initOrObject); if (isIToBTCSwapInit(initOrObject)) { this.state = ToBTCSwapState.CREATED; } else { this.networkFee = initOrObject.networkFee == null ? null : BigInt(initOrObject.networkFee); this.networkFeeBtc = initOrObject.networkFeeBtc == null ? null : BigInt(initOrObject.networkFeeBtc); } } 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; } } /** * In case swapFee in BTC is not supplied it recalculates it based on swap price * @protected */ tryCalculateSwapFee() { if (this.swapFeeBtc == null) { this.swapFeeBtc = this.swapFee * this.getOutput().rawAmount / this.getInputWithoutFee().rawAmount; } if (this.networkFeeBtc == null) { this.networkFeeBtc = this.networkFee * this.getOutput().rawAmount / this.getInputWithoutFee().rawAmount; } if (this.pricingInfo.swapPriceUSatPerToken == null) { this.pricingInfo = this.wrapper.prices.recomputePriceInfoSend(this.chainIdentifier, this.getOutput().rawAmount, this.pricingInfo.satsBaseFee, this.pricingInfo.feePPM, this.data.getAmount(), this.data.getToken()); } } /** * Returns the payment hash identifier to be sent to the LP for getStatus and getRefund * @protected */ getLpIdentifier() { return this.getClaimHash(); } ////////////////////////////// //// Pricing async refreshPriceData() { if (this.pricingInfo == null) return null; const priceData = await this.wrapper.prices.isValidAmountSend(this.chainIdentifier, this.getOutput().rawAmount, this.pricingInfo.satsBaseFee, this.pricingInfo.feePPM, this.data.getAmount(), this.data.getToken()); this.pricingInfo = priceData; return priceData; } getSwapPrice() { return 100000000000000 / Number(this.pricingInfo.swapPriceUSatPerToken); } getMarketPrice() { return 100000000000000 / Number(this.pricingInfo.realPriceUSatPerToken); } getRealSwapFeePercentagePPM() { const feeWithoutBaseFee = this.swapFeeBtc - this.pricingInfo.satsBaseFee; return feeWithoutBaseFee * 1000000n / this.getOutput().rawAmount; } ////////////////////////////// //// Getters & utils getInputTxId() { return this.commitTxId; } getInputAddress() { return this.getInitiator(); } getOutputAddress() { return this.getRecipient(); } /** * Returns whether the swap is finished and in its terminal state (this can mean successful, refunded or failed) */ isFinished() { return this.state === ToBTCSwapState.CLAIMED || this.state === ToBTCSwapState.REFUNDED || this.state === ToBTCSwapState.QUOTE_EXPIRED; } isActionable() { return this.isRefundable(); } isRefundable() { return this.state === ToBTCSwapState.REFUNDABLE; } isQuoteExpired() { return this.state === ToBTCSwapState.QUOTE_EXPIRED; } isQuoteSoftExpired() { return this.state === ToBTCSwapState.QUOTE_EXPIRED || this.state === ToBTCSwapState.QUOTE_SOFT_EXPIRED; } isSuccessful() { return this.state === ToBTCSwapState.CLAIMED; } isFailed() { return this.state === ToBTCSwapState.REFUNDED; } /** * Checks if the swap can be committed/started */ canCommit() { return this.state === ToBTCSwapState.CREATED; } getInitiator() { return this.data.getOfferer(); } ////////////////////////////// //// Amounts & fees getFee() { return { amountInSrcToken: (0, Tokens_1.toTokenAmount)(this.swapFee + this.networkFee, this.wrapper.tokens[this.data.getToken()], this.wrapper.prices), amountInDstToken: (0, Tokens_1.toTokenAmount)(this.swapFeeBtc + this.networkFeeBtc, this.outputToken, this.wrapper.prices), usdValue: (abortSignal, preFetchedUsdPrice) => this.wrapper.prices.getBtcUsdValue(this.swapFeeBtc + this.networkFeeBtc, abortSignal, preFetchedUsdPrice) }; } getSwapFee() { return { amountInSrcToken: (0, Tokens_1.toTokenAmount)(this.swapFee, this.wrapper.tokens[this.data.getToken()], this.wrapper.prices), amountInDstToken: (0, Tokens_1.toTokenAmount)(this.swapFeeBtc, this.outputToken, this.wrapper.prices), usdValue: (abortSignal, preFetchedUsdPrice) => this.wrapper.prices.getBtcUsdValue(this.swapFeeBtc, abortSignal, preFetchedUsdPrice) }; } /** * Returns network fee for the swap, the fee is represented in source currency & destination currency, but is * paid only once */ getNetworkFee() { return { amountInSrcToken: (0, Tokens_1.toTokenAmount)(this.networkFee, this.wrapper.tokens[this.data.getToken()], this.wrapper.prices), amountInDstToken: (0, Tokens_1.toTokenAmount)(this.networkFeeBtc, this.outputToken, this.wrapper.prices), usdValue: (abortSignal, preFetchedUsdPrice) => this.wrapper.prices.getBtcUsdValue(this.networkFeeBtc, abortSignal, preFetchedUsdPrice) }; } getInputWithoutFee() { return (0, Tokens_1.toTokenAmount)(this.data.getAmount() - (this.swapFee + this.networkFee), this.wrapper.tokens[this.data.getToken()], this.wrapper.prices); } getInput() { return (0, Tokens_1.toTokenAmount)(this.data.getAmount(), this.wrapper.tokens[this.data.getToken()], this.wrapper.prices); } /** * Get the estimated smart chain transaction fee of the refund transaction */ getRefundFee() { return this.wrapper.contract.getRefundFee(this.data); } /** * Checks if the intiator/sender has enough balance to go through with the swap */ async hasEnoughBalance() { const [balance, commitFee] = await Promise.all([ this.wrapper.contract.getBalance(this.getInitiator(), this.data.getToken(), false), this.data.getToken() === this.wrapper.contract.getNativeCurrencyAddress() ? this.getCommitFee() : Promise.resolve(null) ]); let required = this.data.getAmount(); if (commitFee != null) required = required + commitFee; return { enoughBalance: balance >= required, balance: (0, Tokens_1.toTokenAmount)(balance, this.wrapper.tokens[this.data.getToken()], this.wrapper.prices), required: (0, Tokens_1.toTokenAmount)(required, this.wrapper.tokens[this.data.getToken()], this.wrapper.prices) }; } /** * Check if the initiator/sender has enough balance to cover the transaction fee for processing the swap */ async hasEnoughForTxFees() { const [balance, commitFee] = await Promise.all([ this.wrapper.contract.getBalance(this.getInitiator(), this.wrapper.contract.getNativeCurrencyAddress(), false), this.getCommitFee() ]); return { enoughBalance: balance >= commitFee, balance: (0, Tokens_1.toTokenAmount)(balance, this.wrapper.getNativeToken(), this.wrapper.prices), required: (0, Tokens_1.toTokenAmount)(commitFee, this.wrapper.getNativeToken(), this.wrapper.prices) }; } ////////////////////////////// //// Commit /** * Commits the swap on-chain, initiating the swap * * @param signer Signer to sign the transactions with, must be the same as used in the initialization * @param abortSignal Abort signal * @param 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 skipChecks)` * @throws {Error} If invalid signer is provided that doesn't match the swap data */ async commit(signer, abortSignal, skipChecks) { this.checkSigner(signer); const result = await this.wrapper.contract.sendAndConfirm(signer, await this.txsCommit(skipChecks), true, abortSignal); this.commitTxId = result[0]; if (this.state === ToBTCSwapState.CREATED || this.state === ToBTCSwapState.QUOTE_SOFT_EXPIRED || this.state === ToBTCSwapState.QUOTE_EXPIRED) { await this._saveAndEmit(ToBTCSwapState.COMMITED); } return result[0]; } /** * Returns transactions for committing the swap on-chain, initiating the swap * * @param 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) * * @throws {Error} When in invalid state (not PR_CREATED) */ async txsCommit(skipChecks) { if (!this.canCommit()) throw new Error("Must be in CREATED state!"); if (!this.initiated) { this.initiated = true; await this._saveAndEmit(); } return await this.wrapper.contract.txsInit(this.data, this.signatureData, skipChecks, this.feeRate).catch(e => Promise.reject(e instanceof base_1.SignatureVerificationError ? new Error("Request timed out") : e)); } /** * Waits till a swap is committed, should be called after sending the commit transactions manually * * @param abortSignal AbortSignal * @throws {Error} If swap is not in the correct state (must be 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); const result = await Promise.race([ this.watchdogWaitTillCommited(abortController.signal), this.waitTillState(ToBTCSwapState.COMMITED, "gte", abortController.signal).then(() => 0) ]); abortController.abort(); if (result === 0) this.logger.debug("waitTillCommited(): Resolved from state change"); if (result === true) this.logger.debug("waitTillCommited(): Resolved from watchdog - commited"); if (result === false) { 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); } return; } if (this.state === ToBTCSwapState.QUOTE_SOFT_EXPIRED || this.state === ToBTCSwapState.CREATED || this.state === ToBTCSwapState.QUOTE_EXPIRED) { await this._saveAndEmit(ToBTCSwapState.COMMITED); } } ////////////////////////////// //// Payment /** * A blocking promise resolving when swap was concluded by the intermediary, * rejecting in case of failure * * @param abortSignal Abort signal * @param checkIntervalSeconds How often to poll the intermediary for answer * * @returns {Promise<boolean>} Was the payment successful? If not 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 COMMITED) */ async waitForPayment(abortSignal, checkIntervalSeconds) { 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); const result = await Promise.race([ this.waitTillState(ToBTCSwapState.CLAIMED, "gte", abortController.signal), this.waitTillIntermediarySwapProcessed(abortController.signal, checkIntervalSeconds) ]); abortController.abort(); 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: await (0, Utils_1.tryWithRetries)(() => this.wrapper.contract.isValidRefundAuthorization(this.data, result.data), null, base_1.SignatureVerificationError, abortSignal); await this._saveAndEmit(ToBTCSwapState.REFUNDABLE); return false; case IntermediaryAPI_1.RefundAuthorizationResponseCodes.EXPIRED: if (await this.wrapper.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("Intermediary swap not found"); } } async waitTillIntermediarySwapProcessed(abortSignal, 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 IntermediaryAPI_1.IntermediaryAPI.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, Utils_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 * @private */ async checkIntermediarySwapProcessed(save = true) { if (this.state === ToBTCSwapState.CREATED || this.state == ToBTCSwapState.QUOTE_EXPIRED) return false; if (this.isFinished() || this.isRefundable()) return true; //Check if that maybe already concluded according to the LP const resp = await IntermediaryAPI_1.IntermediaryAPI.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 (0, Utils_1.tryWithRetries)(() => this.wrapper.contract.isValidRefundAuthorization(this.data, resp.data), null, base_1.SignatureVerificationError); this.state = ToBTCSwapState.REFUNDABLE; if (save) await this._saveAndEmit(); return true; default: return false; } } ////////////////////////////// //// Refund /** * Refunds the swap if the swap is in refundable state, you can check so with isRefundable() * * @param signer Signer to sign the transactions with, must be the same as used in the initialization * @param abortSignal Abort signal * @throws {Error} If invalid signer is provided that doesn't match the swap data */ async refund(signer, abortSignal) { const result = await this.wrapper.contract.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]; } /** * Returns transactions for refunding the swap if the swap is in refundable state, you can check so with isRefundable() * * @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!"); signer ??= this.getInitiator(); if (await this.wrapper.contract.isExpired(this.getInitiator(), this.data)) { return await this.wrapper.contract.txsRefund(signer, this.data, true, true); } else { const res = await IntermediaryAPI_1.IntermediaryAPI.getRefundAuthorization(this.url, this.getLpIdentifier(), this.data.getSequence()); if (res.code === IntermediaryAPI_1.RefundAuthorizationResponseCodes.REFUND_DATA) { return await this.wrapper.contract.txsRefundWithAuthorization(signer, this.data, res.data, true, true); } throw new IntermediaryError_1.IntermediaryError("Invalid intermediary cooperative message returned"); } } /** * Waits till a swap is refunded, should be called after sending the refund transactions manually * * @param abortSignal AbortSignal * @throws {Error} When swap is not in a valid state (must be COMMITED) * @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) 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(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 === base_1.SwapCommitStatus.PAID) { await this._saveAndEmit(ToBTCSwapState.CLAIMED); throw new Error("Tried to refund swap, but claimer claimed it in the meantime!"); } if (res === base_1.SwapCommitStatus.NOT_COMMITED) { await this._saveAndEmit(ToBTCSwapState.REFUNDED); } } ////////////////////////////// //// Storage 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() { if (this.state === ToBTCSwapState.CREATED || this.state === ToBTCSwapState.QUOTE_SOFT_EXPIRED || this.state === ToBTCSwapState.COMMITED || this.state === ToBTCSwapState.SOFT_CLAIMED || this.state === ToBTCSwapState.REFUNDABLE) { const res = await (0, Utils_1.tryWithRetries)(() => this.wrapper.contract.getCommitStatus(this.getInitiator(), this.data)); switch (res) { case base_1.SwapCommitStatus.PAID: this.state = ToBTCSwapState.CLAIMED; return true; case base_1.SwapCommitStatus.REFUNDABLE: this.state = ToBTCSwapState.REFUNDABLE; return true; case base_1.SwapCommitStatus.EXPIRED: this.state = ToBTCSwapState.QUOTE_EXPIRED; return true; case base_1.SwapCommitStatus.NOT_COMMITED: if (this.state === ToBTCSwapState.COMMITED || this.state === ToBTCSwapState.REFUNDABLE) { this.state = ToBTCSwapState.REFUNDED; return true; } break; case base_1.SwapCommitStatus.COMMITED: if (this.state !== ToBTCSwapState.COMMITED && this.state !== ToBTCSwapState.REFUNDABLE) { this.state = ToBTCSwapState.COMMITED; return true; } break; } } } async _sync(save) { let changed = await this.syncStateFromChain(); if ((this.state === ToBTCSwapState.CREATED || this.state === ToBTCSwapState.QUOTE_SOFT_EXPIRED) && !await this.isQuoteValid()) { //Check if quote is still valid this.state = ToBTCSwapState.QUOTE_EXPIRED; changed ||= true; } if (this.state === ToBTCSwapState.COMMITED || this.state === ToBTCSwapState.SOFT_CLAIMED) { //Check if that maybe already concluded if (await this.checkIntermediarySwapProcessed(false)) changed = true; } if (save && changed) await this._saveAndEmit(); return changed; } 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.wrapper.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; var ToBTCSwapState; (function (ToBTCSwapState) { ToBTCSwapState[ToBTCSwapState["REFUNDED"] = -3] = "REFUNDED"; ToBTCSwapState[ToBTCSwapState["QUOTE_EXPIRED"] = -2] = "QUOTE_EXPIRED"; ToBTCSwapState[ToBTCSwapState["QUOTE_SOFT_EXPIRED"] = -1] = "QUOTE_SOFT_EXPIRED"; ToBTCSwapState[ToBTCSwapState["CREATED"] = 0] = "CREATED"; ToBTCSwapState[ToBTCSwapState["COMMITED"] = 1] = "COMMITED"; ToBTCSwapState[ToBTCSwapState["SOFT_CLAIMED"] = 2] = "SOFT_CLAIMED"; ToBTCSwapState[ToBTCSwapState["CLAIMED"] = 3] = "CLAIMED"; ToBTCSwapState[ToBTCSwapState["REFUNDABLE"] = 4] = "REFUNDABLE"; })(ToBTCSwapState = exports.ToBTCSwapState || (exports.ToBTCSwapState = {}));