UNPKG

@atomiqlabs/sdk

Version:

atomiq labs SDK for cross-chain swaps between smart chains and bitcoin

844 lines (759 loc) 29.4 kB
import {SwapType} from "../../../enums/SwapType"; import {ChainType} from "@atomiqlabs/base"; import {toBigInt} from "../../../utils/Utils"; import {parsePsbtTransaction, toOutputScript} from "../../../utils/BitcoinUtils"; import {isISwapInit, ISwap, ISwapInit} from "../../ISwap"; import {TrustedAddressStatusResponseCodes} from "../../../intermediaries/apis/IntermediaryAPI"; import {OnchainForGasSwapTypeDefinition, OnchainForGasWrapper} from "./OnchainForGasWrapper"; import {Fee} from "../../../types/fees/Fee"; import {IBitcoinWallet, isIBitcoinWallet} from "../../../bitcoin/wallet/IBitcoinWallet"; import {IAddressSwap} from "../../IAddressSwap"; import {IBTCWalletSwap} from "../../IBTCWalletSwap"; import {Transaction} from "@scure/btc-signer"; import {SingleAddressBitcoinWallet} from "../../../bitcoin/wallet/SingleAddressBitcoinWallet"; import {Buffer} from "buffer"; import { MinimalBitcoinWalletInterface, MinimalBitcoinWalletInterfaceWithSigner } from "../../../types/wallets/MinimalBitcoinWalletInterface"; import {FeeType} from "../../../enums/FeeType"; import {ppmToPercentage} from "../../../types/fees/PercentagePPM"; 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"; /** * State enum for trusted on-chain gas swaps * * @category Swaps/Trusted Gas Swaps */ export enum OnchainForGasSwapState { /** * The swap quote expired without user sending in the BTC */ EXPIRED = -3, /** * The swap has failed after the intermediary already received the BTC on the source chain */ FAILED = -2, /** * Swap was refunded and BTC returned to the user's refund address */ REFUNDED = -1, /** * Swap was created, send the BTC to the swap address */ PR_CREATED = 0, /** * The swap is finished after the intermediary sent funds on the destination chain */ FINISHED = 1, /** * Swap is refundable because the intermediary cannot honor the swap request on the destination chain */ REFUNDABLE = 2 } const OnchainForGasSwapStateDescription: Record<OnchainForGasSwapState, string> = { [OnchainForGasSwapState.EXPIRED]: "The swap quote expired without user sending in the BTC", [OnchainForGasSwapState.FAILED]: "The swap has failed after the intermediary already received the BTC on the source chain", [OnchainForGasSwapState.REFUNDED]: "Swap was refunded and BTC returned to the user's refund address", [OnchainForGasSwapState.PR_CREATED]: "Swap was created, send the BTC to the swap address", [OnchainForGasSwapState.FINISHED]: "The swap is finished after the intermediary sent funds on the destination chain", [OnchainForGasSwapState.REFUNDABLE]: "Swap is refundable because the intermediary cannot honor the swap request on the destination chain", }; 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); } /** * Trusted swap for Bitcoin -> Smart chains, to be used for minor amounts to get gas tokens on the * destination chain, which is only needed for Solana, which still uses legacy swaps * * @category Swaps/Trusted Gas Swaps */ export class OnchainForGasSwap<T extends ChainType = ChainType> extends ISwap<T, OnchainForGasSwapTypeDefinition<T>, OnchainForGasSwapState> implements IAddressSwap, IBTCWalletSwap { protected readonly TYPE: SwapType.TRUSTED_FROM_BTC = SwapType.TRUSTED_FROM_BTC; /** * @internal */ protected readonly swapStateDescription = OnchainForGasSwapStateDescription; /** * @internal */ protected readonly swapStateName = (state: number) => OnchainForGasSwapState[state]; /** * @internal */ protected readonly logger: LoggerType; //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 /** * Destination transaction ID on the smart chain side * @private */ private scTxId?: string; /** * Source transaction ID on the source (bitcoin) side * @private */ private txId?: string; //State: REFUNDED /** * Transaction ID on the source (bitcoin) side used for refunding the funds back to the user * @private */ private refundTxId?: string; /** * @internal */ protected readonly 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!=null) initOrObj.url += "/frombtc_trusted"; super(wrapper, initOrObj); this.wrapper = wrapper; if(isOnchainForGasSwapInit(initOrObj)) { this.paymentHash = initOrObj.paymentHash; this.sequence = initOrObj.sequence; this.address = initOrObj.address; this.inputAmount = initOrObj.inputAmount; this.outputAmount = initOrObj.outputAmount; this.recipient = initOrObj.recipient; this.token = initOrObj.token; this.refundAddress = initOrObj.refundAddress; this._state = OnchainForGasSwapState.PR_CREATED; } else { this.paymentHash = initOrObj.paymentHash; this.sequence = toBigInt(initOrObj.sequence); this.address = initOrObj.address; this.inputAmount = toBigInt(initOrObj.inputAmount); this.outputAmount = toBigInt(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(); } /** * @inheritDoc * @internal */ protected upgradeVersion() { if(this.version == null) { //Noop this.version = 1; } } /** * @inheritDoc * @internal */ protected tryRecomputeSwapPrice() { if(this.swapFeeBtc==null && this.swapFee!=null) { this.swapFeeBtc = this.swapFee * this.getInput().rawAmount / this.getOutAmountWithoutFee(); } super.tryRecomputeSwapPrice(); } ////////////////////////////// //// Getters & utils /** * @inheritDoc * @internal */ _getEscrowHash(): string { return this.paymentHash; } /** * @inheritDoc */ getOutputAddress(): string | null { return this.recipient; } /** * @inheritDoc */ getInputAddress(): string | null { //TODO: Fuck this, it's not used anyway return null; } /** * @inheritDoc */ getInputTxId(): string | null { return this.txId ?? null; } /** * @inheritDoc */ getOutputTxId(): string | null { return this.scTxId ?? null; } /** * @inheritDoc */ getId(): string { return this.paymentHash; } /** * @inheritDoc */ getAddress(): string { return this.address; } /** * @inheritDoc */ getHyperlink(): string { return "bitcoin:"+this.address+"?amount="+encodeURIComponent((Number(this.inputAmount)/100000000).toString(10)); } /** * @inheritDoc */ requiresAction(): boolean { return this._state===OnchainForGasSwapState.REFUNDABLE; } /** * @inheritDoc */ isFinished(): boolean { return this._state===OnchainForGasSwapState.FINISHED || this._state===OnchainForGasSwapState.FAILED || this._state===OnchainForGasSwapState.EXPIRED || this._state===OnchainForGasSwapState.REFUNDED; } /** * @inheritDoc */ isQuoteExpired(): boolean { return this._state===OnchainForGasSwapState.EXPIRED; } /** * @inheritDoc */ isQuoteSoftExpired(): boolean { return this.expiry<Date.now(); } /** * @inheritDoc */ isFailed(): boolean { return this._state===OnchainForGasSwapState.FAILED; } /** * @inheritDoc */ isSuccessful(): boolean { return this._state===OnchainForGasSwapState.FINISHED; } /** * @inheritDoc */ isInProgress(): boolean { return (this._state===OnchainForGasSwapState.PR_CREATED && this.txId!=null) || (this._state===OnchainForGasSwapState.REFUNDABLE && this.refundAddress!=null); } /** * @inheritDoc * @internal */ _verifyQuoteDefinitelyExpired(): Promise<boolean> { return Promise.resolve(this.expiry<Date.now()); } /** * @inheritDoc * @internal */ _verifyQuoteValid(): Promise<boolean> { return Promise.resolve(this.expiry>Date.now()); } ////////////////////////////// //// Amounts & fees /** * Returns an output amount in base units without a swap fee included, hence this value * is larger than the actual output amount * * @internal */ protected getOutAmountWithoutFee(): bigint { return this.outputAmount + (this.swapFee ?? 0n); } /** * @inheritDoc */ getOutputToken(): SCToken<T["ChainId"]> { return this.wrapper._tokens[this.wrapper._chain.getNativeCurrencyAddress()]; } /** * @inheritDoc */ getOutput(): TokenAmount<SCToken<T["ChainId"]>, true> { return toTokenAmount( this.outputAmount, this.wrapper._tokens[this.wrapper._chain.getNativeCurrencyAddress()], this.wrapper._prices, this.pricingInfo ); } /** * @inheritDoc */ getInputToken(): BtcToken<false> { return BitcoinTokens.BTC; } /** * @inheritDoc */ getInput(): TokenAmount<BtcToken<false>, true> { return toTokenAmount(this.inputAmount, BitcoinTokens.BTC, this.wrapper._prices, this.pricingInfo); } /** * @inheritDoc */ getInputWithoutFee(): TokenAmount<BtcToken<false>, true> { return toTokenAmount( this.inputAmount - (this.swapFeeBtc ?? 0n), BitcoinTokens.BTC, this.wrapper._prices, this.pricingInfo ); } /** * Returns the swap fee charged by the intermediary (LP) on this swap * * @internal */ protected getSwapFee(): Fee<T["ChainId"], BtcToken<false>, SCToken<T["ChainId"]>> { if(this.pricingInfo==null) throw new Error("No pricing info known!"); const feeWithoutBaseFee = this.swapFeeBtc==null ? 0n : this.swapFeeBtc - this.pricingInfo.satsBaseFee; const swapFeePPM = feeWithoutBaseFee * 1000000n / this.getInputWithoutFee().rawAmount; const amountInSrcToken = toTokenAmount( this.swapFeeBtc ?? 0n, BitcoinTokens.BTC, this.wrapper._prices, this.pricingInfo ); return { amountInSrcToken, amountInDstToken: toTokenAmount( this.swapFee ?? 0n, this.wrapper._tokens[this.wrapper._chain.getNativeCurrencyAddress()], this.wrapper._prices, this.pricingInfo ), currentUsdValue: amountInSrcToken.currentUsdValue, usdValue: amountInSrcToken.usdValue, pastUsdValue: amountInSrcToken.pastUsdValue, composition: { base: toTokenAmount(this.pricingInfo.satsBaseFee, BitcoinTokens.BTC, this.wrapper._prices, this.pricingInfo), percentage: ppmToPercentage(swapFeePPM) } }; } /** * @inheritDoc */ getFee(): Fee<T["ChainId"], BtcToken<false>, SCToken<T["ChainId"]>> { return this.getSwapFee(); } /** * @inheritDoc */ getFeeBreakdown(): [{type: FeeType.SWAP, fee: Fee<T["ChainId"], BtcToken<false>, SCToken<T["ChainId"]>>}] { return [{ type: FeeType.SWAP, fee: this.getSwapFee() }]; } /** * @inheritDoc */ getRequiredConfirmationsCount(): number { return 1; } /** * @inheritDoc */ 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 }; } /** * @inheritDoc */ 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(output0.script==null || !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")); } /** * @inheritDoc */ async estimateBitcoinFee(_bitcoinWallet: IBitcoinWallet | MinimalBitcoinWalletInterface, feeRate?: number): Promise<TokenAmount<BtcToken<false>, true> | null> { const bitcoinWallet: IBitcoinWallet = toBitcoinWallet(_bitcoinWallet, this.wrapper._btcRpc, this.wrapper._options.bitcoinNetwork); const txFee = await bitcoinWallet.getTransactionFee(this.address, this.inputAmount, feeRate); if(txFee==null) return null; return toTokenAmount(BigInt(txFee), BitcoinTokens.BTC, this.wrapper._prices, this.pricingInfo); } /** * @inheritDoc */ 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); } } /** * @inheritDoc * * @param options.bitcoinWallet Optional bitcoin wallet address specification to return a funded PSBT, * if not provided an address is returned instead. */ async getExecutionAction(options?: { bitcoinWallet?: MinimalBitcoinWalletInterface }): Promise<never> { throw new Error("Not supported"); } /** * @inheritDoc */ async getExecutionSteps(): Promise<never> { throw new Error("Not supported"); } /** * @inheritDoc */ async getExecutionStatus(): Promise<never> { throw new Error("Not supported"); } /** * @internal * @inheritDoc */ _submitExecutionTransactions(): Promise<string[]> { throw new Error("Not supported"); } /** * @remarks Not supported */ async execute(): Promise<boolean> { throw new Error("Not supported"); } ////////////////////////////// //// Payment /** * Queries the intermediary (LP) node for the state of the swap * * @param save Whether the save the result or not * * @returns Whether the swap was successful as `boolean` or `null` if the swap is still pending * @internal */ protected async checkAddress(save: boolean = true): Promise<boolean | null> { if( this._state===OnchainForGasSwapState.FAILED || this._state===OnchainForGasSwapState.EXPIRED || this._state===OnchainForGasSwapState.REFUNDED ) return false; if(this._state===OnchainForGasSwapState.FINISHED) return false; if(this.url==null) return false; const response = await this.wrapper._lpApi.getTrustedAddressStatus( this.url, this.paymentHash, this.sequence, this.wrapper._options.getRequestTimeout ); switch(response.code) { case TrustedAddressStatusResponseCodes.AWAIT_PAYMENT: if(this.txId!=null) { this.txId = undefined; if(save) await this._save(); return true; } return false; case TrustedAddressStatusResponseCodes.AWAIT_CONFIRMATION: case TrustedAddressStatusResponseCodes.PENDING: case TrustedAddressStatusResponseCodes.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 TrustedAddressStatusResponseCodes.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 TrustedAddressStatusResponseCodes.EXPIRED: this._state = OnchainForGasSwapState.EXPIRED; if(save) await this._saveAndEmit(); return true; case TrustedAddressStatusResponseCodes.REFUNDABLE: if(this._state===OnchainForGasSwapState.REFUNDABLE) return null; this._state = OnchainForGasSwapState.REFUNDABLE; if(save) await this._saveAndEmit(); return true; case TrustedAddressStatusResponseCodes.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; } } /** * Sets the bitcoin address used for possible refunds in case something goes wrong with the swap * * @param refundAddress Bitcoin address to receive the refund to * @internal */ protected async setRefundAddress(refundAddress: string): Promise<void> { if(this.refundAddress!=null) { if(this.refundAddress!==refundAddress) throw new Error("Different refund address already set!"); return; } if(this.url==null) throw new Error("LP URL not known, cannot set refund address!"); await this.wrapper._lpApi.setTrustedRefundAddress( this.url, this.paymentHash, this.sequence, refundAddress, this.wrapper._options.getRequestTimeout ); this.refundAddress = refundAddress; } /** * @inheritDoc */ 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(); } else if(res.confirmations!=null && 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 ?? undefined); } } 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 Error("Swap expired"); if(this.isFailed()) throw new Error("Swap failed"); return this.txId!; } /** * Waits till the LP processes a refund for a failed swap. The swap must be in * {@link OnchainForGasSwapState.REFUNDABLE} state * * @param checkIntervalSeconds How often to check (default 5 seconds) * @param abortSignal Abort signal */ 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 Error("Swap expired"); if(this.isFailed()) throw new Error("Swap failed"); } /** * Requests a refund after the swap failed, this also waits till the refund is actually sent by the * intermediary (LP). The swap must be in {@link OnchainForGasSwapState.REFUNDABLE} state * * @param refundAddress Bitcoin address to receive the refund to * @param abortSignal Abort signal */ async requestRefund(refundAddress?: string, abortSignal?: AbortSignal): Promise<void> { if(refundAddress!=null) await this.setRefundAddress(refundAddress); await this.waitTillRefunded(undefined, abortSignal); } ////////////////////////////// //// Storage /** * @inheritDoc */ 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, }; } /** * @inheritDoc * @internal */ _getInitiator(): string { return this.recipient; } ////////////////////////////// //// Swap ticks & sync /** * @inheritDoc * @internal */ 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; } /** * @inheritDoc * @internal */ _tick(save?: boolean): Promise<boolean> { return Promise.resolve(false); } }