Skip to content

Commit 6226e13

Browse files
nventuronchamoAztecBotvezenovm
authored
fix: dropped tagging indices no longer cause a pxe crash on sync (#23044)
Fixes #22949. We keep track of the range of tagging indices used by a tx as `(lowestIdx, highestIdx)` - we need this so that future transactions start from `highest + 1` even if a previous tx has not yet been mined, avoiding tag duplication. The lowest index is required in case of transaction reverts: we walk all indices from low to high and test which ones survived onchain, and that way determine the actual highest index for a tx. On sync, we validate that our records for a tx match the indices used in said tx - this helps detect bugs (like the bug being fixed here). This check started failing because our `(low, high)` record did not consider the possibility of logs being squashed - if either `low` or `high` got squashed then the range would change. Note that we don't care about indices between low and high being squashed , gaps are ok - we only really care about the edges. This PR fixes this by inspecting tx effects to adjust the range prior to storing the indices in the database. It hints at the need for a tagging service layer, but that was a bigger change that I didn't want to introduce here. I added tests for both new functions created, plus an e2e regression test that checks we've fixed the original issue. I validated that the test fails without this fix. --------- Co-authored-by: Nicolas Chamo <nicolas@chamo.com.ar> Co-authored-by: AztecBot <tech@aztecprotocol.com> Co-authored-by: Maxim Vezenov <mvezenov@gmail.com>
1 parent b0185eb commit 6226e13

8 files changed

Lines changed: 505 additions & 16 deletions

File tree

noir-projects/noir-contracts/contracts/test/test_log_contract/src/main.nr

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ pub contract TestLog {
77
use aztec::{
88
macros::{events::event, functions::external, storage::storage},
99
messages::message_delivery::MessageDelivery,
10+
note::note_getter_options::NoteGetterOptions,
1011
oracle::random::random,
1112
protocol::{address::AztecAddress, traits::{FromField, Serialize}},
1213
state_vars::{Owned, PrivateSet},
@@ -93,6 +94,24 @@ pub contract TestLog {
9394
self.emit(ExampleNestedEvent { nested: NestedStruct { a, b, c }, extra_value: extra });
9495
}
9596

97+
/// This emits two note messages using unconstrained onchain delivery, consuming two tag indices. The note
98+
/// from the first message is nullified in the same transaction, resulting in note, nullifier and logs being
99+
/// squashed.
100+
///
101+
/// Used to verify that PXE successfully handles scenarios where used tagging indices don't make it their way to the
102+
/// tx effects due to squashing.
103+
#[external("private")]
104+
fn deliver_squashed_and_surviving_notes(other: AztecAddress) {
105+
let note_set = self.storage.example_set.at(other);
106+
107+
// Create the first note and message, then immediately nullify. This squashes all effects.
108+
note_set.insert(FieldNote { value: 0 }).deliver(MessageDelivery.ONCHAIN_UNCONSTRAINED);
109+
let _ = note_set.pop_notes(NoteGetterOptions::new());
110+
111+
// Deliver a second note message. Only this one will be included in the logs.
112+
note_set.insert(FieldNote { value: 1 }).deliver(MessageDelivery.ONCHAIN_UNCONSTRAINED);
113+
}
114+
96115
#[external("private")]
97116
fn emit_encrypted_events_nested(other: AztecAddress, num_nested_calls: u32) {
98117
// Safety: We use the following just as an arbitrary test value

yarn-project/end-to-end/src/e2e_event_logs.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -262,4 +262,29 @@ describe('Logs', () => {
262262
expect(allTags.size).toBe(tx1NumLogs + tx2NumLogs);
263263
});
264264
});
265+
266+
describe('tagging cache reconciliation against kernel squashing', () => {
267+
// Regression test for https://github.com/AztecProtocol/aztec-packages/issues/22949.
268+
//
269+
// The PXE's tagging cache reserves an index for every log emission attempted during private execution, but the
270+
// kernel may then squash some of those logs (e.g. when a note is created and nullified in the same tx, taking
271+
// its delivery log with it). The PXE must reconcile the recorded ranges against the kernel's surviving private
272+
// logs before persisting them, otherwise a subsequent tx sharing the same tagging secret hits a
273+
// `Conflicting range` error when its tagging sync re-derives the range from on-chain data and notices a mismatch.
274+
it('does not throw `Conflicting range` across consecutive squashing txs sharing a tagging secret', async () => {
275+
// Each call reserves two indexes for the (sender, sender, contract) tagging secret and squashes the first
276+
// delivery's (note, nullifier, log) triple. Pre-fix, the second call's tagging sync would observe that the
277+
// first tx had recorded `[N, N+1]` while only `[N+1]` actually landed on chain, and throw.
278+
// Using `account1Address` for both sender and recipient mirrors the original repro from #22949
279+
// (`transfer_private_to_public(self, self, ...)`) and keeps the note's owner accessible from the wallet for
280+
// in-tx nullification.
281+
await testLogContract.methods
282+
.deliver_squashed_and_surviving_notes(account1Address)
283+
.send({ from: account1Address });
284+
285+
await testLogContract.methods
286+
.deliver_squashed_and_surviving_notes(account1Address)
287+
.send({ from: account1Address });
288+
});
289+
});
265290
});

yarn-project/pxe/src/pxe.ts

Lines changed: 15 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ import { PrivateEventStore } from './storage/private_event_store/private_event_s
8484
import { RecipientTaggingStore } from './storage/tagging_store/recipient_tagging_store.js';
8585
import { SenderAddressBookStore } from './storage/tagging_store/sender_address_book_store.js';
8686
import { SenderTaggingStore } from './storage/tagging_store/sender_tagging_store.js';
87+
import { persistSenderTaggingIndexRangesForTx } from './tagging/index.js';
8788

8889
export type PackedPrivateEvent = InTx & {
8990
packedEvent: Fr[];
@@ -842,22 +843,20 @@ export class PXE {
842843
nodeRPCCalls: contractFunctionSimulator?.getStats().nodeRPCCalls,
843844
});
844845

845-
// While not strictly necessary to store tagging cache contents in the DB since we sync tagging indexes from
846-
// chain before sending new logs, the sync can only see logs already included in blocks. If we send another
847-
// transaction before this one is included in a block from this PXE, and that transaction contains a log with
848-
// a tag derived from the same secret, we would reuse the tag and the transactions would be linked. Hence
849-
// storing the tags here prevents linkage of txs sent from the same PXE.
850-
const taggingIndexRangesUsedInTheTx = privateExecutionResult.entrypoint.taggingIndexRanges;
851-
if (taggingIndexRangesUsedInTheTx.length > 0) {
852-
const txHash = await txProvingResult.getTxHash();
853-
854-
await this.senderTaggingStore.storePendingIndexes(taggingIndexRangesUsedInTheTx, txHash, jobId);
855-
this.log.debug(`Stored used tagging index ranges as sender for the tx`, {
856-
taggingIndexRangesUsedInTheTx,
857-
});
858-
} else {
859-
this.log.debug(`No tagging index ranges used in the tx`);
860-
}
846+
// We keep track of which tagging indices we've used in this tx so that we don't repeat them in future txs
847+
// (which would link them) without having to rely on this tx being mined (and us seeing the indices being used
848+
// onchain).
849+
// Note that this must happen _after_ proving as it requires the proof's public inputs, from which the kernels
850+
// may have removed some logs due to note-nullifier squashing - this may lead to range of tagging indices we've
851+
// actually used to being reduced.
852+
await persistSenderTaggingIndexRangesForTx(
853+
this.senderTaggingStore,
854+
privateExecutionResult.entrypoint.taggingIndexRanges,
855+
publicInputs,
856+
() => txProvingResult.getTxHash(),
857+
jobId,
858+
this.log,
859+
);
861860

862861
return txProvingResult;
863862
} catch (err: any) {

yarn-project/pxe/src/tagging/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
export { syncTaggedPrivateLogs } from './recipient_sync/sync_tagged_private_logs.js';
1313
export { syncSenderTaggingIndexes } from './sender_sync/sync_sender_tagging_indexes.js';
14+
export { persistSenderTaggingIndexRangesForTx } from './persist_sender_tagging_index_ranges.js';
1415
export { UNFINALIZED_TAGGING_INDEXES_WINDOW_LEN } from './constants.js';
1516
export { getAllPrivateLogsByTags, getAllPublicLogsByTagsFromContract } from './get_all_logs_by_tags.js';
1617

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import { type Logger, createLogger } from '@aztec/foundation/log';
2+
import type { PrivateKernelTailCircuitPublicInputs } from '@aztec/stdlib/kernel';
3+
import { type ExtendedDirectionalAppTaggingSecret, PrivateLog, SiloedTag } from '@aztec/stdlib/logs';
4+
import { randomExtendedDirectionalAppTaggingSecret } from '@aztec/stdlib/testing';
5+
import { TxHash } from '@aztec/stdlib/tx';
6+
7+
import { jest } from '@jest/globals';
8+
import { type MockProxy, mock } from 'jest-mock-extended';
9+
10+
import type { SenderTaggingStore } from '../storage/tagging_store/sender_tagging_store.js';
11+
import { persistSenderTaggingIndexRangesForTx } from './persist_sender_tagging_index_ranges.js';
12+
13+
describe('persistSenderTaggingIndexRangesForTx', () => {
14+
let secret: ExtendedDirectionalAppTaggingSecret;
15+
let store: MockProxy<SenderTaggingStore>;
16+
let publicInputs: MockProxy<PrivateKernelTailCircuitPublicInputs>;
17+
let log: Logger;
18+
let txHash: TxHash;
19+
let getTxHash: jest.Mock<() => Promise<TxHash>>;
20+
21+
beforeAll(async () => {
22+
secret = await randomExtendedDirectionalAppTaggingSecret();
23+
log = createLogger('test:persist-sender-tagging-index-ranges');
24+
});
25+
26+
beforeEach(() => {
27+
store = mock<SenderTaggingStore>();
28+
publicInputs = mock<PrivateKernelTailCircuitPublicInputs>();
29+
txHash = TxHash.random();
30+
getTxHash = jest.fn<() => Promise<TxHash>>().mockResolvedValue(txHash);
31+
});
32+
33+
/** Builds a `PrivateLog` whose first field is the siloed tag for `(secret, index)`. */
34+
async function survivingLogForIndex(index: number): Promise<PrivateLog> {
35+
const tag = await SiloedTag.compute({ extendedSecret: secret, index });
36+
return PrivateLog.fromBlobFields(1, [tag.value]);
37+
}
38+
39+
it('does nothing when no recorded ranges are provided', async () => {
40+
publicInputs.getNonEmptyPrivateLogs.mockReturnValue([]);
41+
42+
await persistSenderTaggingIndexRangesForTx(store, [], publicInputs, getTxHash, 'test', log);
43+
44+
expect(store.storePendingIndexes).not.toHaveBeenCalled();
45+
expect(getTxHash).not.toHaveBeenCalled();
46+
});
47+
48+
it('does nothing when every recorded index was squashed', async () => {
49+
publicInputs.getNonEmptyPrivateLogs.mockReturnValue([]);
50+
51+
await persistSenderTaggingIndexRangesForTx(
52+
store,
53+
[{ extendedSecret: secret, lowestIndex: 1, highestIndex: 3 }],
54+
publicInputs,
55+
getTxHash,
56+
'test',
57+
log,
58+
);
59+
60+
expect(store.storePendingIndexes).not.toHaveBeenCalled();
61+
expect(getTxHash).not.toHaveBeenCalled();
62+
});
63+
64+
it('persists recorded ranges unchanged when every index survives', async () => {
65+
publicInputs.getNonEmptyPrivateLogs.mockReturnValue([
66+
await survivingLogForIndex(1),
67+
await survivingLogForIndex(2),
68+
await survivingLogForIndex(3),
69+
]);
70+
71+
await persistSenderTaggingIndexRangesForTx(
72+
store,
73+
[{ extendedSecret: secret, lowestIndex: 1, highestIndex: 3 }],
74+
publicInputs,
75+
getTxHash,
76+
'test',
77+
log,
78+
);
79+
80+
expect(store.storePendingIndexes).toHaveBeenCalledWith(
81+
[{ extendedSecret: secret, lowestIndex: 1, highestIndex: 3 }],
82+
txHash,
83+
'test',
84+
);
85+
expect(getTxHash).toHaveBeenCalledTimes(1);
86+
});
87+
88+
it('persists shrunk ranges when some indexes were squashed', async () => {
89+
// Recorded range [1, 5], but only indexes 2 and 4 survived squashing.
90+
publicInputs.getNonEmptyPrivateLogs.mockReturnValue([await survivingLogForIndex(2), await survivingLogForIndex(4)]);
91+
92+
await persistSenderTaggingIndexRangesForTx(
93+
store,
94+
[{ extendedSecret: secret, lowestIndex: 1, highestIndex: 5 }],
95+
publicInputs,
96+
getTxHash,
97+
'test',
98+
log,
99+
);
100+
101+
expect(store.storePendingIndexes).toHaveBeenCalledWith(
102+
[{ extendedSecret: secret, lowestIndex: 2, highestIndex: 4 }],
103+
txHash,
104+
'test',
105+
);
106+
});
107+
});
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import type { Logger } from '@aztec/foundation/log';
2+
import type { PrivateKernelTailCircuitPublicInputs } from '@aztec/stdlib/kernel';
3+
import type { TaggingIndexRange } from '@aztec/stdlib/logs';
4+
import type { TxHash } from '@aztec/stdlib/tx';
5+
6+
import type { SenderTaggingStore } from '../storage/tagging_store/sender_tagging_store.js';
7+
import { reconcileTaggingIndexRangesAgainstSurvivingTags } from './reconcile_tagging_index_ranges.js';
8+
9+
/**
10+
* Persists the tagging index ranges that a tx used as a sender, after reconciling them against the kernel's surviving
11+
* private logs.
12+
*
13+
* The tagging index cache reserves an index for every log emission attempted during private execution, but the kernel
14+
* may then squash some of those logs (e.g. when a note is created and nullified within the same tx). This function
15+
* shrinks the recorded ranges to match the actual on-chain footprint before writing them, so that what we persist is
16+
* consistent with what the network will see.
17+
*
18+
* @remarks Storing the recorded ranges in the DB may not be seen as necessary because we sync from chain before sending
19+
* new logs, but the sync can only see logs already included in blocks. If we sent another transaction before this one
20+
* was included from the same PXE, and that transaction contained a log with a tag derived from the same secret, we
21+
* would reuse the tag and the transactions would be linked. Persisting in the DB prevents that linkage.
22+
*
23+
* @param store - The sender tagging store.
24+
* @param recordedRanges - The tagging index ranges as recorded during private execution (pre-squash).
25+
* @param publicInputs - The final kernel public inputs, used to determine which private logs survived squashing.
26+
* @param getTxHash - Lazy accessor for the tx hash. Called only when there is something to persist, since computing
27+
* the tx hash is expensive.
28+
* @param jobId - Job context for staged writes to the store. See `JobCoordinator` for more details.
29+
* @param log - Logger.
30+
*/
31+
export async function persistSenderTaggingIndexRangesForTx(
32+
store: SenderTaggingStore,
33+
recordedRanges: TaggingIndexRange[],
34+
publicInputs: PrivateKernelTailCircuitPublicInputs,
35+
getTxHash: () => Promise<TxHash>,
36+
jobId: string,
37+
log: Logger,
38+
): Promise<void> {
39+
if (recordedRanges.length === 0) {
40+
log.debug(`No tagging index ranges used in the tx`);
41+
return;
42+
}
43+
44+
const survivingTags = new Set(
45+
publicInputs.getNonEmptyPrivateLogs().map(privateLog => privateLog.fields[0].toString()),
46+
);
47+
const reconciledRanges = await reconcileTaggingIndexRangesAgainstSurvivingTags(recordedRanges, survivingTags);
48+
49+
if (reconciledRanges.length === 0) {
50+
log.debug(`All tagging index ranges used in the tx were squashed by the kernel`, { recordedRanges });
51+
return;
52+
}
53+
54+
const txHash = await getTxHash();
55+
await store.storePendingIndexes(reconciledRanges, txHash, jobId);
56+
log.debug(`Stored used tagging index ranges as sender for the tx`, { recordedRanges, reconciledRanges });
57+
}

0 commit comments

Comments
 (0)