Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

feat: implement light client verification methods #1116

Open
wants to merge 34 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
a537493
feat: implement light client verification methods
austinabell Apr 24, 2023
39b4575
chore: docs
austinabell Apr 25, 2023
879a7ab
refactor: move light-client APIs to own crate, migrate sha impl
austinabell Apr 26, 2023
17f0cf3
chore: changeset
austinabell Apr 26, 2023
f47b4fa
chore: add docs to validation steps
austinabell Apr 26, 2023
f8612ff
refactor: switch function params to be an object rather than positional
austinabell Apr 26, 2023
b280d8e
chore: revert naj test changes
austinabell Apr 26, 2023
4e81fae
test: adds back light client block verification test
austinabell Apr 26, 2023
09f957f
chore: lint
austinabell Apr 26, 2023
8ad77ae
test: add back execution proof verification in NAJ test
austinabell Apr 26, 2023
153dfa7
test: add execution proof test vectors
austinabell Apr 26, 2023
59b4f14
chore: address comments before refactoring
austinabell Apr 27, 2023
68601e5
refactor: move Enum class to types
austinabell Apr 27, 2023
73644e2
refactor: move light client logic into separate files
austinabell Apr 27, 2023
ab94d18
refactor: move borsh utils to own file
austinabell Apr 27, 2023
e15fad7
Merge branch 'master' into light_client
austinabell Apr 27, 2023
206df23
chore: remove todo from updated types
austinabell Apr 27, 2023
de393fb
chore: update changeset
austinabell Apr 27, 2023
daca5d2
test: move execution verification providers check into accounts
austinabell Apr 27, 2023
f509b3f
Merge branch 'master' into light_client
austinabell Apr 29, 2023
0dde04f
Merge branch 'master' into light_client
austinabell May 4, 2023
29c9ea6
fix: bug with bp signature validation
austinabell May 5, 2023
fe45242
refactor: move block hash generation to after trivial validation
austinabell May 5, 2023
20bff2e
chore: lint fix
austinabell May 5, 2023
0559e23
fix: bn comparison bug
austinabell May 23, 2023
d867179
fix: execution test parameter format from changes
austinabell May 24, 2023
8731572
Merge branch 'master' into light_client
austinabell May 24, 2023
f766671
fix: changes based on review comments
austinabell Jun 2, 2023
c5d1378
Merge branch 'master' into light_client
austinabell Jun 2, 2023
a8e19d8
chore: empty commit to re-trigger flaky CI
austinabell Jun 2, 2023
e7c26ce
Merge branch 'master' into light_client
vikinatora Feb 26, 2024
d97a801
fix: pnpm-lock.yaml
vikinatora Feb 26, 2024
35c12da
chore: lock package version
vikinatora Feb 26, 2024
6aed942
Merge remote-tracking branch 'upstream/master' into light_client
vikinatora Mar 12, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/forty-pets-film.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@near-js/light-client": patch
"near-api-js": patch
"@near-js/types": patch
---

Implement light client block and execution validation
1 change: 1 addition & 0 deletions packages/accounts/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
},
"devDependencies": {
"@near-js/keystores": "workspace:*",
"@near-js/light-client": "workspace:*",
"@types/node": "18.11.18",
"bs58": "4.0.0",
"jest": "26.0.1",
Expand Down
5 changes: 5 additions & 0 deletions packages/accounts/test/providers.test.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
const { validateExecutionProof } = require('@near-js/light-client');
const BN = require('bn.js');
const base58 = require('bs58');

Expand Down Expand Up @@ -154,6 +155,7 @@ describe('providers', () => {

const block = await provider.block({ blockId: finalizedStatus.sync_info.latest_block_hash });
const lightClientHead = block.header.last_final_block;
const finalBlock = await provider.block({ blockId: lightClientHead });
let lightClientRequest = {
type: 'transaction',
light_client_head: lightClientHead,
Expand All @@ -169,6 +171,9 @@ describe('providers', () => {
expect('block_hash' in lightClientProof.outcome_proof).toBe(true);
expect(lightClientProof.outcome_root_proof).toEqual([]);
expect(lightClientProof.block_proof.length).toBeGreaterThan(0);

// Validate the proof against the finalized block
validateExecutionProof({ proof: lightClientProof, blockMerkleRoot: base58.decode(finalBlock.header.block_merkle_root) });

// pass nonexistent hash for light client head will fail
lightClientRequest = {
Expand Down
8 changes: 8 additions & 0 deletions packages/light-client/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# @near-js/light-client

NEAR light client verification utilities. Based on the [Light Client spec](https://github.com/near/NEPs/blob/master/specs/ChainSpec/LightClient.md)

# License

This repository is distributed under the terms of both the MIT license and the Apache License (Version 2.0).
See [LICENSE](https://github.com/near/near-api-js/blob/master/LICENSE) and [LICENSE-APACHE](https://github.com/near/near-api-js/blob/master/LICENSE-APACHE) for details.
5 changes: 5 additions & 0 deletions packages/light-client/jest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
collectCoverage: true
};
36 changes: 36 additions & 0 deletions packages/light-client/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
{
"name": "@near-js/light-client",
"version": "0.0.1",
"description": "TypeScript API for NEAR light client verification",
"main": "lib/index.js",
"scripts": {
"build": "pnpm compile",
"compile": "tsc -p tsconfig.json",
"lint:js": "eslint -c ../../.eslintrc.js.yml test/**/*.js --no-eslintrc",
"lint:js:fix": "eslint -c ../../.eslintrc.js.yml test/**/*.js --no-eslintrc --fix",
"lint:ts": "eslint -c ../../.eslintrc.ts.yml src/**/*.ts --no-eslintrc",
"lint:ts:fix": "eslint -c ../../.eslintrc.ts.yml src/**/*.ts --no-eslintrc --fix",
"test": "jest test"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"@near-js/crypto": "workspace:*",
"@near-js/types": "workspace:*",
"bn.js": "5.2.1",
"bs58": "4.0.0",
"borsh": "0.7.0",
"js-sha256": "0.9.0"
},
"devDependencies": {
"@near-js/providers": "workspace:*",
"@types/node": "18.11.18",
"jest": "26.0.1",
"ts-jest": "26.5.6",
"typescript": "4.9.4"
},
"files": [
"lib"
]
}
162 changes: 162 additions & 0 deletions packages/light-client/src/block.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import { computeBlockHash, combineHash } from './utils';
import {
SCHEMA,
BorshApprovalInner,
BorshValidatorStakeView,
BorshValidatorStakeViewV1,
BorshValidatorStakeViewWrapper,
} from './borsh';
import bs58 from 'bs58';
import { sha256 } from 'js-sha256';
import {
LightClientBlockLiteView,
NextLightClientBlockResponse,
ValidatorStakeView,
} from '@near-js/types';
import { PublicKey } from '@near-js/crypto';
import BN from 'bn.js';
import { serialize } from 'borsh';

export interface ValidateLightClientBlockParams {
lastKnownBlock: LightClientBlockLiteView;
currentBlockProducers: ValidatorStakeView[];
newBlock: NextLightClientBlockResponse;
}

/**
* Validates a light client block response from the RPC against the last known block and block
* producer set.
*
* @param lastKnownBlock The last light client block retrieved. This must be the block at the epoch before newBlock.
* @param currentBlockProducers The block producer set for the epoch of the last known block.
* @param newBlock The new block to validate.
*/
export function validateLightClientBlock({
lastKnownBlock,
currentBlockProducers,
newBlock,
}: ValidateLightClientBlockParams) {
// Numbers for each step references the spec:
// https://github.com/near/NEPs/blob/c7d72138117ed0ab86629a27d1f84e9cce80848f/specs/ChainSpec/LightClient.md
// (1) Verify that the block height is greater than the last known block.
if (newBlock.inner_lite.height <= lastKnownBlock.inner_lite.height) {
throw new Error(
'New block must be at least the height of the last known block'
);
}

// (2) Verify that the new block is in the same epoch or in the next epoch known to the last
// known block.
if (
newBlock.inner_lite.epoch_id !== lastKnownBlock.inner_lite.epoch_id &&
newBlock.inner_lite.epoch_id !== lastKnownBlock.inner_lite.next_epoch_id
) {
throw new Error(
'New block must either be in the same epoch or the next epoch from the last known block'
);
}

const blockProducers: ValidatorStakeView[] = currentBlockProducers;
if (newBlock.approvals_after_next.length < blockProducers.length) {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The python implementation checks for strict equality. The spec uses a zip of the two arrays. Should we also assert the lengths are equal?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That implementation is wrong. The spec says, and in practice, there are blocks with more block producers than approvals because in the case of validator rotations, both the old and new bps must be included.

I can find and give a block where this is the case if needed, but I don't have an example off-hand.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gotcha, I trust your judgment here. Thanks!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

References, for posterity, to remove the trust aspect for others 😄 : https://github.com/near/NEPs/blob/master/specs/ChainSpec/LightClient.md#signature-verification. Also, line in rainbow bridge implementation, which is used in production https://github.com/aurora-is-near/rainbow-bridge/blob/33ca808b45cb5e9cf2e27f741b0f6e42d97c276b/contracts/eth/nearbridge/contracts/NearBridge.sol#L212 (python client just used for tests)

Also, anyone would be able to switch this to strict equal and see that it fails on recent blocks.

throw new Error(
'Number of approvals for next epoch must be at least the number of current block producers'
);
}

// (4) and (5)
// (4) `approvals_after_next` contains valid signatures on the block producer approval messages.
// (5) The signatures present represent more than 2/3 of the total stake.
const totalStake = new BN(0);
const approvedStake = new BN(0);

const currentBlockHash = computeBlockHash(newBlock);
const nextBlockHash = combineHash(
bs58.decode(newBlock.next_block_inner_hash),
currentBlockHash
);

for (let i = 0; i < blockProducers.length; i++) {
const approval = newBlock.approvals_after_next[i];
const stake = blockProducers[i].stake;

totalStake.iadd(new BN(stake));

if (approval === null) {
continue;
}

approvedStake.iadd(new BN(stake));

const publicKey = PublicKey.fromString(blockProducers[i].public_key);
const signature = bs58.decode(approval.split(':')[1]);

const approvalEndorsement = serialize(
SCHEMA,
new BorshApprovalInner({ endorsement: nextBlockHash })
);

const approvalHeight: BN = new BN(newBlock.inner_lite.height + 2);
const approvalHeightLe = approvalHeight.toArrayLike(Buffer, 'le', 8);
const approvalMessage = new Uint8Array([
...approvalEndorsement,
...approvalHeightLe,
]);

if (!publicKey.verify(approvalMessage, signature)) {
throw new Error(
`Invalid approval message signature for validator ${blockProducers[i].account_id}`
);
}
}

// (5) Calculates the 2/3 threshold and checks that the approved stake accumulated above
// exceeds it.
const threshold = totalStake.mul(new BN(2)).div(new BN(3));
if (approvedStake.lte(threshold)) {
throw new Error('Approved stake does not exceed the 2/3 threshold');
}

// (6) Verify that if the new block is in the next epoch, the hash of the next block producers
// equals the `next_bp_hash` provided in that block.
if (
newBlock.inner_lite.epoch_id === lastKnownBlock.inner_lite.next_epoch_id
) {
// (3) If the block is in a new epoch, then `next_bps` must be present.
if (!newBlock.next_bps) {
throw new Error(
'New block must include next block producers if a new epoch starts'
);
}

const bpsHash = hashBlockProducers(newBlock.next_bps);

if (!bpsHash.equals(bs58.decode(newBlock.inner_lite.next_bp_hash))) {
throw new Error('Next block producers hash doesn\'t match');
}
}
}

function hashBlockProducers(bps: ValidatorStakeView[]): Buffer {
const borshBps: BorshValidatorStakeView[] = bps.map((bp) => {
if (bp.validator_stake_struct_version) {
if (bp.validator_stake_struct_version !== 'V1') {
throw new Error(
'Only version 1 of the validator stake struct is supported'
);
}
}
return new BorshValidatorStakeView({
v1: new BorshValidatorStakeViewV1({
account_id: bp.account_id,
public_key: PublicKey.fromString(bp.public_key),
stake: bp.stake,
}),
});
});
const serializedBps = serialize(
SCHEMA,
// NOTE: just wrapping because borsh-js requires this type to be in the schema for some reason
new BorshValidatorStakeViewWrapper({ bps: borshBps })
);
return Buffer.from(sha256.array(serializedBps));
}
Loading
Loading