UNPKG

@atomiqlabs/sdk-lib

Version:

Basic SDK functionality library for atomiq

454 lines (453 loc) 21.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 PaymentAuthError_1 = require("../../../errors/PaymentAuthError"); const Utils_1 = require("../../../utils/Utils"); const BitcoinUtils_1 = require("../../../utils/BitcoinUtils"); const BitcoinHelpers_1 = require("../../../utils/BitcoinHelpers"); const ISwap_1 = require("../../ISwap"); const TrustedIntermediaryAPI_1 = require("../../../intermediaries/TrustedIntermediaryAPI"); const Tokens_1 = require("../../../Tokens"); const Fee_1 = require("../../fee/Fee"); const IBitcoinWallet_1 = require("../../../btc/wallet/IBitcoinWallet"); const btc_signer_1 = require("@scure/btc-signer"); const SingleAddressBitcoinWallet_1 = require("../../../btc/wallet/SingleAddressBitcoinWallet"); const buffer_1 = require("buffer"); var OnchainForGasSwapState; (function (OnchainForGasSwapState) { OnchainForGasSwapState[OnchainForGasSwapState["EXPIRED"] = -3] = "EXPIRED"; OnchainForGasSwapState[OnchainForGasSwapState["FAILED"] = -2] = "FAILED"; OnchainForGasSwapState[OnchainForGasSwapState["REFUNDED"] = -1] = "REFUNDED"; OnchainForGasSwapState[OnchainForGasSwapState["PR_CREATED"] = 0] = "PR_CREATED"; OnchainForGasSwapState[OnchainForGasSwapState["FINISHED"] = 1] = "FINISHED"; OnchainForGasSwapState[OnchainForGasSwapState["REFUNDABLE"] = 2] = "REFUNDABLE"; })(OnchainForGasSwapState = exports.OnchainForGasSwapState || (exports.OnchainForGasSwapState = {})); 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; class OnchainForGasSwap extends ISwap_1.ISwap { constructor(wrapper, initOrObj) { if (isOnchainForGasSwapInit(initOrObj)) initOrObj.url += "/frombtc_trusted"; super(wrapper, initOrObj); this.getSmartChainNetworkFee = null; this.TYPE = SwapType_1.SwapType.TRUSTED_FROM_BTC; if (isOnchainForGasSwapInit(initOrObj)) { this.state = OnchainForGasSwapState.PR_CREATED; } else { this.paymentHash = initOrObj.paymentHash; this.sequence = initOrObj.sequence == null ? null : BigInt(initOrObj.sequence); this.address = initOrObj.address; this.inputAmount = initOrObj.inputAmount == null ? null : BigInt(initOrObj.inputAmount); this.outputAmount = initOrObj.outputAmount == null ? null : BigInt(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, Utils_1.getLogger)("OnchainForGas(" + this.getId() + "): "); this.tryRecomputeSwapPrice(); } upgradeVersion() { if (this.version == null) { //Noop this.version = 1; } } /** * In case swapFee in BTC is not supplied it recalculates it based on swap price * @protected */ tryRecomputeSwapPrice() { if (this.swapFeeBtc == null) { this.swapFeeBtc = this.swapFee * this.getInput().rawAmount / this.getOutAmountWithoutFee(); } super.tryRecomputeSwapPrice(); } ////////////////////////////// //// Getters & utils _getEscrowHash() { return this.paymentHash; } getOutputAddress() { return this.recipient; } getInputTxId() { return this.txId; } getOutputTxId() { return this.scTxId; } getId() { return this.paymentHash; } getAddress() { return this.address; } getHyperlink() { return "bitcoin:" + this.address + "?amount=" + encodeURIComponent((Number(this.inputAmount) / 100000000).toString(10)); } requiresAction() { return this.state === OnchainForGasSwapState.REFUNDABLE; } isFinished() { return this.state === OnchainForGasSwapState.FINISHED || this.state === OnchainForGasSwapState.FAILED || this.state === OnchainForGasSwapState.EXPIRED || this.state === OnchainForGasSwapState.REFUNDED; } isQuoteExpired() { return this.state === OnchainForGasSwapState.EXPIRED; } isQuoteSoftExpired() { return this.expiry < Date.now(); } isFailed() { return this.state === OnchainForGasSwapState.FAILED; } isSuccessful() { return this.state === OnchainForGasSwapState.FINISHED; } verifyQuoteValid() { return Promise.resolve(this.expiry > Date.now()); } ////////////////////////////// //// Amounts & fees getOutAmountWithoutFee() { return this.outputAmount + this.swapFee; } getOutput() { return (0, Tokens_1.toTokenAmount)(this.outputAmount, this.wrapper.tokens[this.wrapper.chain.getNativeCurrencyAddress()], this.wrapper.prices); } getInput() { return (0, Tokens_1.toTokenAmount)(this.inputAmount, Tokens_1.BitcoinTokens.BTC, this.wrapper.prices); } getInputWithoutFee() { return (0, Tokens_1.toTokenAmount)(this.inputAmount - this.swapFeeBtc, Tokens_1.BitcoinTokens.BTC, this.wrapper.prices); } getSwapFee() { const feeWithoutBaseFee = this.swapFeeBtc - this.pricingInfo.satsBaseFee; const swapFeePPM = feeWithoutBaseFee * 1000000n / this.getInputWithoutFee().rawAmount; return { amountInSrcToken: (0, Tokens_1.toTokenAmount)(this.swapFeeBtc, Tokens_1.BitcoinTokens.BTC, this.wrapper.prices), amountInDstToken: (0, Tokens_1.toTokenAmount)(this.swapFee, this.wrapper.tokens[this.wrapper.chain.getNativeCurrencyAddress()], this.wrapper.prices), usdValue: (abortSignal, preFetchedUsdPrice) => this.wrapper.prices.getBtcUsdValue(this.swapFeeBtc, abortSignal, preFetchedUsdPrice), composition: { base: (0, Tokens_1.toTokenAmount)(this.pricingInfo.satsBaseFee, Tokens_1.BitcoinTokens.BTC, this.wrapper.prices), percentage: (0, ISwap_1.ppmToPercentage)(swapFeePPM) } }; } getFee() { return this.getSwapFee(); } getFeeBreakdown() { return [{ type: Fee_1.FeeType.SWAP, fee: this.getSwapFee() }]; } getRequiredConfirmationsCount() { return 1; } /** * Returns the PSBT that is already funded with wallet's UTXOs (runs a coin-selection algorithm to choose UTXOs to use), * also returns inputs indices that need to be signed by the wallet before submitting the PSBT back to the SDK with * `swap.submitPsbt()` * * @param _bitcoinWallet Sender's bitcoin wallet * @param feeRate Optional fee rate for the transaction, needs to be at least as big as {minimumBtcFeeRate} field * @param additionalOutputs additional outputs to add to the PSBT - can be used to collect fees from users */ 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 }; } /** * Submits a PSBT signed by the wallet back to the SDK * * @param _psbt A psbt - either a Transaction object or a hex or base64 encoded PSBT string */ async submitPsbt(_psbt) { const psbt = (0, BitcoinHelpers_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 (!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")); } async estimateBitcoinFee(_bitcoinWallet, feeRate) { const bitcoinWallet = (0, BitcoinHelpers_1.toBitcoinWallet)(_bitcoinWallet, this.wrapper.btcRpc, this.wrapper.options.bitcoinNetwork); const txFee = await bitcoinWallet.getTransactionFee(this.address, this.inputAmount, feeRate); return (0, Tokens_1.toTokenAmount)(txFee == null ? null : BigInt(txFee), Tokens_1.BitcoinTokens.BTC, this.wrapper.prices); } 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); } } ////////////////////////////// //// Payment 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; const response = await TrustedIntermediaryAPI_1.TrustedIntermediaryAPI.getAddressStatus(this.url, this.paymentHash, this.sequence, this.wrapper.options.getRequestTimeout); switch (response.code) { case TrustedIntermediaryAPI_1.AddressStatusResponseCodes.AWAIT_PAYMENT: if (this.txId != null) { this.txId = null; if (save) await this._save(); return true; } return false; case TrustedIntermediaryAPI_1.AddressStatusResponseCodes.AWAIT_CONFIRMATION: case TrustedIntermediaryAPI_1.AddressStatusResponseCodes.PENDING: case TrustedIntermediaryAPI_1.AddressStatusResponseCodes.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 TrustedIntermediaryAPI_1.AddressStatusResponseCodes.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 TrustedIntermediaryAPI_1.AddressStatusResponseCodes.EXPIRED: this.state = OnchainForGasSwapState.EXPIRED; if (save) await this._saveAndEmit(); return true; case TrustedIntermediaryAPI_1.AddressStatusResponseCodes.REFUNDABLE: if (this.state === OnchainForGasSwapState.REFUNDABLE) return null; this.state = OnchainForGasSwapState.REFUNDABLE; if (save) await this._saveAndEmit(); return true; case TrustedIntermediaryAPI_1.AddressStatusResponseCodes.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; } } async setRefundAddress(refundAddress) { if (this.refundAddress != null) { if (this.refundAddress !== refundAddress) throw new Error("Different refund address already set!"); return; } await TrustedIntermediaryAPI_1.TrustedIntermediaryAPI.setRefundAddress(this.url, this.paymentHash, this.sequence, refundAddress, this.wrapper.options.getRequestTimeout); this.refundAddress = refundAddress; } /** * A blocking promise resolving when payment was received by the intermediary and client can continue * rejecting in case of failure * * @param abortSignal Abort signal * @param checkIntervalSeconds How often to poll the intermediary for answer * @param updateCallback Callback called when txId is found, and also called with subsequent confirmations * @throws {PaymentAuthError} If swap expired or failed * @throws {Error} When in invalid state (not PR_CREATED) */ 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(null, null, 1, null); } else if (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); } } if (this.state === OnchainForGasSwapState.PR_CREATED) await (0, Utils_1.timeoutPromise)(checkIntervalSeconds * 1000, abortSignal); } if (this.state === OnchainForGasSwapState.REFUNDABLE || this.state === OnchainForGasSwapState.REFUNDED) return this.txId; if (this.isQuoteExpired()) throw new PaymentAuthError_1.PaymentAuthError("Swap expired"); if (this.isFailed()) throw new PaymentAuthError_1.PaymentAuthError("Swap failed"); return this.txId; } 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, Utils_1.timeoutPromise)(checkIntervalSeconds * 1000, abortSignal); } if (this.isQuoteExpired()) throw new PaymentAuthError_1.PaymentAuthError("Swap expired"); if (this.isFailed()) throw new PaymentAuthError_1.PaymentAuthError("Swap failed"); } async requestRefund(refundAddress, abortSignal) { if (refundAddress != null) await this.setRefundAddress(refundAddress); await this.waitTillRefunded(undefined, abortSignal); } ////////////////////////////// //// Storage 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, }; } _getInitiator() { return this.recipient; } ////////////////////////////// //// Swap ticks & sync 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; } _tick(save) { return Promise.resolve(false); } } exports.OnchainForGasSwap = OnchainForGasSwap;