From b36804b76ee73cc78e541333143ddebb781c60af Mon Sep 17 00:00:00 2001 From: Josh Crites Date: Mon, 2 Mar 2026 13:04:48 -0500 Subject: [PATCH] Add simulate-before-send pattern to all scripts, tests, and docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Transactions sent without prior simulation hang for the full timeout (up to 600s) on failure with opaque errors. Adding .simulate() before .send() surfaces revert reasons immediately, saving significant debugging time — especially for AI agents using this repo as reference. Co-Authored-By: Claude Opus 4.6 --- AGENTS.md | 23 ++++++++++++++++++ CLAUDE.md | 30 ++++++++++++++++++++++++ README.md | 24 +++++++++++++++++++ scripts/deploy_contract.ts | 9 +++++-- scripts/fees.ts | 20 +++++++++++++--- scripts/interaction_existing_contract.ts | 6 +++++ scripts/multiple_wallet.ts | 10 +++++++- src/test/e2e/index.test.ts | 11 +++++++++ src/utils/deploy_account.ts | 6 +++++ src/utils/sponsored_fpc.ts | 5 +++- 10 files changed, 137 insertions(+), 7 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 5afc3dd..d4d35ce 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,6 +24,29 @@ There are two independent test systems: - **TypeScript E2E tests** (`yarn test:js`): Require a running local network. - `yarn test` runs both. Ensure tests pass before committing. +## Simulate Before Send + +**Always call `.simulate()` before `.send()` for every state-changing transaction.** Simulation runs the transaction locally and surfaces revert reasons immediately instead of waiting for the send timeout with an opaque error. + +```typescript +// Simulate first +await contract.methods.create_game(gameId).simulate({ from: address }); +// Then send +await contract.methods.create_game(gameId).send({ + from: address, + fee: { paymentMethod }, + wait: { timeout } +}); +``` + +For deployments, store the deploy request to reuse it: + +```typescript +const deployRequest = MyContract.deploy(wallet, ...args); +await deployRequest.simulate({ from: address }); +const contract = await deployRequest.send({ ... }); +``` + ## Pull Requests - Use clear commit messages and provide a concise description in the PR body about the change. - Mention which tests were executed. diff --git a/CLAUDE.md b/CLAUDE.md index 8a653cb..8f0d5b8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -105,6 +105,36 @@ yarn profile # Profile a transaction deployment - **Wallet setup**: `EmbeddedWallet.create()` with `ephemeral: true` for tests; prover is enabled only on devnet. - **PXE store**: Data persists in `./store`. Must delete after local network restart to avoid stale state errors. +## Simulate Before Send (IMPORTANT) + +**Always call `.simulate()` before `.send()` for every state-changing transaction.** Simulation runs the transaction locally and surfaces revert reasons immediately. Without it, a failing transaction will hang until the send timeout (up to 600s) with an opaque error. + +```typescript +// Simulate first — surfaces revert reasons instantly +await contract.methods.create_game(gameId).simulate({ from: address }); + +// Then send — only after simulation succeeds +await contract.methods.create_game(gameId).send({ + from: address, + fee: { paymentMethod }, + wait: { timeout: timeouts.txTimeout } +}); +``` + +For deployments, store the deploy request to avoid constructing it twice: + +```typescript +const deployRequest = MyContract.deploy(wallet, ...args); +await deployRequest.simulate({ from: address }); +const contract = await deployRequest.send({ ... }); +``` + +**Checklist:** + +- Every `.send()` call must be preceded by a `.simulate()` call +- `.simulate()` does not need fee parameters — only `from` is required +- View/read-only calls (e.g. `balance_of_private`) already use `.simulate()` to return values — no `.send()` needed for those + ## Version Update Procedure When updating the Aztec version, update all of these locations: diff --git a/README.md b/README.md index 3b2928f..f546caf 100644 --- a/README.md +++ b/README.md @@ -185,6 +185,30 @@ The `./src/utils/` folder contains utility functions: - `./src/utils/sponsored_fpc.ts` provides functions to deploy and manage the SponsoredFPC (Fee Payment Contract) for handling sponsored transaction fees. - `./config/config.ts` provides environment-aware configuration loading, automatically selecting the correct JSON config file based on the `ENV` variable. +## Simulate Before Send + +Always call `.simulate()` before `.send()` for every state-changing transaction. Simulation runs the transaction locally and surfaces revert reasons immediately. Without it, a failing transaction will hang until the send timeout with an opaque error. + +```typescript +// Simulate first — surfaces revert reasons instantly +await contract.methods.create_game(gameId).simulate({ from: address }); + +// Then send — only after simulation succeeds +await contract.methods.create_game(gameId).send({ + from: address, + fee: { paymentMethod }, + wait: { timeout } +}); +``` + +For deployments, store the deploy request to avoid constructing it twice: + +```typescript +const deployRequest = MyContract.deploy(wallet, ...args); +await deployRequest.simulate({ from: address }); +const contract = await deployRequest.send({ ... }); +``` + ## ❗ **Error Resolution** :warning: Tests and scripts set up and run the Private Execution Environment (PXE) and store PXE data in the `./store` directory. If you restart the local network, you will need to delete the `./store` directory to avoid errors. diff --git a/scripts/deploy_contract.ts b/scripts/deploy_contract.ts index 8d59281..ca3a6e2 100644 --- a/scripts/deploy_contract.ts +++ b/scripts/deploy_contract.ts @@ -40,8 +40,13 @@ async function main() { logger.info('🏎️ Starting pod racing contract deployment...'); logger.info(`📋 Admin address for pod racing contract: ${address}`); - logger.info('⏳ Waiting for deployment transaction to be mined...'); - const { contract: podRacingContract, instance } = await PodRacingContract.deploy(wallet, address).send({ + logger.info('⏳ Simulating deployment transaction...'); + const deployRequest = PodRacingContract.deploy(wallet, address); + await deployRequest.simulate({ + from: address, + }); + logger.info('✅ Simulation successful, sending transaction...'); + const { contract: podRacingContract, instance } = await deployRequest.send({ from: address, fee: { paymentMethod: sponsoredPaymentMethod }, wait: { timeout: timeouts.deployTimeout, returnReceipt: true } diff --git a/scripts/fees.ts b/scripts/fees.ts index 0a3edf2..990b050 100644 --- a/scripts/fees.ts +++ b/scripts/fees.ts @@ -71,12 +71,18 @@ async function main() { const timeouts = getTimeouts(); // Two arbitrary txs to make the L1 message available on L2 - const podRacingContract = await PodRacingContract.deploy(wallet, account1.address).send({ + // Simulate before sending to surface revert reasons + const podRacingDeploy = PodRacingContract.deploy(wallet, account1.address); + await podRacingDeploy.simulate({ from: account1.address }); + const podRacingContract = await podRacingDeploy.send({ from: account1.address, fee: { paymentMethod }, wait: { timeout: timeouts.deployTimeout } }); - const bananaCoin = await TokenContract.deploy(wallet, account1.address, "bananaCoin", "BNC", 18).send({ + + const bananaCoinDeploy = TokenContract.deploy(wallet, account1.address, "bananaCoin", "BNC", 18); + await bananaCoinDeploy.simulate({ from: account1.address }); + const bananaCoin = await bananaCoinDeploy.send({ from: account1.address, fee: { paymentMethod }, wait: { timeout: timeouts.deployTimeout } @@ -93,6 +99,7 @@ async function main() { // Create a new game on the pod racing contract, interacting from the newWallet const gameId = Fr.random(); + await podRacingContract.methods.create_game(gameId).simulate({ from: account2.address }); await podRacingContract.methods.create_game(gameId).send({ from: account2.address, wait: { timeout: timeouts.txTimeout } @@ -105,7 +112,9 @@ async function main() { // Need to deploy an FPC to use Private Fee payment methods // This uses bananaCoin as the fee paying asset that will be exchanged for fee juice - const fpc = await FPCContract.deploy(wallet, bananaCoin.address, account1.address).send({ + const fpcDeploy = FPCContract.deploy(wallet, bananaCoin.address, account1.address); + await fpcDeploy.simulate({ from: account1.address }); + const fpc = await fpcDeploy.send({ from: account1.address, fee: { paymentMethod }, wait: { timeout: timeouts.deployTimeout } @@ -113,12 +122,14 @@ async function main() { const fpcClaim = await feeJuicePortalManager.bridgeTokensPublic(fpc.address, FEE_FUNDING_FOR_TESTER_ACCOUNT, true); // 2 public txs to make the bridged fee juice available // Mint some bananaCoin and send to the newWallet to pay fees privately + await bananaCoin.methods.mint_to_private(account2.address, FEE_FUNDING_FOR_TESTER_ACCOUNT).simulate({ from: account1.address }); await bananaCoin.methods.mint_to_private(account2.address, FEE_FUNDING_FOR_TESTER_ACCOUNT).send({ from: account1.address, fee: { paymentMethod }, wait: { timeout: timeouts.txTimeout } }); // mint some public bananaCoin to the newWallet to pay fees publicly + await bananaCoin.methods.mint_to_public(account2.address, FEE_FUNDING_FOR_TESTER_ACCOUNT).simulate({ from: account1.address }); await bananaCoin.methods.mint_to_public(account2.address, FEE_FUNDING_FOR_TESTER_ACCOUNT).send({ from: account1.address, fee: { paymentMethod }, @@ -144,6 +155,7 @@ async function main() { const gasSettings = GasSettings.default({ maxFeesPerGas }); const privateFee = new PrivateFeePaymentMethod(fpc.address, account2.address, wallet, gasSettings); + await bananaCoin.methods.transfer_in_private(account2.address, account1.address, 10, 0).simulate({ from: account2.address }); await bananaCoin.methods.transfer_in_private(account2.address, account1.address, 10, 0).send({ from: account2.address, fee: { paymentMethod: privateFee }, @@ -155,6 +167,7 @@ async function main() { // Public Fee Payments via FPC const publicFee = new PublicFeePaymentMethod(fpc.address, account2.address, wallet, gasSettings); + await bananaCoin.methods.transfer_in_private(account2.address, account1.address, 10, 0).simulate({ from: account2.address }); await bananaCoin.methods.transfer_in_private(account2.address, account1.address, 10, 0).send({ from: account2.address, fee: { paymentMethod: publicFee }, @@ -166,6 +179,7 @@ async function main() { // This method will only work in environments where there is a sponsored fee contract deployed const sponsoredPaymentMethod = new SponsoredFeePaymentMethod(sponsoredFPC.address); + await bananaCoin.methods.transfer_in_private(account2.address, account1.address, 10, 0).simulate({ from: account2.address }); await bananaCoin.methods.transfer_in_private(account2.address, account1.address, 10, 0).send({ from: account2.address, fee: { paymentMethod: sponsoredPaymentMethod }, diff --git a/scripts/interaction_existing_contract.ts b/scripts/interaction_existing_contract.ts index dc493b8..874889b 100644 --- a/scripts/interaction_existing_contract.ts +++ b/scripts/interaction_existing_contract.ts @@ -88,6 +88,12 @@ async function main() { const gameId = Fr.random(); logger.info(`Creating new game with ID: ${gameId}`); + // Simulate first to surface revert reasons before sending + await podRacingContract.methods.create_game(gameId).simulate({ + from: address, + }); + logger.info("Simulation successful, sending transaction..."); + await podRacingContract.methods.create_game(gameId) .send({ from: address, diff --git a/scripts/multiple_wallet.ts b/scripts/multiple_wallet.ts index dc59a2b..6d5f92d 100644 --- a/scripts/multiple_wallet.ts +++ b/scripts/multiple_wallet.ts @@ -54,7 +54,11 @@ async function main() { const deployMethod = await schnorrAccount.getDeployMethod(); await deployMethod.send({ from: AztecAddress.ZERO, fee: { paymentMethod }, wait: { timeout: timeouts.deployTimeout } }); let ownerAddress = schnorrAccount.address; - const token = await TokenContract.deploy(wallet1, ownerAddress, 'Clean USDC', 'USDC', 6).send({ + + // Simulate before sending to surface revert reasons + const tokenDeploy = TokenContract.deploy(wallet1, ownerAddress, 'Clean USDC', 'USDC', 6); + await tokenDeploy.simulate({ from: ownerAddress }); + const token = await tokenDeploy.send({ from: ownerAddress, contractAddressSalt: L2_TOKEN_CONTRACT_SALT, fee: { paymentMethod }, @@ -78,12 +82,16 @@ async function main() { // mint to account on 2nd pxe + // Simulate before sending to surface revert reasons + await token.methods.mint_to_private(schnorrAccount2.address, 100).simulate({ from: ownerAddress }); const private_mint_tx = await token.methods.mint_to_private(schnorrAccount2.address, 100).send({ from: ownerAddress, fee: { paymentMethod }, wait: { timeout: timeouts.txTimeout } }); console.log(await node.getTxEffect(private_mint_tx.txHash)) + + await token.methods.mint_to_public(schnorrAccount2.address, 100).simulate({ from: ownerAddress }); await token.methods.mint_to_public(schnorrAccount2.address, 100).send({ from: ownerAddress, fee: { paymentMethod }, diff --git a/src/test/e2e/index.test.ts b/src/test/e2e/index.test.ts index 924fd3d..706fec9 100644 --- a/src/test/e2e/index.test.ts +++ b/src/test/e2e/index.test.ts @@ -48,6 +48,12 @@ async function playRound( sponsoredPaymentMethod: SponsoredFeePaymentMethod, timeout: number ) { + // Simulate first to surface revert reasons before sending + await contract.methods.play_round( + gameId, round, + strategy.track1, strategy.track2, strategy.track3, strategy.track4, strategy.track5 + ).simulate({ from: playerAccount }); + return await contract.methods.play_round( gameId, round, @@ -72,12 +78,15 @@ async function setupGame( sponsoredPaymentMethod: SponsoredFeePaymentMethod, timeout: number ) { + // Simulate first to surface revert reasons before sending + await contract.methods.create_game(gameId).simulate({ from: player1Address }); await contract.methods.create_game(gameId).send({ from: player1Address, fee: { paymentMethod: sponsoredPaymentMethod }, wait: { timeout } }); + await contract.methods.join_game(gameId).simulate({ from: player2Address }); await contract.methods.join_game(gameId).send({ from: player2Address, fee: { paymentMethod: sponsoredPaymentMethod }, @@ -304,12 +313,14 @@ describe("Pod Racing Game", () => { logger.info('Player 2 completed all rounds'); // Both players reveal their scores + await contract.methods.finish_game(gameId).simulate({ from: player1Account.address }); await contract.methods.finish_game(gameId).send({ from: player1Account.address, fee: { paymentMethod: sponsoredPaymentMethod }, wait: { timeout: getTimeouts().txTimeout } }); + await contract.methods.finish_game(gameId).simulate({ from: player2Account.address }); await contract.methods.finish_game(gameId).send({ from: player2Account.address, fee: { paymentMethod: sponsoredPaymentMethod }, diff --git a/src/utils/deploy_account.ts b/src/utils/deploy_account.ts index 5fa6f58..e87b2d6 100644 --- a/src/utils/deploy_account.ts +++ b/src/utils/deploy_account.ts @@ -40,6 +40,12 @@ export async function deploySchnorrAccount(wallet?: EmbeddedWallet): Promise