UNPKG

@atomiqlabs/chain-starknet

Version:

Starknet specific base implementation

254 lines (230 loc) 11 kB
import {StarknetModule} from "../StarknetModule"; import { Call, DeployAccountContractPayload, DeployAccountContractTransaction, Invocation, InvocationsSignerDetails, BigNumberish } from "starknet"; import {StarknetSigner} from "../../wallet/StarknetSigner"; import {calculateHash, timeoutPromise, toHex, tryWithRetries} from "../../../utils/Utils"; export type StarknetTx = ({ type: "DEPLOY_ACCOUNT", tx: DeployAccountContractPayload, signed?: DeployAccountContractTransaction } | { type: "INVOKE", tx: Array<Call>, signed?: Invocation }) & { details: InvocationsSignerDetails & {maxFee?: BigNumberish}, txId?: string }; export class StarknetTransactions extends StarknetModule { private cbkBeforeTxSigned: (tx: StarknetTx) => Promise<void>; /** * 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 */ private async confirmTransaction(tx: StarknetTx, abortSignal?: AbortSignal) { let state = "pending"; while(state==="pending" || state==="not_found") { await timeoutPromise(3000, abortSignal); state = await this.getTxIdStatus(tx.txId); if(state==="not_found" && tx.signed!=null) await this.sendSignedTransaction(tx, undefined, undefined, false).catch(e => { if(e.baseError?.code === 59) return; //Transaction already in the mempool console.error("Error on transaction re-send: ", e); }); } if(state==="reverted") throw new Error("Transaction reverted!"); } /** * Prepares starknet transactions, checks if the account is deployed, assigns nonces if needed & calls beforeTxSigned callback * * @param signer * @param txs * @private */ private async prepareTransactions(signer: StarknetSigner, txs: StarknetTx[]): Promise<void> { let nonce: bigint = await signer.getNonce(); if(nonce===BigInt(0) && signer.isWalletAccount()) { //Just increment the nonce by one and hope the wallet is smart enough to deploy account first nonce = BigInt(1); } const deployPayload = await signer.checkAndGetDeployPayload(nonce); if(deployPayload!=null) { txs.unshift(await this.root.Accounts.getAccountDeployTransaction(deployPayload)); } 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(signer.getAddress())); //Fetch the nonce if(tx.details.nonce==null) tx.details.nonce = nonce; this.logger.debug("sendAndConfirm(): transaction prepared ("+(i+1)+"/"+txs.length+"), nonce: "+tx.details.nonce); nonce += BigInt(1); if(this.cbkBeforeTxSigned!=null) await this.cbkBeforeTxSigned(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 * @param signer * @param retryOnSubmissionFailure * @private */ private async sendSignedTransaction( tx: StarknetTx, onBeforePublish?: (txId: string, rawTx: string) => Promise<void>, signer?: StarknetSigner, retryOnSubmissionFailure: boolean = true ): Promise<string> { if(onBeforePublish!=null) await onBeforePublish(tx.txId, await this.serializeTx(tx)); this.logger.debug("sendSignedTransaction(): sending transaction: ", tx); if(tx.signed==null) { let txHash: string; switch(tx.type) { case "INVOKE": txHash = (await signer.account.execute(tx.tx, tx.details)).transaction_hash; break; case "DEPLOY_ACCOUNT": txHash = (await signer.account.deployAccount(tx.tx, tx.details)).transaction_hash; break; default: throw new Error("Unsupported tx type!"); } tx.txId = txHash; return txHash; } const txResult = await tryWithRetries(() => { 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: any) => res.transaction_hash); default: throw new Error("Unsupported tx type!"); } }, retryOnSubmissionFailure ? this.retryPolicy : {maxRetries: 1}); 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 */ public async sendAndConfirm(signer: StarknetSigner, txs: StarknetTx[], waitForConfirmation?: boolean, abortSignal?: AbortSignal, parallel?: boolean, onBeforePublish?: (txId: string, rawTx: string) => Promise<void>): Promise<string[]> { await this.prepareTransactions(signer, txs); if(!signer.isWalletAccount()) { for(let i=0;i<txs.length;i++) { const tx = txs[i]; switch(tx.type) { case "INVOKE": tx.signed = await signer.account.buildInvocation(tx.tx, tx.details); calculateHash(tx); break; case "DEPLOY_ACCOUNT": tx.signed = await signer.account.buildAccountDeployPayload(tx.tx, tx.details); calculateHash(tx); break; default: throw new Error("Unsupported tx type!"); } this.logger.debug("sendAndConfirm(): transaction signed ("+(i+1)+"/"+txs.length+"): "+tx.txId); } } this.logger.debug("sendAndConfirm(): sending transactions, count: "+txs.length+ " waitForConfirmation: "+waitForConfirmation+" parallel: "+parallel); const txIds: string[] = []; if(parallel) { const promises: Promise<void>[] = []; for(let i=0;i<txs.length;i++) { const signedTx = txs[i]; const txId = await this.sendSignedTransaction(signedTx, onBeforePublish, signer); if(waitForConfirmation) promises.push(this.confirmTransaction(signedTx, abortSignal)); txIds.push(txId); this.logger.debug("sendAndConfirm(): transaction sent ("+(i+1)+"/"+txs.length+"): "+signedTx.txId); } if(promises.length>0) await Promise.all(promises); } else { for(let i=0;i<txs.length;i++) { const signedTx = txs[i]; const txId = await this.sendSignedTransaction(signedTx, onBeforePublish, signer); const confirmPromise = this.confirmTransaction(signedTx, abortSignal); this.logger.debug("sendAndConfirm(): transaction sent ("+(i+1)+"/"+txs.length+"): "+signedTx.txId); //Don't await the last promise when !waitForConfirmation if(i<txs.length-1 || waitForConfirmation) await confirmPromise; txIds.push(txId); } } this.logger.info("sendAndConfirm(): sent transactions, count: "+txs.length+ " waitForConfirmation: "+waitForConfirmation+" parallel: "+parallel); return txIds; } /** * Serializes the solana transaction, saves the transaction, signers & last valid blockheight * * @param tx */ public serializeTx(tx: StarknetTx): Promise<string> { return Promise.resolve(JSON.stringify(tx, (key, value) => { if(typeof(value)==="bigint") return toHex(value); return value; })); } /** * Deserializes saved solana transaction, extracting the transaction, signers & last valid blockheight * * @param txData */ public deserializeTx(txData: string): Promise<StarknetTx> { return Promise.resolve(JSON.parse(txData)); } /** * Gets the status of the raw starknet transaction * * @param tx */ public async getTxStatus(tx: string): Promise<"pending" | "success" | "not_found" | "reverted"> { const parsedTx: StarknetTx = await this.deserializeTx(tx); return await this.getTxIdStatus(parsedTx.txId); } /** * Gets the status of the starknet transaction with a specific txId * * @param txId */ public async getTxIdStatus(txId: string): Promise<"pending" | "success" | "not_found" | "reverted"> { const status = await this.provider.getTransactionStatus(txId).catch(e => { if(e.message!=null && e.message.includes("29: Transaction hash not found")) return null; throw e; }); if(status==null) return "not_found"; if(status.finality_status==="RECEIVED") return "pending"; if(status.finality_status!=="REJECTED" && status.execution_status==="SUCCEEDED"){ return "success"; } return "reverted"; } public onBeforeTxSigned(callback: (tx: StarknetTx) => Promise<void>): void { this.cbkBeforeTxSigned = callback; } public offBeforeTxSigned(callback: (tx: StarknetTx) => Promise<void>): boolean { this.cbkBeforeTxSigned = null; return true; } }