Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,18 @@ export class AttestationByTargetRepository {
return attestations.map((attestation) => this.type.deserialize(attestation));
}

/** Attestation with the highest target epoch recorded for `pubkey`, or null if none */
async getLatest(pubkey: BLSPubkey): Promise<SlashingProtectionAttestation | null> {
const [att] = await this.db.values({
gte: this.encodeKey(pubkey, 0),
lt: this.encodeKey(pubkey, Number.MAX_SAFE_INTEGER),
reverse: true,
limit: 1,
bucketId: this.bucketId,
});
return att ? this.type.deserialize(att) : null;
}

async get(pubkey: BLSPubkey, targetEpoch: Epoch): Promise<SlashingProtectionAttestation | null> {
const att = await this.db.get(this.encodeKey(pubkey, targetEpoch), this.dbReqOpts);
return att && this.type.deserialize(att);
Expand Down
10 changes: 10 additions & 0 deletions packages/validator/src/slashingProtection/attestation/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@ export enum InvalidAttestationErrorCode {
* bound on target epochs for this validator.
*/
TARGET_LESS_THAN_OR_EQ_LOWER_BOUND = "ERR_INVALID_ATTESTATION_TARGET_LESS_THAN_OR_EQ_LOWER_BOUND",
/**
* The attestation is invalid because its source epoch is below the min-max span lookback window of the
* latest attestation of this validator, where a surround vote could not be detected.
*/
SOURCE_BELOW_MIN_SPAN_LOOKBACK = "ERR_INVALID_ATTESTATION_SOURCE_BELOW_MIN_SPAN_LOOKBACK",
}

type InvalidAttestationErrorType =
Expand Down Expand Up @@ -61,6 +66,11 @@ type InvalidAttestationErrorType =
code: InvalidAttestationErrorCode.TARGET_LESS_THAN_OR_EQ_LOWER_BOUND;
targetEpoch: Epoch;
minTargetEpoch: Epoch;
}
| {
code: InvalidAttestationErrorCode.SOURCE_BELOW_MIN_SPAN_LOOKBACK;
sourceEpoch: Epoch;
minSourceEpoch: Epoch;
};

export class InvalidAttestationError extends LodestarError<InvalidAttestationErrorType> {}
31 changes: 26 additions & 5 deletions packages/validator/src/slashingProtection/attestation/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,9 +56,16 @@ export class SlashingProtectionAttestationService {
throw new InvalidAttestationError({code: InvalidAttestationErrorCode.SOURCE_EXCEEDS_TARGET});
}

const latestAtt = await this.attestationByTarget.getLatest(pubKey);

// Check for a double vote. Namely, an existing attestation with the same target epoch,
// and a different signing root.
const sameTargetAtt = await this.attestationByTarget.get(pubKey, attestation.targetEpoch);
// and a different signing root. No db read needed if the latest attestation has a lower target epoch.
let sameTargetAtt: SlashingProtectionAttestation | null = null;
if (latestAtt && latestAtt.targetEpoch === attestation.targetEpoch) {
sameTargetAtt = latestAtt;
} else if (latestAtt && latestAtt.targetEpoch > attestation.targetEpoch) {
sameTargetAtt = await this.attestationByTarget.get(pubKey, attestation.targetEpoch);
}
if (sameTargetAtt) {
// Interchange format allows for attestations without signing_root, then assume root is equal
if (isEqualNonZeroRoot(sameTargetAtt.signingRoot, attestation.signingRoot)) {
Expand All @@ -71,6 +78,20 @@ export class SlashingProtectionAttestationService {
});
}

// Min-span entries only exist within the lookback window below each recorded source epoch, a surround vote
// with an older source epoch is undetectable by min-max surround and must be rejected outright. Recorded
// attestations are surround-free, so the latest one has the highest source epoch and the widest window.
if (latestAtt) {
const minSourceEpoch = this.minMaxSurround.minSpanCoverageStart(latestAtt.sourceEpoch);
Comment thread
wemeetagain marked this conversation as resolved.
if (attestation.sourceEpoch < minSourceEpoch) {
throw new InvalidAttestationError({
code: InvalidAttestationErrorCode.SOURCE_BELOW_MIN_SPAN_LOOKBACK,
sourceEpoch: attestation.sourceEpoch,
minSourceEpoch,
});
}
}

// Check for a surround vote
try {
await this.minMaxSurround.assertNoSurround(pubKey, attestation);
Expand Down Expand Up @@ -143,13 +164,13 @@ export class SlashingProtectionAttestationService {
* Interchange import / export functionality
*/
async importAttestations(pubkey: BLSPubkey, attestations: SlashingProtectionAttestation[]): Promise<void> {
await this.attestationByTarget.set(pubkey, attestations);

// Pre-compute spans for all attestations
// Pre-compute spans for all attestations, before storing them so a rejected interchange leaves none behind
for (const attestation of attestations) {
await this.minMaxSurround.insertAttestation(pubkey, attestation);
Comment thread
wemeetagain marked this conversation as resolved.
Outdated
}

await this.attestationByTarget.set(pubkey, attestations);

// Pre-compute and store lower-bound
const minSourceEpoch = minEpoch(attestations.map((attestation) => attestation.sourceEpoch));
const minTargetEpoch = minEpoch(attestations.map((attestation) => attestation.targetEpoch));
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {BLSPubkey} from "@lodestar/types";
import {BLSPubkey, Epoch} from "@lodestar/types";
import {SurroundAttestationError, SurroundAttestationErrorCode} from "./errors.js";
import {DistanceEntry, IDistanceStore, IMinMaxSurround, MinMaxSurroundAttestation} from "./interface.js";

Expand All @@ -9,7 +9,8 @@ import {DistanceEntry, IDistanceStore, IMinMaxSurround, MinMaxSurroundAttestatio
* Number of epochs in the past to check for surrounding attestations.
*
* This value can be limited to a reasonable high amount as Lodestar does not solely rely on this strategy but also
* implements the minimal strategy which has been formally proven to be safe (https://github.com/michaelsproul/slashing-proofs).
* rejects any source epoch below `minSpanCoverageStart` of the latest recorded attestation, which is the minimal
* strategy (formally proven safe, https://github.com/michaelsproul/slashing-proofs) relaxed by this lookback.
*
* Limiting this value is required due to practical reasons as otherwise there would be a min-span DB read and write
* for each validator from current epoch until genesis which massively increases DB size and causes I/O lag, resulting in
Expand All @@ -30,6 +31,11 @@ export class MinMaxSurround implements IMinMaxSurround {
this.maxEpochLookback = options?.maxEpochLookback ?? DEFAULT_MAX_EPOCH_LOOKBACK;
}

/** Lowest epoch with a min-span entry after inserting an attestation with `sourceEpoch` */
minSpanCoverageStart(sourceEpoch: Epoch): Epoch {
return Math.max(0, sourceEpoch - 1 - this.maxEpochLookback);
}

async assertNoSurround(pubKey: BLSPubkey, attestation: MinMaxSurroundAttestation): Promise<void> {
await this.assertNotSurrounding(pubKey, attestation);
await this.assertNotSurrounded(pubKey, attestation);
Expand All @@ -45,7 +51,7 @@ export class MinMaxSurround implements IMinMaxSurround {
private async updateMinSpan(pubKey: BLSPubkey, attestation: MinMaxSurroundAttestation): Promise<void> {
await this.assertNotSurrounding(pubKey, attestation);

const untilEpoch = Math.max(0, attestation.sourceEpoch - 1 - this.maxEpochLookback);
const untilEpoch = this.minSpanCoverageStart(attestation.sourceEpoch);

const values: DistanceEntry[] = [];
for (let epoch = attestation.sourceEpoch - 1; epoch >= untilEpoch; epoch--) {
Expand Down
138 changes: 138 additions & 0 deletions packages/validator/test/unit/slashingProtection/attestation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import {rimraf} from "rimraf";
import {afterEach, beforeEach, describe, expect, it} from "vitest";
import {LevelDbController} from "@lodestar/db/controller/level";
import {ssz} from "@lodestar/types";
import {toHex} from "@lodestar/utils";
import {
InvalidAttestationErrorCode,
SlashingProtection,
SlashingProtectionAttestation,
} from "../../../src/slashingProtection/index.js";
import {testLogger} from "../../utils/logger.js";

/**
* Min-span entries only exist within `DEFAULT_MAX_EPOCH_LOOKBACK` (4096) epochs below each recorded source epoch.
* A surround vote with an older source epoch, as a malicious or buggy beacon node could serve, is undetectable
* by min-max surround and must be rejected by the lookback window of the latest recorded attestation.
*/
describe("SlashingProtection attestation min-span lookback", () => {
const pubkey = ssz.BLSPubkey.defaultValue();
let dbLocation: string;
let db: LevelDbController;
let slashingProtection: SlashingProtection;

beforeEach(async () => {
dbLocation = fs.mkdtempSync(path.join(os.tmpdir(), "lodestar-slashing-protection-"));
db = await LevelDbController.create({name: dbLocation}, {logger: testLogger()});
slashingProtection = new SlashingProtection(db);
});

afterEach(async () => {
await db.close();
rimraf.sync(dbLocation);
});

async function sign(sourceEpoch: number, targetEpoch: number, root = 1): Promise<void> {
const attestation: SlashingProtectionAttestation = {sourceEpoch, targetEpoch, signingRoot: Buffer.alloc(32, root)};
await slashingProtection.checkAndInsertAttestation(pubkey, attestation);
}

function importInterchange(attestations: [source: number, target: number][]): Promise<void> {
return slashingProtection.importInterchange(
{
metadata: {interchange_format_version: "5", genesis_validators_root: toHex(ssz.Root.defaultValue())},
data: [
{
pubkey: toHex(pubkey),
signed_blocks: [],
signed_attestations: attestations.map(([source, target]) => ({
source_epoch: String(source),
target_epoch: String(target),
})),
},
],
},
ssz.Root.defaultValue()
);
}

function rejectsWith(promise: Promise<void>, code: InvalidAttestationErrorCode): Promise<void> {
return expect(promise).rejects.toThrow(expect.objectContaining({type: expect.objectContaining({code})}));
}

it("accepts the next attestation of an honest chain", async () => {
await sign(299_999, 300_000);
await expect(sign(300_000, 300_001)).resolves.toBeUndefined();
});

it("rejects a surrounding attestation whose source is older than the min-max span lookback", async () => {
await sign(299_999, 300_000);
await sign(300_000, 300_001);

// Source 0 surrounds every attestation above but has no minSpan entry (300_000 - 4097 > 0)
await rejectsWith(sign(0, 300_002), InvalidAttestationErrorCode.SOURCE_BELOW_MIN_SPAN_LOOKBACK);
});

it("rejects a surrounding attestation inside the min-max span lookback via min-max spans", async () => {
await sign(10_000, 10_001);
await rejectsWith(sign(9_000, 10_002), InvalidAttestationErrorCode.NEW_SURROUNDS_PREV);
});

it("rejects a surrounding attestation inside an offline gap larger than the lookback", async () => {
await sign(99_999, 100_000);
// Validator offline for ~10_000 epochs, then resumes
await sign(109_999, 110_000);

// Surrounds (109_999, 110_000); minSpan has no entry for 100_500 (below 109_999 - 4097)
await rejectsWith(sign(100_500, 110_001), InvalidAttestationErrorCode.SOURCE_BELOW_MIN_SPAN_LOOKBACK);
});

it("accounts for attestations added by an interchange import", async () => {
await sign(10, 11);
// (0, 1) keeps the interchange lower bound loose so only the lookback window can reject below
await importInterchange([
[0, 1],
[20_000, 20_001],
]);

// Surrounds the imported (20000, 20001); no minSpan entry for 5000 (below 20000 - 4097)
await rejectsWith(sign(5_000, 20_002), InvalidAttestationErrorCode.SOURCE_BELOW_MIN_SPAN_LOOKBACK);
});

it("records nothing from an interchange import that fails validation", async () => {
await sign(10, 11);
await sign(11, 12);
// (9, 14) surrounds (10, 11) so the import must be rejected as a whole, including (12, 13)
await expect(
importInterchange([
[12, 13],
[9, 14],
])
).rejects.toThrow();

expect(await slashingProtection.hasAttestedInEpoch(pubkey, 13)).toBe(false);
await expect(sign(12, 13)).resolves.toBeUndefined();
});

it("rejects a double vote for a target recorded by an interchange import", async () => {
await sign(10, 11);
await sign(11, 12);
await importInterchange([[12, 13]]);

await rejectsWith(sign(12, 13, 2), InvalidAttestationErrorCode.DOUBLE_VOTE);
});

// The oldest epoch with a min-span entry for a recorded source `s` is `s - 1 - DEFAULT_MAX_EPOCH_LOOKBACK` (4096)
it("accepts a non-slashable source epoch at the edge of the min-max span lookback window", async () => {
await sign(10_000, 10_001);
await expect(sign(5_903, 10_000)).resolves.toBeUndefined();
});

it("rejects a source epoch just outside the min-max span lookback window", async () => {
await sign(10_000, 10_001);
await rejectsWith(sign(5_902, 10_000), InvalidAttestationErrorCode.SOURCE_BELOW_MIN_SPAN_LOOKBACK);
});
});
Loading