@atomiqlabs/sdk
Version:
atomiq labs SDK for cross-chain swaps between smart chains and bitcoin
404 lines (371 loc) • 17.3 kB
text/typescript
import {ToBTCSwap, ToBTCSwapInit} from "./ToBTCSwap";
import {IToBTCDefinition, IToBTCWrapper} from "../IToBTCWrapper";
import {
BigIntBufferUtils,
BitcoinRpc,
ChainSwapType,
ChainType,
SwapCommitState
} from "@atomiqlabs/base";
import {Intermediary, SingleChainReputationType} from "../../../../intermediaries/Intermediary";
import {ISwapPrice} from "../../../../prices/abstract/ISwapPrice";
import {EventEmitter} from "events";
import {ISwapWrapperOptions, WrapperCtorTokens} from "../../../ISwapWrapper";
import {Buffer} from "buffer";
import {UserError} from "../../../../errors/UserError";
import {IntermediaryError} from "../../../../errors/IntermediaryError";
import {SwapType} from "../../../../enums/SwapType";
import {
extendAbortController,
mapArrayToObject,
randomBytes,
throwIfUndefined
} from "../../../../utils/Utils";
import {toOutputScript} from "../../../../utils/BitcoinUtils";
import {IntermediaryAPI, ToBTCResponseType} from "../../../../intermediaries/apis/IntermediaryAPI";
import {RequestError} from "../../../../errors/RequestError";
import {BTC_NETWORK, TEST_NETWORK} from "@scure/btc-signer/utils";
import {UnifiedSwapEventListener} from "../../../../events/UnifiedSwapEventListener";
import {UnifiedSwapStorage} from "../../../../storage/UnifiedSwapStorage";
import {ISwap} from "../../../ISwap";
import {AmountData} from "../../../../types/AmountData";
import {tryWithRetries} from "../../../../utils/RetryUtils";
import {AllOptional} from "../../../../utils/TypeUtils";
import {ToBTCSwapState} from "../IToBTCSwap";
export type ToBTCOptions = {
/**
* @deprecated Ignored by the LP anyway
*/
confirmationTarget?: number,
/**
* @deprecated Default 2 confirmations should be enough for any currently supported amount by atomiq
*/
confirmations?: number
}
export type ToBTCWrapperOptions = ISwapWrapperOptions & {
safetyFactor: number,
maxConfirmations: number,
bitcoinNetwork: BTC_NETWORK,
bitcoinBlocktime: number,
maxExpectedOnchainSendSafetyFactor: number,
maxExpectedOnchainSendGracePeriodBlocks: number,
};
export type ToBTCDefinition<T extends ChainType> = IToBTCDefinition<T, ToBTCWrapper<T>, ToBTCSwap<T>>;
/**
* Escrow based (PrTLC) swap for Smart chains -> Bitcoin
*
* @category Swaps/Smart chain → Bitcoin
*/
export class ToBTCWrapper<T extends ChainType> extends IToBTCWrapper<T, ToBTCDefinition<T>, ToBTCWrapperOptions> {
public readonly TYPE: SwapType.TO_BTC = SwapType.TO_BTC;
/**
* @internal
*/
readonly _swapDeserializer = ToBTCSwap;
/**
* @internal
*/
readonly _btcRpc: BitcoinRpc<any>;
/**
* @param chainIdentifier
* @param unifiedStorage Storage interface for the current environment
* @param unifiedChainEvents Smart chain on-chain event listener
* @param chain
* @param versionedContracts Chain specific versioned contracts
* @param prices Swap pricing handler
* @param tokens
* @param btcRpc Bitcoin RPC api
* @param lpApi
* @param options
* @param events Instance to use for emitting events
*/
constructor(
chainIdentifier: string,
unifiedStorage: UnifiedSwapStorage<T>,
unifiedChainEvents: UnifiedSwapEventListener<T>,
chain: T["ChainInterface"],
prices: ISwapPrice,
tokens: WrapperCtorTokens,
versionedContracts: {
[version: string]: {
swapContract: T["Contract"],
swapDataConstructor: new (data: any) => T["Data"]
}
},
btcRpc: BitcoinRpc<any>,
lpApi: IntermediaryAPI,
options?: AllOptional<ToBTCWrapperOptions>,
events?: EventEmitter<{swapState: [ISwap]}>
) {
super(
chainIdentifier, unifiedStorage, unifiedChainEvents, chain, prices, tokens, lpApi,
{
...options,
bitcoinNetwork: options?.bitcoinNetwork ?? TEST_NETWORK,
safetyFactor: options?.safetyFactor ?? 2,
maxConfirmations: options?.maxConfirmations ?? 6,
bitcoinBlocktime: options?.bitcoinBlocktime ?? (60*10),
maxExpectedOnchainSendSafetyFactor: options?.maxExpectedOnchainSendSafetyFactor ?? 4,
maxExpectedOnchainSendGracePeriodBlocks: options?.maxExpectedOnchainSendGracePeriodBlocks ?? 12,
},
versionedContracts,
events
);
this._btcRpc = btcRpc;
}
/**
* Returns randomly generated random bitcoin transaction nonce to be used for BTC on-chain swaps
*
* @returns Escrow nonce
*
* @private
*/
private getRandomNonce(): bigint {
const firstPart = BigInt(Math.floor((Date.now()/1000)) - 700000000);
return (firstPart << 24n) | BigIntBufferUtils.fromBuffer(randomBytes(3));
}
/**
* Converts bitcoin address to its corresponding output script
*
* @param addr Bitcoin address to get the output script for
*
* @returns Output script as Buffer
* @throws {UserError} if invalid address is specified
*
* @private
*/
private btcAddressToOutputScript(addr: string): Buffer {
try {
return toOutputScript(this._options.bitcoinNetwork, addr);
} catch (e) {
throw new UserError("Invalid address specified");
}
}
/**
* Verifies returned LP data
*
* @param signer
* @param resp LP's response
* @param amountData
* @param lp
* @param options Options as passed to the swap create function
* @param data LP's returned parsed swap data
* @param hash Payment hash of the swap
*
* @throws {IntermediaryError} if returned data are not correct
*
* @private
*/
private verifyReturnedData(
signer: string,
resp: ToBTCResponseType,
amountData: AmountData,
lp: Intermediary,
options: {
confirmations: number,
confirmationTarget: number
},
data: T["Data"],
hash: string
): void {
if(resp.totalFee !== (resp.swapFee + resp.networkFee)) throw new IntermediaryError("Invalid totalFee returned");
if(amountData.exactIn) {
if(resp.total !== amountData.amount) throw new IntermediaryError("Invalid total returned");
} else {
if(resp.amount !== amountData.amount) throw new IntermediaryError("Invalid amount returned");
}
const maxAllowedBlockDelta: bigint = BigInt(
options.confirmations +
options.confirmationTarget +
this._options.maxExpectedOnchainSendGracePeriodBlocks
);
const maxAllowedExpiryDelta: bigint = maxAllowedBlockDelta
* BigInt(this._options.maxExpectedOnchainSendSafetyFactor)
* BigInt(this._options.bitcoinBlocktime);
const currentTimestamp: bigint = BigInt(Math.floor(Date.now()/1000));
const maxAllowedExpiryTimestamp: bigint = currentTimestamp + maxAllowedExpiryDelta;
if(data.getExpiry() > maxAllowedExpiryTimestamp) {
throw new IntermediaryError("Expiry time returned too high!");
}
if(
data.getAmount() !== resp.total ||
data.getClaimHash()!==hash ||
data.getType()!==ChainSwapType.CHAIN_NONCED ||
!data.isPayIn() ||
!data.isToken(amountData.token) ||
!data.isClaimer(lp.getAddress(this.chainIdentifier)) ||
!data.isOfferer(signer) ||
data.getTotalDeposit() !== 0n
) {
throw new IntermediaryError("Invalid data returned");
}
}
/**
* Returns a newly created Smart chain -> Bitcoin swap using the PrTLC based escrow swap protocol,
* with the passed amount.
*
* @param signer Source chain signer address initiating the swap
* @param recipient Recipient bitcoin on-chain address
* @param amountData Amount, token and exact input/output data for to swap
* @param lps An array of intermediaries (LPs) to get the quotes from
* @param options Optional additional quote options
* @param additionalParams Optional additional parameters sent to the LP when creating the swap
* @param abortSignal Abort signal
*/
create(
signer: string,
recipient: string,
amountData: AmountData,
lps: Intermediary[],
options?: ToBTCOptions,
additionalParams?: Record<string, any>,
abortSignal?: AbortSignal
): {
quote: Promise<ToBTCSwap<T>>,
intermediary: Intermediary
}[] {
if(!this.isInitialized) throw new Error("Not initialized, call init() first!");
const _options = {
confirmationTarget: options?.confirmationTarget ?? 3,
confirmations: options?.confirmations ?? 2
};
const lpVersions = Intermediary.getContractVersionsForLps(this.chainIdentifier, lps);
const nonce: bigint = this.getRandomNonce();
const outputScript: Buffer = this.btcAddressToOutputScript(recipient);
const _hash = !amountData.exactIn ?
mapArrayToObject(lpVersions, (contractVersion: string) => {
return this._contract(contractVersion).getHashForOnchain(outputScript, amountData.amount, _options.confirmations, nonce).toString("hex");
}) :
undefined;
const _abortController = extendAbortController(abortSignal);
const pricePreFetchPromise: Promise<bigint | undefined> = this.preFetchPrice(amountData, _abortController.signal);
const usdPricePrefetchPromise: Promise<number | undefined> = this.preFetchUsdPrice(_abortController.signal);
const feeRatePromise = this.preFetchFeeRate(signer, amountData, _hash, _abortController, lpVersions);
const _signDataPromise = mapArrayToObject(lpVersions, (contractVersion: string) => {
return this._contract(contractVersion).preFetchBlockDataForSignatures==null ?
this.preFetchSignData(Promise.resolve(true), contractVersion) :
undefined;
});
return lps.map(lp => {
return {
intermediary: lp,
quote: (async () => {
if(lp.services[SwapType.TO_BTC]==null) throw new Error("LP service for processing to btc swaps not found!");
const version = lp.getContractVersion(this.chainIdentifier);
const abortController = extendAbortController(_abortController.signal);
const reputationPromise: Promise<SingleChainReputationType | undefined> = this.preFetchIntermediaryReputation(amountData, lp, abortController, version);
try {
const {signDataPromise, resp} = await tryWithRetries(async(retryCount) => {
const {signDataPrefetch, response} = this._lpApi.initToBTC(this.chainIdentifier, lp.url, {
btcAddress: recipient,
amount: amountData.amount,
confirmationTarget: _options.confirmationTarget,
confirmations: _options.confirmations,
nonce: nonce,
token: amountData.token,
offerer: signer,
exactIn: amountData.exactIn,
feeRate: throwIfUndefined(feeRatePromise[version], "Network fee rate pre-fetch failed!"),
additionalParams
}, this._options.postRequestTimeout, abortController.signal, retryCount>0 ? false : undefined);
let signDataPromise = _signDataPromise[version];
if(signDataPromise==null) {
signDataPromise = this.preFetchSignData(signDataPrefetch, version);
} else signDataPrefetch.catch(() => {});
return {
signDataPromise,
resp: await response
};
}, undefined, RequestError, abortController.signal);
let hash: string = _hash?.[version] ?? this._contract(version).getHashForOnchain(outputScript, resp.amount, _options.confirmations, nonce).toString("hex");
const data: T["Data"] = new (this._swapDataDeserializer(version))(resp.data);
data.setOfferer(signer);
const inputWithoutFees = data.getAmount() - resp.swapFee - resp.networkFee;
const swapFeeBtc = resp.swapFee * resp.amount / inputWithoutFees;
const networkFeeBtc = resp.networkFee * resp.amount / inputWithoutFees;
this.verifyReturnedData(signer, resp, amountData, lp, _options, data, hash);
const [pricingInfo, signatureExpiry, reputation] = await Promise.all([
this.verifyReturnedPrice(
lp.services[SwapType.TO_BTC], true, resp.amount, data.getAmount(),
amountData.token, {networkFee: resp.networkFee, swapFeeBtc}, pricePreFetchPromise, usdPricePrefetchPromise, abortController.signal
),
this.verifyReturnedSignature(signer, data, resp, feeRatePromise[version], signDataPromise, version, abortController.signal),
reputationPromise
]);
abortController.signal.throwIfAborted();
if(reputation!=null) lp.reputation[amountData.token.toString()] = reputation;
const quote = new ToBTCSwap<T>(this, {
pricingInfo,
url: lp.url,
expiry: signatureExpiry,
swapFee: resp.swapFee,
swapFeeBtc,
feeRate: (await feeRatePromise[version])!,
signatureData: resp,
data,
networkFee: resp.networkFee,
networkFeeBtc,
address: recipient,
amount: resp.amount,
confirmationTarget: _options.confirmationTarget,
satsPerVByte: Number(resp.satsPervByte),
exactIn: amountData.exactIn,
requiredConfirmations: _options.confirmations,
nonce,
contractVersion: version
} as ToBTCSwapInit<T["Data"]>);
return quote;
} catch (e) {
abortController.abort(e);
throw e;
}
})()
}
});
}
/**
* @inheritDoc
*/
async recoverFromSwapDataAndState(
init: {data: T["Data"], getInitTxId: () => Promise<string>, getTxBlock: () => Promise<{blockTime: number, blockHeight: number}>},
state: SwapCommitState,
contractVersion: string,
lp?: Intermediary
): Promise<ToBTCSwap<T> | null> {
const data = init.data;
const swapInit: ToBTCSwapInit<T["Data"]> = {
pricingInfo: {
isValid: true,
satsBaseFee: 0n,
swapPriceUSatPerToken: 100_000_000_000_000n,
realPriceUSatPerToken: 100_000_000_000_000n,
differencePPM: 0n,
feePPM: 0n,
},
url: lp?.url,
expiry: 0,
swapFee: 0n,
swapFeeBtc: 0n,
confirmationTarget: 1,
satsPerVByte: 0,
feeRate: "",
signatureData: undefined,
nonce: data.getNonceHint() ?? undefined,
requiredConfirmations: data.getConfirmationsHint() ?? undefined,
data,
networkFee: 0n,
networkFeeBtc: 0n,
exactIn: true,
contractVersion
};
const swap = new ToBTCSwap(this, swapInit);
swap._commitTxId = await init.getInitTxId();
const blockData = await init.getTxBlock();
swap.createdAt = blockData.blockTime * 1000;
swap._setInitiated();
swap._state = ToBTCSwapState.COMMITED;
await swap._sync(false, false, state);
await swap._save();
return swap;
}
}