@atomiqlabs/sdk-lib
Version:
Basic SDK functionality library for atomiq
1,155 lines (1,026 loc) • 53.4 kB
text/typescript
import {isISwapInit, ISwap, ISwapInit, ppmToPercentage} from "../ISwap";
import {
ChainType, isAbstractSigner,
SpvWithdrawalClaimedState,
SpvWithdrawalClosedState,
SpvWithdrawalFrontedState,
SpvWithdrawalState,
SpvWithdrawalStateType
} from "@atomiqlabs/base";
import {SwapType} from "../enums/SwapType";
import {SpvFromBTCWrapper} from "./SpvFromBTCWrapper";
import {
extendAbortController,
getLogger,
timeoutPromise
} from "../../utils/Utils";
import {
toCoinselectAddressType,
toOutputScript
} from "../../utils/BitcoinUtils";
import {
parsePsbtTransaction,toBitcoinWallet
} from "../../utils/BitcoinHelpers";
import {getInputType, Transaction} from "@scure/btc-signer";
import {BitcoinTokens, BtcToken, SCToken, TokenAmount, toTokenAmount} from "../../Tokens";
import {Buffer} from "buffer";
import {Fee, FeeType} from "../fee/Fee";
import {IBitcoinWallet, isIBitcoinWallet} from "../../btc/wallet/IBitcoinWallet";
import {IntermediaryAPI} from "../../intermediaries/IntermediaryAPI";
import {IBTCWalletSwap} from "../IBTCWalletSwap";
import {ISwapWithGasDrop} from "../ISwapWithGasDrop";
import {
MinimalBitcoinWalletInterface,
MinimalBitcoinWalletInterfaceWithSigner
} from "../../btc/wallet/MinimalBitcoinWalletInterface";
import {IClaimableSwap} from "../IClaimableSwap";
export enum SpvFromBTCSwapState {
CLOSED = -5,
FAILED = -4, //Btc tx double-spent, or btc tx inputs double-spent
DECLINED = -3,
QUOTE_EXPIRED = -2,
QUOTE_SOFT_EXPIRED = -1,
CREATED = 0, //Swap data received from the LP
SIGNED = 1, //Swap bitcoin tx funded and signed by the client
POSTED = 2, //Swap bitcoin tx posted to the LP
BROADCASTED = 3, //LP broadcasted the posted tx
FRONTED = 4, //Payout on SC was fronted
BTC_TX_CONFIRMED = 5, //Bitcoin transaction confirmed
CLAIMED = 6 //Funds claimed
}
export type SpvFromBTCSwapInit = ISwapInit & {
quoteId: string;
recipient: string;
vaultOwner: string;
vaultId: bigint;
vaultRequiredConfirmations: number;
vaultTokenMultipliers: bigint[];
vaultBtcAddress: string;
vaultUtxo: string;
vaultUtxoValue: bigint;
btcDestinationAddress: string;
btcAmount: bigint;
btcAmountSwap: bigint;
btcAmountGas: bigint;
minimumBtcFeeRate: number;
outputTotalSwap: bigint;
outputSwapToken: string;
outputTotalGas: bigint;
outputGasToken: string;
gasSwapFeeBtc: bigint;
gasSwapFee: bigint;
callerFeeShare: bigint;
frontingFeeShare: bigint;
executionFeeShare: bigint;
genesisSmartChainBlockHeight: number;
};
export function isSpvFromBTCSwapInit(obj: any): obj is SpvFromBTCSwapInit {
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: boolean, curr: any) => 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" &&
isISwapInit(obj);
}
export class SpvFromBTCSwap<T extends ChainType>
extends ISwap<T, SpvFromBTCSwapState>
implements IBTCWalletSwap, ISwapWithGasDrop<T>, IClaimableSwap<T, SpvFromBTCSwapState> {
readonly TYPE = SwapType.SPV_VAULT_FROM_BTC;
readonly wrapper: SpvFromBTCWrapper<T>;
readonly quoteId: string;
readonly recipient: string;
readonly vaultOwner: string;
readonly vaultId: bigint;
readonly vaultRequiredConfirmations: number;
readonly vaultTokenMultipliers: bigint[];
readonly vaultBtcAddress: string;
readonly vaultUtxo: string;
readonly vaultUtxoValue: bigint;
readonly btcDestinationAddress: string;
readonly btcAmount: bigint;
readonly btcAmountSwap: bigint;
readonly btcAmountGas: bigint;
readonly minimumBtcFeeRate: number;
readonly outputTotalSwap: bigint;
readonly outputSwapToken: string;
readonly outputTotalGas: bigint;
readonly outputGasToken: string;
readonly gasSwapFeeBtc: bigint;
readonly gasSwapFee: bigint;
readonly callerFeeShare: bigint;
readonly frontingFeeShare: bigint;
readonly executionFeeShare: bigint;
readonly genesisSmartChainBlockHeight: number;
claimTxId: string;
frontTxId: string;
data: T["SpvVaultWithdrawalData"];
constructor(wrapper: SpvFromBTCWrapper<T>, init: SpvFromBTCSwapInit);
constructor(wrapper: SpvFromBTCWrapper<T>, obj: any);
constructor(wrapper: SpvFromBTCWrapper<T>, initOrObject: SpvFromBTCSwapInit | any) {
if(isSpvFromBTCSwapInit(initOrObject)) initOrObject.url += "/frombtc_spv";
super(wrapper, initOrObject);
if(isSpvFromBTCSwapInit(initOrObject)) {
this.state = SpvFromBTCSwapState.CREATED;
const vaultAddressType = toCoinselectAddressType(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 = getLogger("SPVFromBTC("+this.getId()+"): ");
}
protected upgradeVersion() { /*NOOP*/ }
/**
* In case swapFee in BTC is not supplied it recalculates it based on swap price
* @protected
*/
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(): Promise<void> {
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(): string {
return this.recipient;
}
_getEscrowHash(): string {
return this.data?.btcTx?.txid;
}
getId(): string {
return this.quoteId+this.randomNonce;
}
getQuoteExpiry(): number {
return this.expiry - 20*1000;
}
verifyQuoteValid(): Promise<boolean> {
return Promise.resolve(this.expiry>Date.now() && (this.state===SpvFromBTCSwapState.CREATED || this.state===SpvFromBTCSwapState.QUOTE_SOFT_EXPIRED));
}
getOutputAddress(): string | null {
return this.recipient;
}
getOutputTxId(): string | null {
return this.frontTxId ?? this.claimTxId;
}
getInputTxId(): string | null {
return this.data?.btcTx?.txid;
}
requiresAction(): boolean {
return this.state===SpvFromBTCSwapState.BTC_TX_CONFIRMED;
}
isFinished(): boolean {
return this.state===SpvFromBTCSwapState.CLAIMED || this.state===SpvFromBTCSwapState.QUOTE_EXPIRED || this.state===SpvFromBTCSwapState.FAILED;
}
isClaimable(): boolean {
return this.state===SpvFromBTCSwapState.BTC_TX_CONFIRMED;
}
isSuccessful(): boolean {
return this.state===SpvFromBTCSwapState.FRONTED || this.state===SpvFromBTCSwapState.CLAIMED;
}
isFailed(): boolean {
return this.state===SpvFromBTCSwapState.FAILED || this.state===SpvFromBTCSwapState.DECLINED || this.state===SpvFromBTCSwapState.CLOSED;
}
isQuoteExpired(): boolean {
return this.state===SpvFromBTCSwapState.QUOTE_EXPIRED;
}
isQuoteSoftExpired(): boolean {
return this.state===SpvFromBTCSwapState.QUOTE_EXPIRED || this.state===SpvFromBTCSwapState.QUOTE_SOFT_EXPIRED;
}
//////////////////////////////
//// Amounts & fees
protected getInputSwapAmountWithoutFee(): bigint {
return (this.btcAmountSwap - this.swapFeeBtc) * 100_000n / (100_000n + this.callerFeeShare + this.frontingFeeShare + this.executionFeeShare);
}
protected getInputGasAmountWithoutFee(): bigint {
return (this.btcAmountGas - this.gasSwapFeeBtc) * 100_000n / (100_000n + this.callerFeeShare + this.frontingFeeShare);
}
protected getInputAmountWithoutFee(): bigint {
return this.getInputSwapAmountWithoutFee() + this.getInputGasAmountWithoutFee();
}
protected getOutputWithoutFee(): TokenAmount<T["ChainId"], SCToken<T["ChainId"]>> {
return toTokenAmount(
(this.outputTotalSwap * (100_000n + this.callerFeeShare + this.frontingFeeShare + this.executionFeeShare) / 100_000n) + this.swapFee,
this.wrapper.tokens[this.outputSwapToken], this.wrapper.prices
);
}
protected getSwapFee(): Fee<T["ChainId"], BtcToken<false>, SCToken<T["ChainId"]>> {
const outputToken = this.wrapper.tokens[this.outputSwapToken];
const gasSwapFeeInOutputToken = this.gasSwapFeeBtc
* (10n ** BigInt(outputToken.decimals))
* 1_000_000n
/ this.pricingInfo.swapPriceUSatPerToken;
const feeWithoutBaseFee = this.swapFeeBtc - this.pricingInfo.satsBaseFee;
const swapFeePPM = feeWithoutBaseFee * 1000000n / (this.btcAmount - this.swapFeeBtc - this.gasSwapFeeBtc);
return {
amountInSrcToken: toTokenAmount(this.swapFeeBtc + this.gasSwapFeeBtc, BitcoinTokens.BTC, this.wrapper.prices),
amountInDstToken: toTokenAmount(this.swapFee + gasSwapFeeInOutputToken, outputToken, this.wrapper.prices),
usdValue: (abortSignal?: AbortSignal, preFetchedUsdPrice?: number) =>
this.wrapper.prices.getBtcUsdValue(this.swapFeeBtc + this.gasSwapFeeBtc, abortSignal, preFetchedUsdPrice),
composition: {
base: toTokenAmount(this.pricingInfo.satsBaseFee, BitcoinTokens.BTC, this.wrapper.prices),
percentage: ppmToPercentage(swapFeePPM)
}
};
}
protected getWatchtowerFee(): Fee<T["ChainId"], BtcToken<false>, SCToken<T["ChainId"]>> {
const totalFeeShare = this.callerFeeShare + this.frontingFeeShare;
const outputToken = this.wrapper.tokens[this.outputSwapToken];
const watchtowerFeeInOutputToken = this.getInputGasAmountWithoutFee() * totalFeeShare
* (10n ** BigInt(outputToken.decimals))
* 1_000_000n
/ this.pricingInfo.swapPriceUSatPerToken
/ 100_000n;
const feeBtc = this.getInputAmountWithoutFee() * (totalFeeShare + this.executionFeeShare) / 100_000n;
return {
amountInSrcToken: toTokenAmount(feeBtc, BitcoinTokens.BTC, this.wrapper.prices),
amountInDstToken: toTokenAmount((this.outputTotalSwap * (totalFeeShare + this.executionFeeShare) / 100_000n) + watchtowerFeeInOutputToken, outputToken, this.wrapper.prices),
usdValue: (abortSignal?: AbortSignal, preFetchedUsdPrice?: number) =>
this.wrapper.prices.getBtcUsdValue(feeBtc, abortSignal, preFetchedUsdPrice)
};
}
getFee(): Fee<T["ChainId"], BtcToken<false>, SCToken<T["ChainId"]>> {
const swapFee = this.getSwapFee();
const watchtowerFee = this.getWatchtowerFee();
return {
amountInSrcToken: toTokenAmount(swapFee.amountInSrcToken.rawAmount + watchtowerFee.amountInSrcToken.rawAmount, BitcoinTokens.BTC, this.wrapper.prices),
amountInDstToken: toTokenAmount(swapFee.amountInDstToken.rawAmount + watchtowerFee.amountInDstToken.rawAmount, this.wrapper.tokens[this.outputSwapToken], this.wrapper.prices),
usdValue: (abortSignal?: AbortSignal, preFetchedUsdPrice?: number) =>
this.wrapper.prices.getBtcUsdValue(swapFee.amountInSrcToken.rawAmount + watchtowerFee.amountInSrcToken.rawAmount, abortSignal, preFetchedUsdPrice)
};
}
getFeeBreakdown(): [
{type: FeeType.SWAP, fee: Fee<T["ChainId"], BtcToken<false>, SCToken<T["ChainId"]>>},
{type: FeeType.NETWORK_OUTPUT, fee: Fee<T["ChainId"], BtcToken<false>, SCToken<T["ChainId"]>>}
] {
return [
{
type: FeeType.SWAP,
fee: this.getSwapFee()
},
{
type: FeeType.NETWORK_OUTPUT,
fee: this.getWatchtowerFee()
}
];
}
getOutput(): TokenAmount<T["ChainId"], SCToken<T["ChainId"]>> {
return toTokenAmount(this.outputTotalSwap, this.wrapper.tokens[this.outputSwapToken], this.wrapper.prices);
}
getGasDropOutput(): TokenAmount<T["ChainId"], SCToken<T["ChainId"]>> {
return toTokenAmount(this.outputTotalGas, this.wrapper.tokens[this.outputGasToken], this.wrapper.prices);
}
getInputWithoutFee(): TokenAmount<T["ChainId"], BtcToken<false>> {
return toTokenAmount(this.getInputAmountWithoutFee(), BitcoinTokens.BTC, this.wrapper.prices);
}
getInput(): TokenAmount<T["ChainId"], BtcToken<false>> {
return toTokenAmount(this.btcAmount, BitcoinTokens.BTC, this.wrapper.prices);
}
//////////////////////////////
//// Bitcoin tx
getRequiredConfirmationsCount(): number {
return this.vaultRequiredConfirmations;
}
async getTransactionDetails(): Promise<{
in0txid: string,
in0vout: number,
in0sequence: number,
vaultAmount: bigint,
vaultScript: Uint8Array,
in1sequence: number,
out1script: Uint8Array,
out2amount: bigint,
out2script: Uint8Array,
locktime: number
}> {
const [txId, voutStr] = this.vaultUtxo.split(":");
const vaultScript = toOutputScript(this.wrapper.options.bitcoinNetwork, this.vaultBtcAddress);
const out2script = 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.concat([
opReturnData.length > 75 ? Buffer.from([0x6a, 0x4c, opReturnData.length]) : 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 & 0b1111_1111_1100_0000_0000n) << 10n;
const nSequence1 = 0x80000000n | (this.executionFeeShare & 0xFFFFFn) | (this.frontingFeeShare & 0b0000_0000_0011_1111_1111n) << 20n;
return {
in0txid: txId,
in0vout: parseInt(voutStr),
in0sequence: Number(nSequence0),
vaultAmount: this.vaultUtxoValue,
vaultScript,
in1sequence: Number(nSequence1),
out1script,
out2amount: this.btcAmount,
out2script,
locktime: 500_000_000 + Math.floor(Math.random() * 1_000_000_000) //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(): Promise<{
psbt: Transaction,
psbtHex: string,
psbtBase64: string,
in1sequence: number
}> {
const res = await this.getTransactionDetails();
const psbt = new 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.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: IBitcoinWallet | MinimalBitcoinWalletInterface,
feeRate?: number,
additionalOutputs?: ({amount: bigint, outputScript: Uint8Array} | {amount: bigint, address: string})[]
): Promise<{
psbt: Transaction,
psbtHex: string,
psbtBase64: string,
signInputs: number[]
}> {
const bitcoinWallet: IBitcoinWallet = 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 as {outputScript: Uint8Array}).outputScript ?? toOutputScript(this.wrapper.options.bitcoinNetwork, (output as {address: string}).address)
});
});
psbt = await bitcoinWallet.fundPsbt(psbt, feeRate);
psbt.updateInput(1, {sequence: in1sequence});
//Sign every input except the first one
const signInputs: number[] = [];
for(let i=1;i<psbt.inputsLength;i++) {
signInputs.push(i);
}
const serializedPsbt = 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: Transaction | string): Promise<string> {
const psbt = 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(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.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(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 ||
!toOutputScript(this.wrapper.options.bitcoinNetwork, this.btcDestinationAddress).equals(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.initSpvFromBTC(
this.chainIdentifier,
this.url,
{
quoteId: this.quoteId,
psbtHex: 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: IBitcoinWallet | MinimalBitcoinWalletInterface, feeRate?: number): Promise<TokenAmount<any, BtcToken<false>>> {
const bitcoinWallet: IBitcoinWallet = toBitcoinWallet(_bitcoinWallet, this.wrapper.btcRpc, this.wrapper.options.bitcoinNetwork);
const txFee = await bitcoinWallet.getFundedPsbtFee((await this.getPsbt()).psbt, feeRate);
return toTokenAmount(txFee==null ? null : BigInt(txFee), BitcoinTokens.BTC, this.wrapper.prices);
}
async sendBitcoinTransaction(wallet: IBitcoinWallet | MinimalBitcoinWalletInterfaceWithSigner, feeRate?: number): Promise<string> {
const {psbt, psbtBase64, psbtHex, signInputs} = await this.getFundedPsbt(wallet, feeRate);
let signedPsbt: Transaction | string;
if(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: IBitcoinWallet | MinimalBitcoinWalletInterfaceWithSigner,
callbacks?: {
onSourceTransactionSent?: (sourceTxId: string) => void,
onSourceTransactionConfirmationStatus?: (sourceTxId: string, confirmations: number, targetConfirations: number, etaMs: number) => void,
onSourceTransactionConfirmed?: (sourceTxId: string) => void,
onSwapSettled?: (destinationTxId: string) => void
},
options?: {
feeRate?: number,
abortSignal?: AbortSignal,
btcTxCheckIntervalSeconds?: number,
maxWaitTillAutomaticSettlementSeconds?: number
}
): Promise<boolean> {
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.
*/
protected async getBitcoinPayment(): Promise<{
txId: string,
confirmations: number,
targetConfirmations: number
} | null> {
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?: (txId: string, confirmations: number, targetConfirmations: number, txEtaMs: number) => void,
checkIntervalSeconds?: number,
abortSignal?: AbortSignal
): Promise<string> {
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: number, txId: string, txEtaMs: number) => {
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 as SpvFromBTCSwapState)!==SpvFromBTCSwapState.FRONTED &&
(this.state as SpvFromBTCSwapState)!==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?: string | T["Signer"] | T["NativeSigner"]): Promise<T["TX"][]> {
let address: string;
if(_signer!=null) {
if (typeof (_signer) === "string") {
address = _signer;
} else if (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: T["SpvVaultWithdrawalData"][] = [];
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: T["Signer"] | T["NativeSigner"], abortSignal?: AbortSignal): Promise<string> {
const signer = isAbstractSigner(_signer) ? _signer : await this.wrapper.chain.wrapSigner(_signer);
let txIds: string[];
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===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
*/
protected async watchdogWaitTillResult(abortSignal?: AbortSignal, interval: number = 5): Promise<
SpvWithdrawalClaimedState | SpvWithdrawalFrontedState | SpvWithdrawalClosedState
> {
let status: SpvWithdrawalState = {type: SpvWithdrawalStateType.NOT_FOUND};
while(status.type===SpvWithdrawalStateType.NOT_FOUND) {
await 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?: number, abortSignal?: AbortSignal): Promise<boolean> {
if(this.state===SpvFromBTCSwapState.CLAIMED || this.state===SpvFromBTCSwapState.FRONTED) return Promise.resolve(true);
const abortController = extendAbortController(abortSignal);
let timedOut: boolean = false;
if(maxWaitTimeSeconds!=null) {
const timeout = setTimeout(() => {
timedOut = true;
abortController.abort();
}, maxWaitTimeSeconds * 1000);
abortController.signal.addEventListener("abort", () => clearTimeout(timeout));
}
let res: number | SpvWithdrawalState;
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===SpvWithdrawalStateType.FRONTED) {
if(
(this.state as SpvFromBTCSwapState)!==SpvFromBTCSwapState.FRONTED ||
(this.state as SpvFromBTCSwapState)!==SpvFromBTCSwapState.CLAIMED
) {
this.frontTxId = res.txId;
await this._saveAndEmit(SpvFromBTCSwapState.FRONTED);
}
}
if(res.type===SpvWithdrawalStateType.CLAIMED) {
if(
(this.state as SpvFromBTCSwapState)!==SpvFromBTCSwapState.CLAIMED
) {
this.claimTxId = res.txId;
await this._saveAndEmit(SpvFromBTCSwapState.FRONTED);
}
}
if(res.type===SpvWithdrawalStateType.CLOSED) {
if(
(this.state as SpvFromBTCSwapState)!==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?: (txId: string, confirmations: number, targetConfirmations: number, txEtaMs: number) => void,
checkIntervalSeconds?: number,
abortSignal?: AbortSignal
): Promise<void> {
await this.waitForBitcoinTransaction(updateCallback, checkIntervalSeconds, abortSignal);
await this.waitTillClaimedOrFronted(undefined, abortSignal);
}
//////////////////////////////
//// Storage
serialize(): any {
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: boolean) {
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
*/
private async syncStateFromChain(): Promise<boolean> {
let changed: boolean = 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 SpvWithdrawalStateType.FRONTED:
this.frontTxId = status.txId;
this.state = SpvFromBTCSwapState.FRONTED;
changed ||= true;
break;
case SpvWithdrawalStateType.CLAIMED:
this.claimTxId = status.txId;
this.state = SpvFromBTCSwapState.CLAIMED;
changed ||= true;
break;
case 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(sav