UNPKG

@atomiqlabs/sdk

Version:

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

699 lines (698 loc) 27.1 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.OnchainForGasSwap = exports.isOnchainForGasSwapInit = exports.OnchainForGasSwapState = void 0; const SwapType_1 = require("../../../enums/SwapType"); const Utils_1 = require("../../../utils/Utils"); const BitcoinUtils_1 = require("../../../utils/BitcoinUtils"); const ISwap_1 = require("../../ISwap"); const IntermediaryAPI_1 = require("../../../intermediaries/apis/IntermediaryAPI"); const IBitcoinWallet_1 = require("../../../bitcoin/wallet/IBitcoinWallet"); const btc_signer_1 = require("@scure/btc-signer"); const SingleAddressBitcoinWallet_1 = require("../../../bitcoin/wallet/SingleAddressBitcoinWallet"); const buffer_1 = require("buffer"); const FeeType_1 = require("../../../enums/FeeType"); const PercentagePPM_1 = require("../../../types/fees/PercentagePPM"); const TokenAmount_1 = require("../../../types/TokenAmount"); const Token_1 = require("../../../types/Token"); const Logger_1 = require("../../../utils/Logger"); const TimeoutUtils_1 = require("../../../utils/TimeoutUtils"); const BitcoinWalletUtils_1 = require("../../../utils/BitcoinWalletUtils"); /** * State enum for trusted on-chain gas swaps * * @category Swaps/Trusted Gas Swaps */ var OnchainForGasSwapState; (function (OnchainForGasSwapState) { /** * The swap quote expired without user sending in the BTC */ OnchainForGasSwapState[OnchainForGasSwapState["EXPIRED"] = -3] = "EXPIRED"; /** * The swap has failed after the intermediary already received the BTC on the source chain */ OnchainForGasSwapState[OnchainForGasSwapState["FAILED"] = -2] = "FAILED"; /** * Swap was refunded and BTC returned to the user's refund address */ OnchainForGasSwapState[OnchainForGasSwapState["REFUNDED"] = -1] = "REFUNDED"; /** * Swap was created, send the BTC to the swap address */ OnchainForGasSwapState[OnchainForGasSwapState["PR_CREATED"] = 0] = "PR_CREATED"; /** * The swap is finished after the intermediary sent funds on the destination chain */ OnchainForGasSwapState[OnchainForGasSwapState["FINISHED"] = 1] = "FINISHED"; /** * Swap is refundable because the intermediary cannot honor the swap request on the destination chain */ OnchainForGasSwapState[OnchainForGasSwapState["REFUNDABLE"] = 2] = "REFUNDABLE"; })(OnchainForGasSwapState = exports.OnchainForGasSwapState || (exports.OnchainForGasSwapState = {})); const OnchainForGasSwapStateDescription = { [OnchainForGasSwapState.EXPIRED]: "The swap quote expired without user sending in the BTC", [OnchainForGasSwapState.FAILED]: "The swap has failed after the intermediary already received the BTC on the source chain", [OnchainForGasSwapState.REFUNDED]: "Swap was refunded and BTC returned to the user's refund address", [OnchainForGasSwapState.PR_CREATED]: "Swap was created, send the BTC to the swap address", [OnchainForGasSwapState.FINISHED]: "The swap is finished after the intermediary sent funds on the destination chain", [OnchainForGasSwapState.REFUNDABLE]: "Swap is refundable because the intermediary cannot honor the swap request on the destination chain", }; function isOnchainForGasSwapInit(obj) { return typeof (obj.paymentHash) === "string" && typeof (obj.sequence) === "bigint" && typeof (obj.address) === "string" && typeof (obj.inputAmount) === "bigint" && typeof (obj.outputAmount) === "bigint" && typeof (obj.recipient) === "string" && typeof (obj.token) === "string" && (obj.refundAddress == null || typeof (obj.refundAddress) === "string") && (0, ISwap_1.isISwapInit)(obj); } exports.isOnchainForGasSwapInit = isOnchainForGasSwapInit; /** * Trusted swap for Bitcoin -> Smart chains, to be used for minor amounts to get gas tokens on the * destination chain, which is only needed for Solana, which still uses legacy swaps * * @category Swaps/Trusted Gas Swaps */ class OnchainForGasSwap extends ISwap_1.ISwap { constructor(wrapper, initOrObj) { if (isOnchainForGasSwapInit(initOrObj) && initOrObj.url != null) initOrObj.url += "/frombtc_trusted"; super(wrapper, initOrObj); this.TYPE = SwapType_1.SwapType.TRUSTED_FROM_BTC; /** * @internal */ this.swapStateDescription = OnchainForGasSwapStateDescription; /** * @internal */ this.swapStateName = (state) => OnchainForGasSwapState[state]; this.wrapper = wrapper; if (isOnchainForGasSwapInit(initOrObj)) { this.paymentHash = initOrObj.paymentHash; this.sequence = initOrObj.sequence; this.address = initOrObj.address; this.inputAmount = initOrObj.inputAmount; this.outputAmount = initOrObj.outputAmount; this.recipient = initOrObj.recipient; this.token = initOrObj.token; this.refundAddress = initOrObj.refundAddress; this._state = OnchainForGasSwapState.PR_CREATED; } else { this.paymentHash = initOrObj.paymentHash; this.sequence = (0, Utils_1.toBigInt)(initOrObj.sequence); this.address = initOrObj.address; this.inputAmount = (0, Utils_1.toBigInt)(initOrObj.inputAmount); this.outputAmount = (0, Utils_1.toBigInt)(initOrObj.outputAmount); this.recipient = initOrObj.recipient; this.token = initOrObj.token; this.refundAddress = initOrObj.refundAddress; this.scTxId = initOrObj.scTxId; this.txId = initOrObj.txId; this.refundTxId = initOrObj.refundTxId; } this.logger = (0, Logger_1.getLogger)("OnchainForGas(" + this.getId() + "): "); this.tryRecomputeSwapPrice(); } /** * @inheritDoc * @internal */ upgradeVersion() { if (this.version == null) { //Noop this.version = 1; } } /** * @inheritDoc * @internal */ tryRecomputeSwapPrice() { if (this.swapFeeBtc == null && this.swapFee != null) { this.swapFeeBtc = this.swapFee * this.getInput().rawAmount / this.getOutAmountWithoutFee(); } super.tryRecomputeSwapPrice(); } ////////////////////////////// //// Getters & utils /** * @inheritDoc * @internal */ _getEscrowHash() { return this.paymentHash; } /** * @inheritDoc */ getOutputAddress() { return this.recipient; } /** * @inheritDoc */ getInputAddress() { //TODO: Fuck this, it's not used anyway return null; } /** * @inheritDoc */ getInputTxId() { return this.txId ?? null; } /** * @inheritDoc */ getOutputTxId() { return this.scTxId ?? null; } /** * @inheritDoc */ getId() { return this.paymentHash; } /** * @inheritDoc */ getAddress() { return this.address; } /** * @inheritDoc */ getHyperlink() { return "bitcoin:" + this.address + "?amount=" + encodeURIComponent((Number(this.inputAmount) / 100000000).toString(10)); } /** * @inheritDoc */ requiresAction() { return this._state === OnchainForGasSwapState.REFUNDABLE; } /** * @inheritDoc */ isFinished() { return this._state === OnchainForGasSwapState.FINISHED || this._state === OnchainForGasSwapState.FAILED || this._state === OnchainForGasSwapState.EXPIRED || this._state === OnchainForGasSwapState.REFUNDED; } /** * @inheritDoc */ isQuoteExpired() { return this._state === OnchainForGasSwapState.EXPIRED; } /** * @inheritDoc */ isQuoteSoftExpired() { return this.expiry < Date.now(); } /** * @inheritDoc */ isFailed() { return this._state === OnchainForGasSwapState.FAILED; } /** * @inheritDoc */ isSuccessful() { return this._state === OnchainForGasSwapState.FINISHED; } /** * @inheritDoc */ isInProgress() { return (this._state === OnchainForGasSwapState.PR_CREATED && this.txId != null) || (this._state === OnchainForGasSwapState.REFUNDABLE && this.refundAddress != null); } /** * @inheritDoc * @internal */ _verifyQuoteDefinitelyExpired() { return Promise.resolve(this.expiry < Date.now()); } /** * @inheritDoc * @internal */ _verifyQuoteValid() { return Promise.resolve(this.expiry > Date.now()); } ////////////////////////////// //// Amounts & fees /** * Returns an output amount in base units without a swap fee included, hence this value * is larger than the actual output amount * * @internal */ getOutAmountWithoutFee() { return this.outputAmount + (this.swapFee ?? 0n); } /** * @inheritDoc */ getOutputToken() { return this.wrapper._tokens[this.wrapper._chain.getNativeCurrencyAddress()]; } /** * @inheritDoc */ getOutput() { return (0, TokenAmount_1.toTokenAmount)(this.outputAmount, this.wrapper._tokens[this.wrapper._chain.getNativeCurrencyAddress()], this.wrapper._prices, this.pricingInfo); } /** * @inheritDoc */ getInputToken() { return Token_1.BitcoinTokens.BTC; } /** * @inheritDoc */ getInput() { return (0, TokenAmount_1.toTokenAmount)(this.inputAmount, Token_1.BitcoinTokens.BTC, this.wrapper._prices, this.pricingInfo); } /** * @inheritDoc */ getInputWithoutFee() { return (0, TokenAmount_1.toTokenAmount)(this.inputAmount - (this.swapFeeBtc ?? 0n), Token_1.BitcoinTokens.BTC, this.wrapper._prices, this.pricingInfo); } /** * 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!"); const feeWithoutBaseFee = this.swapFeeBtc == null ? 0n : this.swapFeeBtc - this.pricingInfo.satsBaseFee; const swapFeePPM = feeWithoutBaseFee * 1000000n / this.getInputWithoutFee().rawAmount; const amountInSrcToken = (0, TokenAmount_1.toTokenAmount)(this.swapFeeBtc ?? 0n, Token_1.BitcoinTokens.BTC, this.wrapper._prices, this.pricingInfo); return { amountInSrcToken, amountInDstToken: (0, TokenAmount_1.toTokenAmount)(this.swapFee ?? 0n, this.wrapper._tokens[this.wrapper._chain.getNativeCurrencyAddress()], this.wrapper._prices, this.pricingInfo), currentUsdValue: amountInSrcToken.currentUsdValue, usdValue: amountInSrcToken.usdValue, pastUsdValue: amountInSrcToken.pastUsdValue, composition: { base: (0, TokenAmount_1.toTokenAmount)(this.pricingInfo.satsBaseFee, Token_1.BitcoinTokens.BTC, this.wrapper._prices, this.pricingInfo), percentage: (0, PercentagePPM_1.ppmToPercentage)(swapFeePPM) } }; } /** * @inheritDoc */ getFee() { return this.getSwapFee(); } /** * @inheritDoc */ getFeeBreakdown() { return [{ type: FeeType_1.FeeType.SWAP, fee: this.getSwapFee() }]; } /** * @inheritDoc */ getRequiredConfirmationsCount() { return 1; } /** * @inheritDoc */ async getFundedPsbt(_bitcoinWallet, feeRate, additionalOutputs) { if (this._state !== OnchainForGasSwapState.PR_CREATED) throw new Error("Swap already paid for!"); let bitcoinWallet; if ((0, IBitcoinWallet_1.isIBitcoinWallet)(_bitcoinWallet)) { bitcoinWallet = _bitcoinWallet; } else { bitcoinWallet = new SingleAddressBitcoinWallet_1.SingleAddressBitcoinWallet(this.wrapper._btcRpc, this.wrapper._options.bitcoinNetwork, _bitcoinWallet); } //TODO: Maybe re-introduce fee rate check here if passed from the user if (feeRate == null) { feeRate = await bitcoinWallet.getFeeRate(); } const basePsbt = new btc_signer_1.Transaction({ allowUnknownOutputs: true, allowLegacyWitnessUtxo: true }); basePsbt.addOutput({ amount: this.outputAmount, script: (0, BitcoinUtils_1.toOutputScript)(this.wrapper._options.bitcoinNetwork, this.address) }); if (additionalOutputs != null) additionalOutputs.forEach(output => { basePsbt.addOutput({ amount: output.amount, script: output.outputScript ?? (0, BitcoinUtils_1.toOutputScript)(this.wrapper._options.bitcoinNetwork, output.address) }); }); const psbt = await bitcoinWallet.fundPsbt(basePsbt, feeRate); //Sign every input const signInputs = []; for (let i = 0; i < psbt.inputsLength; i++) { signInputs.push(i); } const serializedPsbt = buffer_1.Buffer.from(psbt.toPSBT()); return { psbt, psbtHex: serializedPsbt.toString("hex"), psbtBase64: serializedPsbt.toString("base64"), signInputs }; } /** * @inheritDoc */ async submitPsbt(_psbt) { const psbt = (0, BitcoinUtils_1.parsePsbtTransaction)(_psbt); if (this._state !== OnchainForGasSwapState.PR_CREATED) throw new Error("Swap already paid for!"); //Ensure not expired if (this.expiry < Date.now()) { throw new Error("Swap expired!"); } const output0 = psbt.getOutput(0); if (output0.amount !== this.outputAmount) throw new Error("PSBT output amount invalid, expected: " + this.outputAmount + " got: " + output0.amount); const expectedOutputScript = (0, BitcoinUtils_1.toOutputScript)(this.wrapper._options.bitcoinNetwork, this.address); if (output0.script == null || !expectedOutputScript.equals(output0.script)) throw new Error("PSBT output script invalid!"); if (!psbt.isFinal) psbt.finalize(); return await this.wrapper._btcRpc.sendRawTransaction(buffer_1.Buffer.from(psbt.toBytes(true, true)).toString("hex")); } /** * @inheritDoc */ async estimateBitcoinFee(_bitcoinWallet, feeRate) { const bitcoinWallet = (0, BitcoinWalletUtils_1.toBitcoinWallet)(_bitcoinWallet, this.wrapper._btcRpc, this.wrapper._options.bitcoinNetwork); const txFee = await bitcoinWallet.getTransactionFee(this.address, this.inputAmount, feeRate); if (txFee == null) return null; return (0, TokenAmount_1.toTokenAmount)(BigInt(txFee), Token_1.BitcoinTokens.BTC, this.wrapper._prices, this.pricingInfo); } /** * @inheritDoc */ async sendBitcoinTransaction(wallet, feeRate) { if (this._state !== OnchainForGasSwapState.PR_CREATED) throw new Error("Swap already paid for!"); //Ensure not expired if (this.expiry < Date.now()) { throw new Error("Swap expired!"); } if ((0, IBitcoinWallet_1.isIBitcoinWallet)(wallet)) { return await wallet.sendTransaction(this.address, this.inputAmount, feeRate); } else { const { psbt, psbtHex, psbtBase64, signInputs } = await this.getFundedPsbt(wallet, feeRate); const signedPsbt = await wallet.signPsbt({ psbt, psbtHex, psbtBase64 }, signInputs); return await this.submitPsbt(signedPsbt); } } /** * @inheritDoc * * @param options.bitcoinWallet Optional bitcoin wallet address specification to return a funded PSBT, * if not provided an address is returned instead. */ async getExecutionAction(options) { throw new Error("Not supported"); } /** * @inheritDoc */ async getExecutionSteps() { throw new Error("Not supported"); } /** * @inheritDoc */ async getExecutionStatus() { throw new Error("Not supported"); } /** * @internal * @inheritDoc */ _submitExecutionTransactions() { throw new Error("Not supported"); } /** * @remarks Not supported */ async execute() { throw new Error("Not supported"); } ////////////////////////////// //// Payment /** * Queries the intermediary (LP) node for the state of the swap * * @param save Whether the save the result or not * * @returns Whether the swap was successful as `boolean` or `null` if the swap is still pending * @internal */ async checkAddress(save = true) { if (this._state === OnchainForGasSwapState.FAILED || this._state === OnchainForGasSwapState.EXPIRED || this._state === OnchainForGasSwapState.REFUNDED) return false; if (this._state === OnchainForGasSwapState.FINISHED) return false; if (this.url == null) return false; const response = await this.wrapper._lpApi.getTrustedAddressStatus(this.url, this.paymentHash, this.sequence, this.wrapper._options.getRequestTimeout); switch (response.code) { case IntermediaryAPI_1.TrustedAddressStatusResponseCodes.AWAIT_PAYMENT: if (this.txId != null) { this.txId = undefined; if (save) await this._save(); return true; } return false; case IntermediaryAPI_1.TrustedAddressStatusResponseCodes.AWAIT_CONFIRMATION: case IntermediaryAPI_1.TrustedAddressStatusResponseCodes.PENDING: case IntermediaryAPI_1.TrustedAddressStatusResponseCodes.TX_SENT: const inputAmount = BigInt(response.data.adjustedAmount); const outputAmount = BigInt(response.data.adjustedTotal); const adjustedFee = response.data.adjustedFee == null ? null : BigInt(response.data.adjustedFee); const adjustedFeeSats = response.data.adjustedFeeSats == null ? null : BigInt(response.data.adjustedFeeSats); const txId = response.data.txId; if (this.txId != txId || this.inputAmount !== inputAmount || this.outputAmount !== outputAmount) { this.txId = txId; this.inputAmount = inputAmount; this.outputAmount = outputAmount; if (adjustedFee != null) this.swapFee = adjustedFee; if (adjustedFeeSats != null) this.swapFeeBtc = adjustedFeeSats; if (save) await this._save(); return true; } return false; case IntermediaryAPI_1.TrustedAddressStatusResponseCodes.PAID: const txStatus = await this.wrapper._chain.getTxIdStatus(response.data.txId); if (txStatus === "success") { this._state = OnchainForGasSwapState.FINISHED; this.scTxId = response.data.txId; if (save) await this._saveAndEmit(); return true; } return false; case IntermediaryAPI_1.TrustedAddressStatusResponseCodes.EXPIRED: this._state = OnchainForGasSwapState.EXPIRED; if (save) await this._saveAndEmit(); return true; case IntermediaryAPI_1.TrustedAddressStatusResponseCodes.REFUNDABLE: if (this._state === OnchainForGasSwapState.REFUNDABLE) return null; this._state = OnchainForGasSwapState.REFUNDABLE; if (save) await this._saveAndEmit(); return true; case IntermediaryAPI_1.TrustedAddressStatusResponseCodes.REFUNDED: this._state = OnchainForGasSwapState.REFUNDED; this.refundTxId = response.data.txId; if (save) await this._saveAndEmit(); return true; default: this._state = OnchainForGasSwapState.FAILED; if (save) await this._saveAndEmit(); return true; } } /** * Sets the bitcoin address used for possible refunds in case something goes wrong with the swap * * @param refundAddress Bitcoin address to receive the refund to * @internal */ async setRefundAddress(refundAddress) { if (this.refundAddress != null) { if (this.refundAddress !== refundAddress) throw new Error("Different refund address already set!"); return; } if (this.url == null) throw new Error("LP URL not known, cannot set refund address!"); await this.wrapper._lpApi.setTrustedRefundAddress(this.url, this.paymentHash, this.sequence, refundAddress, this.wrapper._options.getRequestTimeout); this.refundAddress = refundAddress; } /** * @inheritDoc */ async waitForBitcoinTransaction(updateCallback, checkIntervalSeconds = 5, abortSignal) { if (this._state !== OnchainForGasSwapState.PR_CREATED) throw new Error("Must be in PR_CREATED state!"); if (!this.initiated) { this.initiated = true; await this._saveAndEmit(); } while (!abortSignal?.aborted && this._state === OnchainForGasSwapState.PR_CREATED) { await this.checkAddress(true); if (this.txId != null && updateCallback != null) { const res = await this.wrapper._btcRpc.getTransaction(this.txId); if (res == null) { updateCallback(); } else if (res.confirmations != null && res.confirmations > 0) { updateCallback(res.txid, res.confirmations, 1, 0); } else { const delay = await this.wrapper._btcRpc.getConfirmationDelay(res, 1); updateCallback(res.txid, 0, 1, delay ?? undefined); } } if (this._state === OnchainForGasSwapState.PR_CREATED) await (0, TimeoutUtils_1.timeoutPromise)(checkIntervalSeconds * 1000, abortSignal); } if (this._state === OnchainForGasSwapState.REFUNDABLE || this._state === OnchainForGasSwapState.REFUNDED) return this.txId; if (this.isQuoteExpired()) throw new Error("Swap expired"); if (this.isFailed()) throw new Error("Swap failed"); return this.txId; } /** * Waits till the LP processes a refund for a failed swap. The swap must be in * {@link OnchainForGasSwapState.REFUNDABLE} state * * @param checkIntervalSeconds How often to check (default 5 seconds) * @param abortSignal Abort signal */ async waitTillRefunded(checkIntervalSeconds, abortSignal) { checkIntervalSeconds ??= 5; if (this._state === OnchainForGasSwapState.REFUNDED) return; if (this._state !== OnchainForGasSwapState.REFUNDABLE) throw new Error("Must be in REFUNDABLE state!"); while (!abortSignal?.aborted && this._state === OnchainForGasSwapState.REFUNDABLE) { await this.checkAddress(true); if (this._state === OnchainForGasSwapState.REFUNDABLE) await (0, TimeoutUtils_1.timeoutPromise)(checkIntervalSeconds * 1000, abortSignal); } if (this.isQuoteExpired()) throw new Error("Swap expired"); if (this.isFailed()) throw new Error("Swap failed"); } /** * Requests a refund after the swap failed, this also waits till the refund is actually sent by the * intermediary (LP). The swap must be in {@link OnchainForGasSwapState.REFUNDABLE} state * * @param refundAddress Bitcoin address to receive the refund to * @param abortSignal Abort signal */ async requestRefund(refundAddress, abortSignal) { if (refundAddress != null) await this.setRefundAddress(refundAddress); await this.waitTillRefunded(undefined, abortSignal); } ////////////////////////////// //// Storage /** * @inheritDoc */ serialize() { return { ...super.serialize(), paymentHash: this.paymentHash, sequence: this.sequence == null ? null : this.sequence.toString(10), address: this.address, inputAmount: this.inputAmount == null ? null : this.inputAmount.toString(10), outputAmount: this.outputAmount == null ? null : this.outputAmount.toString(10), recipient: this.recipient, token: this.token, refundAddress: this.refundAddress, scTxId: this.scTxId, txId: this.txId, refundTxId: this.refundTxId, }; } /** * @inheritDoc * @internal */ _getInitiator() { return this.recipient; } ////////////////////////////// //// Swap ticks & sync /** * @inheritDoc * @internal */ async _sync(save) { if (this._state === OnchainForGasSwapState.PR_CREATED) { //Check if it's maybe already paid const result = await this.checkAddress(false); if (result) { if (save) await this._saveAndEmit(); return true; } } return false; } /** * @inheritDoc * @internal */ _tick(save) { return Promise.resolve(false); } } exports.OnchainForGasSwap = OnchainForGasSwap;