@aztec/stdlib 5.0.0-nightly.20260701 → 5.0.0-nightly.20260702

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/dest/block/l2_block_source.d.ts +22 -2
  2. package/dest/block/l2_block_source.d.ts.map +1 -1
  3. package/dest/block/l2_block_source.js +10 -0
  4. package/dest/block/l2_block_stream/event_driven_l2_block_stream.d.ts +39 -0
  5. package/dest/block/l2_block_stream/event_driven_l2_block_stream.d.ts.map +1 -0
  6. package/dest/block/l2_block_stream/event_driven_l2_block_stream.js +165 -0
  7. package/dest/block/l2_block_stream/index.d.ts +2 -1
  8. package/dest/block/l2_block_stream/index.d.ts.map +1 -1
  9. package/dest/block/l2_block_stream/index.js +1 -0
  10. package/dest/block/l2_block_stream/interfaces.d.ts +16 -2
  11. package/dest/block/l2_block_stream/interfaces.d.ts.map +1 -1
  12. package/dest/block/l2_block_stream/interfaces.js +25 -1
  13. package/dest/block/l2_block_stream/l2_block_stream.d.ts +21 -35
  14. package/dest/block/l2_block_stream/l2_block_stream.d.ts.map +1 -1
  15. package/dest/block/l2_block_stream/l2_block_stream.js +5 -30
  16. package/dest/contract/contract_address.d.ts +3 -2
  17. package/dest/contract/contract_address.d.ts.map +1 -1
  18. package/dest/contract/partial_address.d.ts +9 -3
  19. package/dest/contract/partial_address.d.ts.map +1 -1
  20. package/dest/contract/partial_address.js +8 -2
  21. package/dest/interfaces/proving-job.d.ts +70 -70
  22. package/dest/keys/derivation.d.ts +31 -1
  23. package/dest/keys/derivation.d.ts.map +1 -1
  24. package/dest/keys/derivation.js +19 -11
  25. package/dest/logs/app_tagging_secret.d.ts +11 -8
  26. package/dest/logs/app_tagging_secret.d.ts.map +1 -1
  27. package/dest/logs/app_tagging_secret.js +14 -14
  28. package/dest/logs/shared_secret_derivation.d.ts +14 -7
  29. package/dest/logs/shared_secret_derivation.d.ts.map +1 -1
  30. package/dest/logs/shared_secret_derivation.js +16 -8
  31. package/package.json +8 -8
  32. package/src/block/l2_block_source.ts +41 -0
  33. package/src/block/l2_block_stream/event_driven_l2_block_stream.ts +207 -0
  34. package/src/block/l2_block_stream/index.ts +1 -0
  35. package/src/block/l2_block_stream/interfaces.ts +35 -1
  36. package/src/block/l2_block_stream/l2_block_stream.ts +35 -62
  37. package/src/contract/contract_address.ts +2 -1
  38. package/src/contract/partial_address.ts +8 -2
  39. package/src/keys/derivation.ts +38 -9
  40. package/src/logs/app_tagging_secret.ts +14 -10
  41. package/src/logs/shared_secret_derivation.ts +17 -10
@@ -5,6 +5,7 @@ import { z } from 'zod';
5
5
  import { AztecAddress } from '../aztec-address/index.js';
6
6
  import { computeAddressSecret, computePreaddress } from '../keys/derivation.js';
7
7
  import { AppTaggingSecretKind } from './app_tagging_secret_kind.js';
8
+ import { appSiloEcdhSharedSecretPoint } from './shared_secret_derivation.js';
8
9
  const AppTaggingSecretKindSchema = z.union([
9
10
  z.literal(AppTaggingSecretKind.UNCONSTRAINED),
10
11
  z.literal(AppTaggingSecretKind.CONSTRAINED)
@@ -27,36 +28,35 @@ const AppTaggingSecretKindSchema = z.union([
27
28
  /**
28
29
  * Derives an app-siloed, recipient-directional tagging secret from a shared tagging secret point.
29
30
  *
31
+ * App-silos the point via {@link appSiloEcdhSharedSecretPoint}, then directs the result to `recipient`:
32
+ * `h([s_app, recipient])`. The directional step stops a symmetric shared secret (ECDH against an address, or an
33
+ * arbitrary registered point) from colliding bidirectionally, so two parties never share a tag sequence.
34
+ *
30
35
  * The point is obtained either via {@link computeSharedTaggingSecret} (an ECDH key exchange against a sender) or
31
- * registered directly as a pre-shared secret. Each secret point yields a distinct tagging secret per (app, recipient)
32
- * pair.
36
+ * registered directly as a pre-shared secret.
33
37
  *
34
38
  * @param taggingSecretPoint - The shared tagging secret point (ECDH output, or a directly registered pre-shared secret)
35
39
  * @param app - Contract address to silo the secret to
36
40
  * @param recipient - Recipient of the log. Defines the "direction of the secret".
37
41
  * @returns The secret that can be used along with an index to compute a tag to be included in a log.
38
- */ static async compute(taggingSecretPoint, app, recipient) {
39
- const appTaggingSecret = await poseidon2Hash([
40
- taggingSecretPoint.x,
41
- taggingSecretPoint.y,
42
- app
43
- ]);
42
+ */ static async computeDirectional(taggingSecretPoint, app, recipient) {
43
+ const appSiloedSecret = await appSiloEcdhSharedSecretPoint(taggingSecretPoint, app);
44
44
  const directionalAppTaggingSecret = await poseidon2Hash([
45
- appTaggingSecret,
45
+ appSiloedSecret,
46
46
  recipient
47
47
  ]);
48
48
  return new AppTaggingSecret(directionalAppTaggingSecret, app);
49
49
  }
50
50
  /**
51
- * Derives the unconstrained tagging secret for `(externalAddress, recipient, app)` by performing the ECDH key
52
- * exchange against `externalAddress` and then siloing and directing the result. Returns undefined if
53
- * `externalAddress` is not a valid address.
54
- */ static async computeUnconstrained(localAddress, localIvsk, externalAddress, app, recipient) {
51
+ * Derives the tagging secret for `(externalAddress, recipient, app)` by performing an ECDH key exchange against
52
+ * `externalAddress` to obtain the shared point, then siloing and directing it via {@link computeDirectional}.
53
+ * Returns undefined if `externalAddress` is not a valid address.
54
+ */ static async computeViaEcdh(localAddress, localIvsk, externalAddress, app, recipient) {
55
55
  const taggingSecretPoint = await computeSharedTaggingSecret(localAddress, localIvsk, externalAddress);
56
56
  if (!taggingSecretPoint) {
57
57
  return undefined;
58
58
  }
59
- return AppTaggingSecret.compute(taggingSecretPoint, app, recipient);
59
+ return AppTaggingSecret.computeDirectional(taggingSecretPoint, app, recipient);
60
60
  }
61
61
  toString() {
62
62
  // TODO(F-680): Migrate stored tagging keys and remove the legacy unconstrained format.
@@ -1,12 +1,10 @@
1
1
  import type { Fr } from '@aztec/foundation/curves/bn254';
2
- import type { GrumpkinScalar } from '@aztec/foundation/curves/grumpkin';
2
+ import type { GrumpkinScalar, Point } from '@aztec/foundation/curves/grumpkin';
3
3
  import type { AztecAddress } from '../aztec-address/index.js';
4
4
  import type { PublicKey } from '../keys/public_key.js';
5
5
  /**
6
- * Derives an app-siloed ECDH shared secret.
7
- *
8
- * Computes the raw ECDH shared secret `S = secretKey * publicKey`, then app-silos it:
9
- * `s_app = h(DOM_SEP__APP_SILOED_ECDH_SHARED_SECRET, S.x, S.y, contractAddress)`
6
+ * Derives an app-siloed ECDH shared secret from keys: ECDHs `S = secretKey * publicKey`, then app-silos it via
7
+ * {@link appSiloEcdhSharedSecretPoint}.
10
8
  *
11
9
  * @param secretKey - The secret key used to derive shared secret.
12
10
  * @param publicKey - The public key used to derive shared secret.
@@ -14,5 +12,14 @@ import type { PublicKey } from '../keys/public_key.js';
14
12
  * @returns The app-siloed shared secret as a Field.
15
13
  * @throws If the publicKey is zero.
16
14
  */
17
- export declare function deriveAppSiloedSharedSecret(secretKey: GrumpkinScalar, publicKey: PublicKey, contractAddress: AztecAddress): Promise<Fr>;
18
- //# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoic2hhcmVkX3NlY3JldF9kZXJpdmF0aW9uLmQudHMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyIuLi8uLi9zcmMvbG9ncy9zaGFyZWRfc2VjcmV0X2Rlcml2YXRpb24udHMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IkFBR0EsT0FBTyxLQUFLLEVBQUUsRUFBRSxFQUFFLE1BQU0sZ0NBQWdDLENBQUM7QUFDekQsT0FBTyxLQUFLLEVBQUUsY0FBYyxFQUFFLE1BQU0sbUNBQW1DLENBQUM7QUFFeEUsT0FBTyxLQUFLLEVBQUUsWUFBWSxFQUFFLE1BQU0sMkJBQTJCLENBQUM7QUFDOUQsT0FBTyxLQUFLLEVBQUUsU0FBUyxFQUFFLE1BQU0sdUJBQXVCLENBQUM7QUFFdkQ7Ozs7Ozs7Ozs7O0dBV0c7QUFDSCx3QkFBc0IsMkJBQTJCLENBQy9DLFNBQVMsRUFBRSxjQUFjLEVBQ3pCLFNBQVMsRUFBRSxTQUFTLEVBQ3BCLGVBQWUsRUFBRSxZQUFZLEdBQzVCLE9BQU8sQ0FBQyxFQUFFLENBQUMsQ0FXYiJ9
15
+ export declare function appSiloEcdhSharedSecret(secretKey: GrumpkinScalar, publicKey: PublicKey, contractAddress: AztecAddress): Promise<Fr>;
16
+ /**
17
+ * App-silos a shared secret point: `s_app = h(DOM_SEP__APP_SILOED_ECDH_SHARED_SECRET, S.x, S.y, app)`.
18
+ *
19
+ * Mirrors `compute_app_siloed_shared_secret` in aztec-nr.
20
+ *
21
+ * @param point - The raw shared secret point `S`.
22
+ * @param app - The contract address to silo to.
23
+ */
24
+ export declare function appSiloEcdhSharedSecretPoint(point: Point, app: AztecAddress): Promise<Fr>;
25
+ //# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoic2hhcmVkX3NlY3JldF9kZXJpdmF0aW9uLmQudHMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyIuLi8uLi9zcmMvbG9ncy9zaGFyZWRfc2VjcmV0X2Rlcml2YXRpb24udHMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IkFBR0EsT0FBTyxLQUFLLEVBQUUsRUFBRSxFQUFFLE1BQU0sZ0NBQWdDLENBQUM7QUFDekQsT0FBTyxLQUFLLEVBQUUsY0FBYyxFQUFFLEtBQUssRUFBRSxNQUFNLG1DQUFtQyxDQUFDO0FBRS9FLE9BQU8sS0FBSyxFQUFFLFlBQVksRUFBRSxNQUFNLDJCQUEyQixDQUFDO0FBQzlELE9BQU8sS0FBSyxFQUFFLFNBQVMsRUFBRSxNQUFNLHVCQUF1QixDQUFDO0FBRXZEOzs7Ozs7Ozs7R0FTRztBQUNILHdCQUFzQix1QkFBdUIsQ0FDM0MsU0FBUyxFQUFFLGNBQWMsRUFDekIsU0FBUyxFQUFFLFNBQVMsRUFDcEIsZUFBZSxFQUFFLFlBQVksR0FDNUIsT0FBTyxDQUFDLEVBQUUsQ0FBQyxDQVFiO0FBRUQ7Ozs7Ozs7R0FPRztBQUNILHdCQUFnQiw0QkFBNEIsQ0FBQyxLQUFLLEVBQUUsS0FBSyxFQUFFLEdBQUcsRUFBRSxZQUFZLEdBQUcsT0FBTyxDQUFDLEVBQUUsQ0FBQyxDQUV6RiJ9
@@ -1 +1 @@
1
- {"version":3,"file":"shared_secret_derivation.d.ts","sourceRoot":"","sources":["../../src/logs/shared_secret_derivation.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,EAAE,EAAE,MAAM,gCAAgC,CAAC;AACzD,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,mCAAmC,CAAC;AAExE,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,2BAA2B,CAAC;AAC9D,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,uBAAuB,CAAC;AAEvD;;;;;;;;;;;GAWG;AACH,wBAAsB,2BAA2B,CAC/C,SAAS,EAAE,cAAc,EACzB,SAAS,EAAE,SAAS,EACpB,eAAe,EAAE,YAAY,GAC5B,OAAO,CAAC,EAAE,CAAC,CAWb"}
1
+ {"version":3,"file":"shared_secret_derivation.d.ts","sourceRoot":"","sources":["../../src/logs/shared_secret_derivation.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,EAAE,EAAE,MAAM,gCAAgC,CAAC;AACzD,OAAO,KAAK,EAAE,cAAc,EAAE,KAAK,EAAE,MAAM,mCAAmC,CAAC;AAE/E,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,2BAA2B,CAAC;AAC9D,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,uBAAuB,CAAC;AAEvD;;;;;;;;;GASG;AACH,wBAAsB,uBAAuB,CAC3C,SAAS,EAAE,cAAc,EACzB,SAAS,EAAE,SAAS,EACpB,eAAe,EAAE,YAAY,GAC5B,OAAO,CAAC,EAAE,CAAC,CAQb;AAED;;;;;;;GAOG;AACH,wBAAgB,4BAA4B,CAAC,KAAK,EAAE,KAAK,EAAE,GAAG,EAAE,YAAY,GAAG,OAAO,CAAC,EAAE,CAAC,CAEzF"}
@@ -2,24 +2,32 @@ import { DomainSeparator } from '@aztec/constants';
2
2
  import { Grumpkin } from '@aztec/foundation/crypto/grumpkin';
3
3
  import { poseidon2HashWithSeparator } from '@aztec/foundation/crypto/poseidon';
4
4
  /**
5
- * Derives an app-siloed ECDH shared secret.
6
- *
7
- * Computes the raw ECDH shared secret `S = secretKey * publicKey`, then app-silos it:
8
- * `s_app = h(DOM_SEP__APP_SILOED_ECDH_SHARED_SECRET, S.x, S.y, contractAddress)`
5
+ * Derives an app-siloed ECDH shared secret from keys: ECDHs `S = secretKey * publicKey`, then app-silos it via
6
+ * {@link appSiloEcdhSharedSecretPoint}.
9
7
  *
10
8
  * @param secretKey - The secret key used to derive shared secret.
11
9
  * @param publicKey - The public key used to derive shared secret.
12
10
  * @param contractAddress - The address of the calling contract, used for app-siloing.
13
11
  * @returns The app-siloed shared secret as a Field.
14
12
  * @throws If the publicKey is zero.
15
- */ export async function deriveAppSiloedSharedSecret(secretKey, publicKey, contractAddress) {
13
+ */ export async function appSiloEcdhSharedSecret(secretKey, publicKey, contractAddress) {
16
14
  if (publicKey.isZero()) {
17
15
  throw new Error(`Attempting to derive a shared secret with a zero public key. You have probably passed a zero public key in your Noir code somewhere thinking that the note won't be broadcast... but it was.`);
18
16
  }
19
17
  const rawSharedSecret = await Grumpkin.mul(publicKey, secretKey);
18
+ return appSiloEcdhSharedSecretPoint(rawSharedSecret, contractAddress);
19
+ }
20
+ /**
21
+ * App-silos a shared secret point: `s_app = h(DOM_SEP__APP_SILOED_ECDH_SHARED_SECRET, S.x, S.y, app)`.
22
+ *
23
+ * Mirrors `compute_app_siloed_shared_secret` in aztec-nr.
24
+ *
25
+ * @param point - The raw shared secret point `S`.
26
+ * @param app - The contract address to silo to.
27
+ */ export function appSiloEcdhSharedSecretPoint(point, app) {
20
28
  return poseidon2HashWithSeparator([
21
- rawSharedSecret.x,
22
- rawSharedSecret.y,
23
- contractAddress
29
+ point.x,
30
+ point.y,
31
+ app
24
32
  ], DomainSeparator.APP_SILOED_ECDH_SHARED_SECRET);
25
33
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aztec/stdlib",
3
- "version": "5.0.0-nightly.20260701",
3
+ "version": "5.0.0-nightly.20260702",
4
4
  "type": "module",
5
5
  "inherits": [
6
6
  "../package.common.json",
@@ -92,13 +92,13 @@
92
92
  },
93
93
  "dependencies": {
94
94
  "@aws-sdk/client-s3": "^3.892.0",
95
- "@aztec/bb.js": "5.0.0-nightly.20260701",
96
- "@aztec/blob-lib": "5.0.0-nightly.20260701",
97
- "@aztec/constants": "5.0.0-nightly.20260701",
98
- "@aztec/ethereum": "5.0.0-nightly.20260701",
99
- "@aztec/foundation": "5.0.0-nightly.20260701",
100
- "@aztec/l1-artifacts": "5.0.0-nightly.20260701",
101
- "@aztec/noir-noirc_abi": "5.0.0-nightly.20260701",
95
+ "@aztec/bb.js": "5.0.0-nightly.20260702",
96
+ "@aztec/blob-lib": "5.0.0-nightly.20260702",
97
+ "@aztec/constants": "5.0.0-nightly.20260702",
98
+ "@aztec/ethereum": "5.0.0-nightly.20260702",
99
+ "@aztec/foundation": "5.0.0-nightly.20260702",
100
+ "@aztec/l1-artifacts": "5.0.0-nightly.20260702",
101
+ "@aztec/noir-noirc_abi": "5.0.0-nightly.20260702",
102
102
  "@google-cloud/storage": "^7.15.0",
103
103
  "axios": "^1.15.1",
104
104
  "json-stringify-deterministic": "1.0.12",
@@ -324,6 +324,7 @@ export type ArchiverEmitter = TypedEventEmitter<{
324
324
  [L2BlockSourceEvents.DescendentOfInvalidAttestationsCheckpointDetected]: (
325
325
  args: DescendentOfInvalidAttestationsCheckpointEvent,
326
326
  ) => void;
327
+ [L2BlockSourceEvents.L2BlockSourceUpdated]: (args: L2BlockSourceUpdatedEvent) => void;
327
328
  }>;
328
329
  export interface L2BlockSourceEventEmitter extends L2BlockSource {
329
330
  events: ArchiverEmitter;
@@ -376,6 +377,28 @@ export function makeL2CheckpointId(number: CheckpointNumber, hash: string): Chec
376
377
  return { number, hash };
377
378
  }
378
379
 
380
+ function l2BlockIdEquals(a: L2BlockId, b: L2BlockId): boolean {
381
+ return a.number === b.number && a.hash === b.hash;
382
+ }
383
+
384
+ function l2TipIdEquals(a: L2TipId, b: L2TipId): boolean {
385
+ return (
386
+ l2BlockIdEquals(a.block, b.block) &&
387
+ a.checkpoint.number === b.checkpoint.number &&
388
+ a.checkpoint.hash === b.checkpoint.hash
389
+ );
390
+ }
391
+
392
+ /** Returns whether two {@link L2Tips} snapshots agree on every tier (proposed, checkpointed, proven, finalized). */
393
+ export function l2TipsEqual(a: L2Tips, b: L2Tips): boolean {
394
+ return (
395
+ l2BlockIdEquals(a.proposed, b.proposed) &&
396
+ l2TipIdEquals(a.checkpointed, b.checkpointed) &&
397
+ l2TipIdEquals(a.proven, b.proven) &&
398
+ l2TipIdEquals(a.finalized, b.finalized)
399
+ );
400
+ }
401
+
379
402
  const L2BlockIdSchema = z.object({
380
403
  number: BlockNumberSchema,
381
404
  hash: z.string(),
@@ -406,8 +429,26 @@ export enum L2BlockSourceEvents {
406
429
  InvalidAttestationsCheckpointDetected = 'invalidCheckpointDetected',
407
430
  CheckpointEquivocationDetected = 'checkpointEquivocationDetected',
408
431
  DescendentOfInvalidAttestationsCheckpointDetected = 'descendentOfInvalidAttestationsCheckpointDetected',
432
+ L2BlockSourceUpdated = 'l2BlockSourceUpdated',
409
433
  }
410
434
 
435
+ /**
436
+ * Aggregate event emitted once per committed archiver sync pass that mutated local state. Carries the chain tips
437
+ * before and after the pass, and the blocks added during it. Consumers compare `fromTips` and `toTips` to learn what
438
+ * moved; there is no separate `changed` section.
439
+ *
440
+ * This is an optimization signal that lets a block stream reconcile immediately on an archiver update rather than
441
+ * waiting for its next poll. Polling remains the correctness fallback, so a missed event only affects latency.
442
+ * `blocksAdded` are hydrated blocks already in hand from the sync pass, so a triggered sync that is caught up to
443
+ * `fromTips` can reuse them (and `toTips`) instead of re-reading the store.
444
+ */
445
+ export type L2BlockSourceUpdatedEvent = {
446
+ type: 'l2BlockSourceUpdated';
447
+ fromTips: L2Tips;
448
+ toTips: L2Tips;
449
+ blocksAdded: readonly L2Block[];
450
+ };
451
+
411
452
  export type L2BlockProvenEvent = {
412
453
  type: 'l2BlockProven';
413
454
  blockNumber: BlockNumber;
@@ -0,0 +1,207 @@
1
+ import { type Logger, createLogger } from '@aztec/foundation/log';
2
+ import { RunningPromise } from '@aztec/foundation/running-promise';
3
+
4
+ import type { BlockData } from '../block_data.js';
5
+ import type { L2Block } from '../l2_block.js';
6
+ import {
7
+ type ArchiverEmitter,
8
+ type BlockQuery,
9
+ type BlocksQuery,
10
+ type L2BlockSource,
11
+ type L2BlockSourceEventEmitter,
12
+ L2BlockSourceEvents,
13
+ type L2BlockSourceUpdatedEvent,
14
+ type L2Tips,
15
+ } from '../l2_block_source.js';
16
+ import { type L2BlockStreamEventHandler, type L2BlockStreamLocalDataProvider, localTipsMatch } from './interfaces.js';
17
+ import { L2BlockStream, type L2BlockStreamOptions, type L2BlockStreamSource } from './l2_block_stream.js';
18
+
19
+ /** Derives the metadata-only {@link BlockData} view of a hydrated {@link L2Block}. */
20
+ async function l2BlockToBlockData(block: L2Block): Promise<BlockData> {
21
+ return {
22
+ header: block.header,
23
+ archive: block.archive,
24
+ blockHash: await block.hash(),
25
+ checkpointNumber: block.checkpointNumber,
26
+ indexWithinCheckpoint: block.indexWithinCheckpoint,
27
+ };
28
+ }
29
+
30
+ /** Returns the event emitter of a source that exposes one, or undefined for plain (e.g. RPC-backed) sources. */
31
+ function getEmitter(source: L2BlockSource | L2BlockSourceEventEmitter): ArchiverEmitter | undefined {
32
+ return 'events' in source ? source.events : undefined;
33
+ }
34
+
35
+ /** Fast-path context for a single sync pass: blocks to serve by number, plus the tips to report as the source's. */
36
+ type ActiveUpdate = { byNumber: Map<number, L2Block>; toTips: L2Tips };
37
+
38
+ /**
39
+ * Wraps a block source so a single sync pass can be served from blocks delivered by an aggregate update event,
40
+ * avoiding round-trips to archiver storage. The fast path is only armed (via {@link activate}) for a pass that is
41
+ * confirmed caught up to the event's pre-pass tips: in that case the event's `blocksAdded` contiguously cover the
42
+ * pass's download range and `toTips` is the exact post-pass tip, so `getL2Tips` can report it without querying the
43
+ * source. When the fast path is not armed, every read delegates to the source, so a stale or partial cache never
44
+ * changes the sync outcome.
45
+ */
46
+ class HotBlockSourceAdapter implements L2BlockStreamSource {
47
+ /** Set for the duration of one fast-path pass; undefined when reads must delegate to the source. */
48
+ private active: ActiveUpdate | undefined;
49
+
50
+ constructor(
51
+ private readonly source: L2BlockStreamSource,
52
+ private readonly log: Logger,
53
+ ) {}
54
+
55
+ /** Arms the fast path for the current pass: serve these blocks and report `toTips` as the source tips. */
56
+ public activate(blocks: readonly L2Block[], toTips: L2Tips): void {
57
+ const byNumber = new Map<number, L2Block>();
58
+ for (const block of blocks) {
59
+ byNumber.set(block.number, block);
60
+ }
61
+ this.active = { byNumber, toTips };
62
+ this.log.trace(`Armed hot-block fast path`, { blocks: byNumber.size, toTips });
63
+ }
64
+
65
+ /** Disarms the fast path so subsequent reads delegate to the source again. */
66
+ public deactivate(): void {
67
+ this.active = undefined;
68
+ }
69
+
70
+ public getL2Tips(): Promise<L2Tips> {
71
+ return this.active ? Promise.resolve(this.active.toTips) : this.source.getL2Tips();
72
+ }
73
+
74
+ public getBlocks(query: BlocksQuery): Promise<L2Block[]> {
75
+ const served = this.active ? this.tryServeBlocksFromCache(query) : undefined;
76
+ return served ? Promise.resolve(served) : this.source.getBlocks(query);
77
+ }
78
+
79
+ public getBlockData(query: BlockQuery): Promise<BlockData | undefined> {
80
+ if (this.active && 'number' in query) {
81
+ const block = this.active.byNumber.get(query.number);
82
+ if (block) {
83
+ return l2BlockToBlockData(block);
84
+ }
85
+ }
86
+ return this.source.getBlockData(query);
87
+ }
88
+
89
+ /** Serves a block range from the cache only when it is fully covered by contiguous cached blocks. */
90
+ private tryServeBlocksFromCache(query: BlocksQuery): L2Block[] | undefined {
91
+ // Only the by-range form is cacheable, and only for the full (not checkpointed-only) chain: the cache may hold
92
+ // uncheckpointed blocks that an onlyCheckpointed query must not receive.
93
+ if (!this.active || !('from' in query) || query.onlyCheckpointed) {
94
+ return undefined;
95
+ }
96
+ const from = query.from;
97
+ const to = from + query.limit - 1;
98
+ const blocks: L2Block[] = [];
99
+ for (let n = from; n <= to; n++) {
100
+ const block = this.active.byNumber.get(n);
101
+ if (!block) {
102
+ // A gap inside the requested range: bail out and let the source serve the whole range.
103
+ return undefined;
104
+ }
105
+ blocks.push(block);
106
+ }
107
+ return blocks;
108
+ }
109
+ }
110
+
111
+ /**
112
+ * Event-driven wrapper around {@link L2BlockStream}. Subscribes to the source's aggregate `l2BlockSourceUpdated`
113
+ * event (when the source exposes one) to trigger an immediate reconciliation, while keeping the periodic poll as
114
+ * the correctness fallback. Subsystems keep consuming the same {@link L2BlockStreamEvent}s; the archiver aggregate
115
+ * event is handled entirely here.
116
+ *
117
+ * The event is passed through to the sync pass via {@link RunningPromise.trigger}. If, at the time the pass runs,
118
+ * the stream's local tips match the event's `fromTips` (it is caught up to where the event began), the event's
119
+ * hydrated blocks are served back through a hot-block cache and the event's `toTips` is reported as the source tips
120
+ * — so the triggered sync re-reads neither block bodies nor tips from the archiver. Otherwise the pass delegates
121
+ * entirely to the source, and the periodic poll guarantees eventual catch-up.
122
+ */
123
+ export class EventDrivenL2BlockStream {
124
+ private readonly adapter: HotBlockSourceAdapter;
125
+ private readonly blockStream: L2BlockStream;
126
+ private readonly runningPromise: RunningPromise<L2BlockSourceUpdatedEvent>;
127
+ private readonly emitter: ArchiverEmitter | undefined;
128
+ private started = false;
129
+
130
+ private readonly onSourceUpdated = (event: L2BlockSourceUpdatedEvent) => {
131
+ // Fire-and-forget: trigger coalesces with any in-flight or periodic pass (see RunningPromise.trigger), so a
132
+ // burst of events does not run passes concurrently. Errors are swallowed by the inner stream's own handler.
133
+ void this.runningPromise
134
+ .trigger(event)
135
+ .catch(err => this.log.error(`Error in event-triggered block stream sync`, err));
136
+ };
137
+
138
+ constructor(
139
+ source: L2BlockSource | L2BlockSourceEventEmitter,
140
+ private readonly localData: L2BlockStreamLocalDataProvider,
141
+ handler: L2BlockStreamEventHandler,
142
+ private readonly log = createLogger('types:event_driven_block_stream'),
143
+ opts: L2BlockStreamOptions = {},
144
+ ) {
145
+ this.adapter = new HotBlockSourceAdapter(source, log);
146
+ // The inner stream's own RunningPromise is never started; this wrapper owns the polling loop and drives the
147
+ // stream through `sync()` (which runs `work()` directly when the inner loop is stopped).
148
+ this.blockStream = new L2BlockStream(this.adapter, localData, handler, log, opts);
149
+ this.runningPromise = new RunningPromise<L2BlockSourceUpdatedEvent>(
150
+ this.runPass.bind(this),
151
+ log,
152
+ opts.pollIntervalMS ?? 1000,
153
+ );
154
+ this.emitter = getEmitter(source);
155
+ }
156
+
157
+ public start(): void {
158
+ if (this.started) {
159
+ this.log.warn(`Attempted to start an already-started event-driven block stream`);
160
+ return;
161
+ }
162
+ this.started = true;
163
+ this.emitter?.on(L2BlockSourceEvents.L2BlockSourceUpdated, this.onSourceUpdated);
164
+ this.runningPromise.start();
165
+ }
166
+
167
+ public async stop(): Promise<void> {
168
+ this.started = false;
169
+ this.emitter?.off(L2BlockSourceEvents.L2BlockSourceUpdated, this.onSourceUpdated);
170
+ await this.runningPromise.stop();
171
+ await this.blockStream.stop();
172
+ }
173
+
174
+ public isRunning(): boolean {
175
+ return this.runningPromise.isRunning();
176
+ }
177
+
178
+ /**
179
+ * Runs a synchronization pass now, bypassing the poll interval, and resolves once that pass completes. Concurrent
180
+ * callers and periodic ticks coalesce onto a single pass; a caller that coalesces onto an already in-flight pass
181
+ * can resolve against a pass that began just before it, so this guarantees freshness only up to that coalescing
182
+ * window. The periodic poll and per-pass reorg handling make the gap a latency effect, never a correctness one.
183
+ */
184
+ public sync(): Promise<void> {
185
+ return this.runningPromise.trigger();
186
+ }
187
+
188
+ /**
189
+ * Runs a single pass over the underlying block stream. When triggered by an aggregate event and the stream is
190
+ * caught up to the event's pre-pass tips, the event's blocks and tips serve the pass directly; the fast path is
191
+ * always disarmed afterwards so a subsequent poll-driven pass reads from the source.
192
+ */
193
+ private async runPass(event?: L2BlockSourceUpdatedEvent): Promise<void> {
194
+ if (event) {
195
+ const localTips = await this.localData.getL2Tips();
196
+ if (localTipsMatch(localTips, event.fromTips)) {
197
+ this.adapter.activate(event.blocksAdded, event.toTips);
198
+ }
199
+ }
200
+
201
+ try {
202
+ await this.blockStream.sync();
203
+ } finally {
204
+ this.adapter.deactivate();
205
+ }
206
+ }
207
+ }
@@ -1,3 +1,4 @@
1
+ export * from './event_driven_l2_block_stream.js';
1
2
  export * from './interfaces.js';
2
3
  export * from './l2_block_stream.js';
3
4
  export * from './l2_tips_memory_store.js';
@@ -1,7 +1,7 @@
1
1
  import type { BlockNumber } from '@aztec/foundation/branded-types';
2
2
 
3
3
  import type { L2Block } from '../l2_block.js';
4
- import type { CheckpointId, L2BlockId, L2TipId, LocalL2Tips } from '../l2_block_source.js';
4
+ import type { CheckpointId, L2BlockId, L2TipId, L2Tips, LocalL2Tips } from '../l2_block_source.js';
5
5
 
6
6
  /** Provides the current chain tips. Implemented by world-state, l2-tips-store, and AztecNode. */
7
7
  export interface L2TipsProvider {
@@ -35,6 +35,40 @@ export interface L2BlockStreamLocalDataProvider {
35
35
  getL2BlockHash(number: number): Promise<string | undefined>;
36
36
  }
37
37
 
38
+ /**
39
+ * Returns whether a local block id differs from a source block id. Compares block number and, when the local hash is
40
+ * known, block hash. The hash comparison is skipped when the local hash is undefined: world-state legitimately
41
+ * reports `undefined` hashes for tips ahead of its synced range, and comparing against an undefined hash would treat
42
+ * such a tip as different on every poll. An `undefined` local block (no local tip yet) always counts as differing.
43
+ */
44
+ export function localBlockIdDiffers(localBlock: LocalL2BlockId | undefined, sourceBlock: L2BlockId): boolean {
45
+ if (localBlock === undefined) {
46
+ return true;
47
+ }
48
+ if (sourceBlock.number !== localBlock.number) {
49
+ return true;
50
+ }
51
+ if (localBlock.hash === undefined) {
52
+ return false;
53
+ }
54
+ return sourceBlock.hash !== localBlock.hash;
55
+ }
56
+
57
+ /**
58
+ * Returns whether the local chain tips agree with the given source tips on every tier the local provider exposes.
59
+ * Each tier is compared at the block level via {@link localBlockIdDiffers} (so an unresolved local hash matches on
60
+ * number alone); checkpoint ids are not compared, mirroring the stream's own tier reconciliation. The optional
61
+ * `checkpointed` tier is only compared when present (it is absent when the stream ignores checkpoints).
62
+ */
63
+ export function localTipsMatch(local: LocalChainTips, source: L2Tips): boolean {
64
+ return (
65
+ !localBlockIdDiffers(local.proposed, source.proposed) &&
66
+ (local.checkpointed === undefined || !localBlockIdDiffers(local.checkpointed.block, source.checkpointed.block)) &&
67
+ !localBlockIdDiffers(local.proven.block, source.proven.block) &&
68
+ !localBlockIdDiffers(local.finalized.block, source.finalized.block)
69
+ );
70
+ }
71
+
38
72
  /** Interface to a handler of events emitted. */
39
73
  export interface L2BlockStreamEventHandler {
40
74
  handleBlockStreamEvent(event: L2BlockStreamEvent): Promise<void>;
@@ -4,22 +4,34 @@ import { createLogger } from '@aztec/foundation/log';
4
4
  import { RunningPromise } from '@aztec/foundation/running-promise';
5
5
 
6
6
  import type { L2Block } from '../l2_block.js';
7
+ import { type L2BlockId, type L2BlockSource, type LocalL2Tips, makeL2BlockId } from '../l2_block_source.js';
7
8
  import {
8
- type L2BlockId,
9
- type L2BlockSource,
10
- type L2TipId,
11
- type LocalL2Tips,
12
- makeL2BlockId,
13
- } from '../l2_block_source.js';
14
- import type {
15
- L2BlockStreamEvent,
16
- L2BlockStreamEventHandler,
17
- L2BlockStreamLocalDataProvider,
18
- LocalL2BlockId,
9
+ type L2BlockStreamEvent,
10
+ type L2BlockStreamEventHandler,
11
+ type L2BlockStreamLocalDataProvider,
12
+ localBlockIdDiffers,
19
13
  } from './interfaces.js';
20
14
 
21
15
  /** Subset of the block source the stream depends on. Checkpoint payloads are no longer fetched here. */
22
- type L2BlockStreamSource = Pick<L2BlockSource, 'getBlocks' | 'getBlockData' | 'getL2Tips'>;
16
+ export type L2BlockStreamSource = Pick<L2BlockSource, 'getBlocks' | 'getBlockData' | 'getL2Tips'>;
17
+
18
+ /** Options accepted by {@link L2BlockStream} and {@link EventDrivenL2BlockStream}. */
19
+ export type L2BlockStreamOptions = {
20
+ pollIntervalMS?: number;
21
+ batchSize?: number;
22
+ startingBlock?: number;
23
+ /** Instead of downloading all blocks, only fetch the smallest subset that results in reliable reorg detection. */
24
+ skipFinalized?: boolean;
25
+ /** When true, checkpoint events will not be emitted. Blocks are still fetched but only blocks-added events are emitted. */
26
+ ignoreCheckpoints?: boolean;
27
+ /**
28
+ * When true, the block download loop is skipped entirely: `getBlocks` is never called and `blocks-added` is
29
+ * never emitted. Only the tip events (`chain-proposed`/`chain-checkpointed`/`chain-proven`/`chain-finalized`)
30
+ * and `chain-pruned` are emitted, driven by the `getL2Tips` snapshot. For consumers that track tips but never
31
+ * consume block payloads.
32
+ */
33
+ tipsOnly?: boolean;
34
+ };
23
35
 
24
36
  /** Creates a stream of events for new blocks, chain tips updates, and reorgs, out of polling an archiver or a node. */
25
37
  export class L2BlockStream {
@@ -32,22 +44,7 @@ export class L2BlockStream {
32
44
  private localData: L2BlockStreamLocalDataProvider,
33
45
  private handler: L2BlockStreamEventHandler,
34
46
  private readonly log = createLogger('types:block_stream'),
35
- private opts: {
36
- pollIntervalMS?: number;
37
- batchSize?: number;
38
- startingBlock?: number;
39
- /** Instead of downloading all blocks, only fetch the smallest subset that results in reliable reorg detection. */
40
- skipFinalized?: boolean;
41
- /** When true, checkpoint events will not be emitted. Blocks are still fetched but only blocks-added events are emitted. */
42
- ignoreCheckpoints?: boolean;
43
- /**
44
- * When true, the block download loop is skipped entirely: `getBlocks` is never called and `blocks-added` is
45
- * never emitted. Only the tip events (`chain-proposed`/`chain-checkpointed`/`chain-proven`/`chain-finalized`)
46
- * and `chain-pruned` are emitted, driven by the `getL2Tips` snapshot. For consumers that track tips but never
47
- * consume block payloads.
48
- */
49
- tipsOnly?: boolean;
50
- } = {},
47
+ private opts: L2BlockStreamOptions = {},
51
48
  ) {
52
49
  if (opts.tipsOnly && (opts.startingBlock !== undefined || opts.batchSize !== undefined || opts.skipFinalized)) {
53
50
  throw new Error(
@@ -181,19 +178,25 @@ export class L2BlockStream {
181
178
  // End-of-pass reconciliation: chain-proposed fires against the pre-pass baseline (a post-prune re-read would
182
179
  // equal the source tip and suppress it), then the tiers highest-to-lowest so the finalized <= proven <=
183
180
  // checkpointed <= proposed invariant holds mid-pass.
184
- if (this.blockTipDiffers(prePassProposed, sourceTips.proposed)) {
181
+ if (localBlockIdDiffers(prePassProposed, sourceTips.proposed)) {
185
182
  await this.emitEvent({ type: 'chain-proposed', block: sourceTips.proposed });
186
183
  }
187
184
 
188
185
  const reconcileTips = pruned ? await this.localData.getL2Tips() : localTips;
189
- if (!this.opts.ignoreCheckpoints && this.tipDiffers(reconcileTips.checkpointed?.block, sourceTips.checkpointed)) {
186
+ if (
187
+ !this.opts.ignoreCheckpoints &&
188
+ localBlockIdDiffers(reconcileTips.checkpointed?.block, sourceTips.checkpointed.block)
189
+ ) {
190
190
  await this.emitEvent({
191
191
  type: 'chain-checkpointed',
192
192
  block: sourceTips.checkpointed.block,
193
193
  checkpoint: sourceTips.checkpointed.checkpoint,
194
194
  });
195
195
  }
196
- if (reconcileTips.proven !== undefined && this.tipDiffers(reconcileTips.proven.block, sourceTips.proven)) {
196
+ if (
197
+ reconcileTips.proven !== undefined &&
198
+ localBlockIdDiffers(reconcileTips.proven.block, sourceTips.proven.block)
199
+ ) {
197
200
  await this.emitEvent({
198
201
  type: 'chain-proven',
199
202
  block: sourceTips.proven.block,
@@ -202,7 +205,7 @@ export class L2BlockStream {
202
205
  }
203
206
  if (
204
207
  reconcileTips.finalized !== undefined &&
205
- this.tipDiffers(reconcileTips.finalized.block, sourceTips.finalized)
208
+ localBlockIdDiffers(reconcileTips.finalized.block, sourceTips.finalized.block)
206
209
  ) {
207
210
  await this.emitEvent({
208
211
  type: 'chain-finalized',
@@ -286,36 +289,6 @@ export class L2BlockStream {
286
289
  return true;
287
290
  }
288
291
 
289
- /**
290
- * Returns whether the source tip differs from the local one and therefore warrants a tier event. Compares block
291
- * number and, when both hashes are known, block hash. The hash comparison is skipped when the local hash is
292
- * undefined or missing: world-state legitimately reports `undefined` hashes for tips ahead of its synced range,
293
- * and comparing against an undefined hash would re-emit the event on every poll.
294
- */
295
- private tipDiffers(localBlock: LocalL2BlockId | undefined, sourceTip: L2TipId): boolean {
296
- return this.blockTipDiffers(localBlock, sourceTip.block);
297
- }
298
-
299
- /**
300
- * Block-only variant of {@link tipDiffers} for the proposed tip (an {@link L2BlockId}, with no checkpoint). Compares
301
- * block number and, when the local hash is known, block hash. The hash comparison is skipped when the local hash is
302
- * undefined: world-state reports `undefined` for its proposed hash, and a strict comparison would re-emit
303
- * `chain-proposed` on every poll for it. ({@link L2TipsStoreBase} consumers always carry a hash, so the leniency is
304
- * inert for them.)
305
- */
306
- private blockTipDiffers(localBlock: LocalL2BlockId | undefined, sourceBlock: L2BlockId): boolean {
307
- if (localBlock === undefined) {
308
- return true;
309
- }
310
- if (sourceBlock.number !== localBlock.number) {
311
- return true;
312
- }
313
- if (localBlock.hash === undefined) {
314
- return false;
315
- }
316
- return sourceBlock.hash !== localBlock.hash;
317
- }
318
-
319
292
  /**
320
293
  * Returns whether the source and local agree on the block hash at a given height.
321
294
  * @param blockNumber - The block number to test.
@@ -7,6 +7,7 @@ import type { AztecAddress } from '../aztec-address/index.js';
7
7
  import { computeVarArgsHash } from '../hash/hash.js';
8
8
  import { computeAddress } from '../keys/index.js';
9
9
  import type { ContractInstance } from './interfaces/contract_instance.js';
10
+ import type { PartialAddress } from './partial_address.js';
10
11
 
11
12
  // TODO(@spalladino): Review all generator indices in this file
12
13
 
@@ -36,7 +37,7 @@ export async function computePartialAddress(
36
37
  instance:
37
38
  | Pick<ContractInstance, 'originalContractClassId' | 'initializationHash' | 'salt' | 'deployer' | 'immutablesHash'>
38
39
  | { originalContractClassId: Fr; saltedInitializationHash: Fr },
39
- ): Promise<Fr> {
40
+ ): Promise<PartialAddress> {
40
41
  const saltedInitializationHash =
41
42
  'saltedInitializationHash' in instance
42
43
  ? instance.saltedInitializationHash