diff --git a/src/transaction_builder.ts b/src/transaction_builder.ts index ee3d894..22bcb21 100644 --- a/src/transaction_builder.ts +++ b/src/transaction_builder.ts @@ -18,9 +18,10 @@ import { toByteArray, uint8ArrayToHex } from "./utils.js"; +import TE from "./typed_encoding.js" import { deriveAddress, deriveKeyPair, sign } from "./crypto.js"; -const VERSION = 2 +const VERSION = 3 function getTransactionTypeId(type: UserTypeTransaction): number { switch (type) { @@ -359,10 +360,7 @@ export default class TransactionBuilder { // address address) } else { - // we need to order object keys ASC because that's what elixir does - const orderedArgs = args.map((arg) => sortObjectKeysASC(arg)) - const jsonArgs = JSON.stringify(orderedArgs) - const bufJsonLength = toByteArray(jsonArgs.length) + const serializedArgs = args.map((arg) => TE.serialize(arg)) return concatUint8Arrays( // 1 = named action @@ -372,10 +370,10 @@ export default class TransactionBuilder { // action Uint8Array.from([action.length]), new TextEncoder().encode(action), + // args count + Uint8Array.from([serializedArgs.length]), // args - Uint8Array.from([bufJsonLength.length]), - bufJsonLength, - new TextEncoder().encode(jsonArgs), + ...serializedArgs ) } diff --git a/src/typed_encoding.ts b/src/typed_encoding.ts new file mode 100644 index 0000000..d19a9d2 --- /dev/null +++ b/src/typed_encoding.ts @@ -0,0 +1,166 @@ +import { + concatUint8Arrays, + toBigInt, + fromBigInt, + sortObjectKeysASC, + deserializeString, + serializeString, + nextUint8 +} from "./utils.js" + +import VarInt from "./varint.js" + +export default { + serialize, + deserialize +} + +/** + * Serialize any data + * @param data + * @returns the data encoded + */ +function serialize(data: any, version: number = 1): Uint8Array { + // we need to order object keys ASC because that's what elixir does + data = sortObjectKeysASC(data) + + switch (version) { + default: + return do_serialize_v1(data) + } +} +/** + * Deserialize an encoded data + * @param encoded_data + * @returns the data decoded + */ +function deserialize(encoded_data: Uint8Array, version: number = 1): any { + const iter = encoded_data.entries() + + switch (version) { + default: + return do_deserialize_v1(iter) + } +} + + +const TYPE_INT = 0 +const TYPE_FLOAT = 1 +const TYPE_STR = 2 +const TYPE_LIST = 3 +const TYPE_MAP = 4 +const TYPE_BOOL = 5 +const TYPE_NIL = 6 + + +function do_serialize_v1(data: any): Uint8Array { + if (data === null) { + return Uint8Array.from([TYPE_NIL]) + } else if (data === true) { + return Uint8Array.from([TYPE_BOOL, 1]) + } else if (data === false) { + return Uint8Array.from([TYPE_BOOL, 0]) + } else if (Number(data) === data) { + const sign = data >= 0 + + if (Number.isInteger(data)) { + return concatUint8Arrays( + Uint8Array.from([TYPE_INT]), + Uint8Array.from([sign ? 1 : 0]), + VarInt.serialize(Math.abs(data)) + ) + } else { + return concatUint8Arrays( + Uint8Array.from([TYPE_FLOAT]), + Uint8Array.from([sign ? 1 : 0]), + VarInt.serialize(toBigInt(Math.abs(data))) + ) + } + } else if (typeof data === 'string') { + return concatUint8Arrays( + Uint8Array.from([TYPE_STR]), + VarInt.serialize(byte_size(data)), + serializeString(data) + ) + } else if (Array.isArray(data)) { + const serializedItems = data.map((item) => do_serialize_v1(item)) + return concatUint8Arrays( + Uint8Array.from([TYPE_LIST]), + VarInt.serialize(data.length), + ...serializedItems + ) + } else if (typeof data == "object") { + const serializedKeyValues = + Object.keys(data) + .reduce(function (acc: Uint8Array[], key: any) { + acc.push(do_serialize_v1(key)) + acc.push(do_serialize_v1(data[key])) + return acc + }, []); + + return concatUint8Arrays( + Uint8Array.from([TYPE_MAP]), + VarInt.serialize(Object.keys(data).length), + ...serializedKeyValues + ) + } else { + throw new Error("Unhandled data type") + } +} + +function do_deserialize_v1(iter: IterableIterator<[number, number]>): any { + switch (nextUint8(iter)) { + case TYPE_NIL: + return null + + case TYPE_BOOL: + return nextUint8(iter) == 1 + + case TYPE_INT: + return nextUint8(iter) == 1 + ? VarInt.deserialize(iter) + : VarInt.deserialize(iter) * -1 + + case TYPE_FLOAT: + return nextUint8(iter) == 1 + ? fromBigInt(VarInt.deserialize(iter)) + : fromBigInt(VarInt.deserialize(iter) * -1) + + + case TYPE_STR: + const strLen = VarInt.deserialize(iter) + + let bytes = [] + for (let i = 0; i < strLen; i++) { + bytes.push(nextUint8(iter)) + } + + return deserializeString(Uint8Array.from(bytes)) + + case TYPE_LIST: + const listLen = VarInt.deserialize(iter) + + let list = [] + for (let i = 0; i < listLen; i++) { + list.push(do_deserialize_v1(iter)) + } + + return list + + case TYPE_MAP: + const keysLen = VarInt.deserialize(iter) + + // we use a map here because keys can be of any type + let map = new Map() + for (let i = 0; i < keysLen; i++) { + map.set(do_deserialize_v1(iter), do_deserialize_v1(iter)) + } + + return Object.fromEntries(map.entries()) + + } +} + +function byte_size(str: string) { + return (new TextEncoder().encode(str)).length +} \ No newline at end of file diff --git a/src/utils.ts b/src/utils.ts index 8c184e3..5df506c 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -45,6 +45,12 @@ export function sortObjectKeysASC(term: any): any { if (Array.isArray(term)) return term.map((item: any) => sortObjectKeysASC(item)) + if (term instanceof Map) + // we can't sort keys of a map + // because the keys aren't strings + // FIXME: this might cause an issue because elixir order & javascript order may differ + return term + // object: sort and map over elements if (isObject(term)) return Object.keys(term).sort().reduce((newObj: any, key: string) => { @@ -206,12 +212,53 @@ export function base64url(arraybuffer: ArrayBuffer): string { * Convert any number into a byte array */ export function toByteArray(number: number): Uint8Array { - if (!number) return Uint8Array.from([0]); - const a = []; - a.unshift(number & 255); + if (number === 0) return Uint8Array.from([0]); + + const arr = []; while (number >= 256) { - number = number >>> 8; - a.unshift(number & 255); + arr.push(number % 256); + number = Math.floor(number / 256); } - return Uint8Array.from(a); + + arr.push(number % 256) + + return Uint8Array.from(arr.reverse()); +} + +/** + * Alias of uint8ArrayToInt + * + * @param bytes + * @returns the number + */ +export function fromByteArray(bytes: Uint8Array): number { + return uint8ArrayToInt(bytes) +} + +/** + * Return the next Uint8 from an iterator of Uint8Array + * There is an assumption on success + * @param iter + * @returns + */ +export function nextUint8(iter: IterableIterator<[number, number]>): number { + return iter.next().value[1] } + +/** + * String to Uint8Array + * @param str + * @returns + */ +export function serializeString(str: string): Uint8Array { + return new TextEncoder().encode(str) +} + +/** + * Uint8Array to String + * @param str + * @returns + */ +export function deserializeString(encoded_str: Uint8Array): string { + return new TextDecoder().decode(encoded_str) +} \ No newline at end of file diff --git a/src/varint.ts b/src/varint.ts new file mode 100644 index 0000000..4ee527c --- /dev/null +++ b/src/varint.ts @@ -0,0 +1,26 @@ +import { toByteArray, concatUint8Arrays, nextUint8, fromByteArray } from "./utils.js" + +export default { + serialize, + deserialize +} + +function serialize(int: number): Uint8Array { + const buff = toByteArray(int) + + return concatUint8Arrays( + Uint8Array.from([buff.length]), + buff + ) +} + +function deserialize(iter: IterableIterator<[number, number]>): number { + const length = nextUint8(iter) + + let bytes = [] + for (let i = 0; i < length; i++) { + bytes.push(nextUint8(iter)) + } + + return fromByteArray(Uint8Array.from(bytes)) +} \ No newline at end of file diff --git a/tests/transaction_builder.test.ts b/tests/transaction_builder.test.ts index c12831f..c2657a2 100644 --- a/tests/transaction_builder.test.ts +++ b/tests/transaction_builder.test.ts @@ -9,6 +9,9 @@ import { uint8ArrayToHex, } from "../src/utils"; import { Curve } from "../src/types"; +import TE from "../src/typed_encoding"; + +const VERSION = 3 // all assert should be transformed to jest expect describe("Transaction builder", () => { @@ -207,7 +210,7 @@ describe("Transaction builder", () => { const expected_binary = concatUint8Arrays( //Version - intToUint8Array(2), + intToUint8Array(VERSION), tx.address, Uint8Array.from([253]), //Code size @@ -327,7 +330,7 @@ describe("Transaction builder", () => { const expected_binary = concatUint8Arrays( //Version - intToUint8Array(2), + intToUint8Array(VERSION), tx.address, Uint8Array.from([253]), //Code size @@ -395,13 +398,10 @@ describe("Transaction builder", () => { Uint8Array.from([14]), // action value new TextEncoder().encode("vote_for_mayor"), - // args - // args size bytes - Uint8Array.from([1]), // args size - Uint8Array.from([13]), + Uint8Array.from([1]), // args value - new TextEncoder().encode("[\"Ms. Smith\"]"), + TE.serialize("Ms. Smith") ); expect(payload).toEqual(expected_binary); @@ -424,7 +424,7 @@ describe("Transaction builder", () => { const expected_binary = concatUint8Arrays( //Version - intToUint8Array(2), + intToUint8Array(VERSION), tx.address, Uint8Array.from([253]), //Code size @@ -457,13 +457,10 @@ describe("Transaction builder", () => { Uint8Array.from([10]), // action value new TextEncoder().encode("set_geopos"), - // args - // args size bytes - Uint8Array.from([1]), // args size - Uint8Array.from([19]), + Uint8Array.from([1]), // args value - new TextEncoder().encode(`[{"lat":1,"lng":2}]`), + TE.serialize({ "lng": 2, "lat": 1 }) ); expect(payload).toEqual(expected_binary); @@ -585,7 +582,7 @@ describe("Transaction builder", () => { const payload = tx.originSignaturePayload(); const expected_binary = concatUint8Arrays( //Version - intToUint8Array(2), + intToUint8Array(VERSION), tx.address, Uint8Array.from([253]), //Code size @@ -750,7 +747,7 @@ describe("Transaction builder", () => { const txRPC = tx.toRPC(); // @ts-ignore - expect(txRPC.version).toStrictEqual(2); + expect(txRPC.version).toStrictEqual(VERSION); // @ts-ignore expect(txRPC.data.ledger.uco.transfers[0]).toStrictEqual( { diff --git a/tests/typed_encoding.test.ts b/tests/typed_encoding.test.ts new file mode 100644 index 0000000..c1d4bed --- /dev/null +++ b/tests/typed_encoding.test.ts @@ -0,0 +1,34 @@ +import TE from "../src/typed_encoding" + +describe("TE", () => { + it("should serialize/deserialize a null", () => { + expect(TE.deserialize(TE.serialize(null))).toBeNull() + }) + it("should serialize/deserialize a bool", () => { + expect(TE.deserialize(TE.serialize(true))).toBe(true) + expect(TE.deserialize(TE.serialize(false))).toBe(false) + }) + it("should serialize/deserialize an integer", () => { + expect(TE.deserialize(TE.serialize(0))).toBe(0) + expect(TE.deserialize(TE.serialize(1))).toBe(1) + expect(TE.deserialize(TE.serialize(2 ** 40))).toBe(2 ** 40) + }) + it("should serialize/deserialize a float", () => { + expect(TE.deserialize(TE.serialize(1.00000001))).toBe(1.00000001) + expect(TE.deserialize(TE.serialize(1.99999999))).toBe(1.99999999) + }) + it("should serialize/deserialize a str", () => { + expect(TE.deserialize(TE.serialize("hello"))).toBe("hello") + expect(TE.deserialize(TE.serialize("world"))).toBe("world") + expect(TE.deserialize(TE.serialize("un été à l'ombre"))).toBe("un été à l'ombre") + }) + it("should serialize/deserialize a list", () => { + expect(TE.deserialize(TE.serialize([]))).toStrictEqual([]) + expect(TE.deserialize(TE.serialize([1, 2, 3]))).toStrictEqual([1, 2, 3]) + expect(TE.deserialize(TE.serialize(["1", true, 14]))).toStrictEqual(["1", true, 14]) + }) + it("should serialize/deserialize an object", () => { + expect(TE.deserialize(TE.serialize({}))).toStrictEqual({}) + expect(TE.deserialize(TE.serialize({ a: 1, foo: "bar" }))).toStrictEqual({ a: 1, foo: "bar" }) + }) +}) diff --git a/tests/utils.test.ts b/tests/utils.test.ts index 63cc39b..8ea74cc 100644 --- a/tests/utils.test.ts +++ b/tests/utils.test.ts @@ -8,7 +8,8 @@ import { toByteArray, toBigInt, fromBigInt, - sortObjectKeysASC + sortObjectKeysASC, + uint8ArrayToInt } from "../src/utils.js" describe("Utils", () => { @@ -67,10 +68,32 @@ describe("Utils", () => { describe("toByteArray", () => { it("should encode an integer into a UnInt8Array", () => { expect(toByteArray(0)).toStrictEqual(new Uint8Array([0])) - expect(toByteArray(123)).toStrictEqual(new Uint8Array([123])) - expect(toByteArray(258)).toStrictEqual(new Uint8Array([1, 2])) - expect(toByteArray(65535)).toStrictEqual(new Uint8Array([255, 255])) - expect(toByteArray(65536)).toStrictEqual(new Uint8Array([1, 0, 0])) + expect(toByteArray(2 ** 8 - 1)).toStrictEqual(new Uint8Array([255])) + expect(toByteArray(2 ** 8)).toStrictEqual(new Uint8Array([1, 0])) + expect(toByteArray(2 ** 16 - 1)).toStrictEqual(new Uint8Array([255, 255])) + expect(toByteArray(2 ** 16)).toStrictEqual(new Uint8Array([1, 0, 0])) + expect(toByteArray(2 ** 24 - 1)).toStrictEqual(new Uint8Array([255, 255, 255])) + expect(toByteArray(2 ** 24)).toStrictEqual(new Uint8Array([1, 0, 0, 0])) + expect(toByteArray(2 ** 32 - 1)).toStrictEqual(new Uint8Array([255, 255, 255, 255])) + expect(toByteArray(2 ** 32)).toStrictEqual(new Uint8Array([1, 0, 0, 0, 0])) + expect(toByteArray(2 ** 40 - 1)).toStrictEqual(new Uint8Array([255, 255, 255, 255, 255])) + expect(toByteArray(2 ** 40)).toStrictEqual(new Uint8Array([1, 0, 0, 0, 0, 0])) + }) + }) + + describe("uint8ArrayToInt / fromByteArray", () => { + it("should decode an integer from a UnInt8Array", () => { + expect(uint8ArrayToInt(new Uint8Array([0]))).toStrictEqual(0) + expect(uint8ArrayToInt(new Uint8Array([255]))).toStrictEqual(2 ** 8 - 1) + expect(uint8ArrayToInt(new Uint8Array([1, 0]))).toStrictEqual(2 ** 8) + expect(uint8ArrayToInt(new Uint8Array([255, 255]))).toStrictEqual(2 ** 16 - 1) + expect(uint8ArrayToInt(new Uint8Array([1, 0, 0]))).toStrictEqual(2 ** 16) + expect(uint8ArrayToInt(new Uint8Array([255, 255, 255]))).toStrictEqual(2 ** 24 - 1) + expect(uint8ArrayToInt(new Uint8Array([1, 0, 0, 0]))).toStrictEqual(2 ** 24) + expect(uint8ArrayToInt(new Uint8Array([255, 255, 255, 255]))).toStrictEqual(2 ** 32 - 1) + expect(uint8ArrayToInt(new Uint8Array([1, 0, 0, 0, 0]))).toStrictEqual(2 ** 32) + expect(uint8ArrayToInt(new Uint8Array([255, 255, 255, 255, 255]))).toStrictEqual(2 ** 40 - 1) + expect(uint8ArrayToInt(new Uint8Array([1, 0, 0, 0, 0, 0]))).toStrictEqual(2 ** 40) }) }) diff --git a/tests/varint.test.ts b/tests/varint.test.ts new file mode 100644 index 0000000..ffd61e5 --- /dev/null +++ b/tests/varint.test.ts @@ -0,0 +1,17 @@ +import VarInt from "../src/varint" + +describe("VarInt", () => { + it("should serialize/deserialize", () => { + expect(VarInt.deserialize(VarInt.serialize(0).entries())).toBe(0) + expect(VarInt.deserialize(VarInt.serialize(2 ** 8 - 1).entries())).toBe(2 ** 8 - 1) + expect(VarInt.deserialize(VarInt.serialize(2 ** 8).entries())).toBe(2 ** 8) + expect(VarInt.deserialize(VarInt.serialize(2 ** 16 - 1).entries())).toBe(2 ** 16 - 1) + expect(VarInt.deserialize(VarInt.serialize(2 ** 16).entries())).toBe(2 ** 16) + expect(VarInt.deserialize(VarInt.serialize(2 ** 24 - 1).entries())).toBe(2 ** 24 - 1) + expect(VarInt.deserialize(VarInt.serialize(2 ** 24).entries())).toBe(2 ** 24) + expect(VarInt.deserialize(VarInt.serialize(2 ** 32 - 1).entries())).toBe(2 ** 32 - 1) + expect(VarInt.deserialize(VarInt.serialize(2 ** 32).entries())).toBe(2 ** 32) + expect(VarInt.deserialize(VarInt.serialize(2 ** 40 - 1).entries())).toBe(2 ** 40 - 1) + expect(VarInt.deserialize(VarInt.serialize(2 ** 40).entries())).toBe(2 ** 40) + }) +}) \ No newline at end of file