@atomiqlabs/sdk-lib
Version:
Basic SDK functionality library for atomiq
556 lines (483 loc) • 22.4 kB
text/typescript
import {SwapType} from "../../enums/SwapType";
import {ChainType} from "@atomiqlabs/base";
import {PaymentAuthError} from "../../../errors/PaymentAuthError";
import {getLogger, timeoutPromise} from "../../../utils/Utils";
import {toOutputScript} from "../../../utils/BitcoinUtils";
import {parsePsbtTransaction, toBitcoinWallet} from "../../../utils/BitcoinHelpers";
import {isISwapInit, ISwap, ISwapInit, ppmToPercentage} from "../../ISwap";
import {
AddressStatusResponseCodes,
TrustedIntermediaryAPI
} from "../../../intermediaries/TrustedIntermediaryAPI";
import {BitcoinTokens, BtcToken, SCToken, TokenAmount, toTokenAmount} from "../../../Tokens";
import {OnchainForGasWrapper} from "./OnchainForGasWrapper";
import {Fee, FeeType} from "../../fee/Fee";
import {IBitcoinWallet, isIBitcoinWallet} from "../../../btc/wallet/IBitcoinWallet";
import {IAddressSwap} from "../../IAddressSwap";
import {IBTCWalletSwap} from "../../IBTCWalletSwap";
import {Transaction} from "@scure/btc-signer";
import {SingleAddressBitcoinWallet} from "../../../btc/wallet/SingleAddressBitcoinWallet";
import {Buffer} from "buffer";
import {
MinimalBitcoinWalletInterface,
MinimalBitcoinWalletInterfaceWithSigner
} from "../../../btc/wallet/MinimalBitcoinWalletInterface";
export enum OnchainForGasSwapState {
EXPIRED = -3,
FAILED = -2,
REFUNDED = -1,
PR_CREATED = 0,
FINISHED = 1,
REFUNDABLE = 2
}
export type OnchainForGasSwapInit = ISwapInit & {
paymentHash: string;
sequence: bigint;
address: string;
inputAmount: bigint;
outputAmount: bigint;
recipient: string;
token: string;
refundAddress?: string;
};
export function isOnchainForGasSwapInit(obj: any): obj is OnchainForGasSwapInit {
return typeof(obj.paymentHash)==="string" &&
typeof(obj.sequence)==="bigint" &&
typeof(obj.address)==="string" &&
typeof(obj.inputAmount)==="bigint" &&
typeof(obj.outputAmount)==="bigint" &&
typeof(obj.recipient)==="string" &&
typeof(obj.token)==="string" &&
(obj.refundAddress==null || typeof(obj.refundAddress)==="string") &&
isISwapInit(obj);
}
export class OnchainForGasSwap<T extends ChainType = ChainType> extends ISwap<T, OnchainForGasSwapState> implements IAddressSwap, IBTCWalletSwap {
getSmartChainNetworkFee = null;
protected readonly TYPE: SwapType = SwapType.TRUSTED_FROM_BTC;
//State: PR_CREATED
private readonly paymentHash: string;
private readonly sequence: bigint;
private readonly address: string;
private readonly recipient: string;
private readonly token: string;
private inputAmount: bigint;
private outputAmount: bigint;
private refundAddress: string;
//State: FINISHED
scTxId: string;
txId: string;
//State: REFUNDED
refundTxId: string;
wrapper: OnchainForGasWrapper<T>;
constructor(wrapper: OnchainForGasWrapper<T>, init: OnchainForGasSwapInit);
constructor(wrapper: OnchainForGasWrapper<T>, obj: any);
constructor(
wrapper: OnchainForGasWrapper<T>,
initOrObj: OnchainForGasSwapInit | any
) {
if(isOnchainForGasSwapInit(initOrObj)) initOrObj.url += "/frombtc_trusted";
super(wrapper, initOrObj);
if(isOnchainForGasSwapInit(initOrObj)) {
this.state = OnchainForGasSwapState.PR_CREATED;
} else {
this.paymentHash = initOrObj.paymentHash;
this.sequence = initOrObj.sequence==null ? null : BigInt(initOrObj.sequence);
this.address = initOrObj.address;
this.inputAmount = initOrObj.inputAmount==null ? null : BigInt(initOrObj.inputAmount);
this.outputAmount = initOrObj.outputAmount==null ? null : BigInt(initOrObj.outputAmount);
this.recipient = initOrObj.recipient;
this.token = initOrObj.token;
this.refundAddress = initOrObj.refundAddress;
this.scTxId = initOrObj.scTxId;
this.txId = initOrObj.txId;
this.refundTxId = initOrObj.refundTxId;
}
this.logger = getLogger("OnchainForGas("+this.getId()+"): ");
this.tryRecomputeSwapPrice();
}
protected upgradeVersion() {
if(this.version == null) {
//Noop
this.version = 1;
}
}
/**
* In case swapFee in BTC is not supplied it recalculates it based on swap price
* @protected
*/
protected tryRecomputeSwapPrice() {
if(this.swapFeeBtc==null) {
this.swapFeeBtc = this.swapFee * this.getInput().rawAmount / this.getOutAmountWithoutFee();
}
super.tryRecomputeSwapPrice();
}
//////////////////////////////
//// Getters & utils
_getEscrowHash(): string {
return this.paymentHash;
}
getOutputAddress(): string | null {
return this.recipient;
}
getInputTxId(): string | null {
return this.txId;
}
getOutputTxId(): string | null {
return this.scTxId;
}
getId(): string {
return this.paymentHash;
}
getAddress(): string {
return this.address;
}
getHyperlink(): string {
return "bitcoin:"+this.address+"?amount="+encodeURIComponent((Number(this.inputAmount)/100000000).toString(10));
}
requiresAction(): boolean {
return this.state===OnchainForGasSwapState.REFUNDABLE;
}
isFinished(): boolean {
return this.state===OnchainForGasSwapState.FINISHED || this.state===OnchainForGasSwapState.FAILED || this.state===OnchainForGasSwapState.EXPIRED || this.state===OnchainForGasSwapState.REFUNDED;
}
isQuoteExpired(): boolean {
return this.state===OnchainForGasSwapState.EXPIRED;
}
isQuoteSoftExpired(): boolean {
return this.expiry<Date.now();
}
isFailed(): boolean {
return this.state===OnchainForGasSwapState.FAILED;
}
isSuccessful(): boolean {
return this.state===OnchainForGasSwapState.FINISHED;
}
verifyQuoteValid(): Promise<boolean> {
return Promise.resolve(this.expiry>Date.now());
}
//////////////////////////////
//// Amounts & fees
protected getOutAmountWithoutFee(): bigint {
return this.outputAmount + this.swapFee;
}
getOutput(): TokenAmount<T["ChainId"], SCToken<T["ChainId"]>> {
return toTokenAmount(this.outputAmount, this.wrapper.tokens[this.wrapper.chain.getNativeCurrencyAddress()], this.wrapper.prices);
}
getInput(): TokenAmount<T["ChainId"], BtcToken<false>> {
return toTokenAmount(this.inputAmount, BitcoinTokens.BTC, this.wrapper.prices);
}
getInputWithoutFee(): TokenAmount<T["ChainId"], BtcToken<false>> {
return toTokenAmount(this.inputAmount - this.swapFeeBtc, BitcoinTokens.BTC, this.wrapper.prices);
}
protected getSwapFee(): Fee<T["ChainId"], BtcToken<false>, SCToken<T["ChainId"]>> {
const feeWithoutBaseFee = this.swapFeeBtc - this.pricingInfo.satsBaseFee;
const swapFeePPM = feeWithoutBaseFee * 1000000n / this.getInputWithoutFee().rawAmount;
return {
amountInSrcToken: toTokenAmount(this.swapFeeBtc, BitcoinTokens.BTC, this.wrapper.prices),
amountInDstToken: toTokenAmount(this.swapFee, this.wrapper.tokens[this.wrapper.chain.getNativeCurrencyAddress()], this.wrapper.prices),
usdValue: (abortSignal?: AbortSignal, preFetchedUsdPrice?: number) =>
this.wrapper.prices.getBtcUsdValue(this.swapFeeBtc, abortSignal, preFetchedUsdPrice),
composition: {
base: toTokenAmount(this.pricingInfo.satsBaseFee, BitcoinTokens.BTC, this.wrapper.prices),
percentage: ppmToPercentage(swapFeePPM)
}
};
}
getFee(): Fee<T["ChainId"], BtcToken<false>, SCToken<T["ChainId"]>> {
return this.getSwapFee();
}
getFeeBreakdown(): [{type: FeeType.SWAP, fee: Fee<T["ChainId"], BtcToken<false>, SCToken<T["ChainId"]>>}] {
return [{
type: FeeType.SWAP,
fee: this.getSwapFee()
}];
}
getRequiredConfirmationsCount(): number {
return 1;
}
/**
* 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!==OnchainForGasSwapState.PR_CREATED)
throw new Error("Swap already paid for!");
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.outputAmount,
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!==OnchainForGasSwapState.PR_CREATED)
throw new Error("Swap already paid for!");
//Ensure not expired
if(this.expiry<Date.now()) {
throw new Error("Swap expired!");
}
const output0 = psbt.getOutput(0);
if(output0.amount!==this.outputAmount)
throw new Error("PSBT output amount invalid, expected: "+this.outputAmount+" 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.inputAmount, 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!==OnchainForGasSwapState.PR_CREATED)
throw new Error("Swap already paid for!");
//Ensure not expired
if(this.expiry<Date.now()) {
throw new Error("Swap expired!");
}
if(isIBitcoinWallet(wallet)) {
return await wallet.sendTransaction(this.address, this.inputAmount, 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);
}
}
//////////////////////////////
//// Payment
protected async checkAddress(save: boolean = true): Promise<boolean> {
if(
this.state===OnchainForGasSwapState.FAILED ||
this.state===OnchainForGasSwapState.EXPIRED ||
this.state===OnchainForGasSwapState.REFUNDED
) return false;
if(this.state===OnchainForGasSwapState.FINISHED) return false;
const response = await TrustedIntermediaryAPI.getAddressStatus(
this.url, this.paymentHash, this.sequence, this.wrapper.options.getRequestTimeout
);
switch(response.code) {
case AddressStatusResponseCodes.AWAIT_PAYMENT:
if(this.txId!=null) {
this.txId = null;
if(save) await this._save();
return true;
}
return false;
case AddressStatusResponseCodes.AWAIT_CONFIRMATION:
case AddressStatusResponseCodes.PENDING:
case AddressStatusResponseCodes.TX_SENT:
const inputAmount = BigInt(response.data.adjustedAmount);
const outputAmount = BigInt(response.data.adjustedTotal);
const adjustedFee = response.data.adjustedFee==null ? null : BigInt(response.data.adjustedFee);
const adjustedFeeSats = response.data.adjustedFeeSats==null ? null : BigInt(response.data.adjustedFeeSats);
const txId = response.data.txId;
if(
this.txId!=txId ||
this.inputAmount !== inputAmount ||
this.outputAmount !== outputAmount
) {
this.txId = txId;
this.inputAmount = inputAmount;
this.outputAmount = outputAmount;
if(adjustedFee!=null) this.swapFee = adjustedFee;
if(adjustedFeeSats!=null) this.swapFeeBtc = adjustedFeeSats;
if(save) await this._save();
return true;
}
return false;
case AddressStatusResponseCodes.PAID:
const txStatus = await this.wrapper.chain.getTxIdStatus(response.data.txId);
if(txStatus==="success") {
this.state = OnchainForGasSwapState.FINISHED;
this.scTxId = response.data.txId;
if(save) await this._saveAndEmit();
return true;
}
return false;
case AddressStatusResponseCodes.EXPIRED:
this.state = OnchainForGasSwapState.EXPIRED;
if(save) await this._saveAndEmit();
return true;
case AddressStatusResponseCodes.REFUNDABLE:
if(this.state===OnchainForGasSwapState.REFUNDABLE) return null;
this.state = OnchainForGasSwapState.REFUNDABLE;
if(save) await this._saveAndEmit();
return true;
case AddressStatusResponseCodes.REFUNDED:
this.state = OnchainForGasSwapState.REFUNDED;
this.refundTxId = response.data.txId;
if(save) await this._saveAndEmit();
return true;
default:
this.state = OnchainForGasSwapState.FAILED;
if(save) await this._saveAndEmit();
return true;
}
}
protected async setRefundAddress(refundAddress: string): Promise<void> {
if(this.refundAddress!=null) {
if(this.refundAddress!==refundAddress) throw new Error("Different refund address already set!");
return;
}
await TrustedIntermediaryAPI.setRefundAddress(
this.url, this.paymentHash, this.sequence, refundAddress, this.wrapper.options.getRequestTimeout
);
this.refundAddress = refundAddress;
}
/**
* A blocking promise resolving when payment was received by the intermediary and client can continue
* rejecting in case of failure
*
* @param abortSignal Abort signal
* @param checkIntervalSeconds How often to poll the intermediary for answer
* @param updateCallback Callback called when txId is found, and also called with subsequent confirmations
* @throws {PaymentAuthError} If swap expired or failed
* @throws {Error} When in invalid state (not PR_CREATED)
*/
async waitForBitcoinTransaction(
updateCallback?: (txId: string, confirmations: number, targetConfirmations: number, txEtaMs: number) => void,
checkIntervalSeconds: number = 5,
abortSignal?: AbortSignal
): Promise<string> {
if(this.state!==OnchainForGasSwapState.PR_CREATED) throw new Error("Must be in PR_CREATED state!");
if(!this.initiated) {
this.initiated = true;
await this._saveAndEmit();
}
while(
!abortSignal.aborted &&
this.state===OnchainForGasSwapState.PR_CREATED
) {
await this.checkAddress(true);
if(this.txId!=null && updateCallback!=null) {
const res = await this.wrapper.btcRpc.getTransaction(this.txId);
if(res==null) {
updateCallback(null, null, 1, null);
} else if(res.confirmations>0) {
updateCallback(res.txid, res.confirmations, 1, 0);
} else {
const delay = await this.wrapper.btcRpc.getConfirmationDelay(res, 1);
updateCallback(res.txid, 0, 1, delay);
}
}
if(this.state===OnchainForGasSwapState.PR_CREATED)
await timeoutPromise(checkIntervalSeconds*1000, abortSignal);
}
if(
(this.state as OnchainForGasSwapState)===OnchainForGasSwapState.REFUNDABLE ||
(this.state as OnchainForGasSwapState)===OnchainForGasSwapState.REFUNDED
) return this.txId;
if(this.isQuoteExpired()) throw new PaymentAuthError("Swap expired");
if(this.isFailed()) throw new PaymentAuthError("Swap failed");
return this.txId;
}
async waitTillRefunded(
checkIntervalSeconds?: number,
abortSignal?: AbortSignal
): Promise<void> {
checkIntervalSeconds ??= 5;
if(this.state===OnchainForGasSwapState.REFUNDED) return;
if(this.state!==OnchainForGasSwapState.REFUNDABLE) throw new Error("Must be in REFUNDABLE state!");
while(
!abortSignal.aborted &&
this.state===OnchainForGasSwapState.REFUNDABLE
) {
await this.checkAddress(true);
if(this.state===OnchainForGasSwapState.REFUNDABLE)
await timeoutPromise(checkIntervalSeconds*1000, abortSignal);
}
if(this.isQuoteExpired()) throw new PaymentAuthError("Swap expired");
if(this.isFailed()) throw new PaymentAuthError("Swap failed");
}
async requestRefund(refundAddress?: string, abortSignal?: AbortSignal): Promise<void> {
if(refundAddress!=null) await this.setRefundAddress(refundAddress);
await this.waitTillRefunded(undefined, abortSignal);
}
//////////////////////////////
//// Storage
serialize(): any{
return {
...super.serialize(),
paymentHash: this.paymentHash,
sequence: this.sequence==null ? null : this.sequence.toString(10),
address: this.address,
inputAmount: this.inputAmount==null ? null : this.inputAmount.toString(10),
outputAmount: this.outputAmount==null ? null : this.outputAmount.toString(10),
recipient: this.recipient,
token: this.token,
refundAddress: this.refundAddress,
scTxId: this.scTxId,
txId: this.txId,
refundTxId: this.refundTxId,
};
}
_getInitiator(): string {
return this.recipient;
}
//////////////////////////////
//// Swap ticks & sync
async _sync(save?: boolean): Promise<boolean> {
if(this.state===OnchainForGasSwapState.PR_CREATED) {
//Check if it's maybe already paid
const result = await this.checkAddress(false);
if(result) {
if(save) await this._saveAndEmit();
return true;
}
}
return false;
}
_tick(save?: boolean): Promise<boolean> {
return Promise.resolve(false);
}
}