From 961d82911839aa206d99bf30568a9d4106db9da0 Mon Sep 17 00:00:00 2001 From: Oleksandr Myshchyshyn Date: Wed, 27 Nov 2024 22:54:40 +0200 Subject: [PATCH] Fixed serialization and deserialization for transaction hash with TransactionV1Payload, removed TransactionCategory from Transaction --- README.md | 6 +- src/types/Args.ts | 41 ++++++- src/types/ByteConverters.ts | 86 +++++++++++++ src/types/Deploy.ts | 5 +- src/types/ExecutableDeployItem.ts | 24 ++-- src/types/SerializationUtils.ts | 56 +++++---- src/types/Transaction.ts | 10 -- src/types/TransactionEntryPoint.ts | 2 +- src/types/TransactionScheduling.ts | 2 +- src/types/TransactionV1Payload.ts | 187 ++++++++++++++++------------- 10 files changed, 281 insertions(+), 138 deletions(-) diff --git a/README.md b/README.md index 862165185..e87834763 100644 --- a/README.md +++ b/README.md @@ -144,8 +144,8 @@ const paymentAmount = '20000000000000'; const pricingMode = new PricingMode(); const fixedMode = new FixedMode(); -fixedMode.gasPriceTolerance = 3; -fixedMode.additionalComputationFactor = 1; +fixedMode.gasPriceTolerance = 1; +fixedMode.additionalComputationFactor = 0; pricingMode.fixed = fixedMode; const args = Args.fromMap({ @@ -155,7 +155,7 @@ const args = Args.fromMap({ ) ), amount: CLValueUInt512.newCLUInt512(paymentAmount), - id: CLValueOption.newCLOption(CLValueUInt64.newCLUint64(3)) + id: CLValueOption.newCLOption(CLValueUInt64.newCLUint64(3)) // memo ( optional ) }); const transactionTarget = new TransactionTarget({}); // Native target; diff --git a/src/types/Args.ts b/src/types/Args.ts index a2e58304c..277a4ec5e 100644 --- a/src/types/Args.ts +++ b/src/types/Args.ts @@ -1,8 +1,8 @@ import { concat } from '@ethersproject/bytes'; +import { jsonMapMember, jsonObject } from 'typedjson'; import { CLValue, CLValueParser } from './clvalue'; -import { jsonMapMember, jsonObject } from 'typedjson'; -import { toBytesString, toBytesU32 } from './ByteConverters'; +import { toBytesString, toBytesU32, writeInteger } from './ByteConverters'; /** * Represents a named argument with a name and associated `CLValue`, which can be serialized to bytes. @@ -25,6 +25,43 @@ export class NamedArg { return concat([name, value]); } + /** + * Converts a `NamedArg` object to a `Uint8Array` for serialization. + * + * The method encodes the name of the argument as a UTF-8 string, followed by the serialized + * bytes of its value. The resulting `Uint8Array` can be used for further processing, such as + * storage or transmission. + * + * @param source - The `NamedArg` object to serialize. It contains a name and a value. + * @returns A `Uint8Array` representing the serialized `NamedArg`. + * + * @example + * ```typescript + * const namedArg = new NamedArg("arg1", CLValue.u32(42)); + * const serializedBytes = YourClass.toBytesWithNamedArg(namedArg); + * console.log(serializedBytes); // Logs the serialized bytes. + * ``` + */ + public static toBytesWithNamedArg(source: NamedArg): Uint8Array { + // The buffer size is fixed at 1024 bytes based on the expected maximum size of + // encoded data, with room for edge cases. If inputs exceed this size, revisit + // the implementation. + const buffer = new ArrayBuffer(1024); + const view = new DataView(buffer); + let offset = 0; + + const nameBytes = new TextEncoder().encode(source.name); + offset = writeInteger(view, offset, nameBytes.length); + new Uint8Array(buffer, offset).set(nameBytes); + offset += nameBytes.length; + + const valueBytes = CLValueParser.toBytesWithType(source.value); + new Uint8Array(buffer, offset).set(valueBytes); + offset += valueBytes.length; + + return new Uint8Array(buffer, 0, offset); + } + /** * Creates a `NamedArg` instance from a byte array. * @param bytes - The byte array to parse. diff --git a/src/types/ByteConverters.ts b/src/types/ByteConverters.ts index 786c194a2..33babb034 100644 --- a/src/types/ByteConverters.ts +++ b/src/types/ByteConverters.ts @@ -173,3 +173,89 @@ export const fromBytesU64 = (bytes: Uint8Array): BigNumber => { // Convert the little-endian bytes into a BigNumber return BigNumber.from(bytes.reverse()); }; + +/** + * Writes a 32-bit signed integer to a `DataView` at the specified offset. + * + * The integer is written in little-endian format. + * + * @param view - The `DataView` instance where the integer will be written. + * @param offset - The offset (in bytes) at which to start writing. + * @param value - The 32-bit signed integer to write. + * @returns The new offset after writing the integer. + * + * @example + * ```typescript + * const buffer = new ArrayBuffer(8); + * const view = new DataView(buffer); + * let offset = 0; + * offset = writeInteger(view, offset, 42); + * console.log(new Int32Array(buffer)); // Logs: Int32Array [42, 0] + * ``` + */ +export const writeInteger = ( + view: DataView, + offset: number, + value: number +): number => { + view.setInt32(offset, value, true); + return offset + 4; +}; + +/** + * Writes a 16-bit unsigned integer to a `DataView` at the specified offset. + * + * The integer is written in little-endian format. + * + * @param view - The `DataView` instance where the integer will be written. + * @param offset - The offset (in bytes) at which to start writing. + * @param value - The 16-bit unsigned integer to write. + * @returns The new offset after writing the integer. + * + * @example + * ```typescript + * const buffer = new ArrayBuffer(4); + * const view = new DataView(buffer); + * let offset = 0; + * offset = writeUShort(view, offset, 65535); + * console.log(new Uint16Array(buffer)); // Logs: Uint16Array [65535, 0] + * ``` + */ +export const writeUShort = ( + view: DataView, + offset: number, + value: number +): number => { + view.setUint16(offset, value, true); + return offset + 2; +}; + +/** + * Writes a sequence of bytes (as a `Uint8Array`) to a `DataView` at the specified offset. + * + * Each byte in the array is written in sequence, starting from the given offset. + * + * @param view - The `DataView` instance where the bytes will be written. + * @param offset - The offset (in bytes) at which to start writing. + * @param value - The `Uint8Array` containing the bytes to write. + * @returns The new offset after writing the bytes. + * + * @example + * ```typescript + * const buffer = new ArrayBuffer(10); + * const view = new DataView(buffer); + * let offset = 0; + * offset = writeBytes(view, offset, new Uint8Array([1, 2, 3, 4])); + * console.log(new Uint8Array(buffer)); // Logs: Uint8Array [1, 2, 3, 4, 0, 0, 0, 0, 0, 0] + * ``` + */ +export const writeBytes = ( + view: DataView, + offset: number, + value: Uint8Array +): number => { + value.forEach((byte, index) => { + view.setUint8(offset + index, byte); + }); + return offset + value.length; +}; diff --git a/src/types/Deploy.ts b/src/types/Deploy.ts index 4b29802a4..b4c3c39e6 100644 --- a/src/types/Deploy.ts +++ b/src/types/Deploy.ts @@ -5,7 +5,7 @@ import { Hash } from './key'; import { HexBytes } from './HexBytes'; import { PublicKey, PrivateKey } from './keypair'; import { Duration, Timestamp } from './Time'; -import { Approval, Transaction, TransactionCategory } from './Transaction'; +import { Approval, Transaction } from './Transaction'; import { TransactionEntryPoint, TransactionEntryPointEnum @@ -357,10 +357,8 @@ export class Deploy { static newTransactionFromDeploy(deploy: Deploy): Transaction { let paymentAmount = 0; let transactionEntryPoint: TransactionEntryPoint; - let transactionCategory = TransactionCategory.Large; if (deploy.session.transfer) { - transactionCategory = TransactionCategory.Mint; transactionEntryPoint = new TransactionEntryPoint( TransactionEntryPointEnum.Transfer ); @@ -414,7 +412,6 @@ export class Deploy { transactionEntryPoint, new TransactionScheduling({ standard: {} }), deploy.approvals, - transactionCategory, undefined, deploy ); diff --git a/src/types/ExecutableDeployItem.ts b/src/types/ExecutableDeployItem.ts index 7d7c65dba..4d34d3252 100644 --- a/src/types/ExecutableDeployItem.ts +++ b/src/types/ExecutableDeployItem.ts @@ -7,6 +7,7 @@ import { CLTypeOption, CLTypeUInt64, CLValue, + CLValueByteArray, CLValueOption, CLValueString, CLValueUInt32, @@ -21,7 +22,6 @@ import { serializeArgs } from './SerializationUtils'; import { PublicKey } from './keypair'; -import { toBytesArrayU8 } from './ByteConverters'; /** * Enum representing the different types of executable deploy items. @@ -49,7 +49,7 @@ export class ModuleBytes { serializer: byteArrayJsonSerializer, deserializer: byteArrayJsonDeserializer }) - moduleBytes: Uint8Array; + moduleBytes!: Uint8Array; /** * The arguments passed to the module. @@ -75,13 +75,21 @@ export class ModuleBytes { * @returns The serialized byte array. */ bytes(): Uint8Array { - if (!this.args) throw new Error('Missing arguments for ModuleBytes'); + const lengthBytes = CLValueUInt32.newCLUInt32( + BigNumber.from(this.moduleBytes.length) + ).bytes(); + const bytesArrayBytes = CLValueByteArray.newCLByteArray( + this.moduleBytes + ).bytes(); + + let result = concat([lengthBytes, bytesArrayBytes]); + + if (this.args) { + const argBytes = this.args.toBytes(); + result = concat([result, argBytes]); + } - return concat([ - Uint8Array.from([0]), - toBytesArrayU8(this.moduleBytes), - this.args.toBytes() - ]); + return result; } } diff --git a/src/types/SerializationUtils.ts b/src/types/SerializationUtils.ts index 716e44e5b..d3d3e5593 100644 --- a/src/types/SerializationUtils.ts +++ b/src/types/SerializationUtils.ts @@ -126,39 +126,49 @@ export const dehumanizerTTL = (ttl: string): number => { /** * Deserializes an array of runtime arguments to a `RuntimeArgs` object. * - * @param arr The array of serialized runtime arguments. + * @param arr The array of serialized runtime arguments or a Named wrapper. * @returns A `RuntimeArgs` object containing the deserialized arguments. + * @throws Error if the input format is invalid. */ -export const deserializeArgs = (arr: any) => { +export const deserializeArgs = (arr: any): Args | undefined => { const raSerializer = new TypedJSON(Args); - const value = { - args: arr - }; - return raSerializer.parse(value); + + if (arr.Named && Array.isArray(arr.Named)) { + // If the arguments are wrapped in a "Named" property + return raSerializer.parse({ args: arr.Named }); + } + + if (Array.isArray(arr)) { + // If the input is directly an array of arguments + return raSerializer.parse({ args: arr }); + } + + throw new Error('Invalid argument format for deserialization.'); }; /** - * Serializes a `RuntimeArgs` object to a byte array. + * Serializes a `RuntimeArgs` object to a byte array or an object representation. + * + * This function converts the `RuntimeArgs` (or `Args`) object into a serialized format. + * If `asNamed` is set to `true`, the serialized arguments are wrapped in a `Named` property + * for more structured output. Otherwise, the plain array of serialized arguments is returned. + * + * @param ra - The `Args` object to be serialized. It contains the runtime arguments. + * @param asNamed - A boolean flag indicating whether to wrap the serialized output in a `Named` property. Defaults to `false`. + * @returns A serialized representation of the runtime arguments. + * If `asNamed` is `true`, the output is an object with a `Named` property. Otherwise, it is a plain array. * - * @param ra The `RuntimeArgs` object to be serialized. - * @returns A byte array representing the serialized runtime arguments. */ -export const serializeArgs = (ra: Args) => { +export const serializeArgs = (ra: Args, asNamed = false) => { const raSerializer = new TypedJSON(Args); const json = raSerializer.toPlainJson(ra); - return Object.values(json as any)[0]; -}; + const argsArray = Object.values(json as any)[0]; -/** - * Compares two arrays for equality. - * @param a The first array. - * @param b The second array. - * @returns `true` if the arrays are equal, `false` otherwise. - */ -export const arrayEquals = (a: Uint8Array, b: Uint8Array): boolean => { - if (a.length !== b.length) return false; - for (let i = 0; i < a.length; i++) { - if (a[i] !== b[i]) return false; + if (asNamed) { + return { + Named: argsArray + }; } - return true; + + return argsArray; }; diff --git a/src/types/Transaction.ts b/src/types/Transaction.ts index 69157ad02..601d3c4e2 100644 --- a/src/types/Transaction.ts +++ b/src/types/Transaction.ts @@ -373,13 +373,6 @@ export class Transaction { }) public scheduling: TransactionScheduling; - /** - * The category of the transaction, indicating its type (e.g., minting, auction). - * Using TransactionCategory as enum - */ - @jsonMember({ name: 'transaction_category', constructor: Number }) - public category?: number; - /** * The list of approvals for this transaction. */ @@ -427,7 +420,6 @@ export class Transaction { entryPoint: TransactionEntryPoint, scheduling: TransactionScheduling, approvals: Approval[], - category?: TransactionCategory, originTransactionV1?: TransactionV1, originDeployV1?: Deploy ) { @@ -442,7 +434,6 @@ export class Transaction { this.entryPoint = entryPoint; this.scheduling = scheduling; this.approvals = approvals; - this.category = category; this.originDeployV1 = originDeployV1; this.originTransactionV1 = originTransactionV1; @@ -482,7 +473,6 @@ export class Transaction { v1.payload.fields.entryPoint, v1.payload.fields.scheduling, v1.approvals, - undefined, v1, // originTransactionV1 undefined // originDeployV1 is not applicable for this method ); diff --git a/src/types/TransactionEntryPoint.ts b/src/types/TransactionEntryPoint.ts index 9b5317bae..4dd1c16d5 100644 --- a/src/types/TransactionEntryPoint.ts +++ b/src/types/TransactionEntryPoint.ts @@ -116,7 +116,7 @@ export class TransactionEntryPoint { * * @returns A `Uint8Array` representing the transaction entry point and any associated data. */ - bytes(): Uint8Array { + toBytes(): Uint8Array { const calltableSerialization = new CalltableSerialization(); const tag = this.tag(); calltableSerialization.addField(0, Uint8Array.from([tag])); diff --git a/src/types/TransactionScheduling.ts b/src/types/TransactionScheduling.ts index 7514d02da..570efe10e 100644 --- a/src/types/TransactionScheduling.ts +++ b/src/types/TransactionScheduling.ts @@ -146,7 +146,7 @@ export class TransactionScheduling { * * @returns A `Uint8Array` representing the transaction scheduling. */ - bytes(): Uint8Array { + toBytes(): Uint8Array { if (this.standard) { const calltableSerialization = new CalltableSerialization(); calltableSerialization.addField(0, Uint8Array.of(0)); diff --git a/src/types/TransactionV1Payload.ts b/src/types/TransactionV1Payload.ts index 91bd7139d..09bd000cd 100644 --- a/src/types/TransactionV1Payload.ts +++ b/src/types/TransactionV1Payload.ts @@ -1,24 +1,16 @@ -import { concat } from '@ethersproject/bytes'; -import { - toBytesString, - toBytesU16, - toBytesU32, - toBytesU64 -} from './ByteConverters'; -import { jsonMember, jsonObject, TypedJSON } from 'typedjson'; +import { jsonMember, jsonObject } from 'typedjson'; + import { InitiatorAddr } from './InitiatorAddr'; import { Duration, Timestamp } from './Time'; import { PricingMode } from './PricingMode'; -import { Args } from './Args'; +import { Args, NamedArg } from './Args'; import { TransactionTarget } from './TransactionTarget'; import { TransactionEntryPoint } from './TransactionEntryPoint'; import { TransactionScheduling } from './TransactionScheduling'; import { CalltableSerialization } from './CalltableSerialization'; -import { - byteArrayJsonSerializer, - deserializeArgs, - serializeArgs -} from './SerializationUtils'; +import { deserializeArgs, serializeArgs } from './SerializationUtils'; +import { CLValueString, CLValueUInt64 } from './clvalue'; +import { writeBytes, writeInteger, writeUShort } from './ByteConverters'; /** * Interface representing the parameters required to build a `TransactionV1Payload`. @@ -82,7 +74,7 @@ export class PayloadFields { */ @jsonMember(() => Args, { deserializer: deserializeArgs, - serializer: serializeArgs + serializer: (args: Args) => serializeArgs(args, true) }) public args: Args; @@ -124,26 +116,6 @@ export class PayloadFields { */ private fields: Map = new Map(); - /** - * Utility method to map field identifiers to serialized values. - * Ensures that all fields are properly initialized before serialization. - * @returns A map of field identifiers to their serialized values. - * @throws Error if any required field is uninitialized or invalid. - */ - private toSerializedFields(): Map { - if (!this.args) throw new Error('args field is uninitialized.'); - if (!this.target) throw new Error('target field is uninitialized.'); - if (!this.entryPoint) throw new Error('entryPoint field is uninitialized.'); - if (!this.scheduling) throw new Error('scheduling field is uninitialized.'); - - return new Map([ - [0, this.args.toBytes()], - [1, this.target.toBytes()], - [2, this.entryPoint.bytes()], - [3, this.scheduling.bytes()] - ]); - } - /** * Builds a `PayloadFields` instance from provided transaction details. * @@ -180,8 +152,6 @@ export class PayloadFields { payloadFields.entryPoint = transactionEntryPoint; payloadFields.scheduling = transactionScheduling; - payloadFields.fields = payloadFields.toSerializedFields(); - return payloadFields; } @@ -206,49 +176,31 @@ export class PayloadFields { } /** - * Serializes all fields into a `Uint8Array`. + * Serializes the fields of the object into a `Uint8Array` for transmission or storage. * - * @returns Serialized fields as a `Uint8Array`. - */ - public toBytes(): Uint8Array { - const fieldsCount = toBytesU32(this.fields.size); - const serializedFields = Array.from( - this.fields.entries() - ).map(([key, value]) => concat([toBytesU16(key), value])); - - return concat([fieldsCount, ...serializedFields]); - } - - /** - * Deserializes JSON data into a `PayloadFields` instance. + * This method iterates over the `fields` map, serializing each key-value pair. The key is + * written as a 16-bit unsigned integer, and the value is written as a sequence of bytes. + * The resulting byte array contains all serialized fields in order, preceded by the number of fields. * - * @param json - JSON representation of the payload fields. - * @returns A `PayloadFields` instance. - */ - public static fromJSON(json: any): PayloadFields { - const deserialized = new TypedJSON(PayloadFields).parse(json); - - if (!deserialized) { - throw new Error('Failed to deserialize PayloadFields.'); - } - - deserialized.fields = deserialized.toSerializedFields(); - - return deserialized; - } - - /** - * Converts the payload fields to a JSON object. + * @returns A `Uint8Array` containing the serialized representation of the fields. * - * @returns A JSON representation of the payload fields. */ - public toJSON(): Record { - const result: Record = {}; - const fieldEntries = Array.from(this.fields.entries()); - for (const [key, value] of fieldEntries) { - result[key.toString()] = byteArrayJsonSerializer(value); + toBytes(): Uint8Array { + // The buffer size is fixed at 1024 bytes based on the expected maximum size of + // encoded data, with room for edge cases. If inputs exceed this size, revisit + // the implementation. + const fieldsBytes = new ArrayBuffer(1024); + const view = new DataView(fieldsBytes); + let offset = 0; + + offset = writeInteger(view, offset, this.fields.size); + + for (const [field, value] of Array.from(this.fields.entries())) { + offset = writeUShort(view, offset, field); + offset = writeBytes(view, offset, value); } - return result; + + return new Uint8Array(fieldsBytes, 0, offset); } } @@ -307,8 +259,7 @@ export class TransactionV1Payload { */ @jsonMember({ name: 'fields', - serializer: value => (value ? value.toJSON() : undefined), - deserializer: json => (json ? PayloadFields.fromJSON(json) : undefined) + constructor: PayloadFields }) public fields: PayloadFields; @@ -317,17 +268,81 @@ export class TransactionV1Payload { * * @returns A `Uint8Array` representing the serialized transaction payload. */ - public toBytes(): Uint8Array { - const calltable = new CalltableSerialization(); + toBytes(): Uint8Array { + // The buffer size is fixed at 1024 bytes based on the expected maximum size of + // encoded data, with room for edge cases. If inputs exceed this size, revisit + // the implementation. + const runtimeArgsBuffer = new ArrayBuffer(1024); + const runtimeArgsView = new DataView(runtimeArgsBuffer); + let offset = 0; + + runtimeArgsView.setUint8(offset, 0x00); + offset += 1; + + runtimeArgsView.setUint32(offset, this.fields.args.args.size, true); + offset += 4; + + for (const [name, value] of Array.from(this.fields.args.args.entries())) { + const namedArg = new NamedArg(name, value); + const argBytes = NamedArg.toBytesWithNamedArg(namedArg); + new Uint8Array(runtimeArgsBuffer, offset).set(argBytes); + offset += argBytes.length; + } + + const runtimeArgsBytes = new Uint8Array(runtimeArgsBuffer, 0, offset); - calltable.addField(0, this.initiatorAddr.toBytes()); - calltable.addField(1, toBytesU64(Date.parse(this.timestamp.toJSON()))); - calltable.addField(2, toBytesU64(this.ttl.duration)); - calltable.addField(3, toBytesString(this.chainName)); - calltable.addField(4, this.pricingMode.toBytes()); - calltable.addField(5, this.fields.toBytes()); + const fields = new PayloadFields(); - return calltable.toBytes(); + const runtimeArgsWithLength = new Uint8Array(runtimeArgsBytes.length + 4); + new DataView(runtimeArgsWithLength.buffer).setUint32( + 0, + runtimeArgsBytes.length, + true + ); + runtimeArgsWithLength.set(runtimeArgsBytes, 4); + fields.addField(0, runtimeArgsWithLength); + + const targetBytes = this.fields.target.toBytes(); + const targetWithLength = new Uint8Array(targetBytes.length + 4); + new DataView(targetWithLength.buffer).setUint32( + 0, + targetBytes.length, + true + ); + targetWithLength.set(targetBytes, 4); + fields.addField(1, targetWithLength); + + const entryPointBytes = this.fields.entryPoint.toBytes(); + const entryPointWithLength = new Uint8Array(entryPointBytes.length + 4); + new DataView(entryPointWithLength.buffer).setUint32( + 0, + entryPointBytes.length, + true + ); + entryPointWithLength.set(entryPointBytes, 4); + fields.addField(2, entryPointWithLength); + + const schedulingBytes = this.fields.scheduling.toBytes(); + const schedulingWithLength = new Uint8Array(schedulingBytes.length + 4); + new DataView(schedulingWithLength.buffer).setUint32( + 0, + schedulingBytes.length, + true + ); + schedulingWithLength.set(schedulingBytes, 4); + fields.addField(3, schedulingWithLength); + + return new CalltableSerialization() + .addField(0, this.initiatorAddr.toBytes()) + .addField( + 1, + CLValueUInt64.newCLUint64(this.timestamp.toMilliseconds()).bytes() + ) + .addField(2, CLValueUInt64.newCLUint64(this.ttl.duration).bytes()) + .addField(3, CLValueString.newCLString(this.chainName).bytes()) + .addField(4, this.pricingMode.toBytes()) + .addField(5, fields.toBytes()) + .toBytes(); } /**