@atomiqlabs/sdk
Version:
atomiq labs SDK for cross-chain swaps between smart chains and bitcoin
870 lines • 54.2 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.SpvFromBTCWrapper = exports.REQUIRED_SPV_SWAP_LP_ADDRESS_TYPE = exports.REQUIRED_SPV_SWAP_VAULT_ADDRESS_TYPE = void 0;
const ISwapWrapper_1 = require("../ISwapWrapper");
const base_1 = require("@atomiqlabs/base");
const SpvFromBTCSwap_1 = require("./SpvFromBTCSwap");
const utils_1 = require("@scure/btc-signer/utils");
const SwapType_1 = require("../../enums/SwapType");
const Intermediary_1 = require("../../intermediaries/Intermediary");
const Utils_1 = require("../../utils/Utils");
const BitcoinUtils_1 = require("../../utils/BitcoinUtils");
const RequestError_1 = require("../../errors/RequestError");
const IntermediaryError_1 = require("../../errors/IntermediaryError");
const btc_signer_1 = require("@scure/btc-signer");
const RetryUtils_1 = require("../../utils/RetryUtils");
const UserError_1 = require("../../errors/UserError");
const utils_2 = require("../../bitcoin/coinselect2/utils");
const BitcoinWallet_1 = require("../../bitcoin/wallet/BitcoinWallet");
exports.REQUIRED_SPV_SWAP_VAULT_ADDRESS_TYPE = "p2tr";
exports.REQUIRED_SPV_SWAP_LP_ADDRESS_TYPE = "p2wpkh";
/**
* New spv vault (UTXO-controlled vault) based swaps for Bitcoin -> Smart chain swaps not requiring
* any initiation on the destination chain, and with the added possibility for the user to receive
* a native token on the destination chain as part of the swap (a "gas drop" feature).
*
* @category Swaps/Bitcoin → Smart chain
*/
class SpvFromBTCWrapper extends ISwapWrapper_1.ISwapWrapper {
/**
* @param chainIdentifier
* @param unifiedStorage Storage interface for the current environment
* @param unifiedChainEvents On-chain event listener
* @param chain
* @param prices Pricing to use
* @param tokens
* @param versionedContracts
* @param versionedSynchronizer
* @param btcRpc Bitcoin RPC which also supports getting transactions by txoHash
* @param lpApi
* @param options
* @param events Instance to use for emitting events
*/
constructor(chainIdentifier, unifiedStorage, unifiedChainEvents, chain, prices, tokens, versionedContracts, versionedSynchronizer, btcRpc, lpApi, options, events) {
super(chainIdentifier, unifiedStorage, unifiedChainEvents, chain, prices, tokens, lpApi, {
...options,
bitcoinNetwork: options?.bitcoinNetwork ?? utils_1.TEST_NETWORK,
maxConfirmations: options?.maxConfirmations ?? 6,
bitcoinBlocktime: options?.bitcoinBlocktime ?? 10 * 60,
maxTransactionsDelta: options?.maxTransactionsDelta ?? 3,
maxRawAmountAdjustmentDifferencePPM: options?.maxRawAmountAdjustmentDifferencePPM ?? 100,
maxBtcFeeOffset: options?.maxBtcFeeOffset ?? 10,
maxBtcFeeMultiplier: options?.maxBtcFeeMultiplier ?? 1.5
}, events);
this.TYPE = SwapType_1.SwapType.SPV_VAULT_FROM_BTC;
/**
* @internal
*/
this._claimableSwapStates = [SpvFromBTCSwap_1.SpvFromBTCSwapState.BTC_TX_CONFIRMED];
/**
* @internal
*/
this._swapDeserializer = SpvFromBTCSwap_1.SpvFromBTCSwap;
/**
* @internal
*/
this.btcRelay = (version) => {
const _version = version ?? "v1";
const data = this.versionedContracts[_version];
if (data == null)
throw new Error(`Invalid contract version ${_version} requested`);
return data.btcRelay;
};
/**
* @internal
*/
this.tickSwapState = [
SpvFromBTCSwap_1.SpvFromBTCSwapState.CREATED,
SpvFromBTCSwap_1.SpvFromBTCSwapState.QUOTE_SOFT_EXPIRED,
SpvFromBTCSwap_1.SpvFromBTCSwapState.SIGNED,
SpvFromBTCSwap_1.SpvFromBTCSwapState.POSTED,
SpvFromBTCSwap_1.SpvFromBTCSwapState.BROADCASTED
];
/**
* @internal
*/
this._synchronizer = (version) => {
const _version = version ?? "v1";
const data = this.versionedSynchronizer[_version];
if (data == null)
throw new Error(`Invalid contract version ${_version} requested`);
return data.synchronizer;
};
/**
* @internal
*/
this._contract = (version) => {
const _version = version ?? "v1";
const data = this.versionedContracts[_version];
if (data == null)
throw new Error(`Invalid contract version ${_version} requested`);
return data.spvVaultContract;
};
/**
* @internal
*/
this._spvWithdrawalDataDeserializer = (version) => {
const _version = version ?? "v1";
const data = this.versionedContracts[_version];
if (data == null)
throw new Error(`Invalid contract version ${_version} requested`);
return data.spvVaultWithdrawalDataConstructor;
};
/**
* @internal
*/
this._pendingSwapStates = [
SpvFromBTCSwap_1.SpvFromBTCSwapState.CREATED,
SpvFromBTCSwap_1.SpvFromBTCSwapState.SIGNED,
SpvFromBTCSwap_1.SpvFromBTCSwapState.POSTED,
SpvFromBTCSwap_1.SpvFromBTCSwapState.QUOTE_SOFT_EXPIRED,
SpvFromBTCSwap_1.SpvFromBTCSwapState.BROADCASTED,
SpvFromBTCSwap_1.SpvFromBTCSwapState.DECLINED,
SpvFromBTCSwap_1.SpvFromBTCSwapState.BTC_TX_CONFIRMED
];
this.versionedContracts = {};
this.versionedSynchronizer = {};
this.versionedContracts = versionedContracts;
this.versionedSynchronizer = versionedSynchronizer;
this._btcRpc = btcRpc;
}
async processEventFront(event, swap) {
if (swap._state === SpvFromBTCSwap_1.SpvFromBTCSwapState.SIGNED || swap._state === SpvFromBTCSwap_1.SpvFromBTCSwapState.POSTED ||
swap._state === SpvFromBTCSwap_1.SpvFromBTCSwapState.BROADCASTED || swap._state === SpvFromBTCSwap_1.SpvFromBTCSwapState.DECLINED ||
swap._state === SpvFromBTCSwap_1.SpvFromBTCSwapState.QUOTE_SOFT_EXPIRED || swap._state === SpvFromBTCSwap_1.SpvFromBTCSwapState.BTC_TX_CONFIRMED) {
swap._state = SpvFromBTCSwap_1.SpvFromBTCSwapState.FRONTED;
await swap._setBitcoinTxId(event.btcTxId).catch(e => {
this.logger.warn("processEventFront(): Failed to set bitcoin txId: ", e);
});
return true;
}
return false;
}
async processEventClaim(event, swap) {
if (swap._state === SpvFromBTCSwap_1.SpvFromBTCSwapState.SIGNED || swap._state === SpvFromBTCSwap_1.SpvFromBTCSwapState.POSTED ||
swap._state === SpvFromBTCSwap_1.SpvFromBTCSwapState.BROADCASTED || swap._state === SpvFromBTCSwap_1.SpvFromBTCSwapState.DECLINED ||
swap._state === SpvFromBTCSwap_1.SpvFromBTCSwapState.QUOTE_SOFT_EXPIRED || swap._state === SpvFromBTCSwap_1.SpvFromBTCSwapState.FRONTED ||
swap._state === SpvFromBTCSwap_1.SpvFromBTCSwapState.BTC_TX_CONFIRMED) {
swap._state = SpvFromBTCSwap_1.SpvFromBTCSwapState.CLAIMED;
await swap._setBitcoinTxId(event.btcTxId).catch(e => {
this.logger.warn("processEventClaim(): Failed to set bitcoin txId: ", e);
});
return true;
}
return false;
}
processEventClose(event, swap) {
if (swap._state === SpvFromBTCSwap_1.SpvFromBTCSwapState.SIGNED || swap._state === SpvFromBTCSwap_1.SpvFromBTCSwapState.POSTED ||
swap._state === SpvFromBTCSwap_1.SpvFromBTCSwapState.BROADCASTED || swap._state === SpvFromBTCSwap_1.SpvFromBTCSwapState.DECLINED ||
swap._state === SpvFromBTCSwap_1.SpvFromBTCSwapState.QUOTE_SOFT_EXPIRED || swap._state === SpvFromBTCSwap_1.SpvFromBTCSwapState.BTC_TX_CONFIRMED) {
swap._state = SpvFromBTCSwap_1.SpvFromBTCSwapState.CLOSED;
return Promise.resolve(true);
}
return Promise.resolve(false);
}
/**
* @inheritDoc
* @internal
*/
async processEvent(event, swap) {
if (swap == null)
return;
let swapChanged = false;
if (event instanceof base_1.SpvVaultFrontEvent) {
swapChanged = await this.processEventFront(event, swap);
if (event.meta?.txId != null && swap._frontTxId !== event.meta.txId) {
swap._frontTxId = event.meta.txId;
swapChanged ||= true;
}
}
if (event instanceof base_1.SpvVaultClaimEvent) {
swapChanged = await this.processEventClaim(event, swap);
if (event.meta?.txId != null && swap._claimTxId !== event.meta.txId) {
swap._claimTxId = event.meta.txId;
swapChanged ||= true;
}
}
if (event instanceof base_1.SpvVaultCloseEvent) {
swapChanged = await this.processEventClose(event, swap);
}
this.logger.info("processEvents(): " + event.constructor.name + " processed for " + swap.getId() + " swap: ", swap);
if (swapChanged) {
await swap._saveAndEmit();
}
}
/**
* Pre-fetches latest finalized block height of the smart chain
*
* @param abortController
* @private
*/
async preFetchFinalizedBlockHeight(abortController) {
try {
const block = await this._chain.getFinalizedBlock();
return block.height;
}
catch (e) {
abortController.abort(e);
}
}
/**
* Pre-fetches caller (watchtower) bounty data for the swap. Doesn't throw, instead returns null and aborts the
* provided abortController
*
* @param amountData
* @param options Options as passed to the swap creation function
* @param abortController
* @param contractVersion
* @private
*/
async preFetchCallerFeeInNativeToken(amountData, options, abortController, contractVersion) {
if (options.unsafeZeroWatchtowerFee)
return 0n;
if (amountData.amount === 0n)
return 0n;
try {
const [feePerBlock, btcRelayData, currentBtcBlock, claimFeeRate] = await Promise.all([
this.btcRelay(contractVersion).getFeePerBlock(),
this.btcRelay(contractVersion).getTipData(),
this._btcRpc.getTipHeight(),
this._contract(contractVersion).getClaimFee(this._chain.randomAddress())
]);
if (btcRelayData == null)
throw new Error("Btc relay doesn't seem to be initialized!");
const currentBtcRelayBlock = btcRelayData.blockheight;
const blockDelta = Math.max(currentBtcBlock - currentBtcRelayBlock + this._options.maxConfirmations, 0);
const totalFeeInNativeToken = ((BigInt(blockDelta) * feePerBlock) +
(claimFeeRate * BigInt(this._options.maxTransactionsDelta))) * BigInt(Math.floor(options.feeSafetyFactor * 1000000)) / 1000000n;
return totalFeeInNativeToken;
}
catch (e) {
abortController.abort(e);
}
}
/**
* Pre-fetches caller (watchtower) bounty data for the swap. Doesn't throw, instead returns null and aborts the
* provided abortController
*
* @param amountPrefetch
* @param totalFeeInNativeTokenPrefetch
* @param amountData
* @param options Options as passed to the swap creation function
* @param pricePrefetch
* @param nativeTokenPricePrefetch
* @param abortSignal
* @private
*/
async computeCallerFeeShare(amountPrefetch, totalFeeInNativeTokenPrefetch, amountData, options, pricePrefetch, nativeTokenPricePrefetch, abortSignal) {
if (options.unsafeZeroWatchtowerFee)
return 0n;
const amount = await (0, Utils_1.throwIfUndefined)(amountPrefetch, "Cannot get swap amount!");
if (amount === 0n)
return 0n;
const totalFeeInNativeToken = await (0, Utils_1.throwIfUndefined)(totalFeeInNativeTokenPrefetch, "Cannot get total fee in native token!");
const nativeTokenPrice = await nativeTokenPricePrefetch;
let payoutAmount;
if (amountData.exactIn) {
//Convert input amount in BTC to
const amountInNativeToken = await this._prices.getFromBtcSwapAmount(this.chainIdentifier, amount, this._chain.getNativeCurrencyAddress(), abortSignal, nativeTokenPrice);
payoutAmount = amountInNativeToken - totalFeeInNativeToken;
}
else {
if (amountData.token === this._chain.getNativeCurrencyAddress()) {
//Both amounts in same currency
payoutAmount = amount;
}
else {
//Need to convert both to native currency
const btcAmount = await this._prices.getToBtcSwapAmount(this.chainIdentifier, amount, amountData.token, abortSignal, await pricePrefetch);
payoutAmount = await this._prices.getFromBtcSwapAmount(this.chainIdentifier, btcAmount, this._chain.getNativeCurrencyAddress(), abortSignal, nativeTokenPrice);
}
}
this.logger.debug("computeCallerFeeShare(): Caller fee in native token: " + totalFeeInNativeToken.toString(10) + " total payout in native token: " + payoutAmount.toString(10));
const callerFeeShare = ((totalFeeInNativeToken * 100000n) + payoutAmount - 1n) / payoutAmount; //Make sure to round up here
if (callerFeeShare < 0n)
return 0n;
if (callerFeeShare >= 2n ** 20n)
return 2n ** 20n - 1n;
return callerFeeShare;
}
/**
* Verifies response returned from intermediary
*
* @param resp Response as returned by the intermediary
* @param amountData
* @param lp Intermediary
* @param options Options as passed to the swap creation function
* @param callerFeeShare
* @param maxBitcoinFeeRatePromise Maximum accepted fee rate from the LPs
* @param bitcoinFeeRatePromise
* @param abortSignal
* @private
* @throws {IntermediaryError} in case the response is invalid
*/
async verifyReturnedData(resp, amountData, lp, options, callerFeeShare, maxBitcoinFeeRatePromise, bitcoinFeeRatePromise, abortSignal) {
const btcFeeRate = await (0, Utils_1.throwIfUndefined)(maxBitcoinFeeRatePromise, "Bitcoin fee rate promise failed!");
abortSignal.throwIfAborted();
if (btcFeeRate != null && resp.btcFeeRate > btcFeeRate)
throw new IntermediaryError_1.IntermediaryError(`Required bitcoin fee rate returned from the LP is too high! Maximum accepted: ${btcFeeRate} sats/vB, required by LP: ${resp.btcFeeRate} sats/vB`);
const lpVersion = lp.getContractVersion(this.chainIdentifier);
//Vault related
let vaultScript;
let vaultAddressType;
let btcAddressScript;
let btcAddressType;
//Ensure valid btc addresses returned
try {
vaultScript = (0, BitcoinUtils_1.toOutputScript)(this._options.bitcoinNetwork, resp.vaultBtcAddress);
vaultAddressType = (0, BitcoinUtils_1.toCoinselectAddressType)(vaultScript);
btcAddressScript = (0, BitcoinUtils_1.toOutputScript)(this._options.bitcoinNetwork, resp.btcAddress);
btcAddressType = (0, BitcoinUtils_1.toCoinselectAddressType)(btcAddressScript);
}
catch (e) {
throw new IntermediaryError_1.IntermediaryError("Invalid btc address data returned", e);
}
const decodedUtxo = resp.btcUtxo.split(":");
if (resp.address !== lp.getAddress(this.chainIdentifier) || //Ensure the LP is indeed the vault owner
resp.vaultId < 0n || //Ensure vaultId is not negative
vaultScript == null || //Make sure vault script is parsable and of known type
btcAddressScript == null || //Make sure btc address script is parsable and of known type
btcAddressType !== exports.REQUIRED_SPV_SWAP_LP_ADDRESS_TYPE || //Constrain the btc address script type
vaultAddressType !== exports.REQUIRED_SPV_SWAP_VAULT_ADDRESS_TYPE || //Constrain the vault script type
decodedUtxo.length !== 2 || decodedUtxo[0].length !== 64 || isNaN(parseInt(decodedUtxo[1])) || //Check valid UTXO
resp.btcFeeRate < 1 || resp.btcFeeRate > 10000 //Sanity check on the returned BTC fee rate
)
throw new IntermediaryError_1.IntermediaryError("Invalid vault data returned!");
//Amounts sanity
if (resp.btcAmountSwap + resp.btcAmountGas !== resp.btcAmount)
throw new Error("Btc amount mismatch");
if (resp.swapFeeBtc + resp.gasSwapFeeBtc !== resp.totalFeeBtc)
throw new Error("Btc fee mismatch");
//TODO: For now ensure fees are at 0
if (resp.callerFeeShare !== callerFeeShare ||
resp.frontingFeeShare !== 0n ||
resp.executionFeeShare !== 0n)
throw new IntermediaryError_1.IntermediaryError("Invalid caller/fronting/execution fee returned");
//Check expiry
const timeNowSeconds = Math.floor(Date.now() / 1000);
if (resp.expiry < timeNowSeconds)
throw new IntermediaryError_1.IntermediaryError(`Quote already expired, expiry: ${resp.expiry}, systemTime: ${timeNowSeconds}, clockAdjusted: ${Date._now != null}`);
let utxo = resp.btcUtxo.toLowerCase();
const [txId, voutStr] = utxo.split(":");
const abortController = (0, Utils_1.extendAbortController)(abortSignal);
let [vault, { vaultUtxoValue, btcTx }] = await Promise.all([
(async () => {
//Fetch vault data
let vault;
try {
vault = await this._contract(lpVersion).getVaultData(resp.address, resp.vaultId);
}
catch (e) {
this.logger.error("Error getting spv vault (owner: " + resp.address + " vaultId: " + resp.vaultId.toString(10) + "): ", e);
throw new IntermediaryError_1.IntermediaryError("Spv swap vault not found", e);
}
abortController.signal.throwIfAborted();
//Make sure vault is opened
if (vault == null || !vault.isOpened())
throw new IntermediaryError_1.IntermediaryError("Returned spv swap vault is not opened!");
//Make sure the vault doesn't require insane amount of confirmations
if (vault.getConfirmations() > this._options.maxConfirmations)
throw new IntermediaryError_1.IntermediaryError("SPV swap vault needs too many confirmations: " + vault.getConfirmations());
const tokenData = vault.getTokenData();
//Amounts - make sure the amounts match
if (amountData.exactIn) {
if (!resp.usedUtxoInputCalculation) {
//Legacy calculation
if (resp.btcAmount !== amountData.amount)
throw new IntermediaryError_1.IntermediaryError("Invalid amount returned");
}
else {
//Implies the raw UTXOs were passed for amount derivation
//Verify the derivation was done correctly
if (options.sourceWalletUtxos == null)
throw new IntermediaryError_1.IntermediaryError("Invalid usedUtxoInputCalcuation return value");
if (bitcoinFeeRatePromise == null)
throw new Error("bitcoinFeeRatePromise must be passed for UTXO-based input amount calculation checks");
const walletUtxos = await options.sourceWalletUtxos;
const bitcoinFeeRate = await (0, Utils_1.throwIfUndefined)(bitcoinFeeRatePromise, "Failed to fetch bitcoin fee rate!");
const { balance } = BitcoinWallet_1.BitcoinWallet.getSpendableBalance(walletUtxos, Math.max(resp.btcFeeRate, bitcoinFeeRate), this.getDummySwapPsbt(options.gasAmount !== 0n), exports.REQUIRED_SPV_SWAP_LP_ADDRESS_TYPE);
if (resp.btcAmount !== balance)
throw new IntermediaryError_1.IntermediaryError(`Invalid amount returned, expected: ${balance.toString(10)}, got: ${resp.btcAmount.toString(10)}`);
}
}
else {
//Check the difference between amount adjusted due to scaling to raw amount
const adjustedAmount = amountData.amount / tokenData[0].multiplier * tokenData[0].multiplier;
const adjustmentPPM = (amountData.amount - adjustedAmount) * 1000000n / amountData.amount;
if (adjustmentPPM > this._options.maxRawAmountAdjustmentDifferencePPM)
throw new IntermediaryError_1.IntermediaryError("Invalid amount0 multiplier used, rawAmount diff too high");
if (resp.total !== adjustedAmount)
throw new IntermediaryError_1.IntermediaryError("Invalid total returned");
}
if (options.gasAmount === 0n) {
if (resp.totalGas !== 0n)
throw new IntermediaryError_1.IntermediaryError("Invalid gas total returned");
}
else {
//Check the difference between amount adjusted due to scaling to raw amount
const adjustedGasAmount = options.gasAmount / tokenData[0].multiplier * tokenData[0].multiplier;
const adjustmentPPM = (options.gasAmount - adjustedGasAmount) * 1000000n / options.gasAmount;
if (adjustmentPPM > this._options.maxRawAmountAdjustmentDifferencePPM)
throw new IntermediaryError_1.IntermediaryError("Invalid amount1 multiplier used, rawAmount diff too high");
if (resp.totalGas !== adjustedGasAmount)
throw new IntermediaryError_1.IntermediaryError("Invalid gas total returned");
}
return vault;
})(),
(async () => {
//Require the vault UTXO to have at least 1 confirmation
let btcTx = await this._btcRpc.getTransaction(txId);
if (btcTx == null)
throw new IntermediaryError_1.IntermediaryError("Invalid UTXO, doesn't exist (txId)");
abortController.signal.throwIfAborted();
if (btcTx.confirmations == null || btcTx.confirmations < 1)
throw new IntermediaryError_1.IntermediaryError("SPV vault UTXO not confirmed");
const vout = parseInt(voutStr);
if (btcTx.outs[vout] == null)
throw new IntermediaryError_1.IntermediaryError("Invalid UTXO, doesn't exist");
const vaultUtxoValue = btcTx.outs[vout].value;
return { btcTx, vaultUtxoValue };
})(),
(async () => {
//Require vault UTXO is unspent
if (await this._btcRpc.isSpent(utxo))
throw new IntermediaryError_1.IntermediaryError("Returned spv vault UTXO is already spent", null, true);
abortController.signal.throwIfAborted();
})()
]).catch(e => {
abortController.abort(e);
throw e;
});
this.logger.debug("verifyReturnedData(): Vault UTXO: " + vault.getUtxo() + " current utxo: " + utxo);
//Trace returned utxo back to what's saved on-chain
let pendingWithdrawals = [];
while (vault.getUtxo() !== utxo) {
const [txId, voutStr] = utxo.split(":");
//Such that 1st tx isn't fetched twice
if (btcTx.txid !== txId) {
const _btcTx = await this._btcRpc.getTransaction(txId);
if (_btcTx == null)
throw new IntermediaryError_1.IntermediaryError("Invalid ancestor transaction (not found)");
btcTx = _btcTx;
}
const withdrawalData = await this._contract(lpVersion).getWithdrawalData(btcTx);
abortSignal.throwIfAborted();
pendingWithdrawals.unshift(withdrawalData);
utxo = pendingWithdrawals[0].getSpentVaultUtxo();
this.logger.debug("verifyReturnedData(): Vault UTXO: " + vault.getUtxo() + " current utxo: " + utxo);
if (pendingWithdrawals.length >= this._options.maxTransactionsDelta)
throw new IntermediaryError_1.IntermediaryError("BTC <> SC state difference too deep, maximum: " + this._options.maxTransactionsDelta);
}
//Verify that the vault has enough balance after processing all pending withdrawals
let vaultBalances;
try {
vaultBalances = vault.calculateStateAfter(pendingWithdrawals);
}
catch (e) {
this.logger.error("Error calculating spv vault balance (owner: " + resp.address + " vaultId: " + resp.vaultId.toString(10) + "): ", e);
throw new IntermediaryError_1.IntermediaryError("Spv swap vault balance prediction failed", e);
}
if (vaultBalances.balances[0].scaledAmount < resp.total)
throw new IntermediaryError_1.IntermediaryError("SPV swap vault, insufficient balance, required: " + resp.total.toString(10) +
" has: " + vaultBalances.balances[0].scaledAmount.toString(10));
if (vaultBalances.balances[1].scaledAmount < resp.totalGas)
throw new IntermediaryError_1.IntermediaryError("SPV swap vault, insufficient balance, required: " + resp.totalGas.toString(10) +
" has: " + vaultBalances.balances[1].scaledAmount.toString(10));
//Also verify that all the withdrawal txns are valid, this is an extra sanity check
try {
for (let withdrawal of pendingWithdrawals) {
await this._contract(lpVersion).checkWithdrawalTx(withdrawal);
}
}
catch (e) {
this.logger.error("Error calculating spv vault balance (owner: " + resp.address + " vaultId: " + resp.vaultId.toString(10) + "): ", e);
throw new IntermediaryError_1.IntermediaryError("Spv swap vault balance prediction failed", e);
}
abortSignal.throwIfAborted();
return {
vault,
vaultUtxoValue
};
}
async amountPrefetch(amountData, bitcoinFeeRatePromise, walletUtxosPromise, includeGas, abortController) {
if (amountData.amount != null)
return amountData.amount;
try {
const bitcoinFeeRate = await (0, Utils_1.throwIfUndefined)(bitcoinFeeRatePromise, "Cannot fetch Bitcoin fee rate!");
if (walletUtxosPromise == null)
throw new UserError_1.UserError("Cannot use empty amount without passing UTXOs!");
const walletUtxos = await walletUtxosPromise;
if (walletUtxos.length === 0)
throw new UserError_1.UserError("Wallet doesn't have any BTC balance");
const spendableBalance = await BitcoinWallet_1.BitcoinWallet.getSpendableBalance(walletUtxos, bitcoinFeeRate, this.getDummySwapPsbt(includeGas), exports.REQUIRED_SPV_SWAP_LP_ADDRESS_TYPE);
return spendableBalance.balance;
}
catch (e) {
abortController.abort(e);
}
}
bitcoinFeeRatePrefetch(options, abortController) {
let bitcoinFeeRatePromise;
if (options?.sourceWalletUtxos != null) {
if (options.bitcoinFeeRate != null) {
bitcoinFeeRatePromise = options.bitcoinFeeRate.then(value => {
if (options.maxAllowedBitcoinFeeRate != Infinity && options.maxAllowedBitcoinFeeRate < value)
throw new Error("Passed `maxAllowedBitcoinFeeRate` cannot be lower than `bitcoinFeeRate`");
return value;
});
}
else {
bitcoinFeeRatePromise = this._btcRpc.getFeeRate().then(value => {
if (options.maxAllowedBitcoinFeeRate != Infinity && value > options.maxAllowedBitcoinFeeRate)
return options.maxAllowedBitcoinFeeRate;
return value;
});
}
bitcoinFeeRatePromise = bitcoinFeeRatePromise.catch(e => {
abortController.abort(e);
return undefined;
});
}
const maxBitcoinFeeRatePromise = options.maxAllowedBitcoinFeeRate != Infinity
? Promise.resolve(options.maxAllowedBitcoinFeeRate)
: (0, Utils_1.throwIfUndefined)(bitcoinFeeRatePromise ?? options.bitcoinFeeRate ?? this._btcRpc.getFeeRate())
.then(x => this._options.maxBtcFeeOffset + (x * this._options.maxBtcFeeMultiplier))
.catch(e => {
abortController.abort(e);
return undefined;
});
return {
bitcoinFeeRatePromise,
maxBitcoinFeeRatePromise
};
}
/**
* Returns a newly created Bitcoin -> Smart chain swap using the SPV vault (UTXO-controlled vault) swap protocol,
* with the passed amount. Also allows specifying additional "gas drop" native token that the receipient receives
* on the destination chain in the `options` argument.
*
* @param recipient Recipient address on the destination smart chain
* @param amountData Amount, token and exact input/output data for to swap
* @param lps An array of intermediaries (LPs) to get the quotes from
* @param options Optional additional quote options
* @param additionalParams Optional additional parameters sent to the LP when creating the swap
* @param abortSignal Abort signal
*/
create(recipient, amountData, lps, options, additionalParams, abortSignal) {
const _options = {
gasAmount: this.parseGasAmount(options?.gasAmount),
unsafeZeroWatchtowerFee: options?.unsafeZeroWatchtowerFee ?? false,
feeSafetyFactor: options?.feeSafetyFactor ?? 1.25,
maxAllowedBitcoinFeeRate: options?.maxAllowedBitcoinFeeRate ?? options?.maxAllowedNetworkFeeRate ?? Infinity,
sourceWalletUtxos: options?.sourceWalletUtxos == undefined
? undefined
: options?.sourceWalletUtxos instanceof Promise ? options.sourceWalletUtxos : Promise.resolve(options.sourceWalletUtxos),
bitcoinFeeRate: options?.bitcoinFeeRate == undefined
? undefined
: options?.bitcoinFeeRate instanceof Promise ? options.bitcoinFeeRate : Promise.resolve(options.bitcoinFeeRate),
};
if (_options.gasAmount !== 0n &&
(this._chain.shouldGetNativeTokenDrop != null
? !this._chain.shouldGetNativeTokenDrop(amountData.token)
: amountData.token === this._chain.getNativeCurrencyAddress()))
throw new UserError_1.UserError("Cannot specify `gasAmount` for swaps to a native token!");
if (amountData.amount == null && options?.sourceWalletUtxos == null)
throw new UserError_1.UserError("Source wallet UTXOs need to be passed when amount is null!");
if (amountData.amount == null && !amountData.exactIn)
throw new UserError_1.UserError("Amount can be null only for exactIn swaps!");
if (amountData.amount != null && options?.sourceWalletUtxos != null)
throw new UserError_1.UserError("Source wallet UTXOs cannot be passed while specifying an input amount!");
const lpVersions = Intermediary_1.Intermediary.getContractVersionsForLps(this.chainIdentifier, lps);
const _abortController = (0, Utils_1.extendAbortController)(abortSignal);
const pricePrefetchPromise = this.preFetchPrice(amountData, _abortController.signal);
const usdPricePrefetchPromise = this.preFetchUsdPrice(_abortController.signal);
const finalizedBlockHeightPrefetchPromise = this.preFetchFinalizedBlockHeight(_abortController);
const nativeTokenAddress = this._chain.getNativeCurrencyAddress();
const gasTokenPricePrefetchPromise = _options.gasAmount === 0n ?
undefined :
this.preFetchPrice({ token: nativeTokenAddress }, _abortController.signal);
const callerFeePrefetchPromise = (0, Utils_1.mapArrayToObject)(lpVersions, (contractVersion) => {
return this.preFetchCallerFeeInNativeToken(amountData, _options, _abortController, contractVersion);
});
const { maxBitcoinFeeRatePromise, bitcoinFeeRatePromise } = this.bitcoinFeeRatePrefetch(_options, _abortController);
const amountPromise = this.amountPrefetch(amountData, maxBitcoinFeeRatePromise, _options.sourceWalletUtxos, _options.gasAmount !== 0n, _abortController);
return lps.map(lp => {
return {
intermediary: lp,
quote: (0, RetryUtils_1.tryWithRetries)(async () => {
if (lp.services[SwapType_1.SwapType.SPV_VAULT_FROM_BTC] == null)
throw new Error("LP service for processing spv vault swaps not found!");
const version = lp.getContractVersion(this.chainIdentifier);
const abortController = (0, Utils_1.extendAbortController)(_abortController.signal);
const callerFeeRatePromise = this.computeCallerFeeShare(amountPromise, callerFeePrefetchPromise[version], amountData, _options, pricePrefetchPromise, gasTokenPricePrefetchPromise, abortController.signal);
try {
const resp = await (0, RetryUtils_1.tryWithRetries)(async (retryCount) => {
return await this._lpApi.prepareSpvFromBTC(this.chainIdentifier, lp.url, {
address: recipient,
amount: (0, Utils_1.throwIfUndefined)(amountPromise, "Failed to compute swap amount"),
token: amountData.token.toString(),
exactOut: !amountData.exactIn,
gasToken: nativeTokenAddress,
gasAmount: _options.gasAmount,
callerFeeRate: (0, Utils_1.throwIfUndefined)(callerFeeRatePromise, "Caller fee prefetch failed!"),
frontingFeeRate: 0n,
stickyAddress: options?.stickyAddress,
amountUtxos: _options.sourceWalletUtxos != null
? _options.sourceWalletUtxos.then(utxos => {
if (utxos.length === 0)
return undefined;
return utxos.map(utxo => ({
value: utxo.value,
vSize: utils_2.utils.inputBytes({ type: utxo.type }),
cpfp: utxo.cpfp == null ? undefined : { effectiveVSize: utxo.cpfp?.txVsize, effectiveFeeRate: utxo.cpfp?.txEffectiveFeeRate }
}));
})
: undefined,
amountFeeRate: bitcoinFeeRatePromise,
additionalParams
}, this._options.postRequestTimeout, abortController.signal, retryCount > 0 ? false : undefined);
}, undefined, e => e instanceof RequestError_1.RequestError, abortController.signal);
this.logger.debug("create(" + lp.url + "): LP response: ", resp);
const callerFeeShare = await callerFeeRatePromise;
const amount = await (0, Utils_1.throwIfUndefined)(amountPromise);
const [pricingInfo, gasPricingInfo, { vault, vaultUtxoValue }] = await Promise.all([
this.verifyReturnedPrice(lp.services[SwapType_1.SwapType.SPV_VAULT_FROM_BTC], false, resp.btcAmountSwap, resp.total * (100000n + callerFeeShare) / 100000n, amountData.token, { swapFeeBtc: resp.swapFeeBtc }, pricePrefetchPromise, usdPricePrefetchPromise, abortController.signal),
_options.gasAmount === 0n ? Promise.resolve(undefined) : this.verifyReturnedPrice({ ...lp.services[SwapType_1.SwapType.SPV_VAULT_FROM_BTC], swapBaseFee: 0 }, //Base fee should be charged only on the amount, not on gas
false, resp.btcAmountGas, resp.totalGas * (100000n + callerFeeShare) / 100000n, nativeTokenAddress, { swapFeeBtc: resp.gasSwapFeeBtc }, gasTokenPricePrefetchPromise, usdPricePrefetchPromise, abortController.signal),
this.verifyReturnedData(resp, { ...amountData, amount }, lp, _options, callerFeeShare, maxBitcoinFeeRatePromise, bitcoinFeeRatePromise, abortController.signal)
]);
let minimumBtcFeeRate = resp.btcFeeRate;
if (bitcoinFeeRatePromise != null)
minimumBtcFeeRate = Math.max(minimumBtcFeeRate, await (0, Utils_1.throwIfUndefined)(bitcoinFeeRatePromise));
const swapInit = {
pricingInfo,
url: lp.url,
expiry: resp.expiry * 1000,
swapFee: resp.swapFee,
swapFeeBtc: resp.swapFeeBtc,
exactIn: amountData.exactIn ?? true,
quoteId: resp.quoteId,
recipient,
vaultOwner: resp.address,
vaultId: resp.vaultId,
vaultRequiredConfirmations: vault.getConfirmations(),
vaultTokenMultipliers: vault.getTokenData().map(val => val.multiplier),
vaultBtcAddress: resp.vaultBtcAddress,
vaultUtxo: resp.btcUtxo,
vaultUtxoValue: BigInt(vaultUtxoValue),
btcDestinationAddress: resp.btcAddress,
btcAmount: resp.btcAmount,
btcAmountSwap: resp.btcAmountSwap,
btcAmountGas: resp.btcAmountGas,
minimumBtcFeeRate,
outputTotalSwap: resp.total,
outputSwapToken: amountData.token,
outputTotalGas: resp.totalGas,
outputGasToken: nativeTokenAddress,
gasSwapFeeBtc: resp.gasSwapFeeBtc,
gasSwapFee: resp.gasSwapFee,
gasPricingInfo,
callerFeeShare: resp.callerFeeShare,
frontingFeeShare: resp.frontingFeeShare,
executionFeeShare: resp.executionFeeShare,
genesisSmartChainBlockHeight: await (0, Utils_1.throwIfUndefined)(finalizedBlockHeightPrefetchPromise, "Network finalized blockheight pre-fetch failed!"),
contractVersion: version
};
const quote = new SpvFromBTCSwap_1.SpvFromBTCSwap(this, swapInit);
return quote;
}
catch (e) {
if (e instanceof RequestError_1.OutOfBoundsError) {
const amountResult = await amountPromise.catch(() => undefined);
if (_options.sourceWalletUtxos != null && amountResult != null && amountResult <= 0n) {
e = new UserError_1.UserError("Wallet doesn't have enough BTC balance to cover transaction fees");
}
}
abortController.abort(e);
throw e;
}
}, undefined, err => !(err instanceof IntermediaryError_1.IntermediaryError && err.recoverable), _abortController.signal)
};
});
}
/**
* Recovers an SPV vault (UTXO-controlled vault) based swap from smart chain on-chain data
*
* @param state State of the spv vault withdrawal recovered from on-chain data
* @param vault SPV vault processing the swap
* @param lp Intermediary (LP) used as a counterparty for the swap
*/
async recoverFromState(state, contractVersion, vault, lp) {
//Get the vault
vault ??= await this._contract(contractVersion).getVaultData(state.owner, state.vaultId);
if (vault == null)
return null;
if (state.btcTxId == null)
return null;
const btcTx = await this._btcRpc.getTransaction(state.btcTxId);
if (btcTx == null)
return null;
const withdrawalData = await this._contract(contractVersion).getWithdrawalData(btcTx)
.catch(e => {
this.logger.warn(`Error parsing withdrawal data for tx ${btcTx.txid}: `, e);
return null;
});
if (withdrawalData == null)
return null;
const vaultTokens = vault.getTokenData();
const withdrawalDataOutputs = withdrawalData.getTotalOutput();
const txBlock = await state.getTxBlock?.();
const swapInit = {
pricingInfo: {
isValid: true,
satsBaseFee: 0n,
swapPriceUSatPerToken: 100000000000000n,
realPriceUSatPerToken: 100000000000000n,
differencePPM: 0n,
feePPM: 0n,
},
url: lp?.url,
expiry: 0,
swapFee: 0n,
swapFeeBtc: 0n,
exactIn: true,
//Use bitcoin tx id as quote id, even though this is not strictly correct as this
// is an off-chain identifier presented by the LP that cannot be recovered from on-chain
// data
quoteId: btcTx.txid,
recipient: state.recipient,
vaultOwner: state.owner,
vaultId: state.vaultId,
vaultRequiredConfirmations: vault.getConfirmations(),
vaultTokenMultipliers: vault.getTokenData().map(val => val.multiplier),
vaultBtcAddress: (0, BitcoinUtils_1.fromOutputScript)(this._options.bitcoinNetwork, withdrawalData.getNewVaultScript().toString("hex")),
vaultUtxo: withdrawalData.getSpentVaultUtxo(),
vaultUtxoValue: BigInt(withdrawalData.getNewVaultBtcAmount()),
btcDestinationAddress: (0, BitcoinUtils_1.fromOutputScript)(this._options.bitcoinNetwork, btcTx.outs[2].scriptPubKey.hex),
btcAmount: BigInt(btcTx.outs[2].value),
btcAmountSwap: BigInt(btcTx.outs[2].value),
btcAmountGas: 0n,
minimumBtcFeeRate: 0,
outputTotalSwap: withdrawalDataOutputs[0] * vaultTokens[0].multiplier,
outputSwapToken: vaultTokens[0].token,
outputTotalGas: withdrawalDataOutputs[1] * vaultTokens[1].multiplier,
outputGasToken: vaultTokens[1].token,
gasSwapFeeBtc: 0n,
gasSwapFee: 0n,
gasPricingInfo: {
isValid: true,
satsBaseFee: 0n,
swapPriceUSatPerToken: 100000000000000n,
realPriceUSatPerToken: 100000000000000n,
differencePPM: 0n,
feePPM: 0n,
},
callerFeeShare: withdrawalData.callerFeeRate,
frontingFeeShare: withdrawalData.frontingFeeRate,
executionFeeShare: withdrawalData.executionFeeRate,
genesisSmartChainBlockHeight: txBlock?.blockHeight ?? 0,
contractVersion
};
const quote = new SpvFromBTCSwap_1.SpvFromBTCSwap(this, swapInit);
quote._data = withdrawalData;
if (txBlock != null) {
quote.createdAt = txBlock.blockTime * 1000;
}
else if (btcTx.blockhash == null) {
quote.createdAt = Date.now();
}
else {
const blockHeader = await this._btcRpc.getBlockHeader(btcTx.blockhash);
quote.createdAt = blockHeader == null ? Date.now() : blockHeader.getTimestamp() * 1000;
}
quote._setInitiated();
if (btcTx.inputAddresses != null)
quote._senderAddress = btcTx.inputAddresses[1];
if (state.type === base_1.SpvWithdrawalStateType.FRONTED) {
quote._frontTxId = state.txId;
quote._state = SpvFromBTCSwap_1.SpvFromBTCSwapState.FRONTED;
}
else {
quote._claimTxId = state.txId;
quote._state = SpvFromBTCSwap_1.SpvFromBTCSwapState.CLAIMED;
}
await quote._save();
return quote;
}
/**
* Returns a random dummy PSBT that can be used for fee estimation, the last output (the LP output) is omitted
* to allow for coinselection algorithm to determine maximum sendable amount there
*
* @param includeGasToken Whether to return the PSBT also with the gas token amount (increases the vSize by 8)
*/
getDummySwapPsbt(includeGasToken = false) {
//Construct dummy swap psbt
const psbt = new btc_signer_1.Transaction({
allowUnknownInputs: true,
allowLegacyWitnessUtxo: true,
allowUnknownOutputs: true
});
const randomVaultOutScript = (0, BitcoinUtils_1.getDummyOutputScript)(exports.REQUIRED_SPV_SWAP_VAULT_ADDRESS_TYPE);
psbt.addInput({
txid: (0, Utils_1.randomBytes)(32),
index: 0,
witnessUtxo: {
script: randomVaultOutScript,
amount: 600n
}
});
psbt.addOutput({
script: randomVaultOutScript,
amount: 600n
});
let longestOpReturnData = undefined;
for (let contractVersion in this.versionedContracts) {
if (this.versionedContracts[contractVersion].spvVaultContract == null)
continue;
const opReturnData = this._contract(contractVersion).toOpReturnData(this._chain.randomAddress(), includeGasToken ? [0xffffffffffffffffn, 0xffffffffffffffffn] : [0xffffffffffffffffn]);
if (longestOpReturnData == null || longestOpReturnData.length < opReturnData.length)
longestOpReturnData = opReturnData;
}
if (longestOpReturnData == null)
throw new Error(`No contract version supporting the Spv Vault BTC -> ${this.chainIdentifier} swaps found!`);
psbt.addOutput({
script: Buffer.concat([
longestOpReturnData.length <= 75 ? Buffer.from([0x6a, longestOpReturnData.length]) : Buffer.from([0x6a, 0x4c, longestOpReturnData.length]),
longestOpReturnData
]),
amount: 0n
});
return psbt;
}
/**
* @inheritDoc
* @internal
*/
async _checkPastSwaps(pastSwaps) {
const changedSwaps = new Set();
const removeSwaps = [];
const broadcastedOrConfirmedSwaps = {};
for (let pastSwap of pastSwaps) {
let changed = false;
if (pastSwap._state === SpvFromBTCSwap_1.SpvFromBTCSwapState.SIGNED ||
pastSwap._state === SpvFromBTCSwap_1.SpvFromBTCSwapState.POSTED ||
pastSwap._state === SpvFromBTCSwap_1.SpvFromBTCSwapState.BROADCASTED ||
pastSwap._state === SpvFromBTCSwap_1.SpvFromBTCSwapState.QUOTE_SOFT_EXPIRED ||
pastSwap._state === SpvFromBTCSwap_1.SpvFromBTCSwapState.DECLINED ||
pastSwap._state === SpvFromBTCSwap_1.SpvFromBTCSwapState.BTC_TX_CONFIRMED) {
//Check BTC transaction
if (await pastSwap._syncStateFromBitcoin(false))
changed ||= true;
}
if (pastSwap._state === SpvFromBTCSwap_1.SpvFromBTCSwapState.CREATED ||
pastSwap._state === SpvFromBTCSwap_1.SpvFromBTCSwapState.SIGNED ||
pastSwap._state === SpvFromBTCSwap_1.SpvFromBTCSwapState.POSTED) {
if (await pastSwap._verifyQuoteDefinitelyExpired()) {
if (pastSwap._state === SpvFromBTCSwap_1.SpvFromBTCSwapState.CREATED) {
pastSwap._state = SpvFromBTCSwap_1.SpvFromBTCSwapState.QUOTE_EXPIRED;
}
else {
pastSwap._state = SpvFromBTCSwap_1.SpvFromBTCSwapState.QUOTE_SOFT_EXPIRED;