UNPKG

@atomiqlabs/chain-starknet

Version:
335 lines (286 loc) 15 kB
import {StarknetSigner} from "./StarknetSigner"; import {StarknetTransactions, StarknetTx} from "../chain/modules/StarknetTransactions"; import {StarknetChainInterface} from "../chain/StarknetChainInterface"; import {bigIntMax, getLogger, LoggerType} from "../../utils/Utils"; import {Account, BlockTag} from "starknet"; import {access, readFile, writeFile, mkdir, constants} from "fs/promises"; import {StarknetFees} from "../chain/modules/StarknetFees"; import {cloneDeep} from "@scure/btc-signer/transaction"; import { PromiseQueue } from "promise-queue-ts"; const WAIT_BEFORE_BUMP = 15*1000; const MIN_FEE_INCREASE_ABSOLUTE = 1n*1_000_000n; //0.001GWei const MIN_FEE_INCREASE_PPM = 110_000n; // +11% const MIN_TIP_INCREASE_ABSOLUTE = 1n*1_000_000_000n; //1GWei const MIN_TIP_INCREASE_PPM = 110_000n; // +11% export type StarknetPersistentSignerConfig = { waitBeforeBump?: number; minFeeIncreaseAbsolute?: bigint; minFeeIncreasePpm?: bigint minTipIncreaseAbsolute?: bigint; minTipIncreasePpm?: bigint; }; export class StarknetPersistentSigner extends StarknetSigner { private pendingTxs: Map<bigint, { txs: StarknetTx[], lastBumped: number, sending?: boolean //Not saved }> = new Map(); private confirmedNonce: bigint; private pendingNonce: bigint; private feeBumper: any; private stopped: boolean = false; private readonly directory: string; private readonly config: StarknetPersistentSignerConfig private readonly chainInterface: StarknetChainInterface; private readonly logger: LoggerType; constructor( account: Account, chainInterface: StarknetChainInterface, directory: string, config?: StarknetPersistentSignerConfig, ) { super(account, true); this.signTransaction = null; this.chainInterface = chainInterface; this.directory = directory; this.config = config ?? {}; this.config.minFeeIncreaseAbsolute ??= MIN_FEE_INCREASE_ABSOLUTE; this.config.minFeeIncreasePpm ??= MIN_FEE_INCREASE_PPM; this.config.minTipIncreaseAbsolute ??= MIN_TIP_INCREASE_ABSOLUTE; this.config.minTipIncreasePpm ??= MIN_TIP_INCREASE_PPM; this.config.waitBeforeBump ??= WAIT_BEFORE_BUMP; this.logger = getLogger("StarknetPersistentSigner("+this.account.address+"): "); } private async load() { const fileExists = await access(this.directory+"/txs.json", constants.F_OK).then(() => true).catch(() => false); if(!fileExists) return; const res = await readFile(this.directory+"/txs.json"); if(res!=null) { const pendingTxs: { [nonce: string]: { txs: string[], lastBumped: number } } = JSON.parse((res as Buffer).toString()); for(let nonceStr in pendingTxs) { const nonceData = pendingTxs[nonceStr]; const nonce = BigInt(nonceStr); if(this.confirmedNonce>=nonce) continue; //Already confirmed if(this.pendingNonce<nonce) { this.pendingNonce = nonce; } const parsedPendingTxns = nonceData.txs.map(StarknetTransactions.deserializeTx); this.pendingTxs.set(nonce, { txs: parsedPendingTxns, lastBumped: nonceData.lastBumped }) for(let tx of parsedPendingTxns) { this.chainInterface.Transactions._knownTxSet.add(tx.txId); } } } } private priorSavePromise: Promise<void>; private saveCount: number = 0; private async save() { const pendingTxs: { [nonce: string]: { txs: string[], lastBumped: number } } = {}; for(let [nonce, data] of this.pendingTxs) { pendingTxs[nonce.toString(10)] = { lastBumped: data.lastBumped, txs: data.txs.map(StarknetTransactions.serializeTx) }; } const requiredSaveCount = ++this.saveCount; if(this.priorSavePromise!=null) { await this.priorSavePromise; } if(requiredSaveCount===this.saveCount) { this.priorSavePromise = writeFile(this.directory+"/txs.json", JSON.stringify(pendingTxs)); await this.priorSavePromise; } } private async checkPastTransactions() { let _gasPrice: {l1GasCost: bigint, l2GasCost: bigint, l1DataGasCost: bigint} = null; let _safeBlockNonce: bigint = null; for(let [nonce, data] of this.pendingTxs) { if(!data.sending && data.lastBumped<Date.now()-this.config.waitBeforeBump) { if(_safeBlockNonce==null) { _safeBlockNonce = await this.chainInterface.Transactions.getNonce(this.account.address, BlockTag.LATEST); this.confirmedNonce = _safeBlockNonce - 1n; } if(this.confirmedNonce >= nonce) { this.pendingTxs.delete(nonce); data.txs.forEach(tx => this.chainInterface.Transactions._knownTxSet.delete(tx.txId)); this.logger.info("checkPastTransactions(): Tx confirmed, required fee bumps: ", data.txs.length); this.save(); continue; } const lastTx = data.txs[data.txs.length-1]; if(_gasPrice==null) { const feeRate = await this.chainInterface.Fees.getFeeRate(); _gasPrice = StarknetFees.extractFromFeeRateString(feeRate); } let l1GasCost = BigInt(lastTx.details.resourceBounds.l1_gas.max_price_per_unit); let l2GasCost = BigInt(lastTx.details.resourceBounds.l2_gas.max_price_per_unit); let l1DataGasCost = BigInt(lastTx.details.resourceBounds.l1_data_gas.max_price_per_unit); let tip = BigInt(lastTx.details.tip); let feeBumped: boolean = false; if(_gasPrice.l1GasCost > l1GasCost) { //Bump by minimum allowed or to the actual _gasPrice.l1GasCost l1GasCost = bigIntMax(_gasPrice.l1GasCost, this.config.minFeeIncreaseAbsolute + (l1GasCost * (1_000_000n + this.config.minFeeIncreasePpm) / 1_000_000n)); feeBumped = true; } if(_gasPrice.l1DataGasCost > l1DataGasCost) { //Bump by minimum allowed or to the actual _gasPrice.l1GasCost l1DataGasCost = bigIntMax(_gasPrice.l1DataGasCost, this.config.minFeeIncreaseAbsolute + (l1DataGasCost * (1_000_000n + this.config.minFeeIncreasePpm) / 1_000_000n)); feeBumped = true; } if(_gasPrice.l2GasCost > l2GasCost || feeBumped) { //In case the fees for l1 and l1Data were bumped, we also need to bump the l2GasFee regardless l2GasCost = bigIntMax(_gasPrice.l2GasCost, this.config.minFeeIncreaseAbsolute + (l2GasCost * (1_000_000n + this.config.minFeeIncreasePpm) / 1_000_000n)); feeBumped = true; } if(feeBumped) tip = this.config.minTipIncreaseAbsolute + (tip * (1_000_000n + this.config.minTipIncreasePpm) / 1_000_000n); if(!feeBumped) { //Not fee bumped this.logger.debug("checkPastTransactions(): Tx yet unconfirmed but not increasing fee for ", lastTx.txId); //Rebroadcast the tx await this.chainInterface.Transactions.sendTransaction(lastTx).catch(e => { if(e.baseError?.code === 52) { //Invalid transaction nonce this.logger.debug("checkPastTransactions(): Tx re-broadcast success, tx already confirmed: ", lastTx.txId); return; } if(e.baseError?.code === 59) { //Transaction already in the mempool this.logger.debug("checkPastTransactions(): Tx re-broadcast success, tx already known to the RPC: ", lastTx.txId); return; } this.logger.error("checkPastTransactions(): Tx re-broadcast error", e) }); data.lastBumped = Date.now(); continue; } const newTx = cloneDeep(lastTx); delete newTx.signed; delete newTx.txId; newTx.details.tip = tip; newTx.details.resourceBounds.l1_gas.max_price_per_unit = l1GasCost; newTx.details.resourceBounds.l2_gas.max_price_per_unit = l2GasCost; newTx.details.resourceBounds.l1_data_gas.max_price_per_unit = l1DataGasCost; await this._signTransaction(newTx); this.logger.info(`checkPastTransactions(): Bump fee for tx ${lastTx.txId} -> ${newTx.txId}`); //Double check pending txns still has nonce after async signTransaction was called if(!this.pendingTxs.has(nonce)) continue; for(let callback of this.chainInterface.Transactions._cbksBeforeTxReplace) { try { await callback(StarknetTransactions.serializeTx(lastTx), lastTx.txId, StarknetTransactions.serializeTx(newTx), newTx.txId) } catch (e) { this.logger.error("checkPastTransactions(): beforeTxReplace callback error: ", e); } } data.txs.push(newTx); data.lastBumped = Date.now(); this.save(); this.chainInterface.Transactions._knownTxSet.add(newTx.txId); //TODO: Better error handling when sending tx await this.chainInterface.Transactions.sendTransaction(newTx).catch(e => { if(e.baseError?.code === 52) { //Invalid transaction nonce return } this.logger.error("checkPastTransactions(): Fee-bumped tx broadcast error", e) }); } } } private startFeeBumper() { let func: () => Promise<void>; func = async () => { try { await this.checkPastTransactions(); } catch (e) { this.logger.error("startFeeBumper(): Error when check past transactions: ", e); } if(this.stopped) return; this.feeBumper = setTimeout(func, 1000); }; func(); } private async syncNonceFromChain() { const txCount = await this.chainInterface.Transactions.getNonce(this.account.address, BlockTag.LATEST); this.confirmedNonce = txCount-1n; if(this.pendingNonce < this.confirmedNonce) { this.logger.info(`syncNonceFromChain(): Re-synced latest nonce from chain, adjusting local pending nonce ${this.pendingNonce} -> ${this.confirmedNonce}`); this.pendingNonce = this.confirmedNonce; for(let [nonce, data] of this.pendingTxs) { if(nonce <= this.pendingNonce) { this.pendingTxs.delete(nonce); data.txs.forEach(tx => this.chainInterface.Transactions._knownTxSet.delete(tx.txId)); this.logger.info(`syncNonceFromChain(): Tx confirmed, nonce: ${nonce}, required fee bumps: `, data.txs.length); } } this.save(); } } async init(): Promise<void> { try { await mkdir(this.directory) } catch (e) {} const nonce = await this.chainInterface.Transactions.getNonce(this.account.address, BlockTag.LATEST); this.confirmedNonce = nonce - 1n; this.pendingNonce = nonce - 1n; await this.load(); this.startFeeBumper(); } stop(): Promise<void> { this.stopped = true; if(this.feeBumper!=null) { clearTimeout(this.feeBumper); this.feeBumper = null; } return Promise.resolve(); } private readonly sendTransactionQueue: PromiseQueue = new PromiseQueue(); sendTransaction(transaction: StarknetTx, onBeforePublish?: (txId: string, rawTx: string) => Promise<void>): Promise<string> { return this.sendTransactionQueue.enqueue(async () => { if(transaction.details.nonce!=null) { if(transaction.details.nonce !== this.pendingNonce + 1n) throw new Error("Invalid transaction nonce!"); } else { transaction.details.nonce = this.pendingNonce + 1n; } const signedTx = await this._signTransaction(transaction); if(onBeforePublish!=null) { try { await onBeforePublish(signedTx.txId, StarknetTransactions.serializeTx(signedTx)); } catch (e) { this.logger.error("sendTransaction(): Error when calling onBeforePublish function: ", e); } } const pendingTxObject = {txs: [signedTx], lastBumped: Date.now(), sending: true}; this.pendingNonce++; this.logger.debug("sendTransaction(): Incrementing pending nonce to: ", this.pendingNonce); this.pendingTxs.set(transaction.details.nonce, pendingTxObject); this.save(); this.chainInterface.Transactions._knownTxSet.add(signedTx.txId); try { const result = await this.chainInterface.Transactions.sendTransaction(signedTx); pendingTxObject.sending = false; return result; } catch (e) { this.chainInterface.Transactions._knownTxSet.delete(signedTx.txId); this.pendingTxs.delete(transaction.details.nonce); this.pendingNonce--; this.logger.debug("sendTransaction(): Error when broadcasting transaction, reverting pending nonce to: ", this.pendingNonce); if(e.baseError?.code === 52) { //Invalid transaction nonce //Re-check nonce from on-chain this.logger.info("sendTransaction(): Got INVALID_TRANSACTION_NONCE (52) back from backend, re-checking latest nonce from chain!"); await this.syncNonceFromChain(); } throw e; } }); } }