From 56dfe49515541d72d10921fd7d7d3a5397cf42ba Mon Sep 17 00:00:00 2001 From: sebastijankuzner Date: Fri, 20 Mar 2026 17:59:34 +0100 Subject: [PATCH 01/12] Implement parseBlockNumber --- packages/crypto-validation/source/keywords.ts | 11 ++-- .../source/parse-block-number.ts | 50 +++++++++++++++++++ 2 files changed, 57 insertions(+), 4 deletions(-) create mode 100644 packages/crypto-validation/source/parse-block-number.ts diff --git a/packages/crypto-validation/source/keywords.ts b/packages/crypto-validation/source/keywords.ts index 00091f366..79c23e6b8 100644 --- a/packages/crypto-validation/source/keywords.ts +++ b/packages/crypto-validation/source/keywords.ts @@ -1,6 +1,7 @@ import type { Contracts } from "@mainsail/contracts"; import { BigNumber } from "@mainsail/utils"; import type { FuncKeywordDefinition } from "ajv"; +import { parseBlockNumber } from "./parse-block-number.js"; export const makeKeywords = ( configuration: Contracts.Crypto.Configuration, @@ -63,12 +64,13 @@ export const makeKeywords = ( // Use by: crypto-proposal, p2p const limitToRoundValidators: FuncKeywordDefinition = { compile(schema: { minimum?: number }) { - return (data) => { + return (data, parentSchema) => { if (!Array.isArray(data)) { return false; } - const { roundValidators } = configuration.getMilestone(); + const blockNumber = parseBlockNumber(parentSchema); + const { roundValidators } = configuration.getMilestone(blockNumber); const minimum = schema.minimum !== undefined ? schema.minimum : roundValidators; if (data.length < minimum || data.length > roundValidators) { @@ -91,7 +93,7 @@ export const makeKeywords = ( // Used by: crypto-messages (prevotes / precommits) and crypto-proposal const isValidatorIndex: FuncKeywordDefinition = { compile() { - return (data) => { + return (data, parentSchema) => { if (!Number.isInteger(data)) { return false; } @@ -100,7 +102,8 @@ export const makeKeywords = ( return false; } - const { roundValidators } = configuration.getMilestone(); + const blockNumber = parseBlockNumber(parentSchema); + const { roundValidators } = configuration.getMilestone(blockNumber); return data < roundValidators; }; diff --git a/packages/crypto-validation/source/parse-block-number.ts b/packages/crypto-validation/source/parse-block-number.ts new file mode 100644 index 000000000..7d3d76e9f --- /dev/null +++ b/packages/crypto-validation/source/parse-block-number.ts @@ -0,0 +1,50 @@ +import type { AnySchemaObject } from "ajv"; + +export const parseBlockNumber = (path: string, parentSchema: AnySchemaObject): number | undefined => { + if (path === undefined) { + return undefined; + } + + if (path === "payloadSerialized") { + return parseSerializedPayload(parentSchema.rootData?.payloadSerialized); + } + + return parseOnPath(path, parentSchema.rootData); +}; + +const parseOnPath = (path: string, rootData: object): number | undefined => { + const parts = path.split("."); + let current: any = rootData; + + for (const part of parts) { + if (current[part] === undefined) { + return undefined; + } + current = current[part]; + } + + if(typeof current === "number") { + return current; + } + + return undefined; +} + +// Proposals contain the block only in serialized form (hex). +// We can extract the block number at a fixed offset here, without needing to deserialize the whole block. +const parseSerializedPayload = (serialized): number | undefined => { + if (!serialized) { + return undefined; + } + + if (serialized.length < 30) { + return undefined; + } + + const lockProofSize = 2 + Number.parseInt(serialized.slice(0, 2), 16) * 2; + // version: 1 byte (2 hex) + // timestamp: 6 bytes (12 hex) + // blockNumber: 4 byte (8 hex) + const offset = lockProofSize + 2 + 12; + return Buffer.from(serialized.slice(offset, offset + 8), "hex").readUInt32LE(); +} From 71c474aa74ba062e98f922c7934dee18952d4c0e Mon Sep 17 00:00:00 2001 From: sebastijankuzner Date: Fri, 20 Mar 2026 18:13:47 +0100 Subject: [PATCH 02/12] Test parseBlockNumber --- packages/crypto-validation/source/keywords.ts | 21 +++-- .../source/parse-block-number.test.ts | 91 +++++++++++++++++++ .../source/parse-block-number.ts | 2 +- 3 files changed, 106 insertions(+), 8 deletions(-) create mode 100644 packages/crypto-validation/source/parse-block-number.test.ts diff --git a/packages/crypto-validation/source/keywords.ts b/packages/crypto-validation/source/keywords.ts index 79c23e6b8..50b21368a 100644 --- a/packages/crypto-validation/source/keywords.ts +++ b/packages/crypto-validation/source/keywords.ts @@ -1,6 +1,6 @@ import type { Contracts } from "@mainsail/contracts"; import { BigNumber } from "@mainsail/utils"; -import type { FuncKeywordDefinition } from "ajv"; +import type { AnySchemaObject, FuncKeywordDefinition } from "ajv"; import { parseBlockNumber } from "./parse-block-number.js"; export const makeKeywords = ( @@ -63,13 +63,13 @@ export const makeKeywords = ( // Use by: crypto-proposal, p2p const limitToRoundValidators: FuncKeywordDefinition = { - compile(schema: { minimum?: number }) { - return (data, parentSchema) => { + compile(schema: { minimum?: number; blockNumberPath?: string }) { + return (data, parentSchema: AnySchemaObject) => { if (!Array.isArray(data)) { return false; } - const blockNumber = parseBlockNumber(parentSchema); + const blockNumber = parseBlockNumber(schema.blockNumberPath, parentSchema); const { roundValidators } = configuration.getMilestone(blockNumber); const minimum = schema.minimum !== undefined ? schema.minimum : roundValidators; @@ -84,6 +84,7 @@ export const makeKeywords = ( keyword: "limitToRoundValidators", metaSchema: { properties: { + blockNumberPath: { type: "string" }, minimum: { type: "integer" }, }, type: "object", @@ -92,8 +93,8 @@ export const makeKeywords = ( // Used by: crypto-messages (prevotes / precommits) and crypto-proposal const isValidatorIndex: FuncKeywordDefinition = { - compile() { - return (data, parentSchema) => { + compile(schema: { blockNumberPath?: string }) { + return (data, parentSchema: AnySchemaObject) => { if (!Number.isInteger(data)) { return false; } @@ -102,13 +103,19 @@ export const makeKeywords = ( return false; } - const blockNumber = parseBlockNumber(parentSchema); + const blockNumber = parseBlockNumber(schema.blockNumberPath, parentSchema); const { roundValidators } = configuration.getMilestone(blockNumber); return data < roundValidators; }; }, errors: false, + metaSchema: { + properties: { + blockNumberPath: { type: "string" }, + }, + type: "object", + }, keyword: "isValidatorIndex", }; diff --git a/packages/crypto-validation/source/parse-block-number.test.ts b/packages/crypto-validation/source/parse-block-number.test.ts new file mode 100644 index 000000000..f00b0b8ba --- /dev/null +++ b/packages/crypto-validation/source/parse-block-number.test.ts @@ -0,0 +1,91 @@ + +import { Validator } from "@mainsail/validation/source/validator"; + +import { Application } from "@mainsail/kernel"; +import { describe } from "@mainsail/test-runner"; +import { parseBlockNumber } from "./parse-block-number"; +import { Proposal, ProposalWithLockProof, ProposalWithLockProofAndValidRound, ProposalWithValidRound } from "../../crypto-proposal/test/fixtures/index.js"; + +describe<{ + app: Application; + validator: Validator; +}>("Keywords", ({ it, beforeEach, assert }) => { + it("should return undefined if path is undefined", () => { + const result = parseBlockNumber(undefined, { + rootData: {}, + }); + assert.is(result, undefined); + }); + + it("should return undefined if path does not exist in rootData", () => { + const result = parseBlockNumber("nonexistent.path", { + rootData: {}, + }); + assert.undefined(result); + }); + + it("should return undefined if value at path is not a number", () => { + const result = parseBlockNumber("some.path", { + rootData: { + some: { path: "not a number" }, + }, + }); + assert.undefined(result); + }); + + it("should return undefined if value at path is string representation of a number", () => { + const result = parseBlockNumber("some.path", { + rootData: { + some: { path: "42" }, + }, + }); + assert.undefined(result); + }); + + it("should return the number at the specified path (root)", () => { + const result = parseBlockNumber("path", { + rootData: { + path: 42, + }, + }); + assert.equal(result, 42); + }); + + it("should return the number at the specified path (nested)", () => { + const result = parseBlockNumber("some.path", { + rootData: { + some: { path: 42 }, + }, + }); + assert.equal(result, 42); + }); + + it("should return undefined if payloadSerialized is missing", () => { + const result = parseBlockNumber("payloadSerialized", { + rootData: {}, + }); + assert.undefined(result); + }); + + it("should return undefined if payloadSerialized is too short", () => { + const result = parseBlockNumber("payloadSerialized", { + rootData: { + payloadSerialized: "00", + }, + }); + assert.undefined(result); + }); + + it("should return the block number from payloadSerialized", () => { + const payloadSerialized = Proposal.payloadSerialized; + + for(const payloadSerialized of [Proposal.payloadSerialized, ProposalWithLockProof.payloadSerialized, ProposalWithLockProofAndValidRound.payloadSerialized, ProposalWithValidRound.payloadSerialized]) { + const result = parseBlockNumber("payloadSerialized", { + rootData: { + payloadSerialized, + }, + }); + assert.equal(result, 2); + }; + }); +}); diff --git a/packages/crypto-validation/source/parse-block-number.ts b/packages/crypto-validation/source/parse-block-number.ts index 7d3d76e9f..2f8e27203 100644 --- a/packages/crypto-validation/source/parse-block-number.ts +++ b/packages/crypto-validation/source/parse-block-number.ts @@ -1,6 +1,6 @@ import type { AnySchemaObject } from "ajv"; -export const parseBlockNumber = (path: string, parentSchema: AnySchemaObject): number | undefined => { +export const parseBlockNumber = (path: string | undefined, parentSchema: AnySchemaObject): number | undefined => { if (path === undefined) { return undefined; } From d210a282af803bdf9b2d8f10c9b1a4a4abbfe9fb Mon Sep 17 00:00:00 2001 From: sebastijankuzner Date: Fri, 20 Mar 2026 18:20:46 +0100 Subject: [PATCH 03/12] Fix types --- packages/crypto-validation/source/keywords.ts | 6 +++--- .../crypto-validation/source/parse-block-number.ts | 11 +++++++---- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/packages/crypto-validation/source/keywords.ts b/packages/crypto-validation/source/keywords.ts index 50b21368a..5d6975cb0 100644 --- a/packages/crypto-validation/source/keywords.ts +++ b/packages/crypto-validation/source/keywords.ts @@ -1,6 +1,6 @@ import type { Contracts } from "@mainsail/contracts"; import { BigNumber } from "@mainsail/utils"; -import type { AnySchemaObject, FuncKeywordDefinition } from "ajv"; +import type { FuncKeywordDefinition } from "ajv"; import { parseBlockNumber } from "./parse-block-number.js"; export const makeKeywords = ( @@ -64,7 +64,7 @@ export const makeKeywords = ( // Use by: crypto-proposal, p2p const limitToRoundValidators: FuncKeywordDefinition = { compile(schema: { minimum?: number; blockNumberPath?: string }) { - return (data, parentSchema: AnySchemaObject) => { + return (data, parentSchema) => { if (!Array.isArray(data)) { return false; } @@ -94,7 +94,7 @@ export const makeKeywords = ( // Used by: crypto-messages (prevotes / precommits) and crypto-proposal const isValidatorIndex: FuncKeywordDefinition = { compile(schema: { blockNumberPath?: string }) { - return (data, parentSchema: AnySchemaObject) => { + return (data, parentSchema) => { if (!Number.isInteger(data)) { return false; } diff --git a/packages/crypto-validation/source/parse-block-number.ts b/packages/crypto-validation/source/parse-block-number.ts index 2f8e27203..67d24ccf7 100644 --- a/packages/crypto-validation/source/parse-block-number.ts +++ b/packages/crypto-validation/source/parse-block-number.ts @@ -1,12 +1,15 @@ -import type { AnySchemaObject } from "ajv"; +// Copy form ajv +export interface DataValidationCxt { + rootData: Record | any[]; +} -export const parseBlockNumber = (path: string | undefined, parentSchema: AnySchemaObject): number | undefined => { - if (path === undefined) { +export const parseBlockNumber = (path: string | undefined, parentSchema: DataValidationCxt | undefined): number | undefined => { + if (path === undefined || parentSchema === undefined) { return undefined; } if (path === "payloadSerialized") { - return parseSerializedPayload(parentSchema.rootData?.payloadSerialized); + return parseSerializedPayload(parentSchema.rootData?.["payloadSerialized"]); } return parseOnPath(path, parentSchema.rootData); From 0062b5396b664b57f981423af117acf160e96eb4 Mon Sep 17 00:00:00 2001 From: sebastijankuzner Date: Fri, 20 Mar 2026 18:40:14 +0100 Subject: [PATCH 04/12] Check PayloadSerialized --- .../crypto-proposal/source/schemas.test.ts | 20 +++++++++++++++---- packages/crypto-proposal/source/schemas.ts | 2 +- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/packages/crypto-proposal/source/schemas.test.ts b/packages/crypto-proposal/source/schemas.test.ts index 7debaa8ed..41137f84c 100644 --- a/packages/crypto-proposal/source/schemas.test.ts +++ b/packages/crypto-proposal/source/schemas.test.ts @@ -22,20 +22,22 @@ import { signature } from "../test/fixtures/proposal.js"; describe<{ app: Application; validator: Validator; -}>("Schemas", ({ it, assert, beforeEach }) => { + configuration: Configuration; +}>("Schemas", ({ it, assert, beforeEach, spy }) => { const proposals = [Proposal, ProposalWithValidRound, ProposalWithLockProof, ProposalWithLockProofAndValidRound]; beforeEach((context) => { context.app = new Application(); context.app.bind(Identifiers.Cryptography.Configuration).to(Configuration).inSingletonScope(); - context.app.get(Identifiers.Cryptography.Configuration).setConfig(cryptoJson); - context.app.get(Identifiers.Cryptography.Configuration).setHeight(1); + context.configuration = context.app.get(Identifiers.Cryptography.Configuration); + context.configuration.setConfig(cryptoJson); + context.configuration.setHeight(1); context.validator = context.app.resolve(Validator); for (const keyword of Object.values({ - ...makeBaseKeywords(context.app.get(Identifiers.Cryptography.Configuration)), + ...makeBaseKeywords(context.configuration), })) { context.validator.addKeyword(keyword); } @@ -263,4 +265,14 @@ describe<{ assert.true(result.error!.includes("validators")); } }); + + it("proposalUnsigned - should correctly deserialize block number from payloadSerialized", ({ validator , configuration}) => { + const spyConfigurationGetMilestone = spy(configuration, "getMilestone"); + + const result = validator.validate("proposalUnsigned", Proposal.proposalDataSerializableUnsigned); + assert.undefined(result.error); + + spyConfigurationGetMilestone.calledOnce(); + spyConfigurationGetMilestone.calledWith(2); + }); }); diff --git a/packages/crypto-proposal/source/schemas.ts b/packages/crypto-proposal/source/schemas.ts index 9e3200861..9f5169b05 100644 --- a/packages/crypto-proposal/source/schemas.ts +++ b/packages/crypto-proposal/source/schemas.ts @@ -7,7 +7,7 @@ const proposalUnsigned = { payloadSerialized: { $ref: "hex" }, round: { minimum: 0, type: "integer" }, validRound: { minimum: 0, type: "integer" }, - validatorIndex: { isValidatorIndex: {} }, + validatorIndex: { isValidatorIndex: { blockNumberPath: "payloadSerialized" } }, }, required: ["round", "payloadSerialized", "validatorIndex"], type: "object", From cfd2c12c659b0568f255235c9a0b5d41d2fb9f57 Mon Sep 17 00:00:00 2001 From: sebastijankuzner Date: Fri, 20 Mar 2026 18:50:10 +0100 Subject: [PATCH 05/12] Verify lock proof --- packages/crypto-proposal/source/factory.ts | 6 ++++-- packages/crypto-proposal/source/schemas.test.ts | 13 ++++++++++++- packages/crypto-proposal/source/schemas.ts | 4 +++- 3 files changed, 19 insertions(+), 4 deletions(-) diff --git a/packages/crypto-proposal/source/factory.ts b/packages/crypto-proposal/source/factory.ts index f8335403d..351dfd3ce 100644 --- a/packages/crypto-proposal/source/factory.ts +++ b/packages/crypto-proposal/source/factory.ts @@ -77,8 +77,6 @@ export class Factory implements Contracts.Crypto.ProposalFactory { if (lockProofLength > 0) { const lockProofBuffer = buffer.readBytes(lockProofLength); lockProof = await this.deserializer.deserializeLockProof(lockProofBuffer); - - this.#verifySchema("lockProof", lockProof); } return lockProof; @@ -92,6 +90,10 @@ export class Factory implements Contracts.Crypto.ProposalFactory { const lockProof = await this.#getLockProof(buffer); const blockHeader = await this.blockFactory.headerFromBytes(buffer.getRemainder()); + if(lockProof) { + this.#verifySchema("lockProof", { ...lockProof, number: blockHeader.number }); + } + return { blockHeader, lockProof, diff --git a/packages/crypto-proposal/source/schemas.test.ts b/packages/crypto-proposal/source/schemas.test.ts index 41137f84c..c7a1699b3 100644 --- a/packages/crypto-proposal/source/schemas.test.ts +++ b/packages/crypto-proposal/source/schemas.test.ts @@ -186,9 +186,10 @@ describe<{ assert.undefined(result.error); }); - it("lockProof - all fields are required", ({ validator }) => { + it("lockProof - all fields are required (except number)", ({ validator }) => { const keys = Object.keys(schemas.lockProof.properties); for (let key of keys) { + if (key === "number") continue; const lockProofCopy = { ...lockProof, [key]: undefined }; const result = validator.validate("lockProof", lockProofCopy); assert.defined(result.error); @@ -275,4 +276,14 @@ describe<{ spyConfigurationGetMilestone.calledOnce(); spyConfigurationGetMilestone.calledWith(2); }); + + it("lockProof - should correctly parse block number from number", ({ validator , configuration}) => { + const spyConfigurationGetMilestone = spy(configuration, "getMilestone"); + + const result = validator.validate("lockProof", { ...lockProof, number: 3 }); + assert.undefined(result.error); + + spyConfigurationGetMilestone.calledOnce(); + spyConfigurationGetMilestone.calledWith(3); + }); }); diff --git a/packages/crypto-proposal/source/schemas.ts b/packages/crypto-proposal/source/schemas.ts index 9f5169b05..bc5b5a227 100644 --- a/packages/crypto-proposal/source/schemas.ts +++ b/packages/crypto-proposal/source/schemas.ts @@ -21,9 +21,11 @@ export const schemas: Record<"lockProof" | "proposal" | "proposalUnsigned", AnyS signature: { $ref: "consensusSignature" }, validators: { items: { type: "boolean" }, - limitToRoundValidators: {}, + limitToRoundValidators: { blockNumberPath: "number" }, type: "array", }, + // NOTE: This is not an actual property of the lock proof, but we need it to validate the lock proof against the correct set of validators. + number: { minimum: 0, type: "integer" }, }, required: ["signature", "validators"], type: "object", From fef743c8b10e1b43b8193e34d48e6683069cb9c2 Mon Sep 17 00:00:00 2001 From: sebastijankuzner Date: Fri, 20 Mar 2026 18:53:14 +0100 Subject: [PATCH 06/12] Make number required --- packages/crypto-proposal/source/schemas.test.ts | 11 ++++++++--- packages/crypto-proposal/source/schemas.ts | 2 +- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/packages/crypto-proposal/source/schemas.test.ts b/packages/crypto-proposal/source/schemas.test.ts index c7a1699b3..fd87acc98 100644 --- a/packages/crypto-proposal/source/schemas.test.ts +++ b/packages/crypto-proposal/source/schemas.test.ts @@ -18,6 +18,7 @@ import { } from "../test/fixtures/index.js"; import { schemas } from "./schemas"; import { signature } from "../test/fixtures/proposal.js"; +import { numberArray } from "@mainsail/utils"; describe<{ app: Application; @@ -182,7 +183,7 @@ describe<{ }); it("lockProof - should be ok", ({ validator }) => { - const result = validator.validate("lockProof", lockProof); + const result = validator.validate("lockProof", { ...lockProof, number: 1 }); assert.undefined(result.error); }); @@ -190,7 +191,7 @@ describe<{ const keys = Object.keys(schemas.lockProof.properties); for (let key of keys) { if (key === "number") continue; - const lockProofCopy = { ...lockProof, [key]: undefined }; + const lockProofCopy = { ...lockProof, [key]: undefined, number: 1 }; const result = validator.validate("lockProof", lockProofCopy); assert.defined(result.error); assert.true(result.error?.includes(key)); @@ -198,7 +199,7 @@ describe<{ }); it("lockProof - should not allow additional fields", ({ validator }) => { - const lockProofCopy = { ...lockProof, extraField: "extraValue" }; + const lockProofCopy = { ...lockProof, extraField: "extraValue", number: 1 }; const result = validator.validate("lockProof", lockProofCopy); assert.defined(result.error); assert.true(result.error!.includes("additional properties")); @@ -207,6 +208,7 @@ describe<{ it("lockProof - signature should be ok", ({ validator }) => { for (let char of "0123456789abcdef") { const result = validator.validate("lockProof", { + number: 1, signature: char.repeat(192), validators: Array(53).fill(true), }); @@ -224,6 +226,7 @@ describe<{ const result = validator.validate("lockProof", { signature, validators: Array(53).fill(true), + number: 1, }); assert.defined(result.error); assert.true(result.error!.includes("signature")); @@ -241,6 +244,7 @@ describe<{ for (let validators of validValidators) { const result = validator.validate("lockProof", { + number: 1, signature, validators, }); @@ -261,6 +265,7 @@ describe<{ const result = validator.validate("lockProof", { signature, validators, + number: 1, }); assert.defined(result.error); assert.true(result.error!.includes("validators")); diff --git a/packages/crypto-proposal/source/schemas.ts b/packages/crypto-proposal/source/schemas.ts index bc5b5a227..43e9ba50f 100644 --- a/packages/crypto-proposal/source/schemas.ts +++ b/packages/crypto-proposal/source/schemas.ts @@ -27,7 +27,7 @@ export const schemas: Record<"lockProof" | "proposal" | "proposalUnsigned", AnyS // NOTE: This is not an actual property of the lock proof, but we need it to validate the lock proof against the correct set of validators. number: { minimum: 0, type: "integer" }, }, - required: ["signature", "validators"], + required: ["signature", "validators", "number"], type: "object", }, proposal: { From d60174fbfcba0100db12980625dcfa838b0b80c6 Mon Sep 17 00:00:00 2001 From: sebastijankuzner Date: Fri, 20 Mar 2026 18:56:28 +0100 Subject: [PATCH 07/12] Use on messages --- .../crypto-messages/source/schemas.test.ts | 20 +++++++++++++++---- packages/crypto-messages/source/schemas.ts | 2 +- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/packages/crypto-messages/source/schemas.test.ts b/packages/crypto-messages/source/schemas.test.ts index b0f3456a3..b449a626c 100644 --- a/packages/crypto-messages/source/schemas.test.ts +++ b/packages/crypto-messages/source/schemas.test.ts @@ -15,18 +15,20 @@ import { schemas } from "./schemas"; describe<{ app: Application; validator: Validator; -}>("Schemas", ({ it, assert, beforeEach }) => { + configuration: Configuration; +}>("Schemas", ({ it, assert, beforeEach, spy }) => { beforeEach((context) => { context.app = new Application(); context.app.bind(Identifiers.Cryptography.Configuration).to(Configuration).inSingletonScope(); - context.app.get(Identifiers.Cryptography.Configuration).setConfig(cryptoJson); - context.app.get(Identifiers.Cryptography.Configuration).setHeight(1); // Required by schema to set number for validators + context.configuration = context.app.get(Identifiers.Cryptography.Configuration); + context.configuration.setConfig(cryptoJson); + context.configuration.setHeight(1); // Required by schema to set number for validators context.validator = context.app.resolve(Validator); for (const keyword of Object.values({ - ...makeBaseKeywords(context.app.get(Identifiers.Cryptography.Configuration)), + ...makeBaseKeywords(context.configuration), })) { context.validator.addKeyword(keyword); } @@ -144,4 +146,14 @@ describe<{ const result = validator.validate("message", { ...prevoteData, extraField: 123 }); assert.defined(result.error); }); + + it("message - should correctly parse block number", async ({ validator, configuration }) => { + const spyGetMilestone = spy(configuration, "getMilestone"); + + const result = validator.validate("message", { ...prevoteData, blockNumber: 3 }); + assert.undefined(result.error); + + spyGetMilestone.calledOnce(); + spyGetMilestone.calledWith(3); + }); }); diff --git a/packages/crypto-messages/source/schemas.ts b/packages/crypto-messages/source/schemas.ts index 9c42a70bf..48d33ad1c 100644 --- a/packages/crypto-messages/source/schemas.ts +++ b/packages/crypto-messages/source/schemas.ts @@ -11,7 +11,7 @@ export const schemas: Record<"message", AnySchemaObject> = { round: { minimum: 0, type: "integer" }, signature: { $ref: "consensusSignature" }, type: { enum: [Enums.Crypto.MessageType.Prevote, Enums.Crypto.MessageType.Precommit] }, - validatorIndex: { isValidatorIndex: {} }, + validatorIndex: { isValidatorIndex: { blockNumberPath: "blockNumber" } }, }, required: ["type", "blockNumber", "round", "validatorIndex", "signature"], type: "object", From 151ddacabafe3d166e6cb145da46fb4d5d1507f5 Mon Sep 17 00:00:00 2001 From: sebastijankuzner Date: Fri, 20 Mar 2026 19:21:47 +0100 Subject: [PATCH 08/12] Test crypto commit --- packages/crypto-commit/source/factory.ts | 11 ++++---- packages/crypto-commit/source/schemas.test.ts | 27 +++++++++++++++---- packages/crypto-commit/source/schemas.ts | 3 +++ 3 files changed, 30 insertions(+), 11 deletions(-) diff --git a/packages/crypto-commit/source/factory.ts b/packages/crypto-commit/source/factory.ts index cfad268e2..398e323a0 100644 --- a/packages/crypto-commit/source/factory.ts +++ b/packages/crypto-commit/source/factory.ts @@ -29,7 +29,6 @@ export class CommitFactory implements Contracts.Crypto.CommitFactory { const proofBuffer = buffer.readBytes(this.proofSize()); const proof = await this.commitDeserializer.deserializeCommitProof(proofBuffer); - this.#verifySchema("commitProof", proof); const block = await this.blockFactory.fromBytes(buffer.getRemainder()); @@ -39,7 +38,7 @@ export class CommitFactory implements Contracts.Crypto.CommitFactory { serialized: buff.toString("hex"), }; - this.#verifySchema("commit", commit); + this.#verifySchema(commit); return commit; } @@ -75,15 +74,15 @@ export class CommitFactory implements Contracts.Crypto.CommitFactory { proof: json.proof, serialized: json.serialized, }; - this.#verifySchema("commit", commit); + this.#verifySchema(commit); return commit; } - #verifySchema(schema: string, data: T): void { - const result = this.validator.validate(schema, data); + #verifySchema(data: T): void { + const result = this.validator.validate("commit", data); if (result.error) { - throw new MessageSchemaError(schema, result.error); + throw new MessageSchemaError("commit", result.error); } } } diff --git a/packages/crypto-commit/source/schemas.test.ts b/packages/crypto-commit/source/schemas.test.ts index b9aaa55bc..aaa953ccd 100644 --- a/packages/crypto-commit/source/schemas.test.ts +++ b/packages/crypto-commit/source/schemas.test.ts @@ -19,19 +19,21 @@ import { schemas } from "./schemas"; describe<{ app: Application; validator: Validator; -}>("Schemas", ({ it, assert, beforeEach }) => { + configuration: Configuration; +}>("Schemas", ({ it, assert, beforeEach, spy }) => { beforeEach((context) => { context.app = new Application(); context.app.bind(Identifiers.Cryptography.Configuration).to(Configuration).inSingletonScope(); - context.app.get(Identifiers.Cryptography.Configuration).setConfig(cryptoJson); - context.app.get(Identifiers.Cryptography.Configuration).setHeight(1); + context.configuration = context.app.get(Identifiers.Cryptography.Configuration); + context.configuration.setConfig(cryptoJson); + context.configuration.setHeight(1); context.validator = context.app.resolve(Validator); for (const keyword of Object.values({ - ...makeBaseKeywords(context.app.get(Identifiers.Cryptography.Configuration)), - ...makeTransactionKeywords(context.app.get(Identifiers.Cryptography.Configuration)), + ...makeBaseKeywords(context.configuration), + ...makeTransactionKeywords(context.configuration), })) { context.validator.addKeyword(keyword); } @@ -60,6 +62,21 @@ describe<{ assert.undefined(result.error); }); + + it("commit - should correctly parse block number", ({ validator, configuration }) => { + const spyGetMilestone = spy(configuration, "getMilestone"); + + const result = validator.validate("commit", { + block: blockData, + proof: commitProof1, + serialized: commitSerialized, + }); + assert.undefined(result.error); + + spyGetMilestone.calledTimes(7); // 6 x for block.number and 1 x for limitToRoundValidators + spyGetMilestone.calledWith(blockData.number); + }); + it("commit - should not allow additional fields", ({ validator }) => { const result = validator.validate("commit", { block: blockData, diff --git a/packages/crypto-commit/source/schemas.ts b/packages/crypto-commit/source/schemas.ts index 3d8c71f20..3b5d74d7f 100644 --- a/packages/crypto-commit/source/schemas.ts +++ b/packages/crypto-commit/source/schemas.ts @@ -19,6 +19,9 @@ export const schemas: Record<"commit" | "commitProof", AnySchemaObject> = { round: { minimum: 0, type: "integer" }, signature: { $ref: "consensusSignature" }, validators: { + // NOTE: This is not an actual property of the commit proof, but we need it to validate the commit proof against the correct set of validators. + // We take value from block.number, which is available in the commit schema + limitToRoundValidators: { blockNumberPath: "block.number" }, items: { type: "boolean" }, type: "array", }, From 290f75c430da85ed9aa8143ff308d5f5fa2a6b11 Mon Sep 17 00:00:00 2001 From: sebastijankuzner Date: Fri, 20 Mar 2026 19:41:21 +0100 Subject: [PATCH 09/12] Fix lint errors --- .../source/parse-block-number.ts | 34 ++++++++++--------- 1 file changed, 18 insertions(+), 16 deletions(-) diff --git a/packages/crypto-validation/source/parse-block-number.ts b/packages/crypto-validation/source/parse-block-number.ts index 67d24ccf7..fab7fbe6d 100644 --- a/packages/crypto-validation/source/parse-block-number.ts +++ b/packages/crypto-validation/source/parse-block-number.ts @@ -1,23 +1,11 @@ -// Copy form ajv -export interface DataValidationCxt { - rootData: Record | any[]; +// Copied form ajv and modified +interface DataValidationCxt { + rootData: Record | unknown[]; } -export const parseBlockNumber = (path: string | undefined, parentSchema: DataValidationCxt | undefined): number | undefined => { - if (path === undefined || parentSchema === undefined) { - return undefined; - } - - if (path === "payloadSerialized") { - return parseSerializedPayload(parentSchema.rootData?.["payloadSerialized"]); - } - - return parseOnPath(path, parentSchema.rootData); -}; - const parseOnPath = (path: string, rootData: object): number | undefined => { const parts = path.split("."); - let current: any = rootData; + let current = rootData; for (const part of parts) { if (current[part] === undefined) { @@ -51,3 +39,17 @@ const parseSerializedPayload = (serialized): number | undefined => { const offset = lockProofSize + 2 + 12; return Buffer.from(serialized.slice(offset, offset + 8), "hex").readUInt32LE(); } + + +export const parseBlockNumber = (path: string | undefined, parentSchema: DataValidationCxt | undefined): number | undefined => { + if (path === undefined || parentSchema === undefined) { + return undefined; + } + + if (path === "payloadSerialized") { + return parseSerializedPayload(parentSchema.rootData?.["payloadSerialized"]); + } + + return parseOnPath(path, parentSchema.rootData); +}; + From 0cf9612354ea26580abadd9d1610c3cca2b55096 Mon Sep 17 00:00:00 2001 From: sebastijankuzner <58827427+sebastijankuzner@users.noreply.github.com> Date: Fri, 20 Mar 2026 18:43:49 +0000 Subject: [PATCH 10/12] style: resolve style guide violations [ci-lint-fix] --- packages/crypto-commit/source/schemas.test.ts | 1 - packages/crypto-commit/source/schemas.ts | 2 +- packages/crypto-proposal/source/factory.ts | 2 +- .../crypto-proposal/source/schemas.test.ts | 7 ++++-- packages/crypto-proposal/source/schemas.ts | 6 +++-- packages/crypto-validation/source/keywords.ts | 13 ++++++----- .../source/parse-block-number.test.ts | 23 +++++++++++++------ .../source/parse-block-number.ts | 13 ++++++----- 8 files changed, 41 insertions(+), 26 deletions(-) diff --git a/packages/crypto-commit/source/schemas.test.ts b/packages/crypto-commit/source/schemas.test.ts index aaa953ccd..9cf835107 100644 --- a/packages/crypto-commit/source/schemas.test.ts +++ b/packages/crypto-commit/source/schemas.test.ts @@ -62,7 +62,6 @@ describe<{ assert.undefined(result.error); }); - it("commit - should correctly parse block number", ({ validator, configuration }) => { const spyGetMilestone = spy(configuration, "getMilestone"); diff --git a/packages/crypto-commit/source/schemas.ts b/packages/crypto-commit/source/schemas.ts index 3b5d74d7f..f2c451450 100644 --- a/packages/crypto-commit/source/schemas.ts +++ b/packages/crypto-commit/source/schemas.ts @@ -19,10 +19,10 @@ export const schemas: Record<"commit" | "commitProof", AnySchemaObject> = { round: { minimum: 0, type: "integer" }, signature: { $ref: "consensusSignature" }, validators: { + items: { type: "boolean" }, // NOTE: This is not an actual property of the commit proof, but we need it to validate the commit proof against the correct set of validators. // We take value from block.number, which is available in the commit schema limitToRoundValidators: { blockNumberPath: "block.number" }, - items: { type: "boolean" }, type: "array", }, }, diff --git a/packages/crypto-proposal/source/factory.ts b/packages/crypto-proposal/source/factory.ts index 351dfd3ce..ab18b533a 100644 --- a/packages/crypto-proposal/source/factory.ts +++ b/packages/crypto-proposal/source/factory.ts @@ -90,7 +90,7 @@ export class Factory implements Contracts.Crypto.ProposalFactory { const lockProof = await this.#getLockProof(buffer); const blockHeader = await this.blockFactory.headerFromBytes(buffer.getRemainder()); - if(lockProof) { + if (lockProof) { this.#verifySchema("lockProof", { ...lockProof, number: blockHeader.number }); } diff --git a/packages/crypto-proposal/source/schemas.test.ts b/packages/crypto-proposal/source/schemas.test.ts index fd87acc98..fb996a03e 100644 --- a/packages/crypto-proposal/source/schemas.test.ts +++ b/packages/crypto-proposal/source/schemas.test.ts @@ -272,7 +272,10 @@ describe<{ } }); - it("proposalUnsigned - should correctly deserialize block number from payloadSerialized", ({ validator , configuration}) => { + it("proposalUnsigned - should correctly deserialize block number from payloadSerialized", ({ + validator, + configuration, + }) => { const spyConfigurationGetMilestone = spy(configuration, "getMilestone"); const result = validator.validate("proposalUnsigned", Proposal.proposalDataSerializableUnsigned); @@ -282,7 +285,7 @@ describe<{ spyConfigurationGetMilestone.calledWith(2); }); - it("lockProof - should correctly parse block number from number", ({ validator , configuration}) => { + it("lockProof - should correctly parse block number from number", ({ validator, configuration }) => { const spyConfigurationGetMilestone = spy(configuration, "getMilestone"); const result = validator.validate("lockProof", { ...lockProof, number: 3 }); diff --git a/packages/crypto-proposal/source/schemas.ts b/packages/crypto-proposal/source/schemas.ts index 43e9ba50f..41c6490c6 100644 --- a/packages/crypto-proposal/source/schemas.ts +++ b/packages/crypto-proposal/source/schemas.ts @@ -18,14 +18,16 @@ export const schemas: Record<"lockProof" | "proposal" | "proposalUnsigned", AnyS $id: "lockProof", additionalProperties: false, properties: { + // NOTE: This is not an actual property of the lock proof, but we need it to validate the lock proof against the correct set of validators. + number: { minimum: 0, type: "integer" }, + signature: { $ref: "consensusSignature" }, + validators: { items: { type: "boolean" }, limitToRoundValidators: { blockNumberPath: "number" }, type: "array", }, - // NOTE: This is not an actual property of the lock proof, but we need it to validate the lock proof against the correct set of validators. - number: { minimum: 0, type: "integer" }, }, required: ["signature", "validators", "number"], type: "object", diff --git a/packages/crypto-validation/source/keywords.ts b/packages/crypto-validation/source/keywords.ts index 5d6975cb0..391e2f65c 100644 --- a/packages/crypto-validation/source/keywords.ts +++ b/packages/crypto-validation/source/keywords.ts @@ -1,6 +1,7 @@ import type { Contracts } from "@mainsail/contracts"; import { BigNumber } from "@mainsail/utils"; -import type { FuncKeywordDefinition } from "ajv"; +import type { FuncKeywordDefinition } from "ajv"; + import { parseBlockNumber } from "./parse-block-number.js"; export const makeKeywords = ( @@ -110,13 +111,13 @@ export const makeKeywords = ( }; }, errors: false, + keyword: "isValidatorIndex", metaSchema: { - properties: { - blockNumberPath: { type: "string" }, - }, - type: "object", + properties: { + blockNumberPath: { type: "string" }, + }, + type: "object", }, - keyword: "isValidatorIndex", }; return { bignumber, buffer, isValidatorIndex, limitToRoundValidators, maxBytes }; diff --git a/packages/crypto-validation/source/parse-block-number.test.ts b/packages/crypto-validation/source/parse-block-number.test.ts index f00b0b8ba..8190ac9b4 100644 --- a/packages/crypto-validation/source/parse-block-number.test.ts +++ b/packages/crypto-validation/source/parse-block-number.test.ts @@ -1,10 +1,14 @@ - import { Validator } from "@mainsail/validation/source/validator"; import { Application } from "@mainsail/kernel"; import { describe } from "@mainsail/test-runner"; import { parseBlockNumber } from "./parse-block-number"; -import { Proposal, ProposalWithLockProof, ProposalWithLockProofAndValidRound, ProposalWithValidRound } from "../../crypto-proposal/test/fixtures/index.js"; +import { + Proposal, + ProposalWithLockProof, + ProposalWithLockProofAndValidRound, + ProposalWithValidRound, +} from "../../crypto-proposal/test/fixtures/index.js"; describe<{ app: Application; @@ -27,7 +31,7 @@ describe<{ it("should return undefined if value at path is not a number", () => { const result = parseBlockNumber("some.path", { rootData: { - some: { path: "not a number" }, + some: { path: "not a number" }, }, }); assert.undefined(result); @@ -36,7 +40,7 @@ describe<{ it("should return undefined if value at path is string representation of a number", () => { const result = parseBlockNumber("some.path", { rootData: { - some: { path: "42" }, + some: { path: "42" }, }, }); assert.undefined(result); @@ -54,7 +58,7 @@ describe<{ it("should return the number at the specified path (nested)", () => { const result = parseBlockNumber("some.path", { rootData: { - some: { path: 42 }, + some: { path: 42 }, }, }); assert.equal(result, 42); @@ -79,13 +83,18 @@ describe<{ it("should return the block number from payloadSerialized", () => { const payloadSerialized = Proposal.payloadSerialized; - for(const payloadSerialized of [Proposal.payloadSerialized, ProposalWithLockProof.payloadSerialized, ProposalWithLockProofAndValidRound.payloadSerialized, ProposalWithValidRound.payloadSerialized]) { + for (const payloadSerialized of [ + Proposal.payloadSerialized, + ProposalWithLockProof.payloadSerialized, + ProposalWithLockProofAndValidRound.payloadSerialized, + ProposalWithValidRound.payloadSerialized, + ]) { const result = parseBlockNumber("payloadSerialized", { rootData: { payloadSerialized, }, }); assert.equal(result, 2); - }; + } }); }); diff --git a/packages/crypto-validation/source/parse-block-number.ts b/packages/crypto-validation/source/parse-block-number.ts index fab7fbe6d..0d1d26cf1 100644 --- a/packages/crypto-validation/source/parse-block-number.ts +++ b/packages/crypto-validation/source/parse-block-number.ts @@ -14,12 +14,12 @@ const parseOnPath = (path: string, rootData: object): number | undefined => { current = current[part]; } - if(typeof current === "number") { + if (typeof current === "number") { return current; } return undefined; -} +}; // Proposals contain the block only in serialized form (hex). // We can extract the block number at a fixed offset here, without needing to deserialize the whole block. @@ -38,10 +38,12 @@ const parseSerializedPayload = (serialized): number | undefined => { // blockNumber: 4 byte (8 hex) const offset = lockProofSize + 2 + 12; return Buffer.from(serialized.slice(offset, offset + 8), "hex").readUInt32LE(); -} - +}; -export const parseBlockNumber = (path: string | undefined, parentSchema: DataValidationCxt | undefined): number | undefined => { +export const parseBlockNumber = ( + path: string | undefined, + parentSchema: DataValidationCxt | undefined, +): number | undefined => { if (path === undefined || parentSchema === undefined) { return undefined; } @@ -52,4 +54,3 @@ export const parseBlockNumber = (path: string | undefined, parentSchema: DataVa return parseOnPath(path, parentSchema.rootData); }; - From 086e2c0eba68e307b097c399d762c5f70bcc4e64 Mon Sep 17 00:00:00 2001 From: sebastijankuzner Date: Mon, 23 Mar 2026 10:12:47 +0100 Subject: [PATCH 11/12] Verify lock proof when making from bytes --- packages/crypto-proposal/source/factory.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/crypto-proposal/source/factory.ts b/packages/crypto-proposal/source/factory.ts index ab18b533a..d08f501f9 100644 --- a/packages/crypto-proposal/source/factory.ts +++ b/packages/crypto-proposal/source/factory.ts @@ -63,6 +63,10 @@ export class Factory implements Contracts.Crypto.ProposalFactory { const lockProof = await this.#getLockProof(buffer); const block = await this.blockFactory.fromBytes(buffer.getRemainder()); + if(lockProof) { + this.#verifySchema("lockProof", { ...lockProof, number: block.number }); + } + return { block, lockProof, From 03d2900951142d2d12c7dd5b3aa7d6fcb54ad993 Mon Sep 17 00:00:00 2001 From: sebastijankuzner <58827427+sebastijankuzner@users.noreply.github.com> Date: Mon, 23 Mar 2026 09:16:19 +0000 Subject: [PATCH 12/12] style: resolve style guide violations [ci-lint-fix] --- packages/crypto-proposal/source/factory.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/crypto-proposal/source/factory.ts b/packages/crypto-proposal/source/factory.ts index d08f501f9..2690fe767 100644 --- a/packages/crypto-proposal/source/factory.ts +++ b/packages/crypto-proposal/source/factory.ts @@ -63,7 +63,7 @@ export class Factory implements Contracts.Crypto.ProposalFactory { const lockProof = await this.#getLockProof(buffer); const block = await this.blockFactory.fromBytes(buffer.getRemainder()); - if(lockProof) { + if (lockProof) { this.#verifySchema("lockProof", { ...lockProof, number: block.number }); }