Skip to content

Commit 2f77221

Browse files
committed
fix: prevent building on orphan proposed blocks
1 parent 60b028c commit 2f77221

13 files changed

Lines changed: 396 additions & 6 deletions

File tree

yarn-project/archiver/src/archiver-misc.test.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,12 @@ describe('Archiver misc', () => {
7878
slashingProposerAddress: EthAddress.random(),
7979
},
8080
archiverStore,
81-
{ pollingIntervalMs: 1000, batchSize: 1000, maxAllowedEthClientDriftSeconds: 300 },
81+
{
82+
pollingIntervalMs: 1000,
83+
batchSize: 1000,
84+
maxAllowedEthClientDriftSeconds: 300,
85+
orphanProposedBlockPruneGraceSeconds: 2,
86+
},
8287
blobClient,
8388
instrumentation,
8489
l1Constants,

yarn-project/archiver/src/archiver-store.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ describe('Archiver Store', () => {
9898
batchSize: 1000,
9999
maxAllowedEthClientDriftSeconds: 300,
100100
ethereumAllowNoDebugHosts: true,
101+
orphanProposedBlockPruneGraceSeconds: 2,
101102
};
102103

103104
const events = new EventEmitter() as ArchiverEmitter;

yarn-project/archiver/src/archiver-sync.test.ts

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,9 @@ describe('Archiver Sync', () => {
9292
// Create epoch cache mock (separate from fake)
9393
epochCache = mock<EpochCache>();
9494
epochCache.getCommitteeForEpoch.mockResolvedValue({ committee: [] as EthAddress[] } as EpochCommitteeInfo);
95+
// Default to no pipelining offset; the orphan-prune tests below override this. Keeps the prune
96+
// deadline well ahead of wall-clock time for the other tests so it never fires spuriously.
97+
epochCache.pipeliningOffset.mockReturnValue(0);
9598

9699
// Create instrumentation mock
97100
const tracer = getTelemetryClient().getTracer('');
@@ -118,6 +121,7 @@ describe('Archiver Sync', () => {
118121
maxAllowedEthClientDriftSeconds: 300,
119122
ethereumAllowNoDebugHosts: true,
120123
skipHistoricalLogsCheck: true,
124+
orphanProposedBlockPruneGraceSeconds: 2,
121125
};
122126

123127
// Create event emitter shared by archiver and synchronizer
@@ -2143,4 +2147,124 @@ describe('Archiver Sync', () => {
21432147
expect(tips.proposedCheckpoint.block.number).toEqual(tips.checkpointed.block.number);
21442148
}, 15_000);
21452149
});
2150+
2151+
describe('pruning orphan proposed blocks', () => {
2152+
let pruneSpy: jest.Mock;
2153+
2154+
// Slot the orphan block targets. With slotDuration=24, slot S starts at l1GenesisTime + S*24.
2155+
const orphanSlot = SlotNumber(1);
2156+
// Grace period configured for these tests (see the `config` object above).
2157+
const graceSeconds = 2;
2158+
2159+
beforeEach(() => {
2160+
pruneSpy = jest.fn();
2161+
archiver.events.on(L2BlockSourceEvents.L2PruneUncheckpointed, pruneSpy);
2162+
// Normal proposer pipelining: a block targeting slot S is built during slot S-1, so its proposed
2163+
// checkpoint is expected by the start of slot S.
2164+
epochCache.pipeliningOffset.mockReturnValue(1);
2165+
});
2166+
2167+
afterEach(() => {
2168+
archiver.events.off(L2BlockSourceEvents.L2PruneUncheckpointed, pruneSpy);
2169+
});
2170+
2171+
// Wall-clock time (seconds) at which the orphan tip becomes prunable: start(orphanSlot) + grace.
2172+
const pruneDeadline = () => now + Number(orphanSlot) * l1Constants.slotDuration + graceSeconds;
2173+
2174+
// Syncs checkpoint 1 (slot 0), then writes uncheckpointed blocks for slot 1 (checkpoint 2) straight
2175+
// into the store as a block-only tip with no matching proposed checkpoint. L1 is held at slot 1 so
2176+
// the L1-sync prune (which only fires once the build slot has ended on L1) stays out of the way.
2177+
const setupOrphanTip = async () => {
2178+
const { checkpoint: cp1 } = await fake.addCheckpoint(CheckpointNumber(1), {
2179+
l1BlockNumber: 1n,
2180+
messagesL1BlockNumber: 1n,
2181+
numL1ToL2Messages: 3,
2182+
slotNumber: SlotNumber(0),
2183+
});
2184+
const cp1Archive = cp1.blocks.at(-1)!.archive;
2185+
fake.setL1BlockNumber(1n);
2186+
await archiver.syncImmediate();
2187+
expect(await archiver.getCheckpointNumber()).toEqual(CheckpointNumber(1));
2188+
2189+
const lastBlockInCp1 = cp1.blocks.at(-1)!.number;
2190+
const provisionalBlocks = await fake.makeBlocks(CheckpointNumber(2), {
2191+
l1BlockNumber: 2n,
2192+
previousArchive: cp1Archive,
2193+
slotNumber: orphanSlot,
2194+
});
2195+
for (const block of provisionalBlocks) {
2196+
await archiverStore.blocks.addProposedBlock(block, { force: true });
2197+
}
2198+
2199+
// Hold L1 at slot 1 so the slot has not ended from L1's perspective.
2200+
fake.setL1BlockNumber(2n);
2201+
return { lastBlockInCp1, lastProvisional: provisionalBlocks.at(-1)!.number, provisionalBlocks };
2202+
};
2203+
2204+
const makeProposedCheckpoint = (lastBlockInCp1: BlockNumber, blockCount: number): ProposedCheckpointInput => ({
2205+
checkpointNumber: CheckpointNumber(2),
2206+
header: CheckpointHeader.empty({ slotNumber: orphanSlot }),
2207+
startBlock: BlockNumber(lastBlockInCp1 + 1),
2208+
blockCount,
2209+
totalManaUsed: 0n,
2210+
feeAssetPriceModifier: 0n,
2211+
});
2212+
2213+
it('does not prune before the grace window elapses', async () => {
2214+
const { lastProvisional } = await setupOrphanTip();
2215+
2216+
dateProvider.setTime((pruneDeadline() - 1) * 1000);
2217+
await archiver.syncImmediate();
2218+
2219+
expect(pruneSpy).not.toHaveBeenCalled();
2220+
expect(await archiver.getBlockNumber()).toEqual(lastProvisional);
2221+
}, 15_000);
2222+
2223+
it('prunes the orphan tip once the grace window elapses', async () => {
2224+
const { lastBlockInCp1, provisionalBlocks } = await setupOrphanTip();
2225+
2226+
dateProvider.setTime((pruneDeadline() + 1) * 1000);
2227+
await archiver.syncImmediate();
2228+
2229+
expect(pruneSpy).toHaveBeenCalledWith(
2230+
expect.objectContaining({
2231+
type: L2BlockSourceEvents.L2PruneUncheckpointed,
2232+
slotNumber: orphanSlot,
2233+
blocks: expect.arrayContaining(provisionalBlocks.map(b => expect.objectContaining({ number: b.number }))),
2234+
}),
2235+
);
2236+
expect(await archiver.getBlockNumber()).toEqual(lastBlockInCp1);
2237+
expect(await archiver.getCheckpointNumber()).toEqual(CheckpointNumber(1));
2238+
}, 15_000);
2239+
2240+
it('does not prune when a matching proposed checkpoint exists', async () => {
2241+
const { lastBlockInCp1, lastProvisional, provisionalBlocks } = await setupOrphanTip();
2242+
2243+
await archiver.addProposedCheckpoint(makeProposedCheckpoint(lastBlockInCp1, provisionalBlocks.length));
2244+
2245+
dateProvider.setTime((pruneDeadline() + 100) * 1000);
2246+
await archiver.syncImmediate();
2247+
2248+
expect(pruneSpy).not.toHaveBeenCalled();
2249+
expect(await archiver.getBlockNumber()).toEqual(lastProvisional);
2250+
expect(await archiverStore.blocks.getLastProposedCheckpoint()).toBeDefined();
2251+
}, 15_000);
2252+
2253+
it('processes a queued proposed checkpoint before pruning, sparing the tip', async () => {
2254+
const { lastBlockInCp1, lastProvisional, provisionalBlocks } = await setupOrphanTip();
2255+
2256+
// Past the grace window: without the matching checkpoint the next sync would prune the tip.
2257+
dateProvider.setTime((pruneDeadline() + 100) * 1000);
2258+
2259+
// Queue the proposed checkpoint. The triggered sync drains the inbound queue (storing the
2260+
// checkpoint) before running the orphan prune, so the prune sees it and stands down. If the
2261+
// order were reversed, this sync would prune the tip before storing the checkpoint.
2262+
await archiver.addProposedCheckpoint(makeProposedCheckpoint(lastBlockInCp1, provisionalBlocks.length));
2263+
await archiver.syncImmediate();
2264+
2265+
expect(pruneSpy).not.toHaveBeenCalled();
2266+
expect(await archiver.getBlockNumber()).toEqual(lastProvisional);
2267+
expect(await archiverStore.blocks.getLastProposedCheckpoint()).toBeDefined();
2268+
}, 15_000);
2269+
});
21462270
});

yarn-project/archiver/src/archiver.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,7 @@ export class Archiver extends ArchiverDataSourceBase implements L2BlockSink, Tra
133133
maxAllowedEthClientDriftSeconds: number;
134134
ethereumAllowNoDebugHosts?: boolean;
135135
skipHistoricalLogsCheck?: boolean;
136+
orphanProposedBlockPruneGraceSeconds: number;
136137
},
137138
private readonly blobClient: BlobClientInterface,
138139
instrumentation: ArchiverInstrumentation,
@@ -336,6 +337,10 @@ export class Archiver extends ArchiverDataSourceBase implements L2BlockSink, Tra
336337
private async sync() {
337338
// Process any queued blocks first, before doing L1 sync
338339
await this.processInboundQueue();
340+
// Prune orphan proposed blocks (block-only tips with no matching proposed checkpoint) on wall-clock
341+
// time. Runs after the queue is drained so freshly-arrived proposed checkpoints are seen first, and
342+
// before L1 sync so it fires even when L1 has not advanced.
343+
await this.synchronizer.pruneOrphanProposedBlocks();
339344
// Now perform L1 sync
340345
await this.syncFromL1();
341346
}

yarn-project/archiver/src/config.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,14 @@ export const archiverConfigMappings: ConfigMappingsType<ArchiverConfig> = {
7878
'Set to true to bypass the check when the connected RPC node is known to prune old logs.',
7979
...booleanConfigHelper(false),
8080
},
81+
orphanProposedBlockPruneGraceSeconds: {
82+
env: 'ARCHIVER_ORPHAN_PROPOSED_BLOCK_PRUNE_GRACE_SECONDS',
83+
description:
84+
'Grace period in seconds, measured from the end of a proposed block build slot, after which a ' +
85+
'proposed block with no matching proposed checkpoint is pruned as an orphan. Defaults from the ' +
86+
'sequencer block duration at the node wiring layer when unset.',
87+
...optionalNumberConfigHelper(),
88+
},
8189
...chainConfigMappings,
8290
...l1ReaderConfigMappings,
8391
viemPollingIntervalMS: {
@@ -107,5 +115,6 @@ export function mapArchiverConfig(config: Partial<ArchiverConfig>) {
107115
maxAllowedEthClientDriftSeconds: config.maxAllowedEthClientDriftSeconds,
108116
ethereumAllowNoDebugHosts: config.ethereumAllowNoDebugHosts,
109117
skipHistoricalLogsCheck: config.archiverSkipHistoricalLogsCheck,
118+
orphanProposedBlockPruneGraceSeconds: config.orphanProposedBlockPruneGraceSeconds,
110119
};
111120
}

yarn-project/archiver/src/factory.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { FunctionType, decodeFunctionSignature } from '@aztec/stdlib/abi';
1616
import type { ArchiverEmitter, BlockHash } from '@aztec/stdlib/block';
1717
import { type ContractClassPublicWithCommitment, computePublicBytecodeCommitment } from '@aztec/stdlib/contract';
1818
import type { DataStoreConfig } from '@aztec/stdlib/kv-store';
19+
import { MIN_EXECUTION_TIME } from '@aztec/stdlib/timetable';
1920
import type { BlockHeader } from '@aztec/stdlib/tx';
2021
import { getTelemetryClient } from '@aztec/telemetry-client';
2122

@@ -129,6 +130,7 @@ export async function createArchiver(
129130
maxAllowedEthClientDriftSeconds: 300,
130131
ethereumAllowNoDebugHosts: false,
131132
skipHistoricalLogsCheck: false,
133+
orphanProposedBlockPruneGraceSeconds: MIN_EXECUTION_TIME,
132134
},
133135
mapArchiverConfig(config),
134136
);

yarn-project/archiver/src/modules/l1_synchronizer.ts

Lines changed: 88 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { getFinalizedL1Block } from '@aztec/ethereum/queries';
66
import type { ViemPublicClient, ViemPublicDebugClient } from '@aztec/ethereum/types';
77
import { asyncPool } from '@aztec/foundation/async-pool';
88
import { maxBigint } from '@aztec/foundation/bigint';
9-
import { BlockNumber, CheckpointNumber, EpochNumber } from '@aztec/foundation/branded-types';
9+
import { BlockNumber, CheckpointNumber, EpochNumber, SlotNumber } from '@aztec/foundation/branded-types';
1010
import { Buffer16, Buffer32 } from '@aztec/foundation/buffer';
1111
import { compactArray, partition, pick } from '@aztec/foundation/collection';
1212
import { Fr } from '@aztec/foundation/curves/bn254';
@@ -18,7 +18,12 @@ import { DateProvider, Timer, elapsed } from '@aztec/foundation/timer';
1818
import { isDefined, isErrorClass } from '@aztec/foundation/types';
1919
import { type ArchiverEmitter, L2BlockSourceEvents, type ValidateCheckpointResult } from '@aztec/stdlib/block';
2020
import { Checkpoint, type CheckpointData, PublishedCheckpoint } from '@aztec/stdlib/checkpoint';
21-
import { type L1RollupConstants, getEpochAtSlot, getSlotAtNextL1Block } from '@aztec/stdlib/epoch-helpers';
21+
import {
22+
type L1RollupConstants,
23+
getEpochAtSlot,
24+
getSlotAtNextL1Block,
25+
getTimestampForSlot,
26+
} from '@aztec/stdlib/epoch-helpers';
2227
import { computeInHashFromL1ToL2Messages } from '@aztec/stdlib/messaging';
2328
import type { CoordinationSignatureContext } from '@aztec/stdlib/p2p';
2429
import { type Traceable, type Tracer, execInSpan, trackSpan } from '@aztec/telemetry-client';
@@ -76,6 +81,7 @@ export class ArchiverL1Synchronizer implements Traceable {
7681
skipValidateCheckpointAttestations?: boolean;
7782
skipPromoteProposedCheckpointDuringL1Sync?: boolean;
7883
maxAllowedEthClientDriftSeconds: number;
84+
orphanProposedBlockPruneGraceSeconds: number;
7985
},
8086
private readonly blobClient: BlobClientInterface,
8187
private readonly epochCache: EpochCache,
@@ -102,6 +108,7 @@ export class ArchiverL1Synchronizer implements Traceable {
102108
skipValidateCheckpointAttestations?: boolean;
103109
skipPromoteProposedCheckpointDuringL1Sync?: boolean;
104110
maxAllowedEthClientDriftSeconds: number;
111+
orphanProposedBlockPruneGraceSeconds: number;
105112
}) {
106113
this.config = newConfig;
107114
}
@@ -294,12 +301,89 @@ export class ArchiverL1Synchronizer implements Traceable {
294301
{ firstUncheckpointedBlockHeader: firstUncheckpointedBlockData?.header.toInspect(), slotAtNextL1Block },
295302
);
296303

297-
const prunedBlocks = await this.updater.removeUncheckpointedBlocksAfter(lastCheckpointedBlockNumber);
304+
await this.removeUncheckpointedBlocksAndEmit(lastCheckpointedBlockNumber, firstUncheckpointedBlockSlot);
305+
}
306+
307+
/**
308+
* Prunes a block-only local tip that was built atop a checkpoint that was never itself proposed.
309+
*
310+
* Under pipelining, a proposer publishes the blocks for a checkpoint (block-only proposals) before
311+
* assembling and publishing the enclosing proposed checkpoint at the end of the build slot. A node
312+
* that received those blocks but never the proposed checkpoint is left with an orphan tip it must
313+
* not build on. We prune it once enough wall-clock time has elapsed that the proposed checkpoint
314+
* should have arrived. This runs on wall-clock time (not L1 block advancement) so it fires during
315+
* quiet L1 periods, and is the liveness counterpart to the sequencer's checkSync guard.
316+
*
317+
* Only the first uncheckpointed block is inspected: if its checkpoint is backed by a proposed
318+
* checkpoint, the tip is legitimate and left for promotion (or for the L1-sync prune to clear if it
319+
* later goes stale); if not, every uncheckpointed block chains off the orphan and is pruned.
320+
*/
321+
public async pruneOrphanProposedBlocks(): Promise<void> {
322+
const [lastCheckpointedBlockNumber, lastProposedBlockNumber] = await Promise.all([
323+
this.stores.blocks.getCheckpointedL2BlockNumber(),
324+
this.stores.blocks.getLatestL2BlockNumber(),
325+
]);
326+
327+
// If there are no uncheckpointed blocks, we got nothing to do
328+
if (lastProposedBlockNumber === lastCheckpointedBlockNumber) {
329+
return;
330+
}
298331

332+
const firstUncheckpointedBlockNumber = BlockNumber(lastCheckpointedBlockNumber + 1);
333+
const firstUncheckpointedBlockData = await this.stores.blocks.getBlockData({
334+
number: firstUncheckpointedBlockNumber,
335+
});
336+
if (firstUncheckpointedBlockData === undefined) {
337+
return;
338+
}
339+
340+
const blockCheckpointNumber = firstUncheckpointedBlockData.checkpointNumber;
341+
const blockSlot = firstUncheckpointedBlockData.header.getSlot();
342+
343+
// A proposed checkpoint covering this block's checkpoint means the tip is not an orphan.
344+
const proposedCheckpoint = await this.stores.blocks.getProposedCheckpointByNumber(blockCheckpointNumber);
345+
if (proposedCheckpoint !== undefined) {
346+
return;
347+
}
348+
349+
// The proposed checkpoint should have landed by the start of the slot after the block's build slot
350+
// (build slot = blockSlot - pipeliningOffset). Wait a grace period beyond that to tolerate propagation.
351+
const pipeliningOffset = this.epochCache.pipeliningOffset();
352+
const deadlineSlot = SlotNumber(Number(blockSlot) - pipeliningOffset + 1);
353+
const pruneAfter =
354+
getTimestampForSlot(deadlineSlot, this.l1Constants) + BigInt(this.config.orphanProposedBlockPruneGraceSeconds);
355+
const now = BigInt(this.dateProvider.nowInSeconds());
356+
if (now < pruneAfter) {
357+
return;
358+
}
359+
360+
this.log.warn(
361+
`Pruning orphan blocks after block ${lastCheckpointedBlockNumber}: block at slot ${blockSlot} belongs to ` +
362+
`checkpoint ${blockCheckpointNumber} which has no matching proposed checkpoint`,
363+
{
364+
firstUncheckpointedBlockHeader: firstUncheckpointedBlockData.header.toInspect(),
365+
blockCheckpointNumber,
366+
blockSlot,
367+
pipeliningOffset,
368+
deadlineSlot,
369+
pruneAfter,
370+
now,
371+
},
372+
);
373+
374+
await this.removeUncheckpointedBlocksAndEmit(lastCheckpointedBlockNumber, blockSlot);
375+
}
376+
377+
/** Removes uncheckpointed blocks after the checkpointed tip and emits a prune event for any removed. */
378+
private async removeUncheckpointedBlocksAndEmit(
379+
lastCheckpointedBlockNumber: BlockNumber,
380+
slotNumber: SlotNumber,
381+
): Promise<void> {
382+
const prunedBlocks = await this.updater.removeUncheckpointedBlocksAfter(lastCheckpointedBlockNumber);
299383
if (prunedBlocks.length > 0) {
300384
this.events.emit(L2BlockSourceEvents.L2PruneUncheckpointed, {
301385
type: L2BlockSourceEvents.L2PruneUncheckpointed,
302-
slotNumber: firstUncheckpointedBlockSlot,
386+
slotNumber,
303387
blocks: prunedBlocks,
304388
});
305389
}

yarn-project/archiver/src/test/noop_l1_archiver.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,9 @@ class NoopL1Synchronizer implements FunctionsOf<ArchiverL1Synchronizer> {
4141
syncFromL1(_initialSyncComplete: boolean): Promise<void> {
4242
return Promise.resolve();
4343
}
44+
pruneOrphanProposedBlocks(): Promise<void> {
45+
return Promise.resolve();
46+
}
4447
}
4548

4649
/**
@@ -89,6 +92,7 @@ export class NoopL1Archiver extends Archiver {
8992
maxAllowedEthClientDriftSeconds: 300,
9093
ethereumAllowNoDebugHosts: true, // Skip trace validation
9194
skipHistoricalLogsCheck: true, // Skip historical logs validation
95+
orphanProposedBlockPruneGraceSeconds: 2,
9296
},
9397
blobClient,
9498
instrumentation,

yarn-project/aztec-node/src/aztec-node/server.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,7 @@ import type { DebugLogStore, LogFilter, SiloedTag, Tag, TxScopedL2Log } from '@a
112112
import { InMemoryDebugLogStore, NullDebugLogStore } from '@aztec/stdlib/logs';
113113
import { InboxLeaf, type L1ToL2MessageSource, appendL1ToL2MessagesToTree } from '@aztec/stdlib/messaging';
114114
import type { Offense } from '@aztec/stdlib/slashing';
115+
import { MIN_EXECUTION_TIME } from '@aztec/stdlib/timetable';
115116
import type { NullifierLeafPreimage, PublicDataTreeLeafPreimage } from '@aztec/stdlib/trees';
116117
import { MerkleTreeId, NullifierMembershipWitness, PublicDataWitness } from '@aztec/stdlib/trees';
117118
import {
@@ -577,6 +578,11 @@ export class AztecNodeService implements AztecNode, AztecNodeAdmin, AztecNodeDeb
577578
// Track started resources so we can clean up on partial failure during node creation.
578579
const started: { stop?(): Promise<void> | void }[] = [];
579580
try {
581+
// Default the orphan-prune grace window from the block build duration when unset, so the archiver
582+
// waits roughly one build slot for a proposed checkpoint to arrive before pruning a block-only tip.
583+
config.orphanProposedBlockPruneGraceSeconds ??=
584+
config.blockDurationMs !== undefined ? Math.ceil(config.blockDurationMs / 1000) : MIN_EXECUTION_TIME;
585+
580586
// Create world-state first so we can retrieve the initial header before constructing the archiver.
581587
const nativeWs = await createWorldState(config, options.genesis);
582588
const initialHeader = nativeWs.getInitialHeader();

yarn-project/foundation/src/config/env_var.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export type EnvVar =
1111
| 'ARCHIVER_URL'
1212
| 'ARCHIVER_VIEM_POLLING_INTERVAL_MS'
1313
| 'ARCHIVER_BATCH_SIZE'
14+
| 'ARCHIVER_ORPHAN_PROPOSED_BLOCK_PRUNE_GRACE_SECONDS'
1415
| 'AZTEC_ADMIN_PORT'
1516
| 'AZTEC_NODE_DEBUG'
1617
| 'AZTEC_ADMIN_API_KEY_HASH'

0 commit comments

Comments
 (0)