From 8b249a188c450fb3b8adc6c02d87b51c6816da07 Mon Sep 17 00:00:00 2001 From: thephez Date: Mon, 16 Sep 2024 15:04:40 -0400 Subject: [PATCH] feat: masternode hard fork signal transaction payload (#301) * feat: groundwork for mnhfsignal special tx * feat: add mnhfsignal * feat: test using an actual tx from mainnet --- index.d.ts | 1 + lib/constants/index.js | 1 + lib/transaction/payload/index.js | 1 + lib/transaction/payload/mnhfsignalpayload.js | 166 +++++++++++++ lib/transaction/payload/payload.js | 3 + test-d/index.test-d.ts | 1 + test/transaction/payload/mnhfsignalpayload.js | 230 ++++++++++++++++++ .../payload/MnHfSignalPayload.d.ts | 73 ++++++ typings/transaction/payload/Payload.d.ts | 2 + 9 files changed, 478 insertions(+) create mode 100644 lib/transaction/payload/mnhfsignalpayload.js create mode 100644 test/transaction/payload/mnhfsignalpayload.js create mode 100644 typings/transaction/payload/MnHfSignalPayload.d.ts diff --git a/index.d.ts b/index.d.ts index 65b32b8f3..3d7c8c7a1 100644 --- a/index.d.ts +++ b/index.d.ts @@ -60,6 +60,7 @@ export { MultiSigScriptHashInput } from './typings/transaction/input/MultiSigScr export { AbstractPayload }; export { CoinbasePayload } from './typings/transaction/payload/CoinbasePayload'; export { CommitmentTxPayload } from './typings/transaction/payload/CommitmentTxPayload'; +export { MnHfSignalPayload } from './typings/transaction/payload/MnHfSignalPayload'; export { ProRegTxPayload } from './typings/transaction/payload/ProRegTxPayload'; export { ProUpRegTxPayload } from './typings/transaction/payload/ProUpRegTxPayload'; export { ProUpRevTxPayload } from './typings/transaction/payload/ProUpRevTxPayload'; diff --git a/lib/constants/index.js b/lib/constants/index.js index bac664c53..c0151a29d 100644 --- a/lib/constants/index.js +++ b/lib/constants/index.js @@ -24,6 +24,7 @@ module.exports = { TRANSACTION_PROVIDER_UPDATE_REVOKE: 4, TRANSACTION_COINBASE: 5, TRANSACTION_QUORUM_COMMITMENT: 6, + TRANSACTION_MASTERNODE_HARD_FORK_SIGNAL: 7, TRANSACTION_ASSET_LOCK: 8, TRANSACTION_ASSET_UNLOCK: 9, }, diff --git a/lib/transaction/payload/index.js b/lib/transaction/payload/index.js index 69f87ff4e..fa64a7e06 100644 --- a/lib/transaction/payload/index.js +++ b/lib/transaction/payload/index.js @@ -10,6 +10,7 @@ Payload.ProTxUpServPayload = require('./proupservtxpayload'); Payload.CoinbasePayload = require('./coinbasepayload'); Payload.constants = require('../../constants'); Payload.CommitmentTxPayload = require('./commitmenttxpayload'); +Payload.MnHfSignalPayload = require('./mnhfsignalpayload'); Payload.AssetLockPayload = require('./assetlockpayload'); Payload.AssetUnlockPayload = require('./assetunlockpayload'); diff --git a/lib/transaction/payload/mnhfsignalpayload.js b/lib/transaction/payload/mnhfsignalpayload.js new file mode 100644 index 000000000..07aa63e9b --- /dev/null +++ b/lib/transaction/payload/mnhfsignalpayload.js @@ -0,0 +1,166 @@ +/* eslint-disable */ +// TODO: Remove previous line and work through linting issues at next edit + +var Preconditions = require('../../util/preconditions'); +var BufferWriter = require('../../encoding/bufferwriter'); +var BufferReader = require('../../encoding/bufferreader'); +var AbstractPayload = require('./abstractpayload'); +var utils = require('../../util/js'); +const _ = require('lodash'); + +var isUnsignedInteger = utils.isUnsignedInteger; + +var CURRENT_PAYLOAD_VERSION = 1; + +/** + * @typedef {Object} MnHfSignalPayloadJSON + * @property {number} version + * @property {Object} signal + * @property {number} signal.versionBit + * @property {string} signal.quorumHash + * @property {string} signal.sig + */ + +/** + * @class MnHfSignalPayload + * @property {number} version + * @property {Object} signal + * @property {number} signal.versionBit + * @property {string} signal.quorumHash + * @property {string} signal.sig + */ +function MnHfSignalPayload() { + AbstractPayload.call(this); + this.version = CURRENT_PAYLOAD_VERSION; + this.signal = { + versionBit: 0, + quorumHash: '', + sig: '' + }; +} + +MnHfSignalPayload.prototype = Object.create(AbstractPayload.prototype); +MnHfSignalPayload.prototype.constructor = AbstractPayload; + +/* Static methods */ + +/** + * Parse raw payload + * @param {Buffer} rawPayload + * @return {MnHfSignalPayload} + */ +MnHfSignalPayload.fromBuffer = function (rawPayload) { + var payloadBufferReader = new BufferReader(rawPayload); + var payload = new MnHfSignalPayload(); + + payload.version = payloadBufferReader.readUInt8(); + payload.signal.versionBit = payloadBufferReader.readUInt8(); + + // Reverse the quorumHash to correct the byte order (from little-endian to big-endian) + payload.signal.quorumHash = payloadBufferReader.read(32).toString('hex'); + + payload.signal.sig = payloadBufferReader.read(96).toString('hex'); + + if (!payloadBufferReader.finished()) { + throw new Error('Failed to parse payload: raw payload is bigger than expected.'); + } + + payload.validate(); + return payload; +}; + +/** + * Create new instance of payload from JSON + * @param {string|MnHfSignalPayloadJSON} payloadJson + * @return {MnHfSignalPayload} + */ +MnHfSignalPayload.fromJSON = function fromJSON(payloadJson) { + var payload = new MnHfSignalPayload(); + payload.version = payloadJson.version || CURRENT_PAYLOAD_VERSION; + payload.signal.versionBit = payloadJson.signal.versionBit; + payload.signal.quorumHash = payloadJson.signal.quorumHash; + payload.signal.sig = payloadJson.signal.sig; + + payload.validate(); + return payload; +}; + +/* Instance methods */ + +/** + * Validates payload data + * @return {boolean} + */ +MnHfSignalPayload.prototype.validate = function () { + Preconditions.checkArgument( + isUnsignedInteger(this.version), + 'Expect version to be an unsigned integer' + ); + + Preconditions.checkArgument( + this.version !== 0 && this.version <= CURRENT_PAYLOAD_VERSION, + 'Invalid version' + ); + + Preconditions.checkArgument( + isUnsignedInteger(this.signal.versionBit) && this.signal.versionBit <= 255, + 'Expect signal.versionBit to be an unsigned 8-bit integer' + ); + + Preconditions.checkArgument( + utils.isSha256HexString(this.signal.quorumHash), + 'Expect signal.quorumHash to be a valid 32-byte SHA256 hex string' + ); + + Preconditions.checkArgument( + utils.isHexaString(this.signal.sig) && Buffer.from(this.signal.sig, 'hex').length === 96, + 'Expect signal.sig to be a valid 96-byte hex string' + ); + + return true; +}; + +/** + * Serializes payload to JSON + * @return {MnHfSignalPayloadJSON} + */ +MnHfSignalPayload.prototype.toJSON = function toJSON() { + this.validate(); + return { + version: this.version, + signal: { + versionBit: this.signal.versionBit, + quorumHash: this.signal.quorumHash, + sig: this.signal.sig + } + }; +}; + +/** + * Serialize payload to buffer + * @return {Buffer} + */ +MnHfSignalPayload.prototype.toBuffer = function toBuffer() { + this.validate(); + var payloadBufferWriter = new BufferWriter(); + + payloadBufferWriter.writeUInt8(this.version); + payloadBufferWriter.writeUInt8(this.signal.versionBit); + + // Reverse the quorumHash to write in little-endian format + payloadBufferWriter.write(Buffer.from(this.signal.quorumHash, 'hex')); + + payloadBufferWriter.write(Buffer.from(this.signal.sig, 'hex')); + + return payloadBufferWriter.toBuffer(); +}; + +/** + * Copy payload instance + * @return {MnHfSignalPayload} + */ +MnHfSignalPayload.prototype.copy = function copy() { + return MnHfSignalPayload.fromJSON(this.toJSON()); +}; + +module.exports = MnHfSignalPayload; diff --git a/lib/transaction/payload/payload.js b/lib/transaction/payload/payload.js index cad426608..15aee1c03 100644 --- a/lib/transaction/payload/payload.js +++ b/lib/transaction/payload/payload.js @@ -10,6 +10,7 @@ var ProRegTxPayload = require('./proregtxpayload'); var ProTxUpServPayload = require('./proupservtxpayload'); var ProUpRegTxPayload = require('./proupregtxpayload'); var ProUpRevTxPayload = require('./prouprevtxpayload'); +var MnHfSignalPayload = require('./mnhfsignalpayload'); var AssetLockPayload = require('./assetlockpayload'); var AssetUnlockPayload = require('./assetunlockpayload'); @@ -25,6 +26,8 @@ PayloadClasses[RegisteredPayloadTypes.TRANSACTION_PROVIDER_UPDATE_REGISTRAR] = ProUpRegTxPayload; PayloadClasses[RegisteredPayloadTypes.TRANSACTION_PROVIDER_UPDATE_REVOKE] = ProUpRevTxPayload; +PayloadClasses[RegisteredPayloadTypes.TRANSACTION_MASTERNODE_HARD_FORK_SIGNAL] = + MnHfSignalPayload; PayloadClasses[RegisteredPayloadTypes.TRANSACTION_ASSET_LOCK] = AssetLockPayload; PayloadClasses[RegisteredPayloadTypes.TRANSACTION_ASSET_UNLOCK] = diff --git a/test-d/index.test-d.ts b/test-d/index.test-d.ts index 20499dece..fee209fba 100644 --- a/test-d/index.test-d.ts +++ b/test-d/index.test-d.ts @@ -41,6 +41,7 @@ import type { AssetUnlockPayload, CoinbasePayload, CommitmentTxPayload, + MnHfSignalPayload, ProRegTxPayload, ProUpRegTxPayload, ProUpRevTxPayload, diff --git a/test/transaction/payload/mnhfsignalpayload.js b/test/transaction/payload/mnhfsignalpayload.js new file mode 100644 index 000000000..5db4db10a --- /dev/null +++ b/test/transaction/payload/mnhfsignalpayload.js @@ -0,0 +1,230 @@ +/* eslint-disable */ +// TODO: Remove previous line and work through linting issues at next edit + +var expect = require('chai').expect; +var sinon = require('sinon'); + +var DashcoreLib = require('../../../index'); + +var MnHfSignalPayload = DashcoreLib.Transaction.Payload.MnHfSignalPayload; + +// Valid test data based on an actual MnHfSignal +var validMnHfSignalPayloadJSON = { + version: 1, + signal: { + versionBit: 10, + quorumHash: '00000000000000107c98e94bdb9ffb729fe9c190a6d1223fd9b6700ccb79b627', + sig: 'a12bc6a3d43e76fd6ab5d48dff11998811747bf51ffe722d9fda93ae892e4b18a716f58045c86459d0dafd38ae1f7f520519002983fc307e92fa606c3eb5ac8cf6ca03a102889866d58c9207b483e0b975baee63c1202209293ff7393222f812' + } +}; + +// Contains same data as JSON above +var validMnHfSignalPayload = MnHfSignalPayload.fromJSON(validMnHfSignalPayloadJSON); +var validMnHfSignalPayloadBuffer = validMnHfSignalPayload.toBuffer(); +var validMnHfSignalPayloadHexString = validMnHfSignalPayloadBuffer.toString('hex'); + +// An example of an actual MnHfSignal in hex format +var mainnetMnHfSignalHex = '010a00000000000000107c98e94bdb9ffb729fe9c190a6d1223fd9b6700ccb79b627a12bc6a3d43e76fd6ab5d48dff11998811747bf51ffe722d9fda93ae892e4b18a716f58045c86459d0dafd38ae1f7f520519002983fc307e92fa606c3eb5ac8cf6ca03a102889866d58c9207b483e0b975baee63c1202209293ff7393222f812'; +var mainnetMnHfSignalBuffer = Buffer.from(mainnetMnHfSignalHex, 'hex'); + +describe('MnHfSignalPayload', function () { + + describe('.fromBuffer', function () { + beforeEach(function () { + sinon.spy(MnHfSignalPayload.prototype, 'validate'); + }); + + afterEach(function () { + MnHfSignalPayload.prototype.validate.restore(); + }); + + it('Should return instance of MnHfSignalPayload and call #validate on it', function () { + var payload = MnHfSignalPayload.fromBuffer(validMnHfSignalPayloadBuffer); + + expect(payload).to.be.an.instanceOf(MnHfSignalPayload); + expect(payload.version).to.be.equal(1); + expect(payload.signal.versionBit).to.be.equal(10); + expect(payload.signal.quorumHash).to.be.equal('00000000000000107c98e94bdb9ffb729fe9c190a6d1223fd9b6700ccb79b627'); + expect(payload.signal.sig).to.be.equal(validMnHfSignalPayloadJSON.signal.sig); + expect(payload.validate.callCount).to.be.equal(1); + }); + + it('Should throw in case if there is some unexpected information in raw payload', function () { + var payloadWithAdditionalZeros = Buffer.from( + validMnHfSignalPayloadHexString + '0000', + 'hex' + ); + + expect(function () { + MnHfSignalPayload.fromBuffer(payloadWithAdditionalZeros); + }).to.throw( + 'Failed to parse payload: raw payload is bigger than expected.' + ); + }); + }); + + describe('.fromJSON', function () { + before(function () { + sinon.spy(MnHfSignalPayload.prototype, 'validate'); + }); + + it('Should return instance of MnHfSignalPayload and call #validate on it', function () { + var payload = MnHfSignalPayload.fromJSON(validMnHfSignalPayloadJSON); + + expect(payload).to.be.an.instanceOf(MnHfSignalPayload); + expect(payload.version).to.be.equal(1); + expect(payload.signal.versionBit).to.be.equal(10); + expect(payload.signal.quorumHash).to.be.equal('00000000000000107c98e94bdb9ffb729fe9c190a6d1223fd9b6700ccb79b627'); + expect(payload.signal.sig).to.be.equal(validMnHfSignalPayloadJSON.signal.sig); + }); + + after(function () { + MnHfSignalPayload.prototype.validate.restore(); + }); + }); + + describe('#validate', function () { + it('Should allow only unsigned integer as version', function () { + var payload = validMnHfSignalPayload.copy(); + + payload.version = -1; + + expect(function () { + payload.validate(); + }).to.throw('Invalid Argument: Expect version to be an unsigned integer'); + + payload.version = 1.5; + + expect(function () { + payload.validate(); + }).to.throw('Invalid Argument: Expect version to be an unsigned integer'); + + payload.version = '12'; + + expect(function () { + payload.validate(); + }).to.throw('Invalid Argument: Expect version to be an unsigned integer'); + + payload.version = Buffer.from('0a0f', 'hex'); + + expect(function () { + payload.validate(); + }).to.throw('Invalid Argument: Expect version to be an unsigned integer'); + + payload.version = 123; + + expect(function () { + payload.validate(); + }).not.to.throw; + }); + + it('Should validate signal fields correctly', function () { + var payload = validMnHfSignalPayload.copy(); + + // Invalid versionBit + payload.signal.versionBit = 256; + + expect(function () { + payload.validate(); + }).to.throw('Invalid Argument: Expect signal.versionBit to be an unsigned 8-bit integer'); + + payload.signal.versionBit = 10; + + // Invalid quorumHash + payload.signal.quorumHash = 'invalidhash'; + + expect(function () { + payload.validate(); + }).to.throw('Invalid Argument: Expect signal.quorumHash to be a valid 32-byte SHA256 hex string'); + + payload.signal.quorumHash = validMnHfSignalPayloadJSON.signal.quorumHash; + + // Invalid sig + payload.signal.sig = 'invalidsig'; + + expect(function () { + payload.validate(); + }).to.throw('Invalid Argument: Expect signal.sig to be a valid 96-byte hex string'); + + payload.signal.sig = validMnHfSignalPayloadJSON.signal.sig; + + expect(function () { + payload.validate(); + }).not.to.throw; + }); + }); + + describe('#toJSON', function () { + beforeEach(function () { + sinon.spy(MnHfSignalPayload.prototype, 'validate'); + }); + + afterEach(function () { + MnHfSignalPayload.prototype.validate.restore(); + }); + + it('Should serialize payload to JSON', function () { + var payload = validMnHfSignalPayload.copy(); + + var payloadJSON = payload.toJSON(); + + expect(payloadJSON.version).to.be.equal(payload.version); + expect(payloadJSON.signal).to.deep.equal(payload.signal); + }); + + it('Should call #validate', function () { + var payload = MnHfSignalPayload.fromJSON(validMnHfSignalPayloadJSON); + MnHfSignalPayload.prototype.validate.resetHistory(); + payload.toJSON(); + expect(payload.validate.callCount).to.be.equal(1); + }); + }); + + describe('#toBuffer', function () { + beforeEach(function () { + sinon.spy(MnHfSignalPayload.prototype, 'validate'); + }); + + afterEach(function () { + MnHfSignalPayload.prototype.validate.restore(); + }); + + it('Should serialize payload to Buffer', function () { + var payload = validMnHfSignalPayload.copy(); + + var serializedPayload = payload.toBuffer(); + var restoredPayload = MnHfSignalPayload.fromBuffer(serializedPayload); + + expect(restoredPayload.version).to.be.equal(payload.version); + expect(restoredPayload.signal).to.deep.equal(payload.signal); + }); + + it('Should call #validate', function () { + var payload = MnHfSignalPayload.fromJSON(validMnHfSignalPayloadJSON); + MnHfSignalPayload.prototype.validate.resetHistory(); + payload.toBuffer(); + expect(payload.validate.callCount).to.be.equal(1); + }); + }); + + describe('Actual MnHfSignalHex parsing', function () { + it('Should parse an actual MnHfSignal (hex) into MnHfSignalPayload', function () { + var payload = MnHfSignalPayload.fromBuffer(mainnetMnHfSignalBuffer); + + expect(payload).to.be.an.instanceOf(MnHfSignalPayload); + expect(payload.version).to.be.equal(1); + expect(payload.signal.versionBit).to.be.equal(10); + expect(payload.signal.quorumHash).to.be.equal('00000000000000107c98e94bdb9ffb729fe9c190a6d1223fd9b6700ccb79b627'); + expect(payload.signal.sig).to.be.equal(validMnHfSignalPayloadJSON.signal.sig); + }); + + it('Should serialize MnHfSignalPayload back to hex and match the original MnHfSignal hex', function () { + var payload = MnHfSignalPayload.fromBuffer(mainnetMnHfSignalBuffer); + + var serializedBuffer = payload.toBuffer(); + var serializedHex = serializedBuffer.toString('hex'); + + expect(serializedHex).to.be.equal(mainnetMnHfSignalHex); + }); + }); +}); diff --git a/typings/transaction/payload/MnHfSignalPayload.d.ts b/typings/transaction/payload/MnHfSignalPayload.d.ts new file mode 100644 index 000000000..e35f249e6 --- /dev/null +++ b/typings/transaction/payload/MnHfSignalPayload.d.ts @@ -0,0 +1,73 @@ +/** + * @typedef {Object} MnHfSignalSignalJSON + * @property {number} versionBit - The version bit associated with the hard fork. + * @property {string} quorumHash - Hash of the quorum signing this message. + * @property {string} sig - BLS signature on the version bit by the public key associated with the quorum. + */ +export type MnHfSignalSignalJSON = { + versionBit: number; + quorumHash: string; + sig: string; +}; + +/** + * @typedef {Object} MnHfSignalPayloadJSON + * @property {number} version - The version number of the transaction. + * @property {MnHfSignalSignalJSON} signal - Signal data containing version bit, quorum hash, and signature. + */ +export type MnHfSignalPayloadJSON = { + version: number; + signal: MnHfSignalSignalJSON; +}; + +/** + * @class MnHfSignalPayload + * @property {number} version - The version number of the transaction. + * @property {MnHfSignalSignal} signal - Signal data containing version bit, quorum hash, and signature. + */ +export class MnHfSignalPayload { + /** + * Parse raw payload buffer. + * @param {Buffer} rawPayload - The raw payload buffer. + * @return {MnHfSignalPayload} - Parsed MnHfSignalPayload instance. + */ + static fromBuffer(rawPayload: Buffer): MnHfSignalPayload; + + /** + * Create a new instance of the payload from JSON. + * @param {string | MnHfSignalPayloadJSON} payloadJson - The JSON object or string representing the payload. + * @return {MnHfSignalPayload} - Parsed MnHfSignalPayload instance. + */ + static fromJSON(payloadJson: string | MnHfSignalPayloadJSON): MnHfSignalPayload; + + /** + * Validates the payload data. + * @return {boolean} - Whether the payload is valid. + */ + validate(): boolean; + + /** + * Serializes the payload to JSON. + * @return {MnHfSignalPayloadJSON} - Serialized JSON representation of the payload. + */ + toJSON(): MnHfSignalPayloadJSON; + + /** + * Serializes the payload to a buffer. + * @return {Buffer} - The buffer representation of the payload. + */ + toBuffer(): Buffer; + + /** + * Create a copy of the payload instance. + * @return {MnHfSignalPayload} - A new copy of the payload. + */ + copy(): MnHfSignalPayload; + + version: number; + signal: { + versionBit: number; + quorumHash: string; + sig: string; + }; +} diff --git a/typings/transaction/payload/Payload.d.ts b/typings/transaction/payload/Payload.d.ts index 7fcef71ff..7d201db54 100644 --- a/typings/transaction/payload/Payload.d.ts +++ b/typings/transaction/payload/Payload.d.ts @@ -4,6 +4,7 @@ import { ProUpRevTxPayload } from './ProUpRevTxPayload'; import { ProUpServTxPayload } from './ProUpServTxPayload'; import { CoinbasePayload } from './CoinbasePayload'; import { CommitmentTxPayload } from './CommitmentTxPayload'; +import { MnHfSignalPayload } from './MnHfSignalPayload'; import { AssetLockPayload } from './AssetLockPayload'; import { AssetUnlockPayload } from './AssetUnlockPayload'; @@ -14,6 +15,7 @@ export namespace Payload { export { ProUpServTxPayload }; export { CoinbasePayload }; export { CommitmentTxPayload }; + export { MnHfSignalPayload }; export { AssetLockPayload }; export { AssetUnlockPayload }; }