diff --git a/packages/snap-networks-utils/CHANGELOG.md b/packages/snap-networks-utils/CHANGELOG.md index e37c204a..9a3fc3e8 100644 --- a/packages/snap-networks-utils/CHANGELOG.md +++ b/packages/snap-networks-utils/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Add helpers `serialize`, `deserialize`, and `Serializable` for round-tripping `BigNumber`, `bigint`, `Uint8Array`, and `undefined` through snap state ([#197](https://github.com/MetaMask/internal-snaps/pull/197)) - Add `InFlightCoalescer`, exported from a new `./dedupe` entry point, which coalesces concurrent async operations by key so callers share one in-flight run ([#149](https://github.com/MetaMask/internal-snaps/pull/149)) - Add origin permission helpers ([#193](https://github.com/MetaMask/internal-snaps/pull/193)) - `createOriginPermissions` for building origin-to-method maps diff --git a/packages/snap-networks-utils/package.json b/packages/snap-networks-utils/package.json index ac2946e6..7d489d88 100644 --- a/packages/snap-networks-utils/package.json +++ b/packages/snap-networks-utils/package.json @@ -75,13 +75,16 @@ "@metamask/remote-feature-flag-controller": "^5.0.0", "@metamask/snaps-sdk": "^11.2.0", "@metamask/superstruct": "^3.4.1", - "@metamask/utils": "^11.11.0" + "@metamask/utils": "^11.11.0", + "bignumber.js": "^9.3.1", + "lodash": "^4.17.21" }, "devDependencies": { "@metamask/auto-changelog": "^6.1.1", "@metamask/messenger": "^2.0.0", "@ts-bridge/cli": "^0.6.4", "@types/jest": "^30.0.0", + "@types/lodash": "^4.17.15", "deepmerge": "^4.2.2", "jest": "30.0.3", "ts-jest": "^29.4.1", diff --git a/packages/snap-networks-utils/src/index.ts b/packages/snap-networks-utils/src/index.ts index ac22a2e7..23ea5b52 100644 --- a/packages/snap-networks-utils/src/index.ts +++ b/packages/snap-networks-utils/src/index.ts @@ -21,6 +21,8 @@ export type { LogMethodDecorator, LoggerDecorators, } from './logger'; +export { serialize, deserialize } from './serialization/serialization'; +export type { Serializable } from './serialization/types'; export { createOriginPermissions, DEFAULT_PROD_ORIGINS, diff --git a/packages/snap-networks-utils/src/serialization/serialization.test.ts b/packages/snap-networks-utils/src/serialization/serialization.test.ts new file mode 100644 index 00000000..1d645ea2 --- /dev/null +++ b/packages/snap-networks-utils/src/serialization/serialization.test.ts @@ -0,0 +1,197 @@ +import { BigNumber } from 'bignumber.js'; + +import { deserialize, serialize } from './serialization'; + +describe('serialize', () => { + it('serializes primitive values', () => { + expect(serialize('test')).toBe('test'); + expect(serialize(42)).toBe(42); + expect(serialize(true)).toBe(true); + expect(serialize(null)).toBeNull(); + expect(serialize(undefined)).toStrictEqual({ __type: 'undefined' }); + }); + + it('serializes special types', () => { + expect(serialize(BigInt(9007199254740991))).toStrictEqual({ + __type: 'bigint', + value: '9007199254740991', + }); + expect(serialize(new BigNumber('123456789.123456789'))).toStrictEqual({ + __type: 'BigNumber', + value: '123456789.123456789', + }); + }); + + it('serializes arrays with mixed types', () => { + const input = [undefined, new BigNumber('123'), BigInt('456')]; + const result = serialize(input); + expect(result).toStrictEqual([ + { __type: 'undefined' }, + { __type: 'BigNumber', value: '123' }, + { __type: 'bigint', value: '456' }, + ]); + }); + + it('serializes objects with nested structures', () => { + const input = { + nested: { + bigNumber: new BigNumber('123.456'), + bigint: BigInt('9007199254740991'), + undefined, + }, + array: [new BigNumber('789.012'), BigInt('9007199254740992')], + }; + + const result = serialize(input); + + expect(result).toStrictEqual({ + nested: { + bigNumber: { __type: 'BigNumber', value: '123.456' }, + bigint: { __type: 'bigint', value: '9007199254740991' }, + undefined: { __type: 'undefined' }, + }, + array: [ + { __type: 'BigNumber', value: '789.012' }, + { __type: 'bigint', value: '9007199254740992' }, + ], + }); + }); + + it('serializes empty objects and arrays', () => { + const input = { + emptyObject: {}, + emptyArray: [], + }; + + const result = serialize(input); + + expect(result).toStrictEqual(input); + }); + + it('serializes deeply nested structures', () => { + const input = { + level1: { + level2: { + level3: { + bigint: BigInt('123'), + undefined, + }, + }, + }, + }; + + const result = serialize(input); + + expect(result).toStrictEqual({ + level1: { + level2: { + level3: { + bigint: { __type: 'bigint', value: '123' }, + undefined: { __type: 'undefined' }, + }, + }, + }, + }); + }); + + it('serializes Uint8Array', () => { + const input = new Uint8Array([1, 2, 3]); + const result = serialize(input); + expect(result).toStrictEqual({ + __type: 'Uint8Array', + value: 'AQID', + }); + }); +}); + +/* eslint-disable jest/prefer-strict-equal */ +describe('deserialize', () => { + it('deserializes primitive values', () => { + expect(deserialize('test')).toBe('test'); + expect(deserialize(42)).toBe(42); + expect(deserialize(true)).toBe(true); + expect(deserialize(null)).toBeNull(); + }); + + it('deserializes special serialized types', () => { + expect(deserialize({ __type: 'undefined' })).toBeUndefined(); + expect( + deserialize({ __type: 'bigint', value: '9007199254740991' }), + ).toStrictEqual(BigInt(9007199254740991)); + expect( + deserialize({ __type: 'BigNumber', value: '123456789.123456789' }), + ).toStrictEqual(new BigNumber('123456789.123456789')); + }); + + it('deserializes arrays with mixed types', () => { + expect( + deserialize([ + 1, + 'hello', + true, + null, + { __type: 'undefined' }, + { __type: 'bigint', value: '9007199254740991' }, + { __type: 'BigNumber', value: '123456789.123456789' }, + ]), + ).toEqual([ + 1, + 'hello', + true, + null, + undefined, + BigInt(9007199254740991), + new BigNumber('123456789.123456789'), + ]); + }); + + it('deserializes objects with nested structures', () => { + const input = { + nested: { + bigNumber: { __type: 'BigNumber', value: '123.456' }, + bigint: { __type: 'bigint', value: '9007199254740991' }, + undefined: { __type: 'undefined' }, + }, + array: [ + { __type: 'BigNumber', value: '789.012' }, + { __type: 'bigint', value: '9007199254740992' }, + ], + }; + + const result = deserialize(input); + + expect(result).toEqual({ + nested: { + bigNumber: new BigNumber('123.456'), + bigint: BigInt('9007199254740991'), + undefined, + }, + array: [new BigNumber('789.012'), BigInt('9007199254740992')], + }); + }); + + it('handles non-undefined falsy values correctly', () => { + const input = { + zero: 0, + emptyString: '', + falseValue: false, + nullValue: null, + }; + + const result = deserialize(input); + + expect(result).toStrictEqual({ + zero: 0, + emptyString: '', + falseValue: false, + nullValue: null, + }); + }); + + it('deserializes Uint8Array', () => { + const input = { __type: 'Uint8Array', value: 'AQID' }; + const result = deserialize(input); + expect(result).toStrictEqual(new Uint8Array([1, 2, 3])); + }); +}); +/* eslint-enable jest/prefer-strict-equal */ diff --git a/packages/snap-networks-utils/src/serialization/serialization.ts b/packages/snap-networks-utils/src/serialization/serialization.ts new file mode 100644 index 00000000..6b5aaf7f --- /dev/null +++ b/packages/snap-networks-utils/src/serialization/serialization.ts @@ -0,0 +1,85 @@ +import type { Json } from '@metamask/snaps-sdk'; +import { BigNumber } from 'bignumber.js'; +import { cloneDeepWith } from 'lodash'; + +import type { Serializable } from './types'; + +/** + * Serializes the passed value to a JSON object so it can be stored in JSON-serializable storage like the snap state and interface context. + * It transforms non-JSON-serializable values into a specific JSON-serializable representation that can be deserialized later. + * + * @param value - The value to serialize. + * @returns The serialized value. + */ +export const serialize = (value: Serializable): Json => + cloneDeepWith(value, (val: unknown) => { + if (val === undefined) { + return { + __type: 'undefined', + }; + } + + if (val instanceof BigNumber) { + return { + __type: 'BigNumber', + value: val.toString(), + }; + } + + if (typeof val === 'bigint') { + return { + __type: 'bigint', + value: val.toString(), + }; + } + + if (val instanceof Uint8Array) { + const binaryString = Array.from(val, (byte) => + String.fromCharCode(byte), + ).join(''); + return { + __type: 'Uint8Array', + value: btoa(binaryString), + }; + } + + // Return undefined to let lodash handle the cloning of other values + return undefined; + }); + +/** + * Deserializes the passed value from a JSON object back to its original values. + * It transforms the JSON-serializable representation of non-JSON-serializable values back into their original values. + * + * @param serializedValue - The value to deserialize. + * @returns The deserialized value. + */ +export const deserialize = (serializedValue: Json): Serializable => + JSON.parse(JSON.stringify(serializedValue), (_key, value) => { + if (!value) { + return value; + } + + if (value.__type === 'undefined') { + return undefined; + } + + if (value.__type === 'BigNumber') { + return new BigNumber(value.value); + } + + if (value.__type === 'bigint') { + return BigInt(value.value); + } + + if (value.__type === 'Uint8Array') { + const binaryString = atob(value.value); + const bytes = new Uint8Array(binaryString.length); + for (let index = 0; index < binaryString.length; index++) { + bytes[index] = binaryString.charCodeAt(index); + } + return bytes; + } + + return value; + }); diff --git a/packages/snap-networks-utils/src/serialization/types.ts b/packages/snap-networks-utils/src/serialization/types.ts new file mode 100644 index 00000000..ba1a1454 --- /dev/null +++ b/packages/snap-networks-utils/src/serialization/types.ts @@ -0,0 +1,17 @@ +import type { Json } from '@metamask/snaps-sdk'; +import type { BigNumber } from 'bignumber.js'; + +/** + * A primitive value that can be serialized to JSON using the `serialize` function. + */ +export type Serializable = + | Json + | undefined + | null + | bigint + | BigNumber + | Uint8Array + | Serializable[] + | { + [prop: string]: Serializable; + }; diff --git a/yarn.lock b/yarn.lock index dd3c61ce..b6e1138b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3314,8 +3314,11 @@ __metadata: "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" "@types/jest": "npm:^30.0.0" + "@types/lodash": "npm:^4.17.15" + bignumber.js: "npm:^9.3.1" deepmerge: "npm:^4.2.2" jest: "npm:30.0.3" + lodash: "npm:^4.17.21" ts-jest: "npm:^29.4.1" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13"