Date analyzed: 2026-06-14
Transaction:
0x074ec9317d8336db37e8c348fbdd7515573ff4088239c77ab429f522509aeeb1
This note describes an observed mismatch between the rollup proof data accepted by the verifier and the L1 public-effect accounting performed by RollupProcessorV3.
The short version: numRealTxs is the boundary used by L1 for public effects. Rows before that boundary are checked for deposits and withdrawals. Rows after it are assumed to be right-padding or otherwise state-neutral. In this transaction, row 1 was after the boundary, but was still treated as part of the accepted rollup state transition.
So the same data was treated two ways:
proof/state transition: row 0 and row 1 mattered
L1 public effects: only row 0 mattered
The result was that deposit-shaped rows could affect private rollup state without triggering L1 deposit accounting, while later withdrawal rows were counted by L1 and transferred assets out of the RollupProcessor.
Raw trace:
tx_0x074ec9317d8336db37e8c348fbdd7515573ff4088239c77ab429f522509aeeb1_trace.json
Decoder script:
aztec_rollup_table_from_trace.mjs
Generated rollup table:
aztec_rollup_full_table.md
Single-call walkthrough:
aztec_connect_single_call_walkthrough.md
Send-row decode:
aztec_connect_send_row_decode.md
The trace was fetched with trace_transaction:
curl -sS --max-time 120 https://ethereum.publicnode.com \
-H 'content-type: application/json' \
--data '{"jsonrpc":"2.0","id":1,"method":"trace_transaction","params":["0x074ec9317d8336db37e8c348fbdd7515573ff4088239c77ab429f522509aeeb1"]}' \
-o tx_0x074ec9317d8336db37e8c348fbdd7515573ff4088239c77ab429f522509aeeb1_trace.jsonThe relevant trace frames are call frames into Aztec Connect / RollupProcessor:
0xff1f2b4adb9df6fc8eafecdcbf96a2b351680455
with selector:
0xf81cccbe = processRollup(bytes,bytes)
Run:
node aztec_rollup_table_from_trace.mjs \
tx_0x074ec9317d8336db37e8c348fbdd7515573ff4088239c77ab429f522509aeeb1_trace.json \
--out aztec_rollup_full_table.mdThe script:
- reads the trace JSON,
- selects calls to RollupProcessor with selector
0xf81cccbe, - ABI-decodes the first
bytesargument asencodedProofData, - reads
numRealTxsandencodedInnerTxData.length, - decodes each compact inner transaction row,
- labels whether each row is counted by L1 accounting.
counted = yes means rowIndex < numRealTxs. Those rows are processed by the L1 deposit/withdraw loop. counted = no means the row is present in proof data but is not processed by the L1 public-effect loop. In this transaction the uncounted second rows are still included in the verifier public-input hash because numRealTxs = 1 commits to the first 32-slot inner-rollup chunk, not only to a single decoded row.
| rollup | trace | numRealTxs | encoded bytes | rows |
|---|---|---|---|---|
| 13277 | 0.0 | 1 | 314 | row 0: account (counted); row 1: deposit 908.987304627963496632 ETH (0) (not counted) |
| 13278 | 0.1 | 1 | 314 | row 0: send (counted); row 1: deposit 270513.054157632152892774 DAI (1) (not counted) |
| 13279 | 0.2 | 1 | 314 | row 0: send (counted); row 1: deposit 167.890392526462638142 wstETH (2) (not counted) |
| 13280 | 0.3 | 1 | 314 | row 0: send (counted); row 1: deposit 4873.856656463299373447 yvDAI (3) (not counted) |
| 13281 | 0.4 | 1 | 314 | row 0: send (counted); row 1: deposit 16.569551230652464753 yvWETH (4) (not counted) |
| 13282 | 1.0 | 1 | 314 | row 0: send (counted); row 1: deposit 9273.734320992446140764 LUSD (10) (not counted) |
| 13283 | 1.1 | 1 | 314 | row 0: send (counted); row 1: deposit 359.047484523695640986 yvLUSD (15) (not counted) |
| 13284 | 1.2 | 1 | 314 | row 0: withdraw 270513.054157632152892774 DAI (1) (counted); row 1: send (not counted) |
| 13285 | 1.3 | 1 | 314 | row 0: withdraw 167.890392526462638142 wstETH (2) (counted); row 1: send (not counted) |
| 13286 | 1.4 | 1 | 314 | row 0: withdraw 4873.856656463299373447 yvDAI (3) (counted); row 1: send (not counted) |
| 13287 | 2.0 | 1 | 314 | row 0: withdraw 16.569551230652464753 yvWETH (4) (counted); row 1: send (not counted) |
| 13288 | 2.1 | 1 | 314 | row 0: withdraw 9273.734320992446140764 LUSD (10) (counted); row 1: send (not counted) |
| 13289 | 2.2 | 1 | 314 | row 0: withdraw 359.047484523695640986 yvLUSD (15) (counted); row 1: send (not counted) |
| 13290 | 2.3 | 1 | 185 | row 0: withdraw 908.986707307963496632 ETH (0) (counted) |
The transaction submitted fourteen processRollup(bytes,bytes) calls through helper contracts. Each call had numRealTxs = 1.
Most calls had encodedInnerTxData.length = 314, which decodes to two rows:
185 bytes deposit/withdraw row + 129 bytes send/account row = 314 bytes
Because numRealTxs = 1, the RollupProcessor L1 public-effect loop processed only row 0 for each rollup. Row 1 remained present in proof data and, for these rollups, remained inside the first inner-rollup chunk committed to by the verifier public-input hash. It therefore participated in the accepted rollup state transition, but did not trigger L1 public effects.
The first phase placed deposit rows in row 1:
13278 row 0: send, counted
13278 row 1: DAI deposit, not counted
13279 row 0: send, counted
13279 row 1: wstETH deposit, not counted
...
Those deposit rows had public amounts matching the later withdrawal rows:
| asset | uncounted deposit | counted withdrawal |
|---|---|---|
| DAI | 270513.054157632152892774 | 270513.054157632152892774 |
| wstETH | 167.890392526462638142 | 167.890392526462638142 |
| yvDAI | 4873.856656463299373447 | 4873.856656463299373447 |
| yvWETH | 16.569551230652464753 | 16.569551230652464753 |
| LUSD | 9273.734320992446140764 | 9273.734320992446140764 |
| yvLUSD | 359.047484523695640986 | 359.047484523695640986 |
| ETH | 908.987304627963496632 | 908.986707307963496632 plus 0.00059732 ETH fees |
The second phase placed withdrawal rows in row 0:
13284 row 0: DAI withdrawal, counted
13285 row 0: wstETH withdrawal, counted
13286 row 0: yvDAI withdrawal, counted
13287 row 0: yvWETH withdrawal, counted
13288 row 0: LUSD withdrawal, counted
13289 row 0: yvLUSD withdrawal, counted
13290 row 0: ETH withdrawal, counted
Those withdrawals were processed by L1 and transferred assets out of the RollupProcessor.
numRealTxs should define where the real encoded prefix ends and where derived right-padding begins:
rows with index < numRealTxs: may be processed by L1 for public effects
rows with index >= numRealTxs: must not be state-changing encoded txs
This does not mean every row before numRealTxs must be non-padding. Padding can appear inside the prefix. The important rule is simpler: state-changing encoded rows must not appear after the boundary that L1 uses to stop applying public effects.
On L1, the boundary is used directly as a loop bound:
end := add(proofDataPtr, mul(_numTxs, TX_PUBLIC_INPUT_LENGTH))Only rows before end are inspected for deposits and withdrawals.
On the proof side, the public-input hash works in inner-rollup chunks, not individual rows. With rollupSize = 1024 and numInnerRollups = 32, each inner rollup chunk has:
txsPerInnerRollup = 1024 / 32 = 32
numFilledBlocks = ceil(numRealTxs / txsPerInnerRollup)
= ceil(1 / 32)
= 1
Therefore numRealTxs = 1 does not mean the verifier public input commits to only one decoded row. It commits to the first 32-slot inner-rollup chunk. That chunk contains row 0 and can also contain row 1.
The observed transaction used that split:
Header says: numRealTxs = 1
Calldata provides: 2 state-changing encoded rows
Verifier public-input hash commits to: first 32-slot inner-rollup chunk
Accepted proof state transition includes: row 0 and row 1
L1 public-effect loop processes: only row 0
The problem is not that the verifier ignored row 1. The problem is the opposite: row 1 was included in the accepted proof/state transition, while the L1 deposit/withdraw accounting skipped it because rowIndex >= numRealTxs.
Aztec Connect deposits are two-stage.
First, depositPendingFunds(...) pulls ETH/ERC20 into the RollupProcessor and increments pending deposit accounting.
Later, a counted deposit proof row is supposed to consume that pending balance. That is where L1 checks authorization and decrements pending deposits.
In this transaction, the deposit-shaped rows were row 1 while numRealTxs = 1. They were therefore skipped by the L1 public-effect loop. That means the following did not run for those rows:
decreasePendingDepositBalance(assetId, publicOwner, publicValue);So the deposit rows could be present in proof data without pulling funds, validating deposit signatures, or decrementing pending deposit balances.
The withdrawal rows were placed at row 0 while numRealTxs = 1. Row 0 is counted, so the L1 loop processed them:
if (proofId == 2) {
withdraw(publicValue, publicOwner, assetId);
}This transferred the assets from the RollupProcessor to:
0x0f18d8b44a740272f0be4d08338d2b165b7edd17
This should be understood as an interface mismatch rather than a single-component explanation.
L1 relies on the accepted proof to make rows after numRealTxs harmless. It does not independently enforce that decoded rows after numRealTxs have no state effect.
The accepted proof, as observed from the trace, allowed state-changing encoded rows after numRealTxs to participate in the rollup state transition.
The mismatch allowed rows to be meaningful to the rollup state while being invisible to L1 public-effect accounting.
Processed withdrawals:
| asset | amount | receiver |
|---|---|---|
| DAI | 270513.054157632152892774 | 0x0f18d8b44a740272f0be4d08338d2b165b7edd17 |
| wstETH | 167.890392526462638142 | 0x0f18d8b44a740272f0be4d08338d2b165b7edd17 |
| yvDAI | 4873.856656463299373447 | 0x0f18d8b44a740272f0be4d08338d2b165b7edd17 |
| yvWETH | 16.569551230652464753 | 0x0f18d8b44a740272f0be4d08338d2b165b7edd17 |
| LUSD | 9273.734320992446140764 | 0x0f18d8b44a740272f0be4d08338d2b165b7edd17 |
| yvLUSD | 359.047484523695640986 | 0x0f18d8b44a740272f0be4d08338d2b165b7edd17 |
| ETH | 908.986707307963496632 | 0x0f18d8b44a740272f0be4d08338d2b165b7edd17 |
The ETH uncounted deposit row was larger by 0.00059732 ETH, which matches rollup fees emitted in the withdrawal phase.
The issue would have been blocked if either side had enforced the shared boundary rule.
L1-side enforcement:
Assert that rows after numRealTxs do not contain state-changing encoded txs,
or process every state-changing decoded row included in the accepted state transition.
Circuit-side enforcement:
Only apply state changes for rows before numRealTxs.
Rows at index >= numRealTxs must be treated as right-padding / state-neutral.
The transaction demonstrates that relying on each side to implicitly cover the other side's interpretation is not enough. The boundary needs a single explicit rule that both sides enforce the same way.
This analysis is based on the transaction trace, verified RollupProcessor source, and local decoding of the processRollup(bytes,bytes) calldata.
The trace shows verifier staticcalls returning true, and the local decoder recomputed matching public-input hashes for the processed rollups. It does not expose private Aztec note witnesses, so it cannot prove private note ownership links between send rows and later withdrawal rows. The public evidence is the decoded row layout, the counted-versus-uncounted L1 accounting behavior, and the matching public deposit/withdrawal amounts.