@atomiqlabs/sdk-lib
Version:
Basic SDK functionality library for atomiq
506 lines (437 loc) • 20.6 kB
text/typescript
import {IFromBTCSwap} from "../IFromBTCSwap";
import {SwapType} from "../../SwapType";
import {FromBTCWrapper} from "./FromBTCWrapper";
import {ChainType, SwapCommitStatus, SwapData} from "@atomiqlabs/base";
import {isISwapInit, ISwapInit} from "../../ISwap";
import {Buffer} from "buffer";
import {BitcoinTokens, BtcToken, SCToken, TokenAmount, toTokenAmount} from "../../Tokens";
import {extendAbortController, getLogger, tryWithRetries} from "../../../utils/Utils";
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> = ISwapInit<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" &&
isISwapInit<T>(obj);
}
export class FromBTCSwap<T extends ChainType = ChainType> extends IFromBTCSwap<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.tryCalculateSwapFee();
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
getInputTxId(): string | null {
return this.txId;
}
getAddress(): string {
return this.address;
}
/**
* Returns bitcoin address where the on-chain BTC should be sent to
*/
getBitcoinAddress(): string {
if(this.state===FromBTCSwapState.PR_CREATED) return null;
return this.address;
}
getQrData(): string {
if(this.state===FromBTCSwapState.PR_CREATED) return null;
return "bitcoin:"+this.address+"?amount="+encodeURIComponent((Number(this.amount) / 100000000).toString(10));
}
/**
* 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;
}
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;
}
isActionable(): boolean {
return this.isClaimable() || (this.state===FromBTCSwapState.CLAIM_COMMITED && this.getTimeoutTime()>Date.now());
}
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;
}
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;
}
canClaim(): boolean {
return this.state===FromBTCSwapState.BTC_TX_CONFIRMED;
}
//////////////////////////////
//// 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
/**
* Waits till the bitcoin transaction confirms and swap becomes claimable
*
* @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 waitForBitcoinTransaction(
abortSignal?: AbortSignal,
checkIntervalSeconds?: number,
updateCallback?: (txId: string, confirmations: number, targetConfirmations: number, txEtaMs: number) => void
): Promise<void> {
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();
}
/**
* Checks whether a bitcoin payment was already made, returns the payment or null when no payment has been made.
*/
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
}
}
//////////////////////////////
//// 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)
* @throws {Error} If invalid signer is provided that doesn't match the swap data
*/
async commit(signer: T["Signer"], abortSignal?: AbortSignal, skipChecks?: boolean): Promise<string> {
this.checkSigner(signer);
const result = await this.wrapper.contract.sendAndConfirm(
signer, await this.txsCommit(skipChecks), true, abortSignal
);
this.commitTxId = result[0];
if(this.state===FromBTCSwapState.PR_CREATED || this.state===FromBTCSwapState.QUOTE_SOFT_EXPIRED) {
await this._saveAndEmit(FromBTCSwapState.CLAIM_COMMITED);
}
return result[0];
}
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(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?: T["Signer"]): Promise<T["TX"][]> {
if(!this.canClaim()) 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
*/
async claim(signer: T["Signer"], abortSignal?: AbortSignal): Promise<string> {
let txIds: string[];
try {
txIds = await this.wrapper.contract.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===FromBTCSwapState.CLAIM_CLAIMED) {
this.logger.info("claim(): Transaction state is CLAIM_CLAIMED, swap was successfully claimed by the watchtower");
return this.claimTxId;
}
if((await this.wrapper.contract.getCommitStatus(this.getInitiator(), this.data))===SwapCommitStatus.PAID) {
this.logger.info("claim(): Transaction commit status is PAID, swap was successfully claimed by the watchtower");
await this._saveAndEmit(FromBTCSwapState.CLAIM_CLAIMED);
return null;
}
throw e;
}
this.claimTxId = txIds[0];
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[0];
}
/**
* Waits till the swap is successfully claimed
*
* @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
*/
async waitTillClaimed(abortSignal?: AbortSignal): Promise<void> {
if(this.state===FromBTCSwapState.CLAIM_CLAIMED) return Promise.resolve();
if(this.state!==FromBTCSwapState.BTC_TX_CONFIRMED) throw new Error("Invalid state (not BTC_TX_CONFIRMED)");
const abortController = new AbortController();
if(abortSignal!=null) abortSignal.addEventListener("abort", () => abortController.abort(abortSignal.reason));
const res = await Promise.race([
this.watchdogWaitTillResult(abortController.signal),
this.waitTillState(FromBTCSwapState.CLAIM_CLAIMED, "eq", abortController.signal).then(() => 0),
this.waitTillState(FromBTCSwapState.FAILED, "eq", abortController.signal).then(() => 1),
]);
abortController.abort();
if(res===0) {
this.logger.debug("waitTillClaimed(): Resolved from state change (CLAIM_CLAIMED)");
return;
}
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===SwapCommitStatus.PAID) {
if((this.state as FromBTCSwapState)!==FromBTCSwapState.CLAIM_CLAIMED) await this._saveAndEmit(FromBTCSwapState.CLAIM_CLAIMED);
}
if(res===SwapCommitStatus.NOT_COMMITED || res===SwapCommitStatus.EXPIRED) {
if(
(this.state as FromBTCSwapState)!==FromBTCSwapState.CLAIM_CLAIMED &&
(this.state as FromBTCSwapState)!==FromBTCSwapState.FAILED
) await this._saveAndEmit(FromBTCSwapState.FAILED);
}
}
//////////////////////////////
//// 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(): Promise<boolean> {
if(this.state===FromBTCSwapState.PR_CREATED || this.state===FromBTCSwapState.QUOTE_SOFT_EXPIRED) {
const status = await tryWithRetries(() => this.wrapper.contract.getCommitStatus(this.getInitiator(), this.data));
switch(status) {
case SwapCommitStatus.COMMITED:
this.state = FromBTCSwapState.CLAIM_COMMITED;
return true;
case SwapCommitStatus.EXPIRED:
this.state = FromBTCSwapState.QUOTE_EXPIRED;
return true;
case SwapCommitStatus.PAID:
this.state = FromBTCSwapState.CLAIM_CLAIMED;
return true;
}
if(!await this.isQuoteValid()) {
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 = await tryWithRetries(() => this.wrapper.contract.getCommitStatus(this.getInitiator(), this.data));
switch(status) {
case SwapCommitStatus.PAID:
this.state = FromBTCSwapState.CLAIM_CLAIMED;
return true;
case SwapCommitStatus.NOT_COMMITED:
case SwapCommitStatus.EXPIRED:
this.state = FromBTCSwapState.FAILED;
return true;
case SwapCommitStatus.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;
}
}
}
async _sync(save?: boolean): Promise<boolean> {
const changed = await this.syncStateFromChain();
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;
}
}
}