@atomiqlabs/sdk-lib
Version:
Basic SDK functionality library for atomiq
945 lines (944 loc) • 48 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.SpvFromBTCSwap = exports.isSpvFromBTCSwapInit = exports.SpvFromBTCSwapState = void 0;
const ISwap_1 = require("../ISwap");
const base_1 = require("@atomiqlabs/base");
const SwapType_1 = require("../enums/SwapType");
const Utils_1 = require("../../utils/Utils");
const BitcoinUtils_1 = require("../../utils/BitcoinUtils");
const BitcoinHelpers_1 = require("../../utils/BitcoinHelpers");
const btc_signer_1 = require("@scure/btc-signer");
const Tokens_1 = require("../../Tokens");
const buffer_1 = require("buffer");
const Fee_1 = require("../fee/Fee");
const IBitcoinWallet_1 = require("../../btc/wallet/IBitcoinWallet");
const IntermediaryAPI_1 = require("../../intermediaries/IntermediaryAPI");
var SpvFromBTCSwapState;
(function (SpvFromBTCSwapState) {
SpvFromBTCSwapState[SpvFromBTCSwapState["CLOSED"] = -5] = "CLOSED";
SpvFromBTCSwapState[SpvFromBTCSwapState["FAILED"] = -4] = "FAILED";
SpvFromBTCSwapState[SpvFromBTCSwapState["DECLINED"] = -3] = "DECLINED";
SpvFromBTCSwapState[SpvFromBTCSwapState["QUOTE_EXPIRED"] = -2] = "QUOTE_EXPIRED";
SpvFromBTCSwapState[SpvFromBTCSwapState["QUOTE_SOFT_EXPIRED"] = -1] = "QUOTE_SOFT_EXPIRED";
SpvFromBTCSwapState[SpvFromBTCSwapState["CREATED"] = 0] = "CREATED";
SpvFromBTCSwapState[SpvFromBTCSwapState["SIGNED"] = 1] = "SIGNED";
SpvFromBTCSwapState[SpvFromBTCSwapState["POSTED"] = 2] = "POSTED";
SpvFromBTCSwapState[SpvFromBTCSwapState["BROADCASTED"] = 3] = "BROADCASTED";
SpvFromBTCSwapState[SpvFromBTCSwapState["FRONTED"] = 4] = "FRONTED";
SpvFromBTCSwapState[SpvFromBTCSwapState["BTC_TX_CONFIRMED"] = 5] = "BTC_TX_CONFIRMED";
SpvFromBTCSwapState[SpvFromBTCSwapState["CLAIMED"] = 6] = "CLAIMED"; //Funds claimed
})(SpvFromBTCSwapState = exports.SpvFromBTCSwapState || (exports.SpvFromBTCSwapState = {}));
function isSpvFromBTCSwapInit(obj) {
return typeof obj === "object" &&
typeof (obj.quoteId) === "string" &&
typeof (obj.recipient) === "string" &&
typeof (obj.vaultOwner) === "string" &&
typeof (obj.vaultId) === "bigint" &&
typeof (obj.vaultRequiredConfirmations) === "number" &&
Array.isArray(obj.vaultTokenMultipliers) && obj.vaultTokenMultipliers.reduce((prev, curr) => prev && typeof (curr) === "bigint", true) &&
typeof (obj.vaultBtcAddress) === "string" &&
typeof (obj.vaultUtxo) === "string" &&
typeof (obj.vaultUtxoValue) === "bigint" &&
typeof (obj.btcDestinationAddress) === "string" &&
typeof (obj.btcAmount) === "bigint" &&
typeof (obj.btcAmountSwap) === "bigint" &&
typeof (obj.btcAmountGas) === "bigint" &&
typeof (obj.minimumBtcFeeRate) === "number" &&
typeof (obj.outputTotalSwap) === "bigint" &&
typeof (obj.outputSwapToken) === "string" &&
typeof (obj.outputTotalGas) === "bigint" &&
typeof (obj.outputGasToken) === "string" &&
typeof (obj.gasSwapFeeBtc) === "bigint" &&
typeof (obj.gasSwapFee) === "bigint" &&
typeof (obj.callerFeeShare) === "bigint" &&
typeof (obj.frontingFeeShare) === "bigint" &&
typeof (obj.executionFeeShare) === "bigint" &&
typeof (obj.genesisSmartChainBlockHeight) === "number" &&
(0, ISwap_1.isISwapInit)(obj);
}
exports.isSpvFromBTCSwapInit = isSpvFromBTCSwapInit;
class SpvFromBTCSwap extends ISwap_1.ISwap {
constructor(wrapper, initOrObject) {
if (isSpvFromBTCSwapInit(initOrObject))
initOrObject.url += "/frombtc_spv";
super(wrapper, initOrObject);
this.TYPE = SwapType_1.SwapType.SPV_VAULT_FROM_BTC;
if (isSpvFromBTCSwapInit(initOrObject)) {
this.state = SpvFromBTCSwapState.CREATED;
const vaultAddressType = (0, BitcoinUtils_1.toCoinselectAddressType)((0, BitcoinUtils_1.toOutputScript)(this.wrapper.options.bitcoinNetwork, this.vaultBtcAddress));
if (vaultAddressType !== "p2tr" && vaultAddressType !== "p2wpkh" && vaultAddressType !== "p2wsh")
throw new Error("Vault address type must be of witness type: p2tr, p2wpkh, p2wsh");
}
else {
this.quoteId = initOrObject.quoteId;
this.recipient = initOrObject.recipient;
this.vaultOwner = initOrObject.vaultOwner;
this.vaultId = BigInt(initOrObject.vaultId);
this.vaultRequiredConfirmations = initOrObject.vaultRequiredConfirmations;
this.vaultTokenMultipliers = initOrObject.vaultTokenMultipliers.map(val => BigInt(val));
this.vaultBtcAddress = initOrObject.vaultBtcAddress;
this.vaultUtxo = initOrObject.vaultUtxo;
this.vaultUtxoValue = BigInt(initOrObject.vaultUtxoValue);
this.btcDestinationAddress = initOrObject.btcDestinationAddress;
this.btcAmount = BigInt(initOrObject.btcAmount);
this.btcAmountSwap = BigInt(initOrObject.btcAmountSwap);
this.btcAmountGas = BigInt(initOrObject.btcAmountGas);
this.minimumBtcFeeRate = initOrObject.minimumBtcFeeRate;
this.outputTotalSwap = BigInt(initOrObject.outputTotalSwap);
this.outputSwapToken = initOrObject.outputSwapToken;
this.outputTotalGas = BigInt(initOrObject.outputTotalGas);
this.outputGasToken = initOrObject.outputGasToken;
this.gasSwapFeeBtc = BigInt(initOrObject.gasSwapFeeBtc);
this.gasSwapFee = BigInt(initOrObject.gasSwapFee);
this.callerFeeShare = BigInt(initOrObject.callerFeeShare);
this.frontingFeeShare = BigInt(initOrObject.frontingFeeShare);
this.executionFeeShare = BigInt(initOrObject.executionFeeShare);
this.genesisSmartChainBlockHeight = initOrObject.genesisSmartChainBlockHeight;
this.claimTxId = initOrObject.claimTxId;
this.frontTxId = initOrObject.frontTxId;
this.data = initOrObject.data == null ? null : new this.wrapper.spvWithdrawalDataDeserializer(initOrObject.data);
}
this.tryCalculateSwapFee();
this.logger = (0, Utils_1.getLogger)("SPVFromBTC(" + this.getId() + "): ");
}
upgradeVersion() { }
/**
* 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.btcAmountSwap / this.getOutputWithoutFee().rawAmount;
}
if (this.pricingInfo.swapPriceUSatPerToken == null) {
this.pricingInfo = this.wrapper.prices.recomputePriceInfoReceive(this.chainIdentifier, this.btcAmountSwap, this.pricingInfo.satsBaseFee, this.pricingInfo.feePPM, this.getOutputWithoutFee().rawAmount, this.outputSwapToken);
}
}
//////////////////////////////
//// Pricing
async refreshPriceData() {
if (this.pricingInfo == null)
return null;
this.pricingInfo = await this.wrapper.prices.isValidAmountReceive(this.chainIdentifier, this.btcAmountSwap, this.pricingInfo.satsBaseFee, this.pricingInfo.feePPM, this.getOutputWithoutFee().rawAmount, this.outputSwapToken);
}
//////////////////////////////
//// Getters & utils
_getInitiator() {
return this.recipient;
}
_getEscrowHash() {
return this.data?.btcTx?.txid;
}
getId() {
return this.quoteId + this.randomNonce;
}
getQuoteExpiry() {
return this.expiry - 20 * 1000;
}
verifyQuoteValid() {
return Promise.resolve(this.expiry > Date.now() && (this.state === SpvFromBTCSwapState.CREATED || this.state === SpvFromBTCSwapState.QUOTE_SOFT_EXPIRED));
}
getOutputAddress() {
return this.recipient;
}
getOutputTxId() {
return this.frontTxId ?? this.claimTxId;
}
getInputTxId() {
return this.data?.btcTx?.txid;
}
requiresAction() {
return this.state === SpvFromBTCSwapState.BTC_TX_CONFIRMED;
}
isFinished() {
return this.state === SpvFromBTCSwapState.CLAIMED || this.state === SpvFromBTCSwapState.QUOTE_EXPIRED || this.state === SpvFromBTCSwapState.FAILED;
}
isClaimable() {
return this.state === SpvFromBTCSwapState.BTC_TX_CONFIRMED;
}
isSuccessful() {
return this.state === SpvFromBTCSwapState.FRONTED || this.state === SpvFromBTCSwapState.CLAIMED;
}
isFailed() {
return this.state === SpvFromBTCSwapState.FAILED || this.state === SpvFromBTCSwapState.DECLINED || this.state === SpvFromBTCSwapState.CLOSED;
}
isQuoteExpired() {
return this.state === SpvFromBTCSwapState.QUOTE_EXPIRED;
}
isQuoteSoftExpired() {
return this.state === SpvFromBTCSwapState.QUOTE_EXPIRED || this.state === SpvFromBTCSwapState.QUOTE_SOFT_EXPIRED;
}
//////////////////////////////
//// Amounts & fees
getInputSwapAmountWithoutFee() {
return (this.btcAmountSwap - this.swapFeeBtc) * 100000n / (100000n + this.callerFeeShare + this.frontingFeeShare + this.executionFeeShare);
}
getInputGasAmountWithoutFee() {
return (this.btcAmountGas - this.gasSwapFeeBtc) * 100000n / (100000n + this.callerFeeShare + this.frontingFeeShare);
}
getInputAmountWithoutFee() {
return this.getInputSwapAmountWithoutFee() + this.getInputGasAmountWithoutFee();
}
getOutputWithoutFee() {
return (0, Tokens_1.toTokenAmount)((this.outputTotalSwap * (100000n + this.callerFeeShare + this.frontingFeeShare + this.executionFeeShare) / 100000n) + this.swapFee, this.wrapper.tokens[this.outputSwapToken], this.wrapper.prices);
}
getSwapFee() {
const outputToken = this.wrapper.tokens[this.outputSwapToken];
const gasSwapFeeInOutputToken = this.gasSwapFeeBtc
* (10n ** BigInt(outputToken.decimals))
* 1000000n
/ this.pricingInfo.swapPriceUSatPerToken;
const feeWithoutBaseFee = this.swapFeeBtc - this.pricingInfo.satsBaseFee;
const swapFeePPM = feeWithoutBaseFee * 1000000n / (this.btcAmount - this.swapFeeBtc - this.gasSwapFeeBtc);
return {
amountInSrcToken: (0, Tokens_1.toTokenAmount)(this.swapFeeBtc + this.gasSwapFeeBtc, Tokens_1.BitcoinTokens.BTC, this.wrapper.prices),
amountInDstToken: (0, Tokens_1.toTokenAmount)(this.swapFee + gasSwapFeeInOutputToken, outputToken, this.wrapper.prices),
usdValue: (abortSignal, preFetchedUsdPrice) => this.wrapper.prices.getBtcUsdValue(this.swapFeeBtc + this.gasSwapFeeBtc, abortSignal, preFetchedUsdPrice),
composition: {
base: (0, Tokens_1.toTokenAmount)(this.pricingInfo.satsBaseFee, Tokens_1.BitcoinTokens.BTC, this.wrapper.prices),
percentage: (0, ISwap_1.ppmToPercentage)(swapFeePPM)
}
};
}
getWatchtowerFee() {
const totalFeeShare = this.callerFeeShare + this.frontingFeeShare;
const outputToken = this.wrapper.tokens[this.outputSwapToken];
const watchtowerFeeInOutputToken = this.getInputGasAmountWithoutFee() * totalFeeShare
* (10n ** BigInt(outputToken.decimals))
* 1000000n
/ this.pricingInfo.swapPriceUSatPerToken
/ 100000n;
const feeBtc = this.getInputAmountWithoutFee() * (totalFeeShare + this.executionFeeShare) / 100000n;
return {
amountInSrcToken: (0, Tokens_1.toTokenAmount)(feeBtc, Tokens_1.BitcoinTokens.BTC, this.wrapper.prices),
amountInDstToken: (0, Tokens_1.toTokenAmount)((this.outputTotalSwap * (totalFeeShare + this.executionFeeShare) / 100000n) + watchtowerFeeInOutputToken, outputToken, this.wrapper.prices),
usdValue: (abortSignal, preFetchedUsdPrice) => this.wrapper.prices.getBtcUsdValue(feeBtc, abortSignal, preFetchedUsdPrice)
};
}
getFee() {
const swapFee = this.getSwapFee();
const watchtowerFee = this.getWatchtowerFee();
return {
amountInSrcToken: (0, Tokens_1.toTokenAmount)(swapFee.amountInSrcToken.rawAmount + watchtowerFee.amountInSrcToken.rawAmount, Tokens_1.BitcoinTokens.BTC, this.wrapper.prices),
amountInDstToken: (0, Tokens_1.toTokenAmount)(swapFee.amountInDstToken.rawAmount + watchtowerFee.amountInDstToken.rawAmount, this.wrapper.tokens[this.outputSwapToken], this.wrapper.prices),
usdValue: (abortSignal, preFetchedUsdPrice) => this.wrapper.prices.getBtcUsdValue(swapFee.amountInSrcToken.rawAmount + watchtowerFee.amountInSrcToken.rawAmount, abortSignal, preFetchedUsdPrice)
};
}
getFeeBreakdown() {
return [
{
type: Fee_1.FeeType.SWAP,
fee: this.getSwapFee()
},
{
type: Fee_1.FeeType.NETWORK_OUTPUT,
fee: this.getWatchtowerFee()
}
];
}
getOutput() {
return (0, Tokens_1.toTokenAmount)(this.outputTotalSwap, this.wrapper.tokens[this.outputSwapToken], this.wrapper.prices);
}
getGasDropOutput() {
return (0, Tokens_1.toTokenAmount)(this.outputTotalGas, this.wrapper.tokens[this.outputGasToken], this.wrapper.prices);
}
getInputWithoutFee() {
return (0, Tokens_1.toTokenAmount)(this.getInputAmountWithoutFee(), Tokens_1.BitcoinTokens.BTC, this.wrapper.prices);
}
getInput() {
return (0, Tokens_1.toTokenAmount)(this.btcAmount, Tokens_1.BitcoinTokens.BTC, this.wrapper.prices);
}
//////////////////////////////
//// Bitcoin tx
getRequiredConfirmationsCount() {
return this.vaultRequiredConfirmations;
}
async getTransactionDetails() {
const [txId, voutStr] = this.vaultUtxo.split(":");
const vaultScript = (0, BitcoinUtils_1.toOutputScript)(this.wrapper.options.bitcoinNetwork, this.vaultBtcAddress);
const out2script = (0, BitcoinUtils_1.toOutputScript)(this.wrapper.options.bitcoinNetwork, this.btcDestinationAddress);
const opReturnData = this.wrapper.contract.toOpReturnData(this.recipient, [
this.outputTotalSwap / this.vaultTokenMultipliers[0],
this.outputTotalGas / this.vaultTokenMultipliers[1]
]);
const out1script = buffer_1.Buffer.concat([
opReturnData.length > 75 ? buffer_1.Buffer.from([0x6a, 0x4c, opReturnData.length]) : buffer_1.Buffer.from([0x6a, opReturnData.length]),
opReturnData
]);
if (this.callerFeeShare < 0n || this.callerFeeShare > 0xfffffn)
throw new Error("Caller fee out of bounds!");
if (this.frontingFeeShare < 0n || this.frontingFeeShare > 0xfffffn)
throw new Error("Fronting fee out of bounds!");
if (this.executionFeeShare < 0n || this.executionFeeShare > 0xfffffn)
throw new Error("Execution fee out of bounds!");
const nSequence0 = 0x80000000n | (this.callerFeeShare & 0xfffffn) | (this.frontingFeeShare & 1047552n) << 10n;
const nSequence1 = 0x80000000n | (this.executionFeeShare & 0xfffffn) | (this.frontingFeeShare & 1023n) << 20n;
return {
in0txid: txId,
in0vout: parseInt(voutStr),
in0sequence: Number(nSequence0),
vaultAmount: this.vaultUtxoValue,
vaultScript,
in1sequence: Number(nSequence1),
out1script,
out2amount: this.btcAmount,
out2script,
locktime: 500000000 + Math.floor(Math.random() * 1000000000) //Use this as a random salt to make the btc txId unique!
};
}
/**
* Returns the raw PSBT (not funded), the wallet should fund the PSBT (add its inputs), set the nSequence field of the
* 2nd input (input 1 - indexing from 0) to the value returned in `in1sequence`, sign the PSBT and then pass
* it back to the SDK with `swap.submitPsbt()`
*/
async getPsbt() {
const res = await this.getTransactionDetails();
const psbt = new btc_signer_1.Transaction({
allowUnknownOutputs: true,
allowLegacyWitnessUtxo: true,
lockTime: res.locktime
});
psbt.addInput({
txid: res.in0txid,
index: res.in0vout,
witnessUtxo: {
amount: res.vaultAmount,
script: res.vaultScript
},
sequence: res.in0sequence
});
psbt.addOutput({
amount: res.vaultAmount,
script: res.vaultScript
});
psbt.addOutput({
amount: 0n,
script: res.out1script
});
psbt.addOutput({
amount: res.out2amount,
script: res.out2script
});
const serializedPsbt = buffer_1.Buffer.from(psbt.toPSBT());
return {
psbt,
psbtHex: serializedPsbt.toString("hex"),
psbtBase64: serializedPsbt.toString("base64"),
in1sequence: res.in1sequence
};
}
/**
* 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) {
const bitcoinWallet = (0, BitcoinHelpers_1.toBitcoinWallet)(_bitcoinWallet, this.wrapper.btcRpc, this.wrapper.options.bitcoinNetwork);
if (feeRate != null) {
if (feeRate < this.minimumBtcFeeRate)
throw new Error("Bitcoin tx fee needs to be at least " + this.minimumBtcFeeRate + " sats/vB");
}
else {
feeRate = Math.max(this.minimumBtcFeeRate, await bitcoinWallet.getFeeRate());
}
let { psbt, in1sequence } = await this.getPsbt();
if (additionalOutputs != null)
additionalOutputs.forEach(output => {
psbt.addOutput({
amount: output.amount,
script: output.outputScript ?? (0, BitcoinUtils_1.toOutputScript)(this.wrapper.options.bitcoinNetwork, output.address)
});
});
psbt = await bitcoinWallet.fundPsbt(psbt, feeRate);
psbt.updateInput(1, { sequence: in1sequence });
//Sign every input except the first one
const signInputs = [];
for (let i = 1; 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);
//Ensure not expired
if (this.expiry < Date.now()) {
throw new Error("Quote expired!");
}
//Ensure valid state
if (this.state !== SpvFromBTCSwapState.QUOTE_SOFT_EXPIRED && this.state !== SpvFromBTCSwapState.CREATED) {
throw new Error("Invalid swap state!");
}
//Ensure all inputs except the 1st are finalized
for (let i = 1; i < psbt.inputsLength; i++) {
if ((0, btc_signer_1.getInputType)(psbt.getInput(i)).txType === "legacy")
throw new Error("Legacy (non-segwit) inputs are not allowed in the transaction!");
psbt.finalizeIdx(i);
}
const btcTx = await this.wrapper.btcRpc.parseTransaction(buffer_1.Buffer.from(psbt.toBytes(true)).toString("hex"));
const data = await this.wrapper.contract.getWithdrawalData(btcTx);
this.logger.debug("submitPsbt(): parsed withdrawal data: ", data);
//Verify correct withdrawal data
if (!data.isRecipient(this.recipient) ||
data.rawAmounts[0] * this.vaultTokenMultipliers[0] !== this.outputTotalSwap ||
(data.rawAmounts[1] ?? 0n) * this.vaultTokenMultipliers[1] !== this.outputTotalGas ||
data.callerFeeRate !== this.callerFeeShare ||
data.frontingFeeRate !== this.frontingFeeShare ||
data.executionFeeRate !== this.executionFeeShare ||
data.getSpentVaultUtxo() !== this.vaultUtxo ||
BigInt(data.getNewVaultBtcAmount()) !== this.vaultUtxoValue ||
!data.getNewVaultScript().equals((0, BitcoinUtils_1.toOutputScript)(this.wrapper.options.bitcoinNetwork, this.vaultBtcAddress)) ||
data.getExecutionData() != null) {
throw new Error("Invalid withdrawal tx data submitted!");
}
//Verify correct LP output
const lpOutput = psbt.getOutput(2);
if (lpOutput.amount !== this.btcAmount ||
!(0, BitcoinUtils_1.toOutputScript)(this.wrapper.options.bitcoinNetwork, this.btcDestinationAddress).equals(buffer_1.Buffer.from(lpOutput.script))) {
throw new Error("Invalid LP bitcoin output in transaction!");
}
//Verify vault utxo not spent yet
if (await this.wrapper.btcRpc.isSpent(this.vaultUtxo)) {
throw new Error("Vault UTXO already spent, please create new swap!");
}
//Verify tx is parsable by the contract
try {
await this.wrapper.contract.checkWithdrawalTx(data);
}
catch (e) {
throw new Error("Transaction not parsable by the contract: " + (e.message ?? e.toString()));
}
//Ensure still not expired
if (this.expiry < Date.now()) {
throw new Error("Quote expired!");
}
this.data = data;
this.initiated = true;
await this._saveAndEmit(SpvFromBTCSwapState.SIGNED);
try {
await IntermediaryAPI_1.IntermediaryAPI.initSpvFromBTC(this.chainIdentifier, this.url, {
quoteId: this.quoteId,
psbtHex: buffer_1.Buffer.from(psbt.toPSBT(0)).toString("hex")
});
await this._saveAndEmit(SpvFromBTCSwapState.POSTED);
}
catch (e) {
await this._saveAndEmit(SpvFromBTCSwapState.DECLINED);
throw e;
}
return this.data.getTxId();
}
async estimateBitcoinFee(_bitcoinWallet, feeRate) {
const bitcoinWallet = (0, BitcoinHelpers_1.toBitcoinWallet)(_bitcoinWallet, this.wrapper.btcRpc, this.wrapper.options.bitcoinNetwork);
const txFee = await bitcoinWallet.getFundedPsbtFee((await this.getPsbt()).psbt, feeRate);
return (0, Tokens_1.toTokenAmount)(txFee == null ? null : BigInt(txFee), Tokens_1.BitcoinTokens.BTC, this.wrapper.prices);
}
async sendBitcoinTransaction(wallet, feeRate) {
const { psbt, psbtBase64, psbtHex, signInputs } = await this.getFundedPsbt(wallet, feeRate);
let signedPsbt;
if ((0, IBitcoinWallet_1.isIBitcoinWallet)(wallet)) {
signedPsbt = await wallet.signPsbt(psbt, signInputs);
}
else {
signedPsbt = await wallet.signPsbt({
psbt, psbtHex, psbtBase64
}, signInputs);
}
return await this.submitPsbt(signedPsbt);
}
/**
* Executes the swap with the provided bitcoin wallet,
*
* @param wallet Bitcoin wallet to use to sign the bitcoin transaction
* @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 a swap was settled automatically by swap watchtowers or requires manual claim by the
* user, in case `false` is returned the user should call `swap.claim()` to settle the swap on the destination manually
*/
async execute(wallet, callbacks, options) {
if (this.state === SpvFromBTCSwapState.CLOSED)
throw new Error("Swap encountered a catastrophic failure!");
if (this.state === SpvFromBTCSwapState.FAILED)
throw new Error("Swap failed!");
if (this.state === SpvFromBTCSwapState.DECLINED)
throw new Error("Swap execution already declined by the LP!");
if (this.state === SpvFromBTCSwapState.QUOTE_EXPIRED || this.state === SpvFromBTCSwapState.QUOTE_SOFT_EXPIRED)
throw new Error("Swap quote expired!");
if (this.state === SpvFromBTCSwapState.CLAIMED || this.state === SpvFromBTCSwapState.FRONTED)
throw new Error("Swap already settled or fronted!");
if (this.state === SpvFromBTCSwapState.CREATED) {
const txId = await this.sendBitcoinTransaction(wallet, options?.feeRate);
if (callbacks?.onSourceTransactionSent != null)
callbacks.onSourceTransactionSent(txId);
}
if (this.state === SpvFromBTCSwapState.POSTED || this.state === SpvFromBTCSwapState.BROADCASTED) {
const txId = await this.waitForBitcoinTransaction(callbacks?.onSourceTransactionConfirmationStatus, options?.btcTxCheckIntervalSeconds, options?.abortSignal);
if (callbacks?.onSourceTransactionConfirmed != null)
callbacks.onSourceTransactionConfirmed(txId);
}
// @ts-ignore
if (this.state === SpvFromBTCSwapState.CLAIMED || this.state === SpvFromBTCSwapState.FRONTED)
return true;
if (this.state === SpvFromBTCSwapState.BTC_TX_CONFIRMED) {
const success = await this.waitTillClaimedOrFronted(options?.maxWaitTillAutomaticSettlementSeconds ?? 60, options?.abortSignal);
if (success && callbacks?.onSwapSettled != null)
callbacks.onSwapSettled(this.getOutputTxId());
return success;
}
}
//////////////////////////////
//// Bitcoin tx listener
/**
* Checks whether a bitcoin payment was already made, returns the payment or null when no payment has been made.
*/
async getBitcoinPayment() {
if (this.data?.btcTx?.txid == null)
return null;
const result = await this.wrapper.btcRpc.getTransaction(this.data?.btcTx?.txid);
if (result == null)
return null;
return {
txId: result.txid,
confirmations: result.confirmations,
targetConfirmations: this.vaultRequiredConfirmations
};
}
/**
* Waits till the bitcoin transaction confirms and swap becomes claimable
*
* @param updateCallback Callback called when txId is found, and also called with subsequent confirmations
* @param checkIntervalSeconds How often to check the bitcoin transaction
* @param abortSignal Abort signal
* @throws {Error} if in invalid state (must be CLAIM_COMMITED)
*/
async waitForBitcoinTransaction(updateCallback, checkIntervalSeconds, abortSignal) {
if (this.state !== SpvFromBTCSwapState.POSTED &&
this.state !== SpvFromBTCSwapState.BROADCASTED &&
!(this.state === SpvFromBTCSwapState.QUOTE_SOFT_EXPIRED && this.initiated))
throw new Error("Must be in POSTED or BROADCASTED state!");
const result = await this.wrapper.btcRpc.waitForTransaction(this.data.btcTx.txid, this.vaultRequiredConfirmations, (confirmations, txId, txEtaMs) => {
if (updateCallback != null)
updateCallback(txId, confirmations, this.vaultRequiredConfirmations, txEtaMs);
if (txId != null &&
(this.state === SpvFromBTCSwapState.POSTED || this.state == SpvFromBTCSwapState.QUOTE_SOFT_EXPIRED))
this._saveAndEmit(SpvFromBTCSwapState.BROADCASTED);
}, abortSignal, checkIntervalSeconds);
if (abortSignal != null)
abortSignal.throwIfAborted();
if (this.state !== SpvFromBTCSwapState.FRONTED &&
this.state !== SpvFromBTCSwapState.CLAIMED) {
await this._saveAndEmit(SpvFromBTCSwapState.BTC_TX_CONFIRMED);
}
return result.txid;
}
//////////////////////////////
//// Claim
/**
* Returns transactions required to claim the swap on-chain (and possibly also sync the bitcoin light client)
* after a bitcoin transaction was sent and confirmed
*
* @throws {Error} If the swap is in invalid state (must be BTC_TX_CONFIRMED)
*/
async txsClaim(_signer) {
let address;
if (_signer != null) {
if (typeof (_signer) === "string") {
address = _signer;
}
else if ((0, base_1.isAbstractSigner)(_signer)) {
address = _signer.getAddress();
}
else {
address = (await this.wrapper.chain.wrapSigner(_signer)).getAddress();
}
}
if (!this.isClaimable())
throw new Error("Must be in BTC_TX_CONFIRMED state!");
const vaultData = await this.wrapper.contract.getVaultData(this.vaultOwner, this.vaultId);
const txs = [await this.wrapper.btcRpc.getTransaction(this.data.btcTx.txid)];
//Trace back from current tx to the vaultData-specified UTXO
const vaultUtxo = vaultData.getUtxo();
while (txs[0].ins[0].txid + ":" + txs[0].ins[0].vout !== vaultUtxo) {
txs.unshift(await this.wrapper.btcRpc.getTransaction(txs[0].ins[0].txid));
}
//Parse transactions to withdrawal data
const withdrawalData = [];
for (let tx of txs) {
withdrawalData.push(await this.wrapper.contract.getWithdrawalData(tx));
}
return await this.wrapper.contract.txsClaim(address ?? this._getInitiator(), vaultData, withdrawalData.map(tx => { return { tx }; }), this.wrapper.synchronizer, true);
}
/**
* Claims and finishes the swap
*
* @param _signer Signer to sign the transactions with, can also be different to the initializer
* @param abortSignal Abort signal to stop waiting for transaction confirmation
*/
async claim(_signer, abortSignal) {
const signer = (0, base_1.isAbstractSigner)(_signer) ? _signer : await this.wrapper.chain.wrapSigner(_signer);
let txIds;
try {
txIds = await this.wrapper.chain.sendAndConfirm(signer, await this.txsClaim(signer), true, abortSignal);
}
catch (e) {
this.logger.info("claim(): Failed to claim ourselves, checking swap claim state...");
if (this.state === SpvFromBTCSwapState.CLAIMED) {
this.logger.info("claim(): Transaction state is CLAIMED, swap was successfully claimed by the watchtower");
return this.claimTxId;
}
const withdrawalState = await this.wrapper.contract.getWithdrawalState(this.data, this.genesisSmartChainBlockHeight);
if (withdrawalState.type === base_1.SpvWithdrawalStateType.CLAIMED) {
this.logger.info("claim(): Transaction status is CLAIMED, swap was successfully claimed by the watchtower");
this.claimTxId = withdrawalState.txId;
await this._saveAndEmit(SpvFromBTCSwapState.CLAIMED);
return null;
}
throw e;
}
this.claimTxId = txIds[0];
if (this.state === SpvFromBTCSwapState.POSTED || this.state === SpvFromBTCSwapState.BROADCASTED ||
this.state === SpvFromBTCSwapState.BTC_TX_CONFIRMED || this.state === SpvFromBTCSwapState.FAILED ||
this.state === SpvFromBTCSwapState.FRONTED) {
await this._saveAndEmit(SpvFromBTCSwapState.CLAIMED);
}
return txIds[0];
}
/**
* Periodically checks the chain to see whether the swap was finished (claimed or refunded)
*
* @param abortSignal
* @param interval How often to check (in seconds), default to 5s
* @protected
*/
async watchdogWaitTillResult(abortSignal, interval = 5) {
let status = { type: base_1.SpvWithdrawalStateType.NOT_FOUND };
while (status.type === base_1.SpvWithdrawalStateType.NOT_FOUND) {
await (0, Utils_1.timeoutPromise)(interval * 1000, abortSignal);
try {
//Be smart about checking withdrawal state
if (await this._shouldCheckWithdrawalState()) {
status = await this.wrapper.contract.getWithdrawalState(this.data, this.genesisSmartChainBlockHeight);
}
}
catch (e) {
this.logger.error("watchdogWaitTillResult(): Error when fetching commit status: ", e);
}
}
if (abortSignal != null)
abortSignal.throwIfAborted();
return status;
}
/**
* Waits till the swap is successfully executed
*
* @param maxWaitTimeSeconds Maximum time in seconds to wait for the swap to be settled
* @param abortSignal
* @throws {Error} If swap is in invalid state (must be BTC_TX_CONFIRMED)
* @throws {Error} If the LP refunded sooner than we were able to claim
* @returns {boolean} whether the swap was claimed or fronted automatically or not, if the swap was not claimed
* the user can claim manually through `swap.claim()`
*/
async waitTillClaimedOrFronted(maxWaitTimeSeconds, abortSignal) {
if (this.state === SpvFromBTCSwapState.CLAIMED || this.state === SpvFromBTCSwapState.FRONTED)
return Promise.resolve(true);
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 res;
try {
res = await Promise.race([
this.watchdogWaitTillResult(abortController.signal),
this.waitTillState(SpvFromBTCSwapState.CLAIMED, "eq", abortController.signal).then(() => 0),
this.waitTillState(SpvFromBTCSwapState.FRONTED, "eq", abortController.signal).then(() => 1),
this.waitTillState(SpvFromBTCSwapState.FAILED, "eq", abortController.signal).then(() => 2),
]);
abortController.abort();
}
catch (e) {
abortController.abort();
if (timedOut)
return false;
throw e;
}
if (typeof (res) === "number") {
if (res === 0) {
this.logger.debug("waitTillClaimedOrFronted(): Resolved from state change (CLAIMED)");
return true;
}
if (res === 1) {
this.logger.debug("waitTillClaimedOrFronted(): Resolved from state change (FRONTED)");
return true;
}
if (res === 2) {
this.logger.debug("waitTillClaimedOrFronted(): Resolved from state change (FAILED)");
throw new Error("Swap failed while waiting for claim or front");
}
return;
}
this.logger.debug("waitTillClaimedOrFronted(): Resolved from watchdog");
if (res.type === base_1.SpvWithdrawalStateType.FRONTED) {
if (this.state !== SpvFromBTCSwapState.FRONTED ||
this.state !== SpvFromBTCSwapState.CLAIMED) {
this.frontTxId = res.txId;
await this._saveAndEmit(SpvFromBTCSwapState.FRONTED);
}
}
if (res.type === base_1.SpvWithdrawalStateType.CLAIMED) {
if (this.state !== SpvFromBTCSwapState.CLAIMED) {
this.claimTxId = res.txId;
await this._saveAndEmit(SpvFromBTCSwapState.FRONTED);
}
}
if (res.type === base_1.SpvWithdrawalStateType.CLOSED) {
if (this.state !== SpvFromBTCSwapState.CLOSED)
await this._saveAndEmit(SpvFromBTCSwapState.CLOSED);
throw new Error("Swap failed with catastrophic error!");
}
return true;
}
/**
* Waits till the bitcoin transaction confirms and swap is claimed
*
* @param abortSignal Abort signal
* @param checkIntervalSeconds How often to check the bitcoin transaction
* @param updateCallback Callback called when txId is found, and also called with subsequent confirmations
* @throws {Error} if in invalid state (must be CLAIM_COMMITED)
*/
async waitTillExecuted(updateCallback, checkIntervalSeconds, abortSignal) {
await this.waitForBitcoinTransaction(updateCallback, checkIntervalSeconds, abortSignal);
await this.waitTillClaimedOrFronted(undefined, abortSignal);
}
//////////////////////////////
//// Storage
serialize() {
return {
...super.serialize(),
quoteId: this.quoteId,
recipient: this.recipient,
vaultOwner: this.vaultOwner,
vaultId: this.vaultId.toString(10),
vaultRequiredConfirmations: this.vaultRequiredConfirmations,
vaultTokenMultipliers: this.vaultTokenMultipliers.map(val => val.toString(10)),
vaultBtcAddress: this.vaultBtcAddress,
vaultUtxo: this.vaultUtxo,
vaultUtxoValue: this.vaultUtxoValue.toString(10),
btcDestinationAddress: this.btcDestinationAddress,
btcAmount: this.btcAmount.toString(10),
btcAmountSwap: this.btcAmountSwap.toString(10),
btcAmountGas: this.btcAmountGas.toString(10),
minimumBtcFeeRate: this.minimumBtcFeeRate,
outputTotalSwap: this.outputTotalSwap.toString(10),
outputSwapToken: this.outputSwapToken,
outputTotalGas: this.outputTotalGas.toString(10),
outputGasToken: this.outputGasToken,
gasSwapFeeBtc: this.gasSwapFeeBtc.toString(10),
gasSwapFee: this.gasSwapFee.toString(10),
callerFeeShare: this.callerFeeShare.toString(10),
frontingFeeShare: this.frontingFeeShare.toString(10),
executionFeeShare: this.executionFeeShare.toString(10),
genesisSmartChainBlockHeight: this.genesisSmartChainBlockHeight,
claimTxId: this.claimTxId,
frontTxId: this.frontTxId,
data: this.data?.serialize()
};
}
//////////////////////////////
//// Swap ticks & sync
async _syncStateFromBitcoin(save) {
if (this.data?.btcTx == null)
return false;
//Check if bitcoin payment was confirmed
const res = await this.getBitcoinPayment();
if (res == null) {
//Check inputs double-spent
for (let input of this.data.btcTx.ins) {
if (await this.wrapper.btcRpc.isSpent(input.txid + ":" + input.vout, true)) {
if (this.state === SpvFromBTCSwapState.SIGNED ||
this.state === SpvFromBTCSwapState.POSTED ||
this.state === SpvFromBTCSwapState.QUOTE_SOFT_EXPIRED ||
this.state === SpvFromBTCSwapState.DECLINED) {
//One of the inputs was double-spent
this.state = SpvFromBTCSwapState.QUOTE_EXPIRED;
}
else {
//One of the inputs was double-spent
this.state = SpvFromBTCSwapState.FAILED;
}
if (save)
await this._saveAndEmit();
return true;
}
}
}
else {
if (res.confirmations >= this.vaultRequiredConfirmations) {
if (this.state !== SpvFromBTCSwapState.BTC_TX_CONFIRMED &&
this.state !== SpvFromBTCSwapState.FRONTED &&
this.state !== SpvFromBTCSwapState.CLAIMED) {
this.state = SpvFromBTCSwapState.BTC_TX_CONFIRMED;
if (save)
await this._saveAndEmit();
return true;
}
}
else if (this.state === SpvFromBTCSwapState.QUOTE_SOFT_EXPIRED ||
this.state === SpvFromBTCSwapState.POSTED ||
this.state === SpvFromBTCSwapState.SIGNED ||
this.state === SpvFromBTCSwapState.DECLINED) {
this.state = SpvFromBTCSwapState.BROADCASTED;
if (save)
await this._saveAndEmit();
return true;
}
}
return false;
}
/**
* 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() {
let changed = false;
if (this.state === SpvFromBTCSwapState.SIGNED ||
this.state === SpvFromBTCSwapState.POSTED ||
this.state === SpvFromBTCSwapState.BROADCASTED ||
this.state === SpvFromBTCSwapState.QUOTE_SOFT_EXPIRED ||
this.state === SpvFromBTCSwapState.DECLINED ||
this.state === SpvFromBTCSwapState.BTC_TX_CONFIRMED) {
//Check BTC transaction
if (await this._syncStateFromBitcoin(false))
changed ||= true;
}
if (this.state === SpvFromBTCSwapState.BROADCASTED || this.state === SpvFromBTCSwapState.BTC_TX_CONFIRMED) {
if (await this._shouldCheckWithdrawalState()) {
const status = await this.wrapper.contract.getWithdrawalState(this.data, this.genesisSmartChainBlockHeight);
this.logger.debug("syncStateFromChain(): status of " + this.data.btcTx.txid, status);
switch (status.type) {
case base_1.SpvWithdrawalStateType.FRONTED:
this.frontTxId = status.txId;
this.state = SpvFromBTCSwapState.FRONTED;
changed ||= true;
break;
case base_1.SpvWithdrawalStateType.CLAIMED:
this.claimTxId = status.txId;
this.state = SpvFromBTCSwapState.CLAIMED;
changed ||= true;
break;
case base_1.SpvWithdrawalStateType.CLOSED:
this.state = SpvFromBTCSwapState.CLOSED;
changed ||= true;
break;
}
}
}
if (this.state === SpvFromBTCSwapState.CREATED ||
this.state === SpvFromBTCSwapState.SIGNED ||
this.state === SpvFromBTCSwapState.POSTED) {
if (this.expiry < Date.now()) {
if (this.state === SpvFromBTCSwapState.CREATED) {
this.state = SpvFromBTCSwapState.QUOTE_EXPIRED;
}
else {
this.state = SpvFromBTCSwapState.QUOTE_SOFT_EXPIRED;
}
changed ||= true;
}
}
return changed;
}
async _sync(save) {
const changed = await this.syncStateFromChain();
if (changed && save)
await this._saveAndEmit();
return changed;
}
async _tick(save) {
if (this.state === SpvFromBTCSwapState.CREATED ||
this.state === SpvFromBTCSwapState.SIGNED) {
if (this.getQuoteExpiry() < Date.now()) {
this.state = SpvFromBTCSwapState.QUOTE_SOFT_EXPIRED;
if (save)
await this._saveAndEmit();
return true;
}
}
if (this.state === SpvFromBTCSwapState.QUOTE_SOFT_EXPIRED && !this.initiated) {
if (this.expiry < Date.now()) {
this.state = SpvFromBTCSwapState.QUOTE_EXPIRED;
if (save)
await this._saveAndEmit();
return true;
}
}
if (Math.floor(Date.now() / 1000) % 120 === 0) {
if (this.state === SpvFromBTCSwapState.POSTED ||
this.state === SpvFromBTCSwapState.BROADCASTED) {
try {
//Check if bitcoin payment was confirmed
return await this._syncStateFromBitcoin(save);
}
catch (e) {
this.logger.error("tickSwap(" + this.getId() + "): ", e);
}
}
}
}
async _shouldCheckWithdrawalState(frontingAddress, vaultDataUtxo) {
if (frontingAddress === undefined)
frontingAddress = await this.wrapper.contract.getFronterAddress(this.vaultOwner, this.vaultId, this.data);
if (vaultDataUtxo === undefined)
vaultDataUtxo = await this.wrapper.contract.getVaultLatestUtxo(this.vaultOwner, this.vaultId);
if (frontingAddress != null)
return true; //In case the swap is fronted there will for sure be a fronted event
if (vaultDataUtxo == null)
return true; //Vault UTXO is null (the vault closed)
const [txId, _] = vaultDataUtxo.split(":");
//Don't check both txns if their txId is equal
if (this.data.btcTx.txid === txId)
return true;
const [btcTx, latestVaultTx] = await Promise.all([
this.wrapper.btcRpc.getTransaction(this.data.btcTx.txid),
this.wrapper.btcRpc.getTransaction(txId)
]);
if (btcTx != null) {
const btcTxHeight = btcTx.blockheight;
const latestVaultTxHeight = latestVaultTx.blockheight;
//We also need to cover the case where bitcoin tx isn't confirmed yet (hence btxTxHeight==null)
if (btcTxHeight == null || latestVaultTxHeight < btcTxHeight) {
//Definitely not claimed!
this.logger.debug(`_shouldCheckWithdrawalState(): Skipped checking withdrawal state, latestVaultTxHeight: ${latestVaultTx.blockheight}, btcTxHeight: ${btcTxHeight} and not fronted!`);
return false;
}
}
else {
//Definitely not claimed because the transaction was probably double-spent (or evicted from mempool)
this.logger.debug(`_shouldCheckWithdrawalState(): Skipped checking withdrawal state, btc tx probably replaced or evicted: ${this.data.btcTx.txid} and not fronted`);
return false;
}
return true;
}
}
exports.SpvFromBTCSwap = SpvFromBTCSwap;