Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions yarn-project/archiver/src/l1/calldata_retriever.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -505,6 +505,7 @@ describe('CalldataRetriever', () => {
archiveRoot: Fr.random(),
header: CheckpointHeader.random(),
attestations: [],
verbatimAttestations: { signatureIndices: '0x', signaturesOrAddresses: '0x' },
blockHash,
feeAssetPriceModifier: 0n,
};
Expand Down
19 changes: 13 additions & 6 deletions yarn-project/archiver/src/l1/calldata_retriever.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,12 @@ type CheckpointData = {
archiveRoot: Fr;
header: CheckpointHeader;
attestations: CommitteeAttestation[];
/**
* The exact packed `CommitteeAttestations` tuple as it appears in the propose calldata, preserved
* verbatim (never re-derived from {@link attestations}) so invalidation evidence stays byte-faithful to
* the on-chain `attestationsHash`.
*/
verbatimAttestations: ViemCommitteeAttestations;
blockHash: string;
feeAssetPriceModifier: bigint;
};
Expand Down Expand Up @@ -406,14 +412,14 @@ export class CalldataRetriever {
return undefined;
}

const [decodedArgs, packedAttestations] = args! as readonly [
const [decodedArgs, verbatimAttestations] = args! as readonly [
{ archive: Hex; oracleInput: { feeAssetPriceModifier: bigint }; header: ViemHeader },
ViemCommitteeAttestations,
...unknown[],
];

// Verify attestationsHash
const computedAttestationsHash = this.computeAttestationsHash(packedAttestations);
const computedAttestationsHash = this.computeAttestationsHash(verbatimAttestations);
if (
!Buffer.from(hexToBytes(computedAttestationsHash)).equals(
Buffer.from(hexToBytes(expectedHashes.attestationsHash)),
Expand Down Expand Up @@ -441,15 +447,15 @@ export class CalldataRetriever {
return undefined;
}

const attestations = CommitteeAttestation.fromPacked(packedAttestations, this.targetCommitteeSize);
const attestations = CommitteeAttestation.fromPacked(verbatimAttestations, this.targetCommitteeSize);

this.logger.trace(`Validated and decoded propose calldata for checkpoint ${checkpointNumber}`, {
checkpointNumber,
archive: decodedArgs.archive,
header: decodedArgs.header,
l1BlockHash: blockHash,
attestations,
packedAttestations,
verbatimAttestations,
targetCommitteeSize: this.targetCommitteeSize,
});

Expand All @@ -458,6 +464,7 @@ export class CalldataRetriever {
archiveRoot,
header,
attestations,
verbatimAttestations,
blockHash,
feeAssetPriceModifier,
};
Expand All @@ -467,8 +474,8 @@ export class CalldataRetriever {
}

/** Computes the keccak256 hash of ABI-encoded CommitteeAttestations. */
private computeAttestationsHash(packedAttestations: ViemCommitteeAttestations): Hex {
return keccak256(encodeAbiParameters([this.getCommitteeAttestationsStructDef()], [packedAttestations]));
private computeAttestationsHash(verbatimAttestations: ViemCommitteeAttestations): Hex {
return keccak256(encodeAbiParameters([this.getCommitteeAttestationsStructDef()], [verbatimAttestations]));
}

/** Computes the keccak256 payload digest from the checkpoint header, archive root, and fee asset price modifier. */
Expand Down
6 changes: 6 additions & 0 deletions yarn-project/archiver/src/l1/data_retrieval.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import type {
InboxContract,
MessageSentLog,
RollupContract,
ViemCommitteeAttestations,
ViemHeader,
} from '@aztec/ethereum/contracts';
import type { ViemPublicClient, ViemPublicDebugClient } from '@aztec/ethereum/types';
Expand Down Expand Up @@ -56,6 +57,11 @@ export type RetrievedCheckpointFromCalldata = RetrievedCheckpointBase & {
blobHashes: Buffer[];
/** Parent beacon block root from the L1 block, used for blob fetching. */
parentBeaconBlockRoot: string | undefined;
/**
* The exact packed `CommitteeAttestations` tuple from the propose calldata, carried verbatim so that
* attestation validation can attach byte-faithful invalidation evidence to a negative result.
*/
verbatimAttestations: ViemCommitteeAttestations;
};

export async function retrievedToPublishedCheckpoint({
Expand Down
63 changes: 60 additions & 3 deletions yarn-project/archiver/src/modules/validation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,48 @@ import { times } from '@aztec/foundation/collection';
import { Secp256k1Signer, flipSignature } from '@aztec/foundation/crypto/secp256k1-signer';
import { Signature } from '@aztec/foundation/eth-signature';
import { type Logger, createLogger } from '@aztec/foundation/log';
import { CommitteeAttestation, EthAddress } from '@aztec/stdlib/block';
import { Checkpoint } from '@aztec/stdlib/checkpoint';
import {
CommitteeAttestation,
CommitteeAttestationsAndSigners,
EthAddress,
type ValidateCheckpointResult,
} from '@aztec/stdlib/block';
import { Checkpoint, type PublishedCheckpoint } from '@aztec/stdlib/checkpoint';
import type { L1RollupConstants } from '@aztec/stdlib/epoch-helpers';
import { ConsensusPayload, type CoordinationSignatureContext } from '@aztec/stdlib/p2p';
import { TEST_COORDINATION_SIGNATURE_CONTEXT } from '@aztec/stdlib/testing';

import { type MockProxy, mock } from 'jest-mock-extended';
import assert from 'node:assert';

import { makeSignedPublishedCheckpoint } from '../test/mock_structs.js';
import { getAttestationInfoFromPublishedCheckpoint, validateCheckpointAttestations } from './validation.js';
import { getAttestationInfoFromPublishedCheckpoint, validateAttestations } from './validation.js';

/**
* Blob-path wrapper over the shared attestation validator, used only by these tests. Production
* invalidation always originates from the calldata path (validateCheckpointAttestationsFromCalldata);
* this repacks a fully decoded checkpoint's attestations to supply the verbatimAttestations field.
*/
function validateCheckpointAttestations(
publishedCheckpoint: PublishedCheckpoint,
epochCache: EpochCache,
constants: Pick<L1RollupConstants, 'epochDuration'>,
signatureContext: CoordinationSignatureContext,
logger?: Logger,
): Promise<ValidateCheckpointResult> {
const { checkpoint, attestations } = publishedCheckpoint;
const payload = ConsensusPayload.fromCheckpoint(checkpoint, signatureContext);
const verbatimAttestations = CommitteeAttestationsAndSigners.packAttestations(attestations);
return validateAttestations(
payload,
attestations,
verbatimAttestations,
checkpoint.toCheckpointInfo(),
epochCache,
constants,
logger,
);
}

describe('validateCheckpointAttestations', () => {
let epochCache: MockProxy<EpochCache>;
Expand Down Expand Up @@ -232,6 +265,30 @@ describe('validateCheckpointAttestations', () => {
expect(result.invalidIndex).toBe(2);
});

it('fails if an attestation is in yParity (v in {0, 1}) form even though it recovers to the right member', async () => {
const checkpoint = await makeCheckpoint(signers.slice(0, 4), committee);

// Rewrite index 2's canonical signature to its yParity form: same (r, s), recovery byte 0/1. It still
// recovers to the same committee member off-chain, but L1 ECDSA.recover rejects v not in {27, 28}.
const original = checkpoint.attestations[2];
const yParity = new Signature(original.signature.r, original.signature.s, original.signature.v - 27);
checkpoint.attestations[2] = new CommitteeAttestation(original.address, yParity);

const attestations = getAttestationInfoFromPublishedCheckpoint(checkpoint, TEST_COORDINATION_SIGNATURE_CONTEXT);
expect(attestations[2].status).toBe('invalid-signature');

const result = await validateCheckpointAttestations(
checkpoint,
epochCache,
constants,
TEST_COORDINATION_SIGNATURE_CONTEXT,
logger,
);
assert(!result.valid);
assert(result.reason === 'invalid-attestation');
expect(result.invalidIndex).toBe(2);
});

it('reports correct index when invalid attestation follows provided address', async () => {
const checkpoint = await makeCheckpoint(signers.slice(0, 3), committee);

Expand Down
33 changes: 15 additions & 18 deletions yarn-project/archiver/src/modules/validation.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { EpochCache } from '@aztec/epoch-cache';
import type { ViemCommitteeAttestations } from '@aztec/ethereum/contracts';
import { type CheckpointNumber, EpochNumber } from '@aztec/foundation/branded-types';
import { compactArray } from '@aztec/foundation/collection';
import type { Fr } from '@aztec/foundation/curves/bn254';
Expand Down Expand Up @@ -29,29 +30,15 @@ export function getAttestationInfoFromPublishedCheckpoint(
return getAttestationInfoFromPayload(payload, attestations);
}

/**
* Validates the attestations of a checkpoint already retrieved (with its blocks) from blobs.
* Returns true if the attestations are valid and sufficient, false otherwise.
*/
export function validateCheckpointAttestations(
publishedCheckpoint: PublishedCheckpoint,
epochCache: EpochCache,
constants: Pick<L1RollupConstants, 'epochDuration'>,
signatureContext: CoordinationSignatureContext,
logger?: Logger,
): Promise<ValidateCheckpointResult> {
const { checkpoint, attestations } = publishedCheckpoint;
const payload = ConsensusPayload.fromCheckpoint(checkpoint, signatureContext);
return validateAttestations(payload, attestations, checkpoint.toCheckpointInfo(), epochCache, constants, logger);
}

/** The subset of a calldata-only checkpoint needed to validate its committee attestations. */
export type CalldataCheckpointForAttestations = {
checkpointNumber: CheckpointNumber;
archiveRoot: Fr;
feeAssetPriceModifier: bigint;
header: CheckpointHeader;
attestations: CommitteeAttestation[];
/** The exact packed attestations tuple from L1 calldata, carried verbatim for byte-faithful invalidation. */
verbatimAttestations: ViemCommitteeAttestations;
};

/**
Expand Down Expand Up @@ -80,17 +67,26 @@ export function validateCheckpointAttestationsFromCalldata(
checkpointNumber: checkpoint.checkpointNumber,
timestamp: checkpoint.header.timestamp,
};
return validateAttestations(payload, checkpoint.attestations, checkpointInfo, epochCache, constants, logger);
return validateAttestations(
payload,
checkpoint.attestations,
checkpoint.verbatimAttestations,
checkpointInfo,
epochCache,
constants,
logger,
);
}

/**
* Core attestation validation over a consensus payload, its attestations, and checkpoint metadata --
* independent of whether the checkpoint's blocks have been decoded from blobs. Returns true if the
* attestations are valid and sufficient, false otherwise.
*/
async function validateAttestations(
export async function validateAttestations(
payload: ConsensusPayload,
attestations: CommitteeAttestation[],
verbatimAttestations: ViemCommitteeAttestations,
checkpointInfo: CheckpointInfo,
epochCache: EpochCache,
constants: Pick<L1RollupConstants, 'epochDuration'>,
Expand Down Expand Up @@ -137,6 +133,7 @@ async function validateAttestations(
epoch,
attestors,
attestations,
verbatimAttestations,
});

for (let i = 0; i < attestorInfos.length; i++) {
Expand Down
4 changes: 4 additions & 0 deletions yarn-project/archiver/src/store/block_store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1989,6 +1989,7 @@ describe('BlockStore', () => {
seed: 456n,
attestors: [EthAddress.random()],
attestations: [CommitteeAttestation.random()],
verbatimAttestations: { signatureIndices: '0x', signaturesOrAddresses: '0x' },
reason: 'insufficient-attestations',
};

Expand All @@ -2007,6 +2008,7 @@ describe('BlockStore', () => {
epoch: EpochNumber(789),
seed: 101n,
attestations: [CommitteeAttestation.random()],
verbatimAttestations: { signatureIndices: '0x', signaturesOrAddresses: '0x' },
reason: 'invalid-attestation',
invalidIndex: 5,
};
Expand All @@ -2027,6 +2029,7 @@ describe('BlockStore', () => {
seed: 888n,
attestors: [EthAddress.random()],
attestations: [CommitteeAttestation.random()],
verbatimAttestations: { signatureIndices: '0x', signaturesOrAddresses: '0x' },
reason: 'insufficient-attestations',
};

Expand All @@ -2046,6 +2049,7 @@ describe('BlockStore', () => {
seed: 0n,
attestors: [],
attestations: [],
verbatimAttestations: { signatureIndices: '0x', signaturesOrAddresses: '0x' },
reason: 'insufficient-attestations',
};

Expand Down
6 changes: 3 additions & 3 deletions yarn-project/archiver/src/test/fake_l1_state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -682,7 +682,7 @@ export class FakeL1State {
getHashedSignaturePayloadTypedData(attestationsAndSigners),
);

const packedAttestations = attestationsAndSigners.getPackedAttestations();
const verbatimAttestations = attestationsAndSigners.getPackedAttestations();

const rollupInput = encodeFunctionData({
abi: RollupAbi,
Expand All @@ -693,7 +693,7 @@ export class FakeL1State {
archive,
oracleInput: { feeAssetPriceModifier: 0n },
},
packedAttestations,
verbatimAttestations,
attestationsAndSigners.getSigners().map(signer => signer.toString()),
attestationsAndSignersSignature.toViemSignature(),
blobInput,
Expand All @@ -716,7 +716,7 @@ export class FakeL1State {

// Compute attestationsHash (same logic as CalldataRetriever)
const attestationsHash = Buffer32.fromString(
keccak256(encodeAbiParameters([this.getCommitteeAttestationsStructDef()], [packedAttestations])),
keccak256(encodeAbiParameters([this.getCommitteeAttestationsStructDef()], [verbatimAttestations])),
);

// Compute payloadDigest (same logic as CalldataRetriever)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -452,6 +452,7 @@ function makeInvalidStatus(firstInvalid: CheckpointNumber): ValidateCheckpointRe
seed: 0n,
attestors: [],
attestations: [],
verbatimAttestations: { signatureIndices: '0x', signaturesOrAddresses: '0x' },
reason: 'insufficient-attestations',
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -818,6 +818,19 @@ describe('multi-node/invalid-attestations/invalidate_block', () => {
});
});

// Injects every non-proposer attestation slot in yParity (v ∈ {0, 1}) form in the packed L1 tuple. L1
// accepts it at propose() (attestation signatures are not verified there), but the checkpoint can never be
// proven (ValidatorSelectionLib.verifyAttestations → ECDSA.recover rejects v ∉ {27, 28}). A repack of the
// invalidation evidence would canonicalize the bytes back to 27/28 and diverge from the on-chain
// attestationsHash, reverting the invalidation. Verifies an honest proposer detects the bad checkpoint and
// invalidates it byte-faithfully from the raw calldata. Regression for A-1401.
it('proposer invalidates checkpoint with a yParity attestation slot', async () => {
await runInvalidationTest({
attackConfig: { injectYParityAttestation: true },
disableConfig: { injectYParityAttestation: false },
});
});

// Injects shuffled attestation ordering (accepted offchain but rejected by L1 which requires the
// committee order). Waits for the bad checkpoint then verifies a good proposer invalidates it.
// Regression for the node accepting attestations that did not conform to the committee order,
Expand Down
5 changes: 5 additions & 0 deletions yarn-project/sequencer-client/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ export const DefaultSequencerConfig = {
injectFakeAttestation: false,
injectHighSValueAttestation: false,
injectUnrecoverableSignatureAttestation: false,
injectYParityAttestation: false,
fishermanMode: false,
shuffleAttestationOrdering: false,
skipPushProposedBlocksToArchiver: false,
Expand Down Expand Up @@ -225,6 +226,10 @@ export const sequencerConfigMappings: ConfigMappingsType<SequencerConfig> = {
description: 'Inject an attestation with an unrecoverable signature (for testing only)',
...booleanConfigHelper(DefaultSequencerConfig.injectUnrecoverableSignatureAttestation),
},
injectYParityAttestation: {
description: 'Inject a non-proposer attestation slot in yParity form in the packed L1 tuple (for testing only)',
...booleanConfigHelper(DefaultSequencerConfig.injectYParityAttestation),
},
fishermanMode: {
env: 'FISHERMAN_MODE',
description:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -893,6 +893,7 @@ describe('L1Publisher integration', () => {
checkpoint: checkpoint.toCheckpointInfo(),
attestors: [],
attestations: badAttestations,
verbatimAttestations: CommitteeAttestationsAndSigners.packAttestations(badAttestations),
epoch: EpochNumber(1),
seed: 1n,
reason: 'insufficient-attestations',
Expand Down
Loading
Loading