mirror of
https://github.com/ChainSafe/lodestar.git
synced 2026-01-08 23:28:10 -05:00
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:
15
packages/api/.babel-register
Normal file
15
packages/api/.babel-register
Normal 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
3
packages/api/.babelrc
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": "../../.babelrc"
|
||||
}
|
||||
10
packages/api/.gitignore
vendored
Normal file
10
packages/api/.gitignore
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
node_modules/
|
||||
lib
|
||||
.nyc_output/
|
||||
coverage/**
|
||||
.DS_Store
|
||||
*.swp
|
||||
.idea
|
||||
yarn-error.log
|
||||
package-lock.json
|
||||
dist*
|
||||
4
packages/api/.mocharc.yaml
Normal file
4
packages/api/.mocharc.yaml
Normal file
@@ -0,0 +1,4 @@
|
||||
colors: true
|
||||
require: ts-node/register
|
||||
timeout: 2000
|
||||
exit: true
|
||||
3
packages/api/.nycrc.json
Normal file
3
packages/api/.nycrc.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": "../../.nycrc.json"
|
||||
}
|
||||
201
packages/api/LICENSE
Normal file
201
packages/api/LICENSE
Normal 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
52
packages/api/README.md
Normal file
@@ -0,0 +1,52 @@
|
||||
# Lodestar ETH2.0 API
|
||||
|
||||
[")](https://travis-ci.com/ChainSafe/lodestar)
|
||||
[](https://discord.gg/aMxzVcr)
|
||||

|
||||

|
||||

|
||||
|
||||
> 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
64
packages/api/package.json
Normal 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
1
packages/api/server.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./lib/server";
|
||||
2
packages/api/server.js
Normal file
2
packages/api/server.js
Normal file
@@ -0,0 +1,2 @@
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
module.exports = require("./lib/server");
|
||||
13
packages/api/src/client/beacon.ts
Normal file
13
packages/api/src/client/beacon.ts
Normal 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);
|
||||
}
|
||||
13
packages/api/src/client/config.ts
Normal file
13
packages/api/src/client/config.ts
Normal 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);
|
||||
}
|
||||
13
packages/api/src/client/debug.ts
Normal file
13
packages/api/src/client/debug.ts
Normal 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);
|
||||
}
|
||||
56
packages/api/src/client/events.ts
Normal file
56
packages/api/src/client/events.ts
Normal 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};
|
||||
30
packages/api/src/client/index.ts
Normal file
30
packages/api/src/client/index.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
27
packages/api/src/client/lightclient.ts
Normal file
27
packages/api/src/client/lightclient.ts
Normal 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};
|
||||
},
|
||||
};
|
||||
}
|
||||
13
packages/api/src/client/lodestar.ts
Normal file
13
packages/api/src/client/lodestar.ts
Normal 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);
|
||||
}
|
||||
13
packages/api/src/client/node.ts
Normal file
13
packages/api/src/client/node.ts
Normal 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);
|
||||
}
|
||||
78
packages/api/src/client/utils/client.ts
Normal file
78
packages/api/src/client/utils/client.ts
Normal 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;
|
||||
}
|
||||
145
packages/api/src/client/utils/httpClient.ts
Normal file
145
packages/api/src/client/utils/httpClient.ts
Normal 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(/^(\/)+/, "/")
|
||||
);
|
||||
}
|
||||
2
packages/api/src/client/utils/index.ts
Normal file
2
packages/api/src/client/utils/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./client";
|
||||
export * from "./httpClient";
|
||||
13
packages/api/src/client/validator.ts
Normal file
13
packages/api/src/client/validator.ts
Normal 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);
|
||||
}
|
||||
5
packages/api/src/index.ts
Normal file
5
packages/api/src/index.ts
Normal 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
|
||||
19
packages/api/src/interface.ts
Normal file
19
packages/api/src/interface.ts
Normal 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;
|
||||
};
|
||||
167
packages/api/src/routes/beacon/block.ts
Normal file
167
packages/api/src/routes/beacon/block.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
63
packages/api/src/routes/beacon/index.ts
Normal file
63
packages/api/src/routes/beacon/index.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
161
packages/api/src/routes/beacon/pool.ts
Normal file
161
packages/api/src/routes/beacon/pool.ts
Normal 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)),
|
||||
};
|
||||
}
|
||||
279
packages/api/src/routes/beacon/state.ts
Normal file
279
packages/api/src/routes/beacon/state.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
69
packages/api/src/routes/config.ts
Normal file
69
packages/api/src/routes/config.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
117
packages/api/src/routes/debug.ts
Normal file
117
packages/api/src/routes/debug.ts
Normal 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>),
|
||||
};
|
||||
}
|
||||
139
packages/api/src/routes/events.ts
Normal file
139
packages/api/src/routes/events.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
}
|
||||
47
packages/api/src/routes/index.ts
Normal file
47
packages/api/src/routes/index.ts
Normal 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.
|
||||
74
packages/api/src/routes/lightclient.ts
Normal file
74
packages/api/src/routes/lightclient.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
48
packages/api/src/routes/lodestar.ts
Normal file
48
packages/api/src/routes/lodestar.ts
Normal 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(),
|
||||
};
|
||||
}
|
||||
179
packages/api/src/routes/node.ts
Normal file
179
packages/api/src/routes/node.ts
Normal 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 peer’s 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(),
|
||||
};
|
||||
}
|
||||
360
packages/api/src/routes/validator.ts
Normal file
360
packages/api/src/routes/validator.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
8
packages/api/src/server/beacon.ts
Normal file
8
packages/api/src/server/beacon.ts
Normal 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);
|
||||
}
|
||||
8
packages/api/src/server/config.ts
Normal file
8
packages/api/src/server/config.ts
Normal 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);
|
||||
}
|
||||
49
packages/api/src/server/debug.ts
Normal file
49
packages/api/src/server/debug.ts
Normal 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);
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
66
packages/api/src/server/events.ts
Normal file
66
packages/api/src/server/events.ts
Normal 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");
|
||||
}
|
||||
69
packages/api/src/server/index.ts
Normal file
69
packages/api/src/server/index.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
28
packages/api/src/server/lightclient.ts
Normal file
28
packages/api/src/server/lightclient.ts
Normal 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));
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
8
packages/api/src/server/lodestar.ts
Normal file
8
packages/api/src/server/lodestar.ts
Normal 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);
|
||||
}
|
||||
8
packages/api/src/server/node.ts
Normal file
8
packages/api/src/server/node.ts
Normal 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);
|
||||
}
|
||||
1
packages/api/src/server/utils/index.ts
Normal file
1
packages/api/src/server/utils/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./server";
|
||||
79
packages/api/src/server/utils/server.ts
Normal file
79
packages/api/src/server/utils/server.ts
Normal 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 {};
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
8
packages/api/src/server/validator.ts
Normal file
8
packages/api/src/server/validator.ts
Normal 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);
|
||||
}
|
||||
@@ -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 {
|
||||
4
packages/api/src/utils/index.ts
Normal file
4
packages/api/src/utils/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from "./schema";
|
||||
export * from "./StringType";
|
||||
export * from "./types";
|
||||
export * from "./urlFormat";
|
||||
116
packages/api/src/utils/schema.ts
Normal file
116
packages/api/src/utils/schema.ts
Normal 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;
|
||||
}
|
||||
150
packages/api/src/utils/types.ts
Normal file
150
packages/api/src/utils/types.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
74
packages/api/src/utils/urlFormat.ts
Normal file
74
packages/api/src/utils/urlFormat.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
32
packages/api/test/parser.test.ts
Normal file
32
packages/api/test/parser.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
175
packages/api/test/unit/beacon.test.ts
Normal file
175
packages/api/test/unit/beacon.test.ts
Normal 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
|
||||
});
|
||||
28
packages/api/test/unit/config.test.ts
Normal file
28
packages/api/test/unit/config.test.ts
Normal 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()},
|
||||
},
|
||||
});
|
||||
});
|
||||
67
packages/api/test/unit/debug.test.ts
Normal file
67
packages/api/test/unit/debug.test.ts
Normal 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"
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
80
packages/api/test/unit/events.test.ts
Normal file
80
packages/api/test/unit/events.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
43
packages/api/test/unit/lightclient.test.ts
Normal file
43
packages/api/test/unit/lightclient.test.ts
Normal 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},
|
||||
},
|
||||
});
|
||||
});
|
||||
62
packages/api/test/unit/node.test.ts
Normal file
62
packages/api/test/unit/node.test.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
});
|
||||
154
packages/api/test/unit/utils/httpClient.test.ts
Normal file
154
packages/api/test/unit/utils/httpClient.test.ts
Normal 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}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
93
packages/api/test/unit/validator.test.ts
Normal file
93
packages/api/test/unit/validator.test.ts
Normal 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
|
||||
});
|
||||
56
packages/api/test/utils/genericServerTest.ts
Normal file
56
packages/api/test/utils/genericServerTest.ts
Normal 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");
|
||||
});
|
||||
}
|
||||
}
|
||||
42
packages/api/test/utils/utils.ts
Normal file
42
packages/api/test/utils/utils.ts
Normal 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;
|
||||
}
|
||||
7
packages/api/tsconfig.build.json
Normal file
7
packages/api/tsconfig.build.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "../../tsconfig.build.json",
|
||||
"include": ["src"],
|
||||
"compilerOptions": {
|
||||
"outDir": "lib"
|
||||
}
|
||||
}
|
||||
4
packages/api/tsconfig.json
Normal file
4
packages/api/tsconfig.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}),
|
||||
|
||||
@@ -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}),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
@@ -1,4 +1,4 @@
|
||||
# Lodestar
|
||||
# Lodestar Light-client
|
||||
|
||||
[")](https://travis-ci.com/ChainSafe/lodestar)
|
||||
[](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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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> => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
"extends": "../../tsconfig.build.json",
|
||||
"include": ["src"],
|
||||
"compilerOptions": {
|
||||
"outDir": "lib",
|
||||
"typeRoots": ["../../node_modules/@types", "./node_modules/@types"]
|
||||
"outDir": "lib"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"typeRoots": ["../../node_modules/@types", "./node_modules/@types"]
|
||||
}
|
||||
"compilerOptions": {}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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)]);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
@@ -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") {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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);
|
||||
})
|
||||
);
|
||||
})
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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);
|
||||
})
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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: [],
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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});
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user