diff --git a/packages/transaction-builder/src/abstract-tx-builder.ts b/packages/transaction-builder/src/abstract-tx-builder.ts index d075f70..31c3c24 100644 --- a/packages/transaction-builder/src/abstract-tx-builder.ts +++ b/packages/transaction-builder/src/abstract-tx-builder.ts @@ -114,6 +114,7 @@ export abstract class AbstractBuilder { * @param {string[]} chains - Network identifier list to be serviced by this node * @param {string} amount - the amount to stake, must be greater than or equal to 1 POKT * @param {URL} serviceURL - Node service url + * @param { [key: string]: number } rewardDelegators - Reward delegators * @returns {MsgProtoNodeStakeTx} - The unsigned Node Stake message. */ abstract nodeStake({ @@ -122,12 +123,14 @@ export abstract class AbstractBuilder { chains, amount, serviceURL, + rewardDelegators, }: { nodePubKey: string outputAddress: string chains: string[] amount: string serviceURL: URL + rewardDelegators: { [key: string]: number } | undefined }): MsgProtoNodeStakeTx /** diff --git a/packages/transaction-builder/src/factory/proto-tx-encoder.ts b/packages/transaction-builder/src/factory/proto-tx-encoder.ts index 31f0504..458d8a1 100755 --- a/packages/transaction-builder/src/factory/proto-tx-encoder.ts +++ b/packages/transaction-builder/src/factory/proto-tx-encoder.ts @@ -7,6 +7,7 @@ import { ProtoStdSignature, ProtoStdTx, } from '../models/proto/generated/tx-signer' +import { stringifyObjectWithSort } from '@pokt-foundation/pocketjs-utils' export class ProtoTxEncoder extends BaseTxEncoder { public getFeeObj() { @@ -31,7 +32,8 @@ export class ProtoTxEncoder extends BaseTxEncoder { msg: this.msg.toStdSignDocMsgObj(), } - return Buffer.from(JSON.stringify(stdSignDoc), 'utf-8') + // Use stringifyObject instead JSON.stringify to get a deterministic result. + return Buffer.from(stringifyObjectWithSort(stdSignDoc), 'utf-8') } // Returns the encoded transaction diff --git a/packages/transaction-builder/src/models/msgs/msg-proto-node-stake.ts b/packages/transaction-builder/src/models/msgs/msg-proto-node-stake.ts index 503ac00..ad36599 100755 --- a/packages/transaction-builder/src/models/msgs/msg-proto-node-stake.ts +++ b/packages/transaction-builder/src/models/msgs/msg-proto-node-stake.ts @@ -18,6 +18,7 @@ export class MsgProtoNodeStakeTx extends TxMsg { public readonly chains: string[] public readonly amount: string public readonly serviceURL: URL + public readonly rewardDelegators: { [key: string]: number } | undefined /** * @param {string} pubKey - Public key @@ -30,7 +31,8 @@ export class MsgProtoNodeStakeTx extends TxMsg { outputAddress: string, chains: string[], amount: string, - serviceURL: URL + serviceURL: URL, + rewardDelegators: { [key: string]: number } | undefined, ) { super() this.pubKey = Buffer.from(pubKey, 'hex') @@ -38,6 +40,7 @@ export class MsgProtoNodeStakeTx extends TxMsg { this.chains = chains this.amount = amount this.serviceURL = serviceURL + this.rewardDelegators = rewardDelegators if (!this.serviceURL.port) { this.serviceURL.port = '443' @@ -77,18 +80,22 @@ export class MsgProtoNodeStakeTx extends TxMsg { * @memberof MsgNodeStake */ public toStdSignDocMsgObj(): object { + const msg = { + chains: this.chains, + output_address: this.outputAddress.toString('hex'), + public_key: { + type: 'crypto/ed25519_public_key', + value: this.pubKey.toString('hex'), + }, + service_url: this.getParsedServiceURL(), + value: this.amount, + }; + if (this.rewardDelegators) { + msg['reward_delegators'] = this.rewardDelegators; + } return { type: this.AMINO_KEY, - value: { - chains: this.chains, - output_address: this.outputAddress.toString('hex'), - public_key: { - type: 'crypto/ed25519_public_key', - value: this.pubKey.toString('hex'), - }, - service_url: this.getParsedServiceURL(), - value: this.amount, - }, + value: msg, } } @@ -104,6 +111,7 @@ export class MsgProtoNodeStakeTx extends TxMsg { value: this.amount, ServiceUrl: this.getParsedServiceURL(), OutAddress: this.outputAddress, + RewardDelegators: this.rewardDelegators || {}, } return Any.fromJSON({ diff --git a/packages/transaction-builder/src/models/proto/generated/tx-signer.ts b/packages/transaction-builder/src/models/proto/generated/tx-signer.ts index 9d1f3ad..f045e47 100644 --- a/packages/transaction-builder/src/models/proto/generated/tx-signer.ts +++ b/packages/transaction-builder/src/models/proto/generated/tx-signer.ts @@ -62,6 +62,12 @@ export interface MsgProtoNodeStake8 { value: string; ServiceUrl: string; OutAddress: Uint8Array; + RewardDelegators: { [key: string]: number }; +} + +export interface MsgProtoNodeStake8_RewardDelegatorsEntry { + key: string; + value: number; } export interface MsgBeginNodeUnstake8 { @@ -775,7 +781,14 @@ export const MsgUnjail = { }; function createBaseMsgProtoNodeStake8(): MsgProtoNodeStake8 { - return { Publickey: new Uint8Array(0), Chains: [], value: "", ServiceUrl: "", OutAddress: new Uint8Array(0) }; + return { + Publickey: new Uint8Array(0), + Chains: [], + value: "", + ServiceUrl: "", + OutAddress: new Uint8Array(0), + RewardDelegators: {}, + }; } export const MsgProtoNodeStake8 = { @@ -795,6 +808,9 @@ export const MsgProtoNodeStake8 = { if (message.OutAddress.length !== 0) { writer.uint32(42).bytes(message.OutAddress); } + Object.entries(message.RewardDelegators).forEach(([key, value]) => { + MsgProtoNodeStake8_RewardDelegatorsEntry.encode({ key: key as any, value }, writer.uint32(50).fork()).ldelim(); + }); return writer; }, @@ -840,6 +856,16 @@ export const MsgProtoNodeStake8 = { message.OutAddress = reader.bytes(); continue; + case 6: + if (tag !== 50) { + break; + } + + const entry6 = MsgProtoNodeStake8_RewardDelegatorsEntry.decode(reader, reader.uint32()); + if (entry6.value !== undefined) { + message.RewardDelegators[entry6.key] = entry6.value; + } + continue; } if ((tag & 7) === 4 || tag === 0) { break; @@ -856,6 +882,12 @@ export const MsgProtoNodeStake8 = { value: isSet(object.value) ? globalThis.String(object.value) : "", ServiceUrl: isSet(object.ServiceUrl) ? globalThis.String(object.ServiceUrl) : "", OutAddress: isSet(object.OutAddress) ? bytesFromBase64(object.OutAddress) : new Uint8Array(0), + RewardDelegators: isObject(object.RewardDelegators) + ? Object.entries(object.RewardDelegators).reduce<{ [key: string]: number }>((acc, [key, value]) => { + acc[key] = Number(value); + return acc; + }, {}) + : {}, }; }, @@ -876,6 +908,15 @@ export const MsgProtoNodeStake8 = { if (message.OutAddress.length !== 0) { obj.OutAddress = base64FromBytes(message.OutAddress); } + if (message.RewardDelegators) { + const entries = Object.entries(message.RewardDelegators); + if (entries.length > 0) { + obj.RewardDelegators = {}; + entries.forEach(([k, v]) => { + obj.RewardDelegators[k] = Math.round(v); + }); + } + } return obj; }, @@ -889,6 +930,93 @@ export const MsgProtoNodeStake8 = { message.value = object.value ?? ""; message.ServiceUrl = object.ServiceUrl ?? ""; message.OutAddress = object.OutAddress ?? new Uint8Array(0); + message.RewardDelegators = Object.entries(object.RewardDelegators ?? {}).reduce<{ [key: string]: number }>( + (acc, [key, value]) => { + if (value !== undefined) { + acc[key] = globalThis.Number(value); + } + return acc; + }, + {}, + ); + return message; + }, +}; + +function createBaseMsgProtoNodeStake8_RewardDelegatorsEntry(): MsgProtoNodeStake8_RewardDelegatorsEntry { + return { key: "", value: 0 }; +} + +export const MsgProtoNodeStake8_RewardDelegatorsEntry = { + encode(message: MsgProtoNodeStake8_RewardDelegatorsEntry, writer: _m0.Writer = _m0.Writer.create()): _m0.Writer { + if (message.key !== "") { + writer.uint32(10).string(message.key); + } + if (message.value !== 0) { + writer.uint32(16).uint32(message.value); + } + return writer; + }, + + decode(input: _m0.Reader | Uint8Array, length?: number): MsgProtoNodeStake8_RewardDelegatorsEntry { + const reader = input instanceof _m0.Reader ? input : _m0.Reader.create(input); + let end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseMsgProtoNodeStake8_RewardDelegatorsEntry(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: + if (tag !== 10) { + break; + } + + message.key = reader.string(); + continue; + case 2: + if (tag !== 16) { + break; + } + + message.value = reader.uint32(); + continue; + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skipType(tag & 7); + } + return message; + }, + + fromJSON(object: any): MsgProtoNodeStake8_RewardDelegatorsEntry { + return { + key: isSet(object.key) ? globalThis.String(object.key) : "", + value: isSet(object.value) ? globalThis.Number(object.value) : 0, + }; + }, + + toJSON(message: MsgProtoNodeStake8_RewardDelegatorsEntry): unknown { + const obj: any = {}; + if (message.key !== "") { + obj.key = message.key; + } + if (message.value !== 0) { + obj.value = Math.round(message.value); + } + return obj; + }, + + create, I>>( + base?: I, + ): MsgProtoNodeStake8_RewardDelegatorsEntry { + return MsgProtoNodeStake8_RewardDelegatorsEntry.fromPartial(base ?? ({} as any)); + }, + fromPartial, I>>( + object: I, + ): MsgProtoNodeStake8_RewardDelegatorsEntry { + const message = createBaseMsgProtoNodeStake8_RewardDelegatorsEntry(); + message.key = object.key ?? ""; + message.value = object.value ?? 0; return message; }, }; @@ -1609,6 +1737,10 @@ if (_m0.util.Long !== Long) { _m0.configure(); } +function isObject(value: any): boolean { + return typeof value === "object" && value !== null; +} + function isSet(value: any): boolean { return value !== null && value !== undefined; } diff --git a/packages/transaction-builder/src/models/proto/tx-signer.proto b/packages/transaction-builder/src/models/proto/tx-signer.proto index 4a8c034..0bf388e 100644 --- a/packages/transaction-builder/src/models/proto/tx-signer.proto +++ b/packages/transaction-builder/src/models/proto/tx-signer.proto @@ -58,6 +58,7 @@ message MsgProtoNodeStake8 { string value = 3; string ServiceUrl = 4; bytes OutAddress = 5; + map RewardDelegators = 6; } message MsgBeginNodeUnstake8 { diff --git a/packages/transaction-builder/src/tx-builder.ts b/packages/transaction-builder/src/tx-builder.ts index 05f2768..a62a11e 100644 --- a/packages/transaction-builder/src/tx-builder.ts +++ b/packages/transaction-builder/src/tx-builder.ts @@ -237,6 +237,7 @@ export class TransactionBuilder implements AbstractBuilder { * @param {string[]} chains - Network identifier list to be serviced by this node * @param {string} amount - the amount to stake, must be greater than or equal to 1 POKT * @param {URL} serviceURL - Node service url + * @param { [key: string]: number } rewardDelegators - Reward delegators * @returns {MsgProtoNodeStakeTx} - The unsigned Node Stake message. */ public nodeStake({ @@ -245,19 +246,22 @@ export class TransactionBuilder implements AbstractBuilder { chains, amount, serviceURL, + rewardDelegators, }: { nodePubKey?: string outputAddress?: string chains: string[] amount: string serviceURL: URL + rewardDelegators?: { [key: string]: number } }): MsgProtoNodeStakeTx { return new MsgProtoNodeStakeTx( nodePubKey, outputAddress, chains, amount, - serviceURL + serviceURL, + rewardDelegators, ) } diff --git a/packages/transaction-builder/tests/transactions.test.ts b/packages/transaction-builder/tests/transactions.test.ts index b417f96..2c44756 100644 --- a/packages/transaction-builder/tests/transactions.test.ts +++ b/packages/transaction-builder/tests/transactions.test.ts @@ -98,6 +98,7 @@ describe('TransactionBuilder Tests', () => { chains: ['0040'], amount: '69420000000', serviceURL: new URL('https://mofongonodes.co:8081'), + rewardDelegators: {'6efd6a4118fc75035959c270d7a81117ec5e45c0': 1}, }) expect(nodeStakeMsg instanceof MsgProtoNodeStakeTx).toBe(true) diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index eb5c2e1..9910cd8 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -1,3 +1,4 @@ export * from './addr-from-pubkey' export * from './public-key-from-private' export * from './hrtime' +export * from './sort' diff --git a/packages/utils/src/sort.ts b/packages/utils/src/sort.ts new file mode 100644 index 0000000..224d8ec --- /dev/null +++ b/packages/utils/src/sort.ts @@ -0,0 +1,36 @@ + +/** +* Internal function to sort an object with keys resursively +* @param {any} obj - The hex string to convert. +* @returns {Uint8Array} - A byte array with the converted hex string. +* +* */ +function sortKeysRecursively(obj: any): any { + if (typeof obj !== 'object' || obj === null) { + return obj; + } + + if (Array.isArray(obj)) { + return obj.map(sortKeysRecursively); + } + + const sortedKeys = Object.keys(obj).sort(); + const sortedObj: Record = {}; + + sortedKeys.forEach(key => { + sortedObj[key] = sortKeysRecursively(obj[key]); + }); + + return sortedObj; +} + +/** + * Stringify an object with its keys sorted alphabetically + * @param {Buffer} obj - Object to stringify + * @returns {string} - Stringified result + */ +export function stringifyObjectWithSort(obj: Record): string { + const sortedObj = sortKeysRecursively(obj); + const jsonString = JSON.stringify(sortedObj); + return jsonString; +} diff --git a/packages/utils/tests/utils.test.ts b/packages/utils/tests/utils.test.ts index 85f136d..63fdce5 100644 --- a/packages/utils/tests/utils.test.ts +++ b/packages/utils/tests/utils.test.ts @@ -1,5 +1,6 @@ import { getAddressFromPublicKey } from '../src/addr-from-pubkey' import { publicKeyFromPrivate } from '../src/public-key-from-private' +import { stringifyObjectWithSort } from '../src/sort' const PRIVATE_KEY = '1f8cbde30ef5a9db0a5a9d5eb40536fc9defc318b8581d543808b7504e0902bcb243b27bc9fbe5580457a46370ae5f03a6f6753633e51efdaf2cf534fdc26cc3' @@ -20,3 +21,27 @@ describe('Utils: Public key from private key tests', () => { expect(resultingPublicKey).toBe(PUBLIC_KEY) }) }) + +describe('Utils: Test stringifyObjectWithSort', () => { + it('Nested valid object', () => { + const sorted = stringifyObjectWithSort({ + abc: [3, 2, 1], + ab: { + y: { + 1: 1, + 3: 3, + 2: 2, + }, + z: '3', + x: '1', + }, + a: 1, + }); + expect(sorted).toBe( + '{"a":1,' + + '"ab":{"x":"1",' + + '"y":{"1":1,"2":2,"3":3},' + + '"z":"3"},' + + '"abc":[3,2,1]}') + }) +})