@aztec/stdlib 0.84.0 → 0.85.0
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.
- package/dest/avm/avm.d.ts +2474 -284
- package/dest/avm/avm.d.ts.map +1 -1
- package/dest/avm/avm.js +116 -17
- package/dest/avm/avm_proving_request.d.ts +1071 -23
- package/dest/avm/avm_proving_request.d.ts.map +1 -1
- package/dest/block/in_block.d.ts.map +1 -1
- package/dest/block/index.d.ts +1 -1
- package/dest/block/index.d.ts.map +1 -1
- package/dest/block/index.js +1 -1
- package/dest/block/l2_block_source.d.ts +8 -5
- package/dest/block/l2_block_source.d.ts.map +1 -1
- package/dest/block/l2_block_source.js +9 -0
- package/dest/block/l2_block_stream/index.d.ts +4 -0
- package/dest/block/l2_block_stream/index.d.ts.map +1 -0
- package/dest/block/l2_block_stream/index.js +3 -0
- package/dest/block/l2_block_stream/interfaces.d.ts +26 -0
- package/dest/block/l2_block_stream/interfaces.d.ts.map +1 -0
- package/dest/block/l2_block_stream/interfaces.js +1 -0
- package/dest/block/{l2_block_downloader → l2_block_stream}/l2_block_stream.d.ts +4 -24
- package/dest/block/l2_block_stream/l2_block_stream.d.ts.map +1 -0
- package/dest/block/{l2_block_downloader → l2_block_stream}/l2_block_stream.js +29 -10
- package/dest/block/l2_block_stream/l2_tips_memory_store.d.ts +18 -0
- package/dest/block/l2_block_stream/l2_tips_memory_store.d.ts.map +1 -0
- package/dest/block/l2_block_stream/l2_tips_memory_store.js +70 -0
- package/dest/block/test/index.d.ts +2 -0
- package/dest/block/test/index.d.ts.map +1 -0
- package/dest/block/test/index.js +1 -0
- package/dest/block/test/l2_tips_store_test_suite.d.ts +3 -0
- package/dest/block/test/l2_tips_store_test_suite.d.ts.map +1 -0
- package/dest/block/test/l2_tips_store_test_suite.js +107 -0
- package/dest/database-version/version_manager.d.ts +21 -5
- package/dest/database-version/version_manager.d.ts.map +1 -1
- package/dest/database-version/version_manager.js +25 -15
- package/dest/epoch-helpers/index.d.ts +9 -0
- package/dest/epoch-helpers/index.d.ts.map +1 -1
- package/dest/epoch-helpers/index.js +15 -2
- package/dest/interfaces/archiver.d.ts.map +1 -1
- package/dest/interfaces/archiver.js +4 -4
- package/dest/interfaces/aztec-node.d.ts +5 -6
- package/dest/interfaces/aztec-node.d.ts.map +1 -1
- package/dest/interfaces/aztec-node.js +2 -3
- package/dest/interfaces/proving-job.d.ts +1071 -23
- package/dest/interfaces/proving-job.d.ts.map +1 -1
- package/dest/interfaces/pxe.d.ts +5 -7
- package/dest/interfaces/pxe.d.ts.map +1 -1
- package/dest/interfaces/pxe.js +2 -7
- package/dest/interfaces/world_state.d.ts +3 -2
- package/dest/interfaces/world_state.d.ts.map +1 -1
- package/dest/logs/log_with_tx_data.d.ts +3 -2
- package/dest/logs/log_with_tx_data.d.ts.map +1 -1
- package/dest/logs/log_with_tx_data.js +3 -2
- package/dest/logs/pending_tagged_log.d.ts +4 -2
- package/dest/logs/pending_tagged_log.d.ts.map +1 -1
- package/dest/logs/pending_tagged_log.js +6 -3
- package/dest/messaging/l1_to_l2_message_source.d.ts +5 -0
- package/dest/messaging/l1_to_l2_message_source.d.ts.map +1 -1
- package/dest/proofs/proof.d.ts +4 -1
- package/dest/proofs/proof.d.ts.map +1 -1
- package/dest/proofs/proof.js +9 -17
- package/dest/tests/factories.d.ts +5 -1
- package/dest/tests/factories.d.ts.map +1 -1
- package/dest/tests/factories.js +23 -7
- package/dest/tests/mocks.d.ts +3 -1
- package/dest/tests/mocks.d.ts.map +1 -1
- package/dest/tests/mocks.js +3 -2
- package/dest/tx/index.d.ts +2 -0
- package/dest/tx/index.d.ts.map +1 -1
- package/dest/tx/index.js +2 -0
- package/dest/tx/indexed_tx_effect.d.ts +24 -0
- package/dest/tx/indexed_tx_effect.d.ts.map +1 -0
- package/dest/tx/indexed_tx_effect.js +14 -0
- package/dest/tx/tx_hash.d.ts.map +1 -1
- package/dest/tx/tx_hash.js +1 -4
- package/dest/tx/validator/error_texts.d.ts +20 -0
- package/dest/tx/validator/error_texts.d.ts.map +1 -0
- package/dest/tx/validator/error_texts.js +27 -0
- package/package.json +8 -6
- package/src/avm/avm.ts +188 -29
- package/src/block/in_block.ts +1 -0
- package/src/block/index.ts +1 -1
- package/src/block/l2_block_source.ts +15 -5
- package/src/block/l2_block_stream/index.ts +3 -0
- package/src/block/l2_block_stream/interfaces.ts +33 -0
- package/src/block/{l2_block_downloader → l2_block_stream}/l2_block_stream.ts +34 -44
- package/src/block/l2_block_stream/l2_tips_memory_store.ts +75 -0
- package/src/block/test/index.ts +1 -0
- package/src/block/test/l2_tips_store_test_suite.ts +87 -0
- package/src/database-version/version_manager.ts +56 -17
- package/src/epoch-helpers/index.ts +19 -0
- package/src/interfaces/archiver.ts +3 -3
- package/src/interfaces/aztec-node.ts +7 -6
- package/src/interfaces/pxe.ts +15 -11
- package/src/interfaces/world_state.ts +3 -2
- package/src/logs/log_with_tx_data.ts +4 -3
- package/src/logs/pending_tagged_log.ts +5 -2
- package/src/messaging/l1_to_l2_message_source.ts +7 -0
- package/src/proofs/proof.ts +9 -19
- package/src/tests/factories.ts +54 -4
- package/src/tests/mocks.ts +6 -1
- package/src/tx/index.ts +2 -0
- package/src/tx/indexed_tx_effect.ts +17 -0
- package/src/tx/tx_hash.ts +0 -4
- package/src/tx/validator/error_texts.ts +34 -0
- package/dest/block/l2_block_downloader/index.d.ts +0 -3
- package/dest/block/l2_block_downloader/index.d.ts.map +0 -1
- package/dest/block/l2_block_downloader/index.js +0 -2
- package/dest/block/l2_block_downloader/l2_block_downloader.d.ts +0 -58
- package/dest/block/l2_block_downloader/l2_block_downloader.d.ts.map +0 -1
- package/dest/block/l2_block_downloader/l2_block_downloader.js +0 -124
- package/dest/block/l2_block_downloader/l2_block_stream.d.ts.map +0 -1
- package/src/block/l2_block_downloader/index.ts +0 -2
- package/src/block/l2_block_downloader/l2_block_downloader.ts +0 -149
|
@@ -2,8 +2,8 @@ import { AbortError } from '@aztec/foundation/error';
|
|
|
2
2
|
import { createLogger } from '@aztec/foundation/log';
|
|
3
3
|
import { RunningPromise } from '@aztec/foundation/running-promise';
|
|
4
4
|
|
|
5
|
-
import type
|
|
6
|
-
import type {
|
|
5
|
+
import { type L2BlockId, type L2BlockSource, makeL2BlockId } from '../l2_block_source.js';
|
|
6
|
+
import type { L2BlockStreamEvent, L2BlockStreamEventHandler, L2BlockStreamLocalDataProvider } from './interfaces.js';
|
|
7
7
|
|
|
8
8
|
/** Creates a stream of events for new blocks, chain tips updates, and reorgs, out of polling an archiver or a node. */
|
|
9
9
|
export class L2BlockStream {
|
|
@@ -21,6 +21,8 @@ export class L2BlockStream {
|
|
|
21
21
|
pollIntervalMS?: number;
|
|
22
22
|
batchSize?: number;
|
|
23
23
|
startingBlock?: number;
|
|
24
|
+
/** Instead of downloading all blocks, only fetch the smallest subset that results in reliable reorg detection. */
|
|
25
|
+
skipFinalized?: boolean;
|
|
24
26
|
} = {},
|
|
25
27
|
) {
|
|
26
28
|
this.runningPromise = new RunningPromise(() => this.work(), log, this.opts.pollIntervalMS ?? 1000);
|
|
@@ -72,14 +74,13 @@ export class L2BlockStream {
|
|
|
72
74
|
}
|
|
73
75
|
|
|
74
76
|
if (latestBlockNumber < localTips.latest.number) {
|
|
77
|
+
latestBlockNumber = Math.min(latestBlockNumber, sourceTips.latest.number); // see #13471
|
|
78
|
+
const hash = sourceCache.get(latestBlockNumber) ?? (await this.getBlockHashFromSource(latestBlockNumber));
|
|
79
|
+
if (latestBlockNumber !== 0 && !hash) {
|
|
80
|
+
throw new Error(`Block hash not found in block source for block number ${latestBlockNumber}`);
|
|
81
|
+
}
|
|
75
82
|
this.log.verbose(`Reorg detected. Pruning blocks from ${latestBlockNumber + 1} to ${localTips.latest.number}.`);
|
|
76
|
-
await this.emitEvent({
|
|
77
|
-
type: 'chain-pruned',
|
|
78
|
-
block: {
|
|
79
|
-
number: latestBlockNumber,
|
|
80
|
-
hash: sourceCache.get(latestBlockNumber) ?? (await this.getBlockHashFromSource(latestBlockNumber))!,
|
|
81
|
-
},
|
|
82
|
-
});
|
|
83
|
+
await this.emitEvent({ type: 'chain-pruned', block: makeL2BlockId(latestBlockNumber, hash) });
|
|
83
84
|
}
|
|
84
85
|
|
|
85
86
|
// If we are just starting, use the starting block number from the options.
|
|
@@ -93,17 +94,26 @@ export class L2BlockStream {
|
|
|
93
94
|
this.hasStarted = true;
|
|
94
95
|
}
|
|
95
96
|
|
|
97
|
+
let nextBlockNumber = latestBlockNumber + 1;
|
|
98
|
+
if (this.opts.skipFinalized) {
|
|
99
|
+
// When skipping finalized blocks we need to provide reliable reorg detection while fetching as few blocks as
|
|
100
|
+
// possible. Finalized blocks cannot be reorged by definition, so we can skip most of them. We do need the very
|
|
101
|
+
// last finalized block however in order to guarantee that we will eventually find a block in which our local
|
|
102
|
+
// store matches the source.
|
|
103
|
+
// If the last finalized block is behind our local tip, there is nothing to skip.
|
|
104
|
+
nextBlockNumber = Math.max(sourceTips.finalized.number, nextBlockNumber);
|
|
105
|
+
}
|
|
106
|
+
|
|
96
107
|
// Request new blocks from the source.
|
|
97
|
-
while (
|
|
98
|
-
const
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
const blocks = await this.l2BlockSource.getPublishedBlocks(from, limit, this.opts.proven);
|
|
108
|
+
while (nextBlockNumber <= sourceTips.latest.number) {
|
|
109
|
+
const limit = Math.min(this.opts.batchSize ?? 20, sourceTips.latest.number - nextBlockNumber + 1);
|
|
110
|
+
this.log.trace(`Requesting blocks from ${nextBlockNumber} limit ${limit} proven=${this.opts.proven}`);
|
|
111
|
+
const blocks = await this.l2BlockSource.getPublishedBlocks(nextBlockNumber, limit, this.opts.proven);
|
|
102
112
|
if (blocks.length === 0) {
|
|
103
113
|
break;
|
|
104
114
|
}
|
|
105
115
|
await this.emitEvent({ type: 'blocks-added', blocks });
|
|
106
|
-
|
|
116
|
+
nextBlockNumber = blocks.at(-1)!.block.number + 1;
|
|
107
117
|
}
|
|
108
118
|
|
|
109
119
|
// Update the proven and finalized tips.
|
|
@@ -134,6 +144,15 @@ export class L2BlockStream {
|
|
|
134
144
|
return true;
|
|
135
145
|
}
|
|
136
146
|
const localBlockHash = await this.localData.getL2BlockHash(blockNumber);
|
|
147
|
+
if (!localBlockHash && this.opts.skipFinalized) {
|
|
148
|
+
// Failing to find a block hash when skipping finalized blocks can be highly problematic as we'd potentially need
|
|
149
|
+
// to go all the way back to the genesis block to find a block in which we agree with the source (since we've
|
|
150
|
+
// potentially skipped all history). This means that stores that prune old blocks must be careful to leave no gaps
|
|
151
|
+
// when going back from latest block to the last finalized one.
|
|
152
|
+
this.log.error(`No local block hash for block number ${blockNumber}`);
|
|
153
|
+
throw new AbortError();
|
|
154
|
+
}
|
|
155
|
+
|
|
137
156
|
const sourceBlockHashFromCache = args.sourceCache.get(blockNumber);
|
|
138
157
|
const sourceBlockHash = args.sourceCache.get(blockNumber) ?? (await this.getBlockHashFromSource(blockNumber));
|
|
139
158
|
if (!sourceBlockHashFromCache && sourceBlockHash) {
|
|
@@ -181,32 +200,3 @@ class BlockHashCache {
|
|
|
181
200
|
return this.cache.get(blockNumber);
|
|
182
201
|
}
|
|
183
202
|
}
|
|
184
|
-
|
|
185
|
-
/** Interface to the local view of the chain. Implemented by world-state and l2-tips-store. */
|
|
186
|
-
export interface L2BlockStreamLocalDataProvider {
|
|
187
|
-
getL2BlockHash(number: number): Promise<string | undefined>;
|
|
188
|
-
getL2Tips(): Promise<L2Tips>;
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
/** Interface to a handler of events emitted. */
|
|
192
|
-
export interface L2BlockStreamEventHandler {
|
|
193
|
-
handleBlockStreamEvent(event: L2BlockStreamEvent): Promise<void>;
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
export type L2BlockStreamEvent =
|
|
197
|
-
| /** Emits blocks added to the chain. */ {
|
|
198
|
-
type: 'blocks-added';
|
|
199
|
-
blocks: PublishedL2Block[];
|
|
200
|
-
}
|
|
201
|
-
| /** Reports last correct block (new tip of the unproven chain). */ {
|
|
202
|
-
type: 'chain-pruned';
|
|
203
|
-
block: L2BlockId;
|
|
204
|
-
}
|
|
205
|
-
| /** Reports new proven block. */ {
|
|
206
|
-
type: 'chain-proven';
|
|
207
|
-
block: L2BlockId;
|
|
208
|
-
}
|
|
209
|
-
| /** Reports new finalized block (proven and finalized on L1). */ {
|
|
210
|
-
type: 'chain-finalized';
|
|
211
|
-
block: L2BlockId;
|
|
212
|
-
};
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { L2Block } from '../l2_block.js';
|
|
2
|
+
import type { L2BlockId, L2BlockTag, L2Tips } from '../l2_block_source.js';
|
|
3
|
+
import type { L2BlockStreamEvent, L2BlockStreamEventHandler, L2BlockStreamLocalDataProvider } from './interfaces.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Stores currently synced L2 tips and unfinalized block hashes.
|
|
7
|
+
* @dev tests in kv-store/src/stores/l2_tips_memory_store.test.ts
|
|
8
|
+
*/
|
|
9
|
+
export class L2TipsMemoryStore implements L2BlockStreamEventHandler, L2BlockStreamLocalDataProvider {
|
|
10
|
+
protected readonly l2TipsStore: Map<L2BlockTag, number> = new Map();
|
|
11
|
+
protected readonly l2BlockHashesStore: Map<number, string> = new Map();
|
|
12
|
+
|
|
13
|
+
public getL2BlockHash(number: number): Promise<string | undefined> {
|
|
14
|
+
return Promise.resolve(this.l2BlockHashesStore.get(number));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
public getL2Tips(): Promise<L2Tips> {
|
|
18
|
+
return Promise.resolve({
|
|
19
|
+
latest: this.getL2Tip('latest'),
|
|
20
|
+
finalized: this.getL2Tip('finalized'),
|
|
21
|
+
proven: this.getL2Tip('proven'),
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
private getL2Tip(tag: L2BlockTag): L2BlockId {
|
|
26
|
+
const blockNumber = this.l2TipsStore.get(tag);
|
|
27
|
+
if (blockNumber === undefined || blockNumber === 0) {
|
|
28
|
+
return { number: 0, hash: undefined };
|
|
29
|
+
}
|
|
30
|
+
const blockHash = this.l2BlockHashesStore.get(blockNumber);
|
|
31
|
+
if (!blockHash) {
|
|
32
|
+
throw new Error(`Block hash not found for block number ${blockNumber}`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return { number: blockNumber, hash: blockHash };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
public async handleBlockStreamEvent(event: L2BlockStreamEvent): Promise<void> {
|
|
39
|
+
switch (event.type) {
|
|
40
|
+
case 'blocks-added': {
|
|
41
|
+
const blocks = event.blocks.map(b => b.block);
|
|
42
|
+
for (const block of blocks) {
|
|
43
|
+
this.l2BlockHashesStore.set(block.number, await this.computeBlockHash(block));
|
|
44
|
+
}
|
|
45
|
+
this.l2TipsStore.set('latest', blocks.at(-1)!.number);
|
|
46
|
+
break;
|
|
47
|
+
}
|
|
48
|
+
case 'chain-pruned':
|
|
49
|
+
this.saveTag('latest', event.block);
|
|
50
|
+
break;
|
|
51
|
+
case 'chain-proven':
|
|
52
|
+
this.saveTag('proven', event.block);
|
|
53
|
+
break;
|
|
54
|
+
case 'chain-finalized':
|
|
55
|
+
this.saveTag('finalized', event.block);
|
|
56
|
+
for (const key of this.l2BlockHashesStore.keys()) {
|
|
57
|
+
if (key < event.block.number) {
|
|
58
|
+
this.l2BlockHashesStore.delete(key);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
break;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
protected saveTag(name: L2BlockTag, block: L2BlockId) {
|
|
66
|
+
this.l2TipsStore.set(name, block.number);
|
|
67
|
+
if (block.hash) {
|
|
68
|
+
this.l2BlockHashesStore.set(block.number, block.hash);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
protected computeBlockHash(block: L2Block) {
|
|
73
|
+
return block.header.hash().then(hash => hash.toString());
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './l2_tips_store_test_suite.js';
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { times } from '@aztec/foundation/collection';
|
|
2
|
+
import { Fr } from '@aztec/foundation/fields';
|
|
3
|
+
import type { L2Block, L2BlockId, PublishedL2Block } from '@aztec/stdlib/block';
|
|
4
|
+
import type { BlockHeader } from '@aztec/stdlib/tx';
|
|
5
|
+
|
|
6
|
+
import { jestExpect as expect } from '@jest/expect';
|
|
7
|
+
|
|
8
|
+
import type { L2TipsStore } from '../l2_block_stream/index.js';
|
|
9
|
+
|
|
10
|
+
export function testL2TipsStore(makeTipsStore: () => Promise<L2TipsStore>) {
|
|
11
|
+
let tipsStore: L2TipsStore;
|
|
12
|
+
|
|
13
|
+
beforeEach(async () => {
|
|
14
|
+
tipsStore = await makeTipsStore();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
const makeBlock = (number: number): PublishedL2Block => ({
|
|
18
|
+
block: { number, header: { hash: () => Promise.resolve(new Fr(number)) } as BlockHeader } as L2Block,
|
|
19
|
+
l1: { blockNumber: BigInt(number), blockHash: `0x${number}`, timestamp: BigInt(number) },
|
|
20
|
+
signatures: [],
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
const makeBlockId = (number: number): L2BlockId => ({
|
|
24
|
+
number,
|
|
25
|
+
hash: new Fr(number).toString(),
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const makeTip = (number: number) => ({ number, hash: number === 0 ? undefined : new Fr(number).toString() });
|
|
29
|
+
|
|
30
|
+
const makeTips = (latest: number, proven: number, finalized: number) => ({
|
|
31
|
+
latest: makeTip(latest),
|
|
32
|
+
proven: makeTip(proven),
|
|
33
|
+
finalized: makeTip(finalized),
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('returns zero if no tips are stored', async () => {
|
|
37
|
+
const tips = await tipsStore.getL2Tips();
|
|
38
|
+
expect(tips).toEqual(makeTips(0, 0, 0));
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('stores chain tips', async () => {
|
|
42
|
+
await tipsStore.handleBlockStreamEvent({ type: 'blocks-added', blocks: times(20, i => makeBlock(i + 1)) });
|
|
43
|
+
|
|
44
|
+
await tipsStore.handleBlockStreamEvent({ type: 'chain-finalized', block: makeBlockId(5) });
|
|
45
|
+
await tipsStore.handleBlockStreamEvent({ type: 'chain-proven', block: makeBlockId(8) });
|
|
46
|
+
await tipsStore.handleBlockStreamEvent({ type: 'chain-pruned', block: makeBlockId(10) });
|
|
47
|
+
|
|
48
|
+
const tips = await tipsStore.getL2Tips();
|
|
49
|
+
expect(tips).toEqual(makeTips(10, 8, 5));
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('sets latest tip from blocks added', async () => {
|
|
53
|
+
await tipsStore.handleBlockStreamEvent({ type: 'blocks-added', blocks: times(3, i => makeBlock(i + 1)) });
|
|
54
|
+
|
|
55
|
+
const tips = await tipsStore.getL2Tips();
|
|
56
|
+
expect(tips).toEqual(makeTips(3, 0, 0));
|
|
57
|
+
|
|
58
|
+
expect(await tipsStore.getL2BlockHash(1)).toEqual(new Fr(1).toString());
|
|
59
|
+
expect(await tipsStore.getL2BlockHash(2)).toEqual(new Fr(2).toString());
|
|
60
|
+
expect(await tipsStore.getL2BlockHash(3)).toEqual(new Fr(3).toString());
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('clears block hashes when setting finalized chain', async () => {
|
|
64
|
+
await tipsStore.handleBlockStreamEvent({ type: 'blocks-added', blocks: times(5, i => makeBlock(i + 1)) });
|
|
65
|
+
await tipsStore.handleBlockStreamEvent({ type: 'chain-proven', block: makeBlockId(3) });
|
|
66
|
+
await tipsStore.handleBlockStreamEvent({ type: 'chain-finalized', block: makeBlockId(3) });
|
|
67
|
+
|
|
68
|
+
const tips = await tipsStore.getL2Tips();
|
|
69
|
+
expect(tips).toEqual(makeTips(5, 3, 3));
|
|
70
|
+
|
|
71
|
+
expect(await tipsStore.getL2BlockHash(1)).toBeUndefined();
|
|
72
|
+
expect(await tipsStore.getL2BlockHash(2)).toBeUndefined();
|
|
73
|
+
|
|
74
|
+
expect(await tipsStore.getL2BlockHash(3)).toEqual(new Fr(3).toString());
|
|
75
|
+
expect(await tipsStore.getL2BlockHash(4)).toEqual(new Fr(4).toString());
|
|
76
|
+
expect(await tipsStore.getL2BlockHash(5)).toEqual(new Fr(5).toString());
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// Regression test for #13142
|
|
80
|
+
it('does not blow up when setting proven chain on an unseen block number', async () => {
|
|
81
|
+
await tipsStore.handleBlockStreamEvent({ type: 'blocks-added', blocks: [makeBlock(5)] });
|
|
82
|
+
await tipsStore.handleBlockStreamEvent({ type: 'chain-proven', block: makeBlockId(3) });
|
|
83
|
+
|
|
84
|
+
const tips = await tipsStore.getL2Tips();
|
|
85
|
+
expect(tips).toEqual(makeTips(5, 3, 0));
|
|
86
|
+
});
|
|
87
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { EthAddress } from '@aztec/foundation/eth-address';
|
|
2
2
|
import { jsonParseWithSchemaSync, jsonStringify } from '@aztec/foundation/json-rpc';
|
|
3
|
-
import { createLogger } from '@aztec/foundation/log';
|
|
3
|
+
import { type Logger, createLogger } from '@aztec/foundation/log';
|
|
4
4
|
|
|
5
5
|
import fs from 'fs/promises';
|
|
6
6
|
import { inspect } from 'node:util';
|
|
@@ -11,7 +11,12 @@ import { z } from 'zod';
|
|
|
11
11
|
* Represents a version record for storing in a version file.
|
|
12
12
|
*/
|
|
13
13
|
export class DatabaseVersion {
|
|
14
|
-
constructor(
|
|
14
|
+
constructor(
|
|
15
|
+
/** The version of the data on disk. Used to perform upgrades */
|
|
16
|
+
public readonly schemaVersion: number,
|
|
17
|
+
/** The rollup the data pertains to */
|
|
18
|
+
public readonly rollupAddress: EthAddress,
|
|
19
|
+
) {}
|
|
15
20
|
|
|
16
21
|
public toBuffer(): Buffer {
|
|
17
22
|
return Buffer.from(jsonStringify(this));
|
|
@@ -66,7 +71,7 @@ export class DatabaseVersion {
|
|
|
66
71
|
}
|
|
67
72
|
|
|
68
73
|
public toString(): string {
|
|
69
|
-
return this.schemaVersion.
|
|
74
|
+
return `DatabaseVersion{schemaVersion=${this.schemaVersion},rollupAddress=${this.rollupAddress}"}`;
|
|
70
75
|
}
|
|
71
76
|
|
|
72
77
|
/**
|
|
@@ -81,6 +86,16 @@ export type DatabaseVersionManagerFs = Pick<typeof fs, 'readFile' | 'writeFile'
|
|
|
81
86
|
|
|
82
87
|
export const DATABASE_VERSION_FILE_NAME = 'db_version';
|
|
83
88
|
|
|
89
|
+
export type DatabaseVersionManagerOptions<T> = {
|
|
90
|
+
schemaVersion: number;
|
|
91
|
+
rollupAddress: EthAddress;
|
|
92
|
+
dataDirectory: string;
|
|
93
|
+
onOpen: (dataDir: string) => Promise<T>;
|
|
94
|
+
onUpgrade?: (dataDir: string, currentVersion: number, latestVersion: number) => Promise<void>;
|
|
95
|
+
fileSystem?: DatabaseVersionManagerFs;
|
|
96
|
+
log?: Logger;
|
|
97
|
+
};
|
|
98
|
+
|
|
84
99
|
/**
|
|
85
100
|
* A manager for handling database versioning and migrations.
|
|
86
101
|
* This class will check the version of data in a directory and either
|
|
@@ -92,6 +107,12 @@ export class DatabaseVersionManager<T> {
|
|
|
92
107
|
private readonly versionFile: string;
|
|
93
108
|
private readonly currentVersion: DatabaseVersion;
|
|
94
109
|
|
|
110
|
+
private dataDirectory: string;
|
|
111
|
+
private onOpen: (dataDir: string) => Promise<T>;
|
|
112
|
+
private onUpgrade?: (dataDir: string, currentVersion: number, latestVersion: number) => Promise<void>;
|
|
113
|
+
private fileSystem: DatabaseVersionManagerFs;
|
|
114
|
+
private log: Logger;
|
|
115
|
+
|
|
95
116
|
/**
|
|
96
117
|
* Create a new version manager
|
|
97
118
|
*
|
|
@@ -104,21 +125,27 @@ export class DatabaseVersionManager<T> {
|
|
|
104
125
|
* @param log - Optional custom logger
|
|
105
126
|
* @param options - Configuration options
|
|
106
127
|
*/
|
|
107
|
-
constructor(
|
|
108
|
-
schemaVersion
|
|
109
|
-
rollupAddress
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
) {
|
|
128
|
+
constructor({
|
|
129
|
+
schemaVersion,
|
|
130
|
+
rollupAddress,
|
|
131
|
+
dataDirectory,
|
|
132
|
+
onOpen,
|
|
133
|
+
onUpgrade,
|
|
134
|
+
fileSystem = fs,
|
|
135
|
+
log = createLogger(`foundation:version-manager`),
|
|
136
|
+
}: DatabaseVersionManagerOptions<T>) {
|
|
116
137
|
if (schemaVersion < 1) {
|
|
117
138
|
throw new TypeError(`Invalid schema version received: ${schemaVersion}`);
|
|
118
139
|
}
|
|
119
140
|
|
|
120
|
-
this.versionFile = join(
|
|
141
|
+
this.versionFile = join(dataDirectory, DatabaseVersionManager.VERSION_FILE);
|
|
121
142
|
this.currentVersion = new DatabaseVersion(schemaVersion, rollupAddress);
|
|
143
|
+
|
|
144
|
+
this.dataDirectory = dataDirectory;
|
|
145
|
+
this.onOpen = onOpen;
|
|
146
|
+
this.onUpgrade = onUpgrade;
|
|
147
|
+
this.fileSystem = fileSystem;
|
|
148
|
+
this.log = log;
|
|
122
149
|
}
|
|
123
150
|
|
|
124
151
|
static async writeVersion(version: DatabaseVersion, dataDir: string, fileSystem: DatabaseVersionManagerFs = fs) {
|
|
@@ -137,6 +164,8 @@ export class DatabaseVersionManager<T> {
|
|
|
137
164
|
public async open(): Promise<[T, boolean]> {
|
|
138
165
|
// const storedVersion = await DatabaseVersion.readVersion(this.versionFile);
|
|
139
166
|
let storedVersion: DatabaseVersion;
|
|
167
|
+
// a flag to suppress logs about 'resetting the data dir' when starting from an empty state
|
|
168
|
+
let shouldLogDataReset = true;
|
|
140
169
|
|
|
141
170
|
try {
|
|
142
171
|
const versionBuf = await this.fileSystem.readFile(this.versionFile);
|
|
@@ -144,6 +173,8 @@ export class DatabaseVersionManager<T> {
|
|
|
144
173
|
} catch (err) {
|
|
145
174
|
if (err && (err as Error & { code: string }).code === 'ENOENT') {
|
|
146
175
|
storedVersion = DatabaseVersion.empty();
|
|
176
|
+
// only turn off these logs if the data dir didn't exist before
|
|
177
|
+
shouldLogDataReset = false;
|
|
147
178
|
} else {
|
|
148
179
|
this.log.warn(`Failed to read stored version information: ${err}. Defaulting to empty version`);
|
|
149
180
|
storedVersion = DatabaseVersion.empty();
|
|
@@ -164,13 +195,21 @@ export class DatabaseVersionManager<T> {
|
|
|
164
195
|
needsReset = true;
|
|
165
196
|
}
|
|
166
197
|
} else if (cmp !== 0) {
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
198
|
+
if (shouldLogDataReset) {
|
|
199
|
+
this.log.info(
|
|
200
|
+
`Can't upgrade from version ${storedVersion} to ${this.currentVersion}. Resetting database at ${this.dataDirectory}`,
|
|
201
|
+
);
|
|
202
|
+
}
|
|
170
203
|
needsReset = true;
|
|
171
204
|
}
|
|
172
205
|
} else {
|
|
173
|
-
|
|
206
|
+
if (shouldLogDataReset) {
|
|
207
|
+
this.log.warn('Rollup address has changed, resetting data directory', {
|
|
208
|
+
versionFile: this.versionFile,
|
|
209
|
+
storedVersion,
|
|
210
|
+
currentVersion: this.currentVersion,
|
|
211
|
+
});
|
|
212
|
+
}
|
|
174
213
|
needsReset = true;
|
|
175
214
|
}
|
|
176
215
|
|
|
@@ -8,6 +8,7 @@ export type L1RollupConstants = {
|
|
|
8
8
|
slotDuration: number;
|
|
9
9
|
epochDuration: number;
|
|
10
10
|
ethereumSlotDuration: number;
|
|
11
|
+
proofSubmissionWindow: number;
|
|
11
12
|
};
|
|
12
13
|
|
|
13
14
|
export const EmptyL1RollupConstants: L1RollupConstants = {
|
|
@@ -16,6 +17,7 @@ export const EmptyL1RollupConstants: L1RollupConstants = {
|
|
|
16
17
|
epochDuration: 1, // Not 0 to pervent division by zero
|
|
17
18
|
slotDuration: 1,
|
|
18
19
|
ethereumSlotDuration: 1,
|
|
20
|
+
proofSubmissionWindow: 2,
|
|
19
21
|
};
|
|
20
22
|
|
|
21
23
|
export const L1RollupConstantsSchema = z.object({
|
|
@@ -24,6 +26,7 @@ export const L1RollupConstantsSchema = z.object({
|
|
|
24
26
|
slotDuration: z.number(),
|
|
25
27
|
epochDuration: z.number(),
|
|
26
28
|
ethereumSlotDuration: z.number(),
|
|
29
|
+
proofSubmissionWindow: z.number(),
|
|
27
30
|
}) satisfies ZodFor<L1RollupConstants>;
|
|
28
31
|
|
|
29
32
|
/** Returns the timestamp for a given L2 slot. */
|
|
@@ -75,3 +78,19 @@ export function getTimestampRangeForEpoch(
|
|
|
75
78
|
BigInt((ethereumSlotsPerL2Slot - 1) * constants.ethereumSlotDuration),
|
|
76
79
|
];
|
|
77
80
|
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Returns the deadline timestamp (in seconds) for submitting a proof for a given epoch.
|
|
84
|
+
* Computed as the start of the given epoch plus the proof submission window.
|
|
85
|
+
*/
|
|
86
|
+
export function getProofSubmissionDeadlineTimestamp(
|
|
87
|
+
epochNumber: bigint,
|
|
88
|
+
constants: Pick<L1RollupConstants, 'l1GenesisTime' | 'slotDuration' | 'epochDuration' | 'proofSubmissionWindow'>,
|
|
89
|
+
) {
|
|
90
|
+
// See l1-contracts/src/core/libraries/rollup/EpochProofLib.sol:
|
|
91
|
+
// Slot deadline = startEpoch.toSlots() + Slot.wrap(rollupStore.config.proofSubmissionWindow);
|
|
92
|
+
const [startSlot] = getSlotRangeForEpoch(epochNumber, constants);
|
|
93
|
+
const deadlineSlot = startSlot + BigInt(constants.proofSubmissionWindow);
|
|
94
|
+
const deadlineTimestamp = getTimestampForSlot(deadlineSlot, constants);
|
|
95
|
+
return deadlineTimestamp;
|
|
96
|
+
}
|
|
@@ -2,7 +2,6 @@ import type { ApiSchemaFor } from '@aztec/foundation/schemas';
|
|
|
2
2
|
|
|
3
3
|
import { z } from 'zod';
|
|
4
4
|
|
|
5
|
-
import { inBlockSchemaFor } from '../block/in_block.js';
|
|
6
5
|
import { L2Block } from '../block/l2_block.js';
|
|
7
6
|
import { type L2BlockSource, L2TipsSchema } from '../block/l2_block_source.js';
|
|
8
7
|
import { PublishedL2BlockSchema } from '../block/published_l2_block.js';
|
|
@@ -18,7 +17,7 @@ import { TxScopedL2Log } from '../logs/tx_scoped_l2_log.js';
|
|
|
18
17
|
import type { L1ToL2MessageSource } from '../messaging/l1_to_l2_message_source.js';
|
|
19
18
|
import { optional, schemas } from '../schemas/schemas.js';
|
|
20
19
|
import { BlockHeader } from '../tx/block_header.js';
|
|
21
|
-
import {
|
|
20
|
+
import { indexedTxSchema } from '../tx/indexed_tx_effect.js';
|
|
22
21
|
import { TxHash } from '../tx/tx_hash.js';
|
|
23
22
|
import { TxReceipt } from '../tx/tx_receipt.js';
|
|
24
23
|
import { GetContractClassLogsResponseSchema, GetPublicLogsResponseSchema } from './get_logs_response.js';
|
|
@@ -47,7 +46,7 @@ export const ArchiverApiSchema: ApiSchemaFor<ArchiverApi> = {
|
|
|
47
46
|
.function()
|
|
48
47
|
.args(schemas.Integer, schemas.Integer, optional(z.boolean()))
|
|
49
48
|
.returns(z.array(PublishedL2BlockSchema)),
|
|
50
|
-
getTxEffect: z.function().args(TxHash.schema).returns(
|
|
49
|
+
getTxEffect: z.function().args(TxHash.schema).returns(indexedTxSchema().optional()),
|
|
51
50
|
getSettledTxReceipt: z.function().args(TxHash.schema).returns(TxReceipt.schema.optional()),
|
|
52
51
|
getL2SlotNumber: z.function().args().returns(schemas.BigInt),
|
|
53
52
|
getL2EpochNumber: z.function().args().returns(schemas.BigInt),
|
|
@@ -74,4 +73,5 @@ export const ArchiverApiSchema: ApiSchemaFor<ArchiverApi> = {
|
|
|
74
73
|
getL1ToL2MessageIndex: z.function().args(schemas.Fr).returns(schemas.BigInt.optional()),
|
|
75
74
|
getDebugFunctionName: z.function().args(schemas.AztecAddress, schemas.FunctionSelector).returns(optional(z.string())),
|
|
76
75
|
getL1Constants: z.function().args().returns(L1RollupConstantsSchema),
|
|
76
|
+
syncImmediate: z.function().args().returns(z.void()),
|
|
77
77
|
};
|
|
@@ -38,14 +38,15 @@ import { NullifierMembershipWitness } from '../trees/nullifier_membership_witnes
|
|
|
38
38
|
import { PublicDataWitness } from '../trees/public_data_witness.js';
|
|
39
39
|
import {
|
|
40
40
|
BlockHeader,
|
|
41
|
+
type IndexedTxEffect,
|
|
41
42
|
PublicSimulationOutput,
|
|
42
43
|
Tx,
|
|
43
44
|
TxHash,
|
|
44
45
|
TxReceipt,
|
|
45
46
|
type TxValidationResult,
|
|
46
47
|
TxValidationResultSchema,
|
|
48
|
+
indexedTxSchema,
|
|
47
49
|
} from '../tx/index.js';
|
|
48
|
-
import { TxEffect } from '../tx/tx_effect.js';
|
|
49
50
|
import { ValidatorsStatsSchema } from '../validators/schemas.js';
|
|
50
51
|
import type { ValidatorsStats } from '../validators/types.js';
|
|
51
52
|
import { type ComponentsVersions, getVersioningResponseHandler } from '../versioning/index.js';
|
|
@@ -330,11 +331,11 @@ export interface AztecNode
|
|
|
330
331
|
getTxReceipt(txHash: TxHash): Promise<TxReceipt>;
|
|
331
332
|
|
|
332
333
|
/**
|
|
333
|
-
*
|
|
334
|
-
* @param txHash - The hash of
|
|
335
|
-
* @returns The requested tx effect.
|
|
334
|
+
* Gets a tx effect.
|
|
335
|
+
* @param txHash - The hash of the tx corresponding to the tx effect.
|
|
336
|
+
* @returns The requested tx effect with block info (or undefined if not found).
|
|
336
337
|
*/
|
|
337
|
-
getTxEffect(txHash: TxHash): Promise<
|
|
338
|
+
getTxEffect(txHash: TxHash): Promise<IndexedTxEffect | undefined>;
|
|
338
339
|
|
|
339
340
|
/**
|
|
340
341
|
* Method to retrieve pending txs.
|
|
@@ -516,7 +517,7 @@ export const AztecNodeApiSchema: ApiSchemaFor<AztecNode> = {
|
|
|
516
517
|
|
|
517
518
|
getTxReceipt: z.function().args(TxHash.schema).returns(TxReceipt.schema),
|
|
518
519
|
|
|
519
|
-
getTxEffect: z.function().args(TxHash.schema).returns(
|
|
520
|
+
getTxEffect: z.function().args(TxHash.schema).returns(indexedTxSchema().optional()),
|
|
520
521
|
|
|
521
522
|
getPendingTxs: z.function().returns(z.array(Tx.schema)),
|
|
522
523
|
|
package/src/interfaces/pxe.ts
CHANGED
|
@@ -10,7 +10,6 @@ import type { AbiDecoded } from '../abi/decoder.js';
|
|
|
10
10
|
import type { EventSelector } from '../abi/event_selector.js';
|
|
11
11
|
import { AuthWitness } from '../auth_witness/auth_witness.js';
|
|
12
12
|
import type { AztecAddress } from '../aztec-address/index.js';
|
|
13
|
-
import { type InBlock, inBlockSchemaFor } from '../block/in_block.js';
|
|
14
13
|
import { L2Block } from '../block/l2_block.js';
|
|
15
14
|
import {
|
|
16
15
|
CompleteAddress,
|
|
@@ -29,10 +28,18 @@ import { type LogFilter, LogFilterSchema } from '../logs/log_filter.js';
|
|
|
29
28
|
import { UniqueNote } from '../note/extended_note.js';
|
|
30
29
|
import { type NotesFilter, NotesFilterSchema } from '../note/notes_filter.js';
|
|
31
30
|
import { AbiDecodedSchema, optional, schemas } from '../schemas/schemas.js';
|
|
32
|
-
import {
|
|
31
|
+
import {
|
|
32
|
+
type IndexedTxEffect,
|
|
33
|
+
PrivateExecutionResult,
|
|
34
|
+
Tx,
|
|
35
|
+
TxExecutionRequest,
|
|
36
|
+
TxHash,
|
|
37
|
+
TxReceipt,
|
|
38
|
+
TxSimulationResult,
|
|
39
|
+
indexedTxSchema,
|
|
40
|
+
} from '../tx/index.js';
|
|
33
41
|
import { TxProfileResult } from '../tx/profiled_tx.js';
|
|
34
42
|
import { TxProvingResult } from '../tx/proven_tx.js';
|
|
35
|
-
import { TxEffect } from '../tx/tx_effect.js';
|
|
36
43
|
import {
|
|
37
44
|
type GetContractClassLogsResponse,
|
|
38
45
|
GetContractClassLogsResponseSchema,
|
|
@@ -202,11 +209,11 @@ export interface PXE {
|
|
|
202
209
|
getTxReceipt(txHash: TxHash): Promise<TxReceipt>;
|
|
203
210
|
|
|
204
211
|
/**
|
|
205
|
-
*
|
|
206
|
-
* @param txHash - The hash of
|
|
207
|
-
* @returns The requested tx effect.
|
|
212
|
+
* Gets a tx effect.
|
|
213
|
+
* @param txHash - The hash of the tx corresponding to the tx effect.
|
|
214
|
+
* @returns The requested tx effect with block info (or undefined if not found).
|
|
208
215
|
*/
|
|
209
|
-
getTxEffect(txHash: TxHash): Promise<
|
|
216
|
+
getTxEffect(txHash: TxHash): Promise<IndexedTxEffect | undefined>;
|
|
210
217
|
|
|
211
218
|
/**
|
|
212
219
|
* Gets the storage value at the given contract storage slot.
|
|
@@ -470,10 +477,7 @@ export const PXESchema: ApiSchemaFor<PXE> = {
|
|
|
470
477
|
.returns(TxSimulationResult.schema),
|
|
471
478
|
sendTx: z.function().args(Tx.schema).returns(TxHash.schema),
|
|
472
479
|
getTxReceipt: z.function().args(TxHash.schema).returns(TxReceipt.schema),
|
|
473
|
-
getTxEffect: z
|
|
474
|
-
.function()
|
|
475
|
-
.args(TxHash.schema)
|
|
476
|
-
.returns(z.union([inBlockSchemaFor(TxEffect.schema), z.undefined()])),
|
|
480
|
+
getTxEffect: z.function().args(TxHash.schema).returns(indexedTxSchema().optional()),
|
|
477
481
|
getPublicStorageAt: z.function().args(schemas.AztecAddress, schemas.Fr).returns(schemas.Fr),
|
|
478
482
|
getNotes: z.function().args(NotesFilterSchema).returns(z.array(UniqueNote.schema)),
|
|
479
483
|
getL1ToL2MembershipWitness: z
|
|
@@ -68,10 +68,11 @@ export interface WorldStateSynchronizer extends ForkMerkleTreeOperations {
|
|
|
68
68
|
|
|
69
69
|
/**
|
|
70
70
|
* Forces an immediate sync to an optionally provided minimum block number
|
|
71
|
-
* @param
|
|
71
|
+
* @param targetBlockNumber - The target block number that we must sync to. Will download unproven blocks if needed to reach it.
|
|
72
|
+
* @param skipThrowIfTargetNotReached - Whether to skip throwing if the target block number is not reached.
|
|
72
73
|
* @returns A promise that resolves with the block number the world state was synced to
|
|
73
74
|
*/
|
|
74
|
-
syncImmediate(minBlockNumber?: number): Promise<number>;
|
|
75
|
+
syncImmediate(minBlockNumber?: number, skipThrowIfTargetNotReached?: boolean): Promise<number>;
|
|
75
76
|
|
|
76
77
|
/** Returns an instance of MerkleTreeAdminOperations that will not include uncommitted data. */
|
|
77
78
|
getCommitted(): MerkleTreeReadOperations;
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { MAX_NOTE_HASHES_PER_TX, PUBLIC_LOG_DATA_SIZE_IN_FIELDS } from '@aztec/constants';
|
|
2
2
|
import { Fr } from '@aztec/foundation/fields';
|
|
3
|
+
import { TxHash } from '@aztec/stdlib/tx';
|
|
3
4
|
|
|
4
5
|
// TypeScript representation of the Noir aztec::oracle::message_discovery::LogWithTxData struct. This is used as a
|
|
5
6
|
// response for PXE's custom getLogByTag oracle.
|
|
6
7
|
export class LogWithTxData {
|
|
7
8
|
constructor(
|
|
8
9
|
public logContent: Fr[],
|
|
9
|
-
public txHash:
|
|
10
|
+
public txHash: TxHash,
|
|
10
11
|
public uniqueNoteHashesInTx: Fr[],
|
|
11
12
|
public firstNullifierInTx: Fr,
|
|
12
13
|
) {}
|
|
@@ -14,14 +15,14 @@ export class LogWithTxData {
|
|
|
14
15
|
toNoirSerialization(): (Fr | Fr[])[] {
|
|
15
16
|
return [
|
|
16
17
|
...toBoundedVecSerialization(this.logContent, PUBLIC_LOG_DATA_SIZE_IN_FIELDS),
|
|
17
|
-
this.txHash,
|
|
18
|
+
this.txHash.hash,
|
|
18
19
|
...toBoundedVecSerialization(this.uniqueNoteHashesInTx, MAX_NOTE_HASHES_PER_TX),
|
|
19
20
|
this.firstNullifierInTx,
|
|
20
21
|
];
|
|
21
22
|
}
|
|
22
23
|
|
|
23
24
|
static noirSerializationOfEmpty(): (Fr | Fr[])[] {
|
|
24
|
-
return new LogWithTxData([],
|
|
25
|
+
return new LogWithTxData([], TxHash.zero(), [], new Fr(0)).toNoirSerialization();
|
|
25
26
|
}
|
|
26
27
|
}
|
|
27
28
|
|