diff --git a/packages/contracts/source/contracts/p2p/endpoints.ts b/packages/contracts/source/contracts/p2p/endpoints.ts index 7cf5c448a..e8304148a 100644 --- a/packages/contracts/source/contracts/p2p/endpoints.ts +++ b/packages/contracts/source/contracts/p2p/endpoints.ts @@ -61,9 +61,15 @@ export interface GetStatusResponse extends Response { config: PeerConfig; } +export interface GetProposalQuery { + blockNumber: number; + round: number; +} + export interface GetProposalRequest extends Request { payload: { headers: HeaderData; + query: GetProposalQuery; }; } diff --git a/packages/contracts/source/contracts/p2p/peer-communicator.ts b/packages/contracts/source/contracts/p2p/peer-communicator.ts index e37d581e6..b333751ab 100644 --- a/packages/contracts/source/contracts/p2p/peer-communicator.ts +++ b/packages/contracts/source/contracts/p2p/peer-communicator.ts @@ -4,6 +4,7 @@ import type { GetMessagesQuery, GetMessagesResponse, GetPeersResponse, + GetProposalQuery, GetProposalResponse, GetStatusResponse, } from "./endpoints.js"; @@ -22,7 +23,7 @@ export interface PeerCommunicator { getPeers(peer: Peer): Promise; getApiNodes(peer: Peer): Promise; getMessages(peer: Peer, query: GetMessagesQuery): Promise; - getProposal(peer: Peer): Promise; + getProposal(peer: Peer, query: GetProposalQuery): Promise; getBlocks( peer: Peer, { fromBlockNumber, limit }: { fromBlockNumber: number; limit?: number }, diff --git a/packages/p2p/source/downloader/proposal-downloader.test.ts b/packages/p2p/source/downloader/proposal-downloader.test.ts index db03dda70..ba7f5e85f 100644 --- a/packages/p2p/source/downloader/proposal-downloader.test.ts +++ b/packages/p2p/source/downloader/proposal-downloader.test.ts @@ -46,6 +46,14 @@ describe<{ context.downloader = context.app.resolve(ProposalDownloader); }); + it("#download - should request exactly the job's block and round", ({ downloader, peer }) => { + const getProposal = stub(communicator, "getProposal").returnValue(new Promise(() => {})); + + downloader.download(peer); + + getProposal.calledWith(peer, { blockNumber: 2, round: 0 }); + }); + it("#download - should release the slot on an empty reply and allow the round to be re-pulled", async ({ downloader, peer, diff --git a/packages/p2p/source/downloader/proposal-downloader.ts b/packages/p2p/source/downloader/proposal-downloader.ts index 1584268d0..9f939cac2 100644 --- a/packages/p2p/source/downloader/proposal-downloader.ts +++ b/packages/p2p/source/downloader/proposal-downloader.ts @@ -129,7 +129,10 @@ export class ProposalDownloader implements Contracts.P2P.Downloader { let error: Error | undefined; try { - const result = await this.communicator.getProposal(job.peer); + const result = await this.communicator.getProposal(job.peer, { + blockNumber: job.blockNumber, + round: job.round, + }); if (result.proposal.length === 0) { return; diff --git a/packages/p2p/source/peer-communicator.ts b/packages/p2p/source/peer-communicator.ts index e306e535b..f1b9546d1 100644 --- a/packages/p2p/source/peer-communicator.ts +++ b/packages/p2p/source/peer-communicator.ts @@ -91,11 +91,14 @@ export class PeerCommunicator implements Contracts.P2P.PeerCommunicator { return response.data; } - public async getProposal(peer: Contracts.P2P.Peer): Promise { + public async getProposal( + peer: Contracts.P2P.Peer, + query: Contracts.P2P.GetProposalQuery, + ): Promise { const response = await this.#emit( peer, Routes.GetProposal, - {}, + { query }, { timeout: 5000 }, ); return response.data; diff --git a/packages/p2p/source/socket-server/codecs/proto/get-proposal.proto b/packages/p2p/source/socket-server/codecs/proto/get-proposal.proto index d34315079..8b21f33e0 100644 --- a/packages/p2p/source/socket-server/codecs/proto/get-proposal.proto +++ b/packages/p2p/source/socket-server/codecs/proto/get-proposal.proto @@ -4,8 +4,14 @@ import "shared.proto"; package getProposal; +message GetProposalQuery { + uint32 blockNumber = 1; + uint32 round = 2; +} + message GetProposalRequest { shared.Headers headers = 1; + GetProposalQuery query = 2; } message GetProposalResponse { diff --git a/packages/p2p/source/socket-server/codecs/proto/protos.d.ts b/packages/p2p/source/socket-server/codecs/proto/protos.d.ts index 52664f668..5ee442beb 100644 --- a/packages/p2p/source/socket-server/codecs/proto/protos.d.ts +++ b/packages/p2p/source/socket-server/codecs/proto/protos.d.ts @@ -1542,6 +1542,129 @@ export namespace getPeers { /** Namespace getProposal. */ export namespace getProposal { + /** + * Properties of a GetProposalQuery. + * @deprecated Use getProposal.GetProposalQuery.$Properties instead. + */ + interface IGetProposalQuery extends getProposal.GetProposalQuery.$Properties { + } + + /** Represents a GetProposalQuery. */ + class GetProposalQuery { + + /** + * Constructs a new GetProposalQuery. + * @param [properties] Properties to set + */ + constructor(properties?: getProposal.GetProposalQuery.$Properties); + + /** Unknown fields preserved while decoding when enabled */ + $unknowns?: Uint8Array[]; + + /** GetProposalQuery blockNumber. */ + blockNumber: number; + + /** GetProposalQuery round. */ + round: number; + + /** + * Creates a new GetProposalQuery instance using the specified properties. + * @param [properties] Properties to set + * @returns GetProposalQuery instance + */ + static create(properties: getProposal.GetProposalQuery.$Shape): getProposal.GetProposalQuery & getProposal.GetProposalQuery.$Shape; + static create(properties?: getProposal.GetProposalQuery.$Properties): getProposal.GetProposalQuery; + + /** + * Encodes the specified GetProposalQuery message. Does not implicitly {@link getProposal.GetProposalQuery.verify|verify} messages. + * @param message GetProposalQuery message or plain object to encode + * @param [writer] Writer to encode to + * @returns Writer + */ + static encode(message: getProposal.GetProposalQuery.$Properties, writer?: $protobuf.Writer): $protobuf.Writer; + + /** + * Encodes the specified GetProposalQuery message, length delimited. Does not implicitly {@link getProposal.GetProposalQuery.verify|verify} messages. + * @param message GetProposalQuery message or plain object to encode + * @param [writer] Writer to encode to + * @returns Writer + */ + static encodeDelimited(message: getProposal.GetProposalQuery.$Properties, writer?: $protobuf.Writer): $protobuf.Writer; + + /** + * Decodes a GetProposalQuery message from the specified reader or buffer. + * @param reader Reader or buffer to decode from + * @param [length] Message length if known beforehand + * @returns {getProposal.GetProposalQuery & getProposal.GetProposalQuery.$Shape} GetProposalQuery + * @throws {Error} If the payload is not a reader or valid buffer + * @throws {$protobuf.util.ProtocolError} If required fields are missing + */ + static decode(reader: ($protobuf.Reader|Uint8Array), length?: number): getProposal.GetProposalQuery & getProposal.GetProposalQuery.$Shape; + + /** + * Decodes a GetProposalQuery message from the specified reader or buffer, length delimited. + * @param reader Reader or buffer to decode from + * @returns {getProposal.GetProposalQuery & getProposal.GetProposalQuery.$Shape} GetProposalQuery + * @throws {Error} If the payload is not a reader or valid buffer + * @throws {$protobuf.util.ProtocolError} If required fields are missing + */ + static decodeDelimited(reader: ($protobuf.Reader|Uint8Array)): getProposal.GetProposalQuery & getProposal.GetProposalQuery.$Shape; + + /** + * Verifies a GetProposalQuery message. + * @param message Plain object to verify + * @returns `null` if valid, otherwise the reason why it is not + */ + static verify(message: { [k: string]: any }): (string|null); + + /** + * Creates a GetProposalQuery message from a plain object. Also converts values to their respective internal types. + * @param object Plain object + * @returns GetProposalQuery + */ + static fromObject(object: { [k: string]: any }): getProposal.GetProposalQuery; + + /** + * Creates a plain object from a GetProposalQuery message. Also converts values to other types if specified. + * @param message GetProposalQuery + * @param [options] Conversion options + * @returns Plain object + */ + static toObject(message: getProposal.GetProposalQuery, options?: $protobuf.IConversionOptions): { [k: string]: any }; + + /** + * Converts this GetProposalQuery to JSON. + * @returns JSON object + */ + toJSON(): { [k: string]: any }; + + /** + * Gets the type url for GetProposalQuery + * @param [prefix] Custom type url prefix, defaults to `"type.googleapis.com"` + * @returns The type url + */ + static getTypeUrl(prefix?: string): string; + } + + namespace GetProposalQuery { + + /** Properties of a GetProposalQuery. */ + interface $Properties { + + /** GetProposalQuery blockNumber */ + blockNumber?: (number|null); + + /** GetProposalQuery round */ + round?: (number|null); + + /** Unknown fields preserved while decoding when enabled */ + $unknowns?: Uint8Array[]; + } + + /** Shape of a GetProposalQuery. */ + type $Shape = getProposal.GetProposalQuery.$Properties; + } + /** * Properties of a GetProposalRequest. * @deprecated Use getProposal.GetProposalRequest.$Properties instead. @@ -1564,6 +1687,9 @@ export namespace getProposal { /** GetProposalRequest headers. */ headers?: (shared.Headers.$Properties|null); + /** GetProposalRequest query. */ + query?: (getProposal.GetProposalQuery.$Properties|null); + /** * Creates a new GetProposalRequest instance using the specified properties. * @param [properties] Properties to set @@ -1651,6 +1777,9 @@ export namespace getProposal { /** GetProposalRequest headers */ headers?: (shared.Headers.$Properties|null); + /** GetProposalRequest query */ + query?: (getProposal.GetProposalQuery.$Properties|null); + /** Unknown fields preserved while decoding when enabled */ $unknowns?: Uint8Array[]; } diff --git a/packages/p2p/source/socket-server/codecs/proto/protos.js b/packages/p2p/source/socket-server/codecs/proto/protos.js index af91c0047..ae663cd1c 100644 --- a/packages/p2p/source/socket-server/codecs/proto/protos.js +++ b/packages/p2p/source/socket-server/codecs/proto/protos.js @@ -3950,12 +3950,303 @@ export const getProposal = $root.getProposal = (() => { */ const getProposal = {}; + getProposal.GetProposalQuery = (function() { + + /** + * Properties of a GetProposalQuery. + * @typedef {Object} getProposal.GetProposalQuery.$Properties + * @property {number|null} [blockNumber] GetProposalQuery blockNumber + * @property {number|null} [round] GetProposalQuery round + * @property {Array.} [$unknowns] Unknown fields preserved while decoding when enabled + */ + + /** + * Properties of a GetProposalQuery. + * @memberof getProposal + * @interface IGetProposalQuery + * @augments getProposal.GetProposalQuery.$Properties + * @deprecated Use getProposal.GetProposalQuery.$Properties instead. + */ + + /** + * Shape of a GetProposalQuery. + * @typedef {getProposal.GetProposalQuery.$Properties} getProposal.GetProposalQuery.$Shape + */ + + /** + * Constructs a new GetProposalQuery. + * @memberof getProposal + * @classdesc Represents a GetProposalQuery. + * @constructor + * @param {getProposal.GetProposalQuery.$Properties=} [properties] Properties to set + * @property {Array.} [$unknowns] Unknown fields preserved while decoding when enabled + */ + const GetProposalQuery = function (properties) { + if (properties) + for (let keys = $Object.keys(properties), i = 0; i < keys.length; ++i) + if (properties[keys[i]] != null && keys[i] !== "__proto__") + this[keys[i]] = properties[keys[i]]; + }; + + /** + * GetProposalQuery blockNumber. + * @member {number} blockNumber + * @memberof getProposal.GetProposalQuery + * @instance + */ + GetProposalQuery.prototype.blockNumber = 0; + + /** + * GetProposalQuery round. + * @member {number} round + * @memberof getProposal.GetProposalQuery + * @instance + */ + GetProposalQuery.prototype.round = 0; + + /** + * Creates a new GetProposalQuery instance using the specified properties. + * @function create + * @memberof getProposal.GetProposalQuery + * @static + * @param {getProposal.GetProposalQuery.$Properties=} [properties] Properties to set + * @returns {getProposal.GetProposalQuery} GetProposalQuery instance + * @type {{ + * (properties: getProposal.GetProposalQuery.$Shape): getProposal.GetProposalQuery & getProposal.GetProposalQuery.$Shape; + * (properties?: getProposal.GetProposalQuery.$Properties): getProposal.GetProposalQuery; + * }} + */ + GetProposalQuery.create = function(properties) { + return new GetProposalQuery(properties); + }; + + /** + * Encodes the specified GetProposalQuery message. Does not implicitly {@link getProposal.GetProposalQuery.verify|verify} messages. + * @function encode + * @memberof getProposal.GetProposalQuery + * @static + * @param {getProposal.GetProposalQuery.$Properties} message GetProposalQuery message or plain object to encode + * @param {$protobuf.Writer} [writer] Writer to encode to + * @returns {$protobuf.Writer} Writer + */ + GetProposalQuery.encode = function (message, writer, _depth) { + if (!writer) + writer = $Writer.create(); + if (_depth === $undefined) + _depth = 0; + if (_depth > $util.recursionLimit) + throw $Error("max depth exceeded"); + if (message.blockNumber != null && $Object.hasOwnProperty.call(message, "blockNumber")) + writer.uint32(/* id 1, wireType 0 =*/8).uint32(message.blockNumber); + if (message.round != null && $Object.hasOwnProperty.call(message, "round")) + writer.uint32(/* id 2, wireType 0 =*/16).uint32(message.round); + if (message.$unknowns != null && $Object.hasOwnProperty.call(message, "$unknowns")) + for (let i = 0; i < message.$unknowns.length; ++i) + writer.raw(message.$unknowns[i]); + return writer; + }; + + /** + * Encodes the specified GetProposalQuery message, length delimited. Does not implicitly {@link getProposal.GetProposalQuery.verify|verify} messages. + * @function encodeDelimited + * @memberof getProposal.GetProposalQuery + * @static + * @param {getProposal.GetProposalQuery.$Properties} message GetProposalQuery message or plain object to encode + * @param {$protobuf.Writer} [writer] Writer to encode to + * @returns {$protobuf.Writer} Writer + */ + GetProposalQuery.encodeDelimited = function(message, writer) { + return this.encode(message, writer && writer.len ? writer.fork() : writer).ldelim(); + }; + + /** + * Decodes a GetProposalQuery message from the specified reader or buffer. + * @function decode + * @memberof getProposal.GetProposalQuery + * @static + * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from + * @param {number} [length] Message length if known beforehand + * @returns {getProposal.GetProposalQuery & getProposal.GetProposalQuery.$Shape} GetProposalQuery + * @throws {Error} If the payload is not a reader or valid buffer + * @throws {$protobuf.util.ProtocolError} If required fields are missing + */ + GetProposalQuery.decode = function (reader, length, _end, _depth, _target) { + if (!(reader instanceof $Reader)) + reader = $Reader.create(reader); + if (_depth === $undefined) + _depth = 0; + if (_depth > $Reader.recursionLimit) + throw $Error("max depth exceeded"); + let end = length === $undefined ? reader.len : reader.pos + length, message = _target || new $root.getProposal.GetProposalQuery(), value; + while (reader.pos < end) { + let start = reader.pos; + let tag = reader.tag(); + if (tag === _end) { + _end = $undefined; + break; + } + let wireType = tag & 7; + switch (tag >>>= 3) { + case 1: { + if (wireType !== 0) + break; + if (value = reader.uint32()) + message.blockNumber = value; + else + delete message.blockNumber; + continue; + } + case 2: { + if (wireType !== 0) + break; + if (value = reader.uint32()) + message.round = value; + else + delete message.round; + continue; + } + } + reader.skipType(wireType, _depth, tag); + if (!reader.discardUnknown) { + $util.makeProp(message, "$unknowns", false); + (message.$unknowns || (message.$unknowns = [])).push(reader.raw(start, reader.pos)); + } + } + if (_end !== $undefined) + throw $Error("missing end group"); + return message; + }; + + /** + * Decodes a GetProposalQuery message from the specified reader or buffer, length delimited. + * @function decodeDelimited + * @memberof getProposal.GetProposalQuery + * @static + * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from + * @returns {getProposal.GetProposalQuery & getProposal.GetProposalQuery.$Shape} GetProposalQuery + * @throws {Error} If the payload is not a reader or valid buffer + * @throws {$protobuf.util.ProtocolError} If required fields are missing + */ + GetProposalQuery.decodeDelimited = function(reader) { + if (!(reader instanceof $Reader)) + reader = new $Reader(reader); + return this.decode(reader, reader.uint32()); + }; + + /** + * Verifies a GetProposalQuery message. + * @function verify + * @memberof getProposal.GetProposalQuery + * @static + * @param {Object.} message Plain object to verify + * @returns {string|null} `null` if valid, otherwise the reason why it is not + */ + GetProposalQuery.verify = function (message, _depth) { + if (typeof message !== "object" || message === null) + return "object expected"; + if (_depth === $undefined) + _depth = 0; + if (_depth > $util.recursionLimit) + return "max depth exceeded"; + if (message.blockNumber != null && $Object.hasOwnProperty.call(message, "blockNumber")) + if (!$util.isInteger(message.blockNumber)) + return "blockNumber: integer expected"; + if (message.round != null && $Object.hasOwnProperty.call(message, "round")) + if (!$util.isInteger(message.round)) + return "round: integer expected"; + return null; + }; + + /** + * Creates a GetProposalQuery message from a plain object. Also converts values to their respective internal types. + * @function fromObject + * @memberof getProposal.GetProposalQuery + * @static + * @param {Object.} object Plain object + * @returns {getProposal.GetProposalQuery} GetProposalQuery + */ + GetProposalQuery.fromObject = function (object, _depth) { + if (object instanceof $root.getProposal.GetProposalQuery) + return object; + if (!$util.isObject(object)) + throw $TypeError(".getProposal.GetProposalQuery: object expected"); + if (_depth === $undefined) + _depth = 0; + if (_depth > $util.recursionLimit) + throw $Error("max depth exceeded"); + let message = new $root.getProposal.GetProposalQuery(); + if (object.blockNumber != null) + if ($Number(object.blockNumber) !== 0) + message.blockNumber = object.blockNumber >>> 0; + if (object.round != null) + if ($Number(object.round) !== 0) + message.round = object.round >>> 0; + return message; + }; + + /** + * Creates a plain object from a GetProposalQuery message. Also converts values to other types if specified. + * @function toObject + * @memberof getProposal.GetProposalQuery + * @static + * @param {getProposal.GetProposalQuery} message GetProposalQuery + * @param {$protobuf.IConversionOptions} [options] Conversion options + * @returns {Object.} Plain object + */ + GetProposalQuery.toObject = function (message, options, _depth) { + if (!options) + options = {}; + if (_depth === $undefined) + _depth = 0; + if (_depth > $util.recursionLimit) + throw $Error("max depth exceeded"); + let object = {}; + if (options.defaults) { + object.blockNumber = 0; + object.round = 0; + } + if (message.blockNumber != null && $Object.hasOwnProperty.call(message, "blockNumber")) + object.blockNumber = message.blockNumber; + if (message.round != null && $Object.hasOwnProperty.call(message, "round")) + object.round = message.round; + return object; + }; + + /** + * Converts this GetProposalQuery to JSON. + * @function toJSON + * @memberof getProposal.GetProposalQuery + * @instance + * @returns {Object.} JSON object + */ + GetProposalQuery.prototype.toJSON = function() { + return GetProposalQuery.toObject(this, $protobuf.util.toJSONOptions); + }; + + /** + * Gets the type url for GetProposalQuery + * @function getTypeUrl + * @memberof getProposal.GetProposalQuery + * @static + * @param {string} [prefix] Custom type url prefix, defaults to `"type.googleapis.com"` + * @returns {string} The type url + */ + GetProposalQuery.getTypeUrl = function(prefix) { + if (prefix === $undefined) + prefix = "type.googleapis.com"; + return prefix + "/getProposal.GetProposalQuery"; + }; + + return GetProposalQuery; + })(); + getProposal.GetProposalRequest = (function() { /** * Properties of a GetProposalRequest. * @typedef {Object} getProposal.GetProposalRequest.$Properties * @property {shared.Headers.$Properties|null} [headers] GetProposalRequest headers + * @property {getProposal.GetProposalQuery.$Properties|null} [query] GetProposalRequest query * @property {Array.} [$unknowns] Unknown fields preserved while decoding when enabled */ @@ -3995,6 +4286,14 @@ export const getProposal = $root.getProposal = (() => { */ GetProposalRequest.prototype.headers = null; + /** + * GetProposalRequest query. + * @member {getProposal.GetProposalQuery.$Properties|null|undefined} query + * @memberof getProposal.GetProposalRequest + * @instance + */ + GetProposalRequest.prototype.query = null; + /** * Creates a new GetProposalRequest instance using the specified properties. * @function create @@ -4029,6 +4328,8 @@ export const getProposal = $root.getProposal = (() => { throw $Error("max depth exceeded"); if (message.headers != null && $Object.hasOwnProperty.call(message, "headers")) $root.shared.Headers.encode(message.headers, writer.uint32(/* id 1, wireType 2 =*/10).fork(), _depth + 1).ldelim(); + if (message.query != null && $Object.hasOwnProperty.call(message, "query")) + $root.getProposal.GetProposalQuery.encode(message.query, writer.uint32(/* id 2, wireType 2 =*/18).fork(), _depth + 1).ldelim(); if (message.$unknowns != null && $Object.hasOwnProperty.call(message, "$unknowns")) for (let i = 0; i < message.$unknowns.length; ++i) writer.raw(message.$unknowns[i]); @@ -4082,6 +4383,12 @@ export const getProposal = $root.getProposal = (() => { message.headers = $root.shared.Headers.decode(reader, reader.uint32(), $undefined, _depth + 1, message.headers); continue; } + case 2: { + if (wireType !== 2) + break; + message.query = $root.getProposal.GetProposalQuery.decode(reader, reader.uint32(), $undefined, _depth + 1, message.query); + continue; + } } reader.skipType(wireType, _depth, tag); if (!reader.discardUnknown) { @@ -4130,6 +4437,11 @@ export const getProposal = $root.getProposal = (() => { if (error) return "headers." + error; } + if (message.query != null && $Object.hasOwnProperty.call(message, "query")) { + let error = $root.getProposal.GetProposalQuery.verify(message.query, _depth + 1); + if (error) + return "query." + error; + } return null; }; @@ -4156,6 +4468,11 @@ export const getProposal = $root.getProposal = (() => { throw $TypeError(".getProposal.GetProposalRequest.headers: object expected"); message.headers = $root.shared.Headers.fromObject(object.headers, _depth + 1); } + if (object.query != null) { + if (!$util.isObject(object.query)) + throw $TypeError(".getProposal.GetProposalRequest.query: object expected"); + message.query = $root.getProposal.GetProposalQuery.fromObject(object.query, _depth + 1); + } return message; }; @@ -4176,10 +4493,14 @@ export const getProposal = $root.getProposal = (() => { if (_depth > $util.recursionLimit) throw $Error("max depth exceeded"); let object = {}; - if (options.defaults) + if (options.defaults) { object.headers = null; + object.query = null; + } if (message.headers != null && $Object.hasOwnProperty.call(message, "headers")) object.headers = $root.shared.Headers.toObject(message.headers, options, _depth + 1); + if (message.query != null && $Object.hasOwnProperty.call(message, "query")) + object.query = $root.getProposal.GetProposalQuery.toObject(message.query, options, _depth + 1); return object; }; diff --git a/packages/p2p/source/socket-server/controllers/get-proposal.test.ts b/packages/p2p/source/socket-server/controllers/get-proposal.test.ts new file mode 100644 index 000000000..9b9a4f4aa --- /dev/null +++ b/packages/p2p/source/socket-server/controllers/get-proposal.test.ts @@ -0,0 +1,92 @@ +import { Identifiers } from "@mainsail/constants"; +import { Application } from "@mainsail/kernel"; + +import { describe } from "@mainsail/test-runner"; +import { GetProposalController } from "./get-proposal"; + +describe<{ + app: Application; + controller: GetProposalController; + roundStates: Map; +}>("GetProposalController", ({ it, assert, beforeEach }) => { + const consensus = { getBlockNumber: () => 2, getRound: () => 5 }; + + const makeRoundState = (round: number, proposal?: Buffer) => ({ + getProposal: () => (proposal ? { serialized: proposal } : undefined), + round, + }); + + beforeEach((context) => { + context.roundStates = new Map(); + + context.app = new Application(); + context.app.bind(Identifiers.Consensus.Service).toConstantValue(consensus); + context.app.bind(Identifiers.Consensus.RoundStateRepository).toConstantValue({ + getRoundState: (blockNumber: number, round: number) => + context.roundStates.get(`${blockNumber}-${round}`) ?? makeRoundState(round), + }); + + context.controller = context.app.resolve(GetProposalController); + }); + + it("should serve the queried round even while consensus is on a later one", async (context) => { + // Consensus sits on round 5; the requester asks for round 3, which is still retained. + context.roundStates.set("2-3", makeRoundState(3, Buffer.from([3]))); + + const response = await context.controller.handle( + { + payload: { + headers: { blockNumber: 2, round: 5 }, + query: { blockNumber: 2, round: 3 }, + }, + } as any, + {} as any, + ); + + assert.equal(response.proposal, Buffer.from([3])); + }); + + it("should answer empty for a block it is not deciding", async (context) => { + context.roundStates.set("3-0", makeRoundState(0, Buffer.from([1]))); + + const response = await context.controller.handle( + { + payload: { + headers: { blockNumber: 2, round: 5 }, + query: { blockNumber: 3, round: 0 }, + }, + } as any, + {} as any, + ); + + assert.equal(response.proposal, Buffer.alloc(0)); + }); + + it("should answer empty for a round it has not reached", async (context) => { + const response = await context.controller.handle( + { + payload: { + headers: { blockNumber: 2, round: 5 }, + query: { blockNumber: 2, round: 7 }, + }, + } as any, + {} as any, + ); + + assert.equal(response.proposal, Buffer.alloc(0)); + }); + + it("should answer empty when the queried round holds no proposal", async (context) => { + const response = await context.controller.handle( + { + payload: { + headers: { blockNumber: 2, round: 5 }, + query: { blockNumber: 2, round: 4 }, + }, + } as any, + {} as any, + ); + + assert.equal(response.proposal, Buffer.alloc(0)); + }); +}); diff --git a/packages/p2p/source/socket-server/controllers/get-proposal.ts b/packages/p2p/source/socket-server/controllers/get-proposal.ts index 53ab17a3b..6b6f3dc5c 100644 --- a/packages/p2p/source/socket-server/controllers/get-proposal.ts +++ b/packages/p2p/source/socket-server/controllers/get-proposal.ts @@ -17,22 +17,17 @@ export class GetProposalController implements Contracts.P2P.Controller { proposal: Buffer.alloc(0), }; - const { blockNumber, round } = request.payload.headers; - const consensus = this.app.get(Identifiers.Consensus.Service); const roundStateRepo = this.app.get( Identifiers.Consensus.RoundStateRepository, ); - if (blockNumber !== consensus.getBlockNumber()) { - return result; - } - - if (round > consensus.getRound()) { + const { query } = request.payload; + if (query.blockNumber !== consensus.getBlockNumber() || query.round > consensus.getRound()) { return result; } - const roundState = roundStateRepo.getRoundState(blockNumber, round); + const roundState = roundStateRepo.getRoundState(query.blockNumber, query.round); const proposal = roundState.getProposal(); if (!proposal) { diff --git a/packages/p2p/source/socket-server/schemas/get-proposal.test.ts b/packages/p2p/source/socket-server/schemas/get-proposal.test.ts new file mode 100644 index 000000000..214ef8d92 --- /dev/null +++ b/packages/p2p/source/socket-server/schemas/get-proposal.test.ts @@ -0,0 +1,37 @@ +import { describe } from "@mainsail/test-runner"; +import { getProposal } from "./get-proposal"; + +describe("getProposal schema", ({ it, assert }) => { + const makeSchema = () => getProposal({ getMaxRoundValidators: () => 2 } as any); + + const headers = { + blockNumber: 2, + // eslint-disable-next-line unicorn/no-null + proposedBlockHash: null, + round: 0, + step: 1, + validatorsSignedPrecommit: [false, false], + validatorsSignedPrevote: [false, false], + version: "0.0.1", + }; + const query = { + blockNumber: 2, + round: 0, + }; + + it("should accept a request with a query", () => { + assert.undefined(makeSchema().validate({ headers, query }).error); + }); + + it("should reject a request without a query", () => { + assert.defined(makeSchema().validate({ headers }).error); + }); + + it("should reject an incomplete query", () => { + assert.defined(makeSchema().validate({ headers, query: { blockNumber: 2 } }).error); + }); + + it("should reject a negative round", () => { + assert.defined(makeSchema().validate({ headers, query: { blockNumber: 2, round: -1 } }).error); + }); +}); diff --git a/packages/p2p/source/socket-server/schemas/get-proposal.ts b/packages/p2p/source/socket-server/schemas/get-proposal.ts index 223d2aa96..c042e7ce2 100644 --- a/packages/p2p/source/socket-server/schemas/get-proposal.ts +++ b/packages/p2p/source/socket-server/schemas/get-proposal.ts @@ -7,4 +7,8 @@ import { makeHeaders } from "./shared.js"; export const getProposal = (configuration: Contracts.Crypto.Configuration): Joi.ObjectSchema => Joi.object({ headers: makeHeaders(configuration), + query: Joi.object({ + blockNumber: Joi.number().integer().min(1).required(), + round: Joi.number().integer().min(0).required(), + }).required(), });