Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement committee_hash lightclient api #4736

Merged
merged 8 commits into from
Nov 15, 2022
Merged
Show file tree
Hide file tree
Changes from 7 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
12 changes: 12 additions & 0 deletions packages/api/src/beacon/routes/lightclient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,10 @@ export type Api = {
* Only block roots for checkpoints are guaranteed to be available.
*/
getBootstrap(blockRoot: string): Promise<{data: LightClientBootstrap}>;
/**
* Returns an array of sync committee hashes based on the provided period and count
*/
getCommitteeHash(startPeriod: SyncPeriod, count: number): Promise<{data: Uint8Array[]}>;
};

/**
Expand All @@ -60,6 +64,7 @@ export const routesData: RoutesData<Api> = {
getOptimisticUpdate: {url: "/eth/v1/beacon/light_client/optimistic_update", method: "GET"},
getFinalityUpdate: {url: "/eth/v1/beacon/light_client/finality_update", method: "GET"},
getBootstrap: {url: "/eth/v1/beacon/light_client/bootstrap/{block_root}", method: "GET"},
getCommitteeHash: {url: "/eth/v0/beacon/light_client/committee_hash", method: "GET"},
};

/* eslint-disable @typescript-eslint/naming-convention */
Expand All @@ -69,6 +74,7 @@ export type ReqTypes = {
getOptimisticUpdate: ReqEmpty;
getFinalityUpdate: ReqEmpty;
getBootstrap: {params: {block_root: string}};
getCommitteeHash: {query: {start_period: number; count: number}};
};

export function getReqSerializers(): ReqSerializers<Api, ReqTypes> {
Expand All @@ -93,6 +99,11 @@ export function getReqSerializers(): ReqSerializers<Api, ReqTypes> {
parseReq: ({params}) => [params.block_root],
schema: {params: {block_root: Schema.StringRequired}},
},
getCommitteeHash: {
writeReq: (start_period, count) => ({query: {start_period, count}}),
parseReq: ({query}) => [query.start_period, query.count],
schema: {query: {start_period: Schema.UintRequired, count: Schema.UintRequired}},
},
};
}

Expand All @@ -104,5 +115,6 @@ export function getReturnTypes(): ReturnTypes<Api> {
getOptimisticUpdate: ContainerData(ssz.altair.LightClientOptimisticUpdate),
getFinalityUpdate: ContainerData(ssz.altair.LightClientFinalityUpdate),
getBootstrap: ContainerData(ssz.altair.LightClientBootstrap),
getCommitteeHash: ContainerData(ArrayOf(ssz.Root)),
};
}
4 changes: 4 additions & 0 deletions packages/api/test/unit/beacon/testData/lightclient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,4 +66,8 @@ export const testData: GenericServerTestCases<Api> = {
},
},
},
getCommitteeHash: {
args: [1, 2],
res: {data: [Buffer.alloc(32, 0), Buffer.alloc(32, 1)]},
},
};
1 change: 1 addition & 0 deletions packages/beacon-node/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,7 @@
"stream-to-it": "^0.2.0",
"strict-event-emitter-types": "^2.0.0",
"uint8arraylist": "^2.3.2",
"uint8arrays": "^4.0.2",
"varint": "^6.0.0",
"xxhash-wasm": "1.0.1"
},
Expand Down
11 changes: 10 additions & 1 deletion packages/beacon-node/src/api/impl/lightclient/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import {routes} from "@lodestar/api";
import {fromHexString} from "@chainsafe/ssz";
import {ProofType, Tree} from "@chainsafe/persistent-merkle-tree";
import {SyncPeriod} from "@lodestar/types";
import {MAX_REQUEST_LIGHT_CLIENT_UPDATES} from "@lodestar/params";
import {MAX_REQUEST_LIGHT_CLIENT_UPDATES, MAX_REQUEST_LIGHT_CLIENT_COMMITTEE_HASHES} from "@lodestar/params";
import {ApiModules} from "../types.js";
import {resolveStateId} from "../beacon/state/utils.js";
import {IApiOptions} from "../../options.js";
Expand Down Expand Up @@ -70,5 +70,14 @@ export function getLightclientApi(
const bootstrapProof = await chain.lightClientServer.getBootstrap(fromHexString(blockRoot));
return {data: bootstrapProof};
},

async getCommitteeHash(startPeriod: SyncPeriod, count: number) {
const maxAllowedCount = Math.min(MAX_REQUEST_LIGHT_CLIENT_COMMITTEE_HASHES, count);
const periods = Array.from({length: maxAllowedCount}, (_ignored, i) => i + startPeriod);
const committeeHashes = await Promise.all(
periods.map((period) => chain.lightClientServer.getCommitteeHash(period))
);
return {data: committeeHashes};
},
};
}
27 changes: 27 additions & 0 deletions packages/beacon-node/src/chain/lightClient/index.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import {concat} from "uint8arrays";
import {altair, phase0, Root, RootHex, Slot, ssz, SyncPeriod} from "@lodestar/types";
import {IChainForkConfig} from "@lodestar/config";
import {CachedBeaconStateAltair, computeSyncPeriodAtEpoch, computeSyncPeriodAtSlot} from "@lodestar/state-transition";
import {ILogger, MapDef, pruneSetToMax} from "@lodestar/utils";
import {BitArray, CompositeViewDU, toHexString} from "@chainsafe/ssz";
import {MIN_SYNC_COMMITTEE_PARTICIPANTS, SYNC_COMMITTEE_SIZE} from "@lodestar/params";
import {digest} from "@chainsafe/as-sha256";
import {IBeaconDb} from "../../db/index.js";
import {IMetrics} from "../../metrics/index.js";
import {ChainEvent, ChainEventEmitter} from "../emitter.js";
Expand Down Expand Up @@ -291,6 +293,31 @@ export class LightClientServer {
return update;
}

/**
* API ROUTE to get the sync committee hash from the best available update for `period`.
*/
async getCommitteeHash(period: number): Promise<Uint8Array> {
const {attestedHeader} = await this.getUpdate(period);
const blockRoot = ssz.phase0.BeaconBlockHeader.hashTreeRoot(attestedHeader);

const syncCommitteeWitness = await this.db.syncCommitteeWitness.get(blockRoot);
if (!syncCommitteeWitness) {
throw new LightClientServerError(
{code: LightClientServerErrorCode.RESOURCE_UNAVAILABLE},
`syncCommitteeWitness not available ${toHexString(blockRoot)} period ${period}`
);
}

const currentSyncCommittee = await this.db.syncCommittee.get(syncCommitteeWitness.currentSyncCommitteeRoot);
if (!currentSyncCommittee) {
throw new LightClientServerError(
{code: LightClientServerErrorCode.RESOURCE_UNAVAILABLE},
`currentSyncCommittee not available ${toHexString(blockRoot)} for period ${period}`
);
}
return digest(concat(currentSyncCommittee.pubkeys));
wemeetagain marked this conversation as resolved.
Show resolved Hide resolved
}

/**
* API ROUTE to poll LightclientHeaderUpdate.
* Clients should use the SSE type `light_client_optimistic_update` if available
Expand Down
118 changes: 118 additions & 0 deletions packages/beacon-node/test/e2e/api/impl/lightclient/endpoint.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import {expect} from "chai";
import {createIBeaconConfig, IChainConfig} from "@lodestar/config";
import {chainConfig as chainConfigDef} from "@lodestar/config/default";
import {getClient} from "@lodestar/api";
import {sleep} from "@lodestar/utils";
import {SYNC_COMMITTEE_SIZE} from "@lodestar/params";
import {digest} from "@chainsafe/as-sha256";
import {Validator} from "@lodestar/validator";
import {phase0} from "@lodestar/types";
import {LogLevel, testLogger, TestLoggerOpts} from "../../../../utils/logger.js";
import {getDevBeaconNode} from "../../../../utils/node/beacon.js";
import {getAndInitDevValidators} from "../../../../utils/node/validator.js";
import {BeaconNode} from "../../../../../src/node/nodejs.js";
import {waitForEvent} from "../../../../utils/events/resolver.js";
import {ChainEvent} from "../../../../../src/chain/emitter.js";

/* eslint-disable @typescript-eslint/naming-convention */
describe("lightclient api", function () {
this.timeout("10 min");

const SECONDS_PER_SLOT = 1;
const ALTAIR_FORK_EPOCH = 0;
const restPort = 9596;
const chainConfig: IChainConfig = {...chainConfigDef, SECONDS_PER_SLOT, ALTAIR_FORK_EPOCH};
const genesisValidatorsRoot = Buffer.alloc(32, 0xaa);
const config = createIBeaconConfig(chainConfig, genesisValidatorsRoot);
const testLoggerOpts: TestLoggerOpts = {logLevel: LogLevel.info};
const loggerNodeA = testLogger("Node-A", testLoggerOpts);
const validatorCount = 2;

let bn: BeaconNode;
let validators: Validator[];
const afterEachCallbacks: (() => Promise<unknown> | void)[] = [];

this.beforeEach(async () => {
bn = await getDevBeaconNode({
params: chainConfig,
options: {
sync: {isSingleNode: true},
network: {allowPublishToZeroPeers: true},
api: {
rest: {
enabled: true,
port: restPort,
api: ["lightclient"],
},
},
chain: {blsVerifyAllMainThread: true},
},
validatorCount,
logger: loggerNodeA,
});
afterEachCallbacks.push(() => bn.close());

validators = (
await getAndInitDevValidators({
node: bn,
validatorsPerClient: validatorCount,
validatorClientCount: 1,
startIndex: 0,
useRestApi: false,
testLoggerOpts,
})
).validators;
afterEachCallbacks.push(() => Promise.all(validators.map((validator) => validator.close())));
});

afterEach(async () => {
while (afterEachCallbacks.length > 0) {
const callback = afterEachCallbacks.pop();
if (callback) await callback();
}
});

it("getUpdates()", async function () {
await sleep(2 * SECONDS_PER_SLOT * 1000);
const client = getClient({baseUrl: `http://127.0.0.1:${restPort}`}, {config}).lightclient;
const {data: updates} = await client.getUpdates(0, 1);
const slot = bn.chain.clock.currentSlot;
expect(updates.length).to.be.equal(1);
// at slot 2 we got attestedHeader for slot 1
expect(updates[0].attestedHeader.slot).to.be.equal(slot - 1);
});

it("getOptimisticUpdate()", async function () {
await sleep(2 * SECONDS_PER_SLOT * 1000);
const client = getClient({baseUrl: `http://127.0.0.1:${restPort}`}, {config}).lightclient;
const {data: update} = await client.getOptimisticUpdate();
const slot = bn.chain.clock.currentSlot;
// at slot 2 we got attestedHeader for slot 1
expect(update.attestedHeader.slot).to.be.equal(slot - 1);
});

it.skip("getFinalityUpdate()", async function () {
// TODO: not sure how this causes subsequent tests failed
await waitForEvent<phase0.Checkpoint>(bn.chain.emitter, ChainEvent.finalized, 240000);
await sleep(SECONDS_PER_SLOT * 1000);
const client = getClient({baseUrl: `http://127.0.0.1:${restPort}`}, {config}).lightclient;
const {data: update} = await client.getFinalityUpdate();
expect(update).to.be.not.undefined;
});

it("getCommitteeHash() for the 1st period", async function () {
// call this right away causes "No partialUpdate available for period 0"
await sleep(2 * SECONDS_PER_SLOT * 1000);

const lightclient = getClient({baseUrl: `http://127.0.0.1:${restPort}`}, {config}).lightclient;
const {data: syncCommitteeHash} = await lightclient.getCommitteeHash(0, 1);
const client = getClient({baseUrl: `http://127.0.0.1:${restPort}`}, {config}).beacon;
const {data: validatorResponses} = await client.getStateValidators("head");
const pubkeys = validatorResponses.map((v) => v.validator.pubkey);
expect(pubkeys.length).to.be.equal(validatorCount);
// only 2 validators spreading to 512 committee slots
const syncCommittee = Array.from({length: SYNC_COMMITTEE_SIZE}, (_, i) => (i % 2 === 0 ? pubkeys[0] : pubkeys[1]));
// single committe hash since we requested for the first period
expect(syncCommitteeHash).to.be.deep.equal([digest(Buffer.concat(syncCommittee))]);
});
});
4 changes: 4 additions & 0 deletions packages/light-client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,10 @@
"cross-fetch": "^3.1.4",
"mitt": "^3.0.0"
},
"devDependencies": {
"uint8arrays": "^4.0.2",
"@chainsafe/as-sha256": "^0.3.1"
},
"keywords": [
"ethereum",
"eth-consensus",
Expand Down
12 changes: 12 additions & 0 deletions packages/light-client/test/mocks/LightclientServerApiMock.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import {concat} from "uint8arrays";
import {digest} from "@chainsafe/as-sha256";
import {Proof} from "@chainsafe/persistent-merkle-tree";
import {JsonPath} from "@chainsafe/ssz";
import {routes} from "@lodestar/api";
import {altair, RootHex, SyncPeriod} from "@lodestar/types";
import {notNullish} from "@lodestar/utils";
import {BeaconStateAltair} from "../utils/types.js";

export class LightclientServerApiMock implements routes.lightclient.Api {
Expand Down Expand Up @@ -44,6 +47,15 @@ export class LightclientServerApiMock implements routes.lightclient.Api {
if (!snapshot) throw Error(`snapshot for blockRoot ${blockRoot} not available`);
return {data: snapshot};
}

async getCommitteeHash(startPeriod: SyncPeriod, count: number): Promise<{data: Uint8Array[]}> {
const periods = Array.from({length: count}, (_ignored, i) => i + startPeriod);
const committeeHashes = periods
.map((period) => this.updates.get(period)?.nextSyncCommittee.pubkeys)
.filter(notNullish)
.map((pubkeys) => digest(concat(pubkeys)));
return {data: committeeHashes};
}
}

export type IStateRegen = {
Expand Down
1 change: 1 addition & 0 deletions packages/params/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,7 @@ export const NEXT_SYNC_COMMITTEE_GINDEX = 55;
export const NEXT_SYNC_COMMITTEE_DEPTH = 5;
export const NEXT_SYNC_COMMITTEE_INDEX = 23;
export const MAX_REQUEST_LIGHT_CLIENT_UPDATES = 128;
export const MAX_REQUEST_LIGHT_CLIENT_COMMITTEE_HASHES = 128;

/**
* Optimistic sync
Expand Down