@atomiqlabs/sdk
Version:
atomiq labs SDK for cross-chain swaps between smart chains and bitcoin
1,140 lines (1,030 loc) • 78.4 kB
text/typescript
import {IFromBTCSelfInitSwap} from "../IFromBTCSelfInitSwap";
import {SwapType} from "../../../../enums/SwapType";
import {FromBTCDefinition, FromBTCWrapper} from "./FromBTCWrapper";
import {
BtcTxWithBlockheight,
ChainType,
isAbstractSigner,
SwapCommitState,
SwapCommitStateType,
SwapData
} from "@atomiqlabs/base";
import {Buffer} from "buffer";
import {
extendAbortController,
getTxoHash, toBigInt
} from "../../../../utils/Utils";
import {
fromOutputScript, getSenderAddress, getVoutIndex,
parsePsbtTransaction,
toOutputScript,
} from "../../../../utils/BitcoinUtils";
import {IBitcoinWallet, isIBitcoinWallet} from "../../../../bitcoin/wallet/IBitcoinWallet";
import {IBTCWalletSwap} from "../../../IBTCWalletSwap";
import {Transaction} from "@scure/btc-signer";
import {SingleAddressBitcoinWallet} from "../../../../bitcoin/wallet/SingleAddressBitcoinWallet";
import {
MinimalBitcoinWalletInterface,
MinimalBitcoinWalletInterfaceWithSigner
} from "../../../../types/wallets/MinimalBitcoinWalletInterface";
import {IClaimableSwap} from "../../../IClaimableSwap";
import {IEscrowSelfInitSwapInit, isIEscrowSelfInitSwapInit} from "../../IEscrowSelfInitSwap";
import {IAddressSwap} from "../../../IAddressSwap";
import {TokenAmount, toTokenAmount} from "../../../../types/TokenAmount";
import {BitcoinTokens, BtcToken, SCToken} from "../../../../types/Token";
import {getLogger, LoggerType} from "../../../../utils/Logger";
import {timeoutPromise} from "../../../../utils/TimeoutUtils";
import {toBitcoinWallet} from "../../../../utils/BitcoinWalletUtils";
import {
SwapExecutionActionSendToAddress,
SwapExecutionActionSignPSBT,
SwapExecutionActionSignSmartChainTx,
SwapExecutionActionWait
} from "../../../../types/SwapExecutionAction";
import {
SwapExecutionStepPayment,
SwapExecutionStepSettlement,
SwapExecutionStepSetup
} from "../../../../types/SwapExecutionStep";
import {SwapStateInfo} from "../../../../types/SwapStateInfo";
/**
* State enum for legacy escrow based Bitcoin -> Smart chain swaps.
*
* @category Swaps/Legacy/Bitcoin → Smart chain
*/
export enum FromBTCSwapState {
/**
* Bitcoin swap address has expired and the intermediary (LP) has already refunded
* its funds. No BTC should be sent anymore!
*/
FAILED = -4,
/**
* Bitcoin swap address has expired, user should not send any BTC anymore! Though
* the intermediary (LP) hasn't refunded yet. So if there is a transaction already
* in-flight the swap might still succeed.
*/
EXPIRED = -3,
/**
* Swap has expired for good and there is no way how it can be executed anymore
*/
QUOTE_EXPIRED = -2,
/**
* A swap is almost expired, and it should be presented to the user as expired, though
* there is still a chance that it will be processed
*/
QUOTE_SOFT_EXPIRED = -1,
/**
* Swap quote was created, use the {@link FromBTCSwap.commit} or {@link FromBTCSwap.txsCommit} functions
* to initiate it by creating the swap escrow on the destination smart chain
*/
PR_CREATED = 0,
/**
* Swap escrow was initiated (committed) on the destination chain, user can send the BTC to the
* swap address with the {@link FromBTCSwap.getFundedPsbt}, {@link FromBTCSwap.getAddress} or
* {@link FromBTCSwap.getHyperlink} functions.
*/
CLAIM_COMMITED = 1,
/**
* Input bitcoin transaction was confirmed, wait for automatic settlement by the watchtowers
* using the {@link FromBTCSwap.waitTillClaimed} function or settle manually using the {@link FromBTCSwap.claim}
* or {@link FromBTCSwap.txsClaim} function.
*/
BTC_TX_CONFIRMED = 2,
/**
* Swap successfully settled and funds received on the destination chain
*/
CLAIM_CLAIMED = 3
}
const FromBTCSwapStateDescription = {
[FromBTCSwapState.FAILED]: "Bitcoin swap address has expired and the intermediary (LP) has already refunded its funds. No BTC should be sent anymore!",
[FromBTCSwapState.EXPIRED]: "Bitcoin swap address has expired, user should not send any BTC anymore! Though the intermediary (LP) hasn't refunded yet. So if there is a transaction already in-flight the swap might still succeed.",
[FromBTCSwapState.QUOTE_EXPIRED]: "Swap has expired for good and there is no way how it can be executed anymore",
[FromBTCSwapState.QUOTE_SOFT_EXPIRED]: "The swap is expired, though there is still a chance that it will be processed",
[FromBTCSwapState.PR_CREATED]: "Swap quote was created, initiate it by creating the swap escrow on the destination smart chain",
[FromBTCSwapState.CLAIM_COMMITED]: "Swap escrow was initiated (committed) on the destination chain, user can send the BTC to the Bitcoin swap address.",
[FromBTCSwapState.BTC_TX_CONFIRMED]: "Input bitcoin transaction was confirmed, wait for automatic settlement by the watchtower or settle manually.",
[FromBTCSwapState.CLAIM_CLAIMED]: "Swap successfully settled and funds received on the destination chain"
};
export type FromBTCSwapInit<T extends SwapData> = IEscrowSelfInitSwapInit<T> & {
data: T;
address?: string;
amount?: bigint;
requiredConfirmations?: number;
};
export function isFromBTCSwapInit<T extends SwapData>(obj: any): obj is FromBTCSwapInit<T> {
return typeof(obj.data) === "object" &&
(obj.address==null || typeof(obj.address) === "string") &&
(obj.amount==null || typeof(obj.amount) === "bigint") &&
(obj.requiredConfirmations==null || typeof(obj.requiredConfirmations) === "number") &&
isIEscrowSelfInitSwapInit<T>(obj);
}
/**
* Legacy escrow (PrTLC) based swap for Bitcoin -> Smart chains, requires manual initiation
* of the swap escrow on the destination chain.
*
* @category Swaps/Legacy/Bitcoin → Smart chain
*/
export class FromBTCSwap<T extends ChainType = ChainType>
extends IFromBTCSelfInitSwap<T, FromBTCDefinition<T>, FromBTCSwapState>
implements IBTCWalletSwap, IClaimableSwap<T, FromBTCDefinition<T>, FromBTCSwapState>, IAddressSwap {
protected readonly TYPE: SwapType.FROM_BTC = SwapType.FROM_BTC;
/**
* @internal
*/
protected readonly swapStateName = (state: number) => FromBTCSwapState[state];
/**
* @internal
*/
protected readonly swapStateDescription = FromBTCSwapStateDescription;
/**
* @internal
*/
protected readonly logger: LoggerType;
/**
* @internal
*/
protected readonly inputToken: BtcToken<false> = BitcoinTokens.BTC;
/**
* @internal
*/
protected readonly feeRate!: string;
/**
* @internal
*/
readonly _data!: T["Data"];
private address?: string;
private amount?: bigint;
private requiredConfirmations?: number;
private senderAddress?: string;
private txId?: string;
private vout?: number;
private btcTxConfirmedAt?: number;
constructor(wrapper: FromBTCWrapper<T>, init: FromBTCSwapInit<T["Data"]>);
constructor(wrapper: FromBTCWrapper<T>, obj: any);
constructor(wrapper: FromBTCWrapper<T>, initOrObject: FromBTCSwapInit<T["Data"]> | any) {
if(isFromBTCSwapInit(initOrObject) && initOrObject.url!=null) initOrObject.url += "/frombtc";
super(wrapper, initOrObject);
if(isFromBTCSwapInit(initOrObject)) {
this._state = FromBTCSwapState.PR_CREATED;
this._data = initOrObject.data;
this.feeRate = initOrObject.feeRate;
this.address = initOrObject.address;
this.amount = initOrObject.amount;
this.requiredConfirmations = initOrObject.requiredConfirmations;
} else {
this.address = initOrObject.address;
this.amount = toBigInt(initOrObject.amount);
this.senderAddress = initOrObject.senderAddress;
this.txId = initOrObject.txId;
this.vout = initOrObject.vout;
this.requiredConfirmations = initOrObject.requiredConfirmations ?? this._data.getConfirmationsHint();
this.btcTxConfirmedAt = initOrObject.btcTxConfirmedAt;
}
this.tryRecomputeSwapPrice();
this.logger = getLogger("FromBTC("+this.getIdentifierHashString()+"): ");
}
/**
* @inheritDoc
* @internal
*/
protected getSwapData(): T["Data"] {
return this._data;
}
/**
* @inheritDoc
* @internal
*/
protected upgradeVersion() {
if(this.version == null) {
switch(this._state) {
case -2:
this._state = FromBTCSwapState.FAILED
break;
case -1:
this._state = FromBTCSwapState.QUOTE_EXPIRED
break;
case 0:
this._state = FromBTCSwapState.PR_CREATED
break;
case 1:
this._state = FromBTCSwapState.CLAIM_COMMITED
break;
case 2:
this._state = FromBTCSwapState.BTC_TX_CONFIRMED
break;
case 3:
this._state = FromBTCSwapState.CLAIM_CLAIMED
break;
}
this.version = 1;
}
}
//////////////////////////////
//// Getters & utils
/**
* Returns bitcoin address where the on-chain BTC should be sent to
*/
getAddress(): string {
if(this._state===FromBTCSwapState.PR_CREATED) throw new Error("Cannot get bitcoin address of non-initiated swaps! Initiate swap first with commit() or txsCommit().");
return this.address ?? "";
}
/**
* Unsafe bitcoin hyperlink getter, returns the address even before the swap is committed!
*
* @private
*/
private _getHyperlink(): string {
return this.address==null || this.amount==null ? "" : "bitcoin:"+this.address+"?amount="+encodeURIComponent((Number(this.amount) / 100000000).toString(10));
}
/**
* @inheritDoc
*/
getHyperlink(): string {
if(this._state===FromBTCSwapState.PR_CREATED) throw new Error("Cannot get bitcoin address of non-initiated swaps! Initiate swap first with commit() or txsCommit().");
return this._getHyperlink();
}
/**
* @inheritDoc
*/
getInputAddress(): string | null {
return this.senderAddress ?? null;
}
/**
* @inheritDoc
*/
getInputTxId(): string | null {
return this.txId ?? null;
}
private async _setSubmittedBitcoinTx(txId: string, psbt?: Transaction): Promise<void> {
let changed = false;
if(this.txId!==txId) {
this.txId = txId;
changed = true;
}
const submittedVout = this.address==null || this.amount==null || psbt==null
? undefined
: getVoutIndex(psbt, this.wrapper._options.bitcoinNetwork, this.address, this.amount);
if(submittedVout!=null && this.vout!==submittedVout) {
this.vout = submittedVout;
changed = true;
}
const submittedSenderAddress = psbt==null
? undefined
: getSenderAddress(psbt, this.wrapper._options.bitcoinNetwork);
if(submittedSenderAddress!=null && this.senderAddress!==submittedSenderAddress) {
this.senderAddress = submittedSenderAddress;
changed = true;
}
if(changed) await this._saveAndEmit();
}
/**
* Returns timeout time (in UNIX milliseconds) when the on-chain address will expire and no funds should be sent
* to that address anymore
*/
getTimeoutTime(): number {
return Number(this.wrapper._getOnchainSendTimeout(this._data, this.requiredConfirmations ?? 6)) * 1000;
}
/**
* @inheritDoc
*/
requiresAction(): boolean {
return this.isClaimable() || (this._state===FromBTCSwapState.CLAIM_COMMITED && this.getTimeoutTime()>Date.now() && this.txId==null);
}
/**
* @inheritDoc
*/
isFinished(): boolean {
return this._state===FromBTCSwapState.CLAIM_CLAIMED || this._state===FromBTCSwapState.QUOTE_EXPIRED || this._state===FromBTCSwapState.FAILED;
}
/**
* @inheritDoc
*/
isClaimable(): boolean {
return this._state===FromBTCSwapState.BTC_TX_CONFIRMED;
}
/**
* @inheritDoc
*/
isSuccessful(): boolean {
return this._state===FromBTCSwapState.CLAIM_CLAIMED;
}
/**
* @inheritDoc
*/
isFailed(): boolean {
return this._state===FromBTCSwapState.FAILED || this._state===FromBTCSwapState.EXPIRED;
}
/**
* @inheritDoc
*/
isInProgress(): boolean {
return this._state===FromBTCSwapState.CLAIM_COMMITED ||
this._state===FromBTCSwapState.BTC_TX_CONFIRMED;
}
/**
* @inheritDoc
*/
isQuoteExpired(): boolean {
return this._state===FromBTCSwapState.QUOTE_EXPIRED;
}
/**
* @inheritDoc
*/
isQuoteSoftExpired(): boolean {
return this._state===FromBTCSwapState.QUOTE_EXPIRED || this._state===FromBTCSwapState.QUOTE_SOFT_EXPIRED;
}
/**
* @inheritDoc
* @internal
*/
protected canCommit(skipQuoteExpiryChecks?: boolean): boolean {
if(this._state!==FromBTCSwapState.PR_CREATED && (!skipQuoteExpiryChecks || this._state!==FromBTCSwapState.QUOTE_SOFT_EXPIRED)) return false;
if(this.requiredConfirmations==null) return false;
const expiry = this.wrapper._getOnchainSendTimeout(this._data, this.requiredConfirmations);
const currentTimestamp = BigInt(Math.floor(Date.now()/1000));
return (expiry - currentTimestamp) >= this.wrapper._options.minSendWindow;
}
//////////////////////////////
//// Amounts & fees
/**
* @inheritDoc
*/
getInputToken(): BtcToken<false> {
return BitcoinTokens.BTC;
}
/**
* @inheritDoc
*/
getInput(): TokenAmount<BtcToken<false>> {
return toTokenAmount(this.amount ?? null, this.inputToken, this.wrapper._prices);
}
/**
* Returns claimer bounty, acting as a reward for watchtowers to claim the swap automatically,
* this amount is pre-funded by the user on the destination chain when the swap escrow
* is initiated. For total pre-funded deposit amount see {@link getTotalDeposit}.
*/
getClaimerBounty(): TokenAmount<SCToken<T["ChainId"]>, true> {
return toTokenAmount(this._data.getClaimerBounty(), this.wrapper._tokens[this._data.getDepositToken()], this.wrapper._prices);
}
//////////////////////////////
//// Bitcoin tx
/**
* If the required number of confirmations is not known, this function tries to infer it by looping through
* possible confirmation targets and comparing the claim hashes
*
* @param btcTx Bitcoin transaction
* @param vout Output index of the desired output in the bitcoin transaction
*
* @private
*/
private inferRequiredConfirmationsCount(btcTx: Omit<BtcTxWithBlockheight, "hex" | "raw">, vout: number): number | undefined {
const txOut = btcTx.outs[vout];
for(let i=1;i<=20;i++) {
const computedClaimHash = this._contract.getHashForOnchain(
Buffer.from(txOut.scriptPubKey.hex, "hex"),
BigInt(txOut.value),
i
);
if(computedClaimHash.toString("hex")===this._data.getClaimHash()) {
return i;
}
}
}
/**
* @inheritDoc
*/
getRequiredConfirmationsCount(): number {
return this.requiredConfirmations ?? NaN;
}
/**
* Checks whether a bitcoin payment was already made, returns the payment or `null` when no payment has been made.
*
* @internal
*/
protected async getBitcoinPayment(): Promise<{
txId: string,
vout: number,
confirmations: number,
targetConfirmations: number,
inputAddresses?: string[]
} | null> {
const txoHashHint = this._data.getTxoHashHint();
if(txoHashHint==null) throw new Error("Swap data doesn't include the txo hash hint! Cannot check bitcoin transaction!");
if(this.address==null) throw new Error("Cannot check bitcoin payment, because the address is not known! This can happen after a swap is recovered.");
const result = await this.wrapper._btcRpc.checkAddressTxos(this.address, Buffer.from(txoHashHint, "hex"));
if(result==null) return null;
if(this.requiredConfirmations==null) {
this.requiredConfirmations = this.inferRequiredConfirmationsCount(result.tx, result.vout);
}
return {
inputAddresses: result.tx.inputAddresses,
txId: result.tx.txid,
vout: result.vout,
confirmations: result.tx.confirmations ?? 0,
targetConfirmations: this.getRequiredConfirmationsCount()
}
}
/**
* Used to set the txId of the bitcoin payment from the on-chain events listener
*
* @param txId Transaction ID that settled the swap on the smart chain
*
* @internal
*/
async _setBitcoinTxId(txId: string) {
if(this.txId!==txId || this.address==null || this.vout==null || this.senderAddress==null || this.amount==null) {
const btcTx = await this.wrapper._btcRpc.getTransaction(txId);
if(btcTx==null) return;
const txoHashHint = this._data.getTxoHashHint();
if(txoHashHint!=null) {
const expectedTxoHash = Buffer.from(txoHashHint, "hex");
const vout = btcTx.outs.findIndex(out => getTxoHash(out.scriptPubKey.hex, out.value).equals(expectedTxoHash));
if(vout!==-1) {
this.vout = vout;
//If amount or address are not known, parse them from the bitcoin tx
// this can happen if the swap is recovered from on-chain data and
// hence doesn't contain the address and amount data
if(this.amount==null) this.amount = BigInt(btcTx.outs[vout].value);
if(this.address==null) try {
this.address = fromOutputScript(this.wrapper._options.bitcoinNetwork, btcTx.outs[vout].scriptPubKey.hex);
} catch (e: any) {
this.logger.warn("_setBitcoinTxId(): Failed to parse address from output script: ", e);
}
if(this.requiredConfirmations==null) {
this.requiredConfirmations = this.inferRequiredConfirmationsCount(btcTx, vout);
}
}
}
if(btcTx.inputAddresses!=null) {
this.senderAddress = btcTx.inputAddresses[0];
}
}
this.txId = txId;
}
/**
* @inheritDoc
*
* @throws {Error} if in invalid state (must be {@link FromBTCSwapState.CLAIM_COMMITED})
*/
async waitForBitcoinTransaction(
updateCallback?: (txId?: string, confirmations?: number, targetConfirmations?: number, txEtaMs?: number) => void,
checkIntervalSeconds?: number,
abortSignal?: AbortSignal
): Promise<string> {
if(this._state!==FromBTCSwapState.CLAIM_COMMITED && this._state!==FromBTCSwapState.EXPIRED) throw new Error("Must be in COMMITED state!");
const txoHashHint = this._data.getTxoHashHint();
if(txoHashHint==null) throw new Error("Swap data doesn't include the txo hash hint! Cannot check bitcoin transaction!");
if(this.address==null) throw new Error("Cannot check bitcoin payment, because the address is not known! This can happen after a swap is recovered.");
let abortedDueToEnoughConfirmationsResult: {
tx: Omit<BtcTxWithBlockheight, "hex" | "raw">, vout: number
} | undefined;
const abortController = extendAbortController(abortSignal);
const result = await this.wrapper._btcRpc.waitForAddressTxo(
this.address,
Buffer.from(txoHashHint, "hex"),
this.requiredConfirmations ?? 6, //In case confirmation count is not known, we use a conservative estimate
(btcTx?: Omit<BtcTxWithBlockheight, "hex" | "raw">, vout?: number, txEtaMs?: number) => {
let requiredConfirmations = this.requiredConfirmations;
if(btcTx!=null && vout!=null && requiredConfirmations==null) {
requiredConfirmations = this.inferRequiredConfirmationsCount(btcTx, vout);
}
if(btcTx!=null && (
btcTx.txid!==this.txId ||
this.vout==null ||
this.senderAddress==null ||
(this.requiredConfirmations==null && requiredConfirmations!=null)
)) {
this.txId = btcTx.txid;
this.vout = vout;
this.requiredConfirmations = requiredConfirmations;
if(btcTx.inputAddresses!=null) this.senderAddress = btcTx.inputAddresses[0];
this._saveAndEmit().catch(e => {
this.logger.error("waitForBitcoinTransaction(): Failed to save swap from within waitForAddressTxo callback:", e)
});
}
//Abort the loop as soon as the transaction gets enough confirmations, this is required in case
// we pass a default 6 confirmations to the fn, but then are able to infer the actual confirmation
// target from the prior block
if(btcTx?.confirmations!=null && requiredConfirmations!=null && requiredConfirmations<=btcTx.confirmations && vout!=null) {
abortedDueToEnoughConfirmationsResult = {
tx: btcTx,
vout
};
abortController.abort();
return;
}
if(updateCallback!=null) updateCallback(btcTx?.txid, btcTx==null ? undefined : (btcTx?.confirmations ?? 0), requiredConfirmations ?? NaN, txEtaMs);
},
abortController.signal,
checkIntervalSeconds
).catch(e => {
//We catch the case when the loop was aborted due to the transaction getting enough confirmations
if(abortedDueToEnoughConfirmationsResult!=null) return abortedDueToEnoughConfirmationsResult;
throw e;
});
if(abortSignal!=null) abortSignal.throwIfAborted();
this.txId = result.tx.txid;
this.vout = result.vout;
if(result.tx.inputAddresses!=null) this.senderAddress = result.tx.inputAddresses[0];
if(
(this._state as FromBTCSwapState)!==FromBTCSwapState.CLAIM_CLAIMED &&
(this._state as FromBTCSwapState)!==FromBTCSwapState.FAILED
) {
this.btcTxConfirmedAt ??= Date.now();
this._state = FromBTCSwapState.BTC_TX_CONFIRMED;
}
await this._saveAndEmit();
return result.tx.txid;
}
/**
* Private getter of the funded PSBT that doesn't check current state
*
* @param _bitcoinWallet Bitcoin wallet to fund the PSBT with
* @param feeRate Optional bitcoin fee rate in sats/vB
* @param additionalOutputs Optional additional outputs that should also be included in the generated PSBT
*
* @private
*/
private 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[], feeRate: number}> {
if(this.address==null) throw new Error("Cannot create funded PSBT, because the address is not known! This can happen after a swap is recovered.");
let bitcoinWallet: IBitcoinWallet;
if(isIBitcoinWallet(_bitcoinWallet)) {
bitcoinWallet = _bitcoinWallet;
} else {
bitcoinWallet = new SingleAddressBitcoinWallet(this.wrapper._btcRpc, this.wrapper._options.bitcoinNetwork, _bitcoinWallet);
}
//TODO: Maybe re-introduce fee rate check here if passed from the user
if(feeRate==null) {
feeRate = await bitcoinWallet.getFeeRate();
}
const basePsbt = new Transaction({
allowUnknownOutputs: true,
allowLegacyWitnessUtxo: true
});
basePsbt.addOutput({
amount: this.amount,
script: toOutputScript(this.wrapper._options.bitcoinNetwork, this.address)
});
if(additionalOutputs!=null) additionalOutputs.forEach(output => {
basePsbt.addOutput({
amount: output.amount,
script: (output as {outputScript: Uint8Array}).outputScript ?? toOutputScript(this.wrapper._options.bitcoinNetwork, (output as {address: string}).address)
});
});
const psbt = await bitcoinWallet.fundPsbt(basePsbt, feeRate);
//Sign every input
const signInputs: number[] = [];
for(let i=0;i<psbt.inputsLength;i++) {
signInputs.push(i);
}
const serializedPsbt = Buffer.from(psbt.toPSBT());
return {
psbt,
psbtHex: serializedPsbt.toString("hex"),
psbtBase64: serializedPsbt.toString("base64"),
signInputs,
feeRate
};
}
/**
* @inheritDoc
*/
getFundedPsbt(
_bitcoinWallet: IBitcoinWallet | MinimalBitcoinWalletInterface,
feeRate?: number,
additionalOutputs?: ({amount: bigint, outputScript: Uint8Array} | {amount: bigint, address: string})[]
) {
if(this._state!==FromBTCSwapState.CLAIM_COMMITED)
throw new Error("Swap not committed yet, please initiate the swap first with commit() call!");
if(this.txId!=null)
throw new Error("Bitcoin transaction already submitted for this swap!");
return this._getFundedPsbt(_bitcoinWallet, feeRate, additionalOutputs);
}
/**
* @inheritDoc
*
* @throws {Error} if the swap is in invalid state (not in {@link FromBTCSwapState.CLAIM_COMMITED}), or if
* the swap bitcoin address already expired.
*/
async submitPsbt(_psbt: Transaction | string): Promise<string> {
const psbt = parsePsbtTransaction(_psbt);
if(this._state!==FromBTCSwapState.CLAIM_COMMITED)
throw new Error("Swap not committed yet, please initiate the swap first with commit() call!");
if(this.txId!=null)
throw new Error("Bitcoin transaction already submitted for this swap!");
//Ensure not expired
if(this.getTimeoutTime()<Date.now()) {
throw new Error("Swap address expired!");
}
const output0 = psbt.getOutput(0);
if(this.amount!=null && output0.amount!==this.amount)
throw new Error("PSBT output amount invalid, expected: "+this.amount+" got: "+output0.amount);
if(this.address!=null) {
const expectedOutputScript = toOutputScript(this.wrapper._options.bitcoinNetwork, this.address);
if(output0.script==null || !expectedOutputScript.equals(output0.script))
throw new Error("PSBT output script invalid!");
}
if(!psbt.isFinal) psbt.finalize();
const txId = await this.wrapper._btcRpc.sendRawTransaction(Buffer.from(psbt.toBytes(true, true)).toString("hex"));
await this._setSubmittedBitcoinTx(txId, psbt);
return txId;
}
/**
* @inheritDoc
*/
async estimateBitcoinFee(_bitcoinWallet: IBitcoinWallet | MinimalBitcoinWalletInterface, feeRate?: number): Promise<TokenAmount<BtcToken<false>, true> | null> {
if(this.address==null || this.amount==null) return null;
const bitcoinWallet: IBitcoinWallet = toBitcoinWallet(_bitcoinWallet, this.wrapper._btcRpc, this.wrapper._options.bitcoinNetwork);
const txFee = await bitcoinWallet.getTransactionFee(this.address, this.amount, feeRate);
if(txFee==null) return null;
return toTokenAmount(BigInt(txFee), BitcoinTokens.BTC, this.wrapper._prices);
}
/**
* @inheritDoc
*/
async sendBitcoinTransaction(wallet: IBitcoinWallet | MinimalBitcoinWalletInterfaceWithSigner, feeRate?: number): Promise<string> {
if(this.address==null || this.amount==null) throw new Error("Cannot send bitcoin transaction, because the address is not known! This can happen after a swap is recovered.");
if(this._state!==FromBTCSwapState.CLAIM_COMMITED)
throw new Error("Swap not committed yet, please initiate the swap first with commit() call!");
if(this.txId!=null)
throw new Error("Bitcoin transaction already submitted for this swap!");
//Ensure not expired
if(this.getTimeoutTime()<Date.now()) {
throw new Error("Swap address expired!");
}
if(isIBitcoinWallet(wallet)) {
const txId = await wallet.sendTransaction(this.address, this.amount, feeRate);
await this._setSubmittedBitcoinTx(txId);
return txId;
} else {
const {psbt, psbtHex, psbtBase64, signInputs} = await this.getFundedPsbt(wallet, feeRate);
const signedPsbt = await wallet.signPsbt({
psbt, psbtHex, psbtBase64
}, signInputs);
return await this.submitPsbt(signedPsbt);
}
}
//////////////////////////////
//// Execution
/**
* Executes the swap with the provided bitcoin wallet,
*
* @param dstSigner Signer on the destination network, needs to have the same address as the one specified when
* quote was created, this is required for legacy swaps because the destination wallet needs to actively open
* a bitcoin swap address to which the BTC is then sent, this means that the address also needs to have enough
* native tokens to pay for gas on the destination network
* @param wallet Bitcoin wallet to use to sign the bitcoin transaction, can also be null - then the execution waits
* till a transaction is received from an external wallet
* @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(
dstSigner: T["Signer"] | T["NativeSigner"],
wallet?: IBitcoinWallet | MinimalBitcoinWalletInterfaceWithSigner | null | undefined,
callbacks?: {
onDestinationCommitSent?: (destinationCommitTxId: string) => void,
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===FromBTCSwapState.FAILED) throw new Error("Swap failed!");
if(this._state===FromBTCSwapState.EXPIRED) throw new Error("Swap address expired!");
if(this._state===FromBTCSwapState.QUOTE_EXPIRED || this._state===FromBTCSwapState.QUOTE_SOFT_EXPIRED) throw new Error("Swap quote expired!");
if(this._state===FromBTCSwapState.CLAIM_CLAIMED) throw new Error("Swap already settled!");
if(this._state===FromBTCSwapState.PR_CREATED) {
await this.commit(dstSigner, options?.abortSignal, undefined, callbacks?.onDestinationCommitSent);
}
if(this._state===FromBTCSwapState.CLAIM_COMMITED) {
if(wallet!=null) {
const bitcoinPaymentSent = await this.getBitcoinPayment();
if(bitcoinPaymentSent==null && this.txId==null) {
//Send btc tx
const txId = await this.sendBitcoinTransaction(wallet, options?.feeRate);
if(callbacks?.onSourceTransactionSent!=null) callbacks.onSourceTransactionSent(txId);
}
}
const txId = await this.waitForBitcoinTransaction(callbacks?.onSourceTransactionConfirmationStatus, options?.btcTxCheckIntervalSeconds, options?.abortSignal);
if (callbacks?.onSourceTransactionConfirmed != null) callbacks.onSourceTransactionConfirmed(txId);
}
// @ts-ignore
if(this._state===FromBTCSwapState.CLAIM_CLAIMED) return true;
if(this._state===FromBTCSwapState.BTC_TX_CONFIRMED) {
const success = await this.waitTillClaimed(options?.maxWaitTillAutomaticSettlementSeconds ?? 60, options?.abortSignal);
if(success && callbacks?.onSwapSettled!=null) callbacks.onSwapSettled(this.getOutputTxId()!);
return success;
}
throw new Error("Invalid state reached!");
}
/**
* @internal
*/
protected async _getExecutionStatus(options?: {
maxWaitTillAutomaticSettlementSeconds?: number
}) {
const state = this._state;
const now = Date.now();
const timeoutTime = this.getTimeoutTime();
let confirmations: {
current: number,
target: number,
etaSeconds: number
} | undefined;
let bitcoinTxId: string | undefined;
let destinationSetupStatus: SwapExecutionStepSetup<T["ChainId"]>["status"] = "awaiting";
let bitcoinPaymentStatus: SwapExecutionStepPayment<"BITCOIN">["status"] = "inactive";
let destinationSettlementStatus: SwapExecutionStepSettlement<T["ChainId"], "awaiting_automatic" | "awaiting_manual">["status"] = "inactive";
let buildCurrentAction: (actionOptions?: {
bitcoinFeeRate?: number,
bitcoinWallet?: MinimalBitcoinWalletInterface,
skipChecks?: boolean,
manualSettlementSmartChainSigner?: string | T["Signer"] | T["NativeSigner"],
}) => Promise<
SwapExecutionActionSendToAddress<false> |
SwapExecutionActionSignPSBT<"FUNDED_PSBT"> |
SwapExecutionActionWait<"BITCOIN_CONFS" | "SETTLEMENT"> |
SwapExecutionActionSignSmartChainTx<T> |
undefined
> = async () => undefined;
switch(state) {
case FromBTCSwapState.PR_CREATED: {
const quoteValid = await this._verifyQuoteValid();
destinationSetupStatus = quoteValid && timeoutTime>=now ? "awaiting" : "soft_expired";
if(quoteValid && timeoutTime>=now) {
buildCurrentAction = this._buildInitSmartChainTxAction.bind(this);
}
break;
}
case FromBTCSwapState.QUOTE_SOFT_EXPIRED:
destinationSetupStatus = "soft_expired";
break;
case FromBTCSwapState.QUOTE_EXPIRED:
destinationSetupStatus = "expired";
break;
case FromBTCSwapState.CLAIM_COMMITED:
case FromBTCSwapState.EXPIRED:
case FromBTCSwapState.FAILED:
const bitcoinPayment = this.address==null ? null : await this.getBitcoinPayment();
bitcoinTxId = bitcoinPayment?.txId;
let bitcoinConfirmationDelay: number | undefined;
if(bitcoinPayment!=null && bitcoinPayment.confirmations < bitcoinPayment.targetConfirmations) {
const tx = await this.wrapper._btcRpc.getTransaction(bitcoinPayment.txId);
const result = tx==null
? null
: await this.wrapper._btcRpc.getConfirmationDelay(tx, bitcoinPayment.targetConfirmations);
bitcoinConfirmationDelay = result ?? -1;
}
destinationSetupStatus = "completed";
if(bitcoinPayment==null) {
if(this.txId!=null) {
bitcoinPaymentStatus = state===FromBTCSwapState.FAILED ? "expired" : "received";
if(state!==FromBTCSwapState.FAILED) {
buildCurrentAction = this._buildWaitBitcoinConfirmationsAction.bind(this, -1, "Wait for bitcoin transaction to be picked up by the RPC and confirmed.");
}
} else {
bitcoinPaymentStatus = "awaiting";
if(state===FromBTCSwapState.EXPIRED) bitcoinPaymentStatus = "soft_expired";
if(state===FromBTCSwapState.FAILED) bitcoinPaymentStatus = "expired";
if(
state===FromBTCSwapState.CLAIM_COMMITED && timeoutTime>=now &&
this.address!=null && this.amount!=null
) {
buildCurrentAction = this._buildSendToAddressOrSignPsbtAction.bind(this);
}
}
} else if(bitcoinPayment.confirmations >= bitcoinPayment.targetConfirmations) {
bitcoinPaymentStatus = "confirmed";
if(state!==FromBTCSwapState.FAILED) {
buildCurrentAction = this._buildWaitBitcoinConfirmationsAction.bind(this, bitcoinConfirmationDelay ?? -1, undefined);
}
} else {
bitcoinPaymentStatus = "received";
confirmations = {
current: bitcoinPayment.confirmations,
target: bitcoinPayment.targetConfirmations,
etaSeconds: bitcoinConfirmationDelay ?? -1
};
if(state!==FromBTCSwapState.FAILED) {
buildCurrentAction = this._buildWaitBitcoinConfirmationsAction.bind(this, bitcoinConfirmationDelay ?? -1, undefined);
}
}
destinationSettlementStatus = state===FromBTCSwapState.FAILED ? "expired" : "inactive";
break;
case FromBTCSwapState.BTC_TX_CONFIRMED:
destinationSetupStatus = "completed";
bitcoinPaymentStatus = "confirmed";
if(
this.btcTxConfirmedAt==null ||
options?.maxWaitTillAutomaticSettlementSeconds===0 ||
(now - this.btcTxConfirmedAt) > (options?.maxWaitTillAutomaticSettlementSeconds ?? 60)*1000
) {
destinationSettlementStatus = "awaiting_manual";
buildCurrentAction = this._buildClaimSmartChainTxAction.bind(this);
} else {
destinationSettlementStatus = "awaiting_automatic";
buildCurrentAction = this._buildWaitSettlementAction.bind(this, options?.maxWaitTillAutomaticSettlementSeconds);
}
break;
case FromBTCSwapState.CLAIM_CLAIMED:
destinationSetupStatus = "completed";
bitcoinPaymentStatus = "confirmed";
destinationSettlementStatus = "settled";
break;
}
if(bitcoinPaymentStatus==="confirmed") {
const requiredConfirmations = this.getRequiredConfirmationsCount();
if(!Number.isNaN(requiredConfirmations)) {
confirmations = {
current: requiredConfirmations,
target: requiredConfirmations,
etaSeconds: 0
};
}
}
return {
steps: [
{
type: "Setup",
side: "destination",
chain: this.chainIdentifier,
title: "Open Bitcoin swap address",
description: `Create the escrow on the ${this.chainIdentifier} side to open the Bitcoin swap address`,
status: destinationSetupStatus,
setupTxId: this._commitTxId
},
{
type: "Payment",
side: "source",
chain: "BITCOIN",
title: "Bitcoin payment",
description: "Send Bitcoin to the swap address and wait for the transaction to confirm",
status: bitcoinPaymentStatus,
confirmations,
initTxId: this.txId ?? bitcoinTxId,
settleTxId: this.txId
},
{
type: "Settlement",
side: "destination",
chain: this.chainIdentifier,
title: "Destination settlement",
description: `Wait for automatic settlement on the ${this.chainIdentifier} side, or settle manually if it takes too long`,
status: destinationSettlementStatus,
initTxId: this._commitTxId,
settleTxId: this._claimTxId
}
] as [
SwapExecutionStepSetup<T["ChainId"]>,
SwapExecutionStepPayment<"BITCOIN">,
SwapExecutionStepSettlement<T["ChainId"], "awaiting_automatic" | "awaiting_manual">
],
buildCurrentAction,
state
};
}
/**
* @inheritDoc
* @internal
*/
async _submitExecutionTransactions(txs: (T["SignedTXType"] | Transaction | string)[], abortSignal?: AbortSignal, requiredStates?: FromBTCSwapState[], idempotent?: boolean): Promise<string[]> {
if(txs.length===0) throw new Error("Need to submit at least 1 transaction in the array, submitted empty array of transactions!");
if(idempotent) {
// Handle idempotent calls
let idempotencyTriggered = false;
const txIds: string[] = [];
for(let tx of txs) {
let parsedTx: T["SignedTXType"] | Transaction | undefined;
if(typeof(tx)==="string") {
try {
parsedTx = await this.wrapper._chain.deserializeSignedTx(tx);
} catch (e) {}
try {
parsedTx = parsePsbtTransaction(tx);
} catch (e) {}
} else {
parsedTx = tx;
}
if(parsedTx==null) {
this.logger.debug("_submitExecutionTransactions(): Failed to parse provided execution transaction: ", tx);
continue;
}
if(parsedTx instanceof Transaction) {
// Bitcoin tx
const btcTx = await this.wrapper._btcRpc.parseTransaction(Buffer.from(parsedTx.toBytes(true)).toString("hex"));
if(btcTx.txid===this.txId) idempotencyTriggered = true;
txIds.push(btcTx.txid);
} else {
// SC tx
if(this.wrapper._chain.getTxId!=null) {
const txId = await this.wrapper._chain.getTxId(parsedTx);
if(this._commitTxId===txId || this._claimTxId===txId) idempotencyTriggered = true;
txIds.push(txId);
}
}
}
if(idempotencyTriggered) return txIds;
}
if(requiredStates!=null && !requiredStates.includes(this._state)) throw new Error("Swap state has changed before transactions were submitted!");
if(this._state===FromBTCSwapState.CLAIM_COMMITED) {
let psbt: string | Transaction;
if(txs.length!==1) throw new Error("Need to submit exactly 1 signed PSBT!");
if(typeof(txs[0])!=="string" && !(txs[0] instanceof Transaction))
throw new Error("Must submit a valid PSBT as hex/base64 string or `@scure/btc-signer` Transaction object!");
psbt = txs[0];
return [await this.submitPsbt(psbt)];
}
if(this._state===FromBTCSwapState.PR_CREATED || this._state===FromBTCSwapState.QUOTE_SOFT_EXPIRED) {
if(!await this._verifyQuoteValid()) throw new Error("Quote is already expired!");
if(this.getTimeoutTime()<Date.now()) throw new Error("Swap address already expired or close to expiry!");
const parsedTxs: T["SignedTXType"][] = [];
for(let tx of txs) {
parsedTxs.push(typeof(tx)==="string" ? await this.wrapper._chain.deserializeSignedTx(tx) : tx);
}
const txIds = await this.wrapper._chain.sendSignedAndConfirm(parsedTxs, true, abortSignal, false);
await this.waitTillCommited(abortSignal);
return txIds;
}
if(this._state===FromBTCSwapState.BTC_TX_CONFIRMED) {
const parsedTxs: T["SignedTXType"][] = [];
for(let tx of txs) {
parsedTxs.push(typeof(tx)==="string" ? await this.wrapper._chain.deserializeSignedTx(tx) : tx);
}
const txIds = await this.wrapper._chain.sendSignedAndConfirm(parsedTxs, true, abortSignal, false);
await this.waitTillClaimed(undefined, abortSignal);
return txIds;
}
throw new Error("Invalid swap state for transaction submission!");
}
/**
* @internal
*/
private async _buildSendToAddressOrSignPsbtAction(actionOptions?: {
bitcoinFeeRate?: number,
bitcoinWallet?: MinimalBitcoinWalletInterface,
}): Promise<
SwapExecutionActionSendToAddress<false> |
SwapExecutionActionSignPSBT<"FUNDED_PSBT">
> {
if(this.address==null) throw new Error("Bitcoin swap address not known!");
if(this.amount==null) throw new Error("Bitcoin swap amount not known!");
if(actionOptions?.bitcoinWallet==null) {
return {
type: "SendToAddress",
name: "Deposit on Bitcoin",
description: "Send funds to the bitcoin swap address",
chain: "BITCOIN",
txs: [{
type: "BITCOIN_ADDRESS",
address: this.address,
hyperlink: this._getHyperlink(),
amount: toTokenAmount(this.amount, BitcoinTokens.BTC, this.wrapper._prices)
}],
waitForTransactions: async (
maxWaitTimeSeconds?: number, pollIntervalSeconds?: number, abortSignal?: AbortSignal
) => {
let btcTxId: string | undefined;
const abortController = extendAbortController(
abortSignal, maxWaitTimeSeconds, "Timed out waiting for bitcoin transaction"
);
try {
return await this.waitForBitcoinTransaction(
(txId) => {
btcTxId = txId;
abortController.abort();
},
pollIntervalSeconds,
abortController.signal
);
} catch (e) {
if(btcTxId!=null) return btcTxId;
throw e;
}
}
} as SwapExecutionActionSendToAddress<false>;
}
return {
type: "SignPSBT",
name: "Deposit on Bitcoin",
description: "Send funds to the bitcoin swap address",
chain: "BITCOIN",
txs: [{
...await this.getFundedPsbt(actionOptions.bitcoinWallet, actionOptions?.bitcoinFeeRate),
type: "FUNDED_PSBT"
}],
submitPsbt: async (signedPsbt: string | Transaction | (string | Transaction)[], idempotent?: boolean) => {
return this._submitExecutionTransactions(
Array.isArray(signedPsbt) ? signedPsbt : [signedPsbt],