@atomiqlabs/sdk-lib
Version:
Basic SDK functionality library for atomiq
778 lines (680 loc) • 34.6 kB
text/typescript
import {IFromBTCSwap} from "../IFromBTCSwap";
import {SwapType} from "../../../enums/SwapType";
import {FromBTCWrapper} from "./FromBTCWrapper";
import {ChainType, isAbstractSigner, SwapCommitState, SwapCommitStateType, SwapData} from "@atomiqlabs/base";
import {Buffer} from "buffer";
import {BitcoinTokens, BtcToken, SCToken, TokenAmount, toTokenAmount} from "../../../../Tokens";
import {
extendAbortController,
getLogger,
tryWithRetries
} from "../../../../utils/Utils";
import {
toOutputScript,
} from "../../../../utils/BitcoinUtils";
import {
parsePsbtTransaction, toBitcoinWallet,
} from "../../../../utils/BitcoinHelpers";
import {IBitcoinWallet, isIBitcoinWallet} from "../../../../btc/wallet/IBitcoinWallet";
import {IBTCWalletSwap} from "../../../IBTCWalletSwap";
import {Transaction} from "@scure/btc-signer";
import {SingleAddressBitcoinWallet} from "../../../../btc/wallet/SingleAddressBitcoinWallet";
import {
MinimalBitcoinWalletInterface,
MinimalBitcoinWalletInterfaceWithSigner
} from "../../../../btc/wallet/MinimalBitcoinWalletInterface";
import {IClaimableSwap} from "../../../IClaimableSwap";
import {IEscrowSelfInitSwapInit, isIEscrowSelfInitSwapInit} from "../../IEscrowSelfInitSwap";
export enum FromBTCSwapState {
FAILED = -4,
EXPIRED = -3,
QUOTE_EXPIRED = -2,
QUOTE_SOFT_EXPIRED = -1,
PR_CREATED = 0,
CLAIM_COMMITED = 1,
BTC_TX_CONFIRMED = 2,
CLAIM_CLAIMED = 3
}
export type FromBTCSwapInit<T extends SwapData> = IEscrowSelfInitSwapInit<T> & {
address: string;
amount: bigint;
requiredConfirmations: number;
};
export function isFromBTCSwapInit<T extends SwapData>(obj: any): obj is FromBTCSwapInit<T> {
return typeof(obj.address) === "string" &&
typeof(obj.amount) === "bigint" &&
isIEscrowSelfInitSwapInit<T>(obj);
}
export class FromBTCSwap<T extends ChainType = ChainType>
extends IFromBTCSwap<T, FromBTCSwapState>
implements IBTCWalletSwap, IClaimableSwap<T, FromBTCSwapState> {
protected readonly inputToken: BtcToken<false> = BitcoinTokens.BTC;
protected readonly TYPE = SwapType.FROM_BTC;
readonly wrapper: FromBTCWrapper<T>;
readonly address: string;
readonly amount: bigint;
readonly requiredConfirmations: number;
txId?: string;
vout?: 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 += "/frombtc";
super(wrapper, initOrObject);
if(isFromBTCSwapInit(initOrObject)) {
this.state = FromBTCSwapState.PR_CREATED;
} else {
this.address = initOrObject.address;
this.amount = BigInt(initOrObject.amount);
this.txId = initOrObject.txId;
this.vout = initOrObject.vout;
this.requiredConfirmations = initOrObject.requiredConfirmations ?? this.data.getConfirmationsHint();
}
this.tryRecomputeSwapPrice();
this.logger = getLogger("FromBTC("+this.getIdentifierHashString()+"): ");
}
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) return null;
return this.address;
}
getHyperlink(): string {
if(this.state===FromBTCSwapState.PR_CREATED) return null;
return "bitcoin:"+this.address+"?amount="+encodeURIComponent((Number(this.amount) / 100000000).toString(10));
}
getInputTxId(): string | null {
return this.txId;
}
/**
* 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)) * 1000;
}
requiresAction(): boolean {
return this.isClaimable() || (this.state===FromBTCSwapState.CLAIM_COMMITED && this.getTimeoutTime()>Date.now());
}
isFinished(): boolean {
return this.state===FromBTCSwapState.CLAIM_CLAIMED || this.state===FromBTCSwapState.QUOTE_EXPIRED || this.state===FromBTCSwapState.FAILED;
}
isClaimable(): boolean {
return this.state===FromBTCSwapState.BTC_TX_CONFIRMED;
}
isSuccessful(): boolean {
return this.state===FromBTCSwapState.CLAIM_CLAIMED;
}
isFailed(): boolean {
return this.state===FromBTCSwapState.FAILED || (this.state===FromBTCSwapState.EXPIRED && this.txId!=null);
}
isQuoteExpired(): boolean {
return this.state===FromBTCSwapState.QUOTE_EXPIRED;
}
isQuoteSoftExpired(): boolean {
return this.state===FromBTCSwapState.QUOTE_EXPIRED || this.state===FromBTCSwapState.QUOTE_SOFT_EXPIRED;
}
protected canCommit(): boolean {
if(this.state!==FromBTCSwapState.PR_CREATED) 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
getInput(): TokenAmount<T["ChainId"], BtcToken<false>> {
return toTokenAmount(this.amount, this.inputToken, this.wrapper.prices);
}
/**
* Returns claimer bounty, acting as a reward for watchtowers to claim the swap automatically
*/
getClaimerBounty(): TokenAmount<T["ChainId"], SCToken<T["ChainId"]>> {
return toTokenAmount(this.data.getClaimerBounty(), this.wrapper.tokens[this.data.getDepositToken()], this.wrapper.prices);
}
//////////////////////////////
//// Bitcoin tx
getRequiredConfirmationsCount(): number {
return this.requiredConfirmations;
}
/**
* 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,
vout: number,
confirmations: number,
targetConfirmations: number
} | null> {
const result = await this.wrapper.btcRpc.checkAddressTxos(this.address, Buffer.from(this.data.getTxoHashHint(), "hex"));
if(result==null) return null;
return {
txId: result.tx.txid,
vout: result.vout,
confirmations: result.tx.confirmations,
targetConfirmations: this.requiredConfirmations
}
}
/**
* 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!==FromBTCSwapState.CLAIM_COMMITED && this.state!==FromBTCSwapState.EXPIRED) throw new Error("Must be in COMMITED state!");
const result = await this.wrapper.btcRpc.waitForAddressTxo(
this.address,
Buffer.from(this.data.getTxoHashHint(), "hex"),
this.requiredConfirmations,
(confirmations: number, txId: string, vout: number, txEtaMs: number) => {
if(updateCallback!=null) updateCallback(txId, confirmations, this.requiredConfirmations, txEtaMs);
},
abortSignal,
checkIntervalSeconds
);
if(abortSignal!=null) abortSignal.throwIfAborted();
this.txId = result.tx.txid;
this.vout = result.vout;
if(
(this.state as FromBTCSwapState)!==FromBTCSwapState.CLAIM_CLAIMED &&
(this.state as FromBTCSwapState)!==FromBTCSwapState.FAILED
) {
this.state = FromBTCSwapState.BTC_TX_CONFIRMED;
}
await this._saveAndEmit();
return result.tx.txid;
}
/**
* 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[]}> {
if(this.state!==FromBTCSwapState.CLAIM_COMMITED)
throw new Error("Swap not committed yet, please initiate the swap first with commit() call!");
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
};
}
/**
* 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);
if(this.state!==FromBTCSwapState.CLAIM_COMMITED)
throw new Error("Swap not committed yet, please initiate the swap first with commit() call!");
//Ensure not expired
if(this.getTimeoutTime()<Date.now()) {
throw new Error("Swap address expired!");
}
const output0 = psbt.getOutput(0);
if(output0.amount!==this.amount)
throw new Error("PSBT output amount invalid, expected: "+this.amount+" got: "+output0.amount);
const expectedOutputScript = toOutputScript(this.wrapper.options.bitcoinNetwork, this.address);
if(!expectedOutputScript.equals(output0.script))
throw new Error("PSBT output script invalid!");
if(!psbt.isFinal) psbt.finalize();
return await this.wrapper.btcRpc.sendRawTransaction(Buffer.from(psbt.toBytes(true, true)).toString("hex"));
}
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.getTransactionFee(this.address, this.amount, feeRate);
return toTokenAmount(txFee==null ? null : BigInt(txFee), BitcoinTokens.BTC, this.wrapper.prices);
}
async sendBitcoinTransaction(wallet: IBitcoinWallet | MinimalBitcoinWalletInterfaceWithSigner, feeRate?: number): Promise<string> {
if(this.state!==FromBTCSwapState.CLAIM_COMMITED)
throw new Error("Swap not committed yet, please initiate the swap first with commit() call!");
//Ensure not expired
if(this.getTimeoutTime()<Date.now()) {
throw new Error("Swap address expired!");
}
if(isIBitcoinWallet(wallet)) {
return await wallet.sendTransaction(this.address, this.amount, feeRate);
} else {
const {psbt, psbtHex, psbtBase64, signInputs} = await this.getFundedPsbt(wallet, feeRate);
const signedPsbt = await wallet.signPsbt({
psbt, psbtHex, psbtBase64
}, signInputs);
return await this.submitPsbt(signedPsbt);
}
}
//////////////////////////////
//// 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) {
//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;
}
}
//////////////////////////////
//// Commit
/**
* Commits the swap on-chain, locking the tokens from the intermediary in a PTLC
*
* @param _signer Signer to sign the transactions with, must be the same as used in the initialization
* @param abortSignal Abort signal to stop waiting for the transaction confirmation and abort
* @param skipChecks Skip checks like making sure init signature is still valid and swap wasn't commited yet
* (this is handled when swap is created (quoted), if you commit right after quoting, you can use skipChecks=true)
* @param onBeforeTxSent
* @throws {Error} If invalid signer is provided that doesn't match the swap data
*/
async commit(_signer: T["Signer"] | T["NativeSigner"], abortSignal?: AbortSignal, skipChecks?: boolean, onBeforeTxSent?: (txId: string) => void): Promise<string> {
const signer = isAbstractSigner(_signer) ? _signer : await this.wrapper.chain.wrapSigner(_signer);
this.checkSigner(signer);
let txCount = 0;
const txs = await this.txsCommit(skipChecks);
const result = await this.wrapper.chain.sendAndConfirm(
signer, txs, true, abortSignal, undefined, (txId: string) => {
txCount++;
if(onBeforeTxSent!=null && txCount===txs.length) onBeforeTxSent(txId);
return Promise.resolve();
}
);
this.commitTxId = result[result.length - 1];
if(this.state===FromBTCSwapState.PR_CREATED || this.state===FromBTCSwapState.QUOTE_SOFT_EXPIRED) {
await this._saveAndEmit(FromBTCSwapState.CLAIM_COMMITED);
}
return this.commitTxId;
}
async waitTillCommited(abortSignal?: AbortSignal): Promise<void> {
if(this.state===FromBTCSwapState.CLAIM_COMMITED || this.state===FromBTCSwapState.CLAIM_CLAIMED) return Promise.resolve();
if(this.state!==FromBTCSwapState.PR_CREATED && this.state!==FromBTCSwapState.QUOTE_SOFT_EXPIRED) throw new Error("Invalid state");
const abortController = extendAbortController(abortSignal);
const result = await Promise.race([
this.watchdogWaitTillCommited(undefined, abortController.signal),
this.waitTillState(FromBTCSwapState.CLAIM_COMMITED, "gte", abortController.signal).then(() => 0)
]);
abortController.abort();
if(result===0) this.logger.debug("waitTillCommited(): Resolved from state changed");
if(result===true) this.logger.debug("waitTillCommited(): Resolved from watchdog - commited");
if(result===false) {
this.logger.debug("waitTillCommited(): Resolved from watchdog - signature expired");
if(this.state===FromBTCSwapState.PR_CREATED || this.state===FromBTCSwapState.QUOTE_SOFT_EXPIRED) {
await this._saveAndEmit(FromBTCSwapState.QUOTE_EXPIRED);
}
return;
}
if(this.state===FromBTCSwapState.PR_CREATED || this.state===FromBTCSwapState.QUOTE_SOFT_EXPIRED) {
await this._saveAndEmit(FromBTCSwapState.CLAIM_COMMITED);
}
}
//////////////////////////////
//// 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 signer: string | T["Signer"];
if(_signer!=null) {
if (typeof (_signer) === "string") {
signer = _signer;
} else if (isAbstractSigner(_signer)) {
signer = _signer;
} else {
signer = await this.wrapper.chain.wrapSigner(_signer);
}
}
if(this.state!==FromBTCSwapState.BTC_TX_CONFIRMED) throw new Error("Must be in BTC_TX_CONFIRMED state!");
const tx = await this.wrapper.btcRpc.getTransaction(this.txId);
return await this.wrapper.contract.txsClaimWithTxData(signer ?? this._getInitiator(), this.data, {
blockhash: tx.blockhash,
confirmations: tx.confirmations,
txid: tx.txid,
hex: tx.hex,
height: tx.blockheight
}, this.requiredConfirmations, this.vout, null, 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
* @param onBeforeTxSent
*/
async claim(_signer: T["Signer"] | T["NativeSigner"], abortSignal?: AbortSignal, onBeforeTxSent?: (txId: string) => void): Promise<string> {
const signer = isAbstractSigner(_signer) ? _signer : await this.wrapper.chain.wrapSigner(_signer);
let txIds: string[];
try {
let txCount = 0;
const txs = await this.txsClaim(signer);
txIds = await this.wrapper.chain.sendAndConfirm(
signer, txs, true, abortSignal, undefined, (txId: string) => {
txCount++;
if(onBeforeTxSent!=null && txCount===txs.length) onBeforeTxSent(txId);
return Promise.resolve();
}
);
} catch (e) {
this.logger.info("claim(): Failed to claim ourselves, checking swap claim state...");
if(this.state===FromBTCSwapState.CLAIM_CLAIMED) {
this.logger.info("claim(): Transaction state is CLAIM_CLAIMED, swap was successfully claimed by the watchtower");
return this.claimTxId;
}
const status = await this.wrapper.contract.getCommitStatus(this._getInitiator(), this.data);
if(status?.type===SwapCommitStateType.PAID) {
this.logger.info("claim(): Transaction commit status is PAID, swap was successfully claimed by the watchtower");
if(this.claimTxId==null) this.claimTxId = await status.getClaimTxId();
await this._saveAndEmit(FromBTCSwapState.CLAIM_CLAIMED);
return this.claimTxId;
}
throw e;
}
this.claimTxId = txIds[txIds.length - 1];
if(
this.state===FromBTCSwapState.CLAIM_COMMITED || this.state===FromBTCSwapState.BTC_TX_CONFIRMED ||
this.state===FromBTCSwapState.EXPIRED || this.state===FromBTCSwapState.FAILED
) {
await this._saveAndEmit(FromBTCSwapState.CLAIM_CLAIMED);
}
return txIds[txIds.length - 1];
}
/**
* Waits till the swap is successfully claimed
*
* @param maxWaitTimeSeconds Maximum time in seconds to wait for the swap to be settled
* @param abortSignal 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 in time or not
*/
async waitTillClaimed(maxWaitTimeSeconds?: number, abortSignal?: AbortSignal): Promise<boolean> {
if(this.state===FromBTCSwapState.CLAIM_CLAIMED) return Promise.resolve(true);
if(this.state!==FromBTCSwapState.BTC_TX_CONFIRMED) throw new Error("Invalid state (not BTC_TX_CONFIRMED)");
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: 0 | 1 | SwapCommitState;
try {
res = await Promise.race([
this.watchdogWaitTillResult(undefined, abortController.signal),
this.waitTillState(FromBTCSwapState.CLAIM_CLAIMED, "eq", abortController.signal).then(() => 0 as const),
this.waitTillState(FromBTCSwapState.FAILED, "eq", abortController.signal).then(() => 1 as const),
]);
abortController.abort();
} catch (e) {
abortController.abort();
if(timedOut) return false;
throw e;
}
if(res===0) {
this.logger.debug("waitTillClaimed(): Resolved from state change (CLAIM_CLAIMED)");
return true;
}
if(res===1) {
this.logger.debug("waitTillClaimed(): Resolved from state change (FAILED)");
throw new Error("Offerer refunded during claiming");
}
this.logger.debug("waitTillClaimed(): Resolved from watchdog");
if(res?.type===SwapCommitStateType.PAID) {
if((this.state as FromBTCSwapState)!==FromBTCSwapState.CLAIM_CLAIMED) {
this.claimTxId = await res.getClaimTxId();
await this._saveAndEmit(FromBTCSwapState.CLAIM_CLAIMED);
}
}
if(res?.type===SwapCommitStateType.NOT_COMMITED || res?.type===SwapCommitStateType.EXPIRED) {
if(
(this.state as FromBTCSwapState)!==FromBTCSwapState.CLAIM_CLAIMED &&
(this.state as FromBTCSwapState)!==FromBTCSwapState.FAILED
) {
this.refundTxId = res.getRefundTxId==null ? null : await res.getRefundTxId();
await this._saveAndEmit(FromBTCSwapState.FAILED);
}
throw new Error("Swap expired while waiting for claim!");
}
return true;
}
//////////////////////////////
//// Storage
serialize(): any {
return {
...super.serialize(),
address: this.address,
amount: this.amount.toString(10),
requiredConfirmations: this.requiredConfirmations,
txId: this.txId,
vout: this.vout
};
}
//////////////////////////////
//// Swap ticks & sync
/**
* 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(quoteDefinitelyExpired?: boolean, commitStatus?: SwapCommitState): Promise<boolean> {
if(this.state===FromBTCSwapState.PR_CREATED || this.state===FromBTCSwapState.QUOTE_SOFT_EXPIRED) {
const quoteExpired = quoteDefinitelyExpired ?? await this._verifyQuoteDefinitelyExpired(); //Make sure we check for expiry here, to prevent race conditions
const status = commitStatus ?? await tryWithRetries(() => this.wrapper.contract.getCommitStatus(this._getInitiator(), this.data));
switch(status?.type) {
case SwapCommitStateType.COMMITED:
this.state = FromBTCSwapState.CLAIM_COMMITED;
return true;
case SwapCommitStateType.EXPIRED:
if(this.refundTxId==null && status.getRefundTxId) this.refundTxId = await status.getRefundTxId();
this.state = FromBTCSwapState.QUOTE_EXPIRED;
return true;
case SwapCommitStateType.PAID:
if(this.claimTxId==null) this.claimTxId = await status.getClaimTxId();
this.state = FromBTCSwapState.CLAIM_CLAIMED;
return true;
}
if(quoteExpired) {
this.state = FromBTCSwapState.QUOTE_EXPIRED;
return true;
}
return false;
}
if(this.state===FromBTCSwapState.CLAIM_COMMITED || this.state===FromBTCSwapState.BTC_TX_CONFIRMED || this.state===FromBTCSwapState.EXPIRED) {
const status = commitStatus ?? await tryWithRetries(() => this.wrapper.contract.getCommitStatus(this._getInitiator(), this.data));
switch(status?.type) {
case SwapCommitStateType.PAID:
if(this.claimTxId==null) this.claimTxId = await status.getClaimTxId();
this.state = FromBTCSwapState.CLAIM_CLAIMED;
return true;
case SwapCommitStateType.NOT_COMMITED:
case SwapCommitStateType.EXPIRED:
if(this.refundTxId==null && status.getRefundTxId) this.refundTxId = await status.getRefundTxId();
this.state = FromBTCSwapState.FAILED;
return true;
case SwapCommitStateType.COMMITED:
const res = await this.getBitcoinPayment();
if(res!=null && res.confirmations>=this.requiredConfirmations) {
this.txId = res.txId;
this.vout = res.vout;
this.state = FromBTCSwapState.BTC_TX_CONFIRMED;
return true;
}
break;
}
}
}
_shouldFetchCommitStatus(): boolean {
return this.state===FromBTCSwapState.PR_CREATED || this.state===FromBTCSwapState.QUOTE_SOFT_EXPIRED ||
this.state===FromBTCSwapState.CLAIM_COMMITED || this.state===FromBTCSwapState.BTC_TX_CONFIRMED ||
this.state===FromBTCSwapState.EXPIRED;
}
_shouldFetchExpiryStatus(): boolean {
return this.state===FromBTCSwapState.PR_CREATED || this.state===FromBTCSwapState.QUOTE_SOFT_EXPIRED;
}
async _sync(save?: boolean, quoteDefinitelyExpired?: boolean, commitStatus?: SwapCommitState): Promise<boolean> {
const changed = await this.syncStateFromChain(quoteDefinitelyExpired, commitStatus);
if(changed && save) await this._saveAndEmit();
return changed;
}
async _tick(save?: boolean): Promise<boolean> {
switch(this.state) {
case FromBTCSwapState.PR_CREATED:
if(this.expiry<Date.now()) {
this.state = FromBTCSwapState.QUOTE_SOFT_EXPIRED;
if(save) await this._saveAndEmit();
return true;
}
break;
case FromBTCSwapState.CLAIM_COMMITED:
if(this.getTimeoutTime()<Date.now()) {
this.state = FromBTCSwapState.EXPIRED;
if(save) await this._saveAndEmit();
return true;
}
case FromBTCSwapState.EXPIRED:
//Check if bitcoin payment was received every 2 minutes
if(Math.floor(Date.now()/1000)%120===0) {
try {
const res = await this.getBitcoinPayment();
if(res!=null && res.confirmations>=this.requiredConfirmations) {
this.txId = res.txId;
this.vout = res.vout;
this.state = FromBTCSwapState.BTC_TX_CONFIRMED;
if(save) await this._saveAndEmit();
return true;
}
} catch (e) {
this.logger.warn("tickSwap("+this.getIdentifierHashString()+"): ", e);
}
}
break;
}
}
}