UNPKG

@atomiqlabs/chain-starknet

Version:
664 lines (663 loc) 31.9 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.StarknetTransactions = exports.isStarknetTxDeployAccount = exports.isStarknetTxInvoke = void 0; const StarknetModule_1 = require("../StarknetModule"); const starknet_1 = require("starknet"); const Utils_1 = require("../../../utils/Utils"); const base_1 = require("@atomiqlabs/base"); /** * Type-guard for the "INVOKE" type of transaction, used to call smart contracts on Starknet * * @category Chain Interface */ function isStarknetTxInvoke(obj) { return typeof (obj) === "object" && typeof (obj.details) === "object" && (obj.txId == null || typeof (obj.txId) === "string") && obj.type === "INVOKE" && Array.isArray(obj.tx) && (obj.signed == null || typeof (obj.signed) === "object"); } exports.isStarknetTxInvoke = isStarknetTxInvoke; /** * Type-guard for the "DEPLOY_ACCOUNT" type of transaction, used as a first transaction that the account does * to deploy its smart account contract on the Starknet * * @category Chain Interface */ function isStarknetTxDeployAccount(obj) { return typeof (obj) === "object" && typeof (obj.details) === "object" && (obj.txId == null || typeof (obj.txId) === "string") && obj.type === "DEPLOY_ACCOUNT" && typeof (obj.tx) === "object" && (obj.signed == null || typeof (obj.signed) === "object"); } exports.isStarknetTxDeployAccount = isStarknetTxDeployAccount; const MAX_UNCONFIRMED_TXS = 25; class StarknetTransactions extends StarknetModule_1.StarknetModule { constructor() { super(...arguments); this.latestConfirmedNonces = {}; this.latestPendingNonces = {}; this.latestSignedNonces = {}; this._cbksBeforeTxReplace = []; this.cbksBeforeTxSigned = []; this._knownTxSet = new Set(); } sendTransaction(tx) { if (tx.signed == null) throw new Error("Cannot send unsigned transaction! signed field missing!"); switch (tx.type) { case "INVOKE": return this.provider.channel.invoke(tx.signed, tx.details).then(res => res.transaction_hash); case "DEPLOY_ACCOUNT": return this.provider.channel.deployAccount(tx.signed, tx.details).then((res) => res.transaction_hash); default: throw new Error("Unsupported tx type!"); } } /** * Returns the nonce of the account or 0, if the account is not deployed yet * * @param address * @param blockTag */ async getNonce(address, blockTag = starknet_1.BlockTag.PRE_CONFIRMED) { try { return BigInt(await this.provider.getNonceForAddress(address, blockTag)); } catch (e) { if (e.baseError?.code === 20 || (e.message != null && e.message.includes("20: Contract not found"))) { return BigInt(0); } throw e; } } async confirmTransactionWs(txId, abortSignal) { if (this.root.wsChannel == null) throw new Error("Underlying provider doesn't have a WS channel!"); const subscription = await this.root.wsChannel.subscribeTransactionStatus({ transactionHash: txId }); const endSubscription = async () => { if (this.root.wsChannel.isConnected() && await subscription.unsubscribe()) return; this.root.wsChannel.removeSubscription(subscription.id); }; if (abortSignal != null && abortSignal.aborted) { await endSubscription(); abortSignal.throwIfAborted(); } const status = await new Promise((resolve, reject) => { if (abortSignal != null) abortSignal.onabort = () => { endSubscription().catch(err => this.logger.error("confirmTransactionWs(): End subscription error: ", err)); reject(abortSignal.reason); }; subscription.on((data) => { if (data.status.finality_status !== starknet_1.ETransactionStatus.ACCEPTED_ON_L2 && data.status.finality_status !== starknet_1.ETransactionStatus.ACCEPTED_ON_L1) return; //No pre-confs resolve(data.status.execution_status === starknet_1.ETransactionExecutionStatus.SUCCEEDED ? "success" : "reverted"); }); }); await endSubscription(); this.logger.debug(`confirmTransactionWs(): Transaction ${txId} confirmed, transaction status: ${status}`); return { txId, status }; } async confirmTransactionPolling(walletAddress, nonce, checkTxns, abortSignal) { let state = "pending"; let confirmedTxId; while (state === "pending") { await (0, Utils_1.timeoutPromise)(3000, abortSignal); const latestConfirmedNonce = this.latestConfirmedNonces[(0, Utils_1.toHex)(walletAddress)]; const snapshot = [...checkTxns]; //Iterate over a snapshot const totalTxnCount = snapshot.length; let rejectedTxns = 0; let notFoundTxns = 0; for (let txId of snapshot) { let _state = await this._getTxIdStatus(txId); if (_state === "not_found") notFoundTxns++; if (_state === "rejected") rejectedTxns++; if (_state === "reverted" || _state === "success") { confirmedTxId = txId; state = _state; break; } } if (rejectedTxns === totalTxnCount) { //All rejected state = "rejected"; break; } if (notFoundTxns === totalTxnCount) { //All not found, check the latest account nonce if (latestConfirmedNonce != null && latestConfirmedNonce > nonce) { //Confirmed nonce is already higher than the TX nonce, meaning the TX got replaced throw new Error("Transaction failed - replaced!"); } this.logger.warn("confirmTransaction(): All transactions not found, fetching the latest account nonce..."); const _latestConfirmedNonce = this.latestConfirmedNonces[(0, Utils_1.toHex)(walletAddress)]; const currentLatestNonce = await this.getNonce(walletAddress, starknet_1.BlockTag.LATEST); if (_latestConfirmedNonce == null || _latestConfirmedNonce < currentLatestNonce) { this.latestConfirmedNonces[(0, Utils_1.toHex)(walletAddress)] = currentLatestNonce; } } } if (state !== "rejected") this.logger.debug(`confirmTransactionPolling(): Transaction ${confirmedTxId} confirmed, transaction status: ${state}`); return { txId: confirmedTxId, status: state }; } /** * Waits for transaction confirmation using WS subscription and occasional HTTP polling, also re-sends * the transaction at regular interval * * @param tx starknet transaction to wait for confirmation for & keep re-sending until it confirms * @param abortSignal signal to abort waiting for tx confirmation * @private */ async confirmTransaction(tx, abortSignal) { if (tx.txId == null) throw new Error("txId is null!"); const abortController = new AbortController(); if (abortSignal != null) abortSignal.onabort = () => abortController.abort(abortSignal.reason); let txReplaceListener = undefined; let result; try { result = await new Promise((resolve, reject) => { const checkTxns = new Set([tx.txId]); txReplaceListener = (oldTx, oldTxId, newTx, newTxId) => { if (checkTxns.has(oldTxId)) checkTxns.add(newTxId); //TODO: Enable this once WS subscriptions finally work (also unsubscribe should work!!!!) // if(this.root.wsChannel!=null) this.confirmTransactionWs(newTxId, abortController.signal) // .then(resolve) // .catch(reject); return Promise.resolve(); }; this.onBeforeTxReplace(txReplaceListener); this.confirmTransactionPolling(tx.details.walletAddress, BigInt(tx.details.nonce), checkTxns, abortController.signal) .then(resolve) .catch(reject); //TODO: Enable this once WS subscriptions finally work (also unsubscribe should work!!!!) // if(this.root.wsChannel!=null) this.confirmTransactionWs(tx.txId!, abortController.signal) // .then(resolve) // .catch(reject); }); if (txReplaceListener != null) this.offBeforeTxReplace(txReplaceListener); abortController.abort(); } catch (e) { if (txReplaceListener != null) this.offBeforeTxReplace(txReplaceListener); abortController.abort(e); throw e; } if (result.status === "rejected") throw new Error("Transaction rejected!"); const nextAccountNonce = BigInt(tx.details.nonce) + 1n; const currentConfirmedNonce = this.latestConfirmedNonces[(0, Utils_1.toHex)(tx.details.walletAddress)]; if (currentConfirmedNonce == null || nextAccountNonce > currentConfirmedNonce) { this.latestConfirmedNonces[(0, Utils_1.toHex)(tx.details.walletAddress)] = nextAccountNonce; } if (result.status === "reverted") throw new base_1.TransactionRevertedError("Transaction reverted!"); return result.txId; } /** * Prepares starknet transactions, checks if the account is deployed, assigns nonces if needed * & calls beforeTxSigned callback (only if signer is passed!) * * @param signer * @param txs */ async prepareTransactions(txs, signer) { if (txs.length === 0) return; const signerAddress = signer?.getAddress() ?? txs[0].details.walletAddress; if (signerAddress == null) throw new Error("Cannot get tx sender address!"); let nonce = await this.getNonce(signerAddress); const latestPendingNonce = this.latestPendingNonces[(0, Utils_1.toHex)(signerAddress)]; if (latestPendingNonce != null && latestPendingNonce > nonce) { this.logger.debug("prepareTransactions(): Using 'pending' nonce from local cache!"); nonce = latestPendingNonce; } //Add deploy account tx if (nonce === 0n) { if (signer != null) { const deployPayload = await signer.getDeployPayload(); if (deployPayload != null) { const tx = await this.root.Accounts.getAccountDeployTransaction(deployPayload); tx.addedInPrepare = true; txs.unshift(tx); } } else { // Use a 0x0 class hash to indicate that deployment is needed by external signer const tx = await this.root.Accounts.getAccountDeployTransaction({ classHash: "0x0000000000000000000000000000000000000000000000000000000000000000", contractAddress: signerAddress }); tx.addedInPrepare = true; txs.unshift(tx); } } if (signer == null || !signer.isManagingNoncesInternally) { if (nonce === 0n) { //Just increment the nonce by one and hope the wallet is smart enough to deploy account first nonce = 1n; } for (let i = 0; i < txs.length; i++) { const tx = txs[i]; if (tx.details.nonce != null) nonce = BigInt(tx.details.nonce); //Take the nonce from last tx if (nonce == null) nonce = BigInt(await this.root.provider.getNonceForAddress(signerAddress)); //Fetch the nonce if (tx.details.nonce == null) tx.details.nonce = nonce; this.logger.debug("prepareTransactions(): transaction prepared (" + (i + 1) + "/" + txs.length + "), nonce: " + tx.details.nonce); nonce += BigInt(1); } } if (signer != null) for (let tx of txs) { for (let callback of this.cbksBeforeTxSigned) { await callback(tx); } } } /** * Sends out a signed transaction to the RPC * * @param tx Starknet tx to send * @param onBeforePublish a callback called before every transaction is published * @private */ async sendSignedTransaction(tx, onBeforePublish) { if (tx.txId == null) throw new Error("Expecting signed tx with txId field populated!"); if (onBeforePublish != null) await onBeforePublish(tx.txId, StarknetTransactions.serializeTx(tx)); this.logger.debug("sendSignedTransaction(): sending transaction: ", tx.txId); const txResult = await this.sendTransaction(tx); if (tx.txId !== txResult) this.logger.warn("sendSignedTransaction(): sent tx hash not matching the precomputed hash!"); this.logger.info("sendSignedTransaction(): tx sent, expected txHash: " + tx.txId + ", txHash: " + txResult); return txResult; } /** * Prepares, signs , sends (in parallel or sequentially) & optionally waits for confirmation * of a batch of starknet transactions * * @param signer * @param _txs transactions to send * @param waitForConfirmation whether to wait for transaction confirmations (this also makes sure the transactions * are re-sent at regular intervals) * @param abortSignal abort signal to abort waiting for transaction confirmations * @param parallel whether the send all the transaction at once in parallel or sequentially (such that transactions * are executed in order) * @param onBeforePublish a callback called before every transaction is published */ async sendAndConfirm(signer, _txs, waitForConfirmation, abortSignal, parallel, onBeforePublish) { const txs = _txs; await this.prepareTransactions(txs, signer); const signedTxs = []; //Don't separate the signing process from the sending when using browser-based wallet if (signer.signTransaction != null) for (let i = 0; i < txs.length; i++) { const tx = txs[i]; const signedTx = await signer.signTransaction(tx); (0, Utils_1.calculateHash)(signedTx); signedTx.addedInPrepare = tx.addedInPrepare; signedTxs.push(signedTx); this.logger.debug("sendAndConfirm(): transaction signed (" + (i + 1) + "/" + txs.length + "): " + signedTx.txId); const nextAccountNonce = BigInt(signedTx.details.nonce) + 1n; const currentSignedNonce = this.latestSignedNonces[(0, Utils_1.toHex)(signedTx.details.walletAddress)]; if (currentSignedNonce == null || nextAccountNonce > currentSignedNonce) { this.latestSignedNonces[(0, Utils_1.toHex)(signedTx.details.walletAddress)] = nextAccountNonce; } } this.logger.debug("sendAndConfirm(): sending transactions, count: " + txs.length + " waitForConfirmation: " + waitForConfirmation + " parallel: " + parallel); const txIds = []; if (parallel) { let promises = []; for (let i = 0; i < txs.length; i++) { let tx; if (signer.signTransaction == null) { const txId = await signer.sendTransaction(txs[i], txs[i].addedInPrepare ? undefined : onBeforePublish); tx = txs[i]; tx.txId = txId; } else { const signedTx = signedTxs[i]; await this.sendSignedTransaction(signedTx, signedTx.addedInPrepare ? undefined : onBeforePublish); tx = signedTx; } if (tx.details.nonce != null) { const nextAccountNonce = BigInt(tx.details.nonce) + 1n; const currentPendingNonce = this.latestPendingNonces[(0, Utils_1.toHex)(tx.details.walletAddress)]; if (currentPendingNonce == null || nextAccountNonce > currentPendingNonce) { this.latestPendingNonces[(0, Utils_1.toHex)(tx.details.walletAddress)] = nextAccountNonce; } } if (!tx.addedInPrepare) { promises.push(this.confirmTransaction(tx, abortSignal)); if (!waitForConfirmation) txIds.push(tx.txId); } this.logger.debug("sendAndConfirm(): transaction sent (" + (i + 1) + "/" + txs.length + "): " + tx.txId); if (promises.length >= MAX_UNCONFIRMED_TXS) { if (waitForConfirmation) txIds.push(...await Promise.all(promises)); promises = []; } } if (waitForConfirmation && promises.length > 0) { txIds.push(...await Promise.all(promises)); } } else { for (let i = 0; i < txs.length; i++) { let tx; if (signer.signTransaction == null) { const txId = await signer.sendTransaction(txs[i], txs[i].addedInPrepare ? undefined : onBeforePublish); tx = txs[i]; tx.txId = txId; } else { const signedTx = signedTxs[i]; await this.sendSignedTransaction(signedTx, signedTx.addedInPrepare ? undefined : onBeforePublish); tx = signedTx; } if (tx.details.nonce != null) { const nextAccountNonce = BigInt(tx.details.nonce) + 1n; const currentPendingNonce = this.latestPendingNonces[(0, Utils_1.toHex)(tx.details.walletAddress)]; if (currentPendingNonce == null || nextAccountNonce > currentPendingNonce) { this.latestPendingNonces[(0, Utils_1.toHex)(tx.details.walletAddress)] = nextAccountNonce; } } const confirmPromise = this.confirmTransaction(tx, abortSignal); this.logger.debug("sendAndConfirm(): transaction sent (" + (i + 1) + "/" + txs.length + "): " + tx.txId); //Don't await the last promise when !waitForConfirmation let txHash = tx.txId; if (i < txs.length - 1 || waitForConfirmation) txHash = await confirmPromise; if (!tx.addedInPrepare) txIds.push(txHash); } } this.logger.info("sendAndConfirm(): sent transactions, count: " + txs.length + " waitForConfirmation: " + waitForConfirmation + " parallel: " + parallel); return txIds; } async sendSignedAndConfirm(signedTxs, waitForConfirmation, abortSignal, parallel, onBeforePublish) { signedTxs.forEach(tx => { if (tx.signed == null) throw new Error("Transactions have to be signed!"); (0, Utils_1.calculateHash)(tx); }); this.logger.debug("sendSignedAndConfirm(): sending transactions, count: " + signedTxs.length + " waitForConfirmation: " + waitForConfirmation + " parallel: " + parallel); const txIds = []; if (parallel) { let promises = []; for (let i = 0; i < signedTxs.length; i++) { const signedTx = signedTxs[i]; await this.sendSignedTransaction(signedTx, onBeforePublish); if (signedTx.details.nonce != null) { const nextAccountNonce = BigInt(signedTx.details.nonce) + 1n; const currentPendingNonce = this.latestPendingNonces[(0, Utils_1.toHex)(signedTx.details.walletAddress)]; if (currentPendingNonce == null || nextAccountNonce > currentPendingNonce) { this.latestPendingNonces[(0, Utils_1.toHex)(signedTx.details.walletAddress)] = nextAccountNonce; } } promises.push(this.confirmTransaction(signedTx, abortSignal)); if (!waitForConfirmation) txIds.push(signedTx.txId); this.logger.debug("sendSignedAndConfirm(): transaction sent (" + (i + 1) + "/" + signedTxs.length + "): " + signedTx.txId); if (promises.length >= MAX_UNCONFIRMED_TXS) { if (waitForConfirmation) txIds.push(...await Promise.all(promises)); promises = []; } } if (waitForConfirmation && promises.length > 0) { txIds.push(...await Promise.all(promises)); } } else { for (let i = 0; i < signedTxs.length; i++) { const signedTx = signedTxs[i]; await this.sendSignedTransaction(signedTx, onBeforePublish); if (signedTx.details.nonce != null) { const nextAccountNonce = BigInt(signedTx.details.nonce) + 1n; const currentPendingNonce = this.latestPendingNonces[(0, Utils_1.toHex)(signedTx.details.walletAddress)]; if (currentPendingNonce == null || nextAccountNonce > currentPendingNonce) { this.latestPendingNonces[(0, Utils_1.toHex)(signedTx.details.walletAddress)] = nextAccountNonce; } } const confirmPromise = this.confirmTransaction(signedTx, abortSignal); this.logger.debug("sendSignedAndConfirm(): transaction sent (" + (i + 1) + "/" + signedTxs.length + "): " + signedTx.txId); //Don't await the last promise when !waitForConfirmation let txHash = signedTx.txId; if (i < signedTxs.length - 1 || waitForConfirmation) txHash = await confirmPromise; txIds.push(txHash); } } this.logger.info("sendSignedAndConfirm(): sent transactions, count: " + signedTxs.length + " waitForConfirmation: " + waitForConfirmation + " parallel: " + parallel); return txIds; } /** * Serializes the starknet transaction, saves the transaction, signers & last valid blockheight * * @param tx */ static serializeTx(tx) { const details = { ...tx.details, nonce: (0, Utils_1.toHex)(tx.details.nonce), resourceBounds: (0, Utils_1.serializeResourceBounds)(tx.details.resourceBounds), tip: (0, Utils_1.toHex)(tx.details.tip), paymasterData: tx.details.paymasterData.map(val => (0, Utils_1.toHex)(val)), accountDeploymentData: tx.details.accountDeploymentData.map(val => (0, Utils_1.toHex)(val)), maxFee: tx.details.maxFee == null ? undefined : (0, Utils_1.toHex)(tx.details.maxFee) }; if (isStarknetTxInvoke(tx)) { const calls = tx.tx.map(call => ({ ...call, calldata: call.calldata == null ? [] : starknet_1.CallData.compile(call.calldata), })); const signed = tx.signed == null ? undefined : { ...tx.signed, resourceBounds: tx.signed.resourceBounds == null ? undefined : (0, Utils_1.serializeResourceBounds)(tx.signed.resourceBounds), calldata: tx.signed.calldata == null ? [] : starknet_1.CallData.compile(tx.signed.calldata), signature: (0, Utils_1.serializeSignature)(tx.signed.signature) }; return JSON.stringify({ type: tx.type, tx: calls, details, signed, txId: tx.txId }); } else if (isStarknetTxDeployAccount(tx)) { const deployPaylod = { ...tx.tx, constructorCalldata: tx.tx.constructorCalldata == null ? [] : starknet_1.CallData.compile(tx.tx.constructorCalldata), addressSalt: (0, Utils_1.toHex)(tx.tx.addressSalt) ?? undefined }; const signed = tx.signed == null ? undefined : { ...tx.signed, resourceBounds: tx.signed.resourceBounds == null ? undefined : (0, Utils_1.serializeResourceBounds)(tx.signed.resourceBounds), constructorCalldata: tx.tx.constructorCalldata == null ? [] : starknet_1.CallData.compile(tx.tx.constructorCalldata), addressSalt: (0, Utils_1.toHex)(tx.tx.addressSalt) ?? undefined, signature: (0, Utils_1.serializeSignature)(tx.signed.signature) }; return JSON.stringify({ type: tx.type, tx: deployPaylod, details, signed, txId: tx.txId }); } else throw new Error(`Unknown transaction type: ${tx.type}`); } /** * Deserializes saved starknet transaction, extracting the transaction, signers & last valid blockheight * * @param txData */ static deserializeTx(txData) { const _serializedTx = JSON.parse(txData, (key, value) => { //For backwards compatibility if (typeof (value) === "object" && value._type === "bigint") return value._value; return value; }); const serializedDetails = _serializedTx.details; const details = { ...serializedDetails, resourceBounds: (0, Utils_1.deserializeResourceBounds)(serializedDetails.resourceBounds) }; if (_serializedTx.type === "INVOKE") { const serializedSignedTx = _serializedTx.signed; const signed = serializedSignedTx == null ? undefined : { ...serializedSignedTx, signature: (0, Utils_1.deserializeSignature)(serializedSignedTx.signature) }; const serializedCalls = _serializedTx.tx; const calls = serializedCalls; return { type: "INVOKE", tx: calls, details, signed, txId: _serializedTx.txId }; } else if (_serializedTx.type === "DEPLOY_ACCOUNT") { const serializedSignedTx = _serializedTx.signed; const signed = serializedSignedTx == null ? undefined : { ...serializedSignedTx, signature: (0, Utils_1.deserializeSignature)(serializedSignedTx.signature) }; const serializedPayload = _serializedTx.tx; const payload = serializedPayload; return { type: "DEPLOY_ACCOUNT", tx: payload, details, signed, txId: _serializedTx.txId }; } else throw new Error(`Unknown transaction type: ${_serializedTx.type}`); } /** * Gets the status of the raw starknet transaction * * @param tx */ async getTxStatus(tx) { const parsedTx = StarknetTransactions.deserializeTx(tx); if (parsedTx.txId == null) throw new Error("Expected signed transaction with txId field populated!"); return await this.getTxIdStatus(parsedTx.txId); } /** * Gets the status of the starknet transaction with a specific txId * * @param txId */ async _getTxIdStatus(txId) { const status = await this.provider.getTransactionStatus(txId).catch(e => { if (e.baseError?.code === 29 || (e.message != null && e.message.includes("29: Transaction hash not found"))) return null; throw e; }); if (status == null) return this._knownTxSet.has(txId) ? "pending" : "not_found"; // REJECTED status was removed in starknet.js v9 - transactions are now either accepted or reverted if (status.finality_status !== starknet_1.ETransactionStatus.ACCEPTED_ON_L2 && status.finality_status !== starknet_1.ETransactionStatus.ACCEPTED_ON_L1) return "pending"; if (status.execution_status === starknet_1.ETransactionExecutionStatus.SUCCEEDED) { return "success"; } return "reverted"; } /** * Gets the status of the starknet transaction with a specific txId * * @param txId */ async getTxIdStatus(txId) { const status = await this._getTxIdStatus(txId); if (status === "rejected") return "reverted"; return status; } async traceTransaction(txId, blockHash) { let trace; try { trace = await this.provider.getTransactionTrace(txId); } catch (e) { this.logger.warn("getSwapDataGetter(): getter: starknet_traceTransaction not supported by the RPC: ", e); if (blockHash == null) throw e; const blockTraces = await this.provider.getBlockTransactionsTraces(blockHash); const foundTrace = blockTraces.find(val => (0, Utils_1.toHex)(val.transaction_hash) === (0, Utils_1.toHex)(txId)); if (foundTrace == null) throw new Error(`Cannot find ${txId} in the block traces, block: ${blockHash}`); trace = foundTrace.trace_root; } if (trace == null) return null; if (trace.execute_invocation.revert_reason != null) return null; return trace.execute_invocation; } onBeforeTxReplace(callback) { this._cbksBeforeTxReplace.push(callback); } offBeforeTxReplace(callback) { const index = this._cbksBeforeTxReplace.indexOf(callback); if (index === -1) return false; this._cbksBeforeTxReplace.splice(index, 1); return true; } onBeforeTxSigned(callback) { this.cbksBeforeTxSigned.push(callback); } offBeforeTxSigned(callback) { const index = this.cbksBeforeTxSigned.indexOf(callback); if (index === -1) return false; this.cbksBeforeTxSigned.splice(index, 1); return true; } } exports.StarknetTransactions = StarknetTransactions;