Separate lodestar-api package (#2568)

* Refactor REST API definitions

* Simplify REST testing

* Bump to fastify 3.x.x

* Return {} in fastify handler to trigger send

* Move REST server to lodestar-api

* Improve REST tests

* Add eventstream test

* Clean up

* Bump versions

* Fix query string format

* Add extra debug routes

* Consume lodestar-api package

* Fix tests

* Revert package.json change

* Add HttpClient test

* Destroy all active requests immediately on close

* Fastify hook handlers must resolve

* Fix fastify hook config args

* Fix parsing of ValidatorId

* Remove e2e script test

* Add docs

* Simplify req declarations

* Review PR

* Update license
This commit is contained in:
Lion - dapplion
2021-05-27 19:17:49 +02:00
committed by GitHub
parent f677874007
commit de1e1210b4
315 changed files with 6354 additions and 7911 deletions

View File

@@ -0,0 +1,15 @@
/*
See
https://github.com/babel/babel/issues/8652
https://github.com/babel/babel/pull/6027
Babel isn't currently configured by default to read .ts files and
can only be configured to do so via cli or configuration below.
This file is used by mocha to interpret test files using a properly
configured babel.
This can (probably) be removed in babel 8.x.
*/
require('@babel/register')({
extensions: ['.ts'],
})

3
packages/api/.babelrc Normal file
View File

@@ -0,0 +1,3 @@
{
"extends": "../../.babelrc"
}

10
packages/api/.gitignore vendored Normal file
View File

@@ -0,0 +1,10 @@
node_modules/
lib
.nyc_output/
coverage/**
.DS_Store
*.swp
.idea
yarn-error.log
package-lock.json
dist*

View File

@@ -0,0 +1,4 @@
colors: true
require: ts-node/register
timeout: 2000
exit: true

3
packages/api/.nycrc.json Normal file
View File

@@ -0,0 +1,3 @@
{
"extends": "../../.nycrc.json"
}

201
packages/api/LICENSE Normal file
View File

@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

52
packages/api/README.md Normal file
View File

@@ -0,0 +1,52 @@
# Lodestar ETH2.0 API
[![](https://img.shields.io/travis/com/ChainSafe/lodestar/master.svg?label=master&logo=travis "Master Branch (Travis)")](https://travis-ci.com/ChainSafe/lodestar)
[![Discord](https://img.shields.io/discord/593655374469660673.svg?label=Discord&logo=discord)](https://discord.gg/aMxzVcr)
![ETH2.0_Spec_Version 0.12.1](https://img.shields.io/badge/ETH2.0_Spec_Version-0.12.1-2e86c1.svg)
![ES Version](https://img.shields.io/badge/ES-2020-yellow)
![Node Version](https://img.shields.io/badge/node-12.x-green)
> This package is part of [ChainSafe's Lodestar](https://lodestar.chainsafe.io) project
Typescript REST client for the [Eth2.0 API spec](https://ethereum.github.io/eth2.0-APIs/)
## Usage
```typescript
import {getClient} from "@chainsafe/lodestar-api";
import {config} from "@chainsafe/lodestar-config/mainnet";
const api = getClient(config, {
baseUrl: "http://localhost:9596",
});
const res = await api.state.getStateValidator(
"head",
"0x933ad9491b62059dd065b560d256d8957a8c402cc6e8d8ee7290ae11e8f7329267a8811c397529dac52ae1342ba58c95"
);
console.log("Your balance is:", res.data.balance);
```
## Prerequisites
- [Lerna](https://github.com/lerna/lerna)
- [Yarn](https://yarnpkg.com/)
## What you need
You will need to go over the [specification](https://github.com/ethereum/eth2.0-specs). You will also need to have a [basic understanding of sharding](https://github.com/ethereum/wiki/wiki/Sharding-FAQs).
## Getting started
- Follow the [installation guide](https://chainsafe.github.io/lodestar/installation) to install Lodestar.
- Quickly try out the whole stack by [starting a local testnet](https://chainsafe.github.io/lodestar/usage).
- View the [typedoc code docs](https://chainsafe.github.io/lodestar/packages).
## Contributors
Read our [contributors document](/CONTRIBUTING.md), [submit an issue](https://github.com/ChainSafe/lodestar/issues/new/choose) or talk to us on our [discord](https://discord.gg/yjyvFRP)!
## License
Apache-2.0 [ChainSafe Systems](https://chainsafe.io)

64
packages/api/package.json Normal file
View File

@@ -0,0 +1,64 @@
{
"name": "@chainsafe/lodestar-api",
"private": true,
"description": "A Typescript implementation of the eth2 light client",
"license": "Apache-2.0",
"author": "ChainSafe Systems",
"homepage": "https://github.com/ChainSafe/lodestar#readme",
"repository": {
"type": "git",
"url": "git+https://github.com:ChainSafe/lodestar.git"
},
"bugs": {
"url": "https://github.com/ChainSafe/lodestar/issues"
},
"version": "0.22.0",
"main": "lib/index.js",
"files": [
"lib/**/*.d.ts",
"lib/**/*.js",
"lib/**/*.js.map"
],
"scripts": {
"clean": "rm -rf lib && rm -f *.tsbuildinfo",
"build": "concurrently \"yarn build:lib\" \"yarn build:types\"",
"build:typedocs": "typedoc --exclude src/index.ts --out typedocs src",
"build:lib": "babel src -x .ts -d lib --source-maps",
"build:release": "yarn clean && yarn run build && yarn run build:typedocs",
"build:types": "tsc -p tsconfig.build.json",
"check-types": "tsc",
"coverage": "codecov -F lodestar-api",
"lint": "eslint --color --ext .ts src/ test/",
"lint:fix": "yarn run lint --fix",
"pretest": "yarn run check-types",
"test": "yarn test:unit && yarn test:e2e",
"test:unit": "nyc --cache-dir .nyc_output/.cache -e .ts mocha 'test/unit/**/*.test.ts'"
},
"dependencies": {
"@chainsafe/lodestar-config": "^0.22.0",
"@chainsafe/lodestar-params": "^0.22.0",
"@chainsafe/lodestar-types": "^0.22.0",
"@chainsafe/lodestar-utils": "^0.22.0",
"@chainsafe/persistent-merkle-tree": "^0.3.2",
"@chainsafe/ssz": "^0.8.6",
"abort-controller": "^3.0.0",
"cross-fetch": "^3.1.4",
"eventsource": "^1.1.0",
"qs": "^6.10.1"
},
"devDependencies": {
"@types/eventsource": "^1.1.5",
"@types/qs": "^6.9.6",
"fastify": "3.15.1"
},
"peerDependencies": {
"fastify": "3.15.1"
},
"keywords": [
"ethereum",
"eth2",
"beacon",
"api",
"blockchain"
]
}

1
packages/api/server.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
export * from "./lib/server";

2
packages/api/server.js Normal file
View File

@@ -0,0 +1,2 @@
// eslint-disable-next-line @typescript-eslint/no-require-imports
module.exports = require("./lib/server");

View File

@@ -0,0 +1,13 @@
import {IBeaconConfig} from "@chainsafe/lodestar-config";
import {IHttpClient, generateGenericJsonClient} from "./utils";
import {Api, ReqTypes, routesData, getReqSerializers, getReturnTypes} from "../routes/beacon";
/**
* REST HTTP client for beacon routes
*/
export function getClient(config: IBeaconConfig, httpClient: IHttpClient): Api {
const reqSerializers = getReqSerializers(config);
const returnTypes = getReturnTypes(config);
// All routes return JSON, use a client auto-generator
return generateGenericJsonClient<Api, ReqTypes>(routesData, reqSerializers, returnTypes, httpClient);
}

View File

@@ -0,0 +1,13 @@
import {IBeaconConfig} from "@chainsafe/lodestar-config";
import {IHttpClient, generateGenericJsonClient} from "./utils";
import {Api, ReqTypes, routesData, getReqSerializers, getReturnTypes} from "../routes/config";
/**
* REST HTTP client for config routes
*/
export function getClient(config: IBeaconConfig, httpClient: IHttpClient): Api {
const reqSerializers = getReqSerializers();
const returnTypes = getReturnTypes(config);
// All routes return JSON, use a client auto-generator
return generateGenericJsonClient<Api, ReqTypes>(routesData, reqSerializers, returnTypes, httpClient);
}

View File

@@ -0,0 +1,13 @@
import {IBeaconConfig} from "@chainsafe/lodestar-config";
import {IHttpClient, generateGenericJsonClient} from "./utils";
import {Api, ReqTypes, routesData, getReqSerializers, getReturnTypes} from "../routes/debug";
/**
* REST HTTP client for debug routes
*/
export function getClient(config: IBeaconConfig, httpClient: IHttpClient): Api {
const reqSerializers = getReqSerializers();
const returnTypes = getReturnTypes(config);
// All routes return JSON, use a client auto-generator
return generateGenericJsonClient<Api, ReqTypes>(routesData, reqSerializers, returnTypes, httpClient);
}

View File

@@ -0,0 +1,56 @@
import EventSource from "eventsource";
import qs from "qs";
import {IBeaconConfig} from "@chainsafe/lodestar-config";
import {Api, BeaconEvent, routesData, getEventSerdes} from "../routes/events";
/**
* REST HTTP client for events routes
*/
export function getClient(config: IBeaconConfig, baseUrl: string): Api {
const eventSerdes = getEventSerdes(config);
return {
eventstream: async (topics, signal, onEvent) => {
const query = qs.stringify({topics});
// TODO: Use a proper URL formatter
const url = `${baseUrl}${routesData.eventstream.url}?${query}`;
const eventSource = new EventSource(url);
try {
await new Promise<void>((resolve, reject) => {
for (const topic of topics) {
eventSource.addEventListener(topic, ((event: MessageEvent) => {
const message = eventSerdes.fromJson(topic, JSON.parse(event.data));
onEvent({type: topic, message} as BeaconEvent);
}) as EventListener);
}
// EventSource will try to reconnect always on all errors
// `eventSource.onerror` events are informative but don't indicate the EventSource closed
// The only way to abort the connection from the client is via eventSource.close()
eventSource.onerror = function (err) {
const errEs = (err as unknown) as EventSourceError;
// Consider 400 and 500 status errors unrecoverable, close the eventsource
if (errEs.status === 400) {
reject(Error(`400 Invalid topics: ${errEs.message}`));
}
if (errEs.status === 500) {
reject(Error(`500 Internal Server Error: ${errEs.message}`));
}
// TODO: else log the error somewhere
// console.log("eventstream client error", errEs);
};
// And abort resolve the promise so finally {} eventSource.close()
signal.addEventListener("abort", () => resolve(), {once: true});
});
} finally {
eventSource.close();
}
},
};
}
// https://github.com/EventSource/eventsource/blob/82e034389bd2c08d532c63172b8e858c5b185338/lib/eventsource.js#L143
type EventSourceError = {status: number; message: string};

View File

@@ -0,0 +1,30 @@
import {IBeaconConfig} from "@chainsafe/lodestar-config";
import {Api} from "../interface";
import {IHttpClient, HttpClient, HttpClientOptions} from "./utils";
import * as beacon from "./beacon";
import * as configApi from "./config";
import * as debug from "./debug";
import * as events from "./events";
import * as lightclient from "./lightclient";
import * as lodestar from "./lodestar";
import * as node from "./node";
import * as validator from "./validator";
/**
* REST HTTP client for all routes
*/
export function getClient(config: IBeaconConfig, opts: HttpClientOptions, httpClient?: IHttpClient): Api {
if (!httpClient) httpClient = new HttpClient(opts);
return {
beacon: beacon.getClient(config, httpClient),
config: configApi.getClient(config, httpClient),
debug: debug.getClient(config, httpClient),
events: events.getClient(config, httpClient.baseUrl),
lightclient: lightclient.getClient(config, httpClient),
lodestar: lodestar.getClient(config, httpClient),
node: node.getClient(config, httpClient),
validator: validator.getClient(config, httpClient),
};
}

View File

@@ -0,0 +1,27 @@
import {IBeaconConfig} from "@chainsafe/lodestar-config";
import {deserializeProof} from "@chainsafe/persistent-merkle-tree";
import {IHttpClient, getFetchOptsSerializers, generateGenericJsonClient} from "./utils";
import {Api, ReqTypes, routesData, getReqSerializers, getReturnTypes} from "../routes/lightclient";
/**
* REST HTTP client for lightclient routes
*/
export function getClient(config: IBeaconConfig, httpClient: IHttpClient): Api {
const reqSerializers = getReqSerializers();
const returnTypes = getReturnTypes(config);
// Some routes return JSON, use a client auto-generator
const client = generateGenericJsonClient<Api, ReqTypes>(routesData, reqSerializers, returnTypes, httpClient);
// For `getStateProof()` generate request serializer
const fetchOptsSerializers = getFetchOptsSerializers<Api, ReqTypes>(routesData, reqSerializers);
return {
...client,
async getStateProof(stateId, paths) {
const buffer = await httpClient.arrayBuffer(fetchOptsSerializers.getStateProof(stateId, paths));
const proof = deserializeProof(new Uint8Array(buffer));
return {data: proof};
},
};
}

View File

@@ -0,0 +1,13 @@
import {IBeaconConfig} from "@chainsafe/lodestar-config";
import {IHttpClient, generateGenericJsonClient} from "./utils";
import {Api, ReqTypes, routesData, getReqSerializers, getReturnTypes} from "../routes/lodestar";
/**
* REST HTTP client for lodestar routes
*/
export function getClient(_config: IBeaconConfig, httpClient: IHttpClient): Api {
const reqSerializers = getReqSerializers();
const returnTypes = getReturnTypes();
// All routes return JSON, use a client auto-generator
return generateGenericJsonClient<Api, ReqTypes>(routesData, reqSerializers, returnTypes, httpClient);
}

View File

@@ -0,0 +1,13 @@
import {IBeaconConfig} from "@chainsafe/lodestar-config";
import {IHttpClient, generateGenericJsonClient} from "./utils";
import {Api, ReqTypes, routesData, getReqSerializers, getReturnTypes} from "../routes/node";
/**
* REST HTTP client for beacon routes
*/
export function getClient(config: IBeaconConfig, httpClient: IHttpClient): Api {
const reqSerializers = getReqSerializers();
const returnTypes = getReturnTypes(config);
// All routes return JSON, use a client auto-generator
return generateGenericJsonClient<Api, ReqTypes>(routesData, reqSerializers, returnTypes, httpClient);
}

View File

@@ -0,0 +1,78 @@
import {Json} from "@chainsafe/ssz";
import {mapValues} from "@chainsafe/lodestar-utils";
import {FetchOpts, IHttpClient} from "./httpClient";
import {compileRouteUrlFormater} from "../../utils/urlFormat";
import {
RouteDef,
ReqGeneric,
RouteGeneric,
ReturnTypes,
TypeJson,
jsonOpts,
ReqSerializer,
ReqSerializers,
RoutesData,
} from "../../utils/types";
// See /packages/api/src/routes/index.ts for reasoning
/* eslint-disable @typescript-eslint/no-explicit-any */
/**
* Format FetchFn opts from Fn arguments given a route definition and request serializer.
* For routes that return only JSOn use @see getGenericJsonClient
*/
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, @typescript-eslint/explicit-function-return-type
export function getFetchOptsSerializer<Fn extends (...args: any) => any, ReqType extends ReqGeneric>(
routeDef: RouteDef,
reqSerializer: ReqSerializer<Fn, ReqType>
) {
const urlFormater = compileRouteUrlFormater(routeDef.url);
return function getFetchOpts(...args: Parameters<Fn>): FetchOpts {
const req = reqSerializer.writeReq(...args);
return {
url: urlFormater(req.params || {}),
method: routeDef.method,
query: req.query,
body: req.body as unknown,
};
};
}
/**
* Generate `getFetchOptsSerializer()` functions for all routes in `Api`
*/
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, @typescript-eslint/explicit-function-return-type
export function getFetchOptsSerializers<
Api extends Record<string, RouteGeneric>,
ReqTypes extends {[K in keyof Api]: ReqGeneric}
>(routesData: RoutesData<Api>, reqSerializers: ReqSerializers<Api, ReqTypes>) {
return mapValues(routesData, (routeDef, routeKey) => getFetchOptsSerializer(routeDef, reqSerializers[routeKey]));
}
/**
* Get a generic JSON client from route definition, request serializer and return types.
*/
export function generateGenericJsonClient<
Api extends Record<string, RouteGeneric>,
ReqTypes extends {[K in keyof Api]: ReqGeneric}
>(
routesData: RoutesData<Api>,
reqSerializers: ReqSerializers<Api, ReqTypes>,
returnTypes: ReturnTypes<Api>,
fetchFn: IHttpClient
): Api {
return mapValues(routesData, (routeDef, routeKey) => {
const fetchOptsSerializer = getFetchOptsSerializer(routeDef, reqSerializers[routeKey]);
const returnType = returnTypes[routeKey as keyof ReturnTypes<Api>] as TypeJson<any> | null;
return async function request(...args: Parameters<Api[keyof Api]>): Promise<any | void> {
const res = await fetchFn.json<Json>(fetchOptsSerializer(...args));
if (returnType) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return returnType.fromJson(res, jsonOpts) as ReturnType<Api[keyof Api]>;
}
};
}) as Api;
}

View File

@@ -0,0 +1,145 @@
import {fetch} from "cross-fetch";
import qs from "qs";
import {AbortSignal, AbortController} from "abort-controller";
import {ErrorAborted, TimeoutError} from "@chainsafe/lodestar-utils";
import {ReqGeneric, RouteDef} from "../../utils";
export class HttpError extends Error {
status: number;
url: string;
constructor(message: string, status: number, url: string) {
super(message);
this.status = status;
this.url = url;
}
}
export type FetchOpts = {
url: RouteDef["url"];
method: RouteDef["method"];
query?: ReqGeneric["query"];
body?: ReqGeneric["body"];
};
export interface IHttpClient {
baseUrl: string;
json<T>(opts: FetchOpts): Promise<T>;
arrayBuffer(opts: FetchOpts): Promise<ArrayBuffer>;
}
export type HttpClientOptions = {
baseUrl: string;
timeoutMs?: number;
/** Return an AbortSignal to be attached to all requests */
getAbortSignal?: () => AbortSignal | undefined;
/** Override fetch function */
fetch?: typeof fetch;
};
export class HttpClient implements IHttpClient {
readonly baseUrl: string;
private readonly timeoutMs: number;
private readonly getAbortSignal?: () => AbortSignal | undefined;
private readonly fetch: typeof fetch;
/**
* timeoutMs = config.params.SECONDS_PER_SLOT * 1000
*/
constructor(opts: HttpClientOptions) {
this.baseUrl = opts.baseUrl;
this.timeoutMs = opts.timeoutMs ?? 12000;
this.getAbortSignal = opts.getAbortSignal;
this.fetch = opts.fetch ?? fetch;
}
async json<T>(opts: FetchOpts): Promise<T> {
return await this.request<T>(opts, (res) => res.json() as Promise<T>);
}
async arrayBuffer(opts: FetchOpts): Promise<ArrayBuffer> {
return await this.request<ArrayBuffer>(opts, (res) => res.arrayBuffer());
}
private async request<T>(opts: FetchOpts, getBody: (res: Response) => Promise<T>): Promise<T> {
// Implement fetch timeout
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), this.timeoutMs);
// Attach global signal to this request's controller
const signalGlobal = this.getAbortSignal && this.getAbortSignal();
if (signalGlobal) {
signalGlobal.addEventListener("abort", () => controller.abort());
}
try {
const url = urlJoin(this.baseUrl, opts.url) + (opts.query ? "?" + stringifyQuery(opts.query) : "");
const bodyArgs = opts.body
? {headers: {"Content-Type": "application/json"}, body: JSON.stringify(opts.body)}
: {};
const res = await this.fetch(url, {method: opts.method, ...bodyArgs, signal: controller.signal});
if (!res.ok) {
const errBody = await res.text();
throw new HttpError(`${res.statusText}: ${getErrorMessage(errBody)}`, res.status, url);
}
return await getBody(res);
} catch (e) {
if (isAbortedError(e)) {
if (signalGlobal?.aborted) {
throw new ErrorAborted("REST client");
} else if (controller.signal.aborted) {
throw new TimeoutError("request");
} else {
throw Error("Unknown aborted error");
}
}
throw e;
} finally {
clearTimeout(timeout);
if (signalGlobal) {
signalGlobal.removeEventListener("abort", controller.abort);
}
}
}
}
function isAbortedError(e: Error): boolean {
return e.name === "AbortError" || e.message === "The user aborted a request";
}
function getErrorMessage(errBody: string): string {
try {
const errJson = JSON.parse(errBody) as {message: string};
if (errJson.message) {
return errJson.message;
} else {
return errBody;
}
} catch (e) {
return errBody;
}
}
/**
* Eth2.0 API requires the query with format:
* - arrayFormat: repeat `topic=topic1&topic=topic2`
*/
export function stringifyQuery(query: unknown): string {
return qs.stringify(query, {arrayFormat: "repeat"});
}
/**
* TODO: Optimize, two regex is a bit wasteful
*/
export function urlJoin(...args: string[]): string {
return (
args
.join("/")
.replace(/([^:]\/)\/+/g, "$1")
// Remove duplicate slashes in the front
.replace(/^(\/)+/, "/")
);
}

View File

@@ -0,0 +1,2 @@
export * from "./client";
export * from "./httpClient";

View File

@@ -0,0 +1,13 @@
import {IBeaconConfig} from "@chainsafe/lodestar-config";
import {IHttpClient, generateGenericJsonClient} from "./utils";
import {Api, ReqTypes, routesData, getReqSerializers, getReturnTypes} from "../routes/validator";
/**
* REST HTTP client for validator routes
*/
export function getClient(config: IBeaconConfig, httpClient: IHttpClient): Api {
const reqSerializers = getReqSerializers(config);
const returnTypes = getReturnTypes(config);
// All routes return JSON, use a client auto-generator
return generateGenericJsonClient<Api, ReqTypes>(routesData, reqSerializers, returnTypes, httpClient);
}

View File

@@ -0,0 +1,5 @@
export * as routes from "./routes";
export {Api} from "./interface";
export {getClient} from "./client";
// Node: Don't export server here so it's not bundled to all consumers

View File

@@ -0,0 +1,19 @@
import {Api as BeaconApi} from "./routes/beacon";
import {Api as ConfigApi} from "./routes/config";
import {Api as DebugApi} from "./routes/debug";
import {Api as EventsApi} from "./routes/events";
import {Api as LightclientApi} from "./routes/lightclient";
import {Api as LodestarApi} from "./routes/lodestar";
import {Api as NodeApi} from "./routes/node";
import {Api as ValidatorApi} from "./routes/validator";
export type Api = {
beacon: BeaconApi;
config: ConfigApi;
debug: DebugApi;
events: EventsApi;
lightclient: LightclientApi;
lodestar: LodestarApi;
node: NodeApi;
validator: ValidatorApi;
};

View File

@@ -0,0 +1,167 @@
import {ContainerType, Json} from "@chainsafe/ssz";
import {ForkName, IBeaconConfig} from "@chainsafe/lodestar-config";
import {phase0, allForks, Slot, Root} from "@chainsafe/lodestar-types";
import {
RoutesData,
ReturnTypes,
ArrayOf,
ContainerData,
Schema,
WithVersion,
reqOnlyBody,
TypeJson,
ReqSerializers,
ReqSerializer,
} from "../../utils";
// See /packages/api/src/routes/index.ts for reasoning and instructions to add new routes
export type BlockId = "head" | "genesis" | "finalized" | string | number;
export type BlockHeaderResponse = {
root: Root;
canonical: boolean;
header: phase0.SignedBeaconBlockHeader;
};
export type Api = {
/**
* Get block
* Returns the complete `SignedBeaconBlock` for a given block ID.
* Depending on the `Accept` header it can be returned either as JSON or SSZ-serialized bytes.
*
* @param blockId Block identifier.
* Can be one of: "head" (canonical head in node's view), "genesis", "finalized", \<slot\>, \<hex encoded blockRoot with 0x prefix\>.
*/
getBlock(blockId: BlockId): Promise<{data: allForks.SignedBeaconBlock}>;
/**
* Get block
* Retrieves block details for given block id.
* @param blockId Block identifier.
* Can be one of: "head" (canonical head in node's view), "genesis", "finalized", \<slot\>, \<hex encoded blockRoot with 0x prefix\>.
*/
getBlockV2(blockId: BlockId): Promise<{data: allForks.SignedBeaconBlock; version: ForkName}>;
/**
* Get block attestations
* Retrieves attestation included in requested block.
* @param blockId Block identifier.
* Can be one of: "head" (canonical head in node's view), "genesis", "finalized", \<slot\>, \<hex encoded blockRoot with 0x prefix\>.
*/
getBlockAttestations(blockId: BlockId): Promise<{data: phase0.Attestation[]}>;
/**
* Get block header
* Retrieves block header for given block id.
* @param blockId Block identifier.
* Can be one of: "head" (canonical head in node's view), "genesis", "finalized", \<slot\>, \<hex encoded blockRoot with 0x prefix\>.
*/
getBlockHeader(blockId: BlockId): Promise<{data: BlockHeaderResponse}>;
/**
* Get block headers
* Retrieves block headers matching given query. By default it will fetch current head slot blocks.
* @param slot
* @param parentRoot
*/
getBlockHeaders(filters: Partial<{slot: Slot; parentRoot: string}>): Promise<{data: BlockHeaderResponse[]}>;
/**
* Get block root
* Retrieves hashTreeRoot of BeaconBlock/BeaconBlockHeader
* @param blockId Block identifier.
* Can be one of: "head" (canonical head in node's view), "genesis", "finalized", \<slot\>, \<hex encoded blockRoot with 0x prefix\>.
*/
getBlockRoot(blockId: BlockId): Promise<{data: Root}>;
/**
* Publish a signed block.
* Instructs the beacon node to broadcast a newly signed beacon block to the beacon network,
* to be included in the beacon chain. The beacon node is not required to validate the signed
* `BeaconBlock`, and a successful response (20X) only indicates that the broadcast has been
* successful. The beacon node is expected to integrate the new block into its state, and
* therefore validate the block internally, however blocks which fail the validation are still
* broadcast but a different status code is returned (202)
*
* @param requestBody The `SignedBeaconBlock` object composed of `BeaconBlock` object (produced by beacon node) and validator signature.
* @returns any The block was validated successfully and has been broadcast. It has also been integrated into the beacon node's database.
*/
publishBlock(block: allForks.SignedBeaconBlock): Promise<void>;
};
/**
* Define javascript values for each route
*/
export const routesData: RoutesData<Api> = {
getBlock: {url: "/eth/v1/beacon/blocks/:blockId", method: "GET"},
getBlockV2: {url: "/eth/v2/beacon/blocks/:blockId", method: "GET"},
getBlockAttestations: {url: "/eth/v1/beacon/blocks/:blockId/attestations", method: "GET"},
getBlockHeader: {url: "/eth/v1/beacon/headers/:blockId", method: "GET"},
getBlockHeaders: {url: "/eth/v1/beacon/headers", method: "GET"},
getBlockRoot: {url: "/eth/v1/beacon/blocks/:blockId/root", method: "GET"},
publishBlock: {url: "/eth/v1/beacon/blocks", method: "POST"},
};
type BlockIdOnlyReq = {params: {blockId: string | number}};
/* eslint-disable @typescript-eslint/naming-convention */
export type ReqTypes = {
getBlock: BlockIdOnlyReq;
getBlockV2: BlockIdOnlyReq;
getBlockAttestations: BlockIdOnlyReq;
getBlockHeader: BlockIdOnlyReq;
getBlockHeaders: {query: {slot?: number; parent_root?: string}};
getBlockRoot: BlockIdOnlyReq;
publishBlock: {body: Json};
};
export function getReqSerializers(config: IBeaconConfig): ReqSerializers<Api, ReqTypes> {
const blockIdOnlyReq: ReqSerializer<Api["getBlock"], BlockIdOnlyReq> = {
writeReq: (blockId) => ({params: {blockId}}),
parseReq: ({params}) => [params.blockId],
schema: {params: {blockId: Schema.StringRequired}},
};
// Compute block type from JSON payload. See https://github.com/ethereum/eth2.0-APIs/pull/142
const getSignedBeaconBlockType = (data: allForks.SignedBeaconBlock): ContainerType<allForks.SignedBeaconBlock> =>
config.getForkTypes(data.message.slot).SignedBeaconBlock;
const AllForksSignedBeaconBlock: TypeJson<allForks.SignedBeaconBlock> = {
toJson: (data, opts) => getSignedBeaconBlockType(data).toJson(data, opts),
fromJson: (data, opts) =>
getSignedBeaconBlockType((data as unknown) as allForks.SignedBeaconBlock).fromJson(data, opts),
};
return {
getBlock: blockIdOnlyReq,
getBlockV2: blockIdOnlyReq,
getBlockAttestations: blockIdOnlyReq,
getBlockHeader: blockIdOnlyReq,
getBlockHeaders: {
writeReq: (filters) => ({query: {slot: filters?.slot, parent_root: filters?.parentRoot}}),
parseReq: ({query}) => [{slot: query?.slot, parentRoot: query?.parent_root}],
schema: {query: {slot: Schema.Uint, parent_root: Schema.String}},
},
getBlockRoot: blockIdOnlyReq,
publishBlock: reqOnlyBody(AllForksSignedBeaconBlock, Schema.Object),
};
}
export function getReturnTypes(config: IBeaconConfig): ReturnTypes<Api> {
const BeaconHeaderResType = new ContainerType<BlockHeaderResponse>({
fields: {
root: config.types.Root,
canonical: config.types.Boolean,
header: config.types.phase0.SignedBeaconBlockHeader,
},
});
return {
getBlock: ContainerData(config.types.phase0.SignedBeaconBlock),
getBlockV2: WithVersion((fork) => config.types[fork].SignedBeaconBlock),
getBlockAttestations: ContainerData(ArrayOf(config.types.phase0.Attestation)),
getBlockHeader: ContainerData(BeaconHeaderResType),
getBlockHeaders: ContainerData(ArrayOf(BeaconHeaderResType)),
getBlockRoot: ContainerData(config.types.Root),
};
}

View File

@@ -0,0 +1,63 @@
import {IBeaconConfig} from "@chainsafe/lodestar-config";
import {phase0} from "@chainsafe/lodestar-types";
import {RoutesData, ReturnTypes, reqEmpty, ContainerData} from "../../utils";
import * as block from "./block";
import * as pool from "./pool";
import * as state from "./state";
// See /packages/api/src/routes/index.ts for reasoning and instructions to add new routes
// NOTE: We choose to split the block, pool, and state namespaces so the files are not too big.
// However, for a consumer all these methods are within the same service "beacon"
export {BlockId, BlockHeaderResponse} from "./block";
export {AttestationFilters} from "./pool";
// TODO: Review if re-exporting all these types is necessary
export {
StateId,
ValidatorId,
ValidatorStatus,
ValidatorFilters,
CommitteesFilters,
FinalityCheckpoints,
ValidatorResponse,
ValidatorBalance,
EpochCommitteeResponse,
EpochSyncCommitteeResponse,
} from "./state";
export type Api = block.Api &
pool.Api &
state.Api & {
getGenesis(): Promise<{data: phase0.Genesis}>;
};
export const routesData: RoutesData<Api> = {
getGenesis: {url: "/eth/v1/beacon/genesis", method: "GET"},
...block.routesData,
...pool.routesData,
...state.routesData,
};
export type ReqTypes = {
[K in keyof ReturnType<typeof getReqSerializers>]: ReturnType<ReturnType<typeof getReqSerializers>[K]["writeReq"]>;
};
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, @typescript-eslint/explicit-function-return-type
export function getReqSerializers(config: IBeaconConfig) {
return {
getGenesis: reqEmpty,
...block.getReqSerializers(config),
...pool.getReqSerializers(config),
...state.getReqSerializers(),
};
}
export function getReturnTypes(config: IBeaconConfig): ReturnTypes<Api> {
return {
getGenesis: ContainerData(config.types.phase0.Genesis),
...block.getReturnTypes(config),
...pool.getReturnTypes(config),
...state.getReturnTypes(config),
};
}

View File

@@ -0,0 +1,161 @@
import {IBeaconConfig} from "@chainsafe/lodestar-config";
import {phase0, altair, CommitteeIndex, Slot} from "@chainsafe/lodestar-types";
import {Json} from "@chainsafe/ssz";
import {
RoutesData,
ReturnTypes,
ArrayOf,
ContainerData,
Schema,
reqOnlyBody,
ReqSerializers,
reqEmpty,
ReqEmpty,
} from "../../utils";
// See /packages/api/src/routes/index.ts for reasoning and instructions to add new routes
export type AttestationFilters = {
slot: Slot;
committeeIndex: CommitteeIndex;
};
export type Api = {
/**
* Get Attestations from operations pool
* Retrieves attestations known by the node but not necessarily incorporated into any block
* @param slot
* @param committeeIndex
* @returns any Successful response
* @throws ApiError
*/
getPoolAttestations(filters?: Partial<AttestationFilters>): Promise<{data: phase0.Attestation[]}>;
/**
* Get AttesterSlashings from operations pool
* Retrieves attester slashings known by the node but not necessarily incorporated into any block
* @returns any Successful response
* @throws ApiError
*/
getPoolAttesterSlashings(): Promise<{data: phase0.AttesterSlashing[]}>;
/**
* Get ProposerSlashings from operations pool
* Retrieves proposer slashings known by the node but not necessarily incorporated into any block
* @returns any Successful response
* @throws ApiError
*/
getPoolProposerSlashings(): Promise<{data: phase0.ProposerSlashing[]}>;
/**
* Get SignedVoluntaryExit from operations pool
* Retrieves voluntary exits known by the node but not necessarily incorporated into any block
* @returns any Successful response
* @throws ApiError
*/
getPoolVoluntaryExits(): Promise<{data: phase0.SignedVoluntaryExit[]}>;
/**
* Submit Attestation objects to node
* Submits Attestation objects to the node. Each attestation in the request body is processed individually.
*
* If an attestation is validated successfully the node MUST publish that attestation on the appropriate subnet.
*
* If one or more attestations fail validation the node MUST return a 400 error with details of which attestations have failed, and why.
*
* @param requestBody
* @returns any Attestations are stored in pool and broadcast on appropriate subnet
* @throws ApiError
*/
submitPoolAttestations(attestations: phase0.Attestation[]): Promise<void>;
/**
* Submit AttesterSlashing object to node's pool
* Submits AttesterSlashing object to node's pool and if passes validation node MUST broadcast it to network.
* @param requestBody
* @returns any Success
* @throws ApiError
*/
submitPoolAttesterSlashing(slashing: phase0.AttesterSlashing): Promise<void>;
/**
* Submit ProposerSlashing object to node's pool
* Submits ProposerSlashing object to node's pool and if passes validation node MUST broadcast it to network.
* @param requestBody
* @returns any Success
* @throws ApiError
*/
submitPoolProposerSlashing(slashing: phase0.ProposerSlashing): Promise<void>;
/**
* Submit SignedVoluntaryExit object to node's pool
* Submits SignedVoluntaryExit object to node's pool and if passes validation node MUST broadcast it to network.
* @param requestBody
* @returns any Voluntary exit is stored in node and broadcasted to network
* @throws ApiError
*/
submitPoolVoluntaryExit(exit: phase0.SignedVoluntaryExit): Promise<void>;
/**
* TODO: Add description
*/
submitPoolSyncCommitteeSignatures(signatures: altair.SyncCommitteeSignature[]): Promise<void>;
};
/**
* Define javascript values for each route
*/
export const routesData: RoutesData<Api> = {
getPoolAttestations: {url: "/eth/v1/beacon/pool/attestations", method: "GET"},
getPoolAttesterSlashings: {url: "/eth/v1/beacon/pool/attester_slashings", method: "GET"},
getPoolProposerSlashings: {url: "/eth/v1/beacon/pool/proposer_slashings", method: "GET"},
getPoolVoluntaryExits: {url: "/eth/v1/beacon/pool/voluntary_exits", method: "GET"},
submitPoolAttestations: {url: "/eth/v1/beacon/pool/attestations", method: "POST"},
submitPoolAttesterSlashing: {url: "/eth/v1/beacon/pool/attester_slashings", method: "POST"},
submitPoolProposerSlashing: {url: "/eth/v1/beacon/pool/proposer_slashings", method: "POST"},
submitPoolVoluntaryExit: {url: "/eth/v1/beacon/pool/voluntary_exits", method: "POST"},
submitPoolSyncCommitteeSignatures: {url: "/eth/v1/beacon/pool/sync_committees", method: "POST"},
};
/* eslint-disable @typescript-eslint/naming-convention */
export type ReqTypes = {
getPoolAttestations: {query: {slot?: number; committee_index?: number}};
getPoolAttesterSlashings: ReqEmpty;
getPoolProposerSlashings: ReqEmpty;
getPoolVoluntaryExits: ReqEmpty;
submitPoolAttestations: {body: Json};
submitPoolAttesterSlashing: {body: Json};
submitPoolProposerSlashing: {body: Json};
submitPoolVoluntaryExit: {body: Json};
submitPoolSyncCommitteeSignatures: {body: Json};
};
export function getReqSerializers(config: IBeaconConfig): ReqSerializers<Api, ReqTypes> {
return {
getPoolAttestations: {
writeReq: (filters) => ({query: {slot: filters?.slot, committee_index: filters?.committeeIndex}}),
parseReq: ({query}) => [{slot: query.slot, committeeIndex: query.committee_index}],
schema: {query: {slot: Schema.Uint, committee_index: Schema.Uint}},
},
getPoolAttesterSlashings: reqEmpty,
getPoolProposerSlashings: reqEmpty,
getPoolVoluntaryExits: reqEmpty,
submitPoolAttestations: reqOnlyBody(ArrayOf(config.types.phase0.Attestation), Schema.ObjectArray),
submitPoolAttesterSlashing: reqOnlyBody(config.types.phase0.AttesterSlashing, Schema.Object),
submitPoolProposerSlashing: reqOnlyBody(config.types.phase0.ProposerSlashing, Schema.Object),
submitPoolVoluntaryExit: reqOnlyBody(config.types.phase0.SignedVoluntaryExit, Schema.Object),
submitPoolSyncCommitteeSignatures: reqOnlyBody(
ArrayOf(config.types.altair.SyncCommitteeSignature),
Schema.ObjectArray
),
};
}
export function getReturnTypes(config: IBeaconConfig): ReturnTypes<Api> {
return {
getPoolAttestations: ContainerData(ArrayOf(config.types.phase0.Attestation)),
getPoolAttesterSlashings: ContainerData(ArrayOf(config.types.phase0.AttesterSlashing)),
getPoolProposerSlashings: ContainerData(ArrayOf(config.types.phase0.ProposerSlashing)),
getPoolVoluntaryExits: ContainerData(ArrayOf(config.types.phase0.SignedVoluntaryExit)),
};
}

View File

@@ -0,0 +1,279 @@
import {ContainerType} from "@chainsafe/ssz";
import {IBeaconConfig} from "@chainsafe/lodestar-config";
import {phase0, CommitteeIndex, Slot, ValidatorIndex, Epoch, Root, Gwei} from "@chainsafe/lodestar-types";
import {
RoutesData,
ReturnTypes,
ArrayOf,
ContainerData,
Schema,
StringType,
ReqSerializers,
ReqSerializer,
} from "../../utils";
// See /packages/api/src/routes/index.ts for reasoning and instructions to add new routes
export type StateId = string | "head" | "genesis" | "finalized" | "justified";
export type ValidatorId = string | number;
export type ValidatorStatus =
| "active"
| "pending_initialized"
| "pending_queued"
| "active_ongoing"
| "active_exiting"
| "active_slashed"
| "exited_unslashed"
| "exited_slashed"
| "withdrawal_possible"
| "withdrawal_done";
export type ValidatorFilters = {
indices?: ValidatorId[];
statuses?: ValidatorStatus[];
};
export type CommitteesFilters = {
epoch?: Epoch;
index?: CommitteeIndex;
slot?: Slot;
};
export type FinalityCheckpoints = {
previousJustified: phase0.Checkpoint;
currentJustified: phase0.Checkpoint;
finalized: phase0.Checkpoint;
};
export type ValidatorResponse = {
index: ValidatorIndex;
balance: Gwei;
status: ValidatorStatus;
validator: phase0.Validator;
};
export type ValidatorBalance = {
index: ValidatorIndex;
balance: Gwei;
};
export type EpochCommitteeResponse = {
index: CommitteeIndex;
slot: Slot;
validators: ValidatorIndex[];
};
export type EpochSyncCommitteeResponse = {
/** all of the validator indices in the current sync committee */
validators: ValidatorIndex[];
// TODO: This property will likely be deprecated
/** Subcommittee slices of the current sync committee */
validatorAggregates: ValidatorIndex[];
};
export type Api = {
/**
* Get state SSZ HashTreeRoot
* Calculates HashTreeRoot for state with given 'stateId'. If stateId is root, same value will be returned.
*
* @param stateId State identifier.
* Can be one of: "head" (canonical head in node's view), "genesis", "finalized", "justified", \<slot\>, \<hex encoded stateRoot with 0x prefix\>.
*/
getStateRoot(stateId: StateId): Promise<{data: Root}>;
/**
* Get Fork object for requested state
* Returns [Fork](https://github.com/ethereum/eth2.0-specs/blob/v0.11.1/specs/phase0/beacon-chain.md#fork) object for state with given 'stateId'.
* @param stateId State identifier.
* Can be one of: "head" (canonical head in node's view), "genesis", "finalized", "justified", \<slot\>, \<hex encoded stateRoot with 0x prefix\>.
*/
getStateFork(stateId: StateId): Promise<{data: phase0.Fork}>;
/**
* Get state finality checkpoints
* Returns finality checkpoints for state with given 'stateId'.
* In case finality is not yet achieved, checkpoint should return epoch 0 and ZERO_HASH as root.
* @param stateId State identifier.
* Can be one of: "head" (canonical head in node's view), "genesis", "finalized", "justified", \<slot\>, \<hex encoded stateRoot with 0x prefix\>.
*/
getStateFinalityCheckpoints(stateId: StateId): Promise<{data: FinalityCheckpoints}>;
/**
* Get validators from state
* Returns filterable list of validators with their balance, status and index.
* @param stateId State identifier.
* Can be one of: "head" (canonical head in node's view), "genesis", "finalized", "justified", \<slot\>, \<hex encoded stateRoot with 0x prefix\>.
* @param id Either hex encoded public key (with 0x prefix) or validator index
* @param status [Validator status specification](https://hackmd.io/ofFJ5gOmQpu1jjHilHbdQQ)
*/
getStateValidators(stateId: StateId, filters?: ValidatorFilters): Promise<{data: ValidatorResponse[]}>;
/**
* Get validator from state by id
* Returns validator specified by state and id or public key along with status and balance.
* @param stateId State identifier.
* Can be one of: "head" (canonical head in node's view), "genesis", "finalized", "justified", \<slot\>, \<hex encoded stateRoot with 0x prefix\>.
* @param validatorId Either hex encoded public key (with 0x prefix) or validator index
*/
getStateValidator(stateId: StateId, validatorId: ValidatorId): Promise<{data: ValidatorResponse}>;
/**
* Get validator balances from state
* Returns filterable list of validator balances.
* @param stateId State identifier.
* Can be one of: "head" (canonical head in node's view), "genesis", "finalized", "justified", \<slot\>, \<hex encoded stateRoot with 0x prefix\>.
* @param id Either hex encoded public key (with 0x prefix) or validator index
*/
getStateValidatorBalances(stateId: StateId, indices?: ValidatorId[]): Promise<{data: ValidatorBalance[]}>;
/**
* Get all committees for a state.
* Retrieves the committees for the given state.
* @param stateId State identifier.
* Can be one of: "head" (canonical head in node's view), "genesis", "finalized", "justified", \<slot\>, \<hex encoded stateRoot with 0x prefix\>.
* @param epoch Fetch committees for the given epoch. If not present then the committees for the epoch of the state will be obtained.
* @param index Restrict returned values to those matching the supplied committee index.
* @param slot Restrict returned values to those matching the supplied slot.
*/
getEpochCommittees(stateId: StateId, filters?: CommitteesFilters): Promise<{data: EpochCommitteeResponse[]}>;
getEpochSyncCommittees(stateId: StateId, epoch?: Epoch): Promise<{data: EpochSyncCommitteeResponse}>;
};
/**
* Define javascript values for each route
*/
export const routesData: RoutesData<Api> = {
getEpochCommittees: {url: "/eth/v1/beacon/states/:stateId/committees", method: "GET"},
getEpochSyncCommittees: {url: "/eth/v1/beacon/states/:stateId/sync_committees", method: "GET"},
getStateFinalityCheckpoints: {url: "/eth/v1/beacon/states/:stateId/finality_checkpoints", method: "GET"},
getStateFork: {url: "/eth/v1/beacon/states/:stateId/fork", method: "GET"},
getStateRoot: {url: "/eth/v1/beacon/states/:stateId/root", method: "GET"},
getStateValidator: {url: "/eth/v1/beacon/states/:stateId/validators/:validatorId", method: "GET"},
getStateValidators: {url: "/eth/v1/beacon/states/:stateId/validator_balances", method: "GET"},
getStateValidatorBalances: {url: "/eth/v1/beacon/states/:stateId/validators", method: "GET"},
};
type StateIdOnlyReq = {params: {stateId: string}};
export type ReqTypes = {
getEpochCommittees: {params: {stateId: StateId}; query: {slot?: number; epoch?: number; index?: number}};
getEpochSyncCommittees: {params: {stateId: StateId}; query: {epoch?: number}};
getStateFinalityCheckpoints: StateIdOnlyReq;
getStateFork: StateIdOnlyReq;
getStateRoot: StateIdOnlyReq;
getStateValidator: {params: {stateId: StateId; validatorId: ValidatorId}};
getStateValidators: {params: {stateId: StateId}; query: {indices?: ValidatorId[]; statuses?: ValidatorStatus[]}};
getStateValidatorBalances: {params: {stateId: StateId}; query: {indices?: ValidatorId[]}};
};
export function getReqSerializers(): ReqSerializers<Api, ReqTypes> {
const stateIdOnlyReq: ReqSerializer<Api["getStateFork"], StateIdOnlyReq> = {
writeReq: (stateId) => ({params: {stateId}}),
parseReq: ({params}) => [params.stateId],
schema: {params: {stateId: Schema.StringRequired}},
};
return {
getEpochCommittees: {
writeReq: (stateId, filters) => ({params: {stateId}, query: filters || {}}),
parseReq: ({params, query}) => [params.stateId, query],
schema: {
params: {stateId: Schema.StringRequired},
query: {slot: Schema.Uint, epoch: Schema.Uint, index: Schema.Uint},
},
},
getEpochSyncCommittees: {
writeReq: (stateId, epoch) => ({params: {stateId}, query: {epoch}}),
parseReq: ({params, query}) => [params.stateId, query.epoch],
schema: {
params: {stateId: Schema.StringRequired},
query: {epoch: Schema.Uint},
},
},
getStateFinalityCheckpoints: stateIdOnlyReq,
getStateFork: stateIdOnlyReq,
getStateRoot: stateIdOnlyReq,
getStateValidator: {
writeReq: (stateId, validatorId) => ({params: {stateId, validatorId}}),
parseReq: ({params}) => [params.stateId, params.validatorId],
schema: {
params: {stateId: Schema.StringRequired, validatorId: Schema.StringRequired},
},
},
getStateValidators: {
writeReq: (stateId, filters) => ({params: {stateId}, query: filters || {}}),
parseReq: ({params, query}) => [params.stateId, query],
schema: {
params: {stateId: Schema.StringRequired},
query: {indices: Schema.UintOrStringArray, statuses: Schema.StringArray},
},
},
getStateValidatorBalances: {
writeReq: (stateId, indices) => ({params: {stateId}, query: {indices}}),
parseReq: ({params, query}) => [params.stateId, query.indices],
schema: {
params: {stateId: Schema.StringRequired},
query: {indices: Schema.UintOrStringArray},
},
},
};
}
/* eslint-disable @typescript-eslint/naming-convention */
export function getReturnTypes(config: IBeaconConfig): ReturnTypes<Api> {
const FinalityCheckpoints = new ContainerType<FinalityCheckpoints>({
fields: {
previousJustified: config.types.phase0.Checkpoint,
currentJustified: config.types.phase0.Checkpoint,
finalized: config.types.phase0.Checkpoint,
},
});
const ValidatorResponse = new ContainerType<ValidatorResponse>({
fields: {
index: config.types.ValidatorIndex,
balance: config.types.Gwei,
status: new StringType<ValidatorStatus>(),
validator: config.types.phase0.Validator,
},
});
const ValidatorBalance = new ContainerType<ValidatorBalance>({
fields: {
index: config.types.ValidatorIndex,
balance: config.types.Gwei,
},
});
const EpochCommitteeResponse = new ContainerType<EpochCommitteeResponse>({
fields: {
index: config.types.CommitteeIndex,
slot: config.types.Slot,
validators: config.types.phase0.CommitteeIndices,
},
});
const EpochSyncCommitteesResponse = new ContainerType<EpochSyncCommitteeResponse>({
fields: {
validators: ArrayOf(config.types.ValidatorIndex),
validatorAggregates: ArrayOf(config.types.ValidatorIndex),
},
});
return {
getStateRoot: ContainerData(config.types.Root),
getStateFork: ContainerData(config.types.phase0.Fork),
getStateFinalityCheckpoints: ContainerData(FinalityCheckpoints),
getStateValidators: ContainerData(ArrayOf(ValidatorResponse)),
getStateValidator: ContainerData(ValidatorResponse),
getStateValidatorBalances: ContainerData(ArrayOf(ValidatorBalance)),
getEpochCommittees: ContainerData(ArrayOf(EpochCommitteeResponse)),
getEpochSyncCommittees: ContainerData(EpochSyncCommitteesResponse),
};
}

View File

@@ -0,0 +1,69 @@
import {IBeaconConfig} from "@chainsafe/lodestar-config";
import {IBeaconParams, BeaconParams} from "@chainsafe/lodestar-params";
import {Bytes32, Number64, phase0} from "@chainsafe/lodestar-types";
import {mapValues} from "@chainsafe/lodestar-utils";
import {ByteVectorType, ContainerType} from "@chainsafe/ssz";
import {ArrayOf, ContainerData, ReqEmpty, reqEmpty, ReturnTypes, ReqSerializers, RoutesData} from "../utils";
// See /packages/api/src/routes/index.ts for reasoning and instructions to add new routes
export type DepositContract = {
chainId: Number64;
address: Bytes32;
};
export type Api = {
/**
* Get deposit contract address.
* Retrieve Eth1 deposit contract address and chain ID.
*/
getDepositContract(): Promise<{data: DepositContract}>;
/**
* Get scheduled upcoming forks.
* Retrieve all scheduled upcoming forks this node is aware of.
*/
getForkSchedule(): Promise<{data: phase0.Fork[]}>;
/**
* Get spec params.
* Retrieve specification configuration used on this node.
* [Specification params list](https://github.com/ethereum/eth2.0-specs/blob/v1.0.0-rc.0/configs/mainnet/phase0.yaml)
*
* Values are returned with following format:
* - any value starting with 0x in the spec is returned as a hex string
* - numeric values are returned as a quoted integer
*/
getSpec(): Promise<{data: IBeaconParams}>;
};
/**
* Define javascript values for each route
*/
export const routesData: RoutesData<Api> = {
getDepositContract: {url: "/eth/v1/config/deposit_contract", method: "GET"},
getForkSchedule: {url: "/eth/v1/config/fork_schedule", method: "GET"},
getSpec: {url: "/eth/v1/config/spec", method: "GET"},
};
export type ReqTypes = {[K in keyof Api]: ReqEmpty};
export function getReqSerializers(): ReqSerializers<Api, ReqTypes> {
return mapValues(routesData, () => reqEmpty);
}
/* eslint-disable @typescript-eslint/naming-convention */
export function getReturnTypes(config: IBeaconConfig): ReturnTypes<Api> {
const DepositContract = new ContainerType<DepositContract>({
fields: {
chainId: config.types.Number64,
address: new ByteVectorType({length: 20}),
},
});
return {
getDepositContract: ContainerData(DepositContract),
getForkSchedule: ContainerData(ArrayOf(config.types.phase0.Fork)),
getSpec: ContainerData(BeaconParams),
};
}

View File

@@ -0,0 +1,117 @@
import {ForkName, IBeaconConfig} from "@chainsafe/lodestar-config";
import {allForks, Slot, Root} from "@chainsafe/lodestar-types";
import {ContainerType} from "@chainsafe/ssz";
import {StateId} from "./beacon/state";
import {
ArrayOf,
ContainerData,
ReturnTypes,
RoutesData,
Schema,
WithVersion,
TypeJson,
reqEmpty,
ReqSerializers,
ReqEmpty,
ReqSerializer,
} from "../utils";
// See /packages/api/src/routes/index.ts for reasoning and instructions to add new routes
type SlotRoot = {slot: Slot; root: Root};
export type Api = {
/**
* Get fork choice leaves
* Retrieves all possible chain heads (leaves of fork choice tree).
*/
getHeads(): Promise<{data: SlotRoot[]}>;
/**
* Get full BeaconState object
* Returns full BeaconState object for given stateId.
* Depending on `Accept` header it can be returned either as json or as bytes serialized by SSZ
*
* @param stateId State identifier.
* Can be one of: "head" (canonical head in node's view), "genesis", "finalized", "justified", \<slot\>, \<hex encoded stateRoot with 0x prefix\>.
*/
getState(stateId: StateId): Promise<{data: allForks.BeaconState}>;
/**
* Get full BeaconState object
* Returns full BeaconState object for given stateId.
* Depending on `Accept` header it can be returned either as json or as bytes serialized by SSZ
*
* @param stateId State identifier.
* Can be one of: "head" (canonical head in node's view), "genesis", "finalized", "justified", \<slot\>, \<hex encoded stateRoot with 0x prefix\>.
*/
getStateV2(stateId: StateId): Promise<{data: allForks.BeaconState; version: ForkName}>;
/**
* NOT IN SPEC
* Connect to a peer at the given multiaddr array
*/
connectToPeer(peerIdStr: string, multiaddr: string[]): Promise<void>;
/**
* NOT IN SPEC
* Disconnect from a peer
*/
disconnectPeer(peerIdStr: string): Promise<void>;
};
export const routesData: RoutesData<Api> = {
getHeads: {url: "/eth/v1/debug/beacon/heads", method: "GET"},
getState: {url: "/eth/v1/debug/beacon/states/:stateId", method: "GET"},
getStateV2: {url: "/eth/v2/debug/beacon/states/:stateId", method: "GET"},
connectToPeer: {url: "/eth/v1/debug/connect/:peerId", method: "POST"},
disconnectPeer: {url: "/eth/v1/debug/disconnect/:peerId", method: "POST"},
};
export type ReqTypes = {
getHeads: ReqEmpty;
getState: {params: {stateId: string}};
getStateV2: {params: {stateId: string}};
connectToPeer: {params: {peerId: string}; body: string[]};
disconnectPeer: {params: {peerId: string}};
};
export function getReqSerializers(): ReqSerializers<Api, ReqTypes> {
const getState: ReqSerializer<Api["getState"], ReqTypes["getState"]> = {
writeReq: (stateId) => ({params: {stateId}}),
parseReq: ({params}) => [params.stateId],
schema: {params: {stateId: Schema.StringRequired}},
};
return {
getHeads: reqEmpty,
getState: getState,
getStateV2: getState,
connectToPeer: {
writeReq: (peerId, multiaddr) => ({params: {peerId}, body: multiaddr}),
parseReq: ({params, body}) => [params.peerId, body],
schema: {params: {peerId: Schema.StringRequired}, body: Schema.StringArray},
},
disconnectPeer: {
writeReq: (peerId) => ({params: {peerId}}),
parseReq: ({params}) => [params.peerId],
schema: {params: {peerId: Schema.StringRequired}},
},
};
}
/* eslint-disable @typescript-eslint/naming-convention */
export function getReturnTypes(config: IBeaconConfig): ReturnTypes<Api> {
const SlotRoot = new ContainerType<SlotRoot>({
fields: {
slot: config.types.Slot,
root: config.types.Root,
},
});
return {
getHeads: ContainerData(ArrayOf(SlotRoot)),
getState: ContainerData(config.types.phase0.BeaconState),
getStateV2: WithVersion((fork) => config.types[fork].BeaconState as TypeJson<allForks.BeaconState>),
};
}

View File

@@ -0,0 +1,139 @@
import {IBeaconConfig} from "@chainsafe/lodestar-config";
import {Epoch, Number64, phase0, Slot, Root} from "@chainsafe/lodestar-types";
import {ContainerType, Json, Type} from "@chainsafe/ssz";
import {jsonOpts, RouteDef, TypeJson} from "../utils";
// See /packages/api/src/routes/index.ts for reasoning and instructions to add new routes
export enum EventType {
/**
* The node has finished processing, resulting in a new head. previous_duty_dependent_root is
* `get_block_root_at_slot(state, compute_start_slot_at_epoch(epoch - 1) - 1)` and
* current_duty_dependent_root is `get_block_root_at_slot(state, compute_start_slot_at_epoch(epoch) - 1)`.
* Both dependent roots use the genesis block root in the case of underflow.
*/
head = "head",
/** The node has received a valid block (from P2P or API) */
block = "block",
/** The node has received a valid attestation (from P2P or API) */
attestation = "attestation",
/** The node has received a valid voluntary exit (from P2P or API) */
voluntaryExit = "voluntary_exit",
/** Finalized checkpoint has been updated */
finalizedCheckpoint = "finalized_checkpoint",
/** The node has reorganized its chain */
chainReorg = "chain_reorg",
}
export type EventData = {
[EventType.head]: {
slot: Slot;
block: Root;
state: Root;
epochTransition: boolean;
previousDutyDependentRoot: Root;
currentDutyDependentRoot: Root;
};
[EventType.block]: {slot: Slot; block: Root};
[EventType.attestation]: phase0.Attestation;
[EventType.voluntaryExit]: phase0.SignedVoluntaryExit;
[EventType.finalizedCheckpoint]: {block: Root; state: Root; epoch: Epoch};
[EventType.chainReorg]: {
slot: Slot;
depth: Number64;
oldHeadBlock: Root;
newHeadBlock: Root;
oldHeadState: Root;
newHeadState: Root;
epoch: Epoch;
};
};
export type BeaconEvent = {[K in EventType]: {type: K; message: EventData[K]}}[EventType];
export type Api = {
/**
* Subscribe to beacon node events
* Provides endpoint to subscribe to beacon node Server-Sent-Events stream.
* Consumers should use [eventsource](https://html.spec.whatwg.org/multipage/server-sent-events.html#the-eventsource-interface)
* implementation to listen on those events.
*
* @param topics Event types to subscribe to
* @returns Opened SSE stream.
*/
eventstream(topics: EventType[], signal: AbortSignal, onEvent: (event: BeaconEvent) => void): void;
};
export const routesData: {[K in keyof Api]: RouteDef} = {
eventstream: {url: "/eth/v1/events", method: "GET"},
};
export type ReqTypes = {
eventstream: {
query: {topics: EventType[]};
};
};
// It doesn't make sense to define a getReqSerializers() here given the exotic argument of eventstream()
// The request is very simple: (topics) => {query: {topics}}, and the test will ensure compatibility server - client
export function getTypeByEvent(config: IBeaconConfig): {[K in EventType]: Type<EventData[K]>} {
return {
[EventType.head]: new ContainerType<EventData[EventType.head]>({
fields: {
slot: config.types.Slot,
block: config.types.Root,
state: config.types.Root,
epochTransition: config.types.Boolean,
previousDutyDependentRoot: config.types.Root,
currentDutyDependentRoot: config.types.Root,
},
}),
[EventType.block]: new ContainerType<EventData[EventType.block]>({
fields: {
slot: config.types.Slot,
block: config.types.Root,
},
}),
[EventType.attestation]: config.types.phase0.Attestation,
[EventType.voluntaryExit]: config.types.phase0.SignedVoluntaryExit,
[EventType.finalizedCheckpoint]: new ContainerType<EventData[EventType.finalizedCheckpoint]>({
fields: {
block: config.types.Root,
state: config.types.Root,
epoch: config.types.Epoch,
},
}),
[EventType.chainReorg]: new ContainerType<EventData[EventType.chainReorg]>({
fields: {
slot: config.types.Slot,
depth: config.types.Number64,
oldHeadBlock: config.types.Root,
newHeadBlock: config.types.Root,
oldHeadState: config.types.Root,
newHeadState: config.types.Root,
epoch: config.types.Epoch,
},
}),
};
}
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, @typescript-eslint/explicit-function-return-type
export function getEventSerdes(config: IBeaconConfig) {
const typeByEvent = getTypeByEvent(config);
return {
toJson: (event: BeaconEvent): Json => {
const eventType = typeByEvent[event.type] as TypeJson<BeaconEvent["message"]>;
return eventType.toJson(event.message, jsonOpts);
},
fromJson: (type: EventType, data: Json): BeaconEvent["message"] => {
const eventType = typeByEvent[type] as TypeJson<BeaconEvent["message"]>;
return eventType.fromJson(data, jsonOpts);
},
};
}

View File

@@ -0,0 +1,47 @@
export * as beacon from "./beacon";
export * as config from "./config";
export * as debug from "./debug";
export * as events from "./events";
export * as lightclient from "./lightclient";
export * as lodestar from "./lodestar";
export * as node from "./node";
export * as validator from "./validator";
// Reasoning of the API definitions
// ================================
//
// An HTTP request to the Lodestar BeaconNode API involves these steps regarding serialization:
// 1. Serialize request: api args => req params
// --- wire
// 2. Deserialize request: req params => api args
// --- exec api
// 3. Serialize api return => res body
// --- wire
// 4. Deserialize res body => api return
//
// In our case we define the client in the exact same interface as the API executor layer.
// Therefore we only need to define how to translate args <-> request, and return <-> response.
//
// All files in the /routes directory provide succint definitions to do those transformations plus:
// - URL + method, for each route ID
// - Runtime schema, for each route ID
//
// Almost all routes receive JSON and return JSON. So both the client and the server can be
// auto-generated from the definitions. Also, the design allows for customizability for the few
// routes that need non-JSON serialization (like debug.getState and lightclient.getProof)
//
// With this approach Typescript help us ensure that the client and server are compatible at build
// time, ensure there are tests for all routes and makes it very cheap to mantain and add new routes.
//
//
// How to add new routes
// =====================
//
// 1. Add the route function signature to the `Api` type. The function name MUST match the routeId from the spec.
// The arguments should use spec types if approapriate. Non-spec types MUST be defined in before the Api type
// so they are scoped by routes namespace. The all arguments MUST use camelCase casing.
// 2. Add URL + METHOD in `routesData` matching the spec.
// 3. Declare request serializers in `getReqSerializers()`. You MAY use `RouteReqTypeGenerator` to declare the
// ReqTypes and request serializers in the same place.
// 4. Add the return type of the route to `getReturnTypes()` if it has any. The return type doesn't have to be
// a full SSZ type, but just a TypeJson with allows to convert from struct -> json -> struct.

View File

@@ -0,0 +1,74 @@
import {IBeaconConfig} from "@chainsafe/lodestar-config";
import {Path} from "@chainsafe/ssz";
import {Proof} from "@chainsafe/persistent-merkle-tree";
import {altair, SyncPeriod} from "@chainsafe/lodestar-types";
import {
ArrayOf,
reqEmpty,
ReturnTypes,
RoutesData,
Schema,
sameType,
ContainerData,
ReqSerializers,
ReqEmpty,
} from "../utils";
// See /packages/api/src/routes/index.ts for reasoning and instructions to add new routes
export type Api = {
/** TODO: description */
getStateProof(stateId: string, paths: Path[]): Promise<{data: Proof}>;
/** TODO: description */
getBestUpdates(from: SyncPeriod, to: SyncPeriod): Promise<{data: altair.LightClientUpdate[]}>;
/** TODO: description */
getLatestUpdateFinalized(): Promise<{data: altair.LightClientUpdate}>;
/** TODO: description */
getLatestUpdateNonFinalized(): Promise<{data: altair.LightClientUpdate}>;
};
/**
* Define javascript values for each route
*/
export const routesData: RoutesData<Api> = {
getStateProof: {url: "/eth/v1/lightclient/proof/:stateId", method: "POST"},
getBestUpdates: {url: "/eth/v1/lightclient/best_updates/:periods", method: "GET"},
getLatestUpdateFinalized: {url: "/eth/v1/lightclient/latest_update_finalized/", method: "GET"},
getLatestUpdateNonFinalized: {url: "/eth/v1/lightclient/latest_update_nonfinalized/", method: "GET"},
};
export type ReqTypes = {
getStateProof: {params: {stateId: string}; body: Path[]};
getBestUpdates: {query: {from: number; to: number}};
getLatestUpdateFinalized: ReqEmpty;
getLatestUpdateNonFinalized: ReqEmpty;
};
export function getReqSerializers(): ReqSerializers<Api, ReqTypes> {
return {
getStateProof: {
writeReq: (stateId, paths) => ({params: {stateId}, body: paths}),
parseReq: ({params, body}) => [params.stateId, body],
schema: {params: {stateId: Schema.StringRequired}, body: Schema.AnyArray},
},
getBestUpdates: {
writeReq: (from, to) => ({query: {from, to}}),
parseReq: ({query}) => [query.from, query.to],
schema: {query: {from: Schema.UintRequired, to: Schema.UintRequired}},
},
getLatestUpdateFinalized: reqEmpty,
getLatestUpdateNonFinalized: reqEmpty,
};
}
export function getReturnTypes(config: IBeaconConfig): ReturnTypes<Api> {
return {
// Just sent the proof JSON as-is
getStateProof: sameType(),
getBestUpdates: ContainerData(ArrayOf(config.types.altair.LightClientUpdate)),
getLatestUpdateFinalized: ContainerData(config.types.altair.LightClientUpdate),
getLatestUpdateNonFinalized: ContainerData(config.types.altair.LightClientUpdate),
};
}

View File

@@ -0,0 +1,48 @@
import {Epoch} from "@chainsafe/lodestar-types";
import {mapValues} from "@chainsafe/lodestar-utils";
import {jsonType, ReqEmpty, reqEmpty, ReturnTypes, ReqSerializers, RoutesData, sameType} from "../utils";
// See /packages/api/src/routes/index.ts for reasoning and instructions to add new routes
export type SyncChainDebugState = {
targetRoot: string | null;
targetSlot: number | null;
syncType: string;
status: string;
startEpoch: number;
peers: number;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
batches: any[];
};
export type Api = {
/** TODO: description */
getWtfNode(): Promise<{data: string}>;
/** TODO: description */
getLatestWeakSubjectivityCheckpointEpoch(): Promise<{data: Epoch}>;
/** TODO: description */
getSyncChainsDebugState(): Promise<{data: SyncChainDebugState[]}>;
};
/**
* Define javascript values for each route
*/
export const routesData: RoutesData<Api> = {
getWtfNode: {url: "/eth/v1/lodestar/wtfnode/", method: "GET"},
getLatestWeakSubjectivityCheckpointEpoch: {url: "/eth/v1/lodestar/ws_epoch/", method: "GET"},
getSyncChainsDebugState: {url: "/eth/v1/lodestar/sync-chains-debug-state", method: "GET"},
};
export type ReqTypes = {[K in keyof Api]: ReqEmpty};
export function getReqSerializers(): ReqSerializers<Api, ReqTypes> {
return mapValues(routesData, () => reqEmpty);
}
export function getReturnTypes(): ReturnTypes<Api> {
return {
getWtfNode: sameType(),
getLatestWeakSubjectivityCheckpointEpoch: sameType(),
getSyncChainsDebugState: jsonType(),
};
}

View File

@@ -0,0 +1,179 @@
import {IBeaconConfig} from "@chainsafe/lodestar-config";
import {allForks, Slot} from "@chainsafe/lodestar-types";
import {ContainerType} from "@chainsafe/ssz";
import {
ArrayOf,
ContainerData,
reqEmpty,
jsonType,
ReturnTypes,
RoutesData,
Schema,
StringType,
ReqSerializers,
ReqEmpty,
} from "../utils";
// See /packages/api/src/routes/index.ts for reasoning and instructions to add new routes
export type NetworkIdentity = {
/** Cryptographic hash of a peers public key. [Read more](https://docs.libp2p.io/concepts/peer-id/) */
peerId: string;
/** Ethereum node record. [Read more](https://eips.ethereum.org/EIPS/eip-778) */
enr: string;
p2pAddresses: string[];
discoveryAddresses: string[];
/** Based on eth2 [Metadata object](https://github.com/ethereum/eth2.0-specs/blob/v1.0.1/specs/phase0/p2p-interface.md#metadata) */
metadata: allForks.Metadata;
};
export type PeerState = "disconnected" | "connecting" | "connected" | "disconnecting";
export type PeerDirection = "inbound" | "outbound";
export type NodePeer = {
peerId: string;
enr: string;
lastSeenP2pAddress: string;
state: PeerState;
// the spec does not specify direction for a disconnected peer, lodestar uses null in that case
direction: PeerDirection | null;
};
export type PeerCount = {
disconnected: number;
connecting: number;
connected: number;
disconnecting: number;
};
export type FilterGetPeers = {
state?: PeerState[];
direction?: PeerDirection[];
};
export type SyncingStatus = {
/** Head slot node is trying to reach */
headSlot: Slot;
/** How many slots node needs to process to reach head. 0 if synced. */
syncDistance: Slot;
};
/**
* Read information about the beacon node.
*/
export type Api = {
/**
* Get node network identity
* Retrieves data about the node's network presence
*/
getNetworkIdentity(): Promise<{data: NetworkIdentity}>;
/**
* Get node network peers
* Retrieves data about the node's network peers. By default this returns all peers. Multiple query params are combined using AND conditions
* @param state
* @param direction
*/
getPeers(filters?: FilterGetPeers): Promise<{data: NodePeer[]; meta: {count: number}}>;
/**
* Get peer
* Retrieves data about the given peer
* @param peerId
*/
getPeer(peerId: string): Promise<{data: NodePeer}>;
/**
* Get peer count
* Retrieves number of known peers.
*/
getPeerCount(): Promise<{data: PeerCount}>;
/**
* Get version string of the running beacon node.
* Requests that the beacon node identify information about its implementation in a format similar to a [HTTP User-Agent](https://tools.ietf.org/html/rfc7231#section-5.5.3) field.
*/
getNodeVersion(): Promise<{data: {version: string}}>;
/**
* Get node syncing status
* Requests the beacon node to describe if it's currently syncing or not, and if it is, what block it is up to.
*/
getSyncingStatus(): Promise<{data: SyncingStatus}>;
/**
* Get health check
* Returns node health status in http status codes. Useful for load balancers.
*/
getHealth(): Promise<void>;
};
export const routesData: RoutesData<Api> = {
getNetworkIdentity: {url: "/eth/v1/node/identity", method: "GET"},
getPeers: {url: "/eth/v1/node/peers", method: "GET"},
getPeer: {url: "/eth/v1/node/peers/:peerId", method: "GET"},
getPeerCount: {url: "/eth/v1/node/peer_count", method: "GET"},
getNodeVersion: {url: "/eth/v1/node/version", method: "GET"},
getSyncingStatus: {url: "/eth/v1/node/syncing", method: "GET"},
getHealth: {url: "/eth/v1/node/health", method: "GET"},
};
export type ReqTypes = {
getNetworkIdentity: ReqEmpty;
getPeers: {query: {state?: PeerState[]; direction?: PeerDirection[]}};
getPeer: {params: {peerId: string}};
getPeerCount: ReqEmpty;
getNodeVersion: ReqEmpty;
getSyncingStatus: ReqEmpty;
getHealth: ReqEmpty;
};
export function getReqSerializers(): ReqSerializers<Api, ReqTypes> {
return {
getNetworkIdentity: reqEmpty,
getPeers: {
writeReq: (filters) => ({query: filters || {}}),
parseReq: ({query}) => [query],
schema: {query: {state: Schema.StringArray, direction: Schema.StringArray}},
},
getPeer: {
writeReq: (peerId) => ({params: {peerId}}),
parseReq: ({params}) => [params.peerId],
schema: {params: {peerId: Schema.StringRequired}},
},
getPeerCount: reqEmpty,
getNodeVersion: reqEmpty,
getSyncingStatus: reqEmpty,
getHealth: reqEmpty,
};
}
/* eslint-disable @typescript-eslint/naming-convention */
export function getReturnTypes(config: IBeaconConfig): ReturnTypes<Api> {
const stringType = new StringType();
const NetworkIdentity = new ContainerType<NetworkIdentity>({
fields: {
peerId: stringType,
enr: stringType,
p2pAddresses: ArrayOf(stringType),
discoveryAddresses: ArrayOf(stringType),
metadata: config.types.altair.Metadata,
},
});
return {
//
// TODO: Consider just converting the JSON case without custom types
//
getNetworkIdentity: ContainerData(NetworkIdentity),
// All these types don't contain any BigInt nor Buffer instances.
// Use jsonType() to translate the casing in a generic way.
getPeers: jsonType(),
getPeer: jsonType(),
getPeerCount: jsonType(),
getNodeVersion: jsonType(),
getSyncingStatus: jsonType(),
};
}

View File

@@ -0,0 +1,360 @@
import {ContainerType, fromHexString, Json, toHexString, Type} from "@chainsafe/ssz";
import {ForkName, IBeaconConfig} from "@chainsafe/lodestar-config";
import {
allForks,
altair,
BLSPubkey,
BLSSignature,
CommitteeIndex,
Epoch,
Number64,
phase0,
Root,
Slot,
ValidatorIndex,
} from "@chainsafe/lodestar-types";
import {
RoutesData,
ReturnTypes,
ArrayOf,
ContainerData,
Schema,
WithVersion,
reqOnlyBody,
ReqSerializers,
} from "../utils";
// See /packages/api/src/routes/index.ts for reasoning and instructions to add new routes
export type BeaconCommitteeSubscription = {
validatorIndex: ValidatorIndex;
committeeIndex: number;
committeesAtSlot: number;
slot: Slot;
isAggregator: boolean;
};
/**
* From https://github.com/ethereum/eth2.0-APIs/pull/136
*/
export type SyncCommitteeSubscription = {
validatorIndex: ValidatorIndex;
syncCommitteeIndices: number[];
untilEpoch: Epoch;
};
export type ProposerDuty = {
slot: Slot;
validatorIndex: ValidatorIndex;
pubkey: BLSPubkey;
};
export type AttesterDuty = {
// The validator's public key, uniquely identifying them
pubkey: BLSPubkey;
// Index of validator in validator registry
validatorIndex: ValidatorIndex;
committeeIndex: CommitteeIndex;
// Number of validators in committee
committeeLength: Number64;
// Number of committees at the provided slot
committeesAtSlot: Number64;
// Index of validator in committee
validatorCommitteeIndex: Number64;
// The slot at which the validator must attest.
slot: Slot;
};
/**
* From https://github.com/ethereum/eth2.0-APIs/pull/134
*/
export type SyncDuty = {
pubkey: BLSPubkey;
/** Index of validator in validator registry. */
validatorIndex: ValidatorIndex;
/** The indices of the validator in the sync committee. */
validatorSyncCommitteeIndices: number[];
};
export type Api = {
/**
* Get attester duties
* Requests the beacon node to provide a set of attestation duties, which should be performed by validators, for a particular epoch.
* Duties should only need to be checked once per epoch, however a chain reorganization (of > MIN_SEED_LOOKAHEAD epochs) could occur, resulting in a change of duties. For full safety, you should monitor head events and confirm the dependent root in this response matches:
* - event.previous_duty_dependent_root when `compute_epoch_at_slot(event.slot) == epoch`
* - event.current_duty_dependent_root when `compute_epoch_at_slot(event.slot) + 1 == epoch`
* - event.block otherwise
* The dependent_root value is `get_block_root_at_slot(state, compute_start_slot_at_epoch(epoch - 1) - 1)` or the genesis block root in the case of underflow.
* @param epoch Should only be allowed 1 epoch ahead
* @param requestBody An array of the validator indices for which to obtain the duties.
* @returns any Success response
* @throws ApiError
*/
getAttesterDuties(
epoch: Epoch,
validatorIndices: ValidatorIndex[]
): Promise<{data: AttesterDuty[]; dependentRoot: Root}>;
/**
* Get block proposers duties
* Request beacon node to provide all validators that are scheduled to propose a block in the given epoch.
* Duties should only need to be checked once per epoch, however a chain reorganization could occur that results in a change of duties. For full safety, you should monitor head events and confirm the dependent root in this response matches:
* - event.current_duty_dependent_root when `compute_epoch_at_slot(event.slot) == epoch`
* - event.block otherwise
* The dependent_root value is `get_block_root_at_slot(state, compute_start_slot_at_epoch(epoch) - 1)` or the genesis block root in the case of underflow.
* @param epoch
* @returns any Success response
* @throws ApiError
*/
getProposerDuties(epoch: Epoch): Promise<{data: ProposerDuty[]; dependentRoot: Root}>;
getSyncCommitteeDuties(
epoch: number,
validatorIndices: ValidatorIndex[]
): Promise<{data: SyncDuty[]; dependentRoot: Root}>;
/**
* Produce a new block, without signature.
* Requests a beacon node to produce a valid block, which can then be signed by a validator.
* @param slot The slot for which the block should be proposed.
* @param randaoReveal The validator's randao reveal value.
* @param graffiti Arbitrary data validator wants to include in block.
* @returns any Success response
* @throws ApiError
*/
produceBlock(
slot: Slot,
randaoReveal: BLSSignature,
graffiti: string
): Promise<{data: allForks.BeaconBlock; version: ForkName}>;
/**
* Produce an attestation data
* Requests that the beacon node produce an AttestationData.
* @param slot The slot for which an attestation data should be created.
* @param committeeIndex The committee index for which an attestation data should be created.
* @returns any Success response
* @throws ApiError
*/
produceAttestationData(index: CommitteeIndex, slot: Slot): Promise<{data: phase0.AttestationData}>;
produceSyncCommitteeContribution(
slot: Slot,
subcommitteeIndex: number,
beaconBlockRoot: Root
): Promise<{data: altair.SyncCommitteeContribution}>;
/**
* Get aggregated attestation
* Aggregates all attestations matching given attestation data root and slot
* @param attestationDataRoot HashTreeRoot of AttestationData that validator want's aggregated
* @param slot
* @returns any Returns aggregated `Attestation` object with same `AttestationData` root.
* @throws ApiError
*/
getAggregatedAttestation(attestationDataRoot: Root, slot: Slot): Promise<{data: phase0.Attestation}>;
/**
* Publish multiple aggregate and proofs
* Verifies given aggregate and proofs and publishes them on appropriate gossipsub topic.
* @param requestBody
* @returns any Successful response
* @throws ApiError
*/
publishAggregateAndProofs(signedAggregateAndProofs: phase0.SignedAggregateAndProof[]): Promise<void>;
publishContributionAndProofs(contributionAndProofs: altair.SignedContributionAndProof[]): Promise<void>;
/**
* Signal beacon node to prepare for a committee subnet
* After beacon node receives this request,
* search using discv5 for peers related to this subnet
* and replace current peers with those ones if necessary
* If validator `is_aggregator`, beacon node must:
* - announce subnet topic subscription on gossipsub
* - aggregate attestations received on that subnet
*
* @param requestBody
* @returns any Slot signature is valid and beacon node has prepared the attestation subnet.
*
* Note that, we cannot be certain Beacon node will find peers for that subnet for various reasons,"
*
* @throws ApiError
*/
prepareBeaconCommitteeSubnet(subscriptions: BeaconCommitteeSubscription[]): Promise<void>;
prepareSyncCommitteeSubnets(subscriptions: SyncCommitteeSubscription[]): Promise<void>;
};
/**
* Define javascript values for each route
*/
export const routesData: RoutesData<Api> = {
getAttesterDuties: {url: "/eth/v1/validator/duties/attester/:epoch", method: "POST"},
getProposerDuties: {url: "/eth/v1/validator/duties/proposer/:epoch", method: "GET"},
getSyncCommitteeDuties: {url: "/eth/v1/validator/duties/sync/:epoch", method: "POST"},
produceBlock: {url: "/eth/v1/validator/blocks/:slot", method: "GET"},
produceAttestationData: {url: "/eth/v1/validator/attestation_data", method: "GET"},
produceSyncCommitteeContribution: {url: "/eth/v1/validator/sync_committee_contribution", method: "GET"},
getAggregatedAttestation: {url: "/eth/v1/validator/aggregate_attestation", method: "GET"},
publishAggregateAndProofs: {url: "/eth/v1/validator/aggregate_and_proofs", method: "POST"},
publishContributionAndProofs: {url: "/eth/v1/validator/contribution_and_proofs", method: "POST"},
prepareBeaconCommitteeSubnet: {url: "/eth/v1/validator/beacon_committee_subscriptions", method: "POST"},
prepareSyncCommitteeSubnets: {url: "/eth/v1/validator/sync_committee_subscriptions", method: "POST"},
};
/* eslint-disable @typescript-eslint/naming-convention */
export type ReqTypes = {
getAttesterDuties: {params: {epoch: Epoch}; body: ValidatorIndex[]};
getProposerDuties: {params: {epoch: Epoch}};
getSyncCommitteeDuties: {params: {epoch: Epoch}; body: ValidatorIndex[]};
produceBlock: {params: {slot: number}; query: {randao_reveal: string; grafitti: string}};
produceAttestationData: {query: {slot: number; committee_index: number}};
produceSyncCommitteeContribution: {query: {slot: number; subcommittee_index: number; beacon_block_root: string}};
getAggregatedAttestation: {query: {attestation_data_root: string; slot: number}};
publishAggregateAndProofs: {body: Json};
publishContributionAndProofs: {body: Json};
prepareBeaconCommitteeSubnet: {body: Json};
prepareSyncCommitteeSubnets: {body: Json};
};
export function getReqSerializers(config: IBeaconConfig): ReqSerializers<Api, ReqTypes> {
const BeaconCommitteeSubscription = new ContainerType<BeaconCommitteeSubscription>({
fields: {
validatorIndex: config.types.ValidatorIndex,
committeeIndex: config.types.CommitteeIndex,
committeesAtSlot: config.types.Slot,
slot: config.types.Slot,
isAggregator: config.types.Boolean,
},
});
const SyncCommitteeSubscription = new ContainerType<SyncCommitteeSubscription>({
fields: {
validatorIndex: config.types.ValidatorIndex,
syncCommitteeIndices: ArrayOf(config.types.CommitteeIndex),
untilEpoch: config.types.Epoch,
},
});
return {
getAttesterDuties: {
writeReq: (epoch, validatorIndexes) => ({params: {epoch}, body: validatorIndexes}),
parseReq: ({params, body}) => [params.epoch, body],
schema: {
params: {epoch: Schema.UintRequired},
body: Schema.UintArray,
},
},
getProposerDuties: {
writeReq: (epoch) => ({params: {epoch}}),
parseReq: ({params}) => [params.epoch],
schema: {
params: {epoch: Schema.UintRequired},
},
},
getSyncCommitteeDuties: {
writeReq: (epoch, validatorIndexes) => ({params: {epoch}, body: validatorIndexes}),
parseReq: ({params, body}) => [params.epoch, body],
schema: {
params: {epoch: Schema.UintRequired},
body: Schema.UintArray,
},
},
produceBlock: {
writeReq: (slot, randaoReveal, grafitti) => ({
params: {slot},
query: {randao_reveal: toHexString(randaoReveal), grafitti},
}),
parseReq: ({params, query}) => [params.slot, fromHexString(query.randao_reveal), query.grafitti],
schema: {
params: {slot: Schema.UintRequired},
query: {randao_reveal: Schema.StringRequired, grafitti: Schema.String},
},
},
produceAttestationData: {
writeReq: (index, slot) => ({query: {slot, committee_index: index}}),
parseReq: ({query}) => [query.committee_index, query.slot],
schema: {
query: {slot: Schema.UintRequired, committee_index: Schema.UintRequired},
},
},
produceSyncCommitteeContribution: {
writeReq: (slot, index, root) => ({
query: {slot, subcommittee_index: index, beacon_block_root: toHexString(root)},
}),
parseReq: ({query}) => [query.slot, query.subcommittee_index, fromHexString(query.beacon_block_root)],
schema: {
query: {
slot: Schema.UintRequired,
subcommittee_index: Schema.UintRequired,
beacon_block_root: Schema.StringRequired,
},
},
},
getAggregatedAttestation: {
writeReq: (root, slot) => ({query: {attestation_data_root: toHexString(root), slot}}),
parseReq: ({query}) => [fromHexString(query.attestation_data_root), query.slot],
schema: {
query: {attestation_data_root: Schema.StringRequired, slot: Schema.UintRequired},
},
},
publishAggregateAndProofs: reqOnlyBody(ArrayOf(config.types.phase0.SignedAggregateAndProof), Schema.ObjectArray),
publishContributionAndProofs: reqOnlyBody(
ArrayOf(config.types.altair.SignedContributionAndProof),
Schema.ObjectArray
),
prepareBeaconCommitteeSubnet: reqOnlyBody(ArrayOf(BeaconCommitteeSubscription), Schema.ObjectArray),
prepareSyncCommitteeSubnets: reqOnlyBody(ArrayOf(SyncCommitteeSubscription), Schema.ObjectArray),
};
}
export function getReturnTypes(config: IBeaconConfig): ReturnTypes<Api> {
const WithDependentRoot = <T>(dataType: Type<T>): ContainerType<{data: T; dependentRoot: Root}> =>
new ContainerType({fields: {data: dataType, dependentRoot: config.types.Root}});
const AttesterDuty = new ContainerType<AttesterDuty>({
fields: {
pubkey: config.types.BLSPubkey,
validatorIndex: config.types.ValidatorIndex,
committeeIndex: config.types.CommitteeIndex,
committeeLength: config.types.Number64,
committeesAtSlot: config.types.Number64,
validatorCommitteeIndex: config.types.Number64,
slot: config.types.Slot,
},
});
const ProposerDuty = new ContainerType<ProposerDuty>({
fields: {
slot: config.types.Slot,
validatorIndex: config.types.ValidatorIndex,
pubkey: config.types.BLSPubkey,
},
});
const SyncDuty = new ContainerType<SyncDuty>({
fields: {
pubkey: config.types.BLSPubkey,
validatorIndex: config.types.ValidatorIndex,
validatorSyncCommitteeIndices: ArrayOf(config.types.Number64),
},
});
return {
getAttesterDuties: WithDependentRoot(ArrayOf(AttesterDuty)),
getProposerDuties: WithDependentRoot(ArrayOf(ProposerDuty)),
getSyncCommitteeDuties: WithDependentRoot(ArrayOf(SyncDuty)),
produceBlock: WithVersion((fork) => config.types[fork].BeaconBlock),
produceAttestationData: ContainerData(config.types.phase0.AttestationData),
produceSyncCommitteeContribution: ContainerData(config.types.altair.SyncCommitteeContribution),
getAggregatedAttestation: ContainerData(config.types.phase0.Attestation),
};
}

View File

@@ -0,0 +1,8 @@
import {IBeaconConfig} from "@chainsafe/lodestar-config";
import {ServerRoutes, getGenericJsonServer} from "./utils";
import {Api, ReqTypes, routesData, getReturnTypes, getReqSerializers} from "../routes/beacon";
export function getRoutes(config: IBeaconConfig, api: Api): ServerRoutes<Api, ReqTypes> {
// All routes return JSON, use a server auto-generator
return getGenericJsonServer<Api, ReqTypes>({routesData, getReturnTypes, getReqSerializers}, config, api);
}

View File

@@ -0,0 +1,8 @@
import {IBeaconConfig} from "@chainsafe/lodestar-config";
import {ServerRoutes, getGenericJsonServer} from "./utils";
import {Api, ReqTypes, routesData, getReturnTypes, getReqSerializers} from "../routes/config";
export function getRoutes(config: IBeaconConfig, api: Api): ServerRoutes<Api, ReqTypes> {
// All routes return JSON, use a server auto-generator
return getGenericJsonServer<Api, ReqTypes>({routesData, getReturnTypes, getReqSerializers}, config, api);
}

View File

@@ -0,0 +1,49 @@
import {IBeaconConfig} from "@chainsafe/lodestar-config";
import {jsonOpts} from "../utils";
import {ServerRoutes, getGenericJsonServer} from "./utils";
import {Api, ReqTypes, routesData, getReturnTypes, getReqSerializers} from "../routes/debug";
const mimeTypeSSZ = "application/octet-stream";
export function getRoutes(config: IBeaconConfig, api: Api): ServerRoutes<Api, ReqTypes> {
const reqSerializers = getReqSerializers();
const returnTypes = getReturnTypes(config);
const serverRoutes = getGenericJsonServer<Api, ReqTypes>(
{routesData, getReturnTypes, getReqSerializers},
config,
api
);
return {
...serverRoutes,
// Non-JSON routes. Return JSON or binary depending on "accept" header
getState: {
...serverRoutes.getState,
handler: async (req) => {
const data = await api.getState(...reqSerializers.getState.parseReq(req));
const type = config.getForkTypes(data.data.slot).BeaconState;
if (req.headers["accept"] === mimeTypeSSZ) {
// Fastify 3.x.x will automatically add header `Content-Type: application/octet-stream` if Buffer
return Buffer.from(type.serialize(data.data));
} else {
return returnTypes.getState.toJson(data, jsonOpts);
}
},
},
getStateV2: {
...serverRoutes.getStateV2,
handler: async (req) => {
const data = await api.getStateV2(...reqSerializers.getStateV2.parseReq(req));
const type = config.getForkTypes(data.data.slot).BeaconState;
if (req.headers["accept"] === mimeTypeSSZ) {
// Fastify 3.x.x will automatically add header `Content-Type: application/octet-stream` if Buffer
return Buffer.from(type.serialize(data.data));
} else {
return returnTypes.getStateV2.toJson(data, jsonOpts);
}
},
},
};
}

View File

@@ -0,0 +1,66 @@
import {AbortController} from "abort-controller";
import {IBeaconConfig} from "@chainsafe/lodestar-config";
import {ServerRoutes} from "./utils";
import {Api, ReqTypes, routesData, getEventSerdes} from "../routes/events";
export function getRoutes(config: IBeaconConfig, api: Api): ServerRoutes<Api, ReqTypes> {
const eventSerdes = getEventSerdes(config);
return {
// Non-JSON route. Server Sent Events (SSE)
eventstream: {
url: routesData.eventstream.url,
method: routesData.eventstream.method,
id: "eventstream",
handler: async (req, res) => {
const controller = new AbortController();
try {
res.raw.setHeader("Content-Type", "text/event-stream");
res.raw.setHeader("Cache-Control", "no-cache,no-transform");
res.raw.setHeader("Connection", "keep-alive");
// It was reported that chrome and firefox do not play well with compressed event-streams https://github.com/lolo32/fastify-sse/issues/2
res.raw.setHeader("x-no-compression", 1);
await new Promise<void>((resolve, reject) => {
api.eventstream(req.query.topics, controller.signal, (event) => {
try {
const data = eventSerdes.toJson(event);
res.raw.write(serializeSSEEvent({event: event.type, data}));
} catch (e) {
reject(e);
}
});
// The stream will never end by the server unless the node is stopped.
// In that case the BeaconNode class will call server.close() and end this connection.
// The client may disconnect and we need to clean the subscriptions.
req.raw.once("close", () => resolve());
req.raw.once("end", () => resolve());
req.raw.once("error", (err) => reject(err));
});
// api.eventstream will never stop, so no need to ever call `res.raw.end();`
} finally {
controller.abort();
}
},
// TODO: Bundle this in /routes/events?
schema: {
querystring: {
type: "object",
properties: {
topics: {type: "array", items: {type: "string"}},
},
},
},
},
};
}
export function serializeSSEEvent(chunk: {event: string; data: unknown}): string {
return [`event: ${chunk.event}`, `data: ${JSON.stringify(chunk.data)}`, "\n"].join("\n");
}

View File

@@ -0,0 +1,69 @@
import {IBeaconConfig} from "@chainsafe/lodestar-config";
// eslint-disable-next-line import/no-extraneous-dependencies
import {FastifyInstance} from "fastify";
import {Api} from "../interface";
import {ServerRoute} from "./utils";
import * as beacon from "./beacon";
import * as configApi from "./config";
import * as debug from "./debug";
import * as events from "./events";
import * as lightclient from "./lightclient";
import * as lodestar from "./lodestar";
import * as node from "./node";
import * as validator from "./validator";
export type RouteConfig = {
operationId: ServerRoute["id"];
};
export function registerRoutes(
server: FastifyInstance,
config: IBeaconConfig,
api: Api,
enabledNamespaces: (keyof Api)[]
): void {
const routesByNamespace: {
// Enforces that we are declaring routes for every routeId in `Api`
[K in keyof Api]: {
// The ReqTypes are enforced in each getRoutes return type
// eslint-disable-next-line @typescript-eslint/no-explicit-any
[K2 in keyof Api[K]]: ServerRoute<any>;
};
} = {
// Initializes route types and their definitions
beacon: beacon.getRoutes(config, api.beacon),
config: configApi.getRoutes(config, api.config),
debug: debug.getRoutes(config, api.debug),
events: events.getRoutes(config, api.events),
lightclient: lightclient.getRoutes(config, api.lightclient),
lodestar: lodestar.getRoutes(config, api.lodestar),
node: node.getRoutes(config, api.node),
validator: validator.getRoutes(config, api.validator),
};
for (const namespace of enabledNamespaces) {
const routes = routesByNamespace[namespace];
if (!routes) {
throw Error(`Unknown api namespace ${namespace}`);
}
registerRoutesGroup(server, routes);
}
}
export function registerRoutesGroup(
fastify: FastifyInstance,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
routes: Record<string, ServerRoute<any>>
): void {
for (const route of Object.values(routes)) {
fastify.route({
url: route.url,
method: route.method,
handler: route.handler,
schema: route.schema,
config: {operationId: route.id} as RouteConfig,
});
}
}

View File

@@ -0,0 +1,28 @@
import {IBeaconConfig} from "@chainsafe/lodestar-config";
import {serializeProof} from "@chainsafe/persistent-merkle-tree";
import {ServerRoutes, getGenericJsonServer} from "./utils";
import {Api, ReqTypes, routesData, getReturnTypes, getReqSerializers} from "../routes/lightclient";
export function getRoutes(config: IBeaconConfig, api: Api): ServerRoutes<Api, ReqTypes> {
const reqSerializers = getReqSerializers();
const serverRoutes = getGenericJsonServer<Api, ReqTypes>(
{routesData, getReturnTypes, getReqSerializers},
config,
api
);
return {
...serverRoutes,
// Non-JSON routes. Return binary
getStateProof: {
...serverRoutes.getStateProof,
handler: async (req) => {
const args = reqSerializers.getStateProof.parseReq(req);
const {data: proof} = await api.getStateProof(...args);
// Fastify 3.x.x will automatically add header `Content-Type: application/octet-stream` if Buffer
return Buffer.from(serializeProof(proof));
},
},
};
}

View File

@@ -0,0 +1,8 @@
import {IBeaconConfig} from "@chainsafe/lodestar-config";
import {ServerRoutes, getGenericJsonServer} from "./utils";
import {Api, ReqTypes, routesData, getReturnTypes, getReqSerializers} from "../routes/lodestar";
export function getRoutes(config: IBeaconConfig, api: Api): ServerRoutes<Api, ReqTypes> {
// All routes return JSON, use a server auto-generator
return getGenericJsonServer<Api, ReqTypes>({routesData, getReturnTypes, getReqSerializers}, config, api);
}

View File

@@ -0,0 +1,8 @@
import {IBeaconConfig} from "@chainsafe/lodestar-config";
import {ServerRoutes, getGenericJsonServer} from "./utils";
import {Api, ReqTypes, routesData, getReturnTypes, getReqSerializers} from "../routes/node";
export function getRoutes(config: IBeaconConfig, api: Api): ServerRoutes<Api, ReqTypes> {
// All routes return JSON, use a server auto-generator
return getGenericJsonServer<Api, ReqTypes>({routesData, getReturnTypes, getReqSerializers}, config, api);
}

View File

@@ -0,0 +1 @@
export * from "./server";

View File

@@ -0,0 +1,79 @@
import {Json} from "@chainsafe/ssz";
import {mapValues} from "@chainsafe/lodestar-utils";
// eslint-disable-next-line import/no-extraneous-dependencies
import * as fastify from "fastify";
import {
ReqGeneric,
RouteGeneric,
ReturnTypes,
TypeJson,
Resolves,
jsonOpts,
RouteGroupDefinition,
} from "../../utils/types";
import {getFastifySchema} from "../../utils/schema";
import {IBeaconConfig} from "@chainsafe/lodestar-config";
// See /packages/api/src/routes/index.ts for reasoning
/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/naming-convention */
export type ServerRoute<Req extends ReqGeneric = ReqGeneric> = {
url: string;
method: fastify.HTTPMethods;
handler: FastifyHandler<Req>;
schema?: fastify.FastifySchema;
/** OperationId as defined in https://github.com/ethereum/eth2.0-APIs/blob/18cb6ff152b33a5f34c377f00611821942955c82/apis/beacon/blocks/attestations.yaml#L2 */
id: string;
};
/** Adaptor for Fastify v3.x.x route type which has a ton of arguments */
type FastifyHandler<Req extends ReqGeneric> = fastify.RouteHandlerMethod<
fastify.RawServerDefault,
fastify.RawRequestDefaultExpression<fastify.RawServerDefault>,
fastify.RawReplyDefaultExpression<fastify.RawServerDefault>,
{
Body: Req["body"];
Querystring: Req["query"];
Params: Req["params"];
},
fastify.ContextConfigDefault
>;
export type ServerRoutes<Api extends Record<string, RouteGeneric>, ReqTypes extends {[K in keyof Api]: ReqGeneric}> = {
[K in keyof Api]: ServerRoute<ReqTypes[K]>;
};
export function getGenericJsonServer<
Api extends Record<string, RouteGeneric>,
ReqTypes extends {[K in keyof Api]: ReqGeneric}
>(
{routesData, getReqSerializers, getReturnTypes}: RouteGroupDefinition<Api, ReqTypes>,
config: IBeaconConfig,
api: Api
): ServerRoutes<Api, ReqTypes> {
const reqSerializers = getReqSerializers(config);
const returnTypes = getReturnTypes(config);
return mapValues(routesData, (routeDef, routeKey) => {
const routeSerdes = reqSerializers[routeKey];
const returnType = returnTypes[routeKey as keyof ReturnTypes<Api>] as TypeJson<any> | null;
return {
url: routeDef.url,
method: routeDef.method,
id: routeKey as string,
schema: routeSerdes.schema && getFastifySchema(routeSerdes.schema),
handler: async function handler(req: ReqGeneric): Promise<Json | void> {
const args: any[] = routeSerdes.parseReq(req as ReqTypes[keyof Api]);
const data = (await api[routeKey](...args)) as Resolves<Api[keyof Api]>;
if (returnType) {
return returnType.toJson(data, jsonOpts);
} else {
return {};
}
},
};
});
}

View File

@@ -0,0 +1,8 @@
import {IBeaconConfig} from "@chainsafe/lodestar-config";
import {ServerRoutes, getGenericJsonServer} from "./utils";
import {Api, ReqTypes, routesData, getReturnTypes, getReqSerializers} from "../routes/validator";
export function getRoutes(config: IBeaconConfig, api: Api): ServerRoutes<Api, ReqTypes> {
// All routes return JSON, use a server auto-generator
return getGenericJsonServer<Api, ReqTypes>({routesData, getReturnTypes, getReqSerializers}, config, api);
}

View File

@@ -1,5 +1,7 @@
import {BasicType} from "@chainsafe/ssz";
/* eslint-disable @typescript-eslint/naming-convention */
export class StringType<T extends string = string> extends BasicType<T> {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
struct_getSerializedLength(data?: string): number {

View File

@@ -0,0 +1,4 @@
export * from "./schema";
export * from "./StringType";
export * from "./types";
export * from "./urlFormat";

View File

@@ -0,0 +1,116 @@
import {ReqGeneric} from "./types";
// Reasoning: Allows to declare JSON schemas for server routes in a succinct typesafe way.
// The enums exposed here are very feature incomplete but cover the minimum necessary for
// the existing routes. Since the arguments for Eth2.0 server routes are very simple it suffice.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type JsonSchema = Record<string, any>;
type JsonSchemaObj = {
type: "object";
required: string[];
properties: Record<string, JsonSchema>;
};
export type SchemaDefinition<ReqType extends ReqGeneric> = {
params?: {
[K in keyof ReqType["params"]]: Schema;
};
query?: {
[K in keyof ReqType["query"]]: Schema;
};
body?: Schema;
};
export enum Schema {
Uint,
UintRequired,
UintArray,
String,
StringRequired,
StringArray,
UintOrStringRequired,
UintOrStringArray,
Object,
ObjectArray,
AnyArray,
}
/**
* Return JSON schema from a Schema enum. Useful to declare schemas in a succinct format
*/
function getJsonSchemaItem(schema: Schema): JsonSchema {
switch (schema) {
case Schema.Uint:
case Schema.UintRequired:
return {type: "integer", minimum: 0};
case Schema.UintArray:
return {type: "array", items: {type: "integer", minimum: 0}};
case Schema.String:
case Schema.StringRequired:
return {type: "string"};
case Schema.StringArray:
return {type: "array", items: {type: "string"}};
case Schema.UintOrStringRequired:
return {type: ["string", "integer"]};
case Schema.UintOrStringArray:
return {type: "array", items: {type: ["string", "integer"]}};
case Schema.Object:
return {type: "object"};
case Schema.ObjectArray:
return {type: "array", items: {type: "object"}};
case Schema.AnyArray:
return {type: "array"};
}
}
function isRequired(schema: Schema): boolean {
switch (schema) {
case Schema.UintRequired:
case Schema.StringRequired:
case Schema.UintOrStringRequired:
return true;
default:
return false;
}
}
export function getFastifySchema(schemaDef: SchemaDefinition<ReqGeneric>): JsonSchema {
const schema: {params?: JsonSchemaObj; querystring?: JsonSchemaObj; body?: JsonSchema} = {};
if (schemaDef.body) {
schema.body = getJsonSchemaItem(schemaDef.body);
}
if (schemaDef.params) {
schema.params = {type: "object", required: [] as string[], properties: {}};
for (const [key, def] of Object.entries(schemaDef.params)) {
schema.params.properties[key] = getJsonSchemaItem(def as Schema);
if (isRequired(def as Schema)) {
schema.params.required.push(key);
}
}
}
if (schemaDef.query) {
schema.querystring = {type: "object", required: [] as string[], properties: {}};
for (const [key, def] of Object.entries(schemaDef.query)) {
schema.querystring.properties[key] = getJsonSchemaItem(def as Schema);
if (isRequired(def as Schema)) {
schema.querystring.required.push(key);
}
}
}
return schema;
}

View File

@@ -0,0 +1,150 @@
import {ContainerType, IJsonOptions, Json, ListType, Type} from "@chainsafe/ssz";
import {ForkName, IBeaconConfig} from "@chainsafe/lodestar-config";
import {objectToExpectedCase} from "@chainsafe/lodestar-utils";
import {Schema, SchemaDefinition} from "./schema";
// See /packages/api/src/routes/index.ts for reasoning
/* eslint-disable @typescript-eslint/naming-convention, @typescript-eslint/no-explicit-any */
/** All JSON must be sent in snake case */
export const jsonOpts = {case: "snake" as const};
/** All JSON inside the JS code must be camel case */
export const codeCase = "camel" as const;
export type RouteGroupDefinition<
Api extends Record<string, RouteGeneric>,
ReqTypes extends {[K in keyof Api]: ReqGeneric}
> = {
routesData: RoutesData<Api>;
getReqSerializers: (config: IBeaconConfig) => ReqSerializers<Api, ReqTypes>;
getReturnTypes: (config: IBeaconConfig) => ReturnTypes<Api>;
};
export type RouteDef = {
url: string;
method: "GET" | "POST";
};
export type ReqGeneric = {
params?: Record<string, string | number>;
query?: Record<string, string | number | (string | number)[]>;
body?: any;
};
export type ReqEmpty = ReqGeneric;
export type RouteGeneric = (...args: any) => PromiseLike<any> | any;
type ThenArg<T> = T extends PromiseLike<infer U> ? U : T;
export type Resolves<T extends (...args: any) => any> = ThenArg<ReturnType<T>>;
export type TypeJson<T> = {
toJson(val: T, opts?: IJsonOptions): Json;
fromJson(json: Json, opts?: IJsonOptions): T;
};
//
// REQ
//
export type ReqSerializer<Fn extends (...args: any) => any, ReqType extends ReqGeneric> = {
writeReq: (...args: Parameters<Fn>) => ReqType;
parseReq: (arg: ReqType) => Parameters<Fn>;
schema?: SchemaDefinition<ReqType>;
};
export type ReqSerializers<
Api extends Record<string, RouteGeneric>,
ReqTypes extends {[K in keyof Api]: ReqGeneric}
> = {
[K in keyof Api]: ReqSerializer<Api[K], ReqTypes[K]>;
};
/** Curried definition to infer only one of the two generic types */
export type ReqGenArg<Fn extends (...args: any) => any, ReqType extends ReqGeneric> = ReqSerializer<Fn, ReqType>;
//
// RETURN
//
export type KeysOfNonVoidResolveValues<Api extends Record<string, RouteGeneric>> = {
[K in keyof Api]: Resolves<Api[K]> extends void ? never : K;
}[keyof Api];
export type ReturnTypes<Api extends Record<string, RouteGeneric>> = {
[K in keyof Pick<Api, KeysOfNonVoidResolveValues<Api>>]: TypeJson<Resolves<Api[K]>>;
};
export type RoutesData<Api extends Record<string, RouteGeneric>> = {[K in keyof Api]: RouteDef};
//
// Helpers
//
/** Shortcut for routes that have no params, query nor body */
export const reqEmpty: ReqSerializer<() => void, ReqEmpty> = {
writeReq: () => ({}),
parseReq: () => [] as [],
};
/** Shortcut for routes that have only body */
export const reqOnlyBody = <T>(
type: TypeJson<T>,
bodySchema: Schema
): ReqGenArg<(arg: T) => Promise<void>, {body: Json}> => ({
writeReq: (items) => ({body: type.toJson(items, jsonOpts)}),
parseReq: ({body}) => [type.fromJson(body, jsonOpts)],
schema: {body: bodySchema},
});
/** SSZ factory helper + typed. limit = 1e6 as a big enough random number */
export function ArrayOf<T>(elementType: Type<T>, limit = 1e6): ListType<T[]> {
return new ListType({elementType, limit});
}
/**
* SSZ factory helper + typed to return responses of type
* ```
* data: T
* ```
*/
export function ContainerData<T>(dataType: Type<T>): ContainerType<{data: T}> {
return new ContainerType({fields: {data: dataType}});
}
/**
* SSZ factory helper + typed to return responses of type
* ```
* data: T
* version: ForkName
* ```
*/
export function WithVersion<T>(getType: (fork: ForkName) => TypeJson<T>): TypeJson<{data: T; version: ForkName}> {
return {
toJson: ({data, version}, opts) => ({
data: getType(version).toJson(data, opts),
version,
}),
fromJson: ({data, version}: {data: Json; version: string}, opts) => ({
data: getType(version as ForkName).fromJson(data, opts),
version: version as ForkName,
}),
};
}
/** Helper to only translate casing */
export function jsonType<T extends Record<string, unknown> | Record<string, unknown>[]>(): TypeJson<T> {
return {
toJson: (val, opts) => objectToExpectedCase(val, opts?.case) as Json,
fromJson: (json) => objectToExpectedCase(json as Record<string, unknown>, codeCase) as T,
};
}
/** Helper to not do any transformation with the type */
export function sameType<T>(): TypeJson<T> {
return {
toJson: (val) => (val as unknown) as Json,
fromJson: (json) => (json as unknown) as T,
};
}

View File

@@ -0,0 +1,74 @@
enum TokenType {
String = "String",
Variable = "Variable",
}
type Token = {type: TokenType; start: number};
type Args = Record<string, string | number>;
/**
* Compile a route URL formater with syntax `/path/:var1/:var2`.
* Returns a function that expects an object `{var1: 1, var2: 2}`, and returns`/path/1/2`.
*
* It's cheap enough to be neglibible. For the sample input below it costs:
* - compile: 1010 ns / op
* - execute: 105 ns / op
* - execute with template literal: 12 ns / op
* @param path `/eth/v1/validator/:name/attester/:epoch`
*/
export function compileRouteUrlFormater(path: string): (arg: Args) => string {
const tokens: Token[] = [];
for (let i = 0, len = path.length; i < len; i++) {
const currentToken: Token | undefined = tokens[tokens.length - 1];
switch (path[i]) {
case ":": {
if (currentToken && currentToken.type === TokenType.Variable) {
throw Error(`Invalid path token ':' not closed: ${path}`);
}
tokens.push({type: TokenType.Variable, start: i});
break;
}
case "/": {
if (!currentToken || currentToken.type === TokenType.Variable) {
tokens.push({type: TokenType.String, start: i});
}
break;
}
default: {
if (!currentToken) {
tokens.push({type: TokenType.String, start: i});
}
}
}
}
// Return a faster function if there's not ':' token
if (tokens.length === 1 && tokens[0].type === TokenType.String) {
return () => path;
}
const fns = tokens.map((token, i) => {
const ending = tokens[i + 1] ? tokens[i + 1].start : path.length;
const part = path.slice(token.start, ending);
switch (token.type) {
case TokenType.String:
return () => part;
case TokenType.Variable: {
const argKey = part.slice(1); // remove prepended ":"
return (args: Args) => args[argKey];
}
}
});
return function (args: Args) {
// Don't use .map() or .join(), it's x3 slower
let s = "";
for (const fn of fns) s += fn(args);
return s;
};
}

View File

@@ -0,0 +1,32 @@
import {compileRouteUrlFormater} from "../src/utils/urlFormat";
/* eslint-disable no-console */
describe("route parse", () => {
it.skip("Benchmark compileRouteUrlFormater", () => {
const path = "/eth/v1/validator/:name/attester/:epoch";
const args = {epoch: 5, name: "HEAD"};
console.time("compile");
for (let i = 0; i < 1e6; i++) {
compileRouteUrlFormater(path);
}
console.timeEnd("compile");
const fn = compileRouteUrlFormater(path);
console.log(fn(args));
console.time("execute");
for (let i = 0; i < 1e6; i++) {
fn(args);
}
console.timeEnd("execute");
console.time("execute-template");
for (let i = 0; i < 1e6; i++) {
`/eth/v1/validator/${args.name}/attester/${args.epoch}`;
}
console.timeEnd("execute-template");
});
});

View File

@@ -0,0 +1,175 @@
import {ForkName} from "@chainsafe/lodestar-config";
import {config} from "@chainsafe/lodestar-config/minimal";
import {toHexString} from "@chainsafe/ssz";
import {Api, ReqTypes, BlockHeaderResponse, ValidatorResponse} from "../../src/routes/beacon";
import {getClient} from "../../src/client/beacon";
import {getRoutes} from "../../src/server/beacon";
import {runGenericServerTest} from "../utils/genericServerTest";
describe("beacon", () => {
const root = Buffer.alloc(32, 1);
const balance = BigInt(32e9);
const pubkeyHex = toHexString(Buffer.alloc(48, 1));
const blockHeaderResponse: BlockHeaderResponse = {
root,
canonical: true,
header: config.types.phase0.SignedBeaconBlockHeader.defaultValue(),
};
const validatorResponse: ValidatorResponse = {
index: 1,
balance,
status: "active_ongoing",
validator: config.types.phase0.Validator.defaultValue(),
};
runGenericServerTest<Api, ReqTypes>(config, getClient, getRoutes, {
// block
getBlock: {
args: ["head"],
res: {data: config.types.phase0.SignedBeaconBlock.defaultValue()},
},
getBlockV2: {
args: ["head"],
res: {data: config.types.altair.SignedBeaconBlock.defaultValue(), version: ForkName.altair},
},
getBlockAttestations: {
args: ["head"],
res: {data: [config.types.phase0.Attestation.defaultValue()]},
},
getBlockHeader: {
args: ["head"],
res: {data: blockHeaderResponse},
},
getBlockHeaders: {
args: [{slot: 1, parentRoot: toHexString(root)}],
res: {data: [blockHeaderResponse]},
},
getBlockRoot: {
args: ["head"],
res: {data: root},
},
publishBlock: {
args: [config.types.phase0.SignedBeaconBlock.defaultValue()],
res: undefined,
},
// pool
getPoolAttestations: {
args: [{slot: 1, committeeIndex: 2}],
res: {data: [config.types.phase0.Attestation.defaultValue()]},
},
getPoolAttesterSlashings: {
args: [],
res: {data: [config.types.phase0.AttesterSlashing.defaultValue()]},
},
getPoolProposerSlashings: {
args: [],
res: {data: [config.types.phase0.ProposerSlashing.defaultValue()]},
},
getPoolVoluntaryExits: {
args: [],
res: {data: [config.types.phase0.SignedVoluntaryExit.defaultValue()]},
},
submitPoolAttestations: {
args: [[config.types.phase0.Attestation.defaultValue()]],
res: undefined,
},
submitPoolAttesterSlashing: {
args: [config.types.phase0.AttesterSlashing.defaultValue()],
res: undefined,
},
submitPoolProposerSlashing: {
args: [config.types.phase0.ProposerSlashing.defaultValue()],
res: undefined,
},
submitPoolVoluntaryExit: {
args: [config.types.phase0.SignedVoluntaryExit.defaultValue()],
res: undefined,
},
submitPoolSyncCommitteeSignatures: {
args: [[config.types.altair.SyncCommitteeSignature.defaultValue()]],
res: undefined,
},
// state
getStateRoot: {
args: ["head"],
res: {data: root},
},
getStateFork: {
args: ["head"],
res: {data: config.types.phase0.Fork.defaultValue()},
},
getStateFinalityCheckpoints: {
args: ["head"],
res: {
data: {
previousJustified: config.types.phase0.Checkpoint.defaultValue(),
currentJustified: config.types.phase0.Checkpoint.defaultValue(),
finalized: config.types.phase0.Checkpoint.defaultValue(),
},
},
},
getStateValidators: {
args: ["head", {indices: [pubkeyHex, "1300"], statuses: ["active_ongoing"]}],
res: {data: [validatorResponse]},
},
getStateValidator: {
args: ["head", pubkeyHex],
res: {data: validatorResponse},
},
getStateValidatorBalances: {
args: ["head", ["1300"]],
res: {data: [{index: 1300, balance}]},
},
getEpochCommittees: {
args: ["head", {index: 1, slot: 2, epoch: 3}],
res: {data: [{index: 1, slot: 2, validators: [1300]}]},
},
getEpochSyncCommittees: {
args: ["head", 1],
res: {data: {validators: [1300], validatorAggregates: [1300]}},
},
// -
getGenesis: {
args: [],
res: {data: config.types.phase0.Genesis.defaultValue()},
},
});
// TODO: Extra tests to implement maybe
// getBlockHeaders
// - fetch without filters
// - parse slot param
// - parse parentRoot param
// - throw validation error on invalid slot
// - throw validation error on invalid parentRoot - not hex
// - throw validation error on invalid parentRoot - incorrect length
// - throw validation error on invalid parentRoot - missing 0x prefix
// getEpochCommittees
// - succeed without filters
// - succeed with filters
// - throw validation error on string slot
// - throw validation error on negative epoch
// getStateValidator
// - should get by root
// - should get by index
// - should not found state
// getStateValidatorsBalances
// - success with indices filter
// All others:
// - Failed to parse body
// - should not found state
});

View File

@@ -0,0 +1,28 @@
import {config} from "@chainsafe/lodestar-config/minimal";
import {BeaconParams} from "@chainsafe/lodestar-params";
import {Api, ReqTypes} from "../../src/routes/config";
import {getClient} from "../../src/client/config";
import {getRoutes} from "../../src/server/config";
import {runGenericServerTest} from "../utils/genericServerTest";
describe("config", () => {
runGenericServerTest<Api, ReqTypes>(config, getClient, getRoutes, {
getDepositContract: {
args: [],
res: {
data: {
chainId: 1,
address: Buffer.alloc(20, 1),
},
},
},
getForkSchedule: {
args: [],
res: {data: [config.types.phase0.Fork.defaultValue()]},
},
getSpec: {
args: [],
res: {data: BeaconParams.defaultValue()},
},
});
});

View File

@@ -0,0 +1,67 @@
import {ForkName} from "@chainsafe/lodestar-config";
import {fetch} from "cross-fetch";
import {config} from "@chainsafe/lodestar-config/minimal";
import {Api, ReqTypes, routesData} from "../../src/routes/debug";
import {getClient} from "../../src/client/debug";
import {getRoutes} from "../../src/server/debug";
import {runGenericServerTest} from "../utils/genericServerTest";
import {getMockApi, getTestServer} from "../utils/utils";
import {registerRoutesGroup} from "../../src/server";
import {expect} from "chai";
const root = Buffer.alloc(32, 1);
describe("debug", () => {
runGenericServerTest<Api, ReqTypes>(config, getClient, getRoutes, {
getHeads: {
args: [],
res: {data: [{slot: 1, root}]},
},
getState: {
args: ["head"],
res: {data: config.types.phase0.BeaconState.defaultValue()},
},
getStateV2: {
args: ["head"],
res: {data: config.types.altair.BeaconState.defaultValue(), version: ForkName.altair},
},
connectToPeer: {
args: ["peerId", ["multiaddr1", "multiaddr2"]],
res: undefined,
},
disconnectPeer: {
args: ["peerId"],
res: undefined,
},
});
// Get state by SSZ
describe("get SSZ response", () => {
const {baseUrl, server} = getTestServer();
const mockApi = getMockApi<Api>(routesData);
const routes = getRoutes(config, mockApi);
registerRoutesGroup(server, routes);
it("getState", async () => {
const state = config.types.phase0.BeaconState.defaultValue();
mockApi.getState.resolves({data: state});
const url = baseUrl + routesData.getState.url;
const res = await fetch(url, {
method: routesData.getState.method,
headers: {accept: "application/octet-stream"},
});
if (!res.ok) throw Error(res.statusText);
const arrayBuffer = await res.arrayBuffer();
expect(res.headers.get("Content-Type")).to.equal("application/octet-stream", "Wrong Content-Type header value");
const stateRes = config.types.phase0.BeaconState.deserialize(new Uint8Array(arrayBuffer));
expect(config.types.phase0.BeaconState.toJson(state)).to.deep.equal(
config.types.phase0.BeaconState.toJson(stateRes),
"returned state value is not equal"
);
});
});
});

View File

@@ -0,0 +1,80 @@
import {AbortController} from "abort-controller";
import {sleep} from "@chainsafe/lodestar-utils";
import {config} from "@chainsafe/lodestar-config/minimal";
import {Api, routesData, EventType, BeaconEvent} from "../../src/routes/events";
import {getClient} from "../../src/client/events";
import {getRoutes} from "../../src/server/events";
import {getMockApi, getTestServer} from "../utils/utils";
import {registerRoutesGroup} from "../../src/server";
import {expect} from "chai";
const root = Buffer.alloc(32, 1);
describe("events", () => {
const {baseUrl, server} = getTestServer();
const mockApi = getMockApi<Api>(routesData);
const routes = getRoutes(config, mockApi);
registerRoutesGroup(server, routes);
let controller: AbortController;
beforeEach(() => (controller = new AbortController()));
afterEach(() => controller.abort());
it("Receive events", async () => {
const eventHead1: BeaconEvent = {
type: EventType.head,
message: {
slot: 1,
block: root,
state: root,
epochTransition: false,
previousDutyDependentRoot: root,
currentDutyDependentRoot: root,
},
};
const eventHead2: BeaconEvent = {
type: EventType.head,
message: {
slot: 2,
block: root,
state: root,
epochTransition: true,
previousDutyDependentRoot: root,
currentDutyDependentRoot: root,
},
};
const eventChainReorg: BeaconEvent = {
type: EventType.chainReorg,
message: {
slot: 3,
depth: 2,
oldHeadBlock: root,
newHeadBlock: root,
oldHeadState: root,
newHeadState: root,
epoch: 1,
},
};
const eventsToSend: BeaconEvent[] = [eventHead1, eventHead2, eventChainReorg];
const eventsReceived: BeaconEvent[] = [];
await new Promise<void>((resolve) => {
mockApi.eventstream.callsFake(async (topics, signal, onEvent) => {
for (const event of eventsToSend) {
onEvent(event);
await sleep(5);
}
});
// Capture them on the client
const client = getClient(config, baseUrl);
client.eventstream([EventType.head, EventType.chainReorg], controller.signal, (event) => {
eventsReceived.push(event);
if (eventsReceived.length >= eventsToSend.length) resolve();
});
});
expect(eventsReceived).to.deep.equal(eventsToSend, "Wrong received events");
});
});

View File

@@ -0,0 +1,43 @@
import {config} from "@chainsafe/lodestar-config/minimal";
import {ProofType} from "@chainsafe/persistent-merkle-tree";
import {Api, ReqTypes} from "../../src/routes/lightclient";
import {getClient} from "../../src/client/lightclient";
import {getRoutes} from "../../src/server/lightclient";
import {runGenericServerTest} from "../utils/genericServerTest";
const root = Uint8Array.from(Buffer.alloc(32, 1));
describe("lightclient", () => {
const lightClientUpdate = config.types.altair.LightClientUpdate.defaultValue();
runGenericServerTest<Api, ReqTypes>(config, getClient, getRoutes, {
getStateProof: {
args: [
"head",
[
["validator", 0, "balance"],
["finalized_checkpoint", "root"],
],
],
res: {
data: {
type: ProofType.treeOffset,
offsets: [1, 2, 3],
leaves: [root, root, root, root],
},
},
},
getBestUpdates: {
args: [1, 2],
res: {data: [lightClientUpdate]},
},
getLatestUpdateFinalized: {
args: [],
res: {data: lightClientUpdate},
},
getLatestUpdateNonFinalized: {
args: [],
res: {data: lightClientUpdate},
},
});
});

View File

@@ -0,0 +1,62 @@
import {config} from "@chainsafe/lodestar-config/minimal";
import {Api, ReqTypes, NodePeer} from "../../src/routes/node";
import {getClient} from "../../src/client/node";
import {getRoutes} from "../../src/server/node";
import {runGenericServerTest} from "../utils/genericServerTest";
describe("node", () => {
const peerIdStr = "peerId";
const nodePeer: NodePeer = {
peerId: peerIdStr,
enr: "enr",
lastSeenP2pAddress: "lastSeenP2pAddress",
state: "connected",
direction: "inbound",
};
runGenericServerTest<Api, ReqTypes>(config, getClient, getRoutes, {
getNetworkIdentity: {
args: [],
res: {
data: {
peerId: peerIdStr,
enr: "enr",
p2pAddresses: ["p2pAddresses"],
discoveryAddresses: ["discoveryAddresses"],
metadata: config.types.altair.Metadata.defaultValue(),
},
},
},
getPeers: {
args: [{state: ["connected", "disconnected"], direction: ["inbound"]}],
res: {data: [nodePeer], meta: {count: 1}},
},
getPeer: {
args: [peerIdStr],
res: {data: nodePeer},
},
getPeerCount: {
args: [],
res: {
data: {
disconnected: 1,
connecting: 2,
connected: 3,
disconnecting: 4,
},
},
},
getNodeVersion: {
args: [],
res: {data: {version: "Lodestar/v0.20.0"}},
},
getSyncingStatus: {
args: [],
res: {data: {headSlot: 1, syncDistance: 2}},
},
getHealth: {
args: [],
res: undefined,
},
});
});

View File

@@ -0,0 +1,154 @@
import {ErrorAborted, TimeoutError} from "@chainsafe/lodestar-utils";
import {AbortController} from "abort-controller";
import chai, {expect} from "chai";
import chaiAsPromised from "chai-as-promised";
import fastify, {RouteOptions} from "fastify";
import {IncomingMessage} from "http";
import {HttpClient, HttpError} from "../../../src/client/utils";
chai.use(chaiAsPromised);
interface IUser {
id?: number;
name: string;
}
describe("httpClient test", () => {
const afterEachCallbacks: (() => Promise<any> | any)[] = [];
afterEach(async () => {
while (afterEachCallbacks.length > 0) {
const callback = afterEachCallbacks.pop();
if (callback) await callback();
}
});
const testRoute = {url: "/test-route", method: "GET" as const};
async function getServer(opts: RouteOptions): Promise<{baseUrl: string}> {
const server = fastify({logger: false});
server.route(opts);
const reqs = new Set<IncomingMessage>();
server.addHook("onRequest", async (req) => reqs.add(req.raw));
afterEachCallbacks.push(async () => {
for (const req of reqs) req.destroy();
await server.close();
});
return {baseUrl: await server.listen(0)};
}
async function getServerWithClient(opts: RouteOptions): Promise<HttpClient> {
const {baseUrl} = await getServer(opts);
return new HttpClient({baseUrl});
}
it("should handle successful GET request correctly", async () => {
const url = "/test-get";
const httpClient = await getServerWithClient({
url,
method: "GET",
handler: async () => ({test: 1}),
});
const resBody: IUser = await httpClient.json<IUser>({url, method: "GET"});
expect(resBody).to.deep.equal({test: 1}, "Wrong res body");
});
it("should handle successful POST request correctly", async () => {
const query = {a: "a", b: ["b1", "b2"]};
const body = {c: 4};
const resBody = {test: 1};
let queryReceived: any;
let bodyReceived: any;
const url = "/test-post";
const httpClient = await getServerWithClient({
url,
method: "POST",
handler: async (req) => {
queryReceived = req.query;
bodyReceived = req.body;
return resBody;
},
});
const resBodyReceived: IUser = await httpClient.json<IUser>({url, method: "POST", query, body});
expect(resBodyReceived).to.deep.equal(resBody, "Wrong resBody");
expect(queryReceived).to.deep.equal(query, "Wrong query");
expect(bodyReceived).to.deep.equal(body, "Wrong body");
});
it("should handle http status code 404 correctly", async () => {
const httpClient = await getServerWithClient({
url: "/no-route",
method: "GET",
handler: async () => ({}),
});
try {
await httpClient.json(testRoute);
return Promise.reject(Error("did not throw")); // So it doesn't gets catch {}
} catch (e) {
if (!(e instanceof HttpError)) throw Error(`Not an HttpError: ${(e as Error).message}`);
expect(e.message).to.equal("Not Found: Route GET:/test-route not found", "Wrong error message");
expect(e.status).to.equal(404, "Wrong error status code");
}
});
it("should handle http status code 500 correctly", async () => {
const httpClient = await getServerWithClient({
...testRoute,
handler: async () => {
throw Error("Test error");
},
});
try {
await httpClient.json(testRoute);
return Promise.reject(Error("did not throw"));
} catch (e) {
if (!(e instanceof HttpError)) throw Error(`Not an HttpError: ${(e as Error).message}`);
expect(e.message).to.equal("Internal Server Error: Test error");
expect(e.status).to.equal(500, "Wrong error status code");
}
});
it("should handle aborting request with timeout", async () => {
const {baseUrl} = await getServer({
...testRoute,
handler: async () => new Promise((r) => setTimeout(r, 1000)),
});
const httpClient = new HttpClient({baseUrl, timeoutMs: 10});
try {
await httpClient.json(testRoute);
return Promise.reject(Error("did not throw"));
} catch (e) {
if (!(e instanceof TimeoutError)) throw Error(`Not an TimeoutError: ${(e as Error).message}`);
}
});
it("should handle aborting all request with general AbortController", async () => {
const {baseUrl} = await getServer({
...testRoute,
handler: async () => new Promise((r) => setTimeout(r, 1000)),
});
const controller = new AbortController();
const signal = controller.signal;
const httpClient = new HttpClient({baseUrl, getAbortSignal: () => signal});
setTimeout(() => controller.abort(), 10);
try {
await httpClient.json(testRoute);
return Promise.reject(Error("did not throw"));
} catch (e) {
if (!(e instanceof ErrorAborted)) throw Error(`Not an ErrorAborted: ${(e as Error).message}`);
}
});
});

View File

@@ -0,0 +1,93 @@
import {ForkName} from "@chainsafe/lodestar-config";
import {config} from "@chainsafe/lodestar-config/minimal";
import {Api, ReqTypes} from "../../src/routes/validator";
import {getClient} from "../../src/client/validator";
import {getRoutes} from "../../src/server/validator";
import {runGenericServerTest} from "../utils/genericServerTest";
const ZERO_HASH = Buffer.alloc(32, 0);
describe("validator", () => {
runGenericServerTest<Api, ReqTypes>(config, getClient, getRoutes, {
getAttesterDuties: {
args: [1000, [1, 2, 3]],
res: {
data: [
{
pubkey: Buffer.alloc(48, 1),
validatorIndex: 2,
committeeIndex: 3,
committeeLength: 4,
committeesAtSlot: 5,
validatorCommitteeIndex: 6,
slot: 7,
},
],
dependentRoot: ZERO_HASH,
},
},
getProposerDuties: {
args: [1000],
res: {data: [{slot: 1, validatorIndex: 2, pubkey: Buffer.alloc(48, 3)}], dependentRoot: ZERO_HASH},
},
getSyncCommitteeDuties: {
args: [1000, [1, 2, 3]],
res: {
data: [{pubkey: Buffer.alloc(48, 1), validatorIndex: 2, validatorSyncCommitteeIndices: [3]}],
dependentRoot: ZERO_HASH,
},
},
produceBlock: {
args: [32000, Buffer.alloc(96, 1), "graffiti"],
res: {data: config.types.phase0.BeaconBlock.defaultValue(), version: ForkName.phase0},
},
produceAttestationData: {
args: [2, 32000],
res: {data: config.types.phase0.AttestationData.defaultValue()},
},
produceSyncCommitteeContribution: {
args: [32000, 2, ZERO_HASH],
res: {data: config.types.altair.SyncCommitteeContribution.defaultValue()},
},
getAggregatedAttestation: {
args: [ZERO_HASH, 32000],
res: {data: config.types.phase0.Attestation.defaultValue()},
},
publishAggregateAndProofs: {
args: [[config.types.phase0.SignedAggregateAndProof.defaultValue()]],
res: undefined,
},
publishContributionAndProofs: {
args: [[config.types.altair.SignedContributionAndProof.defaultValue()]],
res: undefined,
},
prepareBeaconCommitteeSubnet: {
args: [[{validatorIndex: 1, committeeIndex: 2, committeesAtSlot: 3, slot: 4, isAggregator: true}]],
res: undefined,
},
prepareSyncCommitteeSubnets: {
args: [[{validatorIndex: 1, syncCommitteeIndices: [2], untilEpoch: 3}]],
res: undefined,
},
});
// TODO: Extra tests to implement maybe
// getAttesterDuties
// - throw validation error on invalid epoch "a"
// - throw validation error on no validator indices
// - throw validation error on invalid validator index "a"
// getProposerDuties
// - throw validation error on invalid epoch "a"
// prepareBeaconCommitteeSubnet
// - throw validation error on missing param
// produceAttestationData
// - throw validation error on missing param
// produceBlock
// - throw validation error on missing randao reveal
// - throw validation error on invalid slot
});

View File

@@ -0,0 +1,56 @@
import {expect} from "chai";
import {IBeaconConfig} from "@chainsafe/lodestar-config";
import {RouteGeneric, ReqGeneric, Resolves} from "../../src/utils";
import {HttpClient, IHttpClient} from "../../src/client/utils";
import {ServerRoutes} from "../../src/server/utils";
import {getMockApi, getTestServer} from "./utils";
import {registerRoutesGroup} from "../../src/server";
type IgnoreVoid<T> = T extends void ? undefined : T;
export type GenericServerTestCases<Api extends Record<string, RouteGeneric>> = {
[K in keyof Api]: {
args: Parameters<Api[K]>;
res: IgnoreVoid<Resolves<Api[K]>>;
};
};
export function runGenericServerTest<
Api extends Record<string, RouteGeneric>,
ReqTypes extends {[K in keyof Api]: ReqGeneric}
>(
config: IBeaconConfig,
getClient: (config: IBeaconConfig, https: IHttpClient) => Api,
getRoutes: (config: IBeaconConfig, api: Api) => ServerRoutes<Api, ReqTypes>,
testCases: GenericServerTestCases<Api>
): void {
const mockApi = getMockApi<Api>(testCases);
const {baseUrl, server} = getTestServer();
const httpClient = new HttpClient({baseUrl});
const client = getClient(config, httpClient);
const routes = getRoutes(config, mockApi);
registerRoutesGroup(server, routes);
for (const key of Object.keys(testCases)) {
const routeId = key as keyof Api;
const testCase = testCases[routeId];
it(routeId as string, async () => {
// Register mock data for this route
mockApi[routeId].resolves(testCases[routeId].res as any);
// Do the call
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const res = await (client[routeId] as RouteGeneric)(...(testCase.args as any[]));
// Assert server handler called with correct args
expect(mockApi[routeId].callCount).to.equal(1, `mockApi[${routeId}] must be called once`);
expect(mockApi[routeId].getCall(0).args).to.deep.equal(testCase.args, `mockApi[${routeId}] wrong args`);
// Assert returned value is correct
expect(res).to.deep.equal(testCase.res, "Wrong returned value");
});
}
}

View File

@@ -0,0 +1,42 @@
import fastify, {FastifyInstance} from "fastify";
import querystring from "querystring";
import {mapValues} from "@chainsafe/lodestar-utils";
import Sinon from "sinon";
export function getTestServer(): {baseUrl: string; server: FastifyInstance} {
const port = Math.floor(Math.random() * (65535 - 49152)) + 49152;
const baseUrl = `http://127.0.0.1:${port}`;
const server = fastify({
ajv: {customOptions: {coerceTypes: "array"}},
querystringParser: querystring.parse,
});
server.addHook("onError", (request, reply, error, done) => {
// eslint-disable-next-line no-console
console.log(error);
done();
});
before("start server", async () => {
await new Promise((resolve, reject) => {
server.listen(port, function (err, address) {
if (err) reject(err);
else resolve(address);
});
});
});
after("stop server", async () => {
await server.close();
});
return {baseUrl, server};
}
/** Type helper to get a Sinon mock object type with Api */
export function getMockApi<Api extends Record<string, any>>(
routeKeys: Record<string, any>
): Sinon.SinonStubbedInstance<Api> & Api {
return mapValues(routeKeys, () => Sinon.stub()) as Sinon.SinonStubbedInstance<Api> & Api;
}

View File

@@ -0,0 +1,7 @@
{
"extends": "../../tsconfig.build.json",
"include": ["src"],
"compilerOptions": {
"outDir": "lib"
}
}

View File

@@ -0,0 +1,4 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {}
}

View File

@@ -24,12 +24,25 @@ export type EpochContextOpts = {
skipSyncPubkeys?: boolean;
};
export class PubkeyIndexMap extends Map<ByteVector, ValidatorIndex> {
get(key: ByteVector): ValidatorIndex | undefined {
return super.get((toHexString(key) as unknown) as ByteVector);
type PubkeyHex = string;
function toHexStringMaybe(hex: ByteVector | string): string {
return typeof hex === "string" ? hex : toHexString(hex);
}
export class PubkeyIndexMap {
private readonly map = new Map<PubkeyHex, ValidatorIndex>();
get size(): number {
return this.map.size;
}
set(key: ByteVector, value: ValidatorIndex): this {
return super.set((toHexString(key) as unknown) as ByteVector, value);
get(key: ByteVector | PubkeyHex): ValidatorIndex | undefined {
return this.map.get(toHexStringMaybe(key));
}
set(key: ByteVector | PubkeyHex, value: ValidatorIndex): void {
this.map.set(toHexStringMaybe(key), value);
}
}

View File

@@ -54,6 +54,7 @@
"@chainsafe/blst": "^0.2.0",
"@chainsafe/discv5": "^0.5.1",
"@chainsafe/lodestar": "^0.22.0",
"@chainsafe/lodestar-api": "^0.22.0",
"@chainsafe/lodestar-beacon-state-transition": "^0.22.0",
"@chainsafe/lodestar-config": "^0.22.0",
"@chainsafe/lodestar-db": "^0.22.0",

View File

@@ -1,5 +1,6 @@
import {Root} from "@chainsafe/lodestar-types";
import {ApiClientOverRest, SlashingProtection} from "@chainsafe/lodestar-validator";
import {getClient} from "@chainsafe/lodestar-api";
import {SlashingProtection} from "@chainsafe/lodestar-validator";
import {LevelDbController} from "@chainsafe/lodestar-db";
import {YargsError} from "../../../../../util";
import {IGlobalArgs} from "../../../../../options";
@@ -29,11 +30,11 @@ export async function getGenesisValidatorsRoot(args: IGlobalArgs & ISlashingProt
const server = args.server;
const config = getBeaconConfigFromArgs(args);
const api = ApiClientOverRest(config, server);
const api = getClient(config, {baseUrl: server});
const genesis = await api.beacon.getGenesis();
if (genesis) {
return genesis.genesisValidatorsRoot;
return genesis.data.genesisValidatorsRoot;
} else {
if (args.force) {
return Buffer.alloc(32, 0);

View File

@@ -5,7 +5,7 @@ import path from "path";
import {AbortController} from "abort-controller";
import {GENESIS_SLOT} from "@chainsafe/lodestar-params";
import {BeaconNode, BeaconDb, initStateFromAnchorState, createNodeJsLibp2p, nodeUtils} from "@chainsafe/lodestar";
import {IApiClient, SlashingProtection, Validator} from "@chainsafe/lodestar-validator";
import {SlashingProtection, Validator} from "@chainsafe/lodestar-validator";
import {LevelDbController} from "@chainsafe/lodestar-db";
import {onGracefulShutdown} from "../../util/process";
import {createEnr, createPeerId} from "../../config";
@@ -104,7 +104,7 @@ export async function devHandler(args: IDevArgs & IGlobalArgs): Promise<void> {
const dbPath = path.join(validatorsDbDir, "validators");
fs.mkdirSync(dbPath, {recursive: true});
const api = args.server === "memory" ? (node.api as IApiClient) : args.server;
const api = args.server === "memory" ? node.api : args.server;
const slashingProtection = new SlashingProtection({
config: config,
controller: new LevelDbController({name: dbPath}, {logger}),

View File

@@ -1,5 +1,5 @@
import {AbortController} from "abort-controller";
import {ApiClientOverRest} from "@chainsafe/lodestar-validator";
import {getClient} from "@chainsafe/lodestar-api";
import {Validator, SlashingProtection} from "@chainsafe/lodestar-validator";
import {LevelDbController} from "@chainsafe/lodestar-db";
import {getBeaconConfigFromArgs} from "../../config";
@@ -44,7 +44,7 @@ export async function validatorHandler(args: IValidatorCliArgs & IGlobalArgs): P
const controller = new AbortController();
onGracefulShutdownCbs.push(async () => controller.abort());
const api = ApiClientOverRest(config, args.server);
const api = getClient(config, {baseUrl: args.server});
const slashingProtection = new SlashingProtection({
config: config,
controller: new LevelDbController({name: dbPath}, {logger}),

View File

@@ -7,7 +7,11 @@ import {getBeaconPaths} from "../../../src/cmds/beacon/paths";
import {depositContractDeployBlock} from "../../../src/networks/pyrmont";
import {testFilesDir} from "../../utils";
import {getLodestarCliTestRunner} from "../commandRunner";
import {ApiNamespace} from "@chainsafe/lodestar/lib/api";
enum ApiNamespace {
DEBUG = "debug",
LODESTAR = "lodestar",
}
describe("cmds / init", function () {
const lodestar = getLodestarCliTestRunner();

View File

@@ -1,165 +1,201 @@
GNU LESSER GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
This version of the GNU Lesser General Public License incorporates
the terms and conditions of version 3 of the GNU General Public
License, supplemented by the additional permissions listed below.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
0. Additional Definitions.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
As used herein, "this License" refers to version 3 of the GNU Lesser
General Public License, and the "GNU GPL" refers to version 3 of the GNU
General Public License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"The Library" refers to a covered work governed by this License,
other than an Application or a Combined Work as defined below.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
An "Application" is any work that makes use of an interface provided
by the Library, but which is not otherwise based on the Library.
Defining a subclass of a class defined by the Library is deemed a mode
of using an interface provided by the Library.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
A "Combined Work" is a work produced by combining or linking an
Application with the Library. The particular version of the Library
with which the Combined Work was made is also called the "Linked
Version".
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
The "Minimal Corresponding Source" for a Combined Work means the
Corresponding Source for the Combined Work, excluding any source code
for portions of the Combined Work that, considered in isolation, are
based on the Application, and not on the Linked Version.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
The "Corresponding Application Code" for a Combined Work means the
object code and/or source code for the Application, including any data
and utility programs needed for reproducing the Combined Work from the
Application, but excluding the System Libraries of the Combined Work.
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
1. Exception to Section 3 of the GNU GPL.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
You may convey a covered work under sections 3 and 4 of this License
without being bound by section 3 of the GNU GPL.
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Conveying Modified Versions.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
If you modify a copy of the Library, and, in your modifications, a
facility refers to a function or data to be supplied by an Application
that uses the facility (other than as an argument passed when the
facility is invoked), then you may convey a copy of the modified
version:
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
a) under this License, provided that you make a good faith effort to
ensure that, in the event an Application does not supply the
function or data, the facility still operates, and performs
whatever part of its purpose remains meaningful, or
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
b) under the GNU GPL, with none of the additional permissions of
this License applicable to that copy.
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
3. Object Code Incorporating Material from Library Header Files.
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
The object code form of an Application may incorporate material from
a header file that is part of the Library. You may convey such object
code under terms of your choice, provided that, if the incorporated
material is not limited to numerical parameters, data structure
layouts and accessors, or small macros, inline functions and templates
(ten or fewer lines in length), you do both of the following:
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
a) Give prominent notice with each copy of the object code that the
Library is used in it and that the Library and its use are
covered by this License.
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
b) Accompany the object code with a copy of the GNU GPL and this license
document.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
4. Combined Works.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
You may convey a Combined Work under terms of your choice that,
taken together, effectively do not restrict modification of the
portions of the Library contained in the Combined Work and reverse
engineering for debugging such modifications, if you also do each of
the following:
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
a) Give prominent notice with each copy of the Combined Work that
the Library is used in it and that the Library and its use are
covered by this License.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
b) Accompany the Combined Work with a copy of the GNU GPL and this license
document.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
c) For a Combined Work that displays copyright notices during
execution, include the copyright notice for the Library among
these notices, as well as a reference directing the user to the
copies of the GNU GPL and this license document.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
d) Do one of the following:
END OF TERMS AND CONDITIONS
0) Convey the Minimal Corresponding Source under the terms of this
License, and the Corresponding Application Code in a form
suitable for, and under terms that permit, the user to
recombine or relink the Application with a modified version of
the Linked Version to produce a modified Combined Work, in the
manner specified by section 6 of the GNU GPL for conveying
Corresponding Source.
APPENDIX: How to apply the Apache License to your work.
1) Use a suitable shared library mechanism for linking with the
Library. A suitable mechanism is one that (a) uses at run time
a copy of the Library already present on the user's computer
system, and (b) will operate properly with a modified version
of the Library that is interface-compatible with the Linked
Version.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
e) Provide Installation Information, but only if you would otherwise
be required to provide such information under section 6 of the
GNU GPL, and only to the extent that such information is
necessary to install and execute a modified version of the
Combined Work produced by recombining or relinking the
Application with a modified version of the Linked Version. (If
you use option 4d0, the Installation Information must accompany
the Minimal Corresponding Source and Corresponding Application
Code. If you use option 4d1, you must provide the Installation
Information in the manner specified by section 6 of the GNU GPL
for conveying Corresponding Source.)
Copyright [yyyy] [name of copyright owner]
5. Combined Libraries.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
You may place library facilities that are a work based on the
Library side by side in a single library together with other library
facilities that are not Applications and are not covered by this
License, and convey such a combined library under terms of your
choice, if you do both of the following:
http://www.apache.org/licenses/LICENSE-2.0
a) Accompany the combined library with a copy of the same work based
on the Library, uncombined with any other library facilities,
conveyed under the terms of this License.
b) Give prominent notice with the combined library that part of it
is a work based on the Library, and explaining where to find the
accompanying uncombined form of the same work.
6. Revised Versions of the GNU Lesser General Public License.
The Free Software Foundation may publish revised and/or new versions
of the GNU Lesser General Public License from time to time. Such new
versions will be similar in spirit to the present version, but may
differ in detail to address new problems or concerns.
Each version is given a distinguishing version number. If the
Library as you received it specifies that a certain numbered version
of the GNU Lesser General Public License "or any later version"
applies to it, you have the option of following the terms and
conditions either of that published version or of any later version
published by the Free Software Foundation. If the Library as you
received it does not specify a version number of the GNU Lesser
General Public License, you may choose any version of the GNU Lesser
General Public License ever published by the Free Software Foundation.
If the Library as you received it specifies that a proxy can decide
whether future versions of the GNU Lesser General Public License shall
apply, that proxy's public statement of acceptance of any version is
permanent authorization for you to choose that version for the
Library.
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

View File

@@ -1,4 +1,4 @@
# Lodestar
# Lodestar Light-client
[![](https://img.shields.io/travis/com/ChainSafe/lodestar/master.svg?label=master&logo=travis "Master Branch (Travis)")](https://travis-ci.com/ChainSafe/lodestar)
[![Discord](https://img.shields.io/discord/593655374469660673.svg?label=Discord&logo=discord)](https://discord.gg/aMxzVcr)
@@ -29,4 +29,4 @@ Read our [contributors document](/CONTRIBUTING.md), [submit an issue](https://gi
## License
LGPL-3.0 [ChainSafe Systems](https://chainsafe.io)
Apache-2.0 [ChainSafe Systems](https://chainsafe.io)

View File

@@ -2,7 +2,7 @@
"name": "@chainsafe/lodestar-light-client",
"private": true,
"description": "A Typescript implementation of the eth2 light client",
"license": "LGPL-3.0",
"license": "Apache-2.0",
"author": "ChainSafe Systems",
"homepage": "https://github.com/ChainSafe/lodestar#readme",
"repository": {
@@ -37,6 +37,7 @@
},
"dependencies": {
"@chainsafe/bls": "6.0.1",
"@chainsafe/lodestar-api": "^0.22.0",
"@chainsafe/lodestar-beacon-state-transition": "^0.22.0",
"@chainsafe/lodestar-config": "^0.22.0",
"@chainsafe/lodestar-params": "^0.22.0",

View File

@@ -1,76 +0,0 @@
import {fetch} from "cross-fetch";
import {Json} from "@chainsafe/ssz";
import {deserializeProof, TreeOffsetProof} from "@chainsafe/persistent-merkle-tree";
import {altair, SyncPeriod, IBeaconSSZTypes} from "@chainsafe/lodestar-types";
export type Paths = (string | number)[][];
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, @typescript-eslint/explicit-function-return-type, @typescript-eslint/naming-convention
export function LightclientApiClient(beaconApiUrl: string, types: IBeaconSSZTypes) {
const prefix = "/eth/v1/lightclient";
async function get<T>(url: string): Promise<T> {
const res = await fetch(beaconApiUrl + prefix + url, {method: "GET"});
const body = (await res.json()) as T;
if (!res.ok) {
const errorBody = (body as unknown) as {message: string};
if (typeof errorBody === "object" && errorBody.message) {
throw Error(errorBody.message);
} else {
throw Error(res.statusText);
}
}
return body;
}
return {
/**
* GET /eth/v1/lightclient/best_updates/:periods
*/
async getBestUpdates(from: SyncPeriod, to: SyncPeriod): Promise<altair.LightClientUpdate[]> {
const res = await get<{data: Json[]}>(`/best_updates/${from}..${to}`);
return res.data.map((item) => types.altair.LightClientUpdate.fromJson(item, {case: "snake"}));
},
/**
* GET /eth/v1/lightclient/latest_update_finalized/
*/
async getLatestUpdateFinalized(): Promise<altair.LightClientUpdate | null> {
const res = await get<{data: Json}>("/latest_update_finalized/");
return types.altair.LightClientUpdate.fromJson(res.data, {case: "snake"});
},
/**
* GET /eth/v1/lightclient/latest_update_nonfinalized/
*/
async getLatestUpdateNonFinalized(): Promise<altair.LightClientUpdate | null> {
const res = await get<{data: Json}>("/latest_update_finalized/");
return types.altair.LightClientUpdate.fromJson(res.data, {case: "snake"});
},
/**
* POST /eth/v1/lodestar/proof/:stateId
*/
async getStateProof(stateId: string | number, paths: Paths): Promise<TreeOffsetProof> {
const res = await fetch(beaconApiUrl + prefix + `/proof/${stateId}`, {
method: "POST",
body: JSON.stringify({paths}),
});
if (!res.ok) {
const errorBody = (await res.json()) as {message: string};
if (typeof errorBody === "object" && errorBody.message) {
throw Error(errorBody.message);
} else {
throw Error(res.statusText);
}
}
const buffer = await res.arrayBuffer();
return deserializeProof(new Uint8Array(buffer)) as TreeOffsetProof;
},
};
}

View File

@@ -1,12 +1,12 @@
import mitt from "mitt";
import {getClient, Api} from "@chainsafe/lodestar-api";
import {altair, Root, Slot, SyncPeriod} from "@chainsafe/lodestar-types";
import {IBeaconConfig} from "@chainsafe/lodestar-config";
import {LIGHT_CLIENT_UPDATE_TIMEOUT} from "@chainsafe/lodestar-params";
import {computeSyncPeriodAtSlot, ZERO_HASH} from "@chainsafe/lodestar-beacon-state-transition";
import {TreeOffsetProof} from "@chainsafe/persistent-merkle-tree";
import {toHexString} from "@chainsafe/ssz";
import {Path, toHexString} from "@chainsafe/ssz";
import {BeaconBlockHeader} from "@chainsafe/lodestar-types/phase0";
import {LightclientApiClient, Paths} from "./apiClient";
import {IClock} from "../utils/clock";
import {deserializeSyncCommittee, isEmptyHeader, serializeSyncCommittee, sumBits} from "../utils/utils";
import {LightClientStoreFast} from "./types";
@@ -28,7 +28,7 @@ export type LightclientModules = {
const maxPeriodPerRequest = 32;
export class Lightclient {
readonly apiClient: ReturnType<typeof LightclientApiClient>;
readonly api: Api;
readonly emitter: LightclientEmitter = mitt();
readonly config: IBeaconConfig;
@@ -42,7 +42,7 @@ export class Lightclient {
this.clock = clock;
this.genesisValidatorsRoot = genesisValidatorsRoot;
this.beaconApiUrl = beaconApiUrl;
this.apiClient = LightclientApiClient(beaconApiUrl, config.types);
this.api = getClient(config, {baseUrl: beaconApiUrl});
this.clock.runEverySlot(this.syncToLatest);
}
@@ -52,12 +52,13 @@ export class Lightclient {
): Promise<Lightclient> {
const {config, beaconApiUrl} = modules;
const {slot, stateRoot} = trustedRoot;
const apiClient = LightclientApiClient(beaconApiUrl, config.types);
// TODO: Consider initializing only the lightclient namespace
const api = getClient(config, {baseUrl: beaconApiUrl});
const paths = getSyncCommitteesProofPaths(config);
const proof = await apiClient.getStateProof(toHexString(stateRoot), paths);
const proof = await api.lightclient.getStateProof(toHexString(stateRoot), paths);
const state = config.types.altair.BeaconState.createTreeBackedFromProof(stateRoot as Uint8Array, proof);
const state = config.types.altair.BeaconState.createTreeBackedFromProof(stateRoot as Uint8Array, proof.data);
const store: LightClientStoreFast = {
bestUpdates: new Map<SyncPeriod, altair.LightClientUpdate>(),
snapshot: {
@@ -99,7 +100,7 @@ export class Lightclient {
const currentPeriod = computeSyncPeriodAtSlot(this.config, currentSlot);
const periodRanges = chunkifyInclusiveRange(lastPeriod, currentPeriod, maxPeriodPerRequest);
for (const [fromPeriod, toPeriod] of periodRanges) {
const updates = await this.apiClient.getBestUpdates(fromPeriod, toPeriod);
const {data: updates} = await this.api.lightclient.getBestUpdates(fromPeriod, toPeriod);
for (const update of updates) {
this.processLightClientUpdate(update);
// Yield to the macro queue, verifying updates is somewhat expensive and we want responsiveness
@@ -109,14 +110,16 @@ export class Lightclient {
}
async syncToLatest(): Promise<void> {
const update = await this.apiClient.getLatestUpdateFinalized();
const {data: update} = await this.api.lightclient.getLatestUpdateFinalized();
if (update) {
this.processLightClientUpdate(update);
}
}
async getStateProof(paths: Paths): Promise<TreeOffsetProof> {
return await this.apiClient.getStateProof(toHexString(this.store.snapshot.header.stateRoot), paths);
async getStateProof(paths: Path[]): Promise<TreeOffsetProof> {
const stateId = toHexString(this.store.snapshot.header.stateRoot);
const res = await this.api.lightclient.getStateProof(stateId, paths);
return res.data as TreeOffsetProof;
}
onSlot = async (): Promise<void> => {

View File

@@ -1,26 +1,16 @@
import {
DefaultBody,
DefaultHeaders,
DefaultParams,
DefaultQuery,
HTTPMethod,
RequestHandler,
RouteShorthandOptions,
} from "fastify";
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
import {Stream} from "stream";
import {FastifyRequest} from "fastify";
import fastify, {FastifyInstance} from "fastify";
import {Api} from "@chainsafe/lodestar-api";
import {registerRoutes} from "@chainsafe/lodestar-api/server";
import {ILogger} from "@chainsafe/lodestar-utils";
import {IncomingMessage, Server, ServerResponse} from "http";
import fastify, {ServerOptions} from "fastify";
import fastifyCors from "fastify-cors";
import querystring from "querystring";
import {serializeProof} from "@chainsafe/persistent-merkle-tree";
import {IBeaconConfig} from "@chainsafe/lodestar-config";
import {LightClientUpdater} from "../src/server/LightClientUpdater";
import {TreeBacked} from "@chainsafe/ssz";
import {altair} from "@chainsafe/lodestar-types";
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
const maxPeriodsPerRequest = 128;
export type IStateRegen = {
@@ -39,174 +29,58 @@ export type ServerModules = {
stateRegen: IStateRegen;
};
export type ApiController<
Query = DefaultQuery,
Params = DefaultParams,
Body = DefaultBody,
Headers = DefaultHeaders
> = {
url: string;
method: HTTPMethod;
handler: RequestHandler<IncomingMessage, ServerResponse, Query, Params, Headers, Body>;
schema?: RouteShorthandOptions<Server, IncomingMessage, ServerResponse, Query, Params, Headers, Body>["schema"];
};
export async function startLightclientApiServer(
opts: ServerOpts,
modules: ServerModules
): Promise<fastify.FastifyInstance> {
export async function startLightclientApiServer(opts: ServerOpts, modules: ServerModules): Promise<FastifyInstance> {
const server = fastify({
logger: new FastifyLogger(modules.logger),
ajv: {
customOptions: {
coerceTypes: "array",
},
},
querystringParser: querystring.parse as ServerOptions["querystringParser"],
logger: false,
ajv: {customOptions: {coerceTypes: "array"}},
querystringParser: querystring.parse,
});
server.register(fastifyCors as any, {origin: "*"});
registerRoutes(server, modules);
const lightclientApi = getLightclientServerApi(modules);
registerRoutes(server, modules.config, {lightclient: lightclientApi} as Api, ["lightclient"]);
void server.register(fastifyCors, {origin: "*"});
await server.listen(opts.port, opts.host);
return server;
}
function registerRoutes(server: fastify.FastifyInstance, modules: ServerModules): void {
function getLightclientServerApi(modules: ServerModules): Api["lightclient"] {
const {config, lightClientUpdater, stateRegen} = modules;
const createProof: ApiController<null, {stateId: string}, {paths: (string | number)[][]}> = {
url: "/proof/:stateId",
method: "POST",
handler: async function (req, resp) {
const state = await stateRegen.getStateByRoot(req.params.stateId);
// the body isn't already JSON parsed
const body = JSON.parse((req.body as unknown) as string) as {paths: (string | number)[][]};
return {
async getStateProof(stateId, paths) {
const state = await stateRegen.getStateByRoot(stateId);
const tree = config.types.altair.BeaconState.createTreeBackedFromStruct(state);
const proof = tree.createProof(body.paths);
const serialized = serializeProof(proof);
return resp.status(200).header("Content-Type", "application/octet-stream").send(Buffer.from(serialized));
return {data: tree.createProof(paths)};
},
};
const getBestUpdates: ApiController<null, {periods: string}> = {
url: "/best_updates/:periods",
method: "GET",
handler: async function (req) {
const periods = parsePeriods(req.params.periods);
async getBestUpdates(from, to) {
const periods = linspace(from, to);
if (periods.length > maxPeriodsPerRequest) {
throw Error("Too many periods requested");
}
const items = await lightClientUpdater.getBestUpdates(periods);
return {
data: items.map((item) => config.types.altair.LightClientUpdate.toJson(item, {case: "snake"})),
};
return {data: await lightClientUpdater.getBestUpdates(periods)};
},
};
const getLatestUpdateFinalized: ApiController = {
url: "/latest_update_finalized/",
method: "GET",
handler: async function () {
async getLatestUpdateFinalized() {
const data = await lightClientUpdater.getLatestUpdateFinalized();
if (!data) throw Error("No update available");
return {
data: config.types.altair.LightClientUpdate.toJson(data, {case: "snake"}),
};
return {data};
},
};
const getLatestUpdateNonFinalized: ApiController = {
url: "/latest_update_nonfinalized/",
method: "GET",
handler: async function () {
async getLatestUpdateNonFinalized() {
const data = await lightClientUpdater.getLatestUpdateNonFinalized();
if (!data) throw Error("No update available");
return {
data: config.types.altair.LightClientUpdate.toJson(data, {case: "snake"}),
};
return {data};
},
};
const routes: ApiController<any, any>[] = [
createProof,
getBestUpdates,
getLatestUpdateFinalized,
getLatestUpdateNonFinalized,
];
server.register(
async function (fastify) {
for (const route of routes) {
fastify.route({
url: route.url,
method: route.method,
handler: route.handler,
schema: route.schema,
});
}
},
{prefix: "/eth/v1/lightclient"}
);
}
/**
* periods = 1 or = 1..4
*/
function parsePeriods(periodsArg: string): number[] {
if (periodsArg.includes("..")) {
const [fromStr, toStr] = periodsArg.split("..");
const from = parseInt(fromStr, 10);
const to = parseInt(toStr, 10);
const periods: number[] = [];
for (let i = from; i <= to; i++) periods.push(i);
return periods;
} else {
const period = parseInt(periodsArg, 10);
return [period];
function linspace(from: number, to: number): number[] {
const arr: number[] = [];
for (let i = from; i <= to; i++) {
arr.push(i);
}
}
/**
* Logs REST API request/response messages.
*/
export class FastifyLogger {
readonly stream: Stream;
readonly serializers = {
req: (req: IncomingMessage & FastifyRequest): {msg: string} => {
const url = req.url ? req.url.split("?")[0] : "-";
return {msg: `Req ${req.id} ${req.ip} ${req.method}:${url}`};
},
};
private log: ILogger;
constructor(logger: ILogger) {
this.log = logger;
this.stream = ({
write: this.handle,
} as unknown) as Stream;
}
private handle = (chunk: string): void => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const log = JSON.parse(chunk);
if (log.req) {
this.log.debug(log.req.msg);
} else if (log.res) {
this.log.debug(`Res ${log.reqId} - ${log.res.statusCode} ${log.responseTime}`);
}
if (log.err) {
if (log.level >= 50) {
this.log.error(`Request ${log.reqId} status ${log.res.statusCode}`, {}, log.err);
} else {
this.log.warn(`Request ${log.reqId} status ${log.res.statusCode}`, {}, log.err);
}
}
};
return arr;
}

View File

@@ -1,4 +1,4 @@
import fastify from "fastify";
import {FastifyInstance} from "fastify";
import {computeEpochAtSlot, computeSyncPeriodAtSlot} from "@chainsafe/lodestar-beacon-state-transition";
import {IBeaconConfig} from "@chainsafe/lodestar-config";
import {toHexString, TreeBacked} from "@chainsafe/ssz";
@@ -16,7 +16,7 @@ enum ApiStatus {
started = "started",
stopped = "stopped",
}
type ApiState = {status: ApiStatus.started; server: fastify.FastifyInstance} | {status: ApiStatus.stopped};
type ApiState = {status: ApiStatus.started; server: FastifyInstance} | {status: ApiStatus.stopped};
export class LightclientMockServer {
private readonly lightClientUpdater: LightClientUpdater;

View File

@@ -2,7 +2,6 @@
"extends": "../../tsconfig.build.json",
"include": ["src"],
"compilerOptions": {
"outDir": "lib",
"typeRoots": ["../../node_modules/@types", "./node_modules/@types"]
"outDir": "lib"
}
}

View File

@@ -1,6 +1,4 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"typeRoots": ["../../node_modules/@types", "./node_modules/@types"]
}
"compilerOptions": {}
}

View File

@@ -47,6 +47,7 @@
"dependencies": {
"@chainsafe/bls": "6.0.1",
"@chainsafe/discv5": "^0.5.1",
"@chainsafe/lodestar-api": "^0.22.0",
"@chainsafe/lodestar-beacon-state-transition": "^0.22.0",
"@chainsafe/lodestar-config": "^0.22.0",
"@chainsafe/lodestar-db": "^0.22.0",
@@ -62,13 +63,12 @@
"@types/datastore-level": "^1.1.1",
"abort-controller": "^3.0.0",
"bl": "^4.0.2",
"cross-fetch": "^3.0.6",
"cross-fetch": "^3.1.4",
"datastore-level": "^2.0.0",
"deepmerge": "^3.2.0",
"es6-promisify": "6.0.2",
"fastify": "2.15.3",
"fastify-cors": "^3.0.3",
"fastify-sse-v2": "^1.0.7",
"fastify": "3.15.1",
"fastify-cors": "^6.0.1",
"gc-stats": "^1.4.0",
"http-terminator": "^2.0.3",
"interface-datastore": "^2.0.0",
@@ -105,7 +105,7 @@
"@types/tmp": "^0.2.0",
"@types/varint": "^5.0.0",
"benchmark": "^2.1.4",
"eventsource": "^1.0.7",
"eventsource": "^1.1.0",
"rewiremock": "^3.14.3",
"rimraf": "^3.0.2",
"tmp": "^0.2.1"

View File

@@ -1,32 +1,23 @@
import {IApiOptions} from "../options";
import {IApi, IApiModules} from "./interface";
import {IBeaconApi, BeaconApi} from "./beacon";
import {INodeApi, NodeApi} from "./node";
import {IValidatorApi, ValidatorApi} from "./validator";
import {EventsApi, IEventsApi} from "./events";
import {DebugApi, IDebugApi} from "./debug";
import {ConfigApi, IConfigApi} from "./config";
import {LightclientApi, ILightclientApi} from "./lightclient";
import {LodestarApi, ILodestarApi} from "./lodestar";
import {Api} from "@chainsafe/lodestar-api";
import {ApiModules} from "./types";
import {getBeaconApi} from "./beacon";
import {getConfigApi} from "./config";
import {getDebugApi} from "./debug";
import {getEventsApi} from "./events";
import {getLightclientApi} from "./lightclient";
import {getLodestarApi} from "./lodestar";
import {getNodeApi} from "./node";
import {getValidatorApi} from "./validator";
export class Api implements IApi {
beacon: IBeaconApi;
node: INodeApi;
validator: IValidatorApi;
events: IEventsApi;
debug: IDebugApi;
config: IConfigApi;
lightclient: ILightclientApi;
lodestar: ILodestarApi;
constructor(opts: Partial<IApiOptions>, modules: IApiModules) {
this.beacon = new BeaconApi(opts, modules);
this.node = new NodeApi(opts, modules);
this.validator = new ValidatorApi(opts, modules);
this.events = new EventsApi(opts, modules);
this.debug = new DebugApi(opts, modules);
this.config = new ConfigApi(opts, modules);
this.lightclient = new LightclientApi(opts, modules);
this.lodestar = new LodestarApi(modules);
}
export function getApi(modules: ApiModules): Api {
return {
beacon: getBeaconApi(modules),
config: getConfigApi(modules),
debug: getDebugApi(modules),
events: getEventsApi(modules),
lightclient: getLightclientApi(modules),
lodestar: getLodestarApi(modules),
node: getNodeApi(modules),
validator: getValidatorApi(modules),
};
}

View File

@@ -1,53 +0,0 @@
/**
* @module api/rpc
*/
import {IBeaconConfig} from "@chainsafe/lodestar-config";
import {GENESIS_SLOT} from "@chainsafe/lodestar-params";
import {allForks, phase0} from "@chainsafe/lodestar-types";
import {LodestarEventIterator} from "@chainsafe/lodestar-utils";
import {ChainEvent, IBeaconChain} from "../../../chain";
import {IApiOptions} from "../../options";
import {ApiNamespace, IApiModules} from "../interface";
import {BeaconBlockApi, IBeaconBlocksApi} from "./blocks";
import {IBeaconApi} from "./interface";
import {BeaconPoolApi, IBeaconPoolApi} from "./pool";
import {IBeaconStateApi} from "./state/interface";
import {BeaconStateApi} from "./state/state";
export class BeaconApi implements IBeaconApi {
namespace: ApiNamespace;
state: IBeaconStateApi;
blocks: IBeaconBlocksApi;
pool: IBeaconPoolApi;
private readonly config: IBeaconConfig;
private readonly chain: IBeaconChain;
constructor(opts: Partial<IApiOptions>, modules: Pick<IApiModules, "config" | "chain" | "db" | "network" | "sync">) {
this.namespace = ApiNamespace.BEACON;
this.config = modules.config;
this.chain = modules.chain;
this.state = new BeaconStateApi(opts, modules);
this.blocks = new BeaconBlockApi(opts, modules);
this.pool = new BeaconPoolApi(opts, modules);
}
async getGenesis(): Promise<phase0.Genesis> {
const genesisForkVersion = this.config.getForkVersion(GENESIS_SLOT);
return {
genesisForkVersion,
genesisTime: BigInt(this.chain.genesisTime),
genesisValidatorsRoot: this.chain.genesisValidatorsRoot,
};
}
getBlockStream(): LodestarEventIterator<allForks.SignedBeaconBlock> {
return new LodestarEventIterator<allForks.SignedBeaconBlock>(({push}) => {
this.chain.emitter.on(ChainEvent.block, push);
return () => {
this.chain.emitter.off(ChainEvent.block, push);
};
});
}
}

View File

@@ -1,136 +1,130 @@
import {Root, phase0, allForks, Slot} from "@chainsafe/lodestar-types";
import {IBeaconConfig} from "@chainsafe/lodestar-config";
import {IBeaconChain} from "../../../../chain";
import {IBeaconDb} from "../../../../db";
import {IApiOptions} from "../../../options";
import {IApiModules} from "../../interface";
import {BlockId, IBeaconBlocksApi} from "./interface";
import {routes} from "@chainsafe/lodestar-api";
import {Api as IBeaconBlocksApi} from "@chainsafe/lodestar-api/lib/routes/beacon/block";
import {fromHexString} from "@chainsafe/ssz";
import {ApiModules} from "../../types";
import {resolveBlockId, toBeaconHeaderResponse} from "./utils";
import {IBeaconSync} from "../../../../sync";
import {INetwork} from "../../../../network/interface";
export * from "./interface";
export class BeaconBlockApi implements IBeaconBlocksApi {
private readonly config: IBeaconConfig;
private readonly chain: IBeaconChain;
private readonly db: IBeaconDb;
private readonly sync: IBeaconSync;
private readonly network: INetwork;
constructor(opts: Partial<IApiOptions>, modules: Pick<IApiModules, "config" | "network" | "sync" | "chain" | "db">) {
this.config = modules.config;
this.sync = modules.sync;
this.chain = modules.chain;
this.db = modules.db;
this.network = modules.network;
}
async getBlockHeaders(
filters: Partial<{slot: Slot; parentRoot: Root}>
): Promise<phase0.SignedBeaconHeaderResponse[]> {
const result: phase0.SignedBeaconHeaderResponse[] = [];
if (filters.parentRoot) {
const finalizedBlock = await this.db.blockArchive.getByParentRoot(filters.parentRoot);
if (finalizedBlock) {
result.push(toBeaconHeaderResponse(this.config, finalizedBlock, true));
}
const nonFinalizedBlockSummaries = this.chain.forkChoice.getBlockSummariesByParentRoot(
filters.parentRoot.valueOf() as Uint8Array
);
await Promise.all(
nonFinalizedBlockSummaries.map(async (summary) => {
const block = await this.db.block.get(summary.blockRoot);
if (block) {
const cannonical = this.chain.forkChoice.getCanonicalBlockSummaryAtSlot(block.message.slot);
if (cannonical) {
result.push(
toBeaconHeaderResponse(
this.config,
block,
this.config.types.Root.equals(cannonical.blockRoot, summary.blockRoot)
)
);
}
}
})
);
return result.filter(
(item) =>
// skip if no slot filter
!(filters.slot && filters.slot !== 0) || item.header.message.slot === filters.slot
);
}
const headSlot = this.chain.forkChoice.getHead().slot;
if (!filters.parentRoot && !filters.slot && filters.slot !== 0) {
filters.slot = headSlot;
}
if (filters.slot !== undefined) {
// future slot
if (filters.slot > headSlot) {
return [];
}
const canonicalBlock = await this.chain.getCanonicalBlockAtSlot(filters.slot);
// skip slot
if (!canonicalBlock) {
return [];
}
const canonicalRoot = this.config
.getForkTypes(canonicalBlock.message.slot)
.BeaconBlock.hashTreeRoot(canonicalBlock.message);
result.push(toBeaconHeaderResponse(this.config, canonicalBlock, true));
// fork blocks
await Promise.all(
this.chain.forkChoice.getBlockSummariesAtSlot(filters.slot).map(async (summary) => {
if (!this.config.types.Root.equals(summary.blockRoot, canonicalRoot)) {
const block = await this.db.block.get(summary.blockRoot);
export function getBeaconBlockApi({
chain,
config,
network,
db,
}: Pick<ApiModules, "chain" | "config" | "network" | "db">): IBeaconBlocksApi {
return {
async getBlockHeaders(filters) {
const result: routes.beacon.BlockHeaderResponse[] = [];
if (filters.parentRoot) {
const parentRoot = fromHexString(filters.parentRoot);
const finalizedBlock = await db.blockArchive.getByParentRoot(parentRoot);
if (finalizedBlock) {
result.push(toBeaconHeaderResponse(config, finalizedBlock, true));
}
const nonFinalizedBlockSummaries = chain.forkChoice.getBlockSummariesByParentRoot(parentRoot);
await Promise.all(
nonFinalizedBlockSummaries.map(async (summary) => {
const block = await db.block.get(summary.blockRoot);
if (block) {
result.push(toBeaconHeaderResponse(this.config, block));
const cannonical = chain.forkChoice.getCanonicalBlockSummaryAtSlot(block.message.slot);
if (cannonical) {
result.push(
toBeaconHeaderResponse(
config,
block,
config.types.Root.equals(cannonical.blockRoot, summary.blockRoot)
)
);
}
}
}
})
);
}
return result;
}
async getBlockHeader(blockId: BlockId): Promise<phase0.SignedBeaconHeaderResponse> {
const block = await this.getBlock(blockId);
return toBeaconHeaderResponse(this.config, block, true);
}
async getBlock(blockId: BlockId): Promise<allForks.SignedBeaconBlock> {
return await resolveBlockId(this.chain.forkChoice, this.db, blockId);
}
async getBlockRoot(blockId: BlockId): Promise<Root> {
// Fast path: From head state already available in memory get historical blockRoot
const slot = parseInt(blockId);
if (!Number.isNaN(slot)) {
const head = this.chain.forkChoice.getHead();
if (slot === head.slot) {
return head.blockRoot;
})
);
return {
data: result.filter(
(item) =>
// skip if no slot filter
!(filters.slot && filters.slot !== 0) || item.header.message.slot === filters.slot
),
};
}
if (slot < head.slot && head.slot <= slot + this.config.params.SLOTS_PER_HISTORICAL_ROOT) {
const state = this.chain.getHeadState();
return state.blockRoots[slot % this.config.params.SLOTS_PER_HISTORICAL_ROOT];
const headSlot = chain.forkChoice.getHead().slot;
if (!filters.parentRoot && !filters.slot && filters.slot !== 0) {
filters.slot = headSlot;
}
}
// Slow path
const block = await resolveBlockId(this.chain.forkChoice, this.db, blockId);
return this.config.getForkTypes(block.message.slot).BeaconBlock.hashTreeRoot(block.message);
}
if (filters.slot !== undefined) {
// future slot
if (filters.slot > headSlot) {
return {data: []};
}
async publishBlock(signedBlock: allForks.SignedBeaconBlock): Promise<void> {
await Promise.all([this.chain.receiveBlock(signedBlock), this.network.gossip.publishBeaconBlock(signedBlock)]);
}
const canonicalBlock = await chain.getCanonicalBlockAtSlot(filters.slot);
// skip slot
if (!canonicalBlock) {
return {data: []};
}
const canonicalRoot = config
.getForkTypes(canonicalBlock.message.slot)
.BeaconBlock.hashTreeRoot(canonicalBlock.message);
result.push(toBeaconHeaderResponse(config, canonicalBlock, true));
// fork blocks
await Promise.all(
chain.forkChoice.getBlockSummariesAtSlot(filters.slot).map(async (summary) => {
if (!config.types.Root.equals(summary.blockRoot, canonicalRoot)) {
const block = await db.block.get(summary.blockRoot);
if (block) {
result.push(toBeaconHeaderResponse(config, block));
}
}
})
);
}
return {data: result};
},
async getBlockHeader(blockId) {
const block = await resolveBlockId(chain.forkChoice, db, blockId);
return {data: toBeaconHeaderResponse(config, block, true)};
},
async getBlock(blockId) {
return {data: await resolveBlockId(chain.forkChoice, db, blockId)};
},
async getBlockV2(blockId) {
const block = await resolveBlockId(chain.forkChoice, db, blockId);
return {data: block, version: config.getForkName(block.message.slot)};
},
async getBlockAttestations(blockId) {
const block = await resolveBlockId(chain.forkChoice, db, blockId);
return {data: Array.from(block.message.body.attestations)};
},
async getBlockRoot(blockId) {
// Fast path: From head state already available in memory get historical blockRoot
const slot = typeof blockId === "string" ? parseInt(blockId) : blockId;
if (!Number.isNaN(slot)) {
const head = chain.forkChoice.getHead();
if (slot === head.slot) {
return {data: head.blockRoot};
}
if (slot < head.slot && head.slot <= slot + config.params.SLOTS_PER_HISTORICAL_ROOT) {
const state = chain.getHeadState();
return {data: state.blockRoots[slot % config.params.SLOTS_PER_HISTORICAL_ROOT]};
}
}
// Slow path
const block = await resolveBlockId(chain.forkChoice, db, blockId);
return {data: config.getForkTypes(block.message.slot).BeaconBlock.hashTreeRoot(block.message)};
},
async publishBlock(signedBlock) {
await Promise.all([chain.receiveBlock(signedBlock), network.gossip.publishBeaconBlock(signedBlock)]);
},
};
}

View File

@@ -1,11 +0,0 @@
import {Root, phase0, allForks, Slot} from "@chainsafe/lodestar-types";
export interface IBeaconBlocksApi {
getBlock(blockId: BlockId): Promise<allForks.SignedBeaconBlock>;
getBlockHeaders(filters: Partial<{slot: Slot; parentRoot: Root}>): Promise<phase0.SignedBeaconHeaderResponse[]>;
getBlockHeader(blockId: BlockId): Promise<phase0.SignedBeaconHeaderResponse>;
getBlockRoot(blockId: BlockId): Promise<Root>;
publishBlock(block: allForks.SignedBeaconBlock): Promise<void>;
}
export type BlockId = string | "head" | "genesis" | "finalized";

View File

@@ -1,8 +1,8 @@
import {phase0, allForks} from "@chainsafe/lodestar-types";
import {allForks} from "@chainsafe/lodestar-types";
import {routes} from "@chainsafe/lodestar-api";
import {blockToHeader} from "@chainsafe/lodestar-beacon-state-transition";
import {IBeaconConfig} from "@chainsafe/lodestar-config";
import {IForkChoice} from "@chainsafe/lodestar-fork-choice";
import {BlockId} from "./interface";
import {IBeaconDb} from "../../../../db";
import {GENESIS_SLOT} from "../../../../constants";
import {fromHexString} from "@chainsafe/ssz";
@@ -12,7 +12,7 @@ export function toBeaconHeaderResponse(
config: IBeaconConfig,
block: allForks.SignedBeaconBlock,
canonical = false
): phase0.SignedBeaconHeaderResponse {
): routes.beacon.BlockHeaderResponse {
return {
root: config.getForkTypes(block.message.slot).BeaconBlock.hashTreeRoot(block.message),
canonical,
@@ -26,7 +26,7 @@ export function toBeaconHeaderResponse(
export async function resolveBlockId(
forkChoice: IForkChoice,
db: IBeaconDb,
blockId: BlockId
blockId: routes.beacon.BlockId
): Promise<allForks.SignedBeaconBlock> {
const block = await resolveBlockIdOrNull(forkChoice, db, blockId);
if (!block) {
@@ -39,7 +39,7 @@ export async function resolveBlockId(
async function resolveBlockIdOrNull(
forkChoice: IForkChoice,
db: IBeaconDb,
blockId: BlockId
blockId: routes.beacon.BlockId
): Promise<allForks.SignedBeaconBlock | null> {
blockId = String(blockId).toLowerCase();
if (blockId === "head") {

View File

@@ -1,8 +1,31 @@
/**
* @module api/rpc
*/
import {routes} from "@chainsafe/lodestar-api";
import {GENESIS_SLOT} from "@chainsafe/lodestar-params";
import {ApiModules} from "../types";
import {getBeaconBlockApi} from "./blocks";
import {getBeaconPoolApi} from "./pool";
import {getBeaconStateApi} from "./state";
import {BeaconApi} from "./beacon";
import {IBeaconApi} from "./interface";
export function getBeaconApi(modules: Pick<ApiModules, "chain" | "config" | "network" | "db">): routes.beacon.Api {
const block = getBeaconBlockApi(modules);
const pool = getBeaconPoolApi(modules);
const state = getBeaconStateApi(modules);
export {BeaconApi, IBeaconApi};
const {chain, config} = modules;
return {
...block,
...pool,
...state,
async getGenesis() {
const genesisForkVersion = config.getForkVersion(GENESIS_SLOT);
return {
data: {
genesisForkVersion,
genesisTime: BigInt(chain.genesisTime),
genesisValidatorsRoot: chain.genesisValidatorsRoot,
},
};
},
};
}

View File

@@ -1,17 +0,0 @@
/**
* @module api/rpc
*/
import {allForks, phase0} from "@chainsafe/lodestar-types";
import {IStoppableEventIterable} from "@chainsafe/lodestar-utils";
import {IBeaconBlocksApi} from "./blocks";
import {IBeaconPoolApi} from "./pool";
import {IBeaconStateApi} from "./state/interface";
export interface IBeaconApi {
blocks: IBeaconBlocksApi;
state: IBeaconStateApi;
pool: IBeaconPoolApi;
getGenesis(): Promise<phase0.Genesis>;
getBlockStream(): IStoppableEventIterable<allForks.SignedBeaconBlock>;
}

View File

@@ -1,2 +1,134 @@
export * from "./interface";
export * from "./pool";
import {Api as IBeaconPoolApi} from "@chainsafe/lodestar-api/lib/routes/beacon/pool";
import {Epoch} from "@chainsafe/lodestar-types";
import {allForks} from "@chainsafe/lodestar-beacon-state-transition";
import {SYNC_COMMITTEE_SUBNET_COUNT} from "@chainsafe/lodestar-params";
import {IAttestationJob} from "../../../../chain";
import {AttestationError, AttestationErrorCode} from "../../../../chain/errors";
import {validateGossipAttestation} from "../../../../chain/validation";
import {validateGossipAttesterSlashing} from "../../../../chain/validation/attesterSlashing";
import {validateGossipProposerSlashing} from "../../../../chain/validation/proposerSlashing";
import {validateGossipVoluntaryExit} from "../../../../chain/validation/voluntaryExit";
import {validateSyncCommitteeSigOnly} from "../../../../chain/validation/syncCommittee";
import {ApiModules} from "../../types";
export function getBeaconPoolApi({
chain,
config,
network,
db,
}: Pick<ApiModules, "chain" | "config" | "network" | "db">): IBeaconPoolApi {
return {
async getPoolAttestations(filters) {
const attestations = (await db.attestation.values()).filter((attestation) => {
if (filters?.slot && filters?.slot !== attestation.data.slot) {
return false;
}
if (filters?.committeeIndex && filters?.committeeIndex !== attestation.data.index) {
return false;
}
return true;
});
return {data: attestations};
},
async getPoolAttesterSlashings() {
return {data: await db.attesterSlashing.values()};
},
async getPoolProposerSlashings() {
return {data: await db.proposerSlashing.values()};
},
async getPoolVoluntaryExits() {
return {data: await db.voluntaryExit.values()};
},
async submitPoolAttestations(attestations) {
for (const attestation of attestations) {
const attestationJob = {
attestation,
validSignature: false,
} as IAttestationJob;
let attestationTargetState;
try {
attestationTargetState = await chain.regen.getCheckpointState(attestation.data.target);
} catch (e) {
throw new AttestationError({
code: AttestationErrorCode.MISSING_ATTESTATION_TARGET_STATE,
error: e as Error,
job: attestationJob,
});
}
const subnet = allForks.computeSubnetForAttestation(config, attestationTargetState.epochCtx, attestation);
await validateGossipAttestation(config, chain, db, attestationJob, subnet);
await Promise.all([
network.gossip.publishBeaconAttestation(attestation, subnet),
db.attestation.add(attestation),
]);
}
},
async submitPoolAttesterSlashing(slashing) {
await validateGossipAttesterSlashing(config, chain, db, slashing);
await Promise.all([network.gossip.publishAttesterSlashing(slashing), db.attesterSlashing.add(slashing)]);
},
async submitPoolProposerSlashing(slashing) {
await validateGossipProposerSlashing(config, chain, db, slashing);
await Promise.all([network.gossip.publishProposerSlashing(slashing), db.proposerSlashing.add(slashing)]);
},
async submitPoolVoluntaryExit(exit) {
await validateGossipVoluntaryExit(config, chain, db, exit);
await Promise.all([network.gossip.publishVoluntaryExit(exit), db.voluntaryExit.add(exit)]);
},
/**
* POST `/eth/v1/beacon/pool/sync_committees`
*
* Submits sync committee signature objects to the node.
* Sync committee signatures are not present in phase0, but are required for Altair networks.
* If a sync committee signature is validated successfully the node MUST publish that sync committee signature on all applicable subnets.
* If one or more sync committee signatures fail validation the node MUST return a 400 error with details of which sync committee signatures have failed, and why.
*
* https://github.com/ethereum/eth2.0-APIs/pull/135
*/
async submitPoolSyncCommitteeSignatures(signatures) {
// Fetch states for all slots of the `signatures`
const slots = new Set<Epoch>();
for (const signature of signatures) {
slots.add(signature.slot);
}
// TODO: Fetch states at signature slots
const state = chain.getHeadState();
// TODO: Cache this value
const SYNC_COMMITTEE_SUBNET_SIZE = Math.floor(config.params.SYNC_COMMITTEE_SIZE / SYNC_COMMITTEE_SUBNET_COUNT);
await Promise.all(
signatures.map(async (signature) => {
const indexesInCommittee = state.currSyncComitteeValidatorIndexMap.get(signature.validatorIndex);
if (indexesInCommittee === undefined || indexesInCommittee.length === 0) {
return; // Not a sync committee member
}
// Verify signature only, all other data is very likely to be correct, since the `signature` object is created by this node.
// Worst case if `signature` is not valid, gossip peers will drop it and slightly downscore us.
await validateSyncCommitteeSigOnly(chain, state, signature);
await Promise.all(
indexesInCommittee.map(async (indexInCommittee) => {
// Sync committee subnet members are just sequential in the order they appear in SyncCommitteeIndexes array
const subnet = Math.floor(indexInCommittee / SYNC_COMMITTEE_SUBNET_SIZE);
const indexInSubCommittee = indexInCommittee % SYNC_COMMITTEE_SUBNET_SIZE;
db.syncCommittee.add(subnet, signature, indexInSubCommittee);
await network.gossip.publishSyncCommitteeSignature(signature, subnet);
})
);
})
);
},
};
}

View File

@@ -1,18 +0,0 @@
import {altair, CommitteeIndex, phase0, Slot} from "@chainsafe/lodestar-types";
export interface IAttestationFilters {
slot: Slot;
committeeIndex: CommitteeIndex;
}
export interface IBeaconPoolApi {
getAttestations(filters?: Partial<IAttestationFilters>): Promise<phase0.Attestation[]>;
getAttesterSlashings(): Promise<phase0.AttesterSlashing[]>;
getProposerSlashings(): Promise<phase0.ProposerSlashing[]>;
getVoluntaryExits(): Promise<phase0.SignedVoluntaryExit[]>;
submitAttestations(attestations: phase0.Attestation[]): Promise<void>;
submitAttesterSlashing(slashing: phase0.AttesterSlashing): Promise<void>;
submitProposerSlashing(slashing: phase0.ProposerSlashing): Promise<void>;
submitVoluntaryExit(exit: phase0.SignedVoluntaryExit): Promise<void>;
submitSyncCommitteeSignatures(signatures: altair.SyncCommitteeSignature[]): Promise<void>;
}

View File

@@ -1,144 +0,0 @@
import {IBeaconConfig} from "@chainsafe/lodestar-config";
import {altair, Epoch, phase0} from "@chainsafe/lodestar-types";
import {allForks} from "@chainsafe/lodestar-beacon-state-transition";
import {SYNC_COMMITTEE_SUBNET_COUNT} from "@chainsafe/lodestar-params";
import {IAttestationJob, IBeaconChain} from "../../../../chain";
import {AttestationError, AttestationErrorCode} from "../../../../chain/errors";
import {validateGossipAttestation} from "../../../../chain/validation";
import {validateGossipAttesterSlashing} from "../../../../chain/validation/attesterSlashing";
import {validateGossipProposerSlashing} from "../../../../chain/validation/proposerSlashing";
import {validateGossipVoluntaryExit} from "../../../../chain/validation/voluntaryExit";
import {validateSyncCommitteeSigOnly} from "../../../../chain/validation/syncCommittee";
import {IBeaconDb} from "../../../../db";
import {INetwork} from "../../../../network";
import {IBeaconSync} from "../../../../sync";
import {IApiOptions} from "../../../options";
import {IApiModules} from "../../interface";
import {IAttestationFilters, IBeaconPoolApi} from "./interface";
export class BeaconPoolApi implements IBeaconPoolApi {
private readonly config: IBeaconConfig;
private readonly db: IBeaconDb;
private readonly sync: IBeaconSync;
private readonly network: INetwork;
private readonly chain: IBeaconChain;
constructor(opts: Partial<IApiOptions>, modules: Pick<IApiModules, "config" | "chain" | "sync" | "network" | "db">) {
this.config = modules.config;
this.db = modules.db;
this.sync = modules.sync;
this.network = modules.network;
this.chain = modules.chain;
}
async getAttestations(filters: Partial<IAttestationFilters> = {}): Promise<phase0.Attestation[]> {
return (await this.db.attestation.values()).filter((attestation) => {
if (filters.slot && filters.slot !== attestation.data.slot) {
return false;
}
if (filters.committeeIndex && filters.committeeIndex !== attestation.data.index) {
return false;
}
return true;
});
}
async getAttesterSlashings(): Promise<phase0.AttesterSlashing[]> {
return this.db.attesterSlashing.values();
}
async getProposerSlashings(): Promise<phase0.ProposerSlashing[]> {
return this.db.proposerSlashing.values();
}
async getVoluntaryExits(): Promise<phase0.SignedVoluntaryExit[]> {
return this.db.voluntaryExit.values();
}
async submitAttestations(attestations: phase0.Attestation[]): Promise<void> {
for (const attestation of attestations) {
const attestationJob = {
attestation,
validSignature: false,
} as IAttestationJob;
let attestationTargetState;
try {
attestationTargetState = await this.chain.regen.getCheckpointState(attestation.data.target);
} catch (e) {
throw new AttestationError({
code: AttestationErrorCode.MISSING_ATTESTATION_TARGET_STATE,
error: e as Error,
job: attestationJob,
});
}
const subnet = allForks.computeSubnetForAttestation(this.config, attestationTargetState.epochCtx, attestation);
await validateGossipAttestation(this.config, this.chain, this.db, attestationJob, subnet);
await Promise.all([
this.network.gossip.publishBeaconAttestation(attestation, subnet),
this.db.attestation.add(attestation),
]);
}
}
async submitAttesterSlashing(slashing: phase0.AttesterSlashing): Promise<void> {
await validateGossipAttesterSlashing(this.config, this.chain, this.db, slashing);
await Promise.all([this.network.gossip.publishAttesterSlashing(slashing), this.db.attesterSlashing.add(slashing)]);
}
async submitProposerSlashing(slashing: phase0.ProposerSlashing): Promise<void> {
await validateGossipProposerSlashing(this.config, this.chain, this.db, slashing);
await Promise.all([this.network.gossip.publishProposerSlashing(slashing), this.db.proposerSlashing.add(slashing)]);
}
async submitVoluntaryExit(exit: phase0.SignedVoluntaryExit): Promise<void> {
await validateGossipVoluntaryExit(this.config, this.chain, this.db, exit);
await Promise.all([this.network.gossip.publishVoluntaryExit(exit), this.db.voluntaryExit.add(exit)]);
}
/**
* POST `/eth/v1/beacon/pool/sync_committees`
*
* Submits sync committee signature objects to the node.
* Sync committee signatures are not present in phase0, but are required for Altair networks.
* If a sync committee signature is validated successfully the node MUST publish that sync committee signature on all applicable subnets.
* If one or more sync committee signatures fail validation the node MUST return a 400 error with details of which sync committee signatures have failed, and why.
*
* https://github.com/ethereum/eth2.0-APIs/pull/135
*/
async submitSyncCommitteeSignatures(signatures: altair.SyncCommitteeSignature[]): Promise<void> {
// Fetch states for all slots of the `signatures`
const slots = new Set<Epoch>();
for (const signature of signatures) {
slots.add(signature.slot);
}
// TODO: Fetch states at signature slots
const state = this.chain.getHeadState();
// TODO: Cache this value
const SYNC_COMMITTEE_SUBNET_SIZE = Math.floor(this.config.params.SYNC_COMMITTEE_SIZE / SYNC_COMMITTEE_SUBNET_COUNT);
await Promise.all(
signatures.map(async (signature) => {
const indexesInCommittee = state.currSyncComitteeValidatorIndexMap.get(signature.validatorIndex);
if (indexesInCommittee === undefined || indexesInCommittee.length === 0) {
return; // Not a sync committee member
}
// Verify signature only, all other data is very likely to be correct, since the `signature` object is created by this node.
// Worst case if `signature` is not valid, gossip peers will drop it and slightly downscore us.
await validateSyncCommitteeSigOnly(this.chain, state, signature);
await Promise.all(
indexesInCommittee.map(async (indexInCommittee) => {
// Sync committee subnet members are just sequential in the order they appear in SyncCommitteeIndexes array
const subnet = Math.floor(indexInCommittee / SYNC_COMMITTEE_SUBNET_SIZE);
const indexInSubCommittee = indexInCommittee % SYNC_COMMITTEE_SUBNET_SIZE;
this.db.syncCommittee.add(subnet, signature, indexInSubCommittee);
await this.network.gossip.publishSyncCommitteeSignature(signature, subnet);
})
);
})
);
}
}

View File

@@ -1,2 +1,184 @@
export * from "./interface";
export * from "./state";
import {routes} from "@chainsafe/lodestar-api";
import {Api as IBeaconStateApi} from "@chainsafe/lodestar-api/lib/routes/beacon/state";
import {allForks, altair} from "@chainsafe/lodestar-types";
import {readonlyValues} from "@chainsafe/ssz";
import {computeEpochAtSlot, getCurrentEpoch} from "@chainsafe/lodestar-beacon-state-transition";
import {ApiError} from "../../errors";
import {ApiModules} from "../../types";
import {
filterStateValidatorsByStatuses,
getEpochBeaconCommittees,
getStateValidatorIndex,
getSyncCommittees,
getValidatorStatus,
resolveStateId,
toValidatorResponse,
} from "./utils";
export function getBeaconStateApi({chain, config, db}: Pick<ApiModules, "chain" | "config" | "db">): IBeaconStateApi {
async function getState(stateId: routes.beacon.StateId): Promise<allForks.BeaconState> {
return await resolveStateId(config, chain, db, stateId);
}
return {
async getStateRoot(stateId) {
const state = await getState(stateId);
return {data: config.getForkTypes(state.slot).BeaconState.hashTreeRoot(state)};
},
async getStateFork(stateId) {
const state = await getState(stateId);
return {data: state.fork};
},
async getStateFinalityCheckpoints(stateId) {
const state = await getState(stateId);
return {
data: {
currentJustified: state.currentJustifiedCheckpoint,
previousJustified: state.previousJustifiedCheckpoint,
finalized: state.finalizedCheckpoint,
},
};
},
async getStateValidators(stateId, filters) {
const state = await resolveStateId(config, chain, db, stateId);
const currentEpoch = getCurrentEpoch(config, state);
const validators: routes.beacon.ValidatorResponse[] = [];
if (filters?.indices) {
for (const id of filters.indices) {
const validatorIndex = getStateValidatorIndex(id, state, chain);
if (validatorIndex != null) {
const validator = state.validators[validatorIndex];
if (filters.statuses && !filters.statuses.includes(getValidatorStatus(validator, currentEpoch))) {
continue;
}
const validatorResponse = toValidatorResponse(
validatorIndex,
validator,
state.balances[validatorIndex],
currentEpoch
);
validators.push(validatorResponse);
}
}
return {data: validators};
} else if (filters?.statuses) {
const validatorsByStatus = filterStateValidatorsByStatuses(filters.statuses, state, chain, currentEpoch);
return {data: validatorsByStatus};
}
let index = 0;
const resp: routes.beacon.ValidatorResponse[] = [];
for (const v of readonlyValues(state.validators)) {
resp.push(toValidatorResponse(index, v, state.balances[index], currentEpoch));
index++;
}
return {data: resp};
},
async getStateValidator(stateId, validatorId) {
const state = await resolveStateId(config, chain, db, stateId);
const validatorIndex = getStateValidatorIndex(validatorId, state, chain);
if (validatorIndex == null) {
throw new ApiError(404, "Validator not found");
}
return {
data: toValidatorResponse(
validatorIndex,
state.validators[validatorIndex],
state.balances[validatorIndex],
getCurrentEpoch(config, state)
),
};
},
async getStateValidatorBalances(stateId, indices) {
const state = await resolveStateId(config, chain, db, stateId);
if (indices) {
const headState = chain.getHeadState();
const balances: routes.beacon.ValidatorBalance[] = [];
for (const id of indices) {
if (typeof id === "number") {
if (state.validators.length <= id) {
continue;
}
balances.push({index: id, balance: state.balances[id]});
} else {
const index = headState.pubkey2index.get(id);
if (index != null && index <= state.validators.length) {
balances.push({index, balance: state.balances[index]});
}
}
}
return {data: balances};
}
const balances = Array.from(readonlyValues(state.balances), (balance, index) => {
return {
index,
balance,
};
});
return {data: balances};
},
async getEpochCommittees(stateId, filters) {
const state = await resolveStateId(config, chain, db, stateId);
const committes = getEpochBeaconCommittees(
config,
state,
filters?.epoch ?? computeEpochAtSlot(config, state.slot)
);
const committesFlat = committes.flatMap((slotCommittees, committeeIndex) => {
if (filters?.index && filters.index !== committeeIndex) {
return [];
}
return slotCommittees.flatMap((committee, slot) => {
if (filters?.slot && filters.slot !== slot) {
return [];
}
return [
{
index: committeeIndex,
slot,
validators: committee,
},
];
});
});
return {data: committesFlat};
},
/**
* Retrieves the sync committees for the given state.
* @param epoch Fetch sync committees for the given epoch. If not present then the sync committees for the epoch of the state will be obtained.
*/
async getEpochSyncCommittees(stateId, epoch) {
// TODO: Should pick a state with the provided epoch too
const state = (await resolveStateId(config, chain, db, stateId)) as altair.BeaconState;
// TODO: If possible compute the syncCommittees in advance of the fork and expose them here.
// So the validators can prepare and potentially attest the first block. Not critical tho, it's very unlikely
const stateEpoch = computeEpochAtSlot(config, state.slot);
if (stateEpoch < config.params.ALTAIR_FORK_EPOCH) {
throw new ApiError(400, "Requested state before ALTAIR_FORK_EPOCH");
}
return {
data: {
validators: getSyncCommittees(config, state, epoch ?? stateEpoch),
// TODO: This is not used by the validator and will be deprecated soon
validatorAggregates: [],
},
};
},
};
}

View File

@@ -1,50 +0,0 @@
import {
phase0,
altair,
allForks,
BLSPubkey,
CommitteeIndex,
Epoch,
Root,
Slot,
ValidatorIndex,
} from "@chainsafe/lodestar-types";
export interface IBeaconStateApi {
getStateRoot(stateId: StateId): Promise<Root>;
getState(stateId: StateId): Promise<allForks.BeaconState>;
getStateFinalityCheckpoints(stateId: StateId): Promise<phase0.FinalityCheckpoints>;
getStateValidators(stateId: StateId, filters?: IValidatorFilters): Promise<phase0.ValidatorResponse[]>;
getStateValidator(stateId: StateId, validatorId: BLSPubkey | ValidatorIndex): Promise<phase0.ValidatorResponse>;
getStateValidatorBalances(
stateId: StateId,
indices?: (BLSPubkey | ValidatorIndex)[]
): Promise<phase0.ValidatorBalance[]>;
getStateCommittees(stateId: StateId, filters?: ICommitteesFilters): Promise<phase0.BeaconCommitteeResponse[]>;
getEpochSyncCommittees(stateId: StateId, epoch?: Epoch): Promise<altair.SyncCommitteeByValidatorIndices>;
getFork(stateId: StateId): Promise<phase0.Fork>;
}
export type StateId = string | "head" | "genesis" | "finalized" | "justified";
export type ValidatorStatus =
| "active"
| "pending_initialized"
| "pending_queued"
| "active_ongoing"
| "active_exiting"
| "active_slashed"
| "exited_unslashed"
| "exited_slashed"
| "withdrawal_possible"
| "withdrawal_done";
export interface IValidatorFilters {
indices?: (BLSPubkey | ValidatorIndex)[];
statuses?: ValidatorStatus[];
}
export interface ICommitteesFilters {
epoch?: Epoch;
index?: CommitteeIndex;
slot?: Slot;
}

View File

@@ -1,190 +0,0 @@
import {IBeaconConfig} from "@chainsafe/lodestar-config";
import {Root, phase0, allForks, BLSPubkey, Epoch, altair} from "@chainsafe/lodestar-types";
import {List, readonlyValues} from "@chainsafe/ssz";
import {computeEpochAtSlot, getCurrentEpoch} from "@chainsafe/lodestar-beacon-state-transition";
import {IBeaconChain} from "../../../../chain/interface";
import {IBeaconDb} from "../../../../db";
import {IApiOptions} from "../../../options";
import {IApiModules} from "../../interface";
import {getStateValidatorIndex} from "../../utils";
import {ApiError} from "../../errors";
import {IBeaconStateApi, ICommitteesFilters, IValidatorFilters, StateId} from "./interface";
import {
filterStateValidatorsByStatuses,
getEpochBeaconCommittees,
getSyncCommittees,
getValidatorStatus,
resolveStateId,
toValidatorResponse,
} from "./utils";
export class BeaconStateApi implements IBeaconStateApi {
private readonly config: IBeaconConfig;
private readonly db: IBeaconDb;
private readonly chain: IBeaconChain;
constructor(opts: Partial<IApiOptions>, modules: Pick<IApiModules, "config" | "db" | "chain">) {
this.config = modules.config;
this.db = modules.db;
this.chain = modules.chain;
}
async getStateRoot(stateId: StateId): Promise<Root> {
const state = await this.getState(stateId);
return this.config.getForkTypes(state.slot).BeaconState.hashTreeRoot(state);
}
async getStateFinalityCheckpoints(stateId: StateId): Promise<phase0.FinalityCheckpoints> {
const state = await this.getState(stateId);
return {
currentJustified: state.currentJustifiedCheckpoint,
previousJustified: state.previousJustifiedCheckpoint,
finalized: state.finalizedCheckpoint,
};
}
async getStateValidators(stateId: StateId, filters?: IValidatorFilters): Promise<phase0.ValidatorResponse[]> {
const state = await resolveStateId(this.config, this.chain, this.db, stateId);
const currentEpoch = getCurrentEpoch(this.config, state);
const validators: phase0.ValidatorResponse[] = [];
if (filters?.indices) {
for (const id of filters.indices) {
const validatorIndex = getStateValidatorIndex(id, state, this.chain);
if (validatorIndex != null) {
const validator = state.validators[validatorIndex];
if (filters.statuses && !filters.statuses.includes(getValidatorStatus(validator, currentEpoch))) {
continue;
}
const validatorResponse = toValidatorResponse(
validatorIndex,
validator,
state.balances[validatorIndex],
currentEpoch
);
validators.push(validatorResponse);
}
}
return validators;
} else if (filters?.statuses) {
const validatorsByStatus = filterStateValidatorsByStatuses(filters.statuses, state, this.chain, currentEpoch);
return validatorsByStatus;
}
let index = 0;
const resp: phase0.ValidatorResponse[] = [];
for (const v of readonlyValues(state.validators)) {
resp.push(toValidatorResponse(index, v, state.balances[index], currentEpoch));
index++;
}
return resp;
}
async getStateValidator(
stateId: StateId,
validatorId: phase0.ValidatorIndex | Root
): Promise<phase0.ValidatorResponse> {
const state = await resolveStateId(this.config, this.chain, this.db, stateId);
const validatorIndex = getStateValidatorIndex(validatorId, state, this.chain);
if (validatorIndex == null) {
throw new ApiError(404, "Validator not found");
}
return toValidatorResponse(
validatorIndex,
state.validators[validatorIndex],
state.balances[validatorIndex],
getCurrentEpoch(this.config, state)
);
}
async getStateValidatorBalances(
stateId: StateId,
indices?: (phase0.ValidatorIndex | BLSPubkey)[]
): Promise<phase0.ValidatorBalance[]> {
const state = await resolveStateId(this.config, this.chain, this.db, stateId);
if (indices) {
const headState = this.chain.getHeadState();
const balances: phase0.ValidatorBalance[] = [];
for (const id of indices) {
if (typeof id === "number") {
if (state.validators.length <= id) {
continue;
}
balances.push({index: id, balance: state.balances[id]});
} else {
const index = headState.pubkey2index.get(id);
if (index != null && index <= state.validators.length) {
balances.push({index, balance: state.balances[index]});
}
}
}
return balances;
}
return Array.from(readonlyValues(state.balances), (balance, index) => {
return {
index,
balance,
};
});
}
async getStateCommittees(stateId: StateId, filters?: ICommitteesFilters): Promise<phase0.BeaconCommitteeResponse[]> {
const state = await resolveStateId(this.config, this.chain, this.db, stateId);
const committes = getEpochBeaconCommittees(
this.config,
state,
filters?.epoch ?? computeEpochAtSlot(this.config, state.slot)
);
return committes.flatMap((slotCommittees, committeeIndex) => {
if (filters?.index && filters.index !== committeeIndex) {
return [];
}
return slotCommittees.flatMap((committee, slot) => {
if (filters?.slot && filters.slot !== slot) {
return [];
}
return [
{
index: committeeIndex,
slot,
validators: committee as List<phase0.ValidatorIndex>,
},
];
});
});
}
/**
* Retrieves the sync committees for the given state.
* @param epoch Fetch sync committees for the given epoch. If not present then the sync committees for the epoch of the state will be obtained.
*/
async getEpochSyncCommittees(stateId: StateId, epoch?: Epoch): Promise<altair.SyncCommitteeByValidatorIndices> {
// TODO: Should pick a state with the provided epoch too
const state = (await resolveStateId(this.config, this.chain, this.db, stateId)) as altair.BeaconState;
// TODO: If possible compute the syncCommittees in advance of the fork and expose them here.
// So the validators can prepare and potentially attest the first block. Not critical tho, it's very unlikely
const stateEpoch = computeEpochAtSlot(this.config, state.slot);
if (stateEpoch < this.config.params.ALTAIR_FORK_EPOCH) {
throw new ApiError(400, "Requested state before ALTAIR_FORK_EPOCH");
}
return {
validators: getSyncCommittees(this.config, state, epoch ?? stateEpoch),
// TODO: This is not used by the validator and will be deprecated soon
validatorAggregates: [],
};
}
async getState(stateId: StateId): Promise<allForks.BeaconState> {
return await resolveStateId(this.config, this.chain, this.db, stateId);
}
async getFork(stateId: StateId): Promise<phase0.Fork> {
return (await this.getState(stateId))?.fork;
}
}

View File

@@ -1,3 +1,4 @@
import {routes} from "@chainsafe/lodestar-api";
// this will need async once we wan't to resolve archive slot
import {
GENESIS_SLOT,
@@ -12,14 +13,11 @@ import {allForks} from "@chainsafe/lodestar-beacon-state-transition";
import {IBeaconConfig} from "@chainsafe/lodestar-config";
import {IForkChoice} from "@chainsafe/lodestar-fork-choice";
import {Epoch, ValidatorIndex, Gwei, Slot} from "@chainsafe/lodestar-types";
import {ValidatorResponse} from "@chainsafe/lodestar-types/phase0";
import {fromHexString, readonlyValues, TreeBacked} from "@chainsafe/ssz";
import {ByteVector, fromHexString, readonlyValues, TreeBacked} from "@chainsafe/ssz";
import {IBeaconChain} from "../../../../chain";
import {StateContextCache} from "../../../../chain/stateCache";
import {IBeaconDb} from "../../../../db";
import {getStateValidatorIndex} from "../../utils";
import {ApiError, ValidationError} from "../../errors";
import {StateId} from "./interface";
import {sleep, assert} from "@chainsafe/lodestar-utils";
type ResolveStateIdOpts = {
@@ -35,7 +33,7 @@ export async function resolveStateId(
config: IBeaconConfig,
chain: IBeaconChain,
db: IBeaconDb,
stateId: StateId,
stateId: routes.beacon.StateId,
opts?: ResolveStateIdOpts
): Promise<allForks.BeaconState> {
const state = await resolveStateIdOrNull(config, chain, db, stateId, opts);
@@ -50,7 +48,7 @@ async function resolveStateIdOrNull(
config: IBeaconConfig,
chain: IBeaconChain,
db: IBeaconDb,
stateId: StateId,
stateId: routes.beacon.StateId,
opts?: ResolveStateIdOpts
): Promise<allForks.BeaconState | null> {
stateId = stateId.toLowerCase();
@@ -74,32 +72,30 @@ async function resolveStateIdOrNull(
* Get the status of the validator
* based on conditions outlined in https://hackmd.io/ofFJ5gOmQpu1jjHilHbdQQ
*/
export function getValidatorStatus(validator: phase0.Validator, currentEpoch: Epoch): phase0.ValidatorStatus {
export function getValidatorStatus(validator: phase0.Validator, currentEpoch: Epoch): routes.beacon.ValidatorStatus {
// pending
if (validator.activationEpoch > currentEpoch) {
if (validator.activationEligibilityEpoch === FAR_FUTURE_EPOCH) {
return phase0.ValidatorStatus.PENDING_INITIALIZED;
return "pending_initialized";
} else if (validator.activationEligibilityEpoch < FAR_FUTURE_EPOCH) {
return phase0.ValidatorStatus.PENDING_QUEUED;
return "pending_queued";
}
}
// active
if (validator.activationEpoch <= currentEpoch && currentEpoch < validator.exitEpoch) {
if (validator.exitEpoch === FAR_FUTURE_EPOCH) {
return phase0.ValidatorStatus.ACTIVE_ONGOING;
return "active_ongoing";
} else if (validator.exitEpoch < FAR_FUTURE_EPOCH) {
return validator.slashed ? phase0.ValidatorStatus.ACTIVE_SLASHED : phase0.ValidatorStatus.ACTIVE_EXITING;
return validator.slashed ? "active_slashed" : "active_exiting";
}
}
// exited
if (validator.exitEpoch <= currentEpoch && currentEpoch < validator.withdrawableEpoch) {
return validator.slashed ? phase0.ValidatorStatus.EXITED_SLASHED : phase0.ValidatorStatus.EXITED_UNSLASHED;
return validator.slashed ? "exited_slashed" : "exited_unslashed";
}
// withdrawal
if (validator.withdrawableEpoch <= currentEpoch) {
return validator.effectiveBalance !== BigInt(0)
? phase0.ValidatorStatus.WITHDRAWAL_POSSIBLE
: phase0.ValidatorStatus.WITHDRAWAL_DONE;
return validator.effectiveBalance !== BigInt(0) ? "withdrawal_possible" : "withdrawal_done";
}
throw new Error("ValidatorStatus unknown");
}
@@ -109,7 +105,7 @@ export function toValidatorResponse(
validator: phase0.Validator,
balance: Gwei,
currentEpoch: Epoch
): phase0.ValidatorResponse {
): routes.beacon.ValidatorResponse {
return {
index,
status: getValidatorStatus(validator, currentEpoch),
@@ -175,7 +171,7 @@ async function stateByName(
db: IBeaconDb,
stateCache: StateContextCache,
forkChoice: IForkChoice,
stateId: StateId
stateId: routes.beacon.StateId
): Promise<allForks.BeaconState | null> {
switch (stateId) {
case "head":
@@ -194,7 +190,7 @@ async function stateByName(
async function stateByRoot(
db: IBeaconDb,
stateCache: StateContextCache,
stateId: StateId
stateId: routes.beacon.StateId
): Promise<allForks.BeaconState | null> {
if (stateId.startsWith("0x")) {
const stateRoot = fromHexString(stateId);
@@ -230,8 +226,8 @@ export function filterStateValidatorsByStatuses(
state: allForks.BeaconState,
chain: IBeaconChain,
currentEpoch: Epoch
): ValidatorResponse[] {
const responses: ValidatorResponse[] = [];
): routes.beacon.ValidatorResponse[] {
const responses: routes.beacon.ValidatorResponse[] = [];
const validators = Array.from(state.validators);
const filteredValidators = validators.filter((v) => statuses.includes(getValidatorStatus(v, currentEpoch)));
for (const validator of readonlyValues(filteredValidators)) {
@@ -281,3 +277,23 @@ async function getFinalizedState(
}
return state;
}
export function getStateValidatorIndex(
id: routes.beacon.ValidatorId | ByteVector,
state: allForks.BeaconState,
chain: IBeaconChain
): number | undefined {
let validatorIndex: ValidatorIndex | undefined;
if (typeof id === "number") {
if (state.validators.length > id) {
validatorIndex = id;
}
} else {
validatorIndex = chain.getHeadState().pubkey2index.get(id) ?? undefined;
// validator added later than given stateId
if (validatorIndex && validatorIndex >= state.validators.length) {
validatorIndex = undefined;
}
}
return validatorIndex;
}

View File

@@ -1,30 +0,0 @@
import {IBeaconConfig} from "@chainsafe/lodestar-config";
import {IBeaconParams} from "@chainsafe/lodestar-params";
import {phase0} from "@chainsafe/lodestar-types";
import {IApiModules} from "..";
import {IApiOptions} from "../../options";
import {IConfigApi} from "./interface";
export class ConfigApi implements IConfigApi {
private readonly config: IBeaconConfig;
constructor(opts: Partial<IApiOptions>, modules: Pick<IApiModules, "config">) {
this.config = modules.config;
}
async getForkSchedule(): Promise<phase0.Fork[]> {
// @TODO: implement the actual fork schedule data get from config params once marin's altair PRs have been merged
return [];
}
async getDepositContract(): Promise<phase0.Contract> {
return {
chainId: this.config.params.DEPOSIT_CHAIN_ID,
address: this.config.params.DEPOSIT_CONTRACT_ADDRESS,
};
}
async getSpec(): Promise<IBeaconParams> {
return this.config.params;
}
}

View File

@@ -1,2 +1,24 @@
export * from "./interface";
export * from "./config";
import {routes} from "@chainsafe/lodestar-api";
import {ApiModules} from "../types";
export function getConfigApi({config}: Pick<ApiModules, "config">): routes.config.Api {
return {
async getForkSchedule() {
// @TODO: implement the actual fork schedule data get from config params once marin's altair PRs have been merged
return {data: []};
},
async getDepositContract() {
return {
data: {
chainId: config.params.DEPOSIT_CHAIN_ID,
address: config.params.DEPOSIT_CONTRACT_ADDRESS,
},
};
},
async getSpec() {
return {data: config.params};
},
};
}

View File

@@ -1,8 +0,0 @@
import {IBeaconParams} from "@chainsafe/lodestar-params";
import {phase0} from "@chainsafe/lodestar-types";
export interface IConfigApi {
getForkSchedule(): Promise<phase0.Fork[]>;
getDepositContract(): Promise<phase0.Contract>;
getSpec(): Promise<IBeaconParams>;
}

View File

@@ -1,32 +0,0 @@
import {IBeaconConfig} from "@chainsafe/lodestar-config";
import {IBlockSummary} from "@chainsafe/lodestar-fork-choice";
import {allForks, phase0} from "@chainsafe/lodestar-types";
import {IBeaconChain} from "../../../../chain";
import {IBeaconDb} from "../../../../db";
import {IApiOptions} from "../../../options";
import {StateId} from "../../beacon/state";
import {resolveStateId} from "../../beacon/state/utils";
import {IApiModules} from "../../interface";
import {IDebugBeaconApi} from "./interface";
export class DebugBeaconApi implements IDebugBeaconApi {
private readonly chain: IBeaconChain;
private readonly db: IBeaconDb;
private readonly config: IBeaconConfig;
constructor(opts: Partial<IApiOptions>, modules: Pick<IApiModules, "chain" | "db" | "config">) {
this.chain = modules.chain;
this.db = modules.db;
this.config = modules.config;
}
async getHeads(): Promise<phase0.SlotRoot[]> {
return this.chain.forkChoice
.getHeads()
.map((blockSummary: IBlockSummary) => ({slot: blockSummary.slot, root: blockSummary.blockRoot}));
}
async getState(stateId: StateId): Promise<allForks.BeaconState> {
return await resolveStateId(this.config, this.chain, this.db, stateId, {regenFinalizedState: true});
}
}

View File

@@ -1,10 +0,0 @@
import {allForks, phase0} from "@chainsafe/lodestar-types";
import {StateId} from "../../beacon/state";
export interface IDebugBeaconApi {
/**
* API wrapper function for `getHeads` in `@chainsafe/lodestar-fork-choice`.
* */
getHeads(): Promise<phase0.SlotRoot[]>;
getState(stateId: StateId): Promise<allForks.BeaconState>;
}

Some files were not shown because too many files have changed in this diff Show More